Puppeteer on AWS Lambdaを利用したサーバーサイドでのPDF生成

WebアプリケーションのPDFダウンロード機能で、ユーザーの使用ブラウザによってPDFのレイアウトが崩れるといった課題が発生していました。

これまではフロントエンド側でPDFの生成をしていましたが、ブラウザの種類やバージョンによらずレイアウトを固定するため、ユーザーの環境に依存しないサーバーサイドでPDFを生成する方法を検証しました。

対応策として、AWS Lambda上でPuppeteerを利用してPDFを生成する仕組みを検証したので、本記事では実装の方法やポイントとなる点について解説します。

## Puppeteerとは

Puppeteerとは、ヘッドレスブラウザを操作するためのライブラリで、人間がブラウザで行う操作を自動化できます。
主な用途として、Webスクレイピング、WebページのスクリーンショットやPDFの生成、UIテストの自動化などがあります。

今回の検証では、アプリケーションの既存のPDFダウンロード機能を活かしつつ、アプリケーションの改修を最小限に抑える必要がありました。
そのため、画面遷移を自動化できるPuppeteerを採用しました。

## 構成

検証を行った全体の構成を簡単な例で示すと以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949720/f84c4aa2-46e7-e262-ee7b-ae93bb00b04c.png)

Puppeteerを利用したPDF生成をサーバーレス環境で実行するために、AWS Lambdaを活用しています。

Lambda関数でPuppeteerを用いるために、以下の2つのライブラリを使用しました。
- [puppeteer-core](https://github.com/puppeteer/puppeteer/tree/main#puppeteer-core)
    - ヘッドレスブラウザを操作するためのライブラリで、ブラウザ本体は含まれず別途Chromiumを準備する必要がある
- [@sparticuz/chromium](https://github.com/Sparticuz/chromium)
    - Lambdaで動作可能な軽量化されたChromiumパッケージ

クライアントサイドからLambda関数を呼び出し、生成されたPDFをレスポンスとして受け取るためには、Amazon API GatewayのLambdaプロキシ統合機能を使用します。

## 実装のポイント
ここからは、上のような構成を実装するにあたってのポイントを解説します。

なお、AWS LambdaやAmazon API Gatewayなどのリソースの定義にはAWS CDKを用いました。
AWS CDKはインフラをコードで管理するツールであり、構築作業を自動化し、再利用可能な形で管理することができます。

以下で紹介する設定やコード例は、AWS CDKを用いた実装例を示しています。

### Lambdaレイヤーの使用
AWS Lambda上でPuppeteerを利用するには、Puppeteer本体（puppeteer-core）だけでなく、動作に必要なブラウザ（Chromium）を準備する必要があり、今回の検証では、軽量化されたChromiumパッケージである@sparticuz/chromiumを使用しています。

しかし、@sparticuz/chromiumはパッケージサイズが大きく、直接Lambda関数に含めるとAWS Lambdaのデプロイ制限を超えてしまう可能性があります。
この問題を解決するため、ChromiumパッケージをLambdaレイヤーとして分離しました。
Lambdaレイヤーを使用することで、関数本体のサイズを削減し、デプロイ可能な形にしています。

以下のLambdaレイヤーを使用しました。
@sparticuz/chromiumがLambdaレイヤーとして公開されているものです。
https://github.com/shelfio/chrome-aws-lambda-layer

以下は、AWS CDKでLambdaレイヤーを設定するコード例です。
```ts
const fn = new nodejs.NodejsFunction(this, "MyFunction", {
      entry: path.resolve(__dirname, "../lambda/index.js"),
      memorySize: 1024,
      architecture: lambda.Architecture.X86_64,
      layers: [
        lambda.LayerVersion.fromLayerVersionArn(
          this,
          "chromium-lambda-layer",
          "arn:aws:lambda:ap-northeast-1:764866452798:layer:chrome-aws-lambda:50",
        ),
      ],
      bundling: {
        externalModules: ["@sparticuz/chromium"],
      },
      timeout: cdk.Duration.seconds(30),
    });
```


### AWS LambdaからAmazon API Gateway経由でPDF（バイナリデータ）を返すときの設定
Puppeteerを用いて生成されたPDFデータをレスポンスとしてクライアントサイドに返しますが、その際に設定すべき部分をきちんと設定していないとPDFが開けないといった問題が発生し、解消に苦労しました。

ダウンロードしたPDFを開くと、以下のようなエラーとなっていました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949720/86aab34c-6428-3d64-1c48-99d541a2af1e.png)

公式ドキュメントにある通り、生成したPDFデータをクライアントに正しく返すため、以下の3点の設定が必要です。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/lambda-proxy-binary-media.html

1.　Lambda関数のレスポンス設定
`isBase64Encoded: true`を指定し、PDFデータをBase64エンコードして返します。
```js
return {
  statusCode: 200,
  headers: {
    "Access-Control-Allow-Origin": "*",
  },
  body: Buffer.from(pdfUint8Array).toString("base64"),
  isBase64Encoded: true,
};
```
2.　API Gatewayのバイナリメディアタイプ
`application/pdf`を指定します。
```ts
const endpoint = new apigw.LambdaRestApi(this, "ApiGwEndpoint", {
  handler: fn,
  proxy: true,
  binaryMediaTypes: ["application/pdf"]      
});
```
3.　クライアントのリクエストヘッダ―
`Accept: "application/pdf"`を指定します。
```html
const response = await fetch(apiUrl, {
  method: "POST",
  headers: {
    Accept: "application/pdf",
  },
});
```


### 性能を改善するための設定
Puppeteerにより操作されるヘッドレスブラウザ上の動作が遅く、高速化するため以下の記事を参考にlaunchオプションを指定しました。
launchオプションに`--disable-gpu`を追加することで、処理速度が速くなりました。
```js
browser = await puppeteer.launch({
  args: [...chromium.args,'--disable-gpu'],
  defaultViewport: chromium.defaultViewport,
  executablePath: await chromium.executablePath(),
  headless: chromium.headless,
});
```
- https://recruit.gmo.jp/engineer/jisedai/blog/generate_pdf_with_headless_chromium_on_aws/
- https://qiita.com/markey/items/ebf2b48626b6ac61ee89

## 全体のコード
全体のコードは以下のようになります。

#### Lambda関数

```lambda/index.js
const puppeteer = require("puppeteer-core");
const chromium = require("@sparticuz/chromium");

exports.handler = async (event) => {
  let browser;

  try {
    browser = await puppeteer.launch({
      args: [...chromium.args,'--disable-gpu'],
      defaultViewport: chromium.defaultViewport,
      executablePath: await chromium.executablePath(),
      headless: chromium.headless,
    });

    const page = await browser.newPage();

    // PDFを生成するページを開く
    await page.goto("https://example.com", { waitUntil: "networkidle0" });

    // PDF生成
    const pdfUint8Array = await page.pdf();

    return {
      statusCode: 200,
      headers: {
        "Access-Control-Allow-Origin": "*",
      },
      body: Buffer.from(pdfUint8Array).toString("base64"),
      isBase64Encoded: true,
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify({
        message: "Error generating PDF file.",
        error: error,
      }),
    };
  } finally {
    if (browser) {
      await browser.close();
    }
  }
};
```

#### AWS CDKのコード
```puppeteer-lambda-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import * as path from "path";
import * as apigw from "aws-cdk-lib/aws-apigateway";
import * as lambda from "aws-cdk-lib/aws-lambda";

export class PuppeteerLambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const fn = new nodejs.NodejsFunction(this, "MyFunction", {
      entry: path.resolve(__dirname, "../lambda/index.js"),
      memorySize: 1024,
      architecture: lambda.Architecture.X86_64,
      layers: [
        lambda.LayerVersion.fromLayerVersionArn(
          this,
          "chromium-lambda-layer",
          "arn:aws:lambda:ap-northeast-1:764866452798:layer:chrome-aws-lambda:50",
        ),
      ],
      bundling: {
        externalModules: ["@sparticuz/chromium"],
      },
      timeout: cdk.Duration.seconds(30),
    });

    const endpoint = new apigw.LambdaRestApi(this, "ApiGwEndpoint", {
      handler: fn,
      proxy: true,
      binaryMediaTypes: ["application/pdf"]
    });
  }
}
```
## 検証から見えた課題点

今回の検証では、生成するPDFのレイアウトをブラウザによらず固定したいという目的を達成できた一方で、性能面では課題がありました。
上で紹介したような高速化の試みを行ったことで多少の改善は見られましたが、ブラウザの起動やアクセスするアプリケーションの初期化などに時間がかかります。
性能が特に重視される状況には適さないと言えます。

一方、生成処理に時間がかかっても支障の少ない非同期タスクでのPDF生成など、性能の遅さを許容できるようなユースケースには利用可能と考えます。

## We Are Hiring!

https://www.biprogy.com/recruit/recruiting/