🤖

Puppeteer+Lambdaでヘッドレスかつサーバーレスな汎用PDF生成機能をつくってみた

2024/12/02に公開

はじめに

「文字化けのない、幸せなPDF生成」を目指して、Lambda+PuppeteerでヘッドレスでサーバーレスなPDF生成機能を作りました。

この実装では、HTMLを用いてPDFを生成し、その作ったPDFをS3に保存するか選択できる状態を実現します。

Puppeteerとは?

Puppeteer(パペティア)とは、Node.js上でChromeなどのウェブブラウザをプログラムから制御できるライブラリのことです。

プログラムからブラウザのボタンを押したり、クローリングしたりキーボード打ったりキャプチャとったり。

ヘッドレス、ヘッドフルどちらも設定可能で今回は完全バックグラウンドでPuppeteerを実行したかったので「ヘッドレス」モードにしました。

そもそもPuppeteerはブラウザを操作することができるライブラリですが、今回利用したのは「HTMLさえあればなんでもPDF化してくれる」機能です。

インフラ

汎用的な利用も念頭に置いて、サーバーサイドやフロントエンド両方から利用できるようにLambdaをAPI Gatewayで呼べるようにしました。

とってもざっくりですが、インフラ構成図はこんな感じ

ライブラリ

今回のプロジェクトは、LambdaにZipでデプロイする関係で容量制限を回避する必要があるため、通常のPuppeteerではなくpuppeteer-core@sparticuz/chromiumを利用しています。

0.やること

  1. Nodejs20.xの環境をローカルに構築
  2. Lambda関数を実装
  3. Lambdaへデプロイ
  4. API Gatewayと、Lambdaを紐付け
  5. IAM周り設定

1. Nodejs20.xの環境をローカルに構築

詳細は割愛します!
「ライブラリ」セクションを参考に、必要なライブラリをインストールします。
ざっくりと以下に最終形態を明記します。

ディレクトリ構成図
./generatePdf
┣━ node_modules
┣━ src // 開発するプログラム群
┃  ┣━ index.ts
┃  ┣━ RequestPdfGeneratorTypes.ts
┃  ┣━ S3SettingsTypes.ts
┃  ┗━ S3Uploader.ts
┃ // トランスパイル後のjsファイル
┣━ index.js // Lambdaがindex.jsを実行する
┣━ RequestPdfGeneratorTypes.js
┣━ S3SettingsTypes.js
┣━ S3Uploader.js
┃ // 設定ファイル
┣━ tsconfig.json
┗━ package.json
tsconfig.json
{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "outDir": "./dist",
    "strict": true,
  },
  "include": [
    "./src/**/*"
  ]
}
package.json
{
  "dependencies": {
    "@aws-sdk/client-s3": "^3.645.0",
    "@sparticuz/chromium": "^122.0.0",
    "@types/puppeteer-core": "^5.4.0",
    "puppeteer-core": "^23.3.1"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.145",
    "esbuild": "^0.23.1",
    "typescript": "^5.6.2"
  }
}

2. Lambda関数を実装

実践例(コメント解説付き)

最低限PDF出力できるまでの部分を書き出しました

index.ts
// 必要なライブラリ読み込み
import chromium from '@sparticuz/chromium'
import puppeteer, {LaunchOptions} from 'puppeteer-core'
import {APIGatewayEvent, APIGatewayProxyResult} from 'aws-lambda'
import {RequestPdfGeneratorTypes} from "./RequestPdfGeneratorTypes";
import {S3Uploader} from "./S3Uploader";

// Lambdaのランタイム設定の"ハンドラ"に指定されているハンドラメソッド名で書く
// API Gateway→Lambda構成のため、eventはAPIGatewayEvent型を指定
// APIGatewayのeventからリクエスト情報を受け取れるようにする
export const handler = async (event: APIGatewayEvent): Promise<APIGatewayProxyResult> => {

    // クライアント側からPOSTしたPDF化するHTMLと、生成されたPDFのS3保存先情報を受け取り
    // リクエストは事前に定義した型にアサーションする
    const body = JSON.parse(event.body ?? '{}') as RequestPdfGeneratorTypes
    const {html, s3Settings} = body;

    try {
        // Puppeteerにセットするchromiumの初期設定値を設定
        chromium.setHeadlessMode = true
        chromium.setGraphicsMode = false

        // Puppeteerで、ヘッダレスでchromiumを動作させるための設定を行う
        // 「@sparticuz/chromium」を利用する限りは特に変更は不要かと
        const browser = await puppeteer.launch({
            args: [...chromium.args, '--lang=ja', '--no-sandbox', '--disable-setuid-sandbox'],
            defaultViewport: chromium.defaultViewport,
            executablePath: await chromium.executablePath(),
            headless: chromium.headless,
            ignoreHTTPSErrors: true,
        } satisfies LaunchOptions)

        // ブラウザの新しいページを生成
        const page = await browser.newPage()
        await page.setExtraHTTPHeaders({
            'Accept-Language': 'ja-JP'
        });

        // クライアント側からPOSTしたHTMLを、ページとして読み込む
        await page.setContent(html, {
            waitUntil: ['domcontentloaded', 'networkidle0']
        })

        // 作成したpageのpdfメソッドにて、セットしたHTMLをPDFバイナリー化する
        // fomratではPDFサイズを指定するので、A4以外の指定も可能なので、クライアント側で指定するのも汎用的で良さそうです
        const pdfBuffer = await page.pdf({
            format: 'A4',
            printBackground: true,
        })

        const buffer = Buffer.from(pdfBuffer)

        // クライアント側からS3保存先情報を受け取った場合には、S3にPDFバイナリファイルを保存
        // フロントエンドでlambda関数を呼び出した時についでにS3に保存できるようにしている
        if (s3Settings) {
            // s3へファイルのアップロード
            await S3Uploader(
                s3Settings.region,
                s3Settings.bucketName,
                s3Settings.key,
                buffer,
                s3Settings.contentType
            )
        }

        // 生成したPDFファイルのバイナリをレスポンスする
        // クライアント側でバイナリを受け取り、好きな場所に保存するなり、ダウンロード処理を書くなり実施
        return {
            statusCode: 200,
            headers: {
                'Content-Type': 'application/pdf',
                'Content-Disposition': 'attachment; filename="output.pdf"',
                'Access-Control-Allow-Headers': 'Content-Type,Authorization',
                'Access-Control-Allow-Methods': 'OPTIONS,POST',
                'Access-Control-Allow-Origin': '*',
            },
            body: buffer.toString('base64'),
            isBase64Encoded: true,
        }

    } catch (error) {
        return {
            statusCode: 500,
            body: JSON.stringify({error: (error as Error).message})
        }
    }
}
S3Uploader.ts
import {PutObjectCommand, S3Client} from "@aws-sdk/client-s3";

export const s3Uploader = async (region: string, bucketName: string, key: string, body: Buffer, contentType: string): Promise<void> => {
    try {
        const s3 = new S3Client({region: region})
        const uploadParams = {
            Bucket: bucketName,
            Key: key,
            Body: body,
            ContentType: contentType,
        }
        await s3.send(new PutObjectCommand(uploadParams))

    } catch (error) {
        throw new Error('S3へのファイルの保存に失敗しました。')
    }
}

3. Lambdaへデプロイ

  1. 実装したコードのトランスパイル後のjsファイルとnode_modulesをまとめてZip圧縮
  2. 圧縮したZipファイルをS3の任意のBucketにアップロード
  3. アップロードしたZipファイルのS3のアクセスURLを取得
    • コピーしたいzipファイルにチェックして、オレンジ枠でURLをLet'sコピー
  4. Lambdaにアップロード
    • S3にアップロードしたzipは、Lambda画面に移動して、さらにコードソースにアップロード
      • 【アップロード元】から「Amazon S3の場所」
      • S3URLのペースト
  5. ランタイムの設定
    • ランタイム・・・Node.js 20.x
    • ハンドラ・・・index.handler
    • アーキテクチャ・・・x86_64
  6. レイヤーの設定
    • レイヤーには日本語フォントのzipファイルをアップロード
    • FontファイルはGoogle Fontsを利用。ttfファイルを以下の構成でzipにまとめます
./fonts
┣━ NotoSansJP-Bold.ttf
┗━ NotoSansJP-Regular.ttf
  1. タイムアウト設定
    PDF生成には時間がかかりますので、Lambdaの実行時間を適切に設定します

4. API Gatewayと、Lambdaを紐付け

  1. API Gatewayでは、HTTP API(Lambda統合)を使っています
  2. "Routes"で、エンドポイント以降のURIを設定
  3. "Integrations"で、連携させたいLambda関数と統合
  4. CORSはプロジェクトの方針に従った内容を設定する

5. IAM周り設定

  1. API Gatewayから、Lambdaの実行権限を与える
    • AllowExecutionFromAPIGateway::lambda:InvokeFunction
  2. LambdaからS3への書き込み権限

ハマったポイント

  • イントラ内システムのページを元にPDFを生成しようとすると、CSSがうまく適用されない
    • Lambda経由のPupeteerがイントラ内のWEB領域へのアクセスができないために、CSSが読み込めません。
    • イントラ内システムからPuppeteerを利用する場合は、cssを別ファイル化してインラインCSSとして読み込めば解決できます。
    • publicなサイトをPupeteerでPDF化する場合は上記問題は発生しません。
  • CORSエラー:ヘッダを正しく書いてるのにCORSエラー!
    • 泣かされました。
    • ヘッダを正しく設定していてもCORSエラーが発生する場合があります。
    • そんな時はLambda関数が正しく動いているか?を真っ先に疑ってみてください。
    • Lambda関数でエラーが発生すると、headerがレスポンスされずCORSエラーが発生します。

最後に

Puppeteer+Lambda+S3+API Gatewayを使って、文字化け・レイアウト崩れのないPDF出力機能を実装しました。

作り終わってみるとそれほどコードも書いてないし、とてもシンプルだなーと思いましたが、進めていく上でPuppeteerで容量オーバーでデプロイできない、文字化けする、CSS読み込まない、アーキテクチャでNodejs16.xでしか動かない(Typescriptで書けば解決)!?タイムアウトエラー、、などなどハマりポイントもそれなりにありました。

この記事で、これらは全て回避できます。

公式ドキュメントを軸に様々なサイト・サンプルなどをみるうちに情報過多になってしまったりもしましたが、最終的には公式ドキュメントは神だということを再認識しました。

この記事が皆様の実装の助けになれば嬉しいです!

レバテック開発部

Discussion