寂しいクリスマスです。
Table of Contents
Lambda – Container Image Support
12/1 **Lambda – Container Image Support**が発表されました。
Lambda作成画面にもContainer Imageが出てきております。
発表当初はあまり注目してなかったのですがとある思いつきをしました。
**Selenium載っける運用なら案外使い勝手いいかもしれない?**と。
Container Image Supportで何がうれしいの?
Container Image SupportとはLambdaでDocker ImageからContainerが使えますよーということなのですが、使い所はあるのでしょうか?
色々メリットあると思いますが、私が思うに、
- イメージは10GBまでデプロイできる
- Lambda Runtime Interface Emulatorを使ってローカルで実行できる
の2点が嬉しい点だと思います。
**Seleniumでそんなに容量使うのか?**問題はありますが、機会学習の推論をLambdaで実行させる、とかだと容量の壁や、Cライブラリビルドのアーキテクチャ差分に苦しめられる煩わしいパッケージ導入もなくなるのでもしかしたら使えるのかもですね。
LambdaでSeleniumと言えばserverless-chromeですが
LambdaでSeleniumを動かすと言えばつい最近までserverless-chromeが有名です。
ようはLambdaのZIPパッケージ制限(一昔前はアップロード10MB, S3経由200MBだった)に引っかからないくらい小さくしたChromeをLambdaにパッケージ内包して、Seleniumのランナーから動かすわけです。
とはいえ、環境構築で一癖も二癖もあるserverless-chromeなので、今回は普通のChromeをheadless起動させるDockerコンテナを作ってそれをLambdaで起動していくことで簡単構築ができるのではないか、というのがこの検証の趣旨です。
今回やりたいこと、というか課題
話は変わりますが弊社にはSlackのWorkSpaceが乱立してます。
さらに勤怠連絡もSlackに書き込むのですが乱立したWorkSpaceにすべて書き込むのはちょっとめんどくさいです。
(以前はSlackのほかTeamsやらメールやら電話やらあらゆる媒体で一斉勤怠連絡しないといけなくてこれでも楽になりました。)
じゃあ、Slack APIとか使って、相互投稿とかして解決すればいいじゃんとなりそうですが、一部のWorkSpaceはセキュリティの観点から外部連携が禁止とのこと。なんじゃそりゃ...。
そこで、セキュア(笑)なSlackのスクリーンショットを取り、普段使っているSlackへ投稿する仕組みにすれば、少なくとも連絡はしたようなものなので、まぁ楽でしょう!ということで作っていきます。
Alpine Python:3.7
軽量イメージで有名なAplineをベースイメージにします。
はい、この選択肢はとある理由で間違いでした、がそれはこの先わかることです。
軽いから使う、という安直極まりない選定で言ってしまったのが後々後悔となりますので、皆さん、ちゃんと調べましょう。
Container Image Supportを使うにはRICが必要
Lambdaは起動する際にAWS基板側からAWS LambdaランタイムAPIでランタイムをキックすることで実現してます。
そうです。Container Image SupportといいつつもLambdaで実現する以上、こいつを受け取らないといけないのです。
なので単純にdocker runで起動するコンテナイメージを作るだけじゃLambdaには載っけられないので、AWS Lambda Runtime Interface Clients(RIC)というOSSがAWSから提供されてます。
例えばPythonであればAWS Lambda Python Runtime Interface Client があります。
こいつを介してLambda起動させるわけです。
こいつがやっかいでした。READMEにも書いてありましたが、インストールにLinuxのBuild tool類が必要で、結局Alpineの軽量さの恩恵を受けられませんでした。
とはいえ、落ち込んでいても仕方ないので、まずはSelenium Pythonが動く環境を作っていきます。
Dockerfileを使って、RIC関連とChrome, Chrome-driverを入れていきます。
FROM python:3.7-alpine
ENV PYTHONIOENCODING utf-8
WORKDIR /app
RUN apk add --update \
build-base \
libtool \
autoconf \
automake \
libexecinfo-dev \
make \
cmake \
libcurl \
wget \
bash \
which \
groff \
udev \
chromium \
chromium-chromedriver && \
pip install --target /app awslambdaric && \
pip install selenium
RICを入れるにはREADMEでは、g++、make、cmake、unzip、libcurl4-openssl-devがでしたが、それはUbuntuベースのイメージだったからで、Alpineでは、他に、autoconfやautomakeなど結構色々必要でした。めんどくさい...。
Collecting awslambdaric
Downloading awslambdaric-1.0.0.tar.gz (3.2 MB)
ERROR: Command errored out with exit status 1:
command: /usr/local/bin/python -c 'import sys, setuptools, tokenize; sys.argv[0] = '"'"'/tmp/pip-install-_vlp4rwk/awslambdaric_c160f1900b624d1a85d1135d75e3b6ef/setup.py'"'"'; __file__='"'"'/tmp/pip-install-_vlp4rwk/awslambdaric_c160f1900b624d1a85d1135d75e3b6ef/setup.py'"'"';f=getattr(tokenize, '"'"'open'"'"', open)(__file__);code=f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' egg_info --egg-base /tmp/pip-pip-egg-info-xp6t7j5r
cwd: /tmp/pip-install-_vlp4rwk/awslambdaric_c160f1900b624d1a85d1135d75e3b6ef/
Complete output (16 lines):
buildconf: autoconf version 2.69 (ok)
buildconf: autom4te version 2.69 (ok)
buildconf: autoheader version 2.69 (ok)
buildconf: automake not found.
You need automake version 1.7 or newer installed.
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "/tmp/pip-install-_vlp4rwk/awslambdaric_c160f1900b624d1a85d1135d75e3b6ef/setup.py", line 94, in <module>
ext_modules=get_runtime_client_extension(),
File "/tmp/pip-install-_vlp4rwk/awslambdaric_c160f1900b624d1a85d1135d75e3b6ef/setup.py", line 45, in get_runtime_client_extension
extra_link_args=get_curl_extra_linker_flags(),
File "/tmp/pip-install-_vlp4rwk/awslambdaric_c160f1900b624d1a85d1135d75e3b6ef/setup.py", line 18, in get_curl_extra_linker_flags
check_call(["./scripts/preinstall.sh"])
File "/usr/local/lib/python3.7/subprocess.py", line 363, in check_call
raise CalledProcessError(retcode, cmd)
subprocess.CalledProcessError: Command '['./scripts/preinstall.sh']' returned non-zero exit status 1.
----------------------------------------
ERROR: Command errored out with exit status 1: python setup.py egg_info Check the logs for full command output.
必要なパッケージが入ってないと**You need automake version 1.7 or newer installed.**のようにRICのインストールで怒られてしまいます。こんな罠があるとは..。
あれこれあれこれ試行錯誤し、うまくいけば次のようにRICのBuildが終わってめでたしめでたしです。
Building wheels for collected packages: awslambdaric, simplejson
Building wheel for awslambdaric (setup.py): started
Building wheel for awslambdaric (setup.py): still running...
Building wheel for awslambdaric (setup.py): finished with status 'done'
Created wheel for awslambdaric: filename=awslambdaric-1.0.0-cp37-cp37m-linux_x86_64.whl size=246020 sha256=4d2550bf826e2ad294aa0335eb87987b63d61d13aad8c06c189c080ff4479ac5
Stored in directory: /root/.cache/pip/wheels/f2/d6/df/40b746a2bdaca7ceec3244383e8e252c5a9f3870621fd68a37
Building wheel for simplejson (setup.py): started
Building wheel for simplejson (setup.py): finished with status 'done'
Created wheel for simplejson: filename=simplejson-3.17.2-cp37-cp37m-linux_x86_64.whl size=74647 sha256=ffca4c04bc4b3e577dcd91c83e01b3670d6274e1a7dde0177a699c7174a3c8f9
Stored in directory: /root/.cache/pip/wheels/e5/69/2c/bdcb34114373fc0dbb53242f5df4bf41bce149acac4f8160d0
Successfully built awslambdaric simplejson
また、肝心のChromeとChrome-driverはapkでそのまま入れればいいので実にこちらは簡単です。
apkからインストールすることで常に、stableでインストールするので、chromedriverとのバージョンを意識することもありません!れはありがたい。
Docker Imageを少しでも軽くする
こちらImage Buildし終わるとあら大変。せっかくのAlpineの41.1MBの軽量イメージが1.14GBと台無しになってしまいました。
# Alpine
python 3.7-alpine f82a49b6a141 10 days ago 41.1MB
# Selenium
selenium latest acc90965ec5b 30 hours ago 1.14GB
こちらなんとかするためにRICがインストールが完了したタイミングで、build関係のパッケージを同一レイヤーで消してしまいましょう。(別レイヤーにするとファイルの増減が記録されてしまうので注意)
RUN apk add --update \
# Add Dependencies for compile AWS Lambda ric
build-base \
libtool \
autoconf \
automake \
libexecinfo-dev \
make \
cmake \
libcurl \
wget \
bash \
which \
groff \
udev \
chromium \
chromium-chromedriver && \
pip install -r requirements.txt && \
pip install --target /app awslambdaric && \
rm /app/requirements.txt && \
apk del \
build-base \
libtool \
autoconf \
automake \
libexecinfo-dev \
make \
cmake \
libcurl \
wget \
bash \
which \
groff
ややこしいですが、こうすることで(後述する日本語フォントも入れながら)846MBまで削減できました。同一レイヤーでいらないものは消す。これ、偉い人とのお約束。
selenium latest 04304fcd4549 18 minutes ago 846MB
ついでに日本語フォントもインストールする
さて、Seleniumを使うときつきまとうのはフォント豆腐問題です。
日本語のようにASCII文字では表現できない文字は、対応するフォントがインストールされていないと文字の代わりに小さい四角(□)、通称豆腐が表示されることがあります。麻雀の白ではないですよ。というより豆腐という日本語が世界語になっているのはすごいですね。
で、話を戻しますが当然Alpineのような軽量Imageには日本語フォントなんて入ってません。
さて、フォント豆腐問題の解消によく使うのがGoogle Noto Fontsです。
No tofuでNotoらしいです。
ただし、配布されているNoto FontsのAll one packageをすべてを使うと例えばアラビア語(اللغة العربية)やサンスクリット語(संस्कृत)なんかも入ってしまい、計1.1GBになってしまいます。これは重い。
そこで、日本語フォントだけ利用したい場合、Noto Sans CJK JPとNoto Serif CJK JPを個別にダウンロードして/usr/share/fonts配下に展開することで容量を抑えることができます。どちらも130MBくらいです。
また、展開したフォントをシステムが使えるようにフォント展開後、fc-cacheも実行していきます。
ちなみにCJKというのはChinese, Japanese, Koreanのことらしいです。
ついでにASCIIフォントのfreefontもインストールします。
RUN apk add --update \
ttf-freefont \
freetype \
fontconfig \
mkdir noto && \
wget -P /app/noto https://noto-website.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip && \
wget -P /app/noto https://noto-website-2.storage.googleapis.com/pkgs/NotoSerifCJKjp-hinted.zip && \
unzip /app/noto/NotoSansCJKjp-hinted.zip -d /app/noto && \
unzip -o /app/noto/NotoSerifCJKjp-hinted.zip -d /app/noto && \
mkdir -p /usr/share/fonts/noto && \
cp /app/noto/*.otf /usr/share/fonts/noto && \
chmod 644 -R /usr/share/fonts/noto/ && \
rm -rf /app/noto && \
fc-cache -fv
これでフォント問題は解決...のはずですが、後半で「だめです」が出るので、さらに改良していくことになります。
Entrypoint他設定
Lambdaでの起動のため、DockerのEntrypointとCMDを設定します。
READMEに書いてあるとおり、EntrypointはawslambdaricをCMDは実際のLambdaが起動するときに動く関数を選択します。(ここでは、app.pyのhandler関数)
ENTRYPOINT [ "/usr/local/bin/python", "-m", "awslambdaric" ]
CMD [ "app.handler" ]
Seleniumが動くようにする
さてSeleniumのランナーに移っていきます。
さきほどコマンドでapp.handlerを指定したので、モジュール名はapp.py、関数名はhandlerでいきます。
また、apkでインストールしたChrome(Chromium)、Chrome-driverはそれぞれ**/usr/bin/chromium-browser** /usr/lib/chromium/chromedriverに存在してます。
まずはとりあえずSlackのログインページを開き、タイトルを表示します。次のように組んでいきます。
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
SLACK_LOGIN_URL = "https://xxxxx.slack.com"
chrome_path = "/usr/bin/chromium-browser"
chromedriver_path = "/usr/lib/chromium/chromedriver"
def handler(event, context):
o = Options()
o.binary_location = chrome_path
o.add_argument('--headless')
o.add_argument('--disable-gpu')
o.add_argument('--no-sandbox')
o.add_argument('--window-size=1920x1080')
d = webdriver.Chrome()
d.get(SLACK_LOGIN_URL)
print(f"PageTitle {d.title}")
Chromeの起動オプションに指定している**--headless**、--disable-gpu、--no-sandboxはいずれもLambdaでのSelenium起動には必須のオプションで、それぞれ**--headlessはヘッドレス起動(ディスプレイやキーボード、マウスなどの入出力機器を接続しない状態をヘッドレスと言います)、--disable-gpuはGPU無効(--headlessオプションと併用必須らしい)、--no-sandbox**はサンドボックス起動無効化のことらしいです。
ちなみに、Chrome(Chromium)はセキュリティ向上のため、レンダリングやスクリプトエンジンをSandboxというchrootで隔離された環境で動かすそうです。
Dockerコンテナ内でchrootをするにはdocker側でそのホストのすべてのデバイスへのアクセスを許可する必要があり、–privilegedオプションが必要となります。
ただ、Lambdaではこちらは許容されないため、–no-sandboxをつけてsandboxを無効化しなければいけません。脆弱性を突かれるとアプリのソースにアクセスされてしまう可能性は残りますが...。
さて、こちらのコードでLambdaにイメージをデプロイして起動して見ると次のようなエラーを吐き出し動きませんでした..。
File "/app/python/lib/python3.6/site-packages/selenium/webdriver/remote/errorhandler.py", line 242, in check_response
raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.WebDriverException: Message: unknown error: Chrome failed to start: crashed.
(unknown error: DevToolsActivePort file doesn't exist)
(The process started from chrome location /usr/bin/chromium-browser is no longer running, so ChromeDriver is assuming that Chrome has crashed.)
Chromeがクラッシュしてしまった、という意味合いとはわかりますがどうにもわかりません。エラーがわかりにくいのがSeleniumの悪いところです。
--disable-dev-shm-usage
さらに調べていくと次のようなことがわかりました。--disable-dev-shm-usageというオプションが必要だったようです。
Chromeではキャッシュ用にtmpfs(/dev/shmファイルシステムレベルの共有メモリ)を利用しているのですがLambdaがこちら64MBしかないので落ちてしまうそうです。なので、キャッシュは/tmp(ディスク)を利用してもらうべく**--disable-dev-shm-usage**も追加する必要があります。
ちなみに、Lambdaの/tmpは最近調べたのですが512MBくらいありました。
def handler(event, context):
o = Options()
o.binary_location = chrome_path
o.add_argument('--headless')
o.add_argument('--disable-gpu')
o.add_argument('--no-sandbox')
o.add_argument('--window-size=1920x1080')
o.add_argument('--disable-dev-shm-usage')
d = webdriver.Chrome()
d.get(SLACK_LOGIN_URL)
print(f"PageTitle {d.title}")
こちらでエラーは出なくなりましたが、5分でタイムアウトしてしまいました。
スピードアップはChrome DriverService
と勝手に聞いてなんとなく納得してしまったのでChrome DriverServiceを使うように書き直したらちゃんとうごきました。なぜなんでしょうねぇー。
def handler(event, context):
o = Options()
o.binary_location = chrome_path
o.add_argument('--headless')
o.add_argument('--disable-gpu')
o.add_argument('--no-sandbox')
o.add_argument('--disable-dev-shm-usage')
o.add_argument('--window-size=1920x1080')
print("Start Chrome Session")
s = Service(executable_path=chromedriver_path)
s.start()
d = webdriver.Remote(
s.service_url,
desired_capabilities=o.to_capabilities()
)
d.get(SLACK_LOGIN_URL)
print(f"PageTitle {d.title}")
Slackにログインして勤怠チャンネルのスクリーンショットを撮る
さて、Seleniumも無事動いたので、Slackにログインして勤怠チャンネルをパシャっと撮りましょう。
まずはログインページアクセスですが、これはブラウザでhttps://slack-workspace-url.slack.comにアクセスしますと、Eメールとパスワードを聞かれるフォームが出てきます。
こちら確認してみると、Elementのidがそれぞれ、email, passwordとなっております。また、Sign inボタンはID signin_btnとなっております。ありがたいですね。
ということで、idがemailのElementが描画されたらEメール、パスワードを入力し、ボチッとSign inボタンをクリックします。
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions
(中略)
wait = WebDriverWait(d, 10)
email = wait.until(expected_conditions.visibility_of_element_located((By.ID, "email")))
email.send_keys(tubone@email.com)
email = d.find_element(by=By.ID, value="password")
email.send_keys("hogehoge")
signin_btn = d.find_element(by=By.ID, value="signin_btn")
signin_btn.click()
d.implicitly_wait(10)
print(f"PageTitle {d.title}")
WebDriverWaitで最大待機秒数を設定後、expected_conditions.visibility_of_element_locatedで特定の要素が描画されるまで待つようにすることで明示的に要素の描画を待つことが実現できます。
sleep(10)とかよりも効率的で安全ですね。
また、implicitly_waitを使うことで何かしらの要素が描画されるまで待つ、みたいなもうちょっと曖昧なこともできます。
ともあれ、これでログインが無事できました。
ログインしたらチャンネルのURLにアクセスします。チャンネルのURLは、
https://app.slack.com/client/xxxxxxxxx/yyyyyyyyy
みたいな感じでyyyyyyがチャンネルと対応してます。あらかじめひかえておきます。
スクリーンショットはsave_screenshotを使えば画面のPNGが撮れます。
ここで少し詰まったのは、Lambdaの場合、/tmpディレクトリしか書き込み権限がないということです。ちょっとハマりました。
/app配下にスクリーンショット吐き出そうとしたらIO Errorになってうまく吐き出せませんでした。
print(d.save_screenshot("/tmp/screen.png"))
ちなみに、save_screenshotの戻り値はboolとなっており、Trueは成功、Falseは失敗です。書き込みに失敗してもExceptionはraiseしないので、Python scriptが進んでいってしまい、後半のSlackアップロードでno such fileになってしまいました。難しい。
Slackにアップロードする
アップロードはSlack APIのfile.uploadを使いました。
あらかじめTOKENとPermissionを設定しておきましょう。
Slack Appを作ったらSlackAPIのOAuth&Permissionsから確認できます。
Permissionsはchannels:join、chat:write、chat:write.public、files:writeがあれば十分だと思います。
url = "https://slack.com/api/files.upload"
data = {
"token": SLACK_TOKEN,
"channels": SLACK_CHANNEL_ID,
"title": "attend bot",
"initial_comment": f"({d.title})のattend状況です"
}
files = {"file": open(FILENAME, "rb")}
print(f"Upload To Slack")
resp = requests.post(url, data=data, files=files)
ほとんどモザイクで申し訳ないですが、きっちりSlackにスクリーンショットを投稿できました。
一部フォントが豆腐のままになる
確かにうまく言ったのですが、Noto fontsを入れているにも関わらず一部フォントが豆腐のままになってしまう事象が起きてしまいました。
12/24なにがあるんでしょうねぇ...
色々調べて、fontconfigでNoto fontを強制適用したり色々しましたがどうにもうまく行かず、しょうがないので日本語フォントの大御所IPAフォントを入れることにします。
そう言えば、IPAフォントは一般社団法人文字情報技術促進協議会に移管されたようですね。
AlpineでIPAフォントを使うには、
RUN apk add font-ipa
でいいはずなのですが、次のようにパッケージが見つからない警告が出てうまくいきませんでした。
---> Running in 1098a4580fb6
fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/community/x86_64/APKINDEX.tar.gz
ERROR: unsatisfiable constraints:
font-ipa (missing):
required by: world[font-ipa]
仕方がないので、OSDNからTrueType Fontをダウンロードして、それをImageにADDします。
IPA Fonts/IPAex Fonts 4書体パック_IPAフォント(Ver.003.03)を使わせていただきました。ADDコマンドで/usr/share/fonts/TTF/配下にTTFファイルを展開して、fc-cache -fvです。
ADD ./font/*.ttf /usr/share/fonts/TTF/
RUN fc-cache -fv
これでフォント豆腐問題は解消しました。やれやれ。
Slackサインイン警告がでる
もう1つ問題になったのは、LambdaのグローバルIPを固定化していなかったため、変なIPアドレスからログインを実行したという警告がでてしまうことです。
こちらは解決法があり、要はLambda in VPCにしてNatGatewayにEIPを当てて、固定IPからインターネットアクセスをさせてあげればいいわけです。
Container Image Supportとはいえ、ここらへんの仕組みはいつも使っているLambdaなので簡単に実現できました。
Terraform化する
さぁ、すべての舞台が整ったので、Terraform化していきます。
Resource: aws_lambda_functionも対応しているため、そこまで大変さはありませんでした。ありがとうございます。
Container supportの場合はpackage_typeをImageにしてimage_uriをECRにプッシュしたイメージURIにします。
イメージURIはECRのコンソール画面からでも確認できますが、CIに乗っけるなど考えた場合ちょっと取り回し悪いので、AWS CLI describe-imagesコマンドでimageDigestが確認できますので、Terraform実行時にShellで取得し、動的生成し、terraform apply -var=hogeの形式で変数として渡してやります。
aws ecr describe-images --repository-name selenium --image-ids imageTag=latest | jq ".imageDetails[].imageDigest"
"sha256:7ec3ea6afd616c09b291c22e8e7676a8855e05beb1d4ea19af1abc6865e6fe6d"
通常のLambdaで必要になるhandlerやruntimeの設定は不要です。Terraformドキュメント上、handlerは必須となっておりますが、不要です。
IntellJ HashiCorp Terraform / HCL language supportを使っているとhandlerとruntimeオプションが無いよ!と怒られてしまいますが、構わず不要で大丈夫です。
完成
ということで完成しました。
https://github.com/tubone24/lambda_container_support_with_selenium
いろんな技術でうまく行かないことが多かったですが、やりたかったことが無事できました。