ガラシのパルプンテ頼み

地方産限界エンジニアのグローバル独り言

M1Mac + Docker環境でGroverを用いたPDF生成をするまでがまじでしんどかった話 | Rails外伝

概要

実務にて特定のwebページのPDF化およびダウンロード機能を実装することとなり、いくつかあるライブラリの中から今回は「Grover」というGemを採用することとなりました。

導入に差し当たり公式のドキュメントを参照しながらDockerで構築済みのローカル環境にGemをインストールし、ドキュメントの内容に沿って動作を確認していく際、PDFの生成に至るまでにかなりハマってしまったためその備忘録として本記事を残します。

まず具体的な対応手順の前に、前提条件となる環境や今回関連するライブラリ群に関する説明をそれぞれ簡単に行っていきます。

開発環境

macOS BigSur ver 11.6

MacBook Air (M1, 2020)

Docker Desktop on Mac(Mac with Apple chip)ver 4.2.0

Groverとは

後述するNode.js製のブラウザ自動操作ライブラリであるPuppeteerの操作をRubyライクで書けるようにしたGemで Groverはざっくりと以下のようなことを行います。

  • アプリケーションで生成したHTML文字列をPuppeteerに受け渡す
  • Puppeteerに対して、出力するファイル形式やスタイルの設定やメディアタイプ等の指定

github.com

Puppeteerとは

端的に表現するとHeadlessChromeを利用するためのブラウザ自動操作ライブラリで、類似ライブラリとしてはCapybaraやselenium-webdriverなどがあります。

Google製のWebブラウザであるChromeは、画面を持たずコマンドラインやリモートデバッグ機能を通じてWebブラウザを操作できるHeadlessChrome機能を備えており、PuppeteerはこのHeadlessChromeをサーバサイドで実行されるNode.jsのJavaScriptから自動操作できるようにしたライブラリです。

Puppeteerを利用するとユーザーがWebブラウザをマウスやキーボードで操作することなく、プログラムから特定のWebページを読み込んだり、画面キャプチャを取得したり、指定された場所をクリックし、値を入力し結果を取得するといった操作を自動的に行わせることが可能となります。

github.com

Grover, Puppeteer, Headless Chromeそれぞれの役割

それぞれの役割と関係性は以下の通りです。

ライブラリ名 役割
Grover Puppeteerで実行可能なブラウザの自動操作オプションをRubyから実行できるようにする
Puppeteer Groverから渡ってきた命令を元にHeadlessChromeを操作
HeadlessChrome 渡ってきたHTMLを描画してPDF、PNG、JPG等のファイルを生成する

今回のハマりポイント

M1 Mac上のDockerでGoogle Chromeが動作しない

ここまでは前提となるGrover、Puppeteer、HeadlessChromeの役割と関係性を整理してきました。

次に今回自分が沼ったポイントについて。Docker Desktop on Mac(Mac with Apple chip)で作成したローカル環境で、Groverを使うにあたり一番ネックとなった問題。それはPuppeteerのインストール時にバンドルされるGoogle Chromeでした。

具体的なエラーの内容としては以下の通り。Groverの公式ドキュメントに沿ってyarnからPuppeteerのインストールを実行したところ、以下のエラーが発生しPuppeteerの導入が行えない状況に直面しました。

The chromium binary is not available for arm64:
If you are on Ubuntu, you can install with:

 apt-get install chromium-browser

Chromiumのバイナリはarm64では利用できないため、もしUbuntuを使っているならaptでChromiumベースのブラウザをインストールして欲しいとのこと。

エラーの原因についてはあくまで推察となってしまうのですが、実装当時(2022年11月時点)そして本記事執筆時点でもどうやらarm64 x Linux向けのGoogle Chromeが存在しておらず、Puppeteerのインストール時にバンドルされてきた、x_86向けのLinux用Google Chromeのインストールに失敗したことからエラーが発生したのではないかと考えています。

そこで回避策として、Dockerのベースイメージに内包されているLinuxディストリビューションのパッケージマネージャーから直接Chromiumをインストールし、それをPuppeteerから利用する方針で対応することを考えました。

今回利用しているベースイメージに含まれているLinuxOSがDebianであったため、Dockerfileにapt経由でのChromiumのインストールコマンドを記述し、ビルド時にあらかじめChromiumをインストールするように設定します。利用しているLinuxOSの種類を確認したい場合、コンテナにログインした上で、以下のコマンドを実行することで確認が可能です。

# コンテナにログイン
$ docker exec -it <container name> bash

# OSの種類を確認
cat /etc/os-release

また、PuppeteerにはバンドルされたChromiumのインストールのスキップと、パッケージマネージャから別途インストールしてきたChromiumを使用するよう、設定を行う必要があります。 上記エラーに関する内容はGitHubでもIssueが上がっており、対応方法について議論が為されていました。 パッケージマネージャ経由でChromiumをインストールする方法はここから着想を得た形となります。

github.com

【2023-03-09 追記】

以下の記事にて、M1MacのDocker上でChromeが使用できない事象について、より詳細な原因や迂回案について綴られています。

blog.savanna.io

シチュエーションや環境としては本記事と類似しているため、「M1MacのDcokerでChromeが使用できない」というエラーの解決策を求めて本記事にたどり着いた方は、 上記記事を参考にしてみることをおすすめします。

具体的な対応手順

ここからは本題であるM1Mac + Docker環境でGroverを使ってPDFを生成を通すまでの具体的な手順を追っていきます。

1. Groverの追加

gemfileにGroverを追加してbundle installします。

gem 'grover'
$ bundle install

2. DcokerfileにPuppeteerのインストール設定とchromiumのインストールコマンドを追加

M1Mac + Docker(arm64 x Linux)環境下でPuppeteerを使用するため、Dockerfileに以下のコマンドを追加します。

※尚PuppeteerのインストールにはNode.jsのver 14.12.0以上が必要となります。

  # Puppeteerのインストール時にバンドルされているChromiumのダウンロードをスキップする
  ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true

  # PuppeteerでパッケージマネージャからインストールしたChromiumを使用するように指定
  ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

  # ディストリビューションのパッケージマネージャからChromiumをインストール
  RUN apt-get install -y chromium

3 コンテナにログインの元、yarn経由でPuppeteerをインストール

コンテナ情報の確認

$ docker container stats

コンテナにログイン

$ docker exec -it <container name> bash

コンテナログイン後にyarn経由でPuppeteerをインストール npmを使用している場合はnpmのパッケージインストールコマンドに置き換えてください。

# yarn add puppeteer

4 controllerとGroverのconfigファイルにPDF出力コードと共通設定を追加

各種controllerにPDFの出力コードを、configにGroverでのPDF出力に関する共通設定をそれぞれ追加し、PDF出力の疎通確認を実行します。

  • config
# config/initializers/grover.rb

Grover.configure do |config|
  config.options = {
    format: 'A4',
    margin: {
      top: '30px',
      right: '20px',
      left: '20px'
    },
    cache: false,
    display_url: <%= ENV.fetch("GROVER_DISPLAY_URL") {'localhost:3000' } %>
  }
end

display_url = 外部のCSSファイルを読み込む際、相対パスを補完するための自ホストのURLを設定するオプション。スタイルをHTMLにインラインで記述している場合には不要。

  • controller
def show
  # PDFの元となるHTML文字列を生成
  html = ApplicationController.new.render_to_string(template: 'sample/show')
  # Groverのインスタンスに生成したHTML文字列を渡してto_pdfメソッドによりPDF形式に変換
  pdf = Grover.new(html).to_pdf
  # send_dataでPDFファイルをクライアントに返却
  send_data(pdf, filename: 'sample.pdf', type: 'application/pdf', disposition: 'inline')
end

5. Headless Chromeのsandbox機能への対応

上記の手順まででDocker環境にGrover、Puppeteer、Chromeをインストールし、PDFを生成するための地盤は整いました。 しかしこのままPDF生成を実行するとおそらく以下のエラーが発生し、処理が落ちることが予想されます。

ERROR:zygote_host_impl_linux.cc(90)] Running as root without --no-sandbox is not supported

原因としてはPuppeteerから実行されたHeadlessChromeの内部的なタブで、sandboxが有効化されていることから、渡されたHTML文字列をセキュリティ的にブロックしてしまうためです。 解決するためにはPuppeteerからHeadless Chromeを起動する際に、オプションでsandbox機能を無効化した上で起動することを伝える必要があります。

github.com

GroverにはPuppeteerからChromeをsandbox無効状態で起動させるための方法が用意されているので、これに則って設定を行います。 方法としては二つあり、一つ目が環境変数を設定するもの。二つ目がGroverのinitializeファイルに設定するものです。以下のいずれかを行うことでエラーの回避が可能です。

  1. Dockerfileに以下を追加してビルドを実行の上、再度PDFの生成を行なってください。
# Puppeteerをサンドボックス無効状態で使用
ENV GROVER_NO_SANDBOX=true
  1. config/initializers/grover.rbに以下を追加
Grover.configure do |config|
  追加→ config.launch_args: ['--no-sandbox', '--disable-setuid-sandbox']
end

6. 日本語フォントの導入と文字化けへの対応

最後に、出力されたPDFを確認すると日本語部分が表示できていないことが確認できるかと思われます。 これはOSに日本語フォントが導入されていないためPDFにフォントの埋め込みが行えていないためです。別途OSに日本語フォントを導入していきます。

OSに日本語フォントを導入(Dockerfile記載)

# PDF生成に使用する日本語用フォントの追加
RUN apt-get -y install fonts-ipafont fonts-ipaexfont

もし日本語フォントの導入で文字化けが解消しない場合、利用しているtemplateに以下の記述が含まれていない可能性があります。

<meta http-equiv="content-language" content="ja" charset="utf-8">

Groverを選定した理由

最後に今回Groverを採用した理由について。

RailsでのPDF生成において、自分の観測範囲ではPrawnが最も代表的なGemなのではないかと思われます。 今回のGroverを選定した理由としては、PDF帳票という性質上レイアウトの変更を求められる可能性が高かったため、以下の観点からPrawnよりもGroverを利用したいと考えました。

  • PrawnはGem独自の記法、オプションを用いてPDFをレイアウトしていかなければならず、帳票の手直しを行う際に再度Prawnのドキュメントのインプットが必要となり、メンテナンスのコストが嵩む
  • Rubyの言語が読み書きできることが必須であるため、今後新規のメンバーなどがアサインした場合に誰でも・比較的容易にメンテナンスが行えるという観点でHTML/CSSをベースにレイアウトが変更できるGroverの方がメンテナンス性が高いと考えたため

同じ観点でHTMLからPDFを生成できるGemとしてwicked_pdfも老舗のGemとして知られていますが、こちらに関しては単純にGem自体の開発が停滞しており、今後使っていくにあたり不安要素が大きかったため、同じHTML to PDFで生成が可能で更新が活発なGroverを採用することとしました。

実際蓋を開けてみると上述の通り開発環境の構築時点で様々な課題が見つかりかなり大変な思いはしましたが、これに関してはM1Mac + Dockerという特定の環境下での事象かと思われますので、基本的には要件の性質やプロジェクトメンバーの知見などに合わせてPrawnまたはGroverを利用していくという考え方でいいのではないかと思います。