Lambda@Edgeで画像リサイズとwebp変換をする方法（Node.js 22、sharp、SAMを使って）

# はじめに

Lambda@Edgeで画像リサイズしたので、対応したことをメモしておきます。
下記の方のお役に立てば嬉しいです :relaxed: 

:::note
## 想定読者
- Lambda@Edge で画像リサイズをしてみたい方
- Node.js 18 以下で動いている Lambda のランタイムをバージョンアップしたい方
:::

SAM を使ってますが、Serverless Framework でも対応可能なはずです。

さて、[AWS公式ブログのサンプル](https://aws.amazon.com/jp/blogs/news/resizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog/)を見ても、Node ランタイムが v6 系と古く CommonJS の記述になっていました。

しかし、ランタイムにも EOL はあり、いつかはメンテナンスされなくなります。
どうせ今から作成するなら現時点で最新の Node.js 22 のランタイムで動かしたいと思いました。Node.js 22 はデフォルトで ESModule を採用していたり、sharp 自体の issue で躓いたポイントがあるので、一緒に見ていきましょう。

まずは、Lambda@Edge の仕組みと、制約について見ていきます。ご存知の方は読み飛ばしてください。

# Lambda@Edge の仕組み

Lambda@Edge を使うと、よりクライアントに近い場所でLambdaを動かすことができ、CloudFront のキャッシュと組み合わせることで更にパフォーマンスが良いスケーラブルな処理できます。

Lambda@Edge はクライアント（End user）からCloudFront経由でサーバー（Origin server）へリクエストが往復する経路ごとに、それぞ4つのタイミングでを設定することができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134221/f8927963-00f6-4121-8867-e4f75c8a510c.png" alt="エンドユーザーとオリジンサーバーの間にクラウドフロントキャッシュがあり、それらのリクエストとレスポンスごとに4種類のLambdaが設定できます" width="700" />
（AWS公式ブログ「Amazon CloudFront & Lambda@Edge で画像をリサイズする」から引用）

> CloudFront ビューワーリクエスト – CloudFront がビューワーからリクエストを受け取った後、リクエストされたオブジェクトがエッジキャッシュにあるかどうかを確認する前に関数が実行されます。

> CloudFront ビューワーレスポンス – リクエストされたオブジェクトがビューワーに返される前に関数が実行されます。オブジェクトがすでにエッジキャッシュに存在するかどうかに関係なく関数が実行されます。

> CloudFront オリジンリクエスト – CloudFront がリクエストをオリジンに転送する場合にのみ関数が実行されます。リクエストされたオブジェクトがエッジキャッシュにある場合は実行されません。

> CloudFront オリジンレスポンス – CloudFront がオリジンからのレスポンスを受け取った後、レスポンス内のオブジェクトをキャッシュする前に関数が実行されます。

下図は、実際の CloudFront の関連付け項目です。

![クラウドフロントのオリジンレスポンスにLambda@Edgeを関連付けした画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134221/0febc4bd-f5d9-4d4e-8237-a3ab4777c4b1.png)

この記事ではオリジンサーバーに S3 を設定し、オリジンレスポンスでの webp 変換のみを行います。

## Lambda@Edge の制約

Lambda@Edge は通常の Lambda と違って制約があります。私が主に感じた制約は下記の項目ですが、他にもあるので確認しておくと良いでしょう。

- 米国東部 (バージニア北部) リージョン `us-east-1` にデプロイする必要がある
- Lambda 関数の番号付きバージョンを指定する必要がある（$LATEST やエイリアスでのデプロイ切り替えできない）
- 環境変数を使えない（開発環境や本番環境ごとの設定の違いをどこかで対応する必要がある。私が対応した方法も後述します。）

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-at-edge-function-restrictions.html

# 設定方法

## Lambda プロジェクトの作成

ここでは SAM を使っていますが、Serverless Framework でも大体の項目は同じなので読み替えていただければと思います。（ざっくり説明すると samconfig.toml と template.yaml が serverless.ts に相当します。）

```bash:プロジェクトのディレクトリ構成
.
├── .aws-sam（←ビルドすると生成される）
│   └─── build
│       └─── OriginResponseFunction（←設定したリソース名。これをデプロイする）
│           ├── node_modules
│           ├── package.json
│           └── index.js
├── src
│   ├── origin-response
│   │   ├── node_modules
│   │   ├── Dockerfile（←ビルドで使う）
│   │   ├── Makefile（←ビルドで使う）
│   │   ├── index.js
│   │   ├── package-lock.json
│   │   └── package.json
│   └── viewer-request（←今後、必要になれば）
├── samconfig.toml
└── template.yaml
```

ディレクトリ階層は任意ですが、今後オリジンレスポンス以外も追加しやすい構造になっていれば良いと思います。

## ライブラリのインストール

下記のコマンドでインストールします。sharp については後述するバージョンに注意してください。

```bash:ターミナル
npm install @aws-sdk/client-s3 sharp@0.32.6
```

### package.json

ライブラリのインストールが終わると、下記のように `dependencies` が変更されています。
そして、Lambda が ESModule と分かるように `"type": "module"` を設定し、エントリーポイントであるindex.js を main に指定しておきます。（自身のファイルが app.js なら、以後、読み替えてください。）

```json:package.json
{
  "name": "origin-response",
  "version": "1.0.0",
  "description": "Origin response に設定する画像リサイズ Lambda Edge",
  "private": true,
  "type": "module",
  "main": "index.js",
  "dependencies": {
    "@aws-sdk/client-s3": "^3.779.0",
    "sharp": "^0.32.6"
  }
}
```

### sharp のプラットフォームとアーキテクチャの依存

sharp は、Node.js のための高性能な画像処理ライブラリです。
sharp は C++ で実装された部分があるので、それぞれの動作環境に向けたビルドが必要になります。今回は Lambda の Node.js 22 ランタイムは Amazon Linux 2023 上で動くのと、x86_64 アーキテクチャで動かすので、下記のように別個の npm install コマンドが必要です。 （arm64 アーキテクチャでの動作確認はしていません。）

```bash:ターミナル
npm rebuild --arch=x64 --platform=linux sharp
```

Serverless Framework であれば、`packagerOptions` オプションに記述できますが、これをビルド時に行う方法については後述します。

#### 2025年4月5日時点の注意点

sharp の最新バージョン v0.33.5 では、指定したクロスプラットフォームの設定が反映されませんでした。現時点では v0.33 系ではなく、v0.32 系を指定すると良いでしょう。

| v0.32.6 | v0.33.5 |
|:-:|:-:|
| ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134221/8a074698-a0fe-457d-a68d-0d17c0ab4df7.png) | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134221/b7de4712-6db8-4c81-ae06-4bc8701907b4.png) |

## samconfig.toml

Lambda@Edgeの制約で、米国東部 (バージニア北部) リージョン `us-east-1` にデプロイする必要があるので、設定ファイルにリージョン指定をしておきます。

```toml:samconfig.toml
[prd.deploy.parameters]
region = "us-east-1"
```

## template.yaml

```yml:template.yaml（一部抜粋）
Resources:
  OriginResponseFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub "origin-response-${Env}"
      CodeUri: src/origin-response
      Role: !GetAtt OriginResponseFunctionIamRole.Arn
      Handler: index.handler
      Runtime: nodejs22.x
      AutoPublishAlias: latest
      Architectures:
        - x86_64
    Metadata:
      BuildMethod: makefile
  OriginResponseFunctionIamRole:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Service:
                - 'lambda.amazonaws.com'
                - 'edgelambda.amazonaws.com'
            Action: 'sts:AssumeRole'
      Path: '/service-role/'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
      Policies:
        - PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: 'Allow'
                Action:
                  - s3:GetObject
                Resource: '__対象のバケットを指定してください__'
              - Effect: 'Allow'
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: '*'
```

Lambda にアタッチするロールの AssumeRolePlicyDocument の Principal に `edgelambda.amazonaws.com` を追加することで、Lambda@Edge がこのロールを使えるようになります。

普段と違う点としては、ファンクションリソースの Metadata の BuildMethod に `makefile` を指定したことです。（※小文字です）
これは、通常の `sam build` コマンドだと、上記で説明した sharp 専用のビルドを挟めなかったからです。makefileを指定することで、`CodeUri`で指定したディレクトリにある Makefile をビルド時に実行してくれます。


### Makefile と Dockerfile

ビルドの仕組みは公式での説明を引用します。

> 画像リサイズ関数は ‘libvips’ ネイティブ拡張を必要とする ‘Sharp’ モジュールを使用します。 Lambda 関数のコードは Lambda 実行環境で動作するように依存関係を含んだ状態でビルドおよびパッケージ化されている必要があります。これらを実現する方法のひとつが、あなたの環境に ‘docker’ をインストールしてから、 Docker コンテナを使ってパッケージをローカルにビルドすることです。

Node.js 22 ランタイムは Amazon Linux 2023 上で動くので、ベースイメージに `amazonlinux:2023` を使います。

```Dockerfile:Dockerfile
FROM amazonlinux:2023

RUN yum install -y gcc-c++

RUN curl-minimal -sL https://rpm.nodesource.com/setup_22.x | bash - && \
    yum install -y nodejs

WORKDIR /build
```

`curl-minimal` は `Amazon Linux 2023` にデフォルトで入ってる curl の軽量版みたいです。このイメージから作成したコンテナで npm install などをしてビルドするので nodejs をいれておきます。

さて、それでは実際にこの Dockerfile を使った Makefile 内の説明をしていきます。

```make:Makefile
build-OriginResponseFunction:
	docker build --tag amazonlinux:nodejs --platform linux/amd64 .
	docker run --platform linux/amd64 --rm --volume ${PWD}/src/origin-response:/build amazonlinux:nodejs /bin/bash -c "source ~/.bashrc; npm install --only=prod; npm rebuild --arch=x64 --platform=linux sharp"
	cp -r ${PWD}/src/origin-response/node_modules $(ARTIFACTS_DIR)/node_modules
	cp -r ${PWD}/src/origin-response/index.js $(ARTIFACTS_DIR)
	cp -r ${PWD}/src/origin-response/package.json $(ARTIFACTS_DIR)
```

BuildMethod に `makefile` を指定すると、`build-{リソース名}`のコマンドを実行する仕様になっています。それでは中身を説明してきます。

- Makefileと同じ階層にあるDockerfileをまずビルドします（タグ名: `amazonlinux:nodejs`）
- ビルドしたイメージを使ってパッケージをインストールしていきます。`--volume ${PWD}/src/origin-response:/build` でコードベースをマウントして npm install をすることで、package-lock.json と node_modules を作成してくれます
※この時に、 sharp で必要な `npm rebuild --arch=x64 --platform=linux sharp` を実行しています。
- インストールができたら、デプロイに必要なアーティファクトを `ARTIFACTS_DIR` 配下にコピーします。`ARTIFACTS_DIR` 環境変数は組み込まれており、今回の例では `.aws-sam/build/OriginResponseFunction` が指定されています

## index.js

CommonJS の書き方であった `require`: ライブラリの読み込み方やハンドラーの書き方 `exports.handler` が変わっています。

また、AWS SDK for JavaScript も v2 から v3 に変わるので確認しておきましょう。

https://docs.aws.amazon.com/ja_jp/sdk-for-javascript/v3/developer-guide/migrating.html

```js:index.js
import sharp from 'sharp';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';

const BUCKET = 'dev-example-bucket';

export const handler = async (event) => {
  const {Records: [{cf: { request, response }}]}} = event;

  const client = new S3Client({ region: 'ap-northeast-1' });
  const path = request.uri;
  const key = path.slice(1);
  const command = new GetObjectCommand({ Bucket: BUCKET, Key: key });
  const s3Response = await client.send(command);
  const body = await s3Response.Body.transformToByteArray();

  const sharpBody = sharp(body);
  const buffer = await sharpBody
    .webp({ quality: 75 })
    .resize({ width: 100, height: 100})
    .toBuffer();

  response.status = '200';
  response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/webp' }];
  response.body = buffer.toString('base64');
  response.bodyEncoding = 'base64';

  return response;
}
```

上の例では、`dev-example-bucket` バケットが東京リージョンにあるので `new S3Client({ region: 'ap-northeast-1' })` にしています。バケットのあるリージョンごとに変更してください。

私が勘違いしたポイントとしては、S3 がオリジンサーバーなので response.body に画像データが含まれているので改めて S3 に取得する必要はないと思っていましたが、Lambda@Edge の仕様でレスポンスの中身を参照することはできませんでした。

（補足）
S3から再取得しているので、キャッシュがない場合のパフォーマンスが悪いですが、これを解決しているのが[公式ブログのやり方](https://aws.amazon.com/jp/blogs/news/resizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog/)で、sharp 変換した画像を S3 にアップロードしています。これにより、次のアクセス時にはリサイズ処理が不要になります。一方で、Request viewer でのリサイズサイズのバリデーションをつけないと、不要にリサイズされた画像が S3 にアップロードされてしまうので、セットで使ったほうがよいでしょう。

## CloudFront

対象のディストリビューションの S3 をオリジンサーバーに指定したビヘイビアで、Lambda@Edge を関連付けてあげましょう。$LATEST やエイリアスの指定は不可で、バージョンの数字で指定する必要があります。

![クラウドフロントのオリジンレスポンスにLambda@Edgeを関連付けした画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134221/0febc4bd-f5d9-4d4e-8237-a3ab4777c4b1.png)

設定後、ブラウザから動作確認を行い、リサイズされ Response の Content-Type が `image/webp` になっていれば成功です。

![ブラウザの開発者コンソールから、レスポンスヘッダーのContent-Typeがimage/webpになっていることを確認できる](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134221/7b7638f0-ef91-4fb8-98a6-798ea463d071.png)

## 環境変数の切り替え対策

Lambda@Edge では環境変数を使えません。なので、先のindex.js の S3 バケットを `process.env.BUCKET`  のように外部から切り替えできません。

そこで、ビルドしたアーティファクトの index.js の中身を置換することで対策しました。具体的に説明します。

まず、index.js のバケット設定を書き換えます。

```diff_javascript
- const BUCKET = 'dev-example-bucket';
+ const BUCKET = '__BUCKET__';
```

こうすることで、デプロイ前に sed コマンドで置換することができます。

```bash
sam build
sed -i 's/__BUCKET__/dev-example-bucket/g' .aws-sam/build/OriginResponseFunction/index.js
sam deploy
```

バケットの設定箇所を集約できないデメリットがあるので、もっと良い方法がありましたら教えてください :bow:

# おわりに

sharp のクロスプラットフォーム問題によるバージョン固定については、今後もウォッチしていく必要がありますが、Lambda@Edgeで画像リサイズすることができました。

AWS にも Cloudflare の Image Resizing のような機能があったら嬉しいので、今後の機能リリースを期待しています！

それでは、この記事が良かったよ〜って方は、チャンネル登録、高評価、お願いしま〜す！

# 参考

- AWS公式ブログ node v6 での実装例が書いてあります

https://aws.amazon.com/jp/blogs/news/resizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog/

- Lambda のランタイム一覧

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-runtimes.html

- makefile の設定方法が記述してあります

https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/building-custom-runtimes.html