Webアプリ組み込み目的でSelenium WebDriver + Headless Chromium/Firefoxを使うときの雑多な知見

魚類は一般には変温動物とされますが、マグロなど一部の魚は体温を一定に保ち活動能力を維持する仕組みがあり事実上の恒温動物としての特徴をもつこともあるのだそうです。

PythonベースのWebアプリにHeadless Chrome(Chromium)やHeadless Firefoxを組み込みSeleniumでこれらを制御しようとしたとき、細かいことにいろいろとハマってしまいました。公式サンプルあるいはQiita記事などを含め、1つの例で自分の手元の環境でちゃんと動くようなものがまとまっているものがなかったので、知見をまとめました。
HerokuあるいはDokkuのような環境、すなわちUbuntuベースでDockerベースであり、計算リソースが比較的貧弱な環境を想定していますが、開発用にMacでも確認しています。

Headless Firefox

環境構築
Firefox

ブラウザ本体としてFirefoxが必要です。またFirefoxを制御するために、Selenium WebdriverのバックエンドとしてFirefox Geckodriverが必要です。
ネットにある解説だとどちらも手作業で入れている例が多いですが、バージョンにこだわりがない場合、Ubuntuのパッケージで最新版が入れられるのでそれで十分です(むしろ、Firefoxとgeckodriverのバージョンのミスマッチで変にハマるのを避けられるので、こだわりがなければそうしたほうがよいみたいです)。

  • firefox (73.0.1 – 2020年3月現在)
  • firefox-geckodriver (0.26.0 – 2020年3月現在)

geckodriverとFirefoxのバージョン対応関係はこちらで確認できます。
Supported platforms — Mozilla Source Tree Docs 75.0a1 documentation

Macの場合は、Firefoxは普通にウェブブラウザとして常用するときと同じようにインストールし、geckodriverはGithubからダウンロードしたtarを展開し、PATHの通っている場所に置けばOKです。
https://github.com/mozilla/geckodriver/releases

フォント

Headless Firefoxを使って何をしたいかによりますが、例えばスクリーンショットなどを撮るなどしたい場合は日本語フォントを入れないと、画像の中の日本語文字が全て豆腐になります。
こちらもUbuntuパッケージでなにか適当な日本語フォントセットを入れておけばOKです。

  • fonts-takao
Python

pipでseleniumが入っていればOKです。上記の環境設定で基本的に最新のFirefox/geckodriverが入るので、seleniumもたぶん新しめのやつにしておいたほうがよさそう。

Python + Selenium WebDriver + Headless Firefoxのスニペット

from selenium import webdriver
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.firefox.firefox_binary import FirefoxBinary

# Firefoxバイナリへのパスは、フルパスである必要がある(PATHが通っていても関係ない)
# Macの場合は/Applications/Firefox.app/Contents/MacOS/firefox
binary = FirefoxBinary('/usr/bin/firefox')

opts = Options()
# Firefoxがクラッシュするなど、デバッグ時に必要
# opts.log.level = "trace"
opts.headless = True
driver = webdriver.Firefox(firefox_binary=binary, options=opts)

市井に出回る例とはいくつか異なる点があります。
詳細をまとめます。

Headlessモードの指定方法

Headlessモードは “opts.headless = True” で行わないといけないようです。binary.add_command_line_options(‘-headless’) とする例が多いですが、それはNGのようです。
ちゃんと-headlessオプションがFirefoxに渡されてない。
するとFirefoxは画面を開こうとするが、当然X環境はないので死ぬ

こういうときは、”can’t kill an exited process”というようなエラーが出ます。

  ...
  File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/remote/webdriver.py", line 157, in __init__
    self.start_session(capabilities, browser_profile)
  File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/remote/webdriver.py", line 252, in start_session
    response = self.execute(Command.NEW_SESSION, parameters)
  File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/remote/webdriver.py", line 321, in execute
    self.error_handler.check_response(response)
  File "/app/.heroku/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: invalid argument: can't kill an exited process

このようなエラーを出す原因は他にもあるので原因特定がとても大変だったのですが、後述の方法でログを確認することで特定できました。。参考までに。

Firefoxがクラッシュする場合はgeckodriverのログレベルを上げる
opts.log.level = "trace"

としている部分で、Firefoxのログをカレントディレクトリのgeckodriver.logに吐くようにできます。
Enabling trace logs — Mozilla Source Tree Docs 75.0a1 documentation

例えば上記のHeadlessモード指定方法が間違っている場合は、このようなエラーが出ます。
DISPLAY環境変数がないこと(つまりウィンドウを作ろうとして失敗してること)、さらによく見ると-headlessがfirefoxに渡ってないことがわかります。

1583562780591   mozrunner::runner       INFO    Running command: "/usr/bin/firefox" "-marionette" "-foreground" "-no-remote" "-profile" "/tmp/rust_mozprofileQAp1Y8"
1583562780592   geckodriver::marionette DEBUG   Waiting 60s to connect to browser on 127.0.0.1:42391
Error: no DISPLAY environment variable specified
1583562780902   mozrunner::runner       DEBUG   Killing process 210
1583562780902   webdriver::server       DEBUG   <- 500 Internal Server Error {"value":{"error":"unknown error","message":"invalid argument: can't kill an exited process","stacktrace":""}}

失敗ログだけでなくFirefoxの挙動が全てログに吐かれるので、プロダクションでは基本的にOFFにしておかないとディスクを食いつぶすことになるので注意。

Headless Chrome(Chromium)

環境構築

Firefoxの場合と同じで、chromiumおよび必要に応じて日本語フォントをUbuntuのパッケージで入れます。

  • chromium-chromedriver (80.0.3987.87 – 2020年3月現在)
  • fonts-takao

Macの場合はブラウザを普通に常用するときと同じようにインストールし、chromedriverをbrew cask install chromedriverで準備すればOKです。

Chrome Embedded Frameworkという、ちょうどSafariに対するWebKitにあたるようなものがあるらしく、たぶん技術的にはそれでWebKitに対するPhantomJSにあたるようなheadlessブラウザを作れるんじゃないかと思っていますが、詳細は不明。

Python + Selenium WebDriver + Headless Chromiumのスニペット
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options()
options.add_argument('--headless')
options.add_argument('--disable-gpu')
options.add_argument('--disable-dev-shm-usage')
options.add_argument('--no-sandbox')

driver = webdriver.Chrome('chromedriver', chrome_options=options)

こちらも、Qiita記事などにある例と少し違う点があります。

chromedriverのパス

フルパスで指定している例が比較的よく見つかりますが、(Firefoxの例と異なり)chromedriverについてはPATHが通っていればフルパスで指定しなくてよいようです。Ubuntuパッケージで入れた場合は勝手に/usr/binに入ります。

–no-sandbox

Chrome(Chromium)ではセキュリティ上の理由で、レンダリングやスクリプトエンジンはSandboxというchrootで隔離された環境で動かされます。Dockerコンテナ内でchrootをするにはdocker側での操作(–privileged)が必要ですがこれは一般的でないので(*1)、結局普通にchromeを実行することができません。
そのような状況では–no-sandboxをつけてsandboxを無効化しなければいけません。
悪意のあるコードに脆弱性を突かれるとアプリのソースコードなどにアクセスされてしまう可能性はありますね。

Python側でのエラーはこんなかんじ。

  ...
  File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/chrome/webdriver.py", line 81, in __init__
    desired_capabilities=desired_capabilities)
  File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/remote/webdriver.py", line 157, in __init__
    self.start_session(capabilities, browser_profile)
  File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/remote/webdriver.py", line 252, in start_session
    response = self.execute(Command.NEW_SESSION, parameters)
  File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/remote/webdriver.py", line 321, in execute
    self.error_handler.check_response(response)
  File "/app/.heroku/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.)

このエラーだけではナンノコッチャわからんのですが、Python経由でなくシェルで直接chromeを動かしてみるとちょっとだけ情報が増えます。このコマンドの引数の–no-sandboxをつけると動くようになります。

$ chromium-browser --headless --disable-gpu https://www.renofa.com
Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
Trace/breakpoint trap (core dumped)

参考:Google Chromeを使ってスクリーンショットの取得を自動化する – おれさまラボ

–disable-dev-shm-usage

これを指定して/dev/shmを使わないようにしないと、Herokuのようなメモリの限られた環境では何らかのサイトを開いたとき、メモリを食いつぶして死ぬという問題が起こります。サイトのコンテンツによって起こらなかったりするので、厄介でした。
この問題もPythonサイドのエラーはこんなかんじでナンノコッチャわからん。

  ...
  File "<string>", line 3, in <module>
  File "/app/.heroku/python/lib/python3.6/contextlib.py", line 81, in __enter__
    return next(self.gen)
  File "/app/libs/scraping/utils.py", line 73, in screenshot
    with chrome(url, window_size=window_size) as driver:
  File "/app/.heroku/python/lib/python3.6/contextlib.py", line 81, in __enter__
    return next(self.gen)
  File "/app/libs/scraping/utils.py", line 51, in chrome
    driver.close()
  File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/remote/webdriver.py", line 688, in close
    self.execute(Command.CLOSE)
  File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/remote/webdriver.py", line 321, in execute
    self.error_handler.check_response(response)
  File "/app/.heroku/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.InvalidSessionIdException: Message: invalid session id

同様にPythonからでなく直接shellでchromiumをheadlessで走らせるとエラーが理解できます。

$ chromium-browser --headless --disable-gpu https://www.renofa.com
...
[0307/065140.096622:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.097729:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.099025:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.099592:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.099927:FATAL:memory.cc(22)] Out of memory. size=262144
[0307/065140.110096:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.110982:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.124425:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.131857:ERROR:broker_posix.cc(46)] Received unexpected number of handles
...
[0307/065140.136192:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.136935:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.142018:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.142645:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.143168:ERROR:broker_posix.cc(46)] Received unexpected number of handles
[0307/065140.320535:ERROR:headless_shell.cc(398)] Abnormal renderer termination.

/dev/shmを拡張してもいいと思いますが、プロダクションサーバなどではそのようなコントロールの効かない環境が多いと思うので、素直に–disable-dev-shm-usageをするのがよいと思います。

参考:Headless Chromium on Docker fails – Stack Overflow

WebDriver共通の話

WebDriverを作った後、後始末をcloseでするかquitでするか、どちらでするべきかよくわかっていませんでした。
こういうことだそうです。
Difference between driver.close() and driver.quit() in selenium webdriver
Webアプリのバックエンドとして例えばJSによるDOM操作後のHTMLを取得したりスクリーンショットを撮ったりするような目的であれば、quitでブラウザセッションを皆殺しにするのでよさそう。

メモリ消費のベンチマーク

Herokuのような環境でheadlessブラウザを動かすとき気になるのがメモリ消費です。
memory_profilerを使って、Pythonインタプリタを含む全体について簡単なメモリ計測をしてみました。

Firefox/Chromeともに上記のスニペットを使ってSelenium WebDriverを作ったあとウェブサイトを開いてスクリーンショットをPNGで撮影するようなコードについて、0.1秒ごとに測定したメモリ消費(MB単位)のピークを雑に比較してみました。いくつかのテキトーなウェブサイトについて、それぞれ10回の平均と最大で測っています。

サイト Average (Chromium) Max (Chromium) Average (Firefox) Max (Firefox)
https://www.google.com/ 389.5 390.6 621.5 634.2
https://www.renofa.com/ 609.4 622.5 754.2 782
https://www.jleague.jp/ 675.9 683.6 797.6 813.4
https://www.rakuten.co.jp/ 654.8 691.9 779.8 797.2
https://ja.wikipedia.org/ 395.1 398.4 639.4 640.8

全体的にChromeのほうがコンスタントにピークメモリ消費が少ない様子。

また、ベースのメモリ消費というか下限値を知りたかったため、WebDriverを単に初期化しただけ(特定のサイトを開かずabout:blankなWebDriverを開いた=つまりブラウザを起動しただけ)ときのメモリ消費はこんなかんじでした。同じくMB単位です。

ブラウザ メモリ(Average) メモリ(Max)
Firefox 559.9 568.6
Chromium 269.3 337.6

時間のベンチマーク

WebDriverの初期化、ページの読み込み、スクショの撮影、Driverを破棄する、という一連の処理にかかる時間を同様に測ってみました。こちらはPythonのtimeで測っていて、Pythonインタプリタの起動時間などは含んでいません。同様に10回の平均と最大を秒単位でまとめています。

なおテスト環境はConoha VPSの東京DCの1GB/SSD50GB/100Mbpsプランです。

サイト Average (Chromium) Max (Chromium) Average (Firefox) Max (Firefox)
https://www.google.com 2.15秒 3.18秒 8.10秒 12.89秒
https://www.renofa.com 7.02秒 8.17秒 15.57秒 17.54秒
https://www.jleague.jp/ 6.26秒 6.82秒 20.91秒 37.79秒
https://www.rakuten.co.jp/ 19.06秒 20.17秒 14.42秒 25.21秒
https://ja.wikipedia.org/ 2.49秒 2.62秒 8.35秒 14.72秒

全般的にChromiumのほうが顕著に速い一方で、楽天のページロードだけやたらめったらFirefoxのほうが速いですね。

また、こちらも同様に単にWebDriverを開いただけウェブサイトを開かない、about:blankなWebDriverを開いて閉じるまでの時間はこんなかんじでした。

ブラウザ 平均時間 最大時間
Firefox 5.73秒 6.71秒
Chromium 1.32秒 1.36秒

こちらはメモリよりも遥かに顕著な差で、ChromiumなWebDriverの起動が速いという結果に。
ただいずれにしても、頻繁にWebDriverを利用するような用途では毎回WebDriverの初期化からするのは五十歩百歩で非効率なので、web driverの使いまわしなどをして効率化したほうがよいでしょう。

純粋にページをロードしレンダリングしスクリーンショットを撮る部分の時間も測ってみました。web driverを使いまわして初期化時間を節約しても1ページを読み込むのに避けられない時間です。キャッシュは効かなくしてあります。基本的には、上記の「全てを含んだ時間」から「driverのみを初期化・破棄するのにかかる時間」を引いたものに相当しますが、Pythonのtime.time()で実測したものです(10回の平均と最大)。

サイト Average (Chromium) Max (Chromium) Average (Firefox) Max (Firefox)
https://www.google.com/ 0.59秒 0.68秒 0.70秒 0.88秒
https://www.renofa.com/ 4.81秒 5.04秒 7.90秒 12.65秒
https://www.jleague.jp/ 5.02秒 5.37秒 5.90秒 14.19秒
https://www.rakuten.co.jp/ 17.39秒 17.71秒 4.29秒 6.22秒
https://ja.wikipedia.org/ 1.09秒 1.19秒 1.30秒 1.38秒

なんで楽天のサイトを開くときだけFirefoxがめちゃくちゃ速いんだろう。

もちろんブラウザ起動・ロード・レンダリングの時間やメモリ消費はその時のコンテンツによるしWebブラウザは性能面で改善が著しい今日このごろなので、あくまで参考程度に。ですが、総合的にみてWebアプリに組み込む目的であればHeadless Chromiumを使うほうが適しているといっても差し支えないと思います。

あと、Firefoxはちょびっとだけ不安定感があります。まれにこんなエラーが出てレンダリングができなかったりとかしました。

selenium.common.exceptions.WebDriverException: Message: [Exception... "Data conversion failed because significant data would be lost"  nsresult: "0x80460003 (NS_ERROR_LOSS_OF_SIGNIFICANT_DATA)"  locat
ion: "JS frame :: resource://gre/modules/AsyncShutdown.jsm :: observe :: line 551"  data: no]

個人的には、常用ブラウザとしては縦タブ機能が実装可能なことやデベロッパーツールが使いやすいことからFirefoxを必ず使っています。

コメントを残す

メールアドレスが公開されることはありません。