📄

wkhtmltopdf依存から抜け出して、PDF生成をLambdaにお引っ越しした話

に公開

はじめに

みなさんは、RailsアプリでPDFを生成するとき、どんな構成を使っていますか?

Seibiiでは長らく、wkhtmltopdfを使ってRailsサーバー内でPDFを生成していました。以前はRailsコンテナをAlpineベースで運用していたのですが、Alpineをアップグレードしようとした際にwkhtmltopdfが動かなくなり、やむを得ずDebianベースのイメージに切り替えた経緯があります。

そこで今回、PDF生成をRailsサーバーから切り離し、EventBridgeとAWS Lambda(Playwright + Chromium)に任せる構成を作りました。本記事では、そのアーキテクチャと移行の背景について紹介します。

wkhtmltopdf依存で抱えていた課題

wkhtmltopdfを使い続ける上で、具体的に以下の課題がありました。

プロジェクト自体の終了:wkhtmltopdfのGitHubリポジトリは既にアーカイブされており、新しいバグ修正や機能追加は期待できません。また、wkhtmltopdfが依存しているQtWebkitもメンテナンスが停止しています。セキュリティ面でも長期的に使い続けるのはリスクがあります。

OSへの強い依存:wkhtmltopdfはglibcベースのバイナリなので、musl libcを使うAlpine Linuxでは動作しません。実際、Alpineのアップグレード時にwkhtmltopdfが動かなくなった経験があり、Debianベースのイメージに切り替えざるを得ませんでした。

Dockerイメージの肥大化:Debianベースのイメージを使わざるを得ないことに加え、日本語PDF対応のためにNoto CJKフォントもインストールが必要で、Railsコンテナのイメージサイズが大きくなっていました。将来的にAlpineやdistrolessに移行してイメージを軽量化したいという方針とも相性が悪い状態でした。

検討した他の選択肢

wkhtmltopdfからの移行先として、いくつかの選択肢を検討しました。

grover gem:Chromium + Puppeteer(Node.js)を使ってHTMLからPDFを生成するgemです。しかし、PuppeteerがNode.jsを必要とするため、Railsコンテナに新たな依存が増えてしまいます。Dockerイメージを軽くしたいという方針とも逆行するため、採用を見送りました。

ferrum gem:Node.jsを必要としないChromium操作ライブラリです。ただし、PDF生成専用のツールではなく、結局Chromiumバイナリ自体はRailsコンテナに入れる必要があります。OS依存やイメージ肥大化の問題は解決しません。

私たちのゴールはwkhtmltopdfからの脱却でした。できればRailsコンテナにChromiumのような重いバイナリを入れたくなかったですし、依存を減らして安定した動作とメンテナンス性を向上させたいという思いもありました。そのため、最終的にLambda(コンテナ)にPDF機能を独立させる構成を選びました。

解決策:PDF Lambdaアーキテクチャ

PDF生成をRailsサーバーから完全に分離し、AWS LambdaとEventBridgeを使ったイベント駆動アーキテクチャに移行しました。

Before / After

この構成で得られたメリット

一番の目的は、メンテナンスが止まりつつあるwkhtmltopdfから脱却することです。PDF生成をLambdaに切り出したことで、Railsコンテナからwkhtmltopdfやフォントなどの重たい依存を完全に取り除けます。これにより、将来的にAlpineやdistrolessベースのイメージに移行する道が開けました。

副次的なメリットとして、PDF生成の負荷がRailsサーバーから分離されたことで、スケーリングの心配が減りました。正直、PDFのスケーリングで致命的に困っていたわけではないのですが、Lambda側に寄せたことで「PDFのピーク負荷のことはあまり気にしなくてよくなった」のは精神的に楽になりました。

実装のポイント

全体のシーケンス

EventBridgeとS3を使った連携

社内にはイベント駆動でLambdaとRailsを連携させるためのフレームワークがあり、今回はそれを利用しています。

PDF生成時のデータの流れで工夫したポイントがいくつかあります。

まず、EventBridgeにはイベントサイズの制限(256KB)があるため、HTMLをそのままイベントに載せることはできません。そこで、Rails側でHTMLをS3にアップロードし、その署名付きURLをイベントのペイロードに含めるようにしています。

Lambda側では、受け取った署名付きURLからHTMLを取得し、Playwright + ChromiumでPDFを生成します。生成したPDFはLambda側のS3バケットにアップロードし、その署名付きURLをレスポンスイベントで返します。

Rails側では、レスポンスイベントから署名付きURLを受け取り、PDFをダウンロードして自分側のS3バケットにコピーします。このようにLambdaとRailsでS3バケットを共有せず、それぞれ独立した責務を保っています。

Rails側ではファイルアップロードにShrineを使っているため、既存のShrineの仕組みに乗せてPDFを保存できるのも便利なポイントでした。

Lambda側の実装

content-converter LambdaはDockerコンテナとしてデプロイしています。Playwright + Chromiumを使ってURLからPDFを生成します。

const convertFromUrl = async ({ url, convertType }) => {
  const browser = await chromium.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox', ...]
  });
  
  const page = await browser.newPage();
  await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
  
  const buffer = await page.pdf({
    format: 'A4',
    printBackground: true,
    preferCSSPageSize: true,
  });
  
  return { buffer, contentType: 'application/pdf', fileExtension: 'pdf' };
};

日本語PDF対応のため、LambdaのDockerイメージにはNoto CJKフォントをインストールしています。フォントの依存もLambdaコンテナに閉じ込められるので、Railsコンテナには影響しません。

今後の展望

現在、この仕組みは実装が完了し、本番環境への移行準備を進めている段階です。今後は以下のような展開を予定しています。

  • 本番環境での領収書などのPDF生成を新方式に切り替え
  • RailsコンテナからwkhtmltopdfとNoto CJKフォントを削除
  • Alpineやdistrolessベースのイメージへの移行検討
  • HTMLからPNG、JPEGなどへの画像変換対応(OGPサムネイル自動生成、SNSシェア用画像生成など)

おわりに

wkhtmltopdfは長らくRailsでのPDF生成のデファクトスタンダードでしたが、プロジェクトのアーカイブやQtWebkitのメンテナンス停止により、今後も使い続けるのは難しい状況になっています。

また、Railsコンテナに重たいPDFエンジンを抱えたままだと、ベースイメージの選択肢が狭まり、コンテナ戦略の足かせになりがちです。PDF生成をLambdaに切り出すことで、「アプリケーションコンテナは薄く・汎用的に」「PDFエンジンは専用コンテナで好きに構成する」という役割分担ができました。

結果として、主目的だった「脱wkhtmltopdf」と「コンテナの自由度向上」が達成でき、スケーリングなどの副次的なメリットもついてきました。同じような課題を抱えている方の参考になれば幸いです。

Seibiiテックブログ

Discussion