ブログ

Cloudflare WorkersでPDF生成が遅い?5分で原因を切り分ける

Workersは高速です。ただし、長時間動くサーバー向けのPDFスタックを載せるまでは。エッジでPDFが必要なとき、本当のボトルネックは何か、どう避けるかを整理します。

請求書、ラベル、領収書のサービスをCloudflare Workersへ移したのは、自然な判断だったはずです。ほかの基盤もすでにそこで動いていて、レイテンシの計算もきれいです。最寄りのcoloまで5 ms、CPU 1 ms、リクエスト完了。

ところがPDF生成が入った途端、p99が800 msになり、50 MBのワーカーバンドル警告が出て、「道具を間違えたのでは」と感じ始めます。なぜそうなるのか、そして午後のうちに直せる実際のボトルネックは何かを整理します。

Workers は Lambda ではない。そこが重要

診断の前に、実行モデルを正しく理解してください。Cloudflare Workersはサーバーレスコンテナではありません。次の制約を持つ V8 isolates です。

  • CPU時間上限: 無料プランはリクエストあたり50 ms、Workers Paid (Bundled)は30秒、Unboundは5分。実時間は無制限ですが課金対象です。
  • メモリ上限: isolateあたり128 MB。
  • バンドルサイズ: 無料プランは1 MB、Paidは10 MB。
  • ファイルシステムなし。 fs.readFileSync はありません。すべてメモリ上に置くか、取得する必要があります。
  • ネイティブバイナリなし。 Pure JavaScript / WebAssemblyのみです。node-canvas、ネイティブzlib呼び出し、Ghostscriptへのシェル実行は使えません。
  • コールドスタート: 約5 ms。 驚くほど速いですが、それは起動すべき大きなものがないからです。

「WorkersでPDFが遅い」問題の多くは、この制約のどれか、たいていはCPU制限またはバンドルサイズを破り、静かに抑制されることで起きます。

実際に遅い 5 つのもの

チームを困らせる頻度が高い順に挙げます。

1. Chromiumベースの生成エンジンをWorkersに入れようとする

これは成立しません。Puppeteerには約250 MBのChromiumと実際のOSが必要です。ブラウザ生成サービス(CloudflareのBrowser Rendering API、Browserless)は動きますが、それらは Workersではありません。Workerから呼ぶ別サービスであり、約500 msの往復時間に生成時間が足されます。

あなたの「WorkerベースのPDF」が実際には「リモートのブラウザ生成APIを呼ぶWorker」なら、レイテンシの下限は約500 msです。これはWorkerの問題ではありません。ブラウザ税がエッジまで付いてきているだけです。

診断: コードに fetch("https://browser-rendering.cloudflare.com/...") や類似の呼び出しがあるか確認します。あるなら、測っているレイテンシはWorkerではなく上流サービスのものです。

2. JavaScript でレイアウトする

自前のJSベースのレイアウトエンジンを書いているなら(「boxの位置を手で計算すればよい」と考える形です)、CPU制限に当たりやすくなります。JSは速いですが、30個以上の要素とテキスト折り返しを含むレイアウトは、Workers Freeでは簡単に50 msを超え、Bundledでも100〜300 msになることがあります。

次のような生成パイプラインは、

JSON → JS layout pass → SVG generation → SVG-to-PDF library → emit

同じデータに対してCPU boundな処理を4回行っています。すべてJSで、すべてgarbage collection overheadを持ち、すべて中間ツリーを再割り当てします。

診断: PDF生成時の wrangler tail ログを確認します。I/Oより前にCPUが50 msを超えているなら、計算処理の問題です。

3. リクエストごとにフォントを読み込む

フォントは1つ50〜250 KBあります。生成エンジンが各PDF生成でKV / R2からフォントを読むなら、フォントごと、リクエストごとにネットワーク往復が発生します。フォント5つなら、生成開始前に5 RTT、つまり50〜150 msです。

診断: フォント読み込みコードにタイミング計測を入れます。p50の大部分を占めるなら、ここが原因です。

修正: フォントはmodule-init時に一度だけ読み込みます(Workerファイルの先頭。リクエストハンドラー内ではありません)。isolateは、そのisolateの寿命(数分〜数時間)の間、バイト列をキャッシュします。

// Module-init: runs ONCE per isolate
import notoSans from "./fonts/noto-sans.woff2";
const FONT_BYTES = new Uint8Array(notoSans);

export default {
  async fetch(request) {
    // Per-request: zero font I/O
    return renderPdf({ fontBytes: FONT_BYTES, ...data });
  }
};

bundlerがフォントをバイト列としてinlineできるなら、さらによいです。I/Oは完全に消えます。

4. Workers向けに作られていないJS PDF libraryを使う

pdfkitpdf-libjsPDF は、いずれもWorkersで動きます。ただし、痛みやすい特徴があります。

  • pdfkit はNode Buffer shimsを必要とします。可能ではありますが、約500 KB増え、計算処理が約30%遅くなります。
  • pdf-lib既存PDFの編集には優れていますが、ゼロから出力する用途にはあまり向きません。抽象化レイヤーが1ページあたり約10 msのoverheadを足します。
  • jsPDF はbrowser-firstです。同じBuffer問題があり、さらにAPI surfaceが広く、tree-shakeしにくいです。

生成パイプラインが主に「JSONを読み、PDFバイトを書く」ものであるなら、汎用PDF抽象を通らずPDFを直接出力する専用エンジンの方が5〜20倍速いことがあります。RustやC++からコンパイルしたWebAssemblyエンジンは、JITに向いたtight loopの恩恵をさらに受けます。

5. 実は 4 MB あるバンドル

Workers Freeのバンドル上限は1 MBです。Workers Bundledは10 MBです。多くのチームは、wrangler deploy が “Script exceeds size limit” で拒否して初めて上限に気づきます。さらに早い段階で、巨大なimportがコールドスタートを遅くすることもあります。V8がそれらをすべてコンパイルしなければならないからです。

wrangler はdeploy outputにバンドルサイズを表示します。500 KBを超えるものは調査対象です。よくある原因は次です。

  • Bundled fonts。Workers Assetsへ移し、module-initで一度だけ fetch します。
  • node: shim layer。source mapに __cf_KVpolyfills: が見えるなら、実際には不要なNode APIsをbundlerがshimしている可能性があります。
  • 使っていない依存関係。Wrangler 4+なら npm run build -- --analyze でtreemapを見られます。

「Workers で速い PDF」は実際どう見えるか

構造化文書向けに作られた専用のエッジ生成エンジン(gPdfはその一例です。設計はよく作られたものなら同じです)では、おおむね次の規模になります。

指標 典型値 理由
コールドスタート 5〜20 ms V8 isolate boot + WASM module first-load
1回の生成CPU 1〜4 ms WASM tight loop、GC pressureなし
1回の生成wall time 3〜8 ms CPU + PDF object IDs用の数µsのcrypto
バンドルサイズ 4〜6 MB 生成エンジン + bundled fonts (Latin + CJK NotoSans)
メモリピーク 8〜20 MB 文書ツリー + 出力PDFバッファ

典型的な「Workersからremote browser rendering経由でPuppeteerを呼ぶ」経路と比べてください。そちらはp50で500〜1000 ms、ブラウザメモリは別サービス側で1〜2 GB、上流コストは約$0.001/renderです。

すぐできる切り分け

いまWorkersのPDFが遅いなら、まずこのチェックリストを実行してください。

  1. 時間はどこへ行っているか。 wrangler tail にtimestampを追加します。ボトルネックがどれかを切り分けます。
    • CPU(process内の処理)
    • Egress fetch(上流サービスの呼び出し)
    • Cold start(静かな時間の後の初回リクエストだけ)
  2. JSレイアウトを走らせているか。 走らせているなら、CPUの大半はそこです。事前計算済みレイアウトを使う生成エンジンへ移します。
  3. フォントをリクエストごとに読み込んでいるか。 フォント読み込みをmodule initへ移します。
  4. 外部ブラウザを呼んでいるか。 その場合、レイテンシの下限はそのサービスの応答時間です。同じisolate内で動く生成エンジンへ移します(fetchなし)。
  5. バンドルが1 MBを超えているか。 コールドスタートはバンドルサイズに応じて伸びます。使っていない依存を削ります。

Workers上で最速のPDFは、文書データ → 出力PDFバイトの処理が単一の fetch handler呼び出し内で完結し、その中で独自の fetch() 呼び出しも、CPUの重いJSレイアウトもない形です。私たちが聞く「WorkersはPDFに遅い」という不満の多くは、実際には「WorkersらしくないPDFスタックをWorkersに載せ、両方の悪いところを引いている」ケースです。

短くまとめると

Cloudflare Workersは1桁ミリ秒でPDFを生成できます。ただし、生成エンジンがisolate型の実行環境向けに作られている場合に限ります。Node向けに設計されたJS PDFライブラリ、Workerから呼ぶブラウザ生成サービス、リクエストごとのフォント読み込み、JavaScriptのレイアウトパスは、p50を「オリジンより遅い」から「リージョンLambdaと見分けがつかない」範囲まで押し上げます。

自分でここまで作り込みたくないなら、gPdf Playground を試してください。この実行環境そのものの上で動く、完全にエッジ配置された生成エンジンです。Render PDF を押してNetworkタブを見れば、パイプラインが実行環境と戦っていないときWorkersが何をできるかが分かります。