Lambda PDF生成を27倍高速化した話 — Puppeteerから@react-pdf/rendererへの移行レポート

こんにちは。Findy Freelance開発チームの久木田です。 今回は、社内で運用している支払明細書PDFの生成基盤を、Lambda + Puppeteerから@react-pdf/rendererへ全面的に移行した話を書きます。最終的に処理時間はP50(中央値)で約27倍速くなり、メモリ消費も実測で約1/4まで落とせました。

これまでのPDF生成基盤と課題

現在、システムから発行しているPDFはいくつかありますが、本記事では一例として支払明細書PDFに絞って紹介します。ファインディからフリーランスエンジニアへの支払明細として月次で一括発行しているPDFです。

支払明細書PDFは1件あたり1ページで、支払先、支払者、明細表、特記事項、ファインディの社印を配置した構成です。情報量としては小規模ですが、月次でフリーランスエンジニア全員分を一括発行する必要があり、1回のバッチで数百件規模のPDFを並列生成していました。Rails側からParallelライブラリで並列にLambdaを呼び出す構成です。

このPDFは、AWS Lambda上でPuppeteerを起動し、EJSテンプレートから組み上げたHTMLをヘッドレスのChromiumでレンダリングしてPDF化する構成で動いていました。フロントエンド向けのHTML/CSSをそのまま流用できるため初期実装は速かったのですが、運用が進むにつれ次の課題が顕在化しました。

  • コールドスタートが重く、Chromiumバイナリの起動だけで5秒前後を消費していた
  • 大量生成で /tmp が枯渇し、一括処理で複数のレンダリングを並走させると、エフェメラルストレージが先に尽きてエラー終了する
  • タイムアウトやエラーが恒常化し、数百件規模の一括出力は途中で停止して再実行が必要になる

ボトルネックはChromiumの不安定さでした。Chromium起動のたびにプロファイル・キャッシュ・ソケットファイルが /tmp 配下に生成され、browser.close() 後もディスク上に残存します。さらに、レンダリング中にChromiumがハングした場合は、一時ファイルを保持したままプロセスが停止します。その間にも新規リクエストでLambdaが起動するため、ウォームスタートで使い回されるインスタンスでは /tmp の残存ファイルが蓄積し、一定量を超えた時点で全体が停止してしまう挙動になっていました。

この蓄積に備えて、メモリと /tmp は常にバッファを含めて多めに割り当てる必要があり、1Lambdaあたり2-3GB相当の確保を継続していました。それでもハングと並列度のピークが重なる局面では詰まるため、設定値を引き上げては別の閾値で詰まるという状態が続いていました。

一括出力は今後数倍に増加する見込みであり、現状の構成のまま継続することは現実的ではありませんでした。

対症療法でしのいだ期間

課題を踏まえ、徐々に対策を施すことにしました。Lambdaのメモリ割り当て増、タイムアウト延長、エフェメラルストレージの拡張、並列度の調整など、設定値で吸収できそうな対策は一通りしました。短期的な改善にはなったものの、根本にあるのはChromiumをサーバーレス環境で動かすこと自体のコストと描画コストが入力規模に比例して伸びる構造です。設定の積み増しではレイテンシーもエラー率も思うように改善しませんでした。

根本対応を決めた3つの背景

根本対応に踏み切る判断は、次の3点が同時に揃った時点で下しました。

  1. 現状の課題: レイテンシーが悪化傾向、エラーも日次で観測される
  2. 将来の悪化見込み: 一括処理の件数は今後さらに増える計画があり、対症療法の余地がもう残っていない
  3. 対症療法の限界: 設定値の調整ではレイテンシーやエラー率の改善が頭打ちで、いずれの打ち手も効果が薄れてきた

課題が大きくなったため、対策を施しました。逆に言えば、どれか1つでも欠けていたら、もう少し対症療法を続けていたと思います。

技術選定: 戻れる順に試す

根本対応の方針として、大きく2案を比較しました。

  • A案: Lambdaを維持し、@react-pdf/rendererでProgrammaticにPDFを組み立てる
  • B案: LambdaからECSなど別のランタイムへ移行する

A案は、いまのLambda構成を保ったままPDF生成方式だけを差し替える案です。@react-pdf/rendererはJSXを書く感覚でPDFを組み立てるライブラリで、Chromiumのヘッドレスブラウザは利用しません。

react-pdf.org

そもそも@react-pdf/rendererが候補に挙がったのは、ヘッドレスブラウザ以外でPDFを生成する方法を調べていたからです。継続的な利用料金が発生する外部サービスを使う選択肢は外し、OSSのプログラマティック生成ライブラリの中で、Reactと同じJSXで書ける@react-pdf/rendererを選びました。Reactはファインディの他プロダクトでも広く使われており、馴染みがあった点も決め手になりました。

B案はメモリやストレージの制約には強くなりますが、コスト構造が変わり、検証の立ち上げにも工数がかかります。Lambdaのタイムアウトに収まらない処理や、Chromiumの表現力(複雑なCSS・JavaScript描画など)をどうしても残したいケースではB案が候補ですが、今回のPDF生成はそのどちらにも当てはまりませんでした。 そのため、最終的には次の3つの理由からA案を選びました。

  • 戻れる: Lambda構成のままPDF生成方式だけを差し替えるので、問題が出てもPuppeteer版に戻すことが容易
  • テスト可能: PDF生成ロジックがJSXで書けるため、単体テストや出力差分テストを書きやすい
  • AIで移行コストが現実的になった: EJSテンプレートからJSXへの変換は、生成AIに任せられるレベルまで来ていた

B案はA案が失敗した場合の代替案として残し、まずは戻れるA案を試すことにしました。

移行で押さえておきたい実装ポイント

EJSからJSXへの書き換え自体は生成AIで一気に進められましたが、@react-pdf/rendererの実装スタイルに合わせるために事前に押さえておきたい点がいくつかありました。

  • @react-pdf/renderer v4はESM-onlyで、tscのCommonJS出力からは読み込めないため、esbuildを入れてESMをCJSにバンドルした
  • 日本語フォントはFont.register()で明示的に登録しないと文字化けする
  • Puppeteerのscale: 0.8相当が無いため、フォントサイズや余白を手で再計算した
  • HTMLの<a>自動展開は再現されず、URL部分だけ<Link>化する小さなコンポーネントを自作した
  • HTMLエスケープは自動化されていて、旧実装のエスケープ処理が不要になった(副産物)

特に大変だったのがJSXの空文字列の扱いです。{stringValue && (...)}と書くと、空文字列がchildとしてそのまま流れ込み、WARN Invalid '' string child outside <Text> componentが大量に出ます。Reactの文法としては正しいのですが、@react-pdf/renderer<View> / <Page>配下では{!!stringValue && (...)}と明示的にboolean化する書き方に揃える必要があります。さまざまなデータでPDFを作成していく過程で警告ログが出ていることに気付き、該当箇所をまとめて修正しました。

設計と実装のキモ

ここからは、@react-pdf/rendererをLambdaに載せていくときに考えた設計面のポイントを2つに分けてまとめます。

テンプレート構造

PDFそのものを1つのReactコンポーネントとして組み立てる構成にしました。リンクの自動展開のように共通で必要な要素は、専用の小さなコンポーネントとして切り出して再利用しています。

EJS時代は部分テンプレートをincludeで組み合わせる作りでしたが、JSXに移ってからはコンポーネントの組み合わせとして自然に再構成できました。

esbuildで橋渡し

@react-pdf/renderer v4はESM-onlyですが、Lambda側はCommonJSで動かしています。tscの出力では直接読み込めなかったため、esbuildでESM→CJSのバンドルを作ってLambdaにデプロイする構成にしました。

// esbuild.config.js(抜粋)
{
  entryPoints: ['src/index.ts'],
  bundle: true,
  platform: 'node',
  format: 'cjs',
}

設定としてはシンプルですが、ここを通さないと依存関係の解決でつまずくことになるため注意が必要です。

どう変わったか

支払明細書PDFの一括作成処理について、移行前後をCloudWatchメトリクスで比較した実測値が次の通りです。表のP50 / P95 / P99は、実行時間を昇順に並べたときの中央値 / 95パーセンタイル / 99パーセンタイルを表します。

指標 Before After 倍率
実行時間 P50 約3,963 ms 約145 ms 約27倍高速
実行時間 P95 約4,707 ms 約212 ms 約22倍高速
実行時間 P99 約5,249 ms 約458 ms 約11倍高速
平均メモリ 約912 MB 約222 MB 約1/4
最大メモリ 約1,589 MB 約239 MB 約1/7

特に改善幅が大きいのはP50です。旧構成では実行時間そのものの遅さに加え、Puppeteer/Chromium由来のエラー(ブラウザの接続切れやハングなど)が起きると、一括処理の中で個別のPDF生成がLambdaのタイムアウトに到達し、リトライしても最後まで完成せずエラーとして残るケースがありました。表のP99の大きさにそれが現れています。移行後はこれらのエラー、エフェメラルストレージの逼迫、コールドスタートによる遅延がいずれも解消され、Lambda上での実行を意識する必要のない構成になっています。

学び

今回の移行で特に有効だったのは、判断軸として置いた「戻れる順に試す」です。Lambda構成を維持したままPDF生成方式だけを差し替えるA案は、もし行き詰まっても旧版のLambdaに切り戻す選択肢を残せました。ランタイムごと載せ替えるB案を最初に選んでいたら、検証のために抱えるリスクははるかに大きくなっていたはずです。

もうひとつは、生成AIの活用で技術選定の前提条件が変わったことです。A案はEJSテンプレートのJSXへの全件書き換えを伴います。AIなしで工数を見積もると、規模だけでA案は採用候補から外れていました。書き換えだけでなく、旧PDFと新PDFのレイアウト差分の特定と修正案の生成までAIに任せられたため、A案の工数は当初の想定より低く収まりました。

最後に、@react-pdf/rendererを使った所感をまとめます。

メリットとして大きかったのは、Lambdaの割り当てメモリを大幅に減らせたことと、ヘッドレスブラウザを使っていたころよりテストが格段に書きやすくなったことです。PDFをバッファのまま受け取って中身を検証できるので、ブラウザを起動しない軽量な統合テストをCI上でも組めるようになりました。

一方で、HTML/CSSではなく<View> <Text>といったPDF専用のプリミティブをFlexboxで組み立てる、React Native寄りのコンポーネントモデルです。HTML/CSSしか経験が無い場合は、最初は書き方に戸惑う場面もあると思います。


ファインディでは一緒に会社を盛り上げてくれるメンバーを募集中です。興味を持っていただいた方はこちらのページからご応募お願いします。

herp.careers