プログラマーのメモ書き

伊勢在住のプログラマーが気になることを気ままにメモったブログです

SAM Lambda で Selenium を動かす

最近、 aws づいてます。ここしばらく、下記などをやってきました。

察しのいい方はもうお分かりかと思いますが、最終的にやりたいのは、 Lambda で Selenium を動かしてスクレイピングするのを定期的に実行するアプリケーションを SAM で管理したいということです。

で、ここまで試したので、あとは簡単に行けるだろうと思ってやってみると、あれれ?うまくいきません。まずはローカルデバッグするとエラーで Selenium が起動しません。

以前、こちらの記事で Selenium の NoSuchDriverException が出た話をおまけ的に書きましたが、またこいつがでてきてます。

ということで調べてみると、 Lambda で Selenium を動かすにはちょっと工夫がいったようです。ということで、この部分を試したので、メモっておきます。

Layer とコンテナイメージ

Selenium はブラウザを動かすので、 Lambda だとそれがなくて動かないということのようです。で、 Lambda で Selenium を動かす方法を調べると、 Layer を使う方法

とコンテナを使う方法

の2種類が見つかります。どちらの方法でも出きるっぽいです。

Lambda でコンテナを使えるようになったのが 2020年12月とのことで、

AWS Lambda の新機能 – コンテナイメージのサポート | Amazon Web Services ブログ

こちらの方法の方が新しいようです。また、両者のメリットデメリットについて調べてみると、いろいろと出てきます。中でも、コンテナイメージを使ったほうが複雑な依存関係をまとめて管理できる、とかが挙げられてます。

なので、当初は Layer でやってみた・・・のですが、なかなかどうして、うまくいきませんでした。

これ、調べてみると、 Python 3.8 以降で Lambda のランタイムが Amazon Linux から Amazon Linux2 に変更になり、その影響で Selenium および Chrome の動作に影響が出るとのことです。下記の記事の書き始め当たりに、いろいろと書かれています。

そういわれて、 Layer を使った方法についての記事を見てみると、 Python 3.7 以前のランタイムのものばかりです。 Layer だと必要なバイナリをまとめておけば動くイメージなのでわかりやすそうだったですがそうも問屋が卸させてくれないようです。

もっとも、頑張れば Python 3.8 以降で Layer を使う方法もなくはないようなのですが、今回はそこに時間をかけるよりも、まずは動かしたいので、コンテナを使う方法でやってみました。

コンテナイメージの作成

さきほどいくつか挙げた記事のうち、新しめだったこちらの

[AWS SAM] Selenium4をAWS lambda Python3.12で動かす #Docker - Qiita

を元に作業をやってみました。

以前にやったこちらの記事で作成したもの(gist に置いてます)をベースにして、 まずは template.yaml を変更します。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  periodic-lambda
  Sample SAM Template for periodic-lambda

Globals:
  Function:
    Timeout: 60
    MemorySize: 512

Resources:
  # Lambda 関数の作成
  PeriodicLambdaFunction:
    Type: AWS::Serverless::Function
    Properties:
      PackageType: Image
      Architectures:
        - x86_64
      Events:
        # EventBridge Scheduler からの定期実行
(略)
    Metadata:
      Dockerfile: Dockerfile
      DockerContext: ./src
      DockerTag: python3.13-v1

主な変更点は、

  • Globals で、タイムアウト時間とメモリサイズを変更
  • Lambda の定義で、 CodeUri, Handler, Runtime を削除
  • MetaData を追加

した形になります。

Lambda のハンドラーを書いたファイルは、

app.py

import glob

from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By

from util import email_util, page_util, util

def debug_environment():
    import os
    import sys

    print(f"current dir: {os.getcwd()}")
    print(f"target file: {__file__}")
    print(f"sys.path: {sys.path}")
    print(f"LAMBDA_TASK_ROOT= {os.environ['LAMBDA_TASK_ROOT']}")
    print(f"PATH= {os.environ['PATH']}")
    print(f"PYTHONPATH= {os.environ['PYTHONPATH']}")


def create_driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--incognito")
    options.add_argument("--blink-settings=imagesEnabled=false")
    options.add_argument("--enable-logging")
    options.add_argument("--log-level=0")
    options.add_argument("--single-process")

    options.binary_location = glob.glob("/opt/chrome/linux64/*/chrome")[0]

    service = Service(glob.glob("/opt/chromedriver/linux64/*/chromedriver")[0])
    driver = webdriver.Chrome(service=service, options=options)
    return driver

def lambda_handler(event, context):

    # for debug
    debug_environment()

    # webdriverの作成
    driver = create_driver()

    try:
        # スクレイピング開始
        (略)

のような形にしました。

なお、 ChromeDriver のオプションは下記などを参考にしました。

requirements.txt には必要なものを書いておきます。

boto3
urllib3
selenium

Dockerfile は

FROM public.ecr.aws/lambda/python:3.13

COPY app.py requirements.txt ${LAMBDA_TASK_ROOT}/
COPY util ${LAMBDA_TASK_ROOT}/util

RUN python3.13 -m pip install -r requirements.txt -t ${LAMBDA_TASK_ROOT}

RUN ${LAMBDA_TASK_ROOT}/selenium/webdriver/common/linux/selenium-manager --browser chrome --cache-path /opt

# Chrome の依存関係をインストール
RUN dnf install -y atk cups-libs gtk3 libXcomposite alsa-lib\
        libXcursor libXdamage libXext libXi libXrandr libXScrnSaver\
        libXtst pango at-spi2-atk libXt xorg-x11-server-Xvfb\
        xorg-x11-xauth dbus-glib dbus-glib-devel nss mesa-libgbm\
        libgbm libxkbcommon libdrm

# Command can be overwritten by providing a different command in the template directly.
CMD ["app.lambda_handler"]

としました。なお、参考にした記事はコピー先のパスがカレントディレクトリ

./

となっていたのですが、これが実際にはどこを指しているのかわかりませんでした。Lambda のドキュメントを調べると、

コンテナイメージで Python Lambda 関数をデプロイする - AWS Lambda

${LAMBDA_TASK_ROOT}

という指定のされ方だったので、これに切り替えました。なお、これを実行時に python で出力させると

/var/task

でした。

あと、 util 以下に自分で作成した関数群を置いていたので、それもコピーするようにしています。

なお、当初は dockerfile の書き方でこの util のコピーの指定方法につまづいていて、なかなか自分で作った関数が読み込めずにはまりました。

ビルド

さて、準備ができたらビルドです。ここが、残念だったのですが、 VSCode からイメージタイプの Lambda のデバッグ実行(およびビルド)を行うことができませんでした。たぶん .vscode/launch.json の内容がまずかったんでしょうね。この構成次第だと思うので、また試してみようと思います。

(2025/2/7 追記)VSCode でもデバッグ実行できました。詳しくは下記をご覧ください。

SAM コンテナイメージを使用した Lambda のデバッグ - プログラマーのメモ書き

ということで、今回は仕方ないので、コマンドラインからビルドします。

(.venv)mor@DESKTOP-DE7IL4F:~/tmp/sam_samples/periodic-lambda$ sam build --use-container
Starting Build inside a container                                                                                                                                                          
(略)
Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
(.venv)mor@DESKTOP-DE7IL4F:~/tmp/sam_samples/periodic-lambda$ 

こんな感じにビルドされます。中で、 docker を呼び出しているっぽいですね。

ローカルテスト

ローカルテストもコマンドラインで実行します。

(.venv)mor@DESKTOP-DE7IL4F:~/tmp/sam_samples/periodic-lambda$ sam local invoke
No current session found, using default AWS::AccountId                                                                                                                                     
Invoking Container created from periodic-lambda:python3.13-v1                                                                                                                             
Building image.................
Using local image: periodic-lambda:rapid-x86_64.                                                                                                                                          
                                                                                                                                                                                           
START RequestId: 49fdc119-ed45-4c2a-b18b-5dcffb70cf6d Version: $LATEST
(略)

こんな感じに実行されていきます。最終的には、 SNS トピックへの発行を呼び出したところでエラーとなって終わりますが、それまでが正しく動作していることが確認できました。

なお、ローカルテストでエラーが見つかり、修正してビルド、を何度か繰り返していると、実行時に最新のビルドが反映されていないことがありました。

どうも SAM では実行時には、ビルドしたイメージから実行用のイメージ(rapid-x86_64 というタグがつていました)を作成してそれを実行しているようなのですが、その rapid のイメージが更新されていない場合に起きているようです。

aws のドキュメントは見つからなかったのですが、下記にそれっぽい動作のことを書いています。

sam local invoke always use same image even I run sam build first when I update the source code. · Issue #5119 · aws/aws-sam-cli · GitHub

もし、こうなった場合は、

(.venv)mor@DESKTOP-DE7IL4F:~/tmp/sam_samples/periodic-lambda$ sam local invoke --force-image-build

と --force-image-build オプションをつけると、実行用のイメージを再作成してくれます。

(おまけ)chromedriver のオプションについて

最初 chromedrive のオプションとして --no-sandbox をつけていなかったら、

[ERROR] SessionNotCreatedException: Message: session not created: probably user data directory is already in use, please specify a unique value for --user-data-dir argument, or don't use --user-data-dir

とエラーが表示されてしまいました。

当初は、このエラーについて調べてみると、

SeleniumでChromeのユーザープロファイルを指定しつつ同時に自分もChromeを使う方法 #Chrome - Qiita

という記事がでてきていたので、元の参考記事のように --user-data-dir をつけたりもしたのですが、思うように動作しませんでした。

で、 ChatGPT に聞いてみると、 --no-sandbox をつけると動くかもとのことだったので、試すと問題なく動作しました。ご参考までに。

デプロイ

ここまでできたら、デプロイします。前回の記事で作った template.yaml をベースにしているので、コンテナ部分で問題が無かったため、すんなりビルドできました。

ただ、前回の記事と異なるところは、

こんな感じに CloudFormation スタックが2つできていました。

無事にデプロイできたら次は、 Lambda 関数を開いて、テストを行って動作確認だけしておきます。ちゃんと、スクレイピング後、 SNS トピックからメールも届いてました。

ただし、コンテナを利用した Lambda 関数の場合、 ECR にリポジトリを自動で作成し、そこにイメージをアップロードしていました。

なので、デプロイを繰り返すとこんな感じにイメージが溜まっていき、当然料金もかかるので、その点ご注意ください。

まとめ

Lambda をコンテナイメージから作成する部分が、不慣れなこともあり手間取りましたが、様子がわかってくればなかなかよさそうです。なんといっても、 SAM を使うおかげで、ローカルでテストができるのは魅力的ですね。

とはいえ、コンテナから Lambda 関数を作った場合、 aws のコンソール上でコードを追いかけられないんですよね。当たり前といえば当たり前ですが、その場で確認できないのって、ちょっと不便かも?と思っています。ま、慣れかな?

(おまけ) Layer でやる場合

ECR の料金も気になるのでコンテナは嫌だ、やっぱり Layer でやりたい、という時になったための備忘録です。

簡単なのは、 Python 3.8 以降の Amazon Linux2 で動作する Layer を作っているリポジトリがあるみたいなので、これを自分の Lambda の Layer として組み込むのが早そうです。

試していないですが、ご参考までに。