初学者の学生エンジニアが複数のURLを並列処理で解析するツールを、DockerやSelemium、Lambdaの沼にハマりながら実装した備忘録です。
キーワード:
Selemium、Docker、ECR、Lambda、ウォームスタート、並列処理、ホットリロード
目次
実装意図
社内での他社URLの調査作業を自動化するために、URLの配列とプロンプトを引数として、スクリーンショットとAIによる分析結果を返します。最終的にはGoogleスプレッドシートやSlackから叩くことを想定しています。
ECRのコンテナイメージからLambdaを作成するという方法をとっています。参考にさせていただいた記事
ECSではなくLambdaを選択した理由はそこまでリクエスト数が多くない想定なのでイベント稼働の方がコストパフォーマンスが良いからです。
コンテナイメージではなく、Lambda LayerでSelemiumを利用する方法もありますが、コンテナの方がコールドスタート速度が早いことやchromeとchrome driverのバージョン管理が容易なことからコンテナから作成しています。Lambdaのコールドスタート速度についての記事
実装方法
ディレクトリ構成は以下です。
.
├── Dockerfile
├── README.md
├── docker-compose.yml
├── main.py
└── requirements.txt
以下からいくつかのポイントについて解説します。
Lambda用のDockerコンテナ構築
Dockerfileは以下のようになっています。
# ----- ビルドステージ -----
FROM --platform=linux/amd64 public.ecr.aws/lambda/python@sha256:63811c90432ba7a9e4de4fe1e9797a48dae0762f1d56cb68636c3d0a7239ff68 as build
RUN dnf install -y unzip && \\
curl -Lo "/tmp/chromedriver-linux64.zip" "<https://storage.googleapis.com/chrome-for-testing-public/132.0.6834.159/linux64/chromedriver-linux64.zip>" && \\
curl -Lo "/tmp/chrome-linux64.zip" "<https://storage.googleapis.com/chrome-for-testing-public/132.0.6834.159/linux64/chrome-linux64.zip>" && \\
unzip /tmp/chromedriver-linux64.zip -d /opt/ && \\
unzip /tmp/chrome-linux64.zip -d /opt/
# ----- 最終ステージ -----
FROM --platform=linux/amd64 public.ecr.aws/lambda/python@sha256:63811c90432ba7a9e4de4fe1e9797a48dae0762f1d56cb68636c3d0a7239ff68
# 必要なライブラリのインストール
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 \\
ipa-gothic-fonts
# Pythonパッケージのインストール
COPY requirements.txt ./
RUN pip install -r requirements.txt
# ビルドステージでダウンロードしたChromeとChromeDriverのコピー
COPY --from=build /opt/chrome-linux64 /opt/chrome
COPY --from=build /opt/chromedriver-linux64 /opt/
# アプリケーションコードのコピー
COPY main.py ./
# Lambdaハンドラーのエントリーポイントを指定(main.handler)
CMD [ "main.handler" ]
Lambda用のDockerfileにはいくつか注意するポイントがあります。
-
ビルドステージと最終ステージの分離:
ビルドステージと最終ステージを分離することで最終的なコンテナイメージのサイズを大幅に削減できます。またビルド時間が短縮される効果もあります。 -
プラットフォームの指定:
Lambdaの実行環境がLinuxである一方、私のローカル実行環境がMacであるため、-platform=linux/amd64
をつけないとLambda上で動かすことができません。Lambdaの実行環境に合わせたコンテナイメージを作ります。 -
日本語フォントのダウンロード:
ipa-gothic-fontsというフォントをインストールすることでスクリーンショット時に日本語が文字化けすることがなくなります。
ECRへのデプロイ、Lambdaで実行
main.pyはこんな感じです。
# 超簡略化しています
from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def process_url(url, query):
html_content = fetch_html(url)
take_fullpage_screenshot_with_timeout(url, screenshot_path)
call_gemini(query + "\n#記事内容#\n" + html_content, screenshot_path)
image_url = upload_to_s3(screenshot_path)
log_to_dynamodb(url, gemini_text)
return {
"url": url, "screenshot_url" : image_url,
"cropped_screenshot_url" : cropped_image_url, "gemini_text": gemini_text,
}
def handler(event, context):
initialize_lambda_environment()
urls = event.get("urls")
query = event.get("query")
results = []
with ThreadPoolExecutor(max_workers=5) as executor:
future_to_url = {executor.submit(process_url, url, query): url for url in urls}
for future in as_completed(future_to_url):
url = future_to_url[future]
result = future.result(timeout=115)
results.append(result)
return {
"statusCode": 200, "body": json.dumps(results, ensure_ascii=False)
}
ローカルでの動作確認が取れたらLambda上での動作確認を行います。
まずはコンテナイメージをECRにデプロイします。ECRにログインしてから以下を実行します。
docker buildx build --platform linux/amd64 --provenance=false --push -t yourid.dkr.ecr.ap-northeast-1.amazonaws.com/competitor_analysis:latest .
ここでもプラットフォームの指定を行います。
また--provenance=false
が超重要です。これをつけないとメタデータのイメージが余分に1個生成されてしまい、Lambdaを作成する際にエラります。
ここまでできたらLambdaを作成します。コンソールの関数作成からコンテナイメージを選択します。
Lambdaのメモリサイズですが、デフォルトの128MBだと全く動かないので容量を増やしましょう。詳しくは並列スクレイピングの負荷対策の項目で記述します。
またコードを更新したときはECRにデプロイするだけじゃなくてLambdaが参照するイメージも更新してあげないといけません。
Lambda上でのSelemiumの安定稼働
Chromeの実行はかなりのメモリを消費するため、Lambda上での実行のためには省メモリ対策が重要です。スクリーンショットを行う関数は試行錯誤の結果以下のようにしています。
def take_fullpage_screenshot(url, output_path):
"""指定のURLの全体スクリーンショットを取得する"""
options = webdriver.ChromeOptions()
service = webdriver.ChromeService("/opt/chromedriver")
options.binary_location = '/opt/chrome/chrome'
options.add_argument("--headless=new") #GUIを表示しない。コマンドラインで開く。
options.add_argument('--no-sandbox') # セキュリティサンドボックスを無効にする。
options.add_argument("--disable-gpu") # GPUではなくCPUでグラフィック処理
options.add_argument("--window-size=1280x1696") # 画面サイズを指定
options.add_argument("--hide-scrollbars") # スクロールバーを非表示にする
# options.add_argument("--single-process") # 使うと安定性が下がるがリソース消費は減る
options.add_argument("--disable-dev-shm-usage") #dev/shmはchromeが頻繁に利用する共有メモリ領域。Lambdaではサイズの変更ができず足りなくなる。このオプションを使うと代わりに/tmpを用いるようになる。
options.add_argument("--disable-dev-tools") #開発ツールを無効にする
options.add_argument("--no-zygote") #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") #デバッグ用
try:
chrome = webdriver.Chrome(options=options, service=service)
# chrome.implicitly_wait(10) こいつ入れると全然動かなくなる。
chrome.get(url)
# ページが完全にロードされるまで明示的に待機(bodyタグが表示されるまで)
WebDriverWait(chrome, 10).until(EC.presence_of_element_located((By.TAG_NAME, "body")))
# Chrome DevTools Protocol (CDP) を使ってページ全体のサイズを取得
metrics = chrome.execute_cdp_cmd("Page.getLayoutMetrics", {})
width = metrics["contentSize"]["width"]
height = metrics["contentSize"]["height"]
# ウィンドウサイズをページ全体のサイズに合わせる
chrome.set_window_size(width, height)
# スクリーンショットの保存
chrome.save_screenshot(output_path)
except Exception:
raise
finally:
chrome.quit()
色々オプションがあって一つ変えるとすぐに動かなくなったりします。女の子以上に繊細な扱いが求められます。一応一通り検証し、とりあえず上記のオプションで動作は安定しています。Lambdaへの十分なメモリとエフェメラルストレージの割り当ても同様に重要です。
並列スクレイピングの負荷対策
私のユースケースだと複数のurlをスクリーンショットしてGeminiで解析するので並列処理によって時間を短縮しています。並列数が増えるにつれてかなりのメモリサイズやエフェメラルストレージサイズが必要になります。これらのサイズが少ないほどスクリーンショットが失敗する確率が上がっていきます。
Lambda上でのSelemium実行はメモリサイズはもちろん、エフェメラルストレージ(=/tmp
)の容量も重要になります。通常Chromeはさまざまなデータを共有メモリ領域の/dev/shm
に配置しますが、Lambdaではこの領域のサイズを指定できないため、容量不足に陥ります。そこで"--disable-dev-shm-usage"
オプションによって代わりにディスク領域の/tmp
にデータを配置するようにしています。そのためエフェメラルストレージの容量が少ないとchromeが頻繁にクラッシュするようになります。
精密な調査はしていませんが、最終的には最大並列処理数であるThreadPoolExecutorのmax_workersは5、メモリは10GB、エフェメラルストレージは5GBを割り当てています。この構成だとほとんどスクリーンショットが失敗することはないです。
Lambdaのウォームスタート対策
Lambda上でSelemiumを動かす際にはLambdaのウォームスタート対策も重要です。Lambdaがウォームスタートすると/tmp
ディレクトリが前回から引き継がれます。ここに残っているファイルが悪さしてSelemiumが全然動かなくなったりするので初期化する関数initialize_lambda_environment()
をプログラムの最初で実行しています。Lambdaのウォームスタートについての記事
def initialize_lambda_environment():
# ウォームコンテナの場合、前回の実行結果が /tmp に残っている可能性があるため、全てのファイルとディレクトリを削除します。
tmp_dir = "/tmp"
for filename in os.listdir(tmp_dir):
file_path = os.path.join(tmp_dir, filename)
try:
if os.path.isfile(file_path) or os.path.islink(file_path):
os.unlink(file_path) # ファイルまたはシンボリックリンクを削除
elif os.path.isdir(file_path):
shutil.rmtree(file_path) # ディレクトリを再帰的に削除
except Exception as e:
print(f"Error deleting {file_path}: {e}")
付録:ホットリロードの実現
Docker初心者であるため、ホットリロードの実現にも大変苦労しました。当初コードの修正をすぐにコンテナに反映することをvolumeやdocker Compose WatchのSyncで実現しようとしていましたがうまくいきませんでした。
初心者なりに色々調べたり試行錯誤した結果、コンテナイメージをリビルドしないと修正が反映されないことがわかりました。理由はDockerfileでCOPY main.py ./
をしているからどんなにローカルとコンテナを同期しようとしても結局コンテナイメージからスクリプトが実行されてしまうからです。
結局docke-composeのwatch機能のrebuildによってmain.pyの変更を検知するたびにリビルドするという実装を行っています。
services:
competitor_analysis: # サービス名 (コンテナの名前)
build:
context: ./
dockerfile: ./Dockerfile
image: competitor_analysis:latest # 使用するイメージ名
ports:
- "9000:8080" # ポートマッピング
environment:
JINA_API_KEY: "a"
GEMINI_API_KEY: "a"
LOCAL_ENV: "true"
volumes:
- ./tmp:/tmp
develop:
watch:
- action: rebuild
path: ./main.py
- action: rebuild
path: ./Dockerfile
コンテナを立ち上げるときは以下のように実行します。
docker compose up --watch
まとめ
以上で終わりになります。
AI周りの苦労で言うとAPI Gatewayのタイムアウト上限緩和や、結果をスプレッドシート貼り付けるためのS3のパブリックURL発行やAIのレスポンスの構造化など苦労したポイントはいくつかありましたが本記事では割愛しています。
本記事が誰かの助けになっていれば幸いです。