【Python】AWS Lambda の Container Image Support を使い Selenium を動かす

2020/12 に AWS Lambda の新機能として Container Image (Docker/OCI) がサポート [1] されました。最大 10 GB のコンテナイメージをデプロイすることができ, AWS Lamdba の可能性がさらに広がりました。ワークロードをサーバレスに移行する流れは加速していきそうです。

今回は, AWS Lambda の Container Image Support を使い Selenium を動かす手順の備忘録です。環境は以下です。

  • macOS Catalina
  • Python 3.7
  • aws-cli/2.0.50
  • selenium-3.141.0
  • ChromeDriver 87.0.4280.88
  • Headless Chromium-87.0.4280.27

Headless Chrome & ChromeDriver

準備として, Headless Chrome (Chromium) と Chrome 用の WebDriver である ChromeDriver を以下から DL し unzip する。

Headless Chrome は GUI を持たない軽量版 Chrome である。また, WebDriver は Web ブラウザを操作し主に Web アプリを自動テストするための OSS ツールで, JavaScript 実行をサポートする。
Headless Chrome と ChromeDriver の互換性に注意する必要がある。 Chrome のバージョンは MAJOR.MINOR.BUILD.PATCH の形式で表されており, MAJOR.MINOR.BUILD が一致する Chrome を ChromeDriver がサポートしている。 その他のバージョン選択の指針は Version Selection を確認する。

Amazon ECR

次に, AWS 管理のコンテナのレジストリである Amazon ECR (Amazon Elastic Container Registry) にリポジトリを作成する。 事前に IAM ユーザに必要な権限のポリシーがアタッチされていることを確認する。

$ REGION=ap-northeast-1
$ AWS_ACCOUNT_ID=xxxxxxxxxxxx
$ aws ecr create-repository \
    --repository-name sample \
    --image-scanning-configuration scanOnPush=true \
    --region $REGION \
    --profile t2sy

リポジトリの作成は, AWS CLI でなく ECR コンソールからも可能。

Dockerfile & Lamnda handler

基本的な手順は AWS 公式ドキュメントの Deploy Python Lambda functions with container imagesCreating Lambda container images を参照する。
AWS から Lambda の実行に必要な言語ランタイムとコンポーネントがプリロードされた AWS base images for Lambda が提供されている。
今回は python:3.7 base image を元に Dockerfile を書く。(OS は Amazon Linux 2 でなく Amazon Linux を用いるため Python 3.7 を選択)

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

RUN pip install --upgrade pip && \
    pip install -t ./ selenium

COPY app.py  ./
COPY bin/chromedriver /var/task/bin/
COPY bin/headless-chromium /var/task/bin/

CMD ["app.handler"]

次に, Python handler (app.py) を書く。 ChromeDriver 経由で Headless Chrome を起動し Event で渡されたクエリを Google 検索し, 検索件数の結果を返すコードである。

import sys
import os
import time
import shutil
from selenium import webdriver


def move_bin(
    fname: str, src_dir: str = "/var/task/bin", dest_dir: str = "/tmp/bin"
) -> None:
    if not os.path.exists(dest_dir):
        os.makedirs(dest_dir)
    dest_file = os.path.join(dest_dir, fname)
    shutil.copy2(os.path.join(src_dir, fname), dest_file)
    os.chmod(dest_file, 0o775)


def create_driver(
    options: webdriver.chrome.options.Options,
) -> webdriver.chrome.webdriver:
    driver = webdriver.Chrome(
        executable_path="/tmp/bin/chromedriver", chrome_options=options
    )
    return driver


def handler(event, context):
    query = event["query"]

    move_bin("headless-chromium")
    move_bin("chromedriver")

    options = webdriver.ChromeOptions()
    options.binary_location = "/tmp/bin/headless-chromium"
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--single-process")
    options.add_argument("--disable-gpu")
    options.add_argument("--window-size=1280x1696")
    options.add_argument("--disable-application-cache")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--disable-infobars")
    options.add_argument("--hide-scrollbars")
    options.add_argument("--enable-logging")
    options.add_argument("--log-level=0")
    options.add_argument("--ignore-certificate-errors")
    options.add_argument("--homedir=/tmp")

    driver = create_driver(options)

    driver.get("https://www.google.co.jp")

    time.sleep(5)

    search_box = driver.find_element_by_name("q")
    search_box.send_keys(query)
    search_box.submit()

    time.sleep(5)

    stats_elem = driver.find_elements_by_css_selector("#result-stats")
    search_count = stats_elem[0].text

    driver.quit()

    return {"statusCode": 200, "body": {"query": query, "result": search_count}}

Headless Chrome の配置先には注意が必要である。Lambda requirements for container imagesに以下の記述がある。

The container image must be able to run on a read-only file system. Your function code can access a writable /tmp directory with 512 MB of storage. If you are using an image that requires a writable directory outside of /tmp, configure it to write to a directory under the /tmp directory.

Lambda 関数から書き込み可能な領域は /tmp (512 MB) のみで, /var/task や /opt への配置を試したところ Headless Chrome の起動に失敗した。このエラーは [2] でも報告されており, [2] を参考に Lambda 関数実行時に Headless Chrome を /tmp/bin に移動する move_bin() を追加した。
この方法は記事執筆時点 (2021-01-10) のため, 今後変更やより良いプラクティスが出てくるかもしれない。

次に, 以下のディレクトリ構成で Dockerfile からコンテナイメージを構築する。

$ tree
.
├── Dockerfile
├── app.py
└── bin
    ├── chromedriver
    └── headless-chromium

$ docker build -t hello-world .

docker login コマンドで Amazon ECR にログインする。認証に失敗した場合は, ~/.aws/credential などを再確認する。

$ aws ecr get-login-password --region $REGION --profile t2sy | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com
Login Succeeded

docker tag コマンドでコンテナイメージにタグ付けし, docker push コマンドで Amazon ECR にコンテナイメージをデプロイする。

$ docker tag  hello-world:latest $AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/hello-world:latest
$ docker push $AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/hello-world:latest

AWS Lambda Runtime Interface Emulator (RIE) でローカルテスト

イメージの変更の度に Amazon ECR にデプロイし Lambda 関数をテストするのは大変なため, AWS は AWS Lambda Runtime Interface Emulator (RIE) というローカルで Lambda 関数をテスト/デバッグするためのエミュレータを提供している。
macOS の場合, 以下のコマンドで RIE をインストールできる。

$ mkdir -p ~/.aws-lambda-rie && curl -Lo ~/.aws-lambda-rie/aws-lambda-rie https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie && chmod +x ~/.aws-lambda-rie/aws-lambda-rie

docker run コマンドでコンテナイメージをローカルで起動する。

docker run -p 9000:8080 hello-world:latest

curl コマンドで以下のエンドポイントに HTTP POST リクエストを投げると Lamdba 関数が呼び出される。

$ curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"query": "Python"}'
{"statusCode": 200, "body": {"query": "Python", "result": "\u7d04 395,000,000 \u4ef6 \uff080.43 \u79d2\uff09 "}}

Python の Google 検索結果の件数は約 395,000,000 件であった。

AWS Lmabda コンソールからテスト

ローカルで動作確認できたため, AWS Lmabda コンソールから Lamdba 関数を作成しテストする。
AWS Lmabda コンソールから 「関数」->「関数の作成」を選択, 「コンテナイメージ」のオプションを選択する。 関数名とコンテナイメージURLを入力する。コンテナイメージURLは Amazon ECR コンソールからも確認できる。

テストイベントに以下を設定する。

{
  "query": "Scala"
}

AWS Lmabda コンソールからテストを実行する。

Scala の Google 検索結果の件数は約 161,000,000 件であった。

[1] AWS Lambda の新機能 – コンテナイメージのサポート
[2] AWS Lambda Container Running Selenium With Headless Chrome Works Locally But Not In AWS Lambda
[3] コンテナイメージ内でLambda レイヤーと拡張機能を動作させる