Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: groverでPDFを生成する方法

PDF生成のgemといえば、一時期は wicked_pdf が主流だったと思います。

mileszs/wicked_pdf - GitHub

しかし、昨今では依存ライブラリである wkhtmltopdf の更新が事実上止まったため、以下のような影響が出ていて採用しづらいです。

  • モダンなブラウザでは意図したとおりにレンダリングできない可能性がある
  • 脆弱性への対応体制に不安がある

wicked_pdfのcontributerも、長期的には他のライブラリを採用するように勧めています。
Long term plans given the deprecation of wkhtmltopdf? · Issue #1081 · mileszs/wicked_pdf

代替の選択肢に悩むところですが、そのような中でPDF生成のために grover を採用したことがあったので、特記すべき点について共有しようと思います。
PDF生成のための一助となれば嬉しいです。

Studiosity/grover - GitHub

🔗 groverの特徴

大きくは以下の二点です。

  • viewに基づくHTMLをそのままPDFに変換する
    • -> DSLの学習コストが不要
  • puppeteer を利用している
    • -> 環境構築に一手間かかる
    • -> PDF生成の時間が伸びる

groverはHTML文字列からPDFファイルを生成し、このHTMLはRailsのviewから作成することができます。
prawnthinreports と比較した場合、DSLの学習コストが不要な点はメリットになりそうです。

一方で、groverはpuppeteerのインストールを必要とするため、Node.jsや関連ライブラリのセットアップまで面倒を見なければいけません。
また、puppeteerを立ち上げるという都合で、ファイル生成にはそれなりの時間がかかるように感じます。
非機能要件次第では、非同期化などの対応が必要になるかもしれません。

🔗 Dockerでの環境構築

PDFの生成を行う環境では、以下の3つが必要となります。

  • Node.js
  • puppeteer
  • Google ChromeまたはChromium

v19.0.0以降のpuppeteerは、最新のChromeを自動でダウンロードするようになっているため、ブラウザの明示的なダウンロードは不要です。

Installation -- puppeteer/puppeteer

注意すべきは、ローカル環境などでDockerを利用している場合です。
Dockerイメージ内でpuppeteerと共にインストールされたChromeは、ライブラリの依存関係を上手く解決できないようです。

Running Puppeteer in Docker -- puppeteer/docs/troubleshooting.md

対策として、不足しているライブラリと、最新のChromiumを直接インストールします。
puppeteer公式のDockerfileが参考になると思います。

puppeteer/docker/Dockerfile · puppeteer/puppeteer

例として、rubyイメージ(debian)を使用したDockerfileの抜粋を載せておきます。

# ...

# Node.jsをインストール
ENV NODE_MAJOR 20
RUN apt update && \
    apt install -y ca-certificates curl gnupg && \
    mkdir -p /etc/apt/keyrings && \
    curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
    echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
    apt update && apt install -y nodejs

# Chromiumと依存ライブラリをインストール
RUN apt update && \
    apt install -y wget && \
    wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg && \
    sh -c 'echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' && \
    apt update && \
    apt install -y google-chrome-stable fonts-ipafont-gothic libxss1 dbus dbus-x11 --no-install-recommends && \
    service dbus start && \
    rm -rf /var/lib/apt/lists/*
# ...

# puppeteerをインストール
ADD package.json yarn.lock $APP_HOME
RUN yarn install
# ...

🔗 M1 / M2 Macの場合(※ 動作未検証)

M1 / M2 Macの場合、このままでは環境構築できません。
M1 / M2 Macはarm64用のDockerイメージをpullしてくるのに対し、puppeteerがインストールするChromeはarm64に対応していないためです。

検証環境がないため成否を確認できていませんが、以下のどちらかで解決しそうです。

  • amd64のChromiumを使用する方法。以下を両方実施する
    • Dockerイメージのプラットフォームに linux/amd64 を指定する。amd64用のChromiumをインストールしてもらうため
    • Docker Desktop for Macの「Use Rosetta for x86/amd64 emulation on Apple Silicon」を有効にする
  • OS標準のChromiumを使用する方法
    • OSのリポジトリ(apt等)からChromiumを直接インストールする

後者の場合、puppeteerによるChromeダウンロードをスキップし、puppeteerにChromiumの実行パスを教える必要があります。
Dockerfileのサンプルは以下の通りです。

# Dockerfile

# Chromium(apt)と依存ライブラリをインストール
ENV PUPPETEER_SKIP_DOWNLOAD true
ENV PUPPETEER_EXECUTABLE_PATH /usr/bin/chromium
RUN apt update && \
    apt install -y chromium && \
    # ...

🔗 Chromeの起動設定

PDFを生成するために、Chromeの起動オプションを設定する必要があります。

デフォルトではChromeのsandboxが有効ですが、これだとセキュリティ都合でPDFを生成できません。
groverのオプションで launch_args: ['--no-sandbox'] を指定すれば回避できます。

# config/initializers/grover.rb
Grover.configure do |config|
  config.options = {
    launch_args: ['--no-sandbox']
  }
end

🔗 アセットの読み込み

PDFが参照するCSSなどのアセットはRailsアプリケーション外から読み込まれるため、相対パスだけでは参照先を解決できません。
ミドルウェアを経由せずにアセットへのパスを解決するには、groverに display_url: 'http://localhost:3000' のようなオプションを与える必要があります。

# xxx_controller.rb

def print
  # ...
  pdf = Grover.new(html, **print_options.merge({ display_url: request.base_url })).to_pdf
  # ...
end

🔗 別コンテナからPDFを生成する場合

PDFを非同期で生成するなどの都合で、アプリケーションサーバーとは異なるプロセスのコンテナからPDFを生成することもあると思います。
この場合、 display_url は別のコンテナ側から見たものにする必要があります。

# compose.yml

services:
  web:
    ports:
      - '3000:3000'
    command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    environment:
      GROVER_DISPLAY_URL: 'http://web:3000'
  que:
    command: /bin/sh -c 'bundle exec que'
# xxx_controller.rb

def print
  # ...
  grover_display_url = ENV.fetch('GROVER_DISPLAY_URL', request.base_url)
  pdf = Grover.new(html, **print_options.merge({ display_url: grover_display_url })).to_pdf
  # ...
end

🔗 シングルスレッドによる影響の回避

これは現代では余程遭遇しないと思いますが、サーバーがマルチスレッドとして機能しない場合、PDFの生成に耐えられずにシステムがハングアップする可能性があります。
例として古いRails + webrick の組み合わせが採用されている場合など、歴史的経緯によっては発生します(しました)。
※ webrickはマルチスレッドですが、Railsのバージョン次第では、スレッドセーフでない処理を回避する目的でタスクの同時実行を抑制します

puma / unicorn などを立ち上げるようにすれば、マルチスレッドのサーバーが動いてPDFを生成できるようになります。

🔗 最後に

開発者の体験を考えれば、viewからPDFを生成できるgroverは良い選択肢だと感じます。
一方で、環境構築に癖があったり、ファイル生成にある程度の時間を要したりと、躓きそうな点も含んでいます。
メリット・デメリットを踏まえた上で、採用するときには本記事を参考にしてもらえると嬉しいです。

参考


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。