AWS Lambda による Web Scraping プラクティス in 2020
久しぶりに AWS Lambda 上でスクレイピング処理を実装しようとしたところ、 PhantomJS 開発終了以来あんまりスクレイピングしていなかったこともあり、勝手がまったくわからなくなっており、いろいろ調べたので書いておく。
なお、あくまでデータ取得のための簡単なスクレイピングであり、 E2E テストのような複雑なシナリオが想定される用途でも適用可能なプラクティスではないと思われる。また筆者が使いたい言語は第一に Go で第二が Python である。
スクレイピング用ライブラリは主に2種類
スクレイピングに活用できるライブラリは各言語様々あると思われるが、ざっくり2種類に大別できると考えている。
- 直接 HTTP アクセスするライブラリ
- Headless ブラウザを使うライブラリ
外部のツールに依存せず、ライブラリの機能だけで HTTP アクセスや DOM の解析を行うものが前者。そのライブラリさえ import すれば使うことができるので、基本的にはこの手のものを使うほうが楽だと思われる。ただしブラウザの機能を再現しているわけではなく、生の HTML をそのままダウンロードするだけなので、凝ったことはできない可能性がある。
凝ったこととはなんぞと言えば、例えばブラウザ表示した画面のスクリーンショットを撮りたい、 JS で動的に DOM 生成されるページをうんにゃらしたい、といった場合がある。こういった用途では Headless ブラウザを使うことになる。
直接 HTTP アクセスするライブラリ
個人的には PuerkitoBio/goquery を使うことが多い。 README から例を抜粋するが、 CSS クラスタの記法で取得する要素を指定できるのが好き。
// Find the review items
doc.Find(".sidebar-reviews article .content-block").Each(func(i int, s *goquery.Selection) {
// For each item found, get the band and title
band := s.Find("a").Text()
title := s.Find("i").Text()
fmt.Printf("Review %d: %s - %s\n", i, band, title)
})
ライブラリを import すればいいだけなので、ビルドなども特別なことはない。
Headless ブラウザを使うライブラリ
Headless ブラウザを操作するフレームワークとしては Selenium が有名。各種メジャーな言語で実装があるのだが、残念ながら Go に対応した公式のパッケージは提供されていない。そのため Python で使うことにしている。
Selenium を使うときはライブラリのインポートだけではなく、実際にウェブアクセスするための Headless ブラウザと、 Selenium がブラウザ操作するための WebDriver も Lambda のデプロイパッケージに含める必要がある。
Headless ブラウザと WebDriver
Headless Chrome を使いたいところなのだが、そのまま AWS Lambda のランタイム上で使えるわけではない。各種 FaaS ランタイム向けに Headless Chromium をビルドした serverless-chrome というプロジェクトがあるので、これを使わせてもらう。
WebDriver には Chrome (Chromium) 向けの ChromeDriver を Downloads - ChromeDriver - WebDriver for Chrome からダウンロードして使う。
注意しなければならないのは、 Selenium, serverless-chrome, ChromeDriver の三者で compatible な version の組み合わせが決まっているということ。任意のバージョンを好きに組み合わせて動くわけではない。現状 serverless-chrome のドキュメントがこれをアップデートしきれていないようで、以下の issue に有志が稼働確認した組み合わせを書き込んでいるのが見受けられるので参考にしている。
本記事執筆時点において、最新の serverless-chrome を使った Selenium for Python との compatible な組み合わせが検証済みになっていない。そのため新し目の API が使えない、ということがしばしばある。例えば自分が経験したものとしては、 element.screenshot_as_png()
が利用できず、要素単位でのスクリーンショットが取得できなかった。代替策として、 Can't get the screenshot of the current element · Issue #2898 · SeleniumHQ/selenium に記載のある、要素の座標から PIL でページスクリーンショットを切り取る方式を使っている。
Lambda layer を使った Headless ブラウザ等のデプロイ
Lambda にはデプロイパッケージサイズに 50 MB の制限があるので、 Headless ブラウザと chromedriver は Lambda layer を用いてデプロイし、 Function 本体のデプロイパッケージから分離する。
Lambda layer にデプロイしたファイルは、 Lambda 実行時に /opt
配下に展開されるので、それに合わせて Selenium からパスを指定して読み込む。自分の場合は以下の Makefile で Lambda layer 向けの zip パッケージを作成しているが、この場合は /opt/bin
配下に chromedriver
と chromium
が配置されることになる。
layer_headless_chrome/bin:
mkdir -p layer_headless_chrome/bin
layer_headless_chrome/bin/headless-chromium: layer_headless_chrome/bin
wget https://github.com/adieuadieu/serverless-chrome/releases/download/v1.0.0-37/stable-headless-chromium-amazonlinux-2017-03.zip -O chromium.zip
unzip chromium.zip
mv headless-chromium layer_headless_chrome/bin/
rm chromium.zip
layer_headless_chrome/bin/chromedriver: layer_headless_chrome/bin
wget https://chromedriver.storage.googleapis.com/2.37/chromedriver_linux64.zip -O chromedriver.zip
unzip chromedriver.zip
mv chromedriver layer_headless_chrome/bin/
rm chromedriver.zip
Selenium を使うときは、割愛した書き方をするとこういう感じ。
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.binary_location = "/opt/bin/chromium"
driver = webdriver.Chrome(chrome_options=options, executable_path="/opt/bin/chromedriver")
日本語フォントの内包
特にスクリーンショットを撮りたい場合、 Lambda のランタイムには日本語フォントが存在しないため、デプロイパッケージに含めてやらなければ文字化けする。
これはそう面倒な問題でもなく、通常 Linux では $HOME/.fonts
にフォントファイルを置けば読んでくれるので、デプロイパッケージを zip するときに .fonts
配下へ使いたいフォントファイルを置いておけばよい。僕は IPA フォントを使っているので、 Makefile だとこういう感じ。
.fonts:
mkdir .fonts
.fonts/ipaexg.ttf .fonts/ipaexm.ttf: .fonts
curl https://ipafont.ipa.go.jp/IPAexfont/IPAexfont00401.zip -o IPAexfont.zip
unzip IPAexfont.zip
mv ./IPAexfont00401/*.ttf ./.fonts/
rm -rf ./IPAexfont00401
rm IPAexfont.zip
Serverless Framework によるデプロイ
Lambda layer と Function を個別にデプロイするのは面倒なので、 Serverless Framework を遣う。先に書いた Makefile の例に倣い、 ./layer_headless_chrome
配下に Headless ブラウザと chromedriver が置かれているものとして、以下のようなサンプルになる。
functions:
sample_function:
handler: sample_function.lambda_handler
layers:
- { Ref: HeadlessChromeLambdaLayer }
role: LambdaBasicExecution
package:
include:
- '.fonts/**'
layers:
headlessChrome:
path: layer_headless_chrome