2026/04/26

PlaywrightのPDF生成が重い?APIに移行してコード量80%削減する方法

Playwright移行PDF APINode.js運用改善

Playwrightは優れたブラウザ自動化ツールですが、PDF生成に使うと Chromium のライフサイクル管理、ブラウザプール、メモリ制限、バージョン互換性といった課題が発生します。これらはいずれも PDF 生成そのものとは関係のない運用コストです。

この記事では、FUNBREW PDF API への移行方法を Node.js・Python の Before/After コード例、コスト比較、ステップバイステップの移行手順とともに解説します。

PlaywrightでPDF生成を運用する際の課題

PlaywrightはPuppeteerより高機能ですが、PDF生成用途では同じ運用コストが発生します。

ブラウザコンテキストごとのメモリ消費

Playwright の Chromium 起動は 1 インスタンスあたり 200〜500MB のRAMを消費します。3 件同時生成でそれだけで 600MB〜1.5GB を占有します。アプリケーション本体のメモリを加えると、メモリ制約のあるサーバーでは OOM によるクラッシュが現実的なリスクになります。

コンテキストのリーク管理

Playwright ではブラウザインスタンスとブラウザコンテキストの両方を管理する必要があります。エラー発生時にコンテキストが閉じられないと、ゾンビとしてメモリを占有し続けます。

// よくある問題: エラー時にコンテキストが閉じられない
const context = await browser.newContext();
const page    = await context.newPage();
await page.setContent(html); // ここでエラーが発生するとコンテキストが残る
const pdf = await page.pdf();
await context.close(); // エラー時には実行されない

Chromiumのバージョン管理

@playwright/testnpm install ごとに特定の Chromium ビルドにロックされます。アップデートにより、フォントメトリクス、CSS @page サポート、flexbox の挙動が変わり、既存のテストが突然壊れます。ネットワーク制限のある環境では Playwright の Chromium ダウンロードが失敗し、CI/CD パイプラインが止まることもあります。

サーバーレスでのコールドスタート

ウォームな状態でも playwright.chromium.launch() には 1〜3 秒かかります。Lambda や Cloud Run のコールドスタートでは 8〜15 秒に達することがあります。マネージドAPIへの移行でコールドスタートが完全に解消される理由についてはサーバーレス向けPDF API活用ガイドを参照してください。

Before / After:Node.js コード比較

Before: Playwright(自前管理)

const { chromium } = require('playwright');

// ブラウザをリクエスト間で共有するための管理が必要
let browser;

async function getBrowser() {
  if (!browser || !browser.isConnected()) {
    browser = await chromium.launch({
      args: ['--no-sandbox', '--disable-setuid-sandbox'],
    });
  }
  return browser;
}

async function generatePdfWithPlaywright(html) {
  const browser = await getBrowser();
  const context = await browser.newContext();
  const page    = await context.newPage();

  try {
    await page.setContent(html, { waitUntil: 'networkidle' });
    const pdf = await page.pdf({
      format: 'A4',
      margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
      printBackground: true,
    });
    return pdf;
  } finally {
    await context.close(); // リークを防ぐために finally で必ず閉じる
  }
}

// 他にも必要なもの:
// - ブラウザのヘルスチェックと再起動ロジック
// - 並行処理の制限(セマフォ or キュー)
// - ページごとのタイムアウト処理
// - npx playwright install chromium(数百MBのダウンロード、環境ごとに必要)
// - @playwright/test アップデートごとの動作確認

After: マネージドAPI

async function generatePdf(html) {
  const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      options: {
        format: 'A4',
        margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
        printBackground: true,
      },
    }),
  });

  if (!response.ok) throw new Error(`PDF API error: ${response.status}`);
  return Buffer.from(await response.arrayBuffer());
}

// これだけで完成です。
// ブラウザ管理・コンテキスト・プール・Chromiumインストール、すべて不要。

Playwright版はPDFエンドポイントごとに約40行のインフラ管理コードが必要です。API版は12行で、どこでも再利用できます。

Before / After:Python コード比較

Before: Playwright(Python非同期版)

import asyncio
from playwright.async_api import async_playwright

async def generate_pdf_playwright(html: str) -> bytes:
    async with async_playwright() as p:
        # Chromiumの起動に1〜3秒かかる(コールドスタート時)
        browser = await p.chromium.launch()
        context = await browser.new_context()
        page    = await context.new_page()

        try:
            await page.set_content(html, wait_until="networkidle")
            pdf = await page.pdf(
                format="A4",
                margin={"top": "20mm", "bottom": "20mm",
                        "left": "15mm", "right": "15mm"},
                print_background=True,
            )
        finally:
            await browser.close()

        return pdf

# 必要なもの:
# pip install playwright
# playwright install chromium   # 環境ごとに〜300MBのダウンロード

After: マネージドAPI(Python)

import os
import requests

def generate_pdf(html: str) -> bytes:
    resp = requests.post(
        "https://pdf.funbrew.cloud/api/v1/pdf/generate",
        headers={
            "X-API-Key": os.environ["FUNBREW_PDF_API_KEY"],
            "Content-Type": "application/json",
        },
        json={
            "html": html,
            "options": {
                "format": "A4",
                "margin": {
                    "top": "20mm", "bottom": "20mm",
                    "left": "15mm", "right": "15mm",
                },
                "printBackground": True,
            },
        },
        timeout=30,
    )
    resp.raise_for_status()
    return resp.content

FastAPI・Starlette・Django Channels など非同期Pythonアプリには httpx を使います。

import os
import httpx

async def generate_pdf_async(html: str) -> bytes:
    async with httpx.AsyncClient(timeout=30.0) as client:
        response = await client.post(
            "https://pdf.funbrew.cloud/api/v1/pdf/generate",
            headers={
                "X-API-Key": os.environ["FUNBREW_PDF_API_KEY"],
                "Content-Type": "application/json",
            },
            json={
                "html": html,
                "options": {
                    "format": "A4",
                    "margin": {
                        "top": "20mm", "bottom": "20mm",
                        "left": "15mm", "right": "15mm",
                    },
                    "printBackground": True,
                },
            },
        )
        response.raise_for_status()
        return response.content

パフォーマンス比較

実測値の目安です(環境・テンプレートの複雑さにより変動します)。

レスポンスタイム

シナリオ Playwright(コールドスタート) Playwright(ウォームプール) FUNBREW PDF API
シンプルなHTML(〜10KB) 3,000〜5,000ms 500〜800ms 300〜600ms
画像入りHTML(〜100KB) 4,000〜8,000ms 800〜1,500ms 500〜1,200ms
複雑なCSS + JS グラフ 6,000〜15,000ms 1,500〜3,000ms 800〜2,000ms
Lambdaコールドスタート 8,000〜15,000ms 300〜600ms

メモリ使用量

Playwright(3プロセスプール、Node.js):
  アイドル時:       600MB〜1.5GB
  PDF生成中:        1GB〜2GB+
  10件同時処理時:   3GB+

FUNBREW PDF APIクライアント:
  アイドル時:       <10MB
  PDF生成中:        <50MB(HTTPレスポンスのバッファリングのみ)
  100件同時処理時:  ほぼ同じ(ローカルにChromiumなし)

Dockerイメージサイズ

# Before: Node.js + Playwright + Chromium
FROM node:20
RUN npx playwright install chromium   # 〜300MBのダウンロード
# 合計: 900MB以上

# After: APIクライアントのみ
FROM node:20-alpine
# Chromium不要
# 合計: 〜150MB

イメージの軽量化でCIビルド時間短縮・デプロイ高速化・コンテナレジストリのストレージ削減が見込めます。コンテナ環境での詳細はDocker・Kubernetes向けPDF API運用ガイドを参照してください。

コスト比較:セルフホスト vs マネージドAPI

セルフホストのコスト内訳

サーバー費用

Playwright 3プロセスプール(AWS t3.medium: 2vCPU / 4GB RAM):
  オンデマンド:         約 $33/月
  安全のため4GB以上推奨: t3.large に升格 = 約 $66/月

開発・メンテナンス工数

初期実装(プール + リトライ + タイムアウト): 8〜16時間
Playwright/Chromiumのバージョンアップ対応:  2〜4時間 × 年3〜4回
障害対応(OOM、ゾンビコンテキスト):        2〜8時間 × 月0〜2回

年間メンテナンスコスト試算:                 エンジニア時間 20〜50時間

CI/CDのオーバーヘッド

Playwright Chromiumのダウンロードは毎CIラン2〜5分の追加が発生します。900MB以上のDockerイメージはプッシュ・プルが遅く、レジストリストレージコストも増加します。

FUNBREW PDF APIのコスト

無料プラン: 月30件まで無料
有料プラン: 件数に応じた従量課金(/pricing参照)
メンテナンス費: ほぼゼロ(HTTPクライアントコードのみ)

損益分岐点の目安

月間件数 セルフホスト FUNBREW PDF API 判定
〜100件 $10〜30 + エンジニア工数 無料〜低コスト APIが明らかに有利
100〜10,000件 $30〜100 + メンテ工数 従量課金 工数込みでAPIが有利なことが多い
10,000件以上 専用インフラ + 運用チーム API側でスケール 要件次第

コスト計算で見落とされやすい変数は「エンジニアの機会コスト」です。Chromium管理に使った時間は、収益につながる機能開発には使えません。

移行手順

Step 1: 出力品質の確認

既存のHTMLテンプレートをPlaygroundに貼り付けて、Playwright での出力と並べて比較します。どちらも Chromium ベースのため、ほとんどのケースで出力はほぼ同一です。

Step 2: APIキーの取得

# .env に追加
FUNBREW_PDF_API_KEY=your_key_here

無料プラン(月30件)でAPIキーを取得し、移行前に動作確認できます。

Step 3: PDF生成関数の差し替え

既存の実装と同じシグネチャのラッパー関数を作ります。

// utils/pdf.js — Playwright ベースの generatePdf() のドロップイン置き換え
export async function generatePdf(html, options = {}) {
  const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      options: {
        format: 'A4',
        margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
        printBackground: true,
        ...options,
      },
    }),
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`PDF API error ${response.status}: ${body}`);
  }
  return Buffer.from(await response.arrayBuffer());
}

既存の呼び出し箇所(const pdf = await generatePdf(html))はすべて変更不要です。

Step 4: CSSメディアクエリの確認

Playwright のデフォルトは screen メディアタイプ、API のデフォルトは print です。@media screen のスタイルを確認して変換します。

/* Before: screen限定(APIのprintメディアタイプでは無視される) */
@media screen {
  .invoice { padding: 40px; background: #fff; }
}

/* After: 無条件に適用するか、print向けに明示 */
.invoice { padding: 40px; background: #fff; }
/* または */
@media print {
  .invoice { padding: 40px; background: #fff; }
}

Step 5: SSR依存パターンの変換

page.waitForSelector()page.evaluate() でデータを注入していた場合はサーバーサイドに移動します。

// Before: PlaywrightでのDOM操作
await page.evaluate((data) => {
  document.querySelector('#total').textContent = data.total;
  document.querySelector('#customer').textContent = data.customerName;
}, invoiceData);
const pdf = await page.pdf();

// After: サーバーサイドでHTMLを生成してからAPI呼び出し
const html = renderInvoiceTemplate(invoiceData); // 完全なHTMLを先に生成
const pdf  = await generatePdf(html);            // APIに渡す

Step 6: Playwrightの依存を削除

npm uninstall playwright @playwright/test

# Dockerfileから削除:
# RUN npx playwright install chromium

# CI/CDから削除:
# - Playwright Chromiumのキャッシュステップ
# - PLAYWRIGHT_BROWSERS_PATH 環境変数

移行後の本番運用チェックリストはPDF API本番運用チェックリストを参照してください。

移行時によくある問題と解決策

問題1: 外部画像が表示されない

原因: APIサーバーが localhost やプライベートネットワーク上のURLにアクセスできない。

解決策: 画像を Base64 データURIに変換してHTMLに埋め込みます。

import fs from 'fs';
import path from 'path';

function embedImage(imagePath) {
  const ext  = path.extname(imagePath).slice(1);
  const data = fs.readFileSync(imagePath).toString('base64');
  return `data:image/${ext};base64,${data}`;
}

const html = `<img src="${embedImage('./logo.png')}" alt="ロゴ">`;

問題2: Google Fontsが読み込まれない

原因: ネットワークレイテンシまたはアウトバウンドアクセス制限によりフォントリクエストがタイムアウトする。

解決策: waitForNetworkIdle: true(デフォルト有効)を使うか、フォントをBase64でHTMLに埋め込みます。日本語テキストについては FUNBREW PDF に Noto Sans JP がプリインストールされているため追加対応は不要です。

<style>
  /* Google Fonts URL(外部アクセスが可能な場合) */
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP&display=swap');

  /* または確実に動作するBase64埋め込み */
  @font-face {
    font-family: 'MyFont';
    src: url('data:font/woff2;base64,d09GMgAB...') format('woff2');
  }
  body { font-family: 'Noto Sans JP', sans-serif; }
</style>

問題3: Chart.jsのグラフが空白になる

原因: PDFが生成される時点でグラフアニメーションがまだ実行中。

解決策: animation: false を設定し、CDNからChart.jsを読み込む場合は waitForNetworkIdle: true を指定します。

new Chart(ctx, {
  type: 'bar',
  data: { ... },
  options: {
    animation: false,  // PDF生成時は必須
    responsive: true,
  },
});

Chart.js + PDF の完全なパターンはPDFレポート自動生成ガイドを参照してください。

問題4: タイムアウトエラーが発生する

解決策: 指数バックオフ付きのリトライロジックを実装します。

async function generatePdfWithRetry(html, options = {}, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const controller = new AbortController();
      const timeoutId  = setTimeout(() => controller.abort(), 30_000); // 30秒

      const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
        method: 'POST',
        headers: {
          'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ html, options }),
        signal: controller.signal,
      });

      clearTimeout(timeoutId);

      if (response.status === 429 && attempt < maxRetries) {
        // レート制限: 指数バックオフで待機してリトライ
        await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
        continue;
      }

      if (!response.ok) throw new Error(`API error: ${response.status}`);
      return Buffer.from(await response.arrayBuffer());
    } catch (err) {
      if (attempt === maxRetries) throw err;
      await new Promise(r => setTimeout(r, 1000 * attempt));
    }
  }
}

完全なリトライ・アラートパターンはPDF APIエラーハンドリング完全ガイドを参照してください。

移行チェックリスト

移行前
  [ ] Playground でHTMLを貼り付けて Playwright の出力と比較
  [ ] 無料プランに登録し FUNBREW_PDF_API_KEY を取得
  [ ] generatePdf(html, options) ラッパー関数を作成
  [ ] ステージング環境でPDF出力を目視比較

CSS確認
  [ ] @media screen / @media print の使い分けを確認
  [ ] page-break-before / break-before: page の動作確認
  [ ] 外部フォントを @font-face Base64 に変換済みか確認
  [ ] printBackground: true で背景色・背景画像が表示されるか確認

コード確認
  [ ] page.waitForSelector() 呼び出しをSSRに変換
  [ ] page.evaluate() DOM操作をテンプレートレンダリングに移行
  [ ] ローカル画像パスを Base64 に変換済みか確認

本番移行
  [ ] エラーハンドリング(429はリトライ、5xxはアラート)を実装
  [ ] クライアントタイムアウトを30秒に設定
  [ ] npm uninstall playwright @playwright/test を実行
  [ ] Dockerfileから npx playwright install を削除
  [ ] CI/CDのPlaywrightキャッシュを削除
  [ ] サーバーのメモリ割り当てを見直し(Chromium不要になるため削減可能)

移行による改善ポイント

項目 Playwright(自前管理) FUNBREW PDF API
メモリ使用量 200〜500MB/コンテキスト ほぼゼロ(HTTPクライアントのみ)
コールドスタート 1〜3秒 なし
並行処理 手動のコンテキストプール管理 API側で自動スケーリング
Chromiumバージョン管理 アップデートごとに対応必要 不要
サーバーレス(Lambda/Cloud Run) レイヤー等の工夫が必要 そのまま動作
Dockerイメージサイズ 900MB以上 〜150MB
Lambdaコールドスタート 8〜15秒 300〜600ms
CIビルド時間 +2〜5分(Chromiumダウンロード) 変化なし

まず試してみる

Playgroundで既存のHTMLテンプレートを貼り付けるだけでPDF出力をすぐに確認できます(登録不要)。コードでテストする場合は無料プラン(月30件)でAPIキーを取得してステージング環境で動作確認できます。

APIの詳細仕様はドキュメントを参照してください。

関連リンク

Powered by FUNBREW PDF