ヘッドレスにした途端、スクリーンショットが豆腐だらけになる
ローカルのデスクトップ環境で作ったSeleniumスクリプトは問題なく動くのに、同じコードをLinuxサーバーやDockerコンテナ上でヘッドレスChromeとして動かした途端、スクリーンショットを開くと日本語のテキストが全部「□□□」のような四角い記号(いわゆる豆腐)に置き換わっている、ということがあります。文字コードの設定を疑ってEncoding.default_externalを見直したり、HTTPレスポンスヘッダーのcharsetを確認したりしても解決せず、原因がなかなか特定できません。
もう一つ別の症状として、同じページを取得しているはずなのに、環境によって要素の位置がずれてスクリーンショットの構図が変わったり、座標をもとにクリックしている処理が誤作動したりすることもあります。どちらも「ローカルでは再現しないのにサーバーだけで起きる」という点が共通していて、原因を切り分けるまでに時間がかかりがちな問題です。
原因1: サーバーに日本語フォントが入っていない
文字化けの正体は、ほとんどの場合エンコーディングの問題ではなく、サーバー上のOSに日本語を表示できるフォントが1つも入っていないことです。Chromeは指定されたフォントが見つからないと、文字を表示できる代替フォントを探しますが、日本語フォントが存在しない最小構成のLinuxイメージでは代替候補すらなく、結果としてグリフのない豆腐が描画されます。
対処はシンプルで、日本語対応のフォントパッケージをインストールするだけです。Debian系であればNoto Sans CJKを入れるのが手軽です。
apt-get update && apt-get install -y fonts-noto-cjk fontconfig
fc-cache -f -v
ここで意外と忘れがちなのが、フォントを入れただけでは足りず、fc-cacheでフォントキャッシュを更新する必要がある点です。パッケージのインストール直後にヘッドレスChromeを起動すると、キャッシュが古いままで新しいフォントを認識しないことがあるため、インストールとキャッシュ更新をセットで行う運用にしています。
フォントを入れても、読み込みタイミングでずれることがある
フォントを導入した後も、ページを開いた直後にスクリーンショットを撮ると、Webフォントの読み込みが終わっていないタイミングと重なって一瞬だけ代替フォントで描画されることがあります。これはブラウザ側のdocument.fontsというAPIで読み込み完了を検知できるので、スクリーンショット前にフォントの準備が整うまで待つ処理を挟んでおくと安定します。
def fonts_ready?(driver)
driver.execute_script("return document.fonts ? document.fonts.status === 'loaded' : true")
end
def wait_for_fonts_ready(driver, timeout_seconds: 5.0, interval: 0.2)
deadline = Time.now + timeout_seconds
sleep interval until fonts_ready?(driver) || Time.now > deadline
end
document.fontsが存在しない古い環境ではtrue扱いにして処理を止めないようにし、あくまで「待てるなら待つ」という補助的な位置づけにしています。
原因2: ウィンドウサイズを指定しないと環境ごとにレイアウトが変わる
もう一つの症状であるレイアウト崩れは、Selenium::WebDriver::Chrome::Optionsにウィンドウサイズを何も指定していないことが原因であるケースがほとんどです。ヘッドレスChromeはウィンドウサイズを明示しない場合のデフォルト値がバージョンや実行環境によって変わることがあり、同じコードでもマシンが変わるとビューポートの幅や高さが違う状態でページが描画されます。
レスポンシブデザインのページでは、ビューポート幅が変わるとレイアウトが横並びから縦積みに切り替わったり、要素の座標そのものが変わったりします。その結果、「決まった位置にある要素をクリックする」「決まった座標を切り抜いてスクリーンショットにする」といった処理が、実行環境によって成功したり失敗したりするようになります。
対処は--window-sizeをオプションで明示することです。
options = Selenium::WebDriver::Chrome::Options.new
options.add_argument("--window-size=1280,1696")
サイズを固定した上で、念のため実際に反映されたビューポートのサイズを取得して確認するようにすると、設定漏れや反映失敗にすぐ気づけます。
def verify_viewport_size(driver, expected_width)
actual_width = driver.execute_script("return window.innerWidth")
return true if actual_width == expected_width
warn "ウィンドウ幅が想定と異なります(期待値:#{expected_width}, 実際:#{actual_width})。座標がずれている可能性があります。"
false
end
Linux/Dockerで起動直後に落ちる場合は–disable-dev-shm-usage
ウィンドウサイズと合わせて、Dockerコンテナ上でヘッドレスChromeがそもそも起動に失敗したり、途中でクラッシュしたりする場合によく効くのが--disable-dev-shm-usageです。Chromeは内部的に/dev/shmという共有メモリ領域を一時ファイルの置き場として使いますが、Dockerのデフォルト設定では/dev/shmの容量がかなり小さく制限されています。ページの描画データがこの容量を超えると、Chromeがメモリ不足を起こしてレンダラープロセスごと落ちてしまいます。
options.add_argument("--disable-dev-shm-usage")
このオプションを付けると、/dev/shmの代わりにディスク上の一時ディレクトリを使うようになり、容量制限による突然のクラッシュを避けられます。コンテナ起動時に--shm-sizeを大きくする対処法もありますが、実行環境ごとにDocker起動オプションを揃えるよりも、Chrome側のオプションで完結させておくほうが運用は楽になります。
これらを1つのoptionsビルダーにまとめる
フォント、ウィンドウサイズ、/dev/shm対策はいずれも「ヘッドレスで動かす前提なら毎回必要になる設定」なので、個別のスクリプトにばらばらに書くのではなく、共通のヘルパー関数にまとめて呼び出すようにしています。
def build_headless_chrome_options(window_size: "1280,1696")
options = Selenium::WebDriver::Chrome::Options.new
options.add_argument("--headless=new")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=#{window_size}")
options.add_argument("--lang=ja-JP")
options.add_argument("--force-color-profile=srgb")
options
end
def build_driver_for_headless_batch(window_size: "1280,1696")
options = build_headless_chrome_options(window_size: window_size)
driver = Selenium::WebDriver.for(:chrome, options: options)
width, _height = window_size.split(",").map(&:to_i)
driver.get("about:blank")
wait_for_fonts_ready(driver)
verify_viewport_size(driver, width)
driver
end
--lang=ja-JPはメニューなど一部のブラウザ内表示を日本語ロケールに合わせるためのもので、--force-color-profile=srgbはヘッドレス環境でカラープロファイルが未設定のためにスクリーンショットの色味が微妙にずれることがあるのを防ぐために加えています。1か所にまとめておくことで、新しいバッチを書くたびに同じオプションを書き写す必要がなくなり、どれか1つの設定を見直すときも修正箇所が1つで済みます。
まとめ
- ヘッドレスChromeでの文字化けは、多くの場合エンコーディングではなくサーバーに日本語フォントが入っていないことが原因
fonts-noto-cjkなどを導入し、fc-cacheでキャッシュも更新する。スクリーンショット前にdocument.fontsでフォント読み込み完了を待つとより安定する- ウィンドウサイズを指定しないと環境によってデフォルトが変わり、レイアウトや要素座標がずれる原因になる。
--window-sizeで明示し、可能なら実際のビューポート幅を取得して確認する - Dockerで起動直後に落ちる場合は
--disable-dev-shm-usageを付け、/dev/shmの容量制限によるクラッシュを避ける - これらのオプションは共通のビルダー関数にまとめておくと、設定漏れがなくなり保守もしやすくなる


コメント