6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-05-06

背景

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

環境

手順

  • ローカル開発環境の構築
  • 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 は、コメントアウト部分を除くと下記のようになっている。

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 にコンパイルできるように下記のように設定する。

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

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

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

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

$ npm run build

動作確認用の Jest を設定

とりあえず Lambda Function として動かすための最低限の handler を実装する。

src/index.ts
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 の設定を追加する。

.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 }),
    });
  });
});

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

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

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

テストを実行する。

$ npm test

スクリーンショット 2024-05-06 10.18.37.png

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

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 設定の箇所で後述する。

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 }),
  };
};

実装のポイント

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 で動作確認するため、テストを下記のように編集する。

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 }),
    });
  });
});

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

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

Lambda にデプロイ

Lambda Function の作成

architecture は x86_64 を選択しないと chromium が動作しないので注意。

スクリーンショット_2024-05-06_13_11_18.png

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 を新規作成する。

スクリーンショット_2024-05-06_13_54_06.png

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

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

スクリーンショット_2024-05-06_13_57_49.png

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

スクリーンショット_2024-05-06_13_58_53.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

--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 に追加して実行してみる。

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 を追加する。

スクリーンショット_2024-05-06_14_35_53.png

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

スクリーンショット 2024-05-06 14.38.16.png

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"

参考

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?