AWS

PuppeteerでPDFを生成する方法|page.pdfの設定とAWS Lambda(@sparticuz/chromium)での動かし方

PuppeteerでPDFを生成する方法|page.pdfの設定とAWS Lambda(@sparticuz/chromium)での動かし方

PuppeteerのPDF生成はpage.pdf()を呼ぶだけで動きます。ただ、用紙が日本の帳票と合わない、改ページで表が崩れる、AWS Lambdaに載せたら起動しない、日本語が□になる、といった問題は、どれも既定値や版の組み合わせを知らないと避けられません。この記事では、page.pdf()の指定をpuppeteer-core 25.12.0とChrome 154で実測した結果とともに整理し、Lambdaで動かすときの版の合わせ方・サイズ上限・フォント配置を2026年10月時点の数値で示します。

まとめ

  • 用紙:formatの既定はletterです。A4の帳票ならformat: 'A4'を必ず書きます。
  • 戻り値:v23.0.0以降page.pdf()はUint8Arrayを返します。pdf.toString('base64')は例外を出さずに数値の羅列を返すので、Buffer.from(pdf)で包んでから変換します。
  • 改ページ:CSSのbreak-before: pageが効き、<thead>の見出し行は各ページに繰り返されます。outline: trueで見出しからしおりが作られます。
  • Lambdaの版:@sparticuz/chromium@153には[email protected]を合わせます。最新の25.13.0はChrome 155向けです。
  • サイズ:153.0.0のchromium.brは67.0MBで、コンソールからのZIPアップロード上限50MBを単体で超えます。実行時に展開される209.0MBは/tmpを消費します。
  • 日本語:Lambdaにはフォントが無く、同梱のOpen Sansに日本語は含まれません。fontsディレクトリごとレイヤで持ち込みます。

page.pdfでPDFを出力する基本コード

URLを開いてPDF化する最小実装

npm install puppeteerで導入すると、対応するChromeが同時にダウンロードされます。PDF生成は次の数行で完結します。

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.pdf({ path: 'out.pdf', format: 'A4', printBackground: true });
await browser.close();

format: 'A4'を省くとLetter(8.5×11インチ)で出ます。PDFOptionsの公式リファレンスの既定値はletterで、実際に何も指定せず出力したPDFのページサイズは612×792pt、A4指定時は595.92×841.92ptでした。用紙指定のないサンプルを流用するとLetterで出力されるため、A4で印刷する帳票では生成したPDFの用紙サイズも確認してください。

ダウンロードされたブラウザはv19.0.0以降、node_modulesではなく$HOME/.cache/puppeteerに置かれます。プロジェクトをZIPに固めて別環境へ運んでもブラウザ本体は付いてきません。この性質が、後半で扱うLambdaの構成を決めます。

HTML文字列から帳票を作るsetContent

請求書や明細書のように、サーバー側でHTMLを組み立ててPDFにする用途ではpage.goto()ではなくpage.setContent()を使います。外部の画像やWebフォントを読み込む場合は、読み込みが終わるまで待つ条件を付けます。

const html = renderInvoice(data); // テンプレートエンジンで組み立てたHTML
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({ format: 'A4', printBackground: true });

page.pdf()はwaitForFontsが既定でtrueのため、document.fonts.readyの解決を待ってから出力します。固定時間の待機は要りません。古い記事にあるpage.waitForTimeout()はv22.0.0(2024年2月5日)で削除されており、呼んだ時点でエラーになります。

v23以降のUint8ArrayでBase64返却が壊れる条件

puppeteer-core v23.0.0(2024年8月7日)の破壊的変更「use Uint8Array instead of Buffer for browser compatibility」で、page.pdf()の戻り値はPromise<Uint8Array>になりました。Uint8ArrayのtoString()は引数を無視し、要素をカンマ区切りで並べた文字列を返します。

// %PDF-1.4 の先頭8バイトで確認(Node.js v26.5.0)
const u = new Uint8Array([0x25,0x50,0x44,0x46,0x2d,0x31,0x2e,0x34]);

u.toString('base64');              // '37,80,68,70,45,49,46,52'(引数が無視される)
Buffer.from(u).toString('base64'); // 'JVBERi0xLjQ='(正しい)

例外が出ないので、API GatewayへBase64で返す実装が残っていると、ログに何も出ないまま壊れたファイルが配信され続けます。v22以前のコードを移行するときは、Base64化のほかにBuffer専用メソッドを使っている箇所も洗い出してください。

page.pdfの用紙・改ページ・しおりの設定

printメディアと背景色が落ちる理由

画面とPDFの見た目が違う原因の多くはメディアタイプです。page.pdf()の公式リファレンスにあるとおり、page.pdf()はprintのCSSメディアタイプで描画します。@media printで隠した要素はPDFにも出ません。画面と同じ見た目にしたいときは、出力前にpage.emulateMediaType('screen')を呼びます。

背景色は2段構えです。printBackground: trueで背景の描画を有効にし、印刷向けの色変換を止めたい要素にはCSSで-webkit-print-color-adjust: exactを指定します。片方だけでは色が出ない場合があるので、両方を確認してください。

用紙・余白・ページ番号の指定

オプション 既定値 用途
format letter A4などの用紙
margin 未設定 上下左右の余白
printBackground false 背景の描画
landscape false 横向き
scale 1 描画倍率
displayHeaderFooter false ヘッダー・フッター
preferCSSPageSize false CSSの@pageを優先
tagged true タグ付きPDF
outline false しおり
timeout 30000 出力の待ち時間(ms)

ページ番号はdisplayHeaderFooter: trueとfooterTemplateで入れます。テンプレート内ではpageNumberとtotalPagesのクラスを持つ要素に値が差し込まれます。テンプレートは本文のCSSを引き継がないので、フォントサイズはテンプレート内に書きます。

await page.pdf({
  format: 'A4',
  margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
  displayHeaderFooter: true,
  headerTemplate: '<span></span>',
  footerTemplate:
    '<div style="font-size:9px;width:100%;text-align:center;">' +
    '<span class="pageNumber"></span> / <span class="totalPages"></span></div>',
});

フッターが本文に重なるときは、まずmargin.bottomにフッターを収める余白があるかを確認します。用紙サイズをCSSの@pageで管理しているなら、formatではなくpreferCSSPageSize: trueにすると二重管理を避けられます。

CSSによる改ページと表ヘッダーの繰り返し

章の区切りで改ページしたいときは、CSSのbreak-beforeにpageを指定します。既定のスタイルのまま、h1見出し・2列120行の表・break-before: pageを付けた2つ目のh1を持つHTMLをsetContent()で渡し、A4で出力したところ、次の結果になりました(puppeteer-core 25.12.0、chrome-headless-shell 154.0.8037.57、macOS)。ページ数はフォントや行の高さで変わるので、結果は構造の確認として読んでください。

確認項目 結果
表の見出し行(thead) 表がかかる1〜4ページ目に再描画
break-before: page の見出し 5ページ目の先頭から開始
outline: true h1の2件がしおりに入る
tagged: false 構造ツリーが消える

表の見出し行を各ページに出すには、見出し行を<thead>に入れるだけで足ります。<tbody>の1行目を太字にして見出し代わりにしている帳票では繰り返されません。1件の明細を途中で分割させたくない場合は、その要素にbreak-inside: avoidを指定します。

ログイン必須ページ・社内ページの認証とCSS取得

会員サイトや管理画面をPDFにするには、ログイン済みの状態を作ってからpage.goto()します。フォームに入力してログインする方法と、Cookieを渡す方法があります。Cookieを渡す場合、v25の型定義ではpage.setCookie()が非推奨になっており、browser.setCookie()を使います。

await browser.setCookie({
  name: 'session',
  value: process.env.SESSION_ID,
  domain: 'admin.example.com',
});
const page = await browser.newPage();
await page.goto('https://admin.example.com/report', { waitUntil: 'networkidle0' });

Lambdaなど社外の実行環境から社内ネットワークのページを開くと、HTMLは渡せてもCSSや画像の取得に失敗し、スタイルの無いPDFになります。setContent()で渡すHTMLにCSSを<style>で埋め込み、画像はBase64のdata URLにしておけば、外部への通信なしで出力できます。

AWS LambdaでPuppeteerを動かす構成

@sparticuz/chromiumとpuppeteer-coreの組み合わせ

Lambdaでは、サーバーレス向けにビルドされたChromiumを@sparticuz/chromiumで調達し、Puppeteerはブラウザを同梱しないpuppeteer-coreを使います。READMEによると、このパッケージは特定のPuppeteer版に縛られず、Chromiumと展開処理、サーバーレス向けの起動引数だけを提供します。版はPuppeteer側の対応Chromeから逆算して選びます。

puppeteer-core 公開日 対応Chrome
25.10.0 2026-09-03 152.0.7977.75
25.11.0 2026-09-14 153.0.8010.36
25.12.0 2026-09-23 154.0.8037.57
25.13.0 2026-10-08 155.0.8059.39

2026年10月12日時点の@sparticuz/chromiumの最新は153.0.0(2026年9月11日公開)です。したがって@sparticuz/chromium@153と[email protected]の組み合わせで固定します。最新同士を入れると、Puppeteerは155を前提にCDPコマンドを送るため、差分に起因する不具合を踏む余地が残ります。対応表の一次情報はPuppeteerリポジトリのversions.jsonです。同じファイルのlastMaintainedChromeVersion(現在152.0.7977.75)は対応版ではなく、メンテナンス対象の最も古い版を指します。

@sparticuz/[email protected]のenginesは^22.17.0 || >=24.0.0です。Lambdaのランタイム一覧ではnodejs20.xの非推奨日が2026年4月30日に過ぎているので、nodejs22.xかnodejs24.xを選びます。

ZIP・レイヤ・コンテナイメージの選び方

配置方法は、Lambdaのクォータから逆算して決まります。

項目 上限
ZIP(API・SDK・コンソール経由) 50MB
ZIPの展開後(レイヤ込み) 250MB
コンテナイメージ(非圧縮) 10GB
関数あたりのレイヤ 5個
/tmp 512MB〜10,240MB
同期呼び出しのレスポンス 6MB(ストリーミングは200MB)

@sparticuz/[email protected]のbin/chromium.brは67.0MBです。Brotliで圧縮済みのファイルはZIPにしてもほとんど縮まないので、このパッケージを含めたZIPはコンソールの50MB上限を超え、S3経由でのアップロードが必要になります。展開後250MBの上限で数えるのは、ZIPを展開した状態で残るchromium.brなどのbin一式(npmのunpackedSizeでパッケージ全体約70.1MB)と依存ファイル、レイヤの合計です。

Brotliを展開した後のChromium本体は209.0MBあり、こちらは実行時に/tmpへ書き出されます。149.0.0では61.8MBと190.6MBだったので、153.0.0までに実行時の展開サイズが約18MB増えています。

  • 依存が軽い:S3経由でZIPをアップロードする。Chromiumをレイヤに分けると、関数本体の更新のたびに大きなファイルを送らずに済む。
  • 依存が重い・日本語フォントも入れる:最初からコンテナイメージにして10GBの枠を使う。手順はGo言語のLambda関数をコンテナイメージでデプロイする手順|provided.al2023対応の流れが応用できます。
  • arm64(Graviton)で動かす:npmの@sparticuz/chromiumはx64のバイナリしか含みません。@sparticuz/chromium-minを入れ、GitHubリリースのchromium-VERSION-layer.arm64.zipをレイヤにするか、chromium-VERSION-pack.arm64.tarをHTTPSで置いてexecutablePath()にURLを渡します。

版を上げたときは、配布ZIPを展開したファイルと全レイヤの合計が250MB以内かを確認します。レイヤに分けても合計の上限は増えません。日本語フォントや他の依存で250MBを超えるなら、コンテナイメージか-minパッケージによるバイナリの外部配置を選びます。

Lambdaハンドラの実装とheadlessの指定

次の例は、JSONのhtmlフィールドで受け取ったHTMLをPDFにして返します。API GatewayのREST APIでPDFをそのまま返すには、isBase64Encoded: trueに加えて、APIのバイナリメディアタイプの設定が必要です。ブラウザから呼ぶ場合はAcceptヘッダーの順序を制御できないため、公式は*/*を指定する方法を案内しています。

import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';

export const handler = async (event) => {
  const browser = await puppeteer.launch({
    args: await puppeteer.defaultArgs({ args: chromium.args, headless: 'shell' }),
    defaultViewport: { width: 1280, height: 800, deviceScaleFactor: 1 },
    executablePath: await chromium.executablePath(),
    headless: 'shell',
  });

  try {
    // API Gatewayのプロキシ統合では本文がevent.bodyに文字列で入る
    const input = typeof event.body === 'string'
      ? JSON.parse(event.isBase64Encoded
          ? Buffer.from(event.body, 'base64').toString('utf8')
          : event.body)
      : event;
    if (typeof input.html !== 'string') throw new Error('html must be a string');

    const page = await browser.newPage();
    await page.setContent(input.html, { waitUntil: 'networkidle0' });
    const pdf = await page.pdf({ format: 'A4', printBackground: true });

    return {
      statusCode: 200,
      headers: { 'Content-Type': 'application/pdf' },
      body: Buffer.from(pdf).toString('base64'),
      isBase64Encoded: true,
    };
  } finally {
    for (const p of await browser.pages()) await p.close();
    await browser.close();
  }
};

headless: 'shell'が要点です。Puppeteer v22以降、headless: trueは通常のChromeと同等の新ヘッドレスモードを指し、旧来の軽量版はchrome-headless-shellとして'shell'で指定します。@sparticuz/chromiumはこのheadless_shellを元にビルドされており、READMEも、このバイナリには新ヘッドレスモードが含まれていないようだと記しています。ローカルでは通常のChromeを使い、Lambdaでは'shell'に切り替える分岐を環境変数で持たせておきます。

finallyでページとブラウザを閉じているのは、READMEが「Chromiumが想定より多くのページを開いて終了に失敗する」事例を挙げているためです。Puppeteerは一時プロファイルを/tmpに作り、ブラウザのプロセスが終了した時点で削除します。browser.close()に到達しないままウォームスタートで実行環境が使い回されると、残ったプロファイルが/tmpを圧迫し続けます。

Webpackやesbuildでバンドルする場合は、@sparticuz/chromiumを外部依存(external)に指定します。バンドルに含めるとbinディレクトリが見つからず、The input directory "/var/task/bin" does not existで失敗します。

メモリ・タイムアウト・/tmpの設定値

READMEは割り当てメモリを最低512MB、1600MB以上を推奨としています。Lambdaのメモリ設定はメモリに比例してCPUを割り当て、1,769MBで1vCPU相当です。Chromiumの描画はCPUを使うため、メモリを絞ると単価は下がっても実行時間が伸びます。1,769MB前後から始め、実行時間とコストを測って詰めてください。

タイムアウトが頻発するときは、上限を延ばす前に待機条件を疑います。networkidle0は通信が500ms途切れるまで待つので、解析タグや常時接続のあるページでは解決しないことがあります。/tmpは既定の512MBでも展開後209.0MBのChromiumは収まりますが、Puppeteerの一時プロファイルや生成したPDFも同じ領域を使うので、実サイズを測って広げます。

同期レスポンスの6MB上限には、Base64化で約33%膨らんだ本文とJSONの付帯情報が含まれます。PDF本体で使えるのは4.5MBより少し小さい値なので、余裕を持ってS3経由の返却へ切り替えます。ページ数が読めない帳票は、S3へ保存して署名付きURLを返す設計にしておくと上限を気にせずに済みます。料金とコールドスタートの考え方はAWS Lambdaとは?仕組み・料金体系とコールドスタート対策・採用判断を実装者目線で解説にまとめています。

日本語PDFが豆腐になる原因とフォントレイヤ

Lambdaで生成したPDFの日本語が□になるのは、設定ミスではなく既定の状態です。@sparticuz/chromiumのREADMEは、Lambdaのランタイムにはフォントが1つも入っていないと明記しています。同梱されるのはOpen Sansだけで、対応はLatin・Greek・Cyrillicの3つです。ローカルで正しく出ていたのは、macOSやWindowsのフォントに暗黙に頼っていたためです。

レイヤで持ち込む場合は、日本語フォントをfontsという名前のディレクトリに入れてZIPにし、レイヤとして追加します。

mkdir fonts
cp NotoSansJP-Regular.ttf fonts/
zip -9 --filesync --move --recurse-paths fonts.zip fonts/

探索先はbin/fonts.tar.br内のfonts.confで決まっており、/var/task/.fonts、/var/task/fonts、/opt/fonts、/tmp/fontsの4か所です。レイヤは/optに展開されるので、fonts/ごとZIPにすれば/opt/fontsに一致します。READMEは.fontsも挙げていますが、同梱のfonts.confが探す.fontsは関数パッケージ内の/var/task/.fontsだけで、レイヤ直下に置いた/opt/.fontsは探索されません。ディレクトリ名をfontやNotoSansJPにした場合も同様に外れ、レイヤを付けても豆腐のままです。

CSSのfont-familyには持ち込んだフォント名を明示します。日本語フォントは1ウェイトで数MBあるため、展開後250MBの枠を使い切らないよう、必要なウェイトだけを入れてください。

タグ付きPDFがLambdaで出ない理由

taggedの既定はtrueで、ローカルのChromeでは文書構造を持つPDFが出ます。前述の実測でも、既定の出力には構造ツリー(StructTreeRoot)が含まれ、tagged: falseで消えました。

ところが@sparticuz/chromiumは、PDFのタグ付けを有効にしないビルド設定で作られています。READMEは、アクセシブルなPDFが必要ならenable_tagged_pdf = trueを加えてChromiumを自分でビルドし、@sparticuz/chromium-minと組み合わせるよう案内しています。スクリーンリーダー対応や公共調達の要件でタグ付きPDFが必要な場合は、既製の@sparticuz/chromiumでは満たせません。自前ビルドを持つか、コンテナイメージにディストリビューションのChromiumを入れる構成を検討してください。

chrome-aws-lambdaから移行するときに動かなくなる箇所

日本語の解説には、いまもchrome-aws-lambda前提のコードが多く残っています。npmでの最新公開は10.1.0(2021年7月17日)で止まっており、peerDependenciesもpuppeteer-core: ^10.1.0のままです。GitHubリポジトリはアーカイブされていませんが、その後のChromiumとPuppeteerの版には追随していません。新規に組むなら@sparticuz/chromiumを選びます。

@sparticuz/[email protected]のデフォルトエクスポートが持つのはargs、graphics、setGraphicsMode、executablePath()の4つだけです。

旧 [email protected] 現行での書き方
chromium.font(url) 廃止。fontsレイヤで配置
chromium.headless 廃止。launchに’shell’
chromium.defaultViewport 廃止。launchで自前指定
chromium.puppeteer 廃止。puppeteer-coreをimport
await chromium.executablePath await chromium.executablePath()

見落としやすいのはexecutablePathです。旧パッケージではプロパティだったため括弧なしで書けましたが、現行はメソッドです。括弧を付け忘れると関数オブジェクトがそのままlaunch()に渡ります。

移行後は、出力サイズと処理時間をログに残してください。Base64の破損のように例外を伴わない失敗は、サイズの監視に加え、復号後の先頭が%PDF-で始まるかを確かめるだけでも検出できます。ログの設計はAWS Lambdaのログ運用ガイド|CloudWatch Logsの見方・JSON構造化・S3出力とコスト削減、実行時間とメモリの監視はAWS LambdaをCloudWatchで監視する方法|標準メトリクス・ログ・アラーム・Lambda Insightsで扱っています。

Puppeteerを使わないほうがよい場面

Puppeteerの強みは、画面用のHTMLとCSSをそのままPDFにできる点です。レイアウトが固定の帳票を大量に作る用途では、ブラウザを起動するコストが割に合わなくなります。JavaScriptを使わずHTMLとCSSだけで帳票を組む選択肢は、WeasyPrintとは:HTMLとCSSで帳票PDFを作る使い方と日本語フォント・改ページの設定【2026年10月】で整理しています。既存のwkhtmltopdfから移行する場合の判断は、wkhtmltopdfとは:開発終了後の現状と日本語・改ページの対処、移行先の選び方で整理しています。

ファインディの支払明細書PDFの移行レポートでは、Lambda上のPuppeteerから、ブラウザを使わない@react-pdf/rendererへ移した結果、実行時間の中央値が約3,963msから約145msに、平均メモリが約912MBから約222MBに下がったと報告されています。移行前はChromiumの起動だけで5秒前後かかり、ハングしたプロセスの一時ファイルが/tmpに溜まってバッチが止まっていたとのことです。

  • Puppeteerを選ぶ:既存の画面やWebページをそのままPDFにしたい。CSSのグリッドや複雑な表、グラフ描画のJavaScriptを使う。生成件数が少なく、1件ごとの待ち時間が問題にならない。
  • PDF専用ライブラリを選ぶ:請求書のようにレイアウトが固定で、一括生成時のメモリ使用量やコールドスタートが処理期限・コストの制約になっている。

なお、ブラウザ自動化の他の手段との比較はBrowser Automationとは?ブラウザ操作を自動化する4つの方式と選び方【2026年】、Puppeteerと同系統のライブラリとの違いはPlaywrightとは|仕組み・できること・Seleniumとの違いを解説を参照してください。Lambdaを含むAWSのPDF生成基盤を設計から任せたい場合は、AWS・Google Cloud・Azureのインフラ構築支援でも相談を受け付けています。

よくある質問

PuppeteerのPDFがA4にならないのはなぜですか?

formatの既定値がletterだからです。page.pdf({ format: 'A4' })と明示してください。CSSの@page { size: A4; }で管理している場合は、preferCSSPageSize: trueを付けるとCSS側の指定が優先されます。

AWS Lambdaでpuppeteerを使うと「Could not find Chrome」で失敗するのはなぜですか?

ブラウザ本体がデプロイパッケージに含まれていないためです。Lambdaではpuppeteer-coreを使い、@sparticuz/chromiumのexecutablePath()の戻り値をlaunch()に渡します。ローカルでも、pnpmのようにインストールスクリプトを既定で止めるパッケージマネージャーではブラウザのダウンロードが飛ばされるので、npx puppeteer browsers installを実行します。

chrome-aws-lambdaは今も使えますか?

新規採用は避けてください。npmの最新は2021年7月17日公開の10.1.0で、peerDependenciesはpuppeteer-core: ^10.1.0のままです。後継の@sparticuz/chromiumへ移り、chromium.font()とchromium.headlessの箇所を書き換えます。

Lambdaで生成したPDFの日本語が□になります。どうすればよいですか?

Lambdaのランタイムにはフォントが無く、同梱のOpen Sansは日本語に対応していません。Noto Sans JPなどをfontsディレクトリに入れてZIPにし、レイヤとして追加します。レイヤで探索されるのは/opt/fontsなので、ZIP直下の名前はfontsにします。

headless: trueとheadless: ‘shell’はどちらを使うべきですか?

Lambdaで@sparticuz/chromiumを使うなら'shell'です。このバイナリはheadless_shellが元で、新ヘッドレスモードを持ちません。ローカルの同梱Chromeではtrueでも動くので、環境変数で切り替えます。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.29 テックブログ ソニー生命の情報漏洩38.5万件|ログインできない理由、再開状況と窓口の電話番号
  2. 2026.11.03 テックブログ ノジマ不正アクセスで約700万件漏えい:対象確認・窓口と乗っ取り注文を止める実装
  3. 2026.11.03 テックブログ クラブツーリズム不正アクセス:約432万件の対象確認と偽メールの見分け方・流出検知の設計
  4. 2026.11.02 テックブログ サントリーの不正アクセスで約10万人分漏えい|M365のファイル大量ダウンロードを監査ログで調べる手順
  5. 2026.11.02 テックブログ アララの不正アクセス:メール約7,700万件の持ち出しと契約企業が行うAPIキー再設定・代替送信・連名報告

RELATED POSTS 関連記事

目次