Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Headful な Selenium を Lambda で動かしたい

こんにちは。Gunosy R&D チームの森田です。

こちらの記事は Gunosy Advent Calendar 2024 の 13 日目の記事です。昨日の記事は koizumi さんの Aurora I/O-Optimized で RDS のコストを削減した話 でした。

Headful (headless でない) Selenium (Chrome) を AWS Lambda で動かそうと思ったら、思ったよりも大変な話だったのでまとめておきたいと思います。LLM を使ったアプリケーションを構築していると、ユーザの指定するページのテキストやスクリーンショットを取得して、要約や質問応答など指示された処理の結果を返すような場合等、Selenium を動かしたくなる状況が多々あると思います。現在の Web はユーザーからは一見分かりにくいもののかなりのページが動的に作られるため、RAG のように事前にどんな Web ページを見に行くか分からない状況では、 Selenium のようなブラウザ自動操作が必要となってきます。

Selenium

その時に Headless Chrome を使う選択肢もあるのですが、Headless モードで動かした時に表示に差が生じてしまうのを避け、人が開いた時に見える Web ページをなるべく再現度高く表示するためには、人が操作するのと同様にブラウザを動かす Headful なモードの方が意図せぬ差を減らすことができます。

LLM アプリケーションは事前にどの程度の頻度で使われるか想定が難しいことが多く、作ってみたもののあまり使われないというパターンもありがちです。いつどのくらいリクエストが来るかわからない状況では、固定のコストを避けアクセスが集中しても耐えられる点で AWS Lambda で手軽に動かせるようにしたいです。

というわけで、Headful な Selenium を Lambda で動かす方法についてまとめていこうと思います。

結論

さて、結論からいいますが docker-selenium-lambda を使いましょう

github.com

  • リポジトリ中のサンプルのスクリプト main.py では Chrome のオプションとして --headless=new を指定していますが、この docker イメージのままで --headless オプションを与えなくとも動作するようになっています

  • これをベースに、後述する日本語フォントをインストールしておくとよさそうです

環境構築

ここまでで伝えるべきことの 90% くらいは説明してしまったのですが、完成品だけをみてもどうしてそうなっているのかが分からないと応用がしにくいこともあります。この先はもう少し細かい環境構築の説明をしていきます。

Chrome のインストール

Selenium を安定して動作させるためには、Chrome と Selenium の WebDriver のバージョンを合わせ、かつ Selenium の動作が不安定なバージョンを避ける必要があります。Selenium には Selenium Manager という適切な Chrome とドライバを選んでダウンロードする機能が備わっていますが、実行時にブラウザ・ドライバが見つからなかった時にはじめて実行されるため、通常永続ストレージを持たない Lambda とはあまり相性がよくありません。 やはり、docker-selenium-lambda のようにイメージにブラウザ・ドライバを含めてしまう方が扱いやすいでしょう。ここで選択されているバージョンはテスト済みのものが選択されているため、第一選択肢としてそのまま使わせていただくのが安全です。継続的にバージョンを更新しつづける場合や、最新版以外も利用したい場合には Chrome for Testing availability から利用可能なバージョン、ダウンロード URL を取得するのがよいでしょう。

Headful にするために

  • AWS Lambda にはディスプレイがありませんので、そのままでは --headless オプション無しでは動作しません。そこで、Xvfb という仮想ディスプレイサーバを利用します。Xvfb は X (Linux 等で使われる GUI を動かすための基盤) の仮想版で、マウスやキーボード、モニタなしで動作し、ソフトウェア側はディスプレイに表示されているのと同様に動作することができるようになります。
  • xvfb 自体のインストール
    • xorg-x11-server-Xvfb と X に関連するパッケージ
  • pyvirtualdisplay python パッケージ
    • pip install pyvirtualdisplay
    • Lambda 環境で動作させるには起動時に '-nolisten inet6 -nolisten unix' オプションが必要
      • 後でお見せするサンプルスクリプトで使われている-maxbigreqsize オプションは長大なページを表示しようとしてメモリが不足する場合に

日本語フォント

標準のままでは日本語が豆腐(􏿮)になるため、レイアウトも崩れ、スクリーンショットを撮影する場合には不都合です。 Amazon Linux 2 であれば 下記パッケージを yum でインストール

yum install -y ipa-gothic-fonts ipa-mincho-fonts ipa-pgothic-fonts ipa-pmincho-fonts

Amazon Linux 2023 の場合は IPAフォントがリポジトリにないため、noto フォントが最も手軽にインストールできます

dnf install -y  google-noto-emoji-color-fonts google-noto-emoji-fonts google-noto-fonts-common google-noto-sans-fonts google-noto-sans-jp-fonts google-noto-sans-gothic-fonts google-noto-serif-fonts google-noto-serif-jp-fonts

サンプルスクリプト

さて、docker-selenium-lambdamain.py をベースに Headful で動くコードを見てみましょう。 差分は pyvirtualdisplay の呼び出し部分と--headless=new のオプションが無い所です。

from selenium import webdriver
from tempfile import mkdtemp
from selenium.webdriver.common.by import By
from pyvirtualdisplay import Display


def handler(event=None, context=None):
    options = webdriver.ChromeOptions()
    service = webdriver.ChromeService("/opt/chromedriver")

    options.binary_location = '/opt/chrome/chrome'
    options.add_argument('--no-sandbox')
    options.add_argument("--disable-gpu")
    options.add_argument("--window-size=1280x1696")
    options.add_argument("--single-process")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--disable-dev-tools")
    options.add_argument("--no-zygote")
    options.add_argument(f"--user-data-dir={mkdtemp()}")
    options.add_argument(f"--data-path={mkdtemp()}")
    options.add_argument(f"--disk-cache-dir={mkdtemp()}")
    options.add_argument("--remote-debugging-port=9222")

    with Display(
            backend="xvfb",
            visible=False,
            size=(1280, 1696),
            extra_args=[
                "-nolisten",
                "inet6",
                "-nolisten",
                "unix",
                "-maxbigreqsize",
                "127",
            ],
    ):
        chrome = webdriver.Chrome(options=options, service=service)
        chrome.get("https://example.com/")

    return chrome.find_element(by=By.XPATH, value="//html").text

Chrome のオプション

Chrome の起動オプションには、どうやら公開されている公式のドキュメントが無いようです。おそらく最も詳細な Chrome 起動オプションの一覧である List of Chromium Command Line Switches « Peter Beverloo には脅威の 1578 件 (2024/12/9 現在) ものオプションが記載されています。

こうなると、ドキュメントを読んで必要なオプションを選ぶ正攻法は難しいため、過去の魔術書から使えそうな呪文をコピペして試すことになります。この記事もその魔術書の一つとして役に立つことを願います。

Chrome のオプションは Headful でも大きくは変わらず --headless オプションを除くだけですが、ざっくりとした解説を残しておきます。

AWS Lambda で動かすために必要なオプション

AWS Lambda で動かすには、どうしても外せないオプションと状況次第では外せるオプションがあります。

  • 必須となるオプションは以下のものです。このオプションを付けないと起動自体難しいでしょう。

    • --no-sandbox
    • --disable-gpu
    • --single-process
    • --disable-dev-shm-usage
  • 推奨されるオプションは以下のものです。外すことも可能ですが、付けておいたほうが安定します。

    • --no-zygote
      • zygote は Chrome のプロセスのコピーを担当しているが、Lambda 環境では役目がなく、かえって zygote のプロセスが増える分適切に終了しなかった時にゾンビプロセスが増える要因となる
      • オプションを与えなくても動かないわけではないが、無効化することでゾンビプロセスが発生するのを防げる
    • --user-data-dir={mkdtemp()}
    • --data-path={mkdtemp()}
    • --disk-cache-dir={mkdtemp()}
      • 変なディレクトリに書き込もうとしてエラー終了することを防ぐ
    • --disable-dev-tools
    • --remote-debugging-port=9222
      • ローカル実行する際などは外したほうが良い場合もある
    • --window-size=1280x1696
      • 値は用途、環境により自由に変更する(その場合、Xvfb の起動時のオプションも変更する)
  • 余談: --single-process を外したい (が AWS Lambda ではどうやら難しい)

    • Chrome は新規プロセスに対して権限を制限しようとするが、Lambda ではその権限が与えられないためエラーが起こる
    • おそらく Lambda も Chrome も seccomp で権限を制限しようとしてそれが衝突している
      • seccomp: Linux のプロセスが利用できるシステムコールを制限する仕組み
    • そうすると --no-sandbox--disable-setuid-sandbox , --disable-seccomp-filter-sandbox などのセキュリティ関連のオプションで無効化できそうに思えるが、残念ながらそうはならないらしい
    • このあたりの議論が出典
    • まれに--single-process が特定のページを表示できない要因となる場合がある(ページを開こうとすると処理が重いのか固まってしまう)ため、そのようなページをどうしても扱う必要がある場合は Lambda の利用は諦め、EC2 などで動かす必要があります。

まとめ

ここまで、Headful な Selenium を Lambda で動かすための環境構築を解説してきました。LLM アプリケーションの構築で、ハマり続けたので、これから Selenium を動かそうとする方の助けになれば幸いです。

明日は k.oshiro さんの QuickSight に入門してみた です。お楽しみに!