the world as code

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 配下に chromedriverchromium が配置されることになる。

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