とある理由から、スクレイピングした結果をDBに保存する処理を定期実行させる簡単なプロダクトを作りたくなった為、AWS Lambda上でSelenium + Headless ChromeをPythonで動かす為の基盤作成を行った際の記録です。
作業時に部分的に参考になる資料は沢山あったものの、事前準備から始まり、Lambdaをどう作るかの作成部分、Lambdaからスクレイピングを実施する為のコード内記述、実行後に多くのケースでハマるポイントへの対処などなど、通しで書かれた内容が少なかった事もあり、振り返りの意味も含めて一度まとめてみます。
やりたいこと
冒頭にも書いたとおり、スクレイピングした結果をDBに保存する処理を定期実行させる簡単なプロダクトを作る為の実行基盤を作るのが目的です。
何故AWS Lambda上で動かす必要があったのか
スクレイピング処理をLambda上で実行させる理由は、以下3点からです。
- 定期実行させたかった
- スクレイピング結果を保存するDBにセキュアにアクセス可能だった
- コストを最小限に抑えたかった
スクレイピング処理を実行するだけであれば、極論ローカル環境から何かしらのスクリプトを叩くなどで容易に実現可能ですが、処理を定期実行させる必要があった今回は、Lambdaを選択するとCloudWatchEventsで定期実行が容易に実現可能です。
ローカル環境でcronから定期実行させると、常時電源をONにしておく必要があり電気代が勿体ない
定期実行させるだけであればGASなどの選択肢もありますが、安価で使用可能なメリットがあった理由から保存先のDBとして選択したDynamoDBに対して、Lambdaを選択するとIAMロールを用いてセキュアにアクセス可能です。
DynamoDBへのアクセスを許可したポリシーをアタッチしたIAM Userのクレデンシャルを使用する方法もありますが、非推奨な方法でもあるようにあまりやりたくなかった。
DBにセキュアにアクセス可能な環境から処理を定期実行させるだけであればEC2やECSなどからcron実行させる選択肢もありますが、Lambdaには十分な無料利用枠(400,000GB-秒)があり、今回の用途であれば無料で使用可能であることと、他に作成したLambda処理との兼ね合いで仮に無料使用枠を超過したとしても、動いている時間だけの課金で済むのは、他の選択肢と比較して圧倒的にコストを抑えられます。
AWS LambdaでSeleniumを動かす方法
ここからがLambda上でSeleniumを動かす為の作業手順で、幾つかハマりポイントがあったので順に書いていきます。
1. Seleniumの用意
通常Seleniumを使用したい場合は、各自のPCのローカル環境でpip installなどを使用してインストールすると思います。
が、この方法でインストールしたSeleniumはインストール環境のOS用ファイルになっている為、Lambdaの実行OSであるAmazon Linuxでは使用する事ができません。
その為、Amazon Linuxの環境でインストールしたSeleniumのファイルを用意する必要があるのですが、Seleniumを落とす為だけに新規にEC2インスタンスを作ってインストール完了後にすぐに消すたりな作業も少し面倒です。
そんな面倒臭さを解消する為、今回は環境の用意が最短で済んでインストール完了後の後片付けも不要なAWSのCloud9を使用してインストールする事にしました。
Cloud9で実行するコマンドは、ローカル環境の時と変わらず以下でOKです。
1 |
sudo pip install selenium -t ./ |
インストールが終わったら、落としたファイルをローカル上にDLして完了です。
scpなどの必要もなくGUIで一発でDL可能です。Cloud9すごい!
2. ChromeDriverとHeadless Chromeの用意
Seleniumが用意できたので、次はSeleniumで起動させるWebブラウザのドライバが必要です。
使用するブラウザは何でも良いので好きなブラウザで大丈夫ですが、今回はGoogle Chromeを使用する前提で進めていきます。
また、LambdaでSeleniumを動かす際はバックグランドで起動させる必要がある為、選択したブラウザをヘッドレスモードで起動させる為の用意も合わせて必要になります。
Google Chromeの場合は、Headless Chromeが該当します。
- Headless Chrome
- ChromeDriver
まずはChromeをヘッドレスモードで動かす為のHeadless Chromeの用意ですが、Lambda上でHeadless Chromeを動かす為のserverless-chromeという便利なリポジトリが公開されているので、ありがたく使わせていただきます。
2020年4月現在ではv1.0.0-55が最新バージョンだった為、最新バージョンの安定版をDLすべく以下で保存します。
1 |
curl https://github.com/adieuadieu/serverless-chrome/releases/download/v1.0.0-55/stable-headless-chromium-amazonlinux-2017-03.zip > headless-chromium.zip |
次にWebブラウザのドライバ用意ですが、以下からChromeDriverの最新版をDLしてきても動きません。
これは先程DLしてきたheadless-chromiumとChromeDriverの間の互換性に原因があるようなので、動作が確認されているバージョン2.43を使用する事で対処します。
1 |
curl https://chromedriver.storage.googleapis.com/2.43/chromedriver_linux64.zip > chromedriver.zip |
Lambdの実行コンテナのOSであるLinux用のドライバなので、落としてくるファイルもchromedriver_linux64.zipです。
3. SeleniumとChromeDriver、Headless ChromeをLayerとして登録
Lambda上でSeleniumを実行する為に必要なもの一式が揃いましたが、これらを全てLambdaに登録しようとするとファイル容量が大き過ぎるのが原因でzip圧縮した上でアップロードするしか選択肢がなくなってしまうのと、圧縮後もまだファイル容量が大き過ぎる為、マネジメントコンソール上のLambdaのGUIエディタから編集を行う事ができません。
ローカル環境で全ての動作確認を済ませてからAWS上には完成物をアップロードするだけの運用方法を取るのであればそれでも問題ありませんが、細かな修正を行う毎に都度zip圧縮してアップロードするのはやはり面倒です。
ファイル容量が大きい関係で、アップロードするのも相当数の時間がかかりストレスが溜まります。
上記問題への対応として、スクレイピングの本体処理以外の環境作成にあたるSeleniumとChromeDriver、Headless Chromeに関しては共通処理としてLayerで事前にアップロードしておき、Lambda本体から使用するLayerとして登録する形を取ります。
こうする事で、Lambda本体のファイル容量が実行ファイルだけになり軽くなるのでマネジメントコンソール上からGUIでのファイル編集が可能になり、億劫だったファイルアップロードもLayerにアップロードする初回の一回のみで済ませられます。
- Selenium のLayer登録
- ChromeDriverとHeadless ChromeのLayer登録
LayerとしてアップロードしたSeleniumは、スクレイピングを行うLambda本体からインポートする形で読み込まれて使用されます。
その際に、インポートする側の記述としてはできるだけシンプルな記述が好ましく、可能であれば以下のような形でSeleniumがLambdaの実行コンテナ上に配置されるディレクトリパスを意識する事なくインポートできると理想です。
1 2 3 |
from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.keys import Keys |
上記のように読み込ませる為にはLambdaの実行環境のPythonが認識しているパスのrootにseleniumのLayerが置かれている必要がありますが、アップロードしたファイル群をLambdaからインポートした際に実行コンテナ上に配置されるディレクトリパスと、Lambdaの実行環境のPythonが認識するパスはそれぞれ以下のようになっています。
– インポートするLayerのLambdaの実行コンテナ上の配置パス
1 |
/opt |
– Lambda実行環境のPythonが認識するパス(PYTHONPATH)
1 2 |
/opt/python /opt/python/lib/python{実行環境Pythonバージョン}/site-packages |
上記のような関係性になっているので、アップロードするSeleniumの親ディレクトリとしてpythonディレクトリを作成した上でpythonディレクトリをzip圧縮してアップロードする事で、インポートする側のLambdaがSeleniumが置かれているディレクトリパスを意識する事なく使用可能になります。
[Layerに登録するSeleniumのディレクトリ構成]
* Seleniumの全ファイルを表示すると数が多過ぎる為、2階層まで表示
1 2 3 4 5 6 |
. └── python ├── selenium ├── selenium-3.141.0.dist-info ├── urllib3 └── urllib3-1.25.8.dist-info |
ChromeDriverとHeadless Chromeの二つは、Lambda上でSeleniumを使用したスクレイピングを実行する際にはセットで使用するものになるので、二つまとめてzip圧縮して一つのLayerとして登録します。
尚、この二つに関してはLambdaからインポートして使用するものではなく、コード上でChromeのWebDriverオブジェクトを作成する際に絶対パスで指定して読み込むファイル群になるので、Layerに登録する内容は、Lambdaの実行コンテナのどこに配置されるかだけ把握しておけばディレクトリ構成はあまり気にする必要はなさそうです。
今回は、headless-chromeというディレクトリを作り、その中に2ファイルを入れてLayerに登録しました。
[Layerに登録するChromeDriverとHeadless Chromeのディレクトリ構成]
1 2 3 4 |
. └── headless-chrome ├── chromedriver └── headless-chromium |
今回のディレクトリ構成でLayerを登録した場合、ChromeDriverとHeadless ChromeのLambdaの実行コンテナ上の絶対パスはそれぞれ以下のようになります。
– ChromeDriverのLambdaの実行コンテナ上の絶対パス
1 |
/opt/headless-chrome/chromedriver |
– Headless ChromeのLambdaの実行コンテナ上の絶対パス
1 |
/opt/headless-chrome/headless-chromium |
seleniumとheadless-chrome(ChromeDriver & headless-chromium)の二つをLayerに登録し終わったら事前準備は完了です。
4. スクレイピングを実行するLambda作成
Lambdaの作成に入り、以下3つの作成/編集を行います。
- 関数の作成
- 使用するLayerの追加
- 実行トリガーの設定
Lambda関数本体の作成です。
今回は特に複雑な設定は不要なので、ざっと登録していきます。
作成オプション |
AWSが用意した設計図を元に関数を作成するかの選択。 今回はCloudWatchEvents経由の定期実行がイベントトリガの為、設計図は使用せず一から作成を選択。 |
---|---|
関数名 |
作成するLambda function名。 お好きにどうぞ。 |
ランタイム |
実行時に使用するプラグラミング言語の選択。 今回は既にインポートするLayerをPythonで作っていた為、Lambda側も合わせてPythonを選択。 |
実行ロール |
他サービスへのアクセス許可/拒否の制御用ロール選択。 スクレイピング結果をどこかに保存/通知するなどLambdaの処理内から他リソースにアクセスする場合は、対象サービスへのアクセス許可が設定されたポリシーがアタッチされたロールを選択。 スクレイピングを実行するだけで特に他サービスへアクセスする必要がなければ、デフォルトで用意されたCloudWatchLogsへの書き込みのみ許可のlambda_basic_executionロールで十分です。 今回はスクレイピング結果をDynamoDBに保存する為、DynamoDBへのputItemが許可されたロールを選択しました。 |
事前に用意したseleniumとheadless-chromeの二つをLambdaに登録します。
作成するLambdaと追加するLayerのランタイム(今回はpython)とバージョンを合わせておかないと登録ができないので、そこだけ注意が必要です。
今回は実行トリガーが定期実行の為、CloudWatchEventsのスケジュール実行を選択して各入力項目を埋めていきます。
トリガーの種類 |
トリガーの形式選択。 CloudWatchEventsを選択。 |
---|---|
ルール |
CloudWatchEventsの実行条件ルール。 今回は新規追加を選択。 |
ルール名 |
ルール名。 お好きにどうぞ。 |
ルールの説明 |
ルールの説明。 お好きにどうぞ。 |
ルールタイプ |
実行タイプの選択。 スケジュール式を選択。 |
スケジュール式 |
ルールタイプにスケジュール式を選択した場合に設定するスケジュール指定。 CloudWatchEventsで指定する時刻のタイムゾーンはUTCの為、日本時間(JST)と9時間前の時差がある点に注意。 |
トリガーの有効化 |
トリガーをすぐに有効化するか、無効化した状態で保存だけするかの設定。 最初は無効化しておき、一通りできたタイミングで有効化する事を推奨。 |
以上でLambda関数はできたので、残りは実行するコードの記述だけです。
5. スクレイピングを実行するコード作成
残りは、コードの記述だけです。
[LambdaからSeleniumでスクレイピングを実行するコードサンプル]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
from selenium import webdriver from selenium.webdriver.chrome.options import Options def lambda_handler(event, context): ## Seleniumが使用するWebDriver作成時のオプションを作る options = webdriver.ChromeOptions() ## オプションのバイナリロケーションにLayerで用意したheadless-chromiumのパスを指定 options.binary_location = '/opt/headless-chrome/headless-chromium' ## オプションにヘッドレスモードで実行させる記述を書く options.add_argument('--headless') ## Seleniumが使用するWebDriverを作成 driver = webdriver.Chrome( ## Layerで用意したchromedriverのパスを指定 '/opt/headless-chrome/chromedriver', options = options ) ## 引数で指定したURLに直リンクで遷移 driver.get('https://masakimisawa.com') ## HTMLの要素を取得する例 ## この例では、name属性がemailaddressの要素を取得している input_email = driver.find_element_by_name('emailaddress') ## 取得した要素に対して操作を行う例 ## この例では、取得したinput要素にhogehogeの文字列を入力している input_email.send_keys('hogehoge') ## HTMLの要素名を指定して取得する例 button = driver.find_element_by_tag_name('button') ## ボタン要素のクリックイベントを実行する例 button.click() ## 滞在するページURLのスクリーンショットを撮影して指定したパスにファイルを保存 driver.get_screenshot_as_file('/tmp/screenshot.png') ## 処理を終了させる際は必ずWebDriverを閉じる。 driver.quit() |
今回はページ遷移やボタンクリックなど基本的な操作しか載せていませんが、もちろんもっと凝った挙動も実現可能なので、色々試したい方は公式リファレンスから探してみてください。
以上でAWS LambdaからSeleniumを実行させた際の記録は終了です!
と言いたいところですが、、、、まだ続きがあります
6. 日本語の文字化け対応
スクレイピングが可能になり遷移先のページで色々な操作を行っていると、何故か存在するはずの文字列が取れなかったり不可解な事態が発生すると思います。
何故だろう?と思い原因を調べる為に対象ページのスクリーンショットを撮影して確認してみると、以下のように日本語が潰れて文字化けしてしまっているのが確認できると思います。
[日本語が文字化けしてしまっている状態]
これはLambdaの実行コンテナのOSが日本語フォントに対応していないのが原因の為、以下二つの対応を行う事で問題が解決します。
- 日本語フォントを用意し、LinuxOSがフォントファイルを認識可能パスに配置
- /opt/.fonts配下のファイルをフォントファイルとして認識可能にする
- Lambdaの環境変数に登録
- Lambdaの実行コードの初期化処理内で設定
まずは日本語フォントの用意ですが、IPAが公開しているフォントを利用するのが最も手軽な為、以下からipaexmとipaexgの二つをDLします。
IPAに感謝!
次にLinuxOSがフォントファイルを認識できるパスにDLしてきた2ファイルを配置する必要がありますが、LinuxOSは以下のディレクトリパスをフォントファイルのパスとして認識します。
– LinuxOSがフォントファイルを認識可能なパス
1 |
/.fonts |
上記の通りroot直下の.fontsディレクトリ配下がフォントファイルを認識できるパスになる為、ローカル環境で.fontsディレクトリを作成してDLしてきた2ファイルを格納します。
[アップロードするフォントファイルのディレクトリ構成]
1 2 3 4 |
. └── .fonts ├── ipaexg.ttf └── ipaexm.ttf |
これで日本語フォントが用意できましたが、作成した.fontsディレクトリをzip圧縮したファイルのサイズを確認すると少し大き過ぎる為、Lambdaと一緒にアップロードするのは難しそうです。
そんな時は、headless-chromeをアップロードした時と同じく、Lambda本体からは分離したLayerとしてフォントファイルをアップロードしておくと、Lambda側でフォントファイルのLayerを登録するだけでフォントファイルが認識可能になります。
が、seleniumとheadless-chromeのLayerを登録する部分でも書いた通り、Lambda本体が使用するLayerとして登録したファイル群の実行コンテナ上のファイルパスは、/opt 配下になっています。
したがって、.fontsディレクトリ配下をLayerとしてアップロードし、Lambdaから読み込んだ段階では実行コンテナ上のディレクトリ構成が以下のようになってしまい、LinuxOSがフォントファイルを認識できるroot直下の.fontsディレクトリとして読み込めず、フォントファイルを認識できません。
[.fontsディレクトリ配下をLayerとして登録した場合の実行コンテナ上のディレクトリ構成]
1 2 3 4 5 |
. └── opt └── .fonts ├── ipaexg.ttf └── ipaexm.ttf |
上記、Layerとしてアップロードしたフォントファイルを認識できない問題を解決する為の解決策が次の対応です。
用意したLayerを実行コンテナ上のどのパスに配置するかはこちらでは選択不可なので、次の手段としてOSが認識する環境変数’HOME’にLayerが配置されるディレクトリパスのroot(/opt)を設定して、環境変数経由で /opt/.fonts 配下をフォントファイルとして認識可能にします。
環境変数の上書きは推奨されませんが、Lambdaの環境変数’HOME’はデフォルトでは未使用の為、値を入れても今まで認識できていたパスが認識できなくなることはありません
OSの環境変数を上書く方法は、Lambdaの環境変数に登録 or Lambdaの実行コードの初期化処理内で設定のどちらかが手軽です。
Lambdaの環境変数に登録する場合は、環境変数の追加から以下のような1行を追加します。
実行コード内で環境変数を上書く場合は、グローバルスコープ部分に以下のような1行を追加します。
1 |
os.environ['HOME'] = '/opt/' |
どちらかの方法で対応することでLayerとして登録したフォントファイルがLambdaの実行コンテナ上からフォントファイルとして認識可能になった為、再度スクレイピングを実行してスクリーンショットを撮影してみると、正しく日本語が認識できるようになっているのが確認できます。
[日本語の文字化けが解消した状態]
これで日本語文字化けも解消し、本当に本当で完了です。
お疲れ様でした!