🐶

コンテナイメージを使用してpuppeteerをAWS Lambdaで動かす

2024/10/18に公開

こんにちは。株式会社スペースマーケットでフロントエンドエンジニアをしておりますwado63です。
puppeteerをコンテナイメージを使ったlambdaで動かそうとした際にかなり苦戦したので、その際のtipsを共有できればと思います。

AWS, Lambda, Puppeteer, Dockerをそれぞれ使ったことあるような方向けの記事となっておりますのでご了承ください。

ざっくりまとめ

  • Macのarm64環境でdocker buildしてpuppeteerは動かせますがversion指定が行いづらい
  • chromeはimageを小さくするためにchrome-headless-shellを使う
  • Rosettaを使ってx86_64環境でdocker buildするのがいいが、aws-lambda-ricのinstallに時間がかかる
  • lambdaでpuppeteerを動かす際、ブラウザは同じものを使い回す必要あり
  • lambdaよりECSを選択すれば良かったと後悔しています

動くものはこちらです
https://github.com/wado63/puppeteer-lambda-container


ローカル環境からpuppeteerを使ってスクレイピングするのは簡単ですが、定期実行やCI/CDを考慮するとGitHub ActionsやCodePipelineなどを使いたいですよね。
そのためにコンテナイメージ使ってLambdaを動かしたくなると思います。
そしてコンテナイメージ作るからには既存の全部入りではなく、1から軽そうなイメージを作りたくなりますよね。

同じようなことを考える方がいらっしゃると思いますので、この記事がちょっとだけでもお役に立てればと思います。

まず初めに今回書いたlambdaの関数とdockerfileを紹介します。
MacでRosettaを使用してbuildを行う前提です。

/**
 * @file lambdaでpuppeteerを動かすだけのサンプル
 */
import { launch } from 'puppeteer'
import { Handler } from 'aws-lambda'
import { globSync } from 'glob'

// @puppeteer/browsersを使ってinstallしたchrome-headless-shellを取得
const executablePath = globSync(
  './chrome-headless-shell/**/chrome-headless-shell'
)[0]

// lambdaはprocessが残り続けるので、browserは共通のものを使いまわす
const browser = await launch({
  headless: 'shell',
  args: [
    '--no-sandbox',
    '--single-process',
    '--disable-gpu',
    '---disable-dev-shm-usage',
  ],
  ...(executablePath && {
    executablePath,
  }),
})

export const handler: Handler = async () => {
  const page = await browser.pages().then((pages) => pages[0])

  await page.goto('https://example.com', { waitUntil: 'load' })

  const h1Text = await page.evaluate(
    () => document.querySelector('h1')?.textContent
  )
  console.log('h1: ', h1Text)
}
ARG FUNCTION_DIR="/function"

FROM node:20.17.0-bookworm-slim AS build-image

# aws-lambda-ricのinstallや実行に必要なパッケージをインストール
# https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/tree/main
RUN apt-get update &&\
    apt-get upgrade -y &&\
    apt-get install -y \
    python3 \
    g++ \
    make \
    cmake \
    unzip \
    libcurl4-openssl-dev \
    autoconf \
    libtool \
    build-essential

ARG FUNCTION_DIR
ENV PUPPETEER_SKIP_DOWNLOAD="true"

WORKDIR ${FUNCTION_DIR}

COPY package.json package-lock.json bundle.mjs ./
COPY src ./src

RUN npm install
RUN npm run build


FROM node:20.17.0-bookworm-slim

# chromeで必要になるパッケージをインストール
# https://source.chromium.org/chromium/chromium/src/+/main:chrome/installer/linux/debian/dist_package_versions.json
RUN apt-get update &&\
    apt-get upgrade -y &&\
    apt-get install -y \
    libasound2 \
    libatk-bridge2.0-0 \
    libatk1.0-0 \
    libatspi2.0-0 \
    libc6 \
    libcairo2 \
    libcups2 \
    libdbus-1-3 \
    libdrm2 \
    libexpat1 \
    libgbm1 \
    libglib2.0-0 \
    libnspr4 \
    libnss3 \
    libpango-1.0-0 \
    libpangocairo-1.0-0 \
    libstdc++6 \
    libudev1 \
    libuuid1 \
    libx11-6 \
    libx11-xcb1 \
    libxcb-dri3-0 \
    libxcb1 \
    libxcomposite1 \
    libxcursor1 \
    libxdamage1 \
    libxext6 \
    libxfixes3 \
    libxi6 \
    libxkbcommon0 \
    libxrandr2 \
    libxrender1 \
    libxshmfence1 \
    libxss1 \
    libxtst6 &&\
    apt-get clean && rm -rf /var/lib/apt/lists/*

ARG FUNCTION_DIR

WORKDIR ${FUNCTION_DIR}

COPY --from=build-image ${FUNCTION_DIR} ${FUNCTION_DIR}

RUN npx @puppeteer/browsers install chrome-headless-shell@129

ENTRYPOINT ["/usr/local/bin/npx", "aws-lambda-ric"]
CMD ["dist/lambdaExample.handler" ]

arm64 OR x86_64

CI/CDではホストマシンを自由に選べますが、ローカルでもbuildしたいと考えるとarm64でdocker buildしたいところです。
しかしここで問題があります。linux/arm64で動かせるchromeがありません。
chromiumであればarm64で動かせますが、その場合version管理が少し面倒になります。

FROM node:20.17.0-bookworm-slim

# chromeで必要になるパッケージをインストール
# https://source.chromium.org/chromium/chromium/src/+/main:chrome/installer/linux/debian/dist_package_versions.json
RUN apt-get update &&\
    apt-get upgrade -y &&\
    apt-get install -y \
    libasound2 \
    #...省略...
    libxtst6 &&\
    chromium &&\ # ここでchromiumをinstallする
    apt-get clean && rm -rf /var/lib/apt/lists/*

このような形で依存パッケージをインストールした上でchromiumをインストールすることは可能なのですが、puppeteerとchromiumはversionを合わせる必要があるため少し壊れやすい状態になります。
debianのパッケージを保存しておけばいいのですが、ちょっと面倒ですよね。

そこでRosettaを使ってx86_64環境でbuildすることをオススメします。

$ arch -x86_64 /bin/zsh
$ docker build --platform=linux/x86_64 -t puppeteer-lambda-container:test .

chrome-headless-shell

コンテナイメージを小さくするためにchrome-headless-shellを使用するのをオススメします。
puppeteerはデフォルトでchrome-for-testingより対応したchromeを~/.cache/puppeteerにダウンロードしますが、これを使うとimageが大きくなりますのでheadless用のchromeを使うようにします。

ENV PUPPETEER_SKIP_DOWNLOAD="true"
RUN npx @puppeteer/browsers install chrome-headless-shell@129

PUPPETEER_SKIP_DOWNLOADをtrueにしておくことでpuppeteerはchromeのダウンロードを行ないません。
@puppeteer/browsersを使用してinstallをするとカレントディレクトリにchrome-headless-shellのディレクトリができるので、puppeteerにはその
chrome-headless-shell/mac_arm-129.0.6668.100/chrome-headless-shell-mac-arm64/chrome-headless-shellなどをexecutablePathで指定して実行します。


import { globSync } from 'glob'

// 今回はscriptで実行ファイルのパスを取得しています
const executablePath = globSync(
  './chrome-headless-shell/**/chrome-headless-shell'
)[0]

const browser = await launch({
  headless: 'shell',
  args: [
    '--no-sandbox',
    '--single-process',
    '--disable-gpu',
    '---disable-dev-shm-usage',
  ],
  ...(executablePath && {
    executablePath,
  }),
})

環境変数のPUPPETEER_EXECUTABLE_PATHでも実行ファイルを指定できますが、versionによってパスが変わるので動的に取得できるようにしておいた方がいいかと思います。

x86_64でのaws-lambda-ricのnpm installに時間がかかる

自前のコンテナイメージを使用してlambdaを動かす際にはランタイムインターフェイスクライアントaws-lambda-ricをインストールする必要があります。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-image.html#nodejs-image-clients

Rosettaを使わないarm64であればnpm installが一瞬で終わるのですが、Rosettaを使ったdocker build内でaws-lambda-ricをinstall行う際はpreinstallの処理でかなり待たされます。(5分以上)
待っていればインストールは終わるのですが、初見だと処理が止まっているように見えるので注意が必要です。
ここに関してはなぜinstallに時間がかかっていないのでわかるいらっしゃればコメントいただけると幸いです。

lambdaでpuppeteerを動かす際、ブラウザは同じものを使い回す

puppeteerを使ったことある人は、お作法的に起動したブラウザをちゃんと閉じるということを守るかと思います。

const browser = await launch()
//   省略
await browser.close()

ただし、lambdaを動かす上ではこれが罠となります。

lambdaは呼び出されるたびにhandler関数が動作するため、handler内でbrowserを起動、閉じるということを処理に時間がかかります。
また時間をかけて動作させたとしても閉じた後にbrowserのゾンビプロセスが残ります。
(下記は開発してたときのスクショです)

開発中にゾンビプロセスが大量発生した!

puppeteerのオプションで--no-zygoteを指定するとゾンビプロセスが発生しないようになりますが、chromeのclash reportのプロセスが残り続けるというところは解決できませんのでhandlerの外でbrowserを起動して使い回すのが良いです。

const browser = await launch()

export const handler: Handler = async () => {
  const page = await browser.pages().then((pages) => pages[0])
  // handlerの最後でもbrowserは閉じない
}

lambdaよりもECS

lambdaでpuppeteerを動かせればスケールアウトを容易に行えますが、メモリを積まないと動かないですし、puppeteerからリクエストする先の負荷を考慮すると思うので、大量に処理を捌くという贅沢な使い方される方は少ないのではないでしょうか。
また開発時、実行のたびにメモリ使用量がわずかに増えていってしまうという問題にも遭遇しました。
ECSであれば定期的に再起動を行うことで対処できますが、lambdaの場合は再起動ができないため処理が詰まってしまうという恐れがあります。

ほかにも書き込みが/tmpしかできなかったり、実行ユーザーがsbx_userになったりと制約があるので詰まりやすいです。

ECSであればlambdaよりも柔軟にコンテナを動かすことができますので、ECSという選択肢が取れるのであればそちらを選択することを個人的にはオススメします。

その他

lambdaでpuppeteerを動かす際の最低限のオプションはこちら

const browser = await launch({
  headless: 'shell',
  args: [
    '--no-sandbox',
    '--single-process',
    '--disable-gpu',
    '---disable-dev-shm-usage',
  ],
})

メモリは多めに指定する
動かしながら調整していけばいいですが、少なくとも512MB以上は指定しておいた方がいいです。
今回用意したサンプルは必要最低限の処理ですがそれでも300MB以上使用しています。

テスト実施時のメモリ使用量


chromeが起動しないときはdumpioのオプションをつける
lambdaでpuppeteerを動かす際、chromeが起動しないということがあります。
その際はdumpioのオプションをtrueにしてchromeのログを出力すると原因がわかるかもしれません。

const browser = await launch({
  dumpio: true,
})

以上、コンテナイメージを使用してpuppeteerをAWS Lambdaで動かす際のtipsでした。

宣伝

スペースマーケットでは現在エンジニアを募集しております!
ちょっと話を聞いてみたいといったようなカジュアルな面談でも構いませんので、ご興味のある方は是非ご応募お待ちしております!

スペースマーケット Engineer Blog

Discussion