Lambda@Edgeで画像のリサイズ配信を実装するterraformで

## 概要
Lambda@edgeを使い
Cloudforntからリサイズした画像を配信してもらう。そういう処理をterraformで実装した。
ハマりにハマってたくさんの時間を溶かしまた忘れると思うので健忘録として残しておく。

## 目的
**なぜリサイズ配信を実装する必要があったのか?**

SEO対策としてサイトのパフォーマンス改善に取り組んでいると
「スマホなのにPCサイズの画像が読み込まれているよ。」と
[PageSpeed Insights](https://pagespeed.web.dev/)に指摘されることがあると思う。
要はスマホにしては画像サイズが大きいよ ということである。

この課題に対するアプローチはいくつかあって

一番単純な方法で調べてよく出てくる方法としては
>スマホで表示する画像とPCで表示する画像2枚を用意してcssもしくはアプリケーションがわでデバイスを判定して出し分けを行う。

という方法がある。
しかしこれは
動的なコンテンツなので運用チームにスマホ画像も用意しもらう必要があるし
DBにそれ用のカラムを追加したらロジックを増やしたり、メリットに比べて労力かかりすぎる。
という課題を感じた。

ので却下。

次に
>自分の環境ではAWS S3に画像を保存しているのでS3に保存されたタイミングでLambdaを走らせてリサイズした画像を生成する。

という方法。


これはありだが
インフラ側でリサイズした画像のオブジェクトのパスをアプリケーションは知らない。
S3のバケットの中がぐちゃぐちゃになって負債化すると感じた。

ので却下。

次に
> 画像登録時にアプリケーション側でリサイズしたものをS3に登録する。

という方法。
これはあり。アプリケーションを見れば何がどうなっているかわかるのでアプリケーション内で完結するので
わかりやすい実装方針だと思う。ただ、既存データどうするのかとかDBにそれ用のカラムを追加したり各所にロジック追加したり
リサイズサイズに柔軟性を持たせるとなると破綻する可能性があるため、ありだが検討余地ありという感じ。



上記のようにいくつか方法はあるがそれぞれ思う部分があり
一番しっくり来たのがLambda@edgeを使ったリサイズ画像配信である。

## Lambda@edgeとは
>Lambda@Edge は、CloudFront が配信するコンテンツをカスタマイズする関数を実行できるコンピューティングサービス

引用:[AWS Lambda@Edge を使用したエッジでのカスタマイズ](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html)

これが定義。

要するにCloudFrontが配信するコンテンツに対してLambda関数を実行できるようにするという仕組みのこと。
EdgeはCloudFrontのエッジロケーションのことを指していて
各エッジロケーションにLambda関数が配信されて実行できまっせ!
ということ。

## 特徴
### 作成するリージョンの指定
Lambda@edgeは通常のLambdaとは違い
米国東部 (バージニア北部) で作成するという特徴がある。

これは各エッジロケーションに配信するという仕様からAWSがバージニア北部リージョンで関数を一元管理することで管理しやすくるするということだと思う。(これは僕の想像)

### Lambda関数の発火イベント
Lambda@edgeを使うにあたり下記イベントがCloudFrontでLambda関数を実行できるタイミングである。


- **CloudFront ビューワーリクエスト**
CloudFront がビューワーからリクエストを受け取った後、リクエストされたオブジェクトがエッジキャッシュにあるかどうかを確認する前に関数が実行されます。
つまり、「CloudFrontに到達する前に実行実行されるイベントということですね。」
- **CloudFront オリジンリクエスト**
CloudFront がリクエストをオリジンに転送する場合にのみ関数が実行されます。リクエストされたオブジェクトがエッジキャッシュにある場合は実行されません。
つまり、「CloudFrontにキャッシュがなかった場合に実行されるイベントということですね。」
- **CloudFront オリジンレスポンス**
CloudFront がオリジンからのレスポンスを受け取った後、レスポンス内のオブジェクトをキャッシュする前に関数が実行されます。
つまり、「オリジンからのレスポンス(僕の場合はS3がオリジン)が返ってくる時に実行されるイベントですね」
- **CloudFront ビューワーレスポンス**
リクエストされたオブジェクトがビューワーに返される前に関数が実行されます。オブジェクトがすでにエッジキャッシュに存在するかどうかに関係なく関数が実行されます。
つまり、「CloudFrontからユーザーにレスポンスを返すタイミングで実行されるイベントですね。」

![lambda@edge.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346005/fd620906-be80-61d8-1906-10669c4ed3b2.png)

引用: [Amazon CloudFront & Lambda@Edge で画像をリサイズする](https://aws.amazon.com/jp/blogs/news/resizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog/)

このCloudFrontが提供している4つイベントの中でLambda関数を実行させてやりたいことを実現する。
という解釈ですね。

## Terraformでの実装
ここまでで「CloudFront+Lambda=Lambda@edge」という説明が終わったので
実際にTerraformでどよにうに実装していくのか書いていきます。

実装していく中で何が一番綺麗にかけるのかとか悩みながら実装したので
今回紹介するコードは最適化されていると言い難いので
思う部分があればご指摘いただけたら嬉しいです。

作成の流れとしては
**CloudFrontを作成**
↓
**Lambdaを作成**
↓
**LambdaとCloudFrontの紐付け**

という流れで行こうかと思います。


### CloudFrontの作成
```cloudfront.tf
# S3バケットを取得
data "aws_s3_bucket" "main" {
  bucket = "オリジンとなるバケット名"
}

# cloudFront
resource "aws_cloudfront_distribution" "main" {

  origin {
    domain_name = data.aws_s3_bucket.main.bucket_domain_name
    origin_id   = data.aws_s3_bucket.main.id
  }
  enabled = true
  is_ipv6_enabled     = true

  # AWS Managed Caching Policy (CachingDisabled)
  default_cache_behavior {
    target_origin_id       = data.aws_s3_bucket.main.id
    viewer_protocol_policy = "allow-all"
    allowed_methods = [ "GET", "HEAD" ]
    cached_methods = [ "GET", "HEAD" ]

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    min_ttl = 0
    default_ttl = 86400 // 1日
    max_ttl = 259200 // 3日

  }

  restrictions {
    geo_restriction {
      restriction_type = "whitelist"
      locations = [ "JP" ]
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}
```

こんな感じでCloudFrontは作成します。
細かい設定は磨く必要あるかもですが...

### Lambda@edgeの作成

#### IAMの設定
```lambda_edge_iam.tf
## lambda@edge
resource "aws_iam_role" "lambda_edge_role" {
    name = "${var.app_name}-lambda-edge-role"
    assume_role_policy = jsonencode({
        "Version": "2012-10-17",
        "Statement": [
            {
              "Effect": "Allow",
              "Principal": {
                "Service": [
                  "lambda.amazonaws.com",
                  "edgelambda.amazonaws.com"
                ]
              },
              "Action": "sts:AssumeRole"
            }
        ]
    })
}

resource "aws_iam_role_policy_attachment" "lambda_edge_policy" {
  role       = aws_iam_role.lambda_edge_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonS3ObjectLambdaExecutionRolePolicy"
}

```

毎回思うのですがIAMのpolicy何が適切なのか何ができるのか探すの難しいですよね、
どうしたらいいのですかね。

#### Lambdaの作成
まず今回使用するCloudForntイベントとそこでやる処理について説明します。
- view-requestイベント
    - lambda_edge_image_url_customizer.jsを実行します。
      エンドユーザーからのリクエストを受け取り指定されたリサイズパラメーターをもとにurlを変更します。
      変更したurlをCloudForntに流します
- origin-responseイベント
    - lambda_edge_image_resize.jsを実行します
      オリジン(S3)からのレスポンスを受け取りlambda_edge_image_url_customizer.jsで指定されたサイズで画像をリサイズしてCloudFrontに流します

ディレクトリ構成はこんな感じにしています。
- lambda/
    - function/
        - lambda_edge_image_url_customizer/
            - lambda_edge_image_url_customizer.js
            - node_modules/
            - package.json
            - package.lock.json
        - lambda_edge_image_resize/
            - lambda_edge_image_resize.js
            - node_modules/
            - package.json
            - package.lock.json
        - output_zip/
            - lambda_edge_image_url_customizer.zip
            - lambda_edge_image_resize.zip
    - main.tf

Lambda関数に依存するライブラリとか必要なので
関数ごとにディレクトリを分けてそこにまとめる形をとっています。
デプロイする時にzipファイルである必要があるためデプロイするzipファイルをまとめるoutput_zipディレクトリを作っています。

では

main.tfのコードです。

```lambda/main.tf

variable "iam_role_lambda_edge_arn" {
  description = "lambda@edgeのIAMロールのARN"
  type        = string
}

# プロバイダの設定でus-east-1リージョンを指定
provider "aws" {
  region = "us-east-1"
  alias  = "us-east-1" # エイリアスを使用してこのプロバイダ設定を区別します
}

# lambda関数をデプロイするためにzip化する
data "archive_file" "lambda_edge_image_url_customizer" {
  type        = "zip"
  source_dir = "${path.module}/function/lambda_edge_image_url_customizer"
  output_path = "${path.module}/function/output_zip/lambda_edge_image_url_customizer.zip"
}
# lambda@edgeのviewr-requestイベントで画像URLをカスタマイズするためのlambda関数
resource "aws_lambda_function" "lambda_edge_image_url_customizer" {
  # lambda@edgeはus-east-1リージョンのみで利用可能なので、プロバイダを指定する
  provider = aws.us-east-1

  function_name = "lambdaEdgeImageUrlCustomizer"
  role          = var.iam_role_lambda_edge_arn
  handler       = "lambda_edge_image_url_customizer.handler"
  runtime       = "nodejs20.x"    # Node.jsのバージョンを適宜選択

  publish = true

  filename         = data.archive_file.lambda_edge_image_url_customizer.output_path
  source_code_hash = data.archive_file.lambda_edge_image_url_customizer.output_base64sha256
}

# lambda関数をデプロイするためにzip化する
data "archive_file" "lambda_edge_image_resize" {
  type        = "zip"
  source_dir = "${path.module}/function/lambda_edge_image_resize"
  output_path = "${path.module}/function/output_zip/lambda_edge_image_resize.zip"
}

# lambda@edgeのorigin-responseイベントで画像をリサイズするためのlambda関数
resource "aws_lambda_function" "lambda_edge_image_resize" {
  # lambda@edgeはus-east-1リージョンのみで利用可能なので、プロバイダを指定する
  provider = aws.us-east-1

  function_name = "lambdaEdgeImageResize"
  role          = var.iam_role_lambda_edge_arn
  handler       = "lambda_edge_image_resize.handler"
  runtime       = "nodejs20.x"    # Node.jsのバージョンを適宜選択

  publish = true

  timeout = 10 // default 3秒だとリサイズ処理が終わらないことがあるため、10秒に設定

  filename         = data.archive_file.lambda_edge_image_resize.output_path
  source_code_hash = data.archive_file.lambda_edge_image_resize.output_base64sha256
}

output "lambda_edge_image_url_customizer_qualified_arn" {
  value = aws_lambda_function.lambda_edge_image_url_customizer.qualified_arn
}

output "lambda_edge_image_resize_qualified_arn" {
  value = aws_lambda_function.lambda_edge_image_resize.qualified_arn
}

```

ここでやっていることは
archive_fileでLambda関数をzip化して
aws_lambda_functionでLambdaにデプロイしている ということですね。

**terraformドキュメント**
[archive_file (Data Source)](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file)
[Resource: aws_lambda_function](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function)

次、

lambda_edge_image_url_customizer.jsです。
基本的には
[Amazon CloudFront & Lambda@Edge で画像をリサイズする](https://aws.amazon.com/jp/blogs/news/resizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog/)
ここのコードを参考にしています。(公式しか勝たん)

```lambda_edge_image_url_customizer.js
'use strict';

const querystring = require('querystring');

// 画像の許可されたリサイズサイズとデフォルトのリサイズサイズを設定
const settingResize = {
  allowedResizeSizes : [ {width:780}, {width:390}],
  defaultResizeSize : {width:780},
};

// Lambda@Edgeのviewer-requestイベントハンドラで使用します
exports.handler = (event, context, callback) => {

  let request;
  try {
    request = event.Records[0].cf.request; // CloudFrontからのリクエスト情報を取得

    // リクエストのクエリ文字列を解析（例: width=780）
    const params = querystring.parse(request.querystring);

    // 元の画像のURIを取得
    let fwdUri = request.uri;

    // widthパラメータがなければ、リクエストをそのまま渡す
    if(!params.width){
      callback(null, request);
      return;
    }

    // リクエストされた幅を整数値に変換
    const requestResizeWidth = parseInt(params.width, 10); // 幅

    // URIからプレフィックス、画像名、拡張子を解析（例: /images/image.jpg）
    const match = fwdUri.match(/^(.+)\/([^\/]+)\.(.+)$/);
    const prefix = match[1];
    const imageName = match[2];
    const extension = match[3];

    // 要求された寸法が許可された寸法に一致するかどうかを判定する変数
    let matchFound = false;

    // 要求された寸法が許可された寸法と一致するかどうかを確認
    for (let allowSize of settingResize.allowedResizeSizes) {

      if (allowSize.width === requestResizeWidth) {
        matchFound = true;
        break;
      }
    }

    // 許可された寸法に一致しない場合は、デフォルトの寸法を使用
    const width = matchFound ? requestResizeWidth : settingResize.defaultResizeSize.width;

    const url = [];
    // 上流に転送される新しいURIを構築
    url.push(prefix);
    url.push("width=" + width);
    url.push(imageName+"."+extension);

    fwdUri = url.join("/");

    // 最終的に変更されたURLは形式 /images/width=780/image.jpg のようになる
    request.uri = fwdUri;
    callback(null, request);

  } catch (error) {

    // エラーが発生した場合は、エラーメッセージをログに出力し、リクエストをそのまま渡す
    console.error(error.message);
    callback(null, request);
  }
};
```

やっていることとしては
`/images/image.jpg?width=780`
みたいなリクエストを受け取って
ごちゃごちゃやって
`/images/width=780/image.jpg`
この形にしてCloudFrontに流すという感じですね。

allowedResizeSizesで縛っているのですが
これは全てのパラメーターを許可してリサイズしてしまったらリソース食われてしまうので
自分のアプリケーションに必要なサイズだけ許可してるという感じですね。
自由度を高めるのであれば縛りを外してあげればいいですね。

これはインフラの世界ですが
アプリケーションからはクエリパラメーターwidthが来ることを期待しているという状況ですね。

どうして
クエリパラメーターをもとにURLパスを作っているかというと
CloudFrontがクエリパラメターを除いたURLでキャッシュするからですね。

aws_cloudfront_distributionリソースの
```
forwarded_values {
    query_string = false
}
```
この設定がそれにあたります。(多分)

次、

lambda_edge_image_resize.jsです。
ここも基本的には
[Amazon CloudFront & Lambda@Edge で画像をリサイズする](https://aws.amazon.com/jp/blogs/news/resizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog/)
ここのコードを参考にしています。(公式しか勝たん)

```lambda_edge_image_resize.js
'use strict';

const http = require('http');
const https = require('https');
const querystring = require('querystring');

const AWS = require('aws-sdk');
// AWS S3サービスを初期化します。
const S3 = new AWS.S3({
  signatureVersion: 'v4',
});
const Sharp = require('sharp');

// Lambda@Edgeのorigin-responseイベントハンドラで使用します
exports.handler = (event, context, callback) => {

  // CloudFrontからのレスポンスを取得します。
  let response = event.Records[0].cf.response;

  // 画像が存在しない（404 or 403応答）場合に処理を行います。 本当は404が返ってきてほしいが403が返ってくるため、403も含めて処理する
  if (Number(response.status) === 404 || Number(response.status) === 403) {

    // CloudFrontからのリクエストを取得します。
    const request = event.Records[0].cf.request;

    // リクエストURIから必要なパスを読み取ります。
    const path = request.uri; //path:/uploads/shops/2/width=780/1_1707444165.webp

    // パスからS3のキーを読み取ります。
    const key = path.substring(1); //Key:uploads/shops/2/width=780/1_1707444165.webp

    // widthの値を抽出
    const widthMatch = key.match(/width=(\d+)/);
    const width = widthMatch ? parseInt(widthMatch[1], 10) : null; // 780

    // パスからwidthまでのパスを抽出
    const pathMatch = key.match(/^(.*?)(?=width=\d+)/);
    const prefix = pathMatch ? pathMatch[1] : null; // uploads/shops/2/

    // ファイル名と拡張子を抽出
    const fileMatch = key.match(/([^/]+)\.(\w+)$/);
    const fileName = fileMatch ? fileMatch[1] : null; // 1_1707444165

    const extension = fileMatch ? fileMatch[2] : null; // webp

    const originalKey = prefix + fileName + "." + extension; // uploads/shops/2/1_1707444165.webp

    // S3から元の画像ファイルを取得します。
    let originalData;
    S3.getObject({ Bucket: 'オリジンとなるバケット名', Key: originalKey }).promise()
    .then(data => {
      originalData = data;
      return Sharp(data.Body)
          .resize(width) // 画像をリサイズします。 横幅だけ指定すると縦は元の画像のアスペクト比に従って自動的にリサイズされます。
          .toBuffer()
    })
    .then(buffer => {

      // バイナリレスポンスを生成し、リサイズされた画像を返します。
      response.status = 200;
      response.body = buffer.toString('base64');
      response.body;
      response.bodyEncoding = 'base64';
      response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/' + extension }];

      callback(null, response);
    })
    .catch( err => {
      console.log("Exception while reading source image :%j",err.message);

      if (originalData) {
        // 元のデータが取得できた場合のエラーハンドリング 画像リサイズ周りでこけることがあったとしても、元の画像は取得できているので、それを返す
        response.status = 200;
        response.body = originalData.Body.toString('base64');
        response.bodyEncoding = 'base64';
        response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/' + extension }];

        callback(null, response);

      } else {

        // 元のデータが取得できなかった場合のエラーハンドリング
        response.status = 500;
        response.body = JSON.stringify({ error: "Error processing the image and original data is unavailable." });
        response.bodyEncoding = 'text';
        response.headers['content-type'] = [{ key: 'Content-Type', value: 'application/json' }];
        callback(null, response);
      }
    });
  } else {
    callback(null, response);
  }
};


```

ここでやっていることは
viewer-requestでカスタムしたURLがCloudFrontにキャッシュしてなかった時に
オリジン(S3)に取りにいく(origin-request)のですがその後のorigin-responseを受け取ります。

ので
viewer-requestから流れてきたURLを受け取ってそれをもとに
リサイズしてCloudFrontに流すということをやっています。

viewer-requestから流れてきたURLを受けとるということを想定しているので
widthがパラメーターにあることを期待している作りになっています。

ここが密結合になっているので課題感があります。
例外処理を適切にしないと終わりますね。

そういう意味でこのコードは不安しかないのでちょっと心配ですね。

## LambdaとCloudFrontの紐付け

作成したLambda関数とCloudFrontの紐付けは
CloudFront側で行います。

```cloudfront.tf
# cloudFront
resource "aws_cloudfront_distribution" "main" {

  origin {
    domain_name = data.aws_s3_bucket.main.bucket_domain_name
    origin_id   = data.aws_s3_bucket.main.id
  }
  enabled = true
  is_ipv6_enabled     = true

  # AWS Managed Caching Policy (CachingDisabled)
  default_cache_behavior {
    target_origin_id       = data.aws_s3_bucket.main.id
    viewer_protocol_policy = "allow-all"
    allowed_methods = [ "GET", "HEAD" ]
    cached_methods = [ "GET", "HEAD" ]

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    min_ttl = 0
    default_ttl = 86400 // 1日
    max_ttl = 259200 // 3日
    
    # ここで紐付けをおこないます!!↓
    lambda_function_association {
      event_type   = "viewer-request"
      lambda_arn   = var.view_request_lambda_qualified_arn
    }

    lambda_function_association {
      event_type   = "origin-response"
      lambda_arn   = var.origin_request_lambda_qualified_arn
    }
    # ここまで↑

  }

  restrictions {
    geo_restriction {
      restriction_type = "whitelist"
      locations = [ "JP" ]
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}
```

## 終わり

なんとなく理解したつもりですが深い部分にまで足を突っ込むと
まだまだ理解が足りてないなと感じますね。

こうやって記事にしてみるとCloudFrontの理解もう少ししないといけないなと思ったし
Lambdaについても理解深めてないといけないなと感じますね。

もしこの記事を読んで
気づいた部分などありましたらご指摘いただけると大変助かります。(レビュアーになってください)

## 現状の課題・不安
- Lambda関数のテストができていない
    - AWS環境上でしか動作確認が取れない
- viewer-requestでの処理とorigin-responseでの処理が密結合になってしまっていて、独立性がない
- 関数内で適切な例外処理ができているという確証が持てていない(影響範囲が大きい分より不安感が高い)
- アプリケーション側からはインフラの都合がわからないのでwidthの値とかパラメーターの渡し方で事故りそう
- エラー通知
    - 知らない間に壊れている可能性があるのでエラー出た時に通知くるように設定したりしておかないとこわい