開拓馬の厩

いろいろやる

Puppeteer を用いたアプリケーションから Chrome を切り離す

Puppeteer を用いた web スクレイピングアプリケーションの起動中に CPU/memory 使用量が荒ぶるので何とかしたいなあと思って最近対策を考えています

Puppeteer は内部で Chrome (Chromium) を起動しているので、スクレイピングのロジック部分と Chrome を分離すればリソース割り当てがやりやすくなりそうなので挑戦してみます

書いたコードはこちら↓*1

github.com

実験台

ここに Puppeteer を使った適当なアプリケーションがあります(Puppeteer 公式サイトのサンプルをコピペしてちょっと手を加えたもの)

# index.ts
import * as puppeteer from 'puppeteer';
import { setTimeout } from 'timers/promises';

const getChromeDevPage = async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  await page.goto('https://developer.chrome.com/');

  // Set screen size
  await page.setViewport({width: 1080, height: 1024});

  // Type into search box
  await page.type('.search-box__input', 'automate beyond recorder');

  // Wait and click on first result
  const searchResultSelector = '.search-box__link';
  await page.waitForSelector(searchResultSelector);
  await page.click(searchResultSelector);

  // Locate the full title with a unique string
  const textSelector = await page.waitForSelector(
    'text/Customize and automate'
  );
  const fullTitle = await textSelector!.evaluate(el => el.textContent);

  // Print the full title
  console.log('The title of this blog post is "%s".', fullTitle);

  await browser.close();
}


(async () => {
  while (true) {
      try {
          await getChromeDevPage();
      } catch (e) {
          console.error('failue', { e });
          break;
      }
      await setTimeout(10 * 1000);
  }
})();

「developer.chrome.com にアクセスし、 "Customize and automate ~" というブログ記事のタイトルを抽出して標準出力に吐き出す」という動作を 10秒ごとに行います

今回はこのアプリケーションに手を加えて

をやっていきます

Puppeteer-core

puppeteer-coreChromium が同梱されていない計量版の Puppeteer です

www.npmjs.com

puppeteer-core の切り替えは細かいところを省くと

1. package.json を書き換える

   "dependencies": {
-     "puppeteer": "19.5.2"
+     "puppeteer-core": "19.5.2"
   },

2. import を書き換える

- import * as puppeteer from 'puppeteer';
+ import * as puppeteer from 'puppeteer-core';

3. puppeteer.launchpuppeteer.connect に変更

- const browser = await puppeteer.launch(someOptions);
+ const browserURL = ... # リモートブラウザの URL
+ const browser = await puppeteer.connect({ browserURL, ...someOptions });

の三か所を変えればとりあえず完了です

先のコードを書き換えるとこんな感じ

# index.ts
import * as puppeteer from 'puppeteer-core';
import { setTimeout } from 'timers/promises';

const getChromeDevPage = async (browserURL: string) => {
    const browser = await puppeteer.connect({ browserURL });
    const page = await browser.newPage();

    <<>>

    await browser.close();
};

(async () => {
    # 環境変数からリモートブラウザの URL を受け取る
    const browserURL = process.env.BROWSER_ADDR;
    if (browserURL === undefined) {
        console.error('Environment variable `BROWSER_ADDR` is not set.');
        return;
    }
    while (true) {
        try {
            await getChromeDevPage(browserURL);
        } catch (e) {
            console.error('failue', { e });
            break;
        }
        await setTimeout(10 * 1000);
    }
})();

Docker image を焼いて動かす

puppeteer-core で書き換えたコードを実際に動かしてみましょう

リモートブラウザは browserless/chrome の Docker image を使います*2

適当に Dockerfile を用意し、こんな感じに docker-compose.yaml を書きます。Dockerfile の内容は GitHub のコードを見てください*3

version: '3.7'
services:
  chrome:
    image: browserless/chrome
    ports:
      - 3000:3000

  app:
    build: .
    environment:
      - BROWSER_ADDR=http://chrome:3000

browserless/chrome の起動にすこし時間がかかるため、先に chrome を起動させてから少し待って app を動かします

$ docker compose up -d chrome
$ docker compose up app

[+] Building 70.1s (13/13) FINISHED
[+] Running 1/1

Attaching to puppeteer-separate-app-1
puppeteer-separate-app-1  | The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
puppeteer-separate-app-1  | The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
puppeteer-separate-app-1  | The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
...

10 秒ごとに「The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".」が出力されます

kubernetes で動かす

実運用を想定して kubernetes で動かしてみます

まず docker image をビルドしておきます。今回は適当に scraping:1.0.0 という名前を付けて

docker build -t scraping:1.0.0 .

kubernetes の cluster は kind を使って作ります

kind.sigs.k8s.io

kind で cluster を作成します

$ kind create cluster
$ kind load docker-image scraping:1.0.0 # 手元でビルドした image を kind の cluster で使えるようにする

kubernetes の manifest を書きます

# chrome.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: chrome-ns
---
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: chrome-ns
  name: chrome
  labels:
    app: chrome
spec:
  selector:
    matchLabels:
      app: chrome
  replicas: 1
  template:
    metadata:
      labels:
        app: chrome
    spec:
      containers:
        - name: chrome
          image: browserless/chrome
          ports:
            - name: chrome
              containerPort: 3000
              protocol: TCP
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  namespace: chrome-ns
  name: chrome-svc
spec:
  ports:
    - name: chrome
      targetPort: chrome
      port: 3000
  selector:
    app: chrome
# scraping.yaml
apiVersion: v1
kind: Namespace
metadata:
  name:  scraping
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: scraping
  namespace: scraping
  labels:
    app: scraping
spec:
  selector:
    matchLabels:
      app: scraping
  replicas: 2
  template:
    metadata:
      labels:
        app: scraping
    spec:
      containers:
        - name: scraping
          image: scraping:1.0.0
          env:
            - name: BROWSER_ADDR
              value: "http://chrome-svc.chrome-ns.svc.cluster.local:3000"
      restartPolicy: Always

cluster にデプロイして scraping のログを見ると「The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".」が出力されています

$ kubectl apply -f chrome.yaml
$ kubectl apply -f scraping.yaml
  # スクレイピングが何回か実行されるまで数十秒待つ
$ kubectl logs deploy/scraping -c scraping -n scraping
Found 2 pods, using pod/scraping-74b5d47b66-psqbj
The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
...

kubernetes 上で動かせていることが確認できました

上記の例では Chrome の pod 1台に対してスクレイピングアプリの pod を2台動かしているので、ブラウザ同梱の Puppeteer を 2台動かすのと比較して Chrome 1台分のリソースを削減できていそうですね(雑な単純計算ですが……)

まとめ

本稿では puppeteer を使ったアプリケーションから Chromium を分離してみました

Chrome を分離することで以下のような利点がありそうです

  • Chrome を分離することで kubernetes の node や CPU/memory の配置に改善できる
    • Node.js の container と Chrome の conatainer のリソースを別々に設定できる
    • スクレイピングアプリの開発者もロジック部分のみを注視して最適化の試行錯誤を行える
  • docker build が楽になる
    • puppeteer 内臓の Chromium を使う場合、ビルド時にちょっとした小細工*4が必要

まだ構想段階なので、勤務先*5のアプリケーションで実際に運用してみて使用感を確かめたいです*6

謝辞:Chrome 分離構想を提案してくれた id:hiroqn さん id:taketo957 さんに感謝

*1:本文では実行環境の説明などを省略しており、それらの説明はリポジトリの README に書くようにしています

*2:browserless/chrome はコンテナサイズがわりとデカめなのでもっと軽量なやつを探したいところ

*3:趣味で nixos/nix ベースにしたので説明がめんどくさい

*4:この記事本来は 'puppeteer' VS 'puppeteer-core' の比較をやろうとしていたが、この小細工が面倒だったので puppeteer が入った image を作らずに済む構成にした

*5:株式会社 HERP ではエンジニアを募集しています herp.careers

*6:この記事の裏の狙いは弊社でスクレイピングをやっているメンバーへの情報共有