API Gateway + Lambda + Puppeteer で任意の Web ページのスクリーンショットを撮って S3 に保存する

## 背景
Lambda で Puppeteer 経由で Chromium を起動して任意の Web ページのスクリーンショットを撮り、画像として S3 に保存する処理を作りたい。

## 環境
- M1 MacOS Sonoma 14.4.1
- node v20.10.0
- typescript v5.4.5
- [@aws-sdk/client-s3](https://www.npmjs.com/package/@aws-sdk/client-s3) v3.569.0
- [@aws-sdk/client-cloudfront](https://www.npmjs.com/package/@aws-sdk/client-cloudfront) v3.598.0
- [@sparticuz/chromium](https://github.com/Sparticuz/chromium) v123.0.1
- [puppeteer-core](https://www.npmjs.com/package/puppeteer-core) v22.7.1

## 手順
- ローカル開発環境の構築
- handler を実装
- Lambda にデプロイ
- API Gateway の設定
- S3, CloudFront の設定

## ローカル開発環境の構築
### プロジェクトの初期設定

```sh
# npm プロジェクトを新規作成
$ npm init
# TypeScript をインストール
$ npm install --save-dev typescript @types/node
# tsconfig.json を作成
$ ./node_modules/typescript/bin/tsc --init
```

生成した tsconfig.json は、コメントアウト部分を除くと下記のようになっている。

```tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}
```

今回の開発環境の目指す姿は、

- TypeScript で実装する
- ローカルで動作確認できる
- コンパイル結果の JS ファイルを Zip 化して Lambda にアップロードするだけでデプロイできる

というものなので、 src/index.ts を dist/index.js にコンパイルできるように下記のように設定する。

```diff_json:tsconfig.json
{
+ "files": ["src/index.ts"],
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
+   "outDir": "dist"
  }
}
```

package.json にビルドコマンドを追加する。

```diff_json:package.json
{
  "scripts": {
+   "build": "tsc"
  },
}
```

コマンドを実行してみると、無事に dist/index.js が生成されているはず。

```sh
$ npm run build
```

### 動作確認用の Jest を設定
とりあえず Lambda Function として動かすための最低限の handler を実装する。

```src/index.ts
export const handler = async () => {
  return {
    statusCode: 200,
    body: JSON.stringify({ success: true }),
  };
};
```

:::note warn
今回、最終的に API Gateway 経由で動作させることを想定しているため、 body は string 型で返す必要がある。
Lambda は body が object 型でも 正常に動作するが、 API Gateway がレスポンスを解釈する段階で Internal Server Error が発生してしまう。
:::

動作確認のため handler を実行したいが、同じファイルの中で実行してしまうと Lambda へアップロードした時に動かなくなってしまうので、 Jest で動作確認できるようにする。

```sh
$ npm install --save-dev jest @types/jest @babel/preset-env @babel/preset-typescript
```

jest が TypeScript を解釈できるように babel の設定を追加する。

```json:.babelrc
{
  "presets": [
    ["@babel/preset-env", { "targets": { "node": "current" } }],
    "@babel/preset-typescript"
  ]
}
```

動作確認用のテストを実装する。

```src/index.test.ts
import { handler } from ".";

describe("handler", () => {
  it("returns success", async () => {
    const result = await handler();
    expect(result).toEqual({
      statusCode: 200,
      body: JSON.stringify({ success: true }),
    });
  });
});
```

:::note
describe, it, expect など @types/jest の型が適用されるように tsconfig.json の files に `"src/index.test.ts"` を追加しておく必要がある。
:::

テストを実行できるように package.json にコマンドを追加する。
jest に環境変数を反映するため node のオプションで .env を読み込むようにしてある。

```diff_json:package.json
{
  "scripts": {
    "build": "tsc",
+   "test": "node --env-file=./.env node_modules/.bin/jest"
  },
}
```

テストを実行する。

```sh
$ npm test
```

![スクリーンショット 2024-05-06 10.18.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131942/900ee041-d54b-7a50-223a-19c4e2ad04e3.png)

これで無事に動作確認することができるようになった。

## handler を実装
### Chromium のインストール
ローカルで動かすための Chromium をインストールする。 Mac なら brew でインストールできる。

```sh
$ brew update && brew install chromium
$ echo "CHROMIUM_EXECUTABLE_PATH=$(which chromium)" > .env
```

:::note info
後で index.ts 内で使うので chromium のパスを環境変数として .env に書き込んでおく。
:::

### index.ts の実装

まず必要なライブラリをインストールする。
```sh
$ npm install --save-dev @sparticuz/chromium
$ npm install --save @aws-sdk/client-s3 @aws-sdk/client-cloudfront puppeteer-core
```

:::note warn
`@sparticuz/chromium` には chromium のバイナリファイルが含まれており、容量が大きいため Lambda のアップロード時の容量制限に引っかかってしまう。
そのため `@sparticuz/chromium` は devDependencies としてインストールしておき、本番環境（Lambda上）では layer として `@sparticuz/chromium` を参照できるようにしておく。詳細は Lambda 設定の箇所で後述する。
:::

```src/index.ts
import {
  CloudFrontClient,
  CreateInvalidationCommand,
} from "@aws-sdk/client-cloudfront";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import chromium from "@sparticuz/chromium";
import puppeteer from "puppeteer-core";

type RequestContext = {
  accountId: string;
  apiId: string;
  domainName: string;
  domainPrefix: string;
  http: {
    method: "POST";
    path: string;
    protocol: string;
    sourceIp: string;
    userAgent: string;
  };
  requestId: string;
  routeKey: string;
  stage: string;
  time: string;
  timeEpoch: number;
};

// type of event generated by API Gateway
export type APIGatewayEvent = {
  version: string;
  routeKey: string;
  rawPath: string;
  rawQueryString: string;
  headers: { [key: string]: string };
  requestContext: RequestContext;
  body: string;
  isBase64Encoded: boolean;
};

// type of request body
export type RequestParams = {
  viewport: {
    width: number;
    height: number;
  };
  url: string;
  upload: {
    bucket: string;
    key: string;
    region?: string;
  };
  cdn: { id: string; };
};

export const handler = async (event: APIGatewayEvent) => {
  // create screenshot image
  const executablePath =
    process.env.CHROMIUM_EXECUTABLE_PATH ?? (await chromium.executablePath());

  const params: RequestParams = JSON.parse(event.body);

  const browser = await puppeteer.launch({
    args: chromium.args,
    defaultViewport: chromium.defaultViewport,
    executablePath: executablePath,
    headless: chromium.headless,
  });

  const page = await browser.newPage();
  await page.goto(params.url);

  page.setViewport({
    width: params.viewport.width,
    height: params.viewport.height,
  });

  const file = await page.screenshot();

  browser.close();

  // upload file to S3
  const region = params.upload.region ?? "ap-northeast-1";
  const client = new S3Client({ region });
  const command = new PutObjectCommand({
    Body: file,
    Bucket: params.upload.bucket,
    Key: params.upload.key,
    ContentDisposition: "inline",
    ContentType: "image/png",
  });

  await client.send(command);

  // invalidate cloudfront cache
  const cloudFrontClient = new CloudFrontClient({
    region: params.cdn.region ?? DEFAULT_AWS_REGION,
  });

  await cloudFrontClient.send(
    new CreateInvalidationCommand({
      DistributionId: params.cdn.id,
      InvalidationBatch: {
        Paths: {
          Quantity: 1,
          Items: [`/${params.upload.key}`],
        },
        CallerReference: new Date().getTime().toString(),
      },
    })
  );

  return {
    statusCode: 200,
    body: JSON.stringify({ success: true }),
  };
};
```

### 実装のポイント

```ts
export type RequestParams = {
  viewport: {
    width: number;
    height: number;
  };
  url: string;
  upload: {
    bucket: string;
    key: string;
    region?: string;
  };
  cdn: { id: string; };
};
```

この部分がクライアントから API Gateway に対して送信するリクエストの本文。
今回はクライアント側でスクリーンショットの viewport やアップロード先のバケットなどを指定できるようにした。

```ts
const executablePath =
  process.env.CHROMIUM_EXECUTABLE_PATH ?? (await chromium.executablePath());
```

ローカル環境では `process.env.CHROMIUM_EXECUTABLE_PATH` にある chromium を利用するが、 Lambda では `chromium.executablePath()` の返り値で指定された chromium を利用する。

```ts
const params: RequestParams = JSON.parse(event.body);
```

request body は API Gateway が文字列として送信してくるので、 handler 内でパースする必要がある。

あとは見たまんまなので特に解説はない。

### AWS の設定
スクリーンショット画像のアップロード用に S3 のバケットを作っておく。
またバケットへのアップロード権限が必要なので、 IAM で該当バケットへの書き込み権限を持ったユーザーを作り、クレデンシャルを .env に書き込んでおく。
※ Management Console での操作方法などは割愛

```sh
$ echo "AWS_ACCESS_KEY_ID=xxx" >> .env
$ echo "AWS_SECRET_ACCESS_KEY=xxx" >> .env
```

### 動作確認

Jest で動作確認するため、テストを下記のように編集する。

```src/index.test.ts
import { handler, APIGatewayEvent, RequestParams } from ".";

describe("handler", () => {
  const baseEvent: APIGatewayEvent = {
    version: "2.0",
    routeKey: "POST /screenshot",
    rawPath: "/production/screenshot",
    rawQueryString: "",
    headers: {
      "content-type": "application/json",
      host: "xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com",
    },
    requestContext: {
      accountId: "270422322105",
      apiId: "xxxxxxxx",
      domainName: "xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com",
      domainPrefix: "xxxxxxxx",
      http: {
        method: "POST",
        path: "/production/screenshot",
        protocol: "HTTP/1.1",
        sourceIp: "127.0.0.1",
        userAgent: "jest",
      },
      requestId: "xxx",
      routeKey: "POST /screenshot",
      stage: "production",
      time: "01/May/2024:15:00:00 +0000",
      timeEpoch: 1700000000000,
    },
    body: "{}",
    isBase64Encoded: false,
  };

  const createEvent = (params: RequestParams) => {
    return { ...baseEvent, body: JSON.stringify(params) };
  };

  const params: RequestParams = {
    viewport: {
      width: 1200,
      height: 630,
    },
    url: "https://example.com",
    upload: {
      bucket: "bucket-name",
      key: "ogp.png",
    },
    cdn: { id: "xxx" },
  };

  it("returns success", async () => {
    const event = createEvent(params);
    const result = await handler(event);

    expect(result).toEqual({
      statusCode: 200,
      body: JSON.stringify({ success: true }),
    });
  });
});
```

:::note warn
params.upload.bucket の `"bucket-name"` は自分で作った S3 バケット名に差し替える。
:::

これで `npm test` を実行すると、 S3 バケットに該当のスクリーンショットがアップロードされているはず。

## Lambda にデプロイ
### Lambda Function の作成
architecture は　`x８６_６４` を選択しないと chromium が動作しないので注意。

![スクリーンショット_2024-05-06_13_11_18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131942/aee48b94-cfd4-f070-8729-cb589abbb82e.png)

### Lambda Layer の適用
Lambda にアップロードできるコードの容量は最大50MBという制限がある。
Node.js のプロジェクトは node_modules まで含めると容易に 50MB を超えてしまうので、 [Lambda Layer](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/chapter-layers.html) という仕組みを利用する。

Lambda Layer とは、予め Zip ファイルをアップロードしておくと、その中身を展開して Lambda 実行環境に置いておいてくれるというもの。
Node.js の実行環境で言えば、予め nodejs/node_modules/ というディレクトリ構成でファイルを Zip 化してアップロードしておけば、そこに配置されたファイルを実行時に /opt/nodejs/node_modules に展開してくれて、コードから require/import することができるようになる。

まず chromium を含むパッケージ `@sparticuz/chromium` を Zip 化してアップロードする。
`@sparticuz/chromium` のレポジトリ内に Zip 化の処理が書かれた Makefile があるため、これをそのまま利用する。

参考:
https://github.com/Sparticuz/chromium/blob/v123.0.1/Makefile

```sh
$ git clone --depth=1 git@github.com:Sparticuz/chromium.git
$ cd chromium
$ make chromium.zip
```

すると chromium.zip というファイルができるので、これを Lambda Layer として登録する。
Lambda Layer にアップロードする際も 50MB の制限があるが、一度 S3 にアップロードしてからその URL を渡す形式にすると容量制限が緩和するので、まず chromium.zip を S3 にアップロードする。
その後 Lambda ページの Layers メニューから Layer を新規作成する。

![スクリーンショット_2024-05-06_13_54_06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131942/a442fa52-e37a-68a6-fa88-fc7dcaf4fc91.png)

:::note warn
S3 link URL の `bucket-name` は自分で chromium.zip をアップロードしたバケット名に差し替える。
:::

次にアップロードした layer を Lambda Function に紐付ける。
Lambda Function 詳細ページでコードを編集する画面の最下部に Layers メニューがあるので、「Add a layer」ボタンをクリックする。

![スクリーンショット_2024-05-06_13_57_49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131942/0be3f8e7-978e-695e-4f4d-aab45d818bee.png)

Custom Layers から先ほど登録した layer を選択し、保存する。

![スクリーンショット_2024-05-06_13_58_53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131942/5b4555c3-5d25-5ac0-4bcf-6d8ea60703bd.png)

これで Lambda 実行時に先ほど Zip 化したファイルが node_modules として配置される。

### S3 への書き込み権限を設定

Lambda から S3 にファイルをアップロードするための権限を設定する必要があるため IAM の Role 一覧ページを開く。
Lambda Function と同じ名前の Role があるはずなので、そこで該当 S3 バケットへの書き込み権限を追加する。
※ Management Console での操作方法などは割愛

### コードのアップロード

AWS CLI を利用して手元のコードを Lambda にアップロードするスクリプトを書く。
※ AWS CLI の初期設定は割愛

```deploy.sh
#!/bin/bash

rm -rf dist
npm install
npm run build

npm ci --omit=dev
cp -r ./node_modules ./dist

cd dist
zip -qr output.zip .
cd ../

aws lambda update-function-code --function-name screenshot --zip-file fileb://dist/output.zip
```

:::note warn
`--function-name screenshot` は自分で設定した Lambda Function の名前に差し替える。
:::

ポイントとしては、

- `npm ci --omit=dev` で node_modules 以下を devDependencies を除外したパッケージのみにした状態で  dist/ 内にコピーする
- それらとコンパイルしたコードをまとめて zip 化して `aws lambda update-function-code` でアップロードしている

ということになる。

※ devDependencies に含まれている `@sparticuz/chromium` は Lambda Layer に含まれているためアップロードしなくて OK
※ `@aws-sdk/*` は Lambda の Node.js 実行環境に標準で含まれているという噂を聞いたので、もしかしたら `@aws-sdk/client-s3` も devDependencies に入れておいても動くかもしれない

最後にデプロイコマンドを package.json に追加して実行してみる。

```diff_json:package.json
{
  "scripts": {
    "test": "node --env-file=.env node_modules/.bin/jest src/index.test.ts",
    "build": "tsc",
+   "deploy": "./deploy.sh"
  },
}
```

```sh
$ chmod +x ./deploy.sh
$ npm run deploy
```

これで無事に Lambda Function のコードがデプロイされているはず。

## API Gateway の設定

API Gateway の画面で Create API > HTTP API で Integrations として先ほど作った Lambda Function を追加する。

![スクリーンショット_2024-05-06_14_35_53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131942/f05b457e-44c8-898d-5112-889073d32feb.png)

Route に HTTP Method と Path を追加する。

![スクリーンショット 2024-05-06 14.38.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131942/9139f925-46ba-b7cb-4bed-d8f52b898581.png)

`https://<API ID>.execute-api.ap-northeast-1.amazonaws.com` で API がホストされるようになるので、作ったパスに curl でリクエストを送信してみると、無事に動作しているはず。

```sh
$ curl -X POST https://<API ID>.execute-api.ap-northeast-1.amazonaws.com/screenshot \
  -d '{"viewport":{"width":1200,"height":630},"url":"https://example.com","upload":{"bucket":"bucket-name","key":"test.png"}}' \
  -H "Content-Type: application/json"
```

## 参考
- https://zenn.dev/ispec_inc/articles/lambda-puppeteer
- https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/packaging-layers.html