CloudFront＆Lambda@Edgeで画像リサイズする構成のSAMテンプレートを作成してみた

# 概要

下記URLのAWS公式ブログにあるような、AWSのCloudFrontとLambda@Edgeを使った画像ファイル自動リサイズ構成を一括で構築するSAM（ServerlessApplicationModel）のテンプレートを作成してみました。
[Amazon Web Services ブログ: Amazon CloudFront & Lambda@Edge で画像をリサイズする](https://aws.amazon.com/jp/blogs/news/resizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog/)

![resize_image.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/9c149fa3-1113-735b-3c9b-1a0b1154486c.png)

CloudFrontのViewerRequestとOriginResponseの通信部分（上の図の①と③部分)に関数の処理を挟み、クエリパラメータで指定のサイズに元画像をリサイズした画像を自動的に作成してレスポンスで返却します。
画像のリサイズ処理には[sharp](https://github.com/lovell/sharp)というNode.jsのライブラリを利用しているので、LambdaのランタイムもNodejs14.xとなっています。

[参考: CloudFront開発者ガイド: 関数を使用してエッジでカスタマイズ](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions.html)

# 元記事からのカスタマイズ内容
実現したいことや実現方式は元記事(前述のAWSブログ記事)とほぼ変わりません。
元記事をベースに主に下記のような私的カスタマイズをしています。

- SAMのテンプレートでサクッと構築可能にしている（★今回一番やりたかったこと）
- ViewerRequestの処理をLambda@EdgeではなくCloudFront Functionsで行っている
- Nodejsのランタイムは執筆時点でサポートされている最新バージョンのNodejs14.xを使用
- ViewerRequestとOriginResponseの関数を極力シンプルになるようにカスタマイズ(※)

※ 例として元ネタ記事ではリサイズ画像のサイズを"d=100×100"など縦と横の両方を指定する実装でしたが、私版では"w=100"などと横幅の指定のみにしてみました。
関数の実装内容では他にもいろいろ細かい違いがあります（詳細は割愛します）。

元ネタ記事に掲載のプログラムコードからの変更内容は私的なカスタマイズであり改善ではありません(私はシンプルにすることを主眼において実装したので、元ネタ記事の実装の方が優れている部分もあるかと思います)。
また、動作確認はしていますが長時間をかけてテストしたワケではなく何かしら要改善点はあるかもしれません。当記事掲載のプログラムコードについてはご参考までにどうぞ。

# SAMテンプレート

```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  Sample SAM Template for sam-imageresize

Parameters:
  OriginS3BucketName:
    Type: String
    Description: 'S3BucketName for image files.'
  OriginS3BucketCreation:
    Type: String 
    Default: true
    AllowedValues: [true, false]
    Description: 'OriginS3Bucket is created only if the value is true.'
  MinImageWidth:
    Type: Number
    Default: 50
    Description: 'Min image size that can be specified by query parameter("w=").'
  MaxImageWidth:
    Type: Number
    Default: 500
    Description: 'Max image size that can be specified by query parameter("w=").'
  CacheControlValue:
    Type: String
    Default: 'max-age=31536000'
    Description: 'The cache-control value to set in the resized image S3Object Metadata'
  CloudFrontPriceClass:
    Type: String
    Default: PriceClass_200
    AllowedValues: [PriceClass_100, PriceClass_200, PriceClass_All]

Conditions:
  NeedOriginS3Creation: 
    !Equals [true, !Ref OriginS3BucketCreation]

Resources:
  # CloudFront Function(ViewerRequest)
  ViewerRequestFunction:
    Type: AWS::CloudFront::Function
    Properties:
      Name: 'ImageResizeViewerFunction'
      FunctionConfig:
        Comment: 'Sample CloudFront Function for image resize'
        Runtime: 'cloudfront-js-1.0'
      AutoPublish: true
      FunctionCode: !Sub |
        var MIN_WIDTH = ${MinImageWidth};
        var MAX_WIDTH = ${MaxImageWidth};
        
        function handler(event) {
          var request = event.request;
          var querystring = request.querystring;
          var uri = request.uri;
        
          console.log('querystring:', querystring);
        
          if (!querystring['w']) {
            // no width specified.
            return request;
          }
        
          var width = querystring['w'].value;
          try {
            width = parseInt(width, 10);
          } catch (e) {
            // failed parseInt
            return request;
          }
        
          if (!checkWidthRange(width)) {
            return request;
          }
        
          // ex: "/images/sample.png" → "/images/w100/sample.png"
          var uriParts = uri.split('/');
          console.log(uriParts.length);
        
          uriParts.splice(2, 0, 'w' + width);
          request['uri'] = uriParts.join('/');
        
          return request;
        }
        
        // Check if the width is within range.
        function checkWidthRange(width) {
          if (width > MAX_WIDTH) {
            console.log('too large specific width:', width);
            return false;
          } else if (width < MIN_WIDTH) {
            console.log('too small specific width:', width);
            return false;
          }
          return true;
        }

  # Lambda@Edge Function(OriginResponse)
  OriginResponseFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub '${AWS::StackName}-OriginResponseFunction'
      Runtime: nodejs14.x
      Handler: index.handler
      Description: 'create resized image if not exists'
      CodeUri: 'OriginResponse/'
      MemorySize: 512
      Timeout: 10
      Role: !GetAtt OriginResponseFunctionRole.Arn

  OriginResponseFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${AWS::StackName}-OriginResponseFunctionRole'
      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'
      # inline policy
      Policies:
        - PolicyName: "OriginResponseFunctionRole-Policy"
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Action:
                  - s3:GetObject
                  - s3:PutObject
                Effect: Allow
                Resource: !Sub arn:aws:s3:::${OriginS3BucketName}/*
              - Action: ssm:GetParameter
                Effect: Allow
                Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/Sample-OriginResponseFunction-Params'

  OriginS3Bucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Condition: NeedOriginS3Creation
    Properties:
      BucketName: !Ref OriginS3BucketName
      AccessControl: Private
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True

  OriginS3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref OriginS3BucketName
      PolicyDocument:
        Statement:
          - Action:
              - s3:GetObject
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${OriginS3BucketName}/*
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${OriginAccessIdentity}
          - Action:
              - s3:ListBucket
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${OriginS3BucketName}
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${OriginAccessIdentity}

  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Comment: "Distribution for image resise sample."
        DefaultCacheBehavior:
          TargetOriginId: myS3Origin
          ForwardedValues:
            QueryString: false
            Cookies:
              Forward: 'none'
          ViewerProtocolPolicy: redirect-to-https
          Compress: true

        CacheBehaviors:
          - PathPattern: images/*
            TargetOriginId: myS3Origin
            ForwardedValues:
              QueryString: false
              Cookies:
                Forward: 'none'
            ViewerProtocolPolicy: redirect-to-https
            Compress: true
            FunctionAssociations:
              - EventType: 'viewer-request'
                FunctionARN: !GetAtt ViewerRequestFunction.FunctionMetadata.FunctionARN
            LambdaFunctionAssociations:
              - EventType: 'origin-response'
                LambdaFunctionARN: !Ref OriginResponseFunctionVersion1

        DefaultRootObject: index.html
        Enabled: true
        Origins:
          - DomainName: !Sub ${OriginS3BucketName}.s3.amazonaws.com
            Id: myS3Origin
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${OriginAccessIdentity}"
        PriceClass: !Ref CloudFrontPriceClass

  OriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Ref AWS::StackName

  # creates a version from the current code
  OriginResponseFunctionVersion1:
    Type: AWS::Lambda::Version
    Properties:
      FunctionName: !Ref OriginResponseFunction
      Description: 'init version'

  SSMParameterStore:
    Type: AWS::SSM::Parameter
    Properties: 
      DataType: text
      Description: 'parameters for OriginResponseFunction.'
      Type: String
      Name: 'Sample-OriginResponseFunction-Params'
      Value: !Sub | 
        {
          "bucketName": "${OriginS3BucketName}",
          "cacheControl": "${CacheControlValue}"
        }
Outputs:
  CloudfrontDomainName:
    Value: !GetAtt CloudFrontDistribution.DomainName
```

[GitHub](https://github.com/chs-k-kinoshita/sam-resizeImgWithEdge)より一式をダウンロードしてsamコマンドでbuildとdeployを行うことでリソースを構築できます。
（手順は後述します）

このテンプレートでのデプロイが成功するとCloudFormationスタックで下記のリソースが作成されます。

- S3バケット（CloudFrontのオリジン）とバケットポリシー
- CloudFrontディストリビューション
- CloudFrontOriginAccessIdentity（CloudFrontからS3バケットへのアクセスを許可させるためのリソース）
- CloudFront Function
- Lambda関数
- IAMロール(Lambda関数用)
- SSMパラメータストアのパラメータ(Lambda関数からの参照用)

# ViewerRequest関数のポイント解説

```javascript
var MIN_WIDTH = 50;
var MAX_WIDTH = 500;

function handler(event) {
  var request = event.request;
  var querystring = request.querystring;
  var uri = request.uri;

  console.log('querystring:', querystring);

  if (!querystring['w']) {
    // no width specified.
    return request;
  }

  var width = querystring['w'].value;
  try {
    width = parseInt(width, 10);
  } catch (e) {
    // failed parseInt
    return request;
  }

  if (!checkWidthRange(width)) {
    return request;
  }

  // ex: "/images/sample.png" → "/images/w100/sample.png"
  var uriParts = uri.split('/');
  console.log(uriParts.length);

  uriParts.splice(2, 0, 'w' + width);
  request['uri'] = uriParts.join('/');

  return request;
}

// Check if the width is within range.
function checkWidthRange(width) {
  if (width > MAX_WIDTH) {
    console.log('too large specific width:', width);
    return false;
  } else if (width < MIN_WIDTH) {
    console.log('too small specific width:', width);
    return false;
  }
  return true;
}
```

ViewerRequestの関数(CloudFront Functions)では、元のリクエストのパスをクエリパラメータの指定に応じて改変しています。
例として、元のHTTPリクエストが"/images/sample.png?w=100"だった場合は"/images/w100/sample.png"のように"w100"というパス階層を挿入したリクエストがオリジンに対して行なわれることになります。
クエリパラメータ("w")の指定がない場合、および数値以外が指定された場合や許容サイズの範囲外の場合は、改変は行いません。

"/images/sample.png"のパスに"?w=100"のクエリパラメータを指定した場合はhandler関数の引数のeventには下記のような構造の情報が設定されますので、"uri"プロパティの値のみを"/images/w100/sample.png"に改変したものをreturnします。
```json
{
  "request": {
    "headers": {},
    "method": "GET",
    "querystring": {
      "w": {
        "value": "100"
      }
    },
    "uri": "/images/sample.png",
    "cookies": {}
  }
}
```

# OriginResponse関数のポイント解説

```javascript
const AWS = require('aws-sdk');
const s3 = new AWS.S3();
const ssm = new AWS.SSM({region: 'us-east-1'});
const Sharp = require('sharp');
const SSMParameterName = 'Sample-OriginResponseFunction-Params';

exports.handler = async (event, context, callback) => {
  let response = event.Records[0].cf.response;

  console.log('Response status code :%s', response.status);
  // check if image is not present
  if (response.status != '404') {
    callback(null, response);
    return;
  }

  let request = event.Records[0].cf.request;
  let path = request.uri;
  // read the S3 key from the path variable. Ex: path variable /images/w100/image.jpg
  let key = path.substring(1);

  const keyParts = key.split('/');
  const fileName = keyParts[keyParts.length - 1];
  const fileNameParts = fileName.split('.');
  const extention = fileNameParts[fileNameParts.length - 1];

  // correction for jpg required for 'Sharp'
  const requiredFormat = extention === 'jpg' ? 'jpeg' : extention;

  if (!(requiredFormat === 'jpeg' || requiredFormat === 'png')) {
    // support only jpeg or png.
    callback(null, response);
    return;
  }

  // get width. Ex: images/w100/image.jpg → 100
  let width;
  const widthPart = keyParts[keyParts.length - 2];
  try {
    width = parseInt(widthPart.substring(1), 10);
  } catch (err) {
    console.log('cannot get width from widthPart: %s', widthPart);
    callback(null, response);
    return;
  }

  // remove width part. Ex: images/w100/image.jpg → images/image.jpg
  keyParts.splice(keyParts.length - 2, 1);
  const originalKey = keyParts.join('/');
  
  const ssmReq = {
    Name: SSMParameterName
  };
  // get parameters from SSM Parameterstore.
  const ssmRes = await ssm.getParameter(ssmReq).promise();
  console.log('ssmResValue:', ssmRes.Parameter.Value);
  const envParams = JSON.parse(ssmRes.Parameter.Value);
  const originBucket = envParams['bucketName'];
  const cacheControl = envParams['cacheControl']; // ex) 'max-age=31536000'

  // get the source image file
  console.log('getObject originalKey:', originalKey);
  let s3Data;
  try {
    s3Data = await s3.getObject({ Bucket: originBucket, Key: originalKey }).promise();
  } catch (err) {
    console.log('Err getObject', err);
    callback(null, response);
    return;
  }

  // perform the resize operation
  const sharpedBuff = await Sharp(s3Data.Body).resize(width).toBuffer();

  // save the resized object to S3 bucket with appropriate object key.
  try {
    await s3.putObject({
      Body: sharpedBuff,
      Bucket: originBucket,
      ContentType: 'image/' + requiredFormat,
      CacheControl: cacheControl,
      Key: key,
      StorageClass: 'STANDARD'
    }).promise();
  } catch (err) {
    console.log('Exception while writing resized image to bucket', err);
    callback(null, response);
    return;
  }
  console.log('resized:', originalKey);

  // generate a binary response with resized image
  response.status = '200';
  response.body = sharpedBuff.toString('base64');
  response.bodyEncoding = 'base64';
  response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/' + requiredFormat }];
  response.headers['cache-control'] = [{ key: 'Cache-Control', value: cacheControl }];
  response.headers['etag'] = [{ key: 'etag', value: s3Data.ETag }];
  response.headers['last-modified'] = [{ key: 'Last-Modified', value: s3Data.LastModified }];
  
  callback(null, response);
};
```

ViewRequestで"/images/w100/sample.png"のようにパスが改変された場合、CloudFrontからOrigin（S3バケット）に対象オブジェクトをGETしに行った結果が"404"(NotFound)になります。
OriginResponseの関数(Lambda@Edge)では、この"404"(NotFound)の場合にパスの"w100"部分とそれを除いたパスからサイズ指定と元画像のパスを特定し、リサイズ画像を動的に作成してレスポンスとして返却する処理を行っています。
レスポンスとして返却するだけでなく、作成したリサイズ画像をS3バケットに永続化して一度生成したものの再処理が行われないようにします。

---

# ビルドとデプロイの手順

前提として、各種リソースを作成するにはSAM CLIやDockerが利用できる環境と、Administrator相当のIAM権限が必要です。
[AWS Cloud9](https://aws.amazon.com/jp/cloud9/)の環境を利用すると必要なものが揃っているので便利です。

## リソースの取得(GitHubよりclone)

[GitHub](https://github.com/chs-k-kinoshita/sam-resizeImgWithEdge)からリソース一式をcloneします。
Lambda関数のソースコードと、SAMのテンプレート(template.yaml)が含まれる一式を取得できます。
※ ViewerRequestのJavaScriptコードは、template.yamlの中にインラインで埋め込まれています。

```
git clone https://github.com/chs-k-kinoshita/sam-resizeImgWithEdge
```

## S3バケット名の修正

まず、CloudFrontで配信する画像ファイルを格納する任意のS3バケット名を決めます。
"samconfig.toml"ファイルの"XXXXXX"部分を置換して保存します。
```
parameter_overrides = "OriginS3BucketName=XXXXXX OriginS3BucketCreation=true"
```

## ビルド

samのコマンドでビルドします。
※コマンドの実行は"template.yaml"ファイルが存在するディレクトリ上に移動して実行します。
```
sam build --use-container
```

ビルドが成功すると下記のような表示になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/e99987bb-e770-6517-a697-da21f5fbdbd0.png)


**(備考１)**
"--use-container"パラメータの指定によりビルドのためのコンテナイメージがダウンロードされます。

:::note warn
"--use-container"パラメータを指定すると、Cloud9環境のデフォルトのEBSストレージサイズ(10G)だと容量が足りなくてエラーになります。その場合は下記URLの情報を参考に20Gなどに拡張します。
[Cloud9ユーザーガイド:環境で使用されている Amazon EBS ボリュームのサイズ変更](https://docs.aws.amazon.com/ja_jp/cloud9/latest/user-guide/move-environment.html#move-environment-resize)
:::

**(備考２)**
画像処理ライブラリである"sharp"は環境依存のものがダウンロードされるので、普通にWindows上でビルドしたものをLinux上にデプロイした場合は動きません。
しかし"--use-container"指定によってLambdaの実行環境に近いコンテナイメージ内でビルドされるので、SAM CLIやDockerが利用できる環境であればWindows環境でもビルドが可能です。

## デプロイ

samのコマンドでデプロイすると、CloudFormationスタックで各リソースが作成されます。
スタック名は"samconfig.toml"ファイルで設定可能です（デフォルトで"sam-sample-resizeImgWithEdge"としています）。
```
sam deploy
```
あるいは
```
sam deploy --resolve-s3
```
※ "--resolve-s3"オプションをつけない場合は、"samconfig.toml"ファイルの"s3_bucket"パラメータでデプロイのためにビルド成果物を一時アップロードするバケットを指定します。"--resolve-s3"オプションをつけた場合は、SAM CLIが自動的にバケットを作成します。

デプロイ後のCloudFrontのディストリビューションには"/images/*"パス用のビヘイビア設定があり、ViewerRequestとOriginResponseに関数がアタッチされていることを確認できると思います。

![cloudfront_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/c74a4c8e-54d2-5ef2-3d71-dea7e1933308.png)


# 動作確認の方法

- 作成されたS3バケットの"images/"フォルダの下に任意のpngあるいはjpegの拡張子で画像をアップロードして、WebブラウザでCloudFrontの公開ドメインのURLから画像が参照可能であることを確認します。
- 次に、同じ画像のURLの末尾に"?w=100"などと任意のサイズを指定したパラメータをつけて参照し、指定サイズにリサイズされた画像が参照可能であることを確認します。
- 成功した場合には、S3バケット上に"images/w100/"のようにクエリパラメータに指定した値のサブフォルダが作成されており、その下にリサイズ画像が格納されています。
- リサイズ画像作成処理はリサイズ画像が存在しない場合のみ行われ、一度作成したものはS3バケットに永続化されてエッジロケーションにもキャッシュされます。

---

# 補足・注意点など

## 指定可能なパラメータについて

"samconfig.toml"ファイルの"parameter_overrides"でパラメータを指定できます。
指定可能なパラメータの詳細は"template.yaml"の"Parameters"セクションを参照下さい。

```yaml
Parameters:
  OriginS3BucketName:
    Type: String
    Description: 'S3BucketName for image files.'
  OriginS3BucketCreation:
    Type: String 
    Default: true
    AllowedValues: [true, false]
    Description: 'OriginS3Bucket is created only if the value is true.'
  MinImageWidth:
    Type: Number
    Default: 50
    Description: 'Min image size that can be specified by query parameter("w=").'
　省略:
```

## リージョン指定について

Lambda@Edgeのリソースはバージニア北部リージョンに作成する必要があります。
samconfig.tomlファイルの"us-east-1"リージョンの指定を別リージョンに変更することはできません。

## CloudFrontのオリジンとなるS3バケットの作成について

オリジンのS3バケットは"DeletionPolicy: Retain"を指定して、スタックと一緒に削除されないようにしています。
samconfig.tomlファイルの"OriginS3BucketCreation"指定で、S3バケットをテンプレートで作成するか否かを指定可能にしています。
```
parameter_overrides = "OriginS3BucketName=XXXXXX OriginS3BucketCreation=true"
```
:::note warn
初回構築後、スタックをアップデートする際には「OriginS3BucketCreation=true」のままだと、既に存在するS3バケットを作成しようとして更新がエラーになります。
初回構築以降のアップデートでは"OriginS3BucketCreation"パラメータをfalseに指定してS3バケット作成のActionが抑制されるようにして下さい。
:::


## キャッシュ設定について

リサイズ画像作成時にはオリジンのS3オブジェクトの"Cache-Control"メタデータに"max-age"でキャッシュ期間を設定しておりHTTPレスポンスヘッダとして"etag"や"Last-Modified"とともに返却されます。
CloudFrontやブラウザのキャッシュの振る舞いはその設定に従います。
"max-age"の値は、SAMテンプレートの"CacheControlValue"パラメータで指定可能にしており、デフォルト値は"max-age=31536000"（1年）としています。

```yaml
Parameters:
　省略:
  CacheControlValue:
    Type: String
    Default: 'max-age=31536000'
    Description: 'The cache-control value to set in the resized image S3Object Metadata'
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2531623/8a4bb40c-8899-d3f0-6aa4-2e20a934fd37.png)


パラメータで指定した設定値はSSM(SystemsManager)パラメータストアに'Sample-OriginResponseFunction-Params'というキーでjson文字列で保存されており、OriginResponseのLambda@Edge関数から参照しています。

## Lambda@Edgeでパラメータストアを使う場合はregion指定が必要

今回はパラメータストアもLambdaもバージニア北部(us-east-1)に作成していますが、EdgeのLambdaが実際に実行されるのはエッジロケーションになります。
明示的にリージョンを指定しないとアクセス元が東京である場合は東京リージョンのパラメータストアを参照しに行ってしまいます。
```javascript
const ssm = new AWS.SSM({region: 'us-east-1'});
```
下記ClassMethodさん技術ブログを参考にさせて頂きました。
[参考: Lambda@EdgeでAWS Systems Managerのパラメータストアを使う](https://dev.classmethod.jp/articles/lambda-at-edge-with-aws-systems-manager-parameter-store/)

## CloudFront Functionではconstやletが使えない

最初は普通に使えると思い込んでコーディングした後、動作確認時に使えないことに気づいて"var"で修正しました。

[参考: CloudFront Functions の JavaScript runtime 機能](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/functions-javascript-runtime-features.html)

## Lambda@Edge関数のバージョン指定について

現状ではLambda@Edgeはバージョンまで指定してLambdaのARNを設定する必要があり、"$LATEST"などのAliasを利用することができません。
初回のデプロイの後にLambda@Edge関数のコードを更新し、Edgeにも修正を反映させたい場合は新しいバージョンを発行してCloudFrontディストリビューションのOriginResponseの設定にも反映させる必要があります。

SAMテンプレートのLambdaバージョン発行部分（初回用バージョン）
※現在CloudFrontから参照されているバージョンは消せないので、直近の定義は消さずに新しい定義を追加する必要があります。
```yaml
  OriginResponseFunctionVersion1:
    Type: AWS::Lambda::Version
    Properties:
      FunctionName: !Ref OriginResponseFunction
      Description: 'init version'
```

新しいVersionを追加した場合は、参照している部分も修正する。
```yaml
  CloudFrontDistribution:
        （省略）:
        CacheBehaviors:
            （省略）:
            LambdaFunctionAssociations:
              - EventType: 'origin-response'
                LambdaFunctionARN: !Ref OriginResponseFunctionVersion1
```

## オリジンにファイルが無い場合に404を返すために(403ではなく)

CloudFrontディストリビューションからS3バケットへのアクセスを許可させるためにバケットポリシーによるOAI（Origin Access Identity）への許可設定を行いますが、GetObjectの許可のみだとファイルが存在しないエラーの場合にも403（ Access Denied）のレスポンスになってしまいOriginResponseのLambda関数で "if (response.status != '404') " で判定している箇所が正しく動作しません。
期待通りに404(Not Found)を返却させるためにはGetObjectだけでなくバケットに対するListBucketの許可も必要です。

template.yamlの下記の部分が該当します。
```yaml
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref OriginS3BucketName
      PolicyDocument:
        Statement:
          - Action:
              - s3:GetObject
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${OriginS3BucketName}/*
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${OriginAccessIdentity}
          - Action:
              - s3:ListBucket
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${OriginS3BucketName}
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${OriginAccessIdentity}
```

[(参考：ClassMethod社ブログ) オリジンを S3 とした CloudFront に対して、存在しないオブジェクトへアクセスした際の HTTP ステータスコードが 403 Forbidden になったときの対処方法](https://dev.classmethod.jp/articles/tsnote-cloudfront-s3-notexistobject-403-001/)

## CloudFormationスタックの削除に失敗する場合がある

当記事のSAMテンプレートで各種リソース構築後、不要になったら作成に使われたCloudFormationスタックを削除することで各種リソース（S3バケット以外）は削除されます。
<font color="Crimson">しかし、CloudFrontのOriginResponseに関連付いているLambda関数はおそらく削除に失敗します。</font>
この場合、OriginResponseのLambda関数のリソースのみスキップしてスタック削除を継続し、Lambda関数のみ時間をおいてから個別に削除してください。

---

# 最後に

元ネタのAWS技術ブログの記事は、書かれたのが結構古い(2018)のもあり、今となっては記事の内容の通りにやってみようとしてもなかなか大変だったりします。
テンプレートからサクッと仕組みを構築できれば便利かもしれません。
当記事の内容がなにかの参考になれば幸いです。