PuppeteerのPDF生成はpage.pdf()を1行呼ぶだけで動きますが、そのコードをAWS Lambdaへ持っていくと高い確率で失敗します。原因は環境ではなく、バージョン間で変わった仕様を踏んでいることがほとんどです。この記事ではpage.pdf()の指定を公式の記述にあたって整理したうえで、Lambdaで動かすときのバージョン整合・日本語フォント・メモリ設定を、実際の数値とともに示します。
まとめ
PuppeteerでのPDF生成でつまずく箇所は、おおむね次の4点に集約されます。
- 戻り値の型:v23.0.0以降
page.pdf()はUint8Arrayを返します。pdf.toString('base64')は例外を投げずに数値の羅列を返すため、壊れたPDFが黙って配信されます。 - バイナリの調達:v19.0.0以降ブラウザは
$HOME/.cache/puppeteerに置かれるため、node_modulesを固めても本体は入りません。Lambdaでは@sparticuz/chromiumで調達します。 - バージョン整合:2026年8月時点の対応版は
@sparticuz/chromium@149と[email protected]です。puppeteer-coreを最新の25.7.0にすると期待するChrome版がずれます。 - 日本語フォント:Lambdaランタイムにフォントは無く、同梱のOpen SansはLatin・Greek・Cyrillicのみです。指定しなければ日本語は豆腐(□)になります。
以下では、ローカルで動く最小コードから順に、この4点をどこでどう指定するかを見ていきます。
page.pdfでPDFを生成する最小コード
ローカルでの最小実装とブラウザが置かれる場所
Puppeteerを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();
注意したいのは、ダウンロードされたブラウザの置き場所です。Puppeteer v19.0.0以降、ブラウザはnode_modules配下ではなく$HOME/.cache/puppeteerにキャッシュされます。つまり、プロジェクトディレクトリをZIPに固めて別環境へ運んでもブラウザ本体は付いてきません。なおv21.6.0以降は、Chrome for Testingに加えてchrome-headless-shellのバイナリも同じキャッシュに入るため、後述するheadless: 'shell'はローカルでもそのまま試せます。
さらに、新しいRFCを適用したnpm、pnpm、Yarn Berry、Bun、Denoなど依存パッケージのインストールスクリプトを既定でブロックするパッケージマネージャーでは、自動ダウンロード自体がスキップされます。この状態で実行するとCould not find Chrome (ver. ...).で失敗するため、npx puppeteer browsers installを明示的に走らせるか、package.jsonのallowScriptsでPuppeteerのスクリプト実行を許可します。
Uint8Arrayへの変更でBase64返却が壊れる条件
page.pdf()の戻り値は、puppeteer-core v23.0.0(2024年8月7日リリース)の破壊的変更「use Uint8Array instead of Buffer for browser compatibility」によりBufferからUint8Arrayへ変わりました。現在のシグネチャはpdf(options?: PDFOptions): Promise<Uint8Array>です。
この変更が厄介なのは、失敗が例外にならない点です。Uint8ArrayにもtoString()は存在しますが、引数を無視して要素をカンマ区切りで並べた文字列を返します。API GatewayへBase64で返す実装がそのまま残っていると、エラーログには何も出ないまま破損したファイルが配信され続けます。
// %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=' こちらが正しい
v23.0.0以降はBuffer.from(pdf)でラップしてからエンコードします。既存コードを移行する際は、Base64化のほかにpdf.lengthを前提にしたバリデーションや、Buffer専用メソッドを使っている箇所も併せて洗い出してください。
CDPのPage.printToPDFとpage.pdfの関係
page.pdf()はPuppeteer独自のレンダラを持つわけではなく、Chrome DevTools Protocol(CDP)のPage.printToPDFをtransferMode: 'ReturnAsStream'で呼び、返ってきたストリームを読み切る実装です。landscapeやdisplayHeaderFooter、printBackground、scaleといったオプションはほぼそのままCDPのパラメータへ渡ります。
したがってCDPを直接叩く構成でも出力は同じで、Puppeteer側のオプション名で情報が見つからないときはPage.printToPDFのパラメータ名で調べると仕様に行き着きます。PuppeteerとCDPの関係そのものはPuppeteerとは?Chrome自動操作Nodeライブラリの仕組みと使い方【2026年最新】で整理しています。
出力を意図どおりに整えるpage.pdfのオプション
printメディア既定と背景色が落ちる理由
「ブラウザで見た画面と出力されたPDFが違う」という相談の大半は、メディアタイプの違いで説明がつきます。公式の注記どおり、page.pdf()はprintのCSSメディアタイプでページを生成します。@media printで要素を隠していれば当然PDFにも出ません。画面表示と同じ見た目で出したい場合は、PDF生成の前にpage.emulateMediaType('screen')を呼びます。
色も同様です。page.pdf()は既定で印刷向けに色を変換した状態のPDFを生成します。指定した色をそのまま出したい場合は、CSS側で-webkit-print-color-adjustを使って正確な色のレンダリングを強制し、背景の描画自体はprintBackground: trueで有効にします。この2つは役割が別なので、背景色が出ないときは両方を確認してください。
フォントについては、page.pdf()が既定でフォントの読み込み完了を待つ仕様です。挙動を切り替えたいときはwaitForFontsオプションで制御します。なお、Webフォント待ちにpage.waitForTimeoutを使っていた古いコードは、そもそもこのAPIがv22.0.0(2024年2月5日)で削除されているため呼んだ時点で落ちます。固定待機が残っているなら、page.pdf()の既定動作に任せるかpage.waitForFunctionへ置き換えてください。
用紙・余白・ヘッダーフッターの指定
レイアウト関連のオプションはPDFOptionsに定義されています。実務で指定頻度が高いものを挙げます。
| オプション | 型 | 用途 |
|---|---|---|
| format | PaperFormat | A4・letter等の用紙指定 |
| width / height | string | number | 用紙寸法の直接指定 |
| margin | PDFMargin | top / bottom / left / right |
| printBackground | boolean | 背景の描画 |
| landscape | boolean | 横向き |
| scale | number | 描画倍率 |
| pageRanges | string | 出力ページの絞り込み |
| displayHeaderFooter | boolean | ヘッダーフッターの有効化 |
| headerTemplate / footerTemplate | string | ヘッダーフッターのHTML |
| preferCSSPageSize | boolean | CSSの@pageサイズを優先 |
| tagged | boolean | タグ付きPDFの生成 |
| outline | boolean | しおりの生成 |
ページ番号はdisplayHeaderFooter: trueとfooterTemplateの組み合わせで入れます。テンプレート内ではpageNumberやtotalPagesの専用クラスが差し替わりますが、親ページのCSSは引き継がないので、フォントサイズや余白はテンプレート内で個別指定が必要です。用紙サイズをCSSの@pageで管理しているなら、formatよりpreferCSSPageSize: trueのほうが二重管理を避けられます。
AWS LambdaでPuppeteerとheadless Chromiumを動かす構成
@sparticuz/chromiumとpuppeteer-coreのバージョン整合
Lambdaでは、ブラウザ本体をサーバーレス向けにビルドした@sparticuz/chromiumで調達し、Puppeteerはブラウザを同梱しないpuppeteer-coreを使います。ここで最も事故が多いのがバージョンの組み合わせです。
@sparticuz/chromiumの版番号はMajorChromiumVersion.MinorChromiumIncrement.PatchLevelという体系で、Chromiumのリリースサイクルに追随します。READMEが明記しているとおり、これはセマンティックバージョニングではありません。パッチレベルでも破壊的変更が入りえます。一方のpuppeteer-coreは、自身のバージョンごとに対応するChrome版が決まっています。
| パッケージ | 版 | 公開日 | 対応Chrome/Chromium |
|---|---|---|---|
| @sparticuz/chromium | 149.0.0 | 2026-05-27 | 149系 |
| puppeteer-core | 25.1.0 | 2026-05-26 | 149.0.7827.22 |
| puppeteer-core(最新) | 25.7.0 | 2026-08-13 | 152.0.7977.42 |
つまり2026年8月時点で@sparticuz/chromium@149を使うなら、puppeteer-coreは最新の25.7.0ではなく25.1.0で固定するのが整合の取れた組み合わせです。最新同士を入れるとPuppeteer側がChrome 152を前提にした挙動を期待するため、CDPコマンドの差異に起因する不具合を踏む余地が生まれます。対応表はPuppeteerリポジトリのversions.jsonが一次情報です。
同じファイルのlastMaintainedChromeVersionは149.0.7827.22ですが、これは「対応版」ではなくPuppeteerがメンテナンスを続けるChromeの最も古い版=サポート窓の下端です。Chrome 149はまだ窓の内側ですが、次のバンプで外へ出ます。@sparticuz/chromium側に150以降が出た時点で両方を上げる前提で運用してください。enginesは@sparticuz/[email protected]が^22.17.0 || >=24.0.0、[email protected]が>=22.12.0なので、ランタイムは実質Node.js 22系以降です。
レイヤ方式とコンテナイメージ方式の選択基準
配置方法は、Lambdaのサイズ上限から逆算して決まります。旧来「Lambdaのディスクは512MBまで」と説明されることがありますが、実際にChromiumで効いてくる制限は別のものです。
| 項目 | 上限 |
|---|---|
| .zip(圧縮時・API/SDK/コンソール経由) | 50MB |
| .zip(展開後・レイヤとカスタムランタイム込み) | 250MB |
| コンテナイメージ(非圧縮・全レイヤ込み) | 10GB |
| 関数あたりのレイヤ数 | 5 |
| /tmp | 512MB〜10,240MB |
数字を当てると判断が早くなります。@sparticuz/[email protected]に含まれるbin/chromium.brは圧縮状態で約61.8MB、Brotliを展開すると約190.6MBです。この時点でコンソールからのZIPアップロード(50MB)は通らず、展開後250MBの枠に対しても残りは約59MBしかありません。
選択肢は3つです。ZIPで通すならS3経由でアップロードし、他の依存を切り詰めて展開後250MBに収める。レイヤに切り出して関数本体を軽く保つ。制約そのものから抜けたいならコンテナイメージにして10GBの枠を使う。アプリ側の依存が重いなら、残り59MBを削り合うより最初からコンテナイメージが素直です。デプロイ手順はGo言語のLambda関数をコンテナイメージでデプロイする手順|provided.al2023対応の流れがそのまま応用できます。
デプロイ先の制約でどうしてもバイナリを同梱できない場合は、Brotliファイルを含まない@sparticuz/chromium-minを使い、S3など近い場所に置いたchromium-VERSION-pack.arm64.tarを実行時に取得する構成にします。この場合、初回実行で/tmp/chromiumへ展開され、ウォームスタート時は展開済みのものが再利用されます。
Lambdaハンドラの実装とheadlessの指定
ハンドラは次の形になります。headlessに'shell'を指定している点が要点です。
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: 1920, height: 1080, deviceScaleFactor: 1 },
executablePath: await chromium.executablePath(),
headless: 'shell',
});
try {
const page = await browser.newPage();
await page.setContent(event.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 {
await browser.close();
}
};
headless: 'shell'は気分の問題ではありません。Puppeteer v22以降、headless: trueは通常のChromeと同じ機能を持つ新ヘッドレスモードを指し、旧来の軽量なヘッドレスはchrome-headless-shellという別バイナリに分離されheadless: 'shell'で指定する形になりました。@sparticuz/chromiumはGUIを含まないheadless.gnのビルド変数でビルドされているため、そもそもヘッドフルでは起動できません。ローカルでは通常のChromeを使い、Lambdaでは'shell'に切り替える分岐を環境変数で持たせておくと開発時に困りません。
browser.close()をfinallyに置いているのは、例外発生時にブラウザプロセスが残ったまま実行環境が再利用されるのを避けるためです。Lambdaは実行環境をウォームスタートで使い回すので、閉じ損ねたプロセスがメモリを占有し続けると後続の呼び出しが不安定になります。
メモリ・タイムアウト・一時領域の設定値
@sparticuz/chromiumのREADMEは、割り当てメモリを最低512MB、1600MB以上を推奨としています。Lambdaはメモリ量に比例してCPUを割り当てる設計で、1,769MBでちょうど1vCPU相当です。Chromiumのレンダリングは明確にCPUバウンドなので、メモリを絞ると単価は下がっても実行時間が伸び、合計コストが下がるとは限りません。1,769MB前後を起点に、AWS Lambda Power Tuningのような仕組みで実測して決めるのが確実です。
タイムアウトはLambdaの上限が900秒(15分)ですが、PDF生成でここまで必要になることは通常ありません。むしろ待ち条件の設計ミスでnetworkidle0が解決せず張り付いている場合が多いので、上限を延ばす前にpage.setContentやpage.gotoの待機条件を見直しましょう。/tmpは512MBから10,240MBまで1MB単位で設定できます。展開後190.6MBのChromiumを-minパッケージで実行時に展開する構成では既定の512MBが手狭になるため、生成物の一時保存と合わせて実サイズを測ってから設定してください。
コールドスタート時はChromiumの展開と起動が加算され、初回呼び出しだけ極端に遅くなります。同期的なAPI応答でPDFを返すか、S3へ置いて後から取得させるかは、この特性を踏まえて決めてください。課金体系とコールドスタート対策はAWS Lambdaとは?仕組み・料金体系とコールドスタート対策・採用判断を実装者目線で解説にまとめています。
日本語PDFが豆腐になる原因とフォントの配置先
Lambdaで生成したPDFの日本語が□(豆腐)になる現象は、設定ミスではなく既定の状態です。@sparticuz/chromiumのREADMEは「AWS Lambdaランタイムにはフォントフェイスが一切プロビジョニングされていない」と明記しており、その代わりに同梱されるのはOpen Sansのみです。Open Sansが対応するのはLatin・Greek・Cyrillicの3スクリプトで、日本語のグリフは含まれません。ローカルのmacOSやWindowsで正常に出ていたものがLambdaで崩れるのは、OS側のフォントに暗黙に依存していたためです。
解決策は、日本語フォントをLambda Layerで持ち込むことです。fontsまたは.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に一致します。ディレクトリ名をfontやNotoSansJPのように変えてしまうと探索対象から外れ、レイヤは付いているのに豆腐のまま、という状態になります。
日本語フォントはファイル自体が大きく、展開後250MBの枠を圧迫します。必要なウェイトだけに絞り、絵文字が要る場合のみNotoColorEmojiを足す順で組んでください。CSS側ではfont-familyに持ち込んだフォント名を明示します。フォールバック任せにすると、環境によって別のフォントが当たり行送りが変わります。
chrome-aws-lambda時代のコードが動かなくなる箇所
日本語の解説記事には、いまもchrome-aws-lambdaを前提にしたコードが多く残っています。このパッケージはGitHubリポジトリこそアーカイブされていないものの、npmでの最新公開は10.1.0(2021年7月17日)で止まっており、以降のChromiumにもPuppeteerにも追随していません。peerDependenciesもpuppeteer-core: ^10.1.0のままで、v23以降とは依存の範囲すら満たせない状態です。新規に組むなら@sparticuz/chromium一択と考えて差し支えありません。
移行で最初に踏むのは、デフォルトエクスポートのchromiumが持つメンバーが入れ替わっている点です。@sparticuz/[email protected]のchromiumが公開するのはargs、graphics、setGraphicsMode、executablePath(input?)の4つだけで、旧[email protected]にあった次のメンバーは存在しません(このほかモジュールとしてsetupLambdaEnvironmentとinflateが名前付きエクスポートされます)。
| 旧: [email protected] | 現行での扱い |
|---|---|
| font(input) | 廃止。フォントはLayerで配置 |
| headless(getter) | 廃止。launch()に’shell’を指定 |
| defaultViewport(getter) | 廃止。launch()で自前指定 |
| puppeteer(getter) | 廃止。puppeteer-coreを直接import |
| executablePath(getter) | executablePath()(メソッド)へ変更 |
とくに見落としやすいのがexecutablePathです。旧パッケージではプロパティ(getter)だったためexecutablePath: await chromium.executablePathと括弧なしで書けましたが、現行はメソッドなので、括弧を付け忘れると関数オブジェクトがそのままlaunch()へ渡ります。chromium.font()を残した場合は前節のレイヤ方式へ置き換えてください。
WebGLはsetGraphicsModeで制御でき、既定は有効です。launch()の前にchromium.setGraphicsMode = falseを置けばWebGLの初期化を省けますが、速度への効果はREADME自身が要検証としており、Chromiumの起動にはbin/swiftshader.tar.brの展開が必要な点も変わりません。過度な期待はしないほうが無難です。
移行後は、成否をレスポンスだけで判断せず、出力サイズと処理時間をログに残してください。Base64破損のように例外を伴わない失敗は、ログの数値でしか気づけません。ログ設計はAWS Lambdaのログ運用ガイド|CloudWatch Logsの見方・JSON構造化・S3出力とコスト削減、実行時間やメモリ使用量の継続的な監視はAWS LambdaをCloudWatchで監視する方法|標準メトリクス・ログ・アラーム・Lambda Insightsが扱っています。
よくある質問
Lambdaで生成したPDFが大きいとAPI経由の取得に失敗するのはなぜですか
Lambdaの同期呼び出しには、リクエスト・レスポンスとも6MBのペイロード上限があるためです。PDFをBase64で返す設計では符号化で容量が約33%増えるので、実ファイルが4.5MBを超えたあたりから上限に触れます。回避策は2つです。レスポンスストリーミングを使えば同期でも200MBまで扱えます。ページ数が読めない帳票なら、LambdaからS3へ直接保存して署名付きURLだけを返す設計のほうが安定するでしょう。
AWS Lambdaでpuppeteerを使うと「Could not find Chrome」で失敗するのはなぜですか
ブラウザ本体がデプロイパッケージに含まれていないためです。Lambdaではpuppeteerではなくpuppeteer-coreを使い、@sparticuz/chromiumのexecutablePath()が返すパスをlaunch()に渡してください。なお同じエラーはローカルでも起きます。pnpmやYarn Berry、Bun、Denoのようにインストールスクリプトを既定でブロックするパッケージマネージャーでは自動ダウンロードがスキップされるので、npx puppeteer browsers installを明示的に実行します。
chrome-aws-lambdaは今も使えますか
新規採用は避けるのが妥当です。npmでの最新公開は10.1.0で、2021年7月17日から更新されていません。GitHubのリポジトリはアーカイブ状態ではありませんが、その後のChromiumおよびPuppeteerのバージョンには追随していないため、現行のNode.jsランタイムやPuppeteer v23以降の仕様変更と噛み合いません。後継として広く使われているのは@sparticuz/chromiumで、こちらはChromiumのリリースに追随しています。既存コードを移行する場合は、chromium.font()とchromium.headlessが現行の型定義に存在しない点に注意してください。
Lambdaで生成したPDFの日本語が□になります。どうすればよいですか
AWS Lambdaのランタイムにはフォントが一切入っておらず、@sparticuz/chromiumが同梱するOpen SansもLatin・Greek・Cyrillicのみの対応だからです。Noto Sans JPなどをLambda Layerで持ち込んでください。その際、ディレクトリ名はfontsまたは.fontsにする必要があります。名前が違うと探索対象から外れ、レイヤをアタッチしているのに豆腐のまま、という状態になります。
headless: trueとheadless: ‘shell’はどちらを使うべきですか
実行環境で決まります。公式ドキュメントは、Chromeの全機能を必要としない自動化用途ではchrome-headless-shellのほうが現時点で高速だとしています。AWS Lambdaで@sparticuz/chromiumを使う場合はGUIを含まないビルドなので'shell'が前提です。ローカル開発でDOM挙動の再現性を優先するならtrueを選び、環境変数で切り替える構成にしておくと迷いません。