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を必ず使っています。
*1: Herokuは"containers are not run with root privileges on Heroku"と述べています