背景
Lambda で Puppeteer 経由で Chromium を起動して任意の Web ページのスクリーンショットを撮り、画像として S3 に保存する処理を作りたい。
環境
- M1 MacOS Sonoma 14.4.1
- node v20.10.0
- typescript v5.4.5
- @aws-sdk/client-s3 v3.569.0
- @aws-sdk/client-cloudfront v3.598.0
- @sparticuz/chromium v123.0.1
- puppeteer-core v22.7.1
手順
- ローカル開発環境の構築
- handler を実装
- Lambda にデプロイ
- API Gateway の設定
- S3, CloudFront の設定
ローカル開発環境の構築
プロジェクトの初期設定
# npm プロジェクトを新規作成
$ npm init
# TypeScript をインストール
$ npm install --save-dev typescript @types/node
# tsconfig.json を作成
$ ./node_modules/typescript/bin/tsc --init
生成した 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 にコンパイルできるように下記のように設定する。
{
+ "files": ["src/index.ts"],
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
+ "outDir": "dist"
}
}
package.json にビルドコマンドを追加する。
{
"scripts": {
+ "build": "tsc"
},
}
コマンドを実行してみると、無事に dist/index.js が生成されているはず。
$ npm run build
動作確認用の Jest を設定
とりあえず Lambda Function として動かすための最低限の handler を実装する。
export const handler = async () => {
return {
statusCode: 200,
body: JSON.stringify({ success: true }),
};
};
今回、最終的に API Gateway 経由で動作させることを想定しているため、 body は string 型で返す必要がある。
Lambda は body が object 型でも 正常に動作するが、 API Gateway がレスポンスを解釈する段階で Internal Server Error が発生してしまう。
動作確認のため handler を実行したいが、同じファイルの中で実行してしまうと Lambda へアップロードした時に動かなくなってしまうので、 Jest で動作確認できるようにする。
$ npm install --save-dev jest @types/jest @babel/preset-env @babel/preset-typescript
jest が TypeScript を解釈できるように babel の設定を追加する。
{
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }],
"@babel/preset-typescript"
]
}
動作確認用のテストを実装する。
import { handler } from ".";
describe("handler", () => {
it("returns success", async () => {
const result = await handler();
expect(result).toEqual({
statusCode: 200,
body: JSON.stringify({ success: true }),
});
});
});
describe, it, expect など @types/jest の型が適用されるように tsconfig.json の files に "src/index.test.ts"
を追加しておく必要がある。
テストを実行できるように package.json にコマンドを追加する。
jest に環境変数を反映するため node のオプションで .env を読み込むようにしてある。
{
"scripts": {
"build": "tsc",
+ "test": "node --env-file=./.env node_modules/.bin/jest"
},
}
テストを実行する。
$ npm test
これで無事に動作確認することができるようになった。
handler を実装
Chromium のインストール
ローカルで動かすための Chromium をインストールする。 Mac なら brew でインストールできる。
$ brew update && brew install chromium
$ echo "CHROMIUM_EXECUTABLE_PATH=$(which chromium)" > .env
後で index.ts 内で使うので chromium のパスを環境変数として .env に書き込んでおく。
index.ts の実装
まず必要なライブラリをインストールする。
$ npm install --save-dev @sparticuz/chromium
$ npm install --save @aws-sdk/client-s3 @aws-sdk/client-cloudfront puppeteer-core
@sparticuz/chromium
には chromium のバイナリファイルが含まれており、容量が大きいため Lambda のアップロード時の容量制限に引っかかってしまう。
そのため @sparticuz/chromium
は devDependencies としてインストールしておき、本番環境(Lambda上)では layer として @sparticuz/chromium
を参照できるようにしておく。詳細は Lambda 設定の箇所で後述する。
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 }),
};
};
実装のポイント
export type RequestParams = {
viewport: {
width: number;
height: number;
};
url: string;
upload: {
bucket: string;
key: string;
region?: string;
};
cdn: { id: string; };
};
この部分がクライアントから API Gateway に対して送信するリクエストの本文。
今回はクライアント側でスクリーンショットの viewport やアップロード先のバケットなどを指定できるようにした。
const executablePath =
process.env.CHROMIUM_EXECUTABLE_PATH ?? (await chromium.executablePath());
ローカル環境では process.env.CHROMIUM_EXECUTABLE_PATH
にある chromium を利用するが、 Lambda では chromium.executablePath()
の返り値で指定された chromium を利用する。
const params: RequestParams = JSON.parse(event.body);
request body は API Gateway が文字列として送信してくるので、 handler 内でパースする必要がある。
あとは見たまんまなので特に解説はない。
AWS の設定
スクリーンショット画像のアップロード用に S3 のバケットを作っておく。
またバケットへのアップロード権限が必要なので、 IAM で該当バケットへの書き込み権限を持ったユーザーを作り、クレデンシャルを .env に書き込んでおく。
※ Management Console での操作方法などは割愛
$ echo "AWS_ACCESS_KEY_ID=xxx" >> .env
$ echo "AWS_SECRET_ACCESS_KEY=xxx" >> .env
動作確認
Jest で動作確認するため、テストを下記のように編集する。
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 }),
});
});
});
params.upload.bucket の "bucket-name"
は自分で作った S3 バケット名に差し替える。
これで npm test
を実行すると、 S3 バケットに該当のスクリーンショットがアップロードされているはず。
Lambda にデプロイ
Lambda Function の作成
architecture は x86_64
を選択しないと chromium が動作しないので注意。
Lambda Layer の適用
Lambda にアップロードできるコードの容量は最大50MBという制限がある。
Node.js のプロジェクトは node_modules まで含めると容易に 50MB を超えてしまうので、 Lambda Layer という仕組みを利用する。
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
$ 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 を新規作成する。
S3 link URL の bucket-name
は自分で chromium.zip をアップロードしたバケット名に差し替える。
次にアップロードした layer を Lambda Function に紐付ける。
Lambda Function 詳細ページでコードを編集する画面の最下部に Layers メニューがあるので、「Add a layer」ボタンをクリックする。
Custom Layers から先ほど登録した layer を選択し、保存する。
これで Lambda 実行時に先ほど Zip 化したファイルが node_modules として配置される。
S3 への書き込み権限を設定
Lambda から S3 にファイルをアップロードするための権限を設定する必要があるため IAM の Role 一覧ページを開く。
Lambda Function と同じ名前の Role があるはずなので、そこで該当 S3 バケットへの書き込み権限を追加する。
※ Management Console での操作方法などは割愛
コードのアップロード
AWS CLI を利用して手元のコードを Lambda にアップロードするスクリプトを書く。
※ AWS CLI の初期設定は割愛
#!/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
--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 に追加して実行してみる。
{
"scripts": {
"test": "node --env-file=.env node_modules/.bin/jest src/index.test.ts",
"build": "tsc",
+ "deploy": "./deploy.sh"
},
}
$ chmod +x ./deploy.sh
$ npm run deploy
これで無事に Lambda Function のコードがデプロイされているはず。
API Gateway の設定
API Gateway の画面で Create API > HTTP API で Integrations として先ほど作った Lambda Function を追加する。
Route に HTTP Method と Path を追加する。
https://<API ID>.execute-api.ap-northeast-1.amazonaws.com
で API がホストされるようになるので、作ったパスに curl でリクエストを送信してみると、無事に動作しているはず。
$ 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"