Lambda@Edge+CloudFront+S3で画像をリサイズする（Python）

# 背景
- `CloudFront`と `S3` で画像を配信する際、任意のサイズでリサイズしたいという要望があった
- Lambda@Edge で画像リサイズをする記事はいろいろあったが、Pythonで行っているものは案外少なく四苦八苦した（Lambda@Edge自体も初めてだった）

# 環境
- Widows11
- WSL2
- Visial Studio Code

# 前提
- ビルドを行うWSLでDockerコマンドが使用できること
- sam コマンド（AWS SAM CLI）が使用できること（インストール方法は下記参照）
    [AWS SAM CLIのインストール](https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/install-sam-cli.html)
- CloudFront と S3で画像を配信する仕組みは構築済み

# Lambda@Edgeを使用する上での制限
下記をよく読まないと無駄な苦労をする（自分）
[エッジ関数に対する制限](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions-restrictions.html)

# 簡単な仕様説明
- リサイズしたい画像のURLパスの直前に「/{width}_{height}/」をつけると、指定サイズの画像を返す
    - 例）https://ルートドメイン/images/example.png を300×300にしたい場合
        -> https://ルートドメイン/images/300_300/example.png
        ※ https://ルートドメイン/300_300/images/example.png だとリサイズされない
- エラーが発生した場合はLINE通知を行う
    ※ この記事では LINE Notify のやり方については詳しく説明しない

# 注意事項
- IP制限などを行っておらずどこからでもリクエストを受け付ける場合、無限に画像を作成されてしまう恐れがある。（この記事のコードではその対処ができていない。対処方法は下記の参考サイト参照。）
- エラーが発生したときにすぐに検知できるようLINE通知を用いているが、実装を誤ると大量の通知が来る恐れがある
- 関数ログ出力場所については下記参照
    [AWS Lambda@Edgeのログはどこ？AWS Lambda@Edgeのログ出力先について](https://dev.classmethod.jp/articles/where-is-the-lambda-edge-log/)

# 参考サイト
- [Amazon CloudFront & Lambda@Edge で画像をリサイズする](https://aws.amazon.com/jp/blogs/news/resizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog/)
    -> AWSのブログ。 Lambda@Edgeでのリサイズの流れやSAMでデプロイする方法などがある。ただしランタイムはNode.js

# ディレクトリ構成
```
resize-image（ルートディレクトリ）
├─ function
│  ├─ lambda_function.py
│  ├─ line.py
│  └─ requirements.txt
├─ samconfig.yaml
└─ template.yaml
```

# 各ファイルの内容

```python:function/lambda_function.py
import base64
import io
import json
import logging
import re
import sys
import traceback
from pathlib import Path
from urllib.parse import unquote

import boto3
from botocore.exceptions import ClientError
from line import Line  # LINE通知を行いたい場合
from PIL import Image

s3 = boto3.client('s3')

logger = logging.getLogger()
logger.setLevel(logging.INFO)

BUCKET_NAME = 'BUCKET_NAME'


def lambda_handler(event, context):  
    # event の内容を確認したい場合、ログレベルをDEBUGにする
    logger.debug("\nイベントデータ受信\n" + json.dumps(event, indent=4))
    
    request = event['Records'][0]['cf']['request']
    response = event['Records'][0]['cf']['response']
    
    try:
        # S3から画像を取得するために先頭のスラッシュをなくす
        s3_key = unquote(re.sub(r'^/', '', request['uri']))
        response_status = response['status']
        
        logger.info(f"リクエスト: {s3_key}")
        logger.info(f"オリジンレスポンス: {response_status}")
        
        # 404でなければそのまま返却
        if response_status != '404':
            logger.info(f"404ではないのでそのまま返却します")
            return response
        
        s3_path = Path(s3_key)
        ext = s3_path.suffix[1:]
        logger.info(f"拡張子: {ext}")
        
        # パターンにマッチしなければそのまま返却（"/{width}_{height}/画像ファイル" のパターンであるか）
        match = re.search(rf"(/(\d+)_(\d+)/)[^/]+\.{ext}", s3_key)
        if not match:
            logger.info("パターンにマッチしませんでした")
            return response

        # オリジナル画像のS3キーを取得する
        original_s3_key = s3_key.replace(match.group(1), '/')
        logger.info(f"オリジン: {original_s3_key}")

        # オリジナルの画像自体がない場合はそのまま返却
        if not file_exists(original_s3_key):
            logger.info(f"リクエストされた画像はS3に存在しません。")
            return response
        
        # リサイズ処理
        width = int(match.group(2))
        height = int(match.group(3))
        logger.info(f"リサイズ: {str(width)}x{str(height)}")
        
        original_image = s3.get_object(Bucket=BUCKET_NAME, Key=original_s3_key)
        original_image_body  = original_image['Body'].read()
        content_type = original_image['ContentType']

        # Pillowで画像処理
        with Image.open(io.BytesIO(original_image_body)) as img:
            img = img.resize((width, height), Image.LANCZOS)
            
            resized_image = io.BytesIO()
            # saveのformatにJPGを指定する際に発生するエラーを回避
            if ext.upper() == 'JPG':
                save_format = 'jpeg'
            else:
                save_format = ext.upper()
            img.save(resized_image, format=save_format)
            
            resized_image.seek(0)

            # リサイズした画像をS3に保存
            s3_response = s3.put_object(
                Bucket=BUCKET_NAME,
                Key=s3_key,
                Body=resized_image,
                ContentType=content_type,
                CacheControl='s-max-age=31536000'  # ここは任意の値
            )
            logger.info(f'S3_PutResponse: {s3_response}')
            logger.info(f'S3に保存: {s3_key}')

        # 元のレスポンスを修正したものを返す
        response['status'] = '200'
        response['statusDescription'] = 'OK'
        # cache-control は任意。不要であればつけなくてよい。
        response['headers']['cache-control'] = [
            {
                'key': 'Cache-Control',
                'value': 's-max-age=31536000'
            }
        ]
        response['headers']['content-type'] = [
            {
                'key': 'Content-Type',
                'value': content_type
            }
        ]
        response['body'] = base64.b64encode(resized_image.getvalue()).decode('utf-8')
        response['bodyEncoding'] = 'base64'
        
        logger.debug(response)
        
        return response
    
    except Exception:
        etype, value, tb = sys.exc_info()
        message = f"[{context.function_name}] でエラーが発生しました。\n" + '\n'.join(traceback.format_exception(etype, value, tb))
        logger.error(message)
        # 下記はエラーをLINE通知したい場合
        line = Line()
        line.line_notify_alarm(message)
        
        # そのままレスポンスを返す
        return response


def file_exists(key: str) -> bool:
    """指定されたS3バケットにファイルが存在するか確認する"""
    try:
        s3.head_object(Bucket=BUCKET_NAME, Key=key)
        return True
    except ClientError as e:
        if e.response['Error']['Code'] == '404':
            return False
        else:
            # その他のエラーは再度例外を発生させる
            raise

```

```python:function/line.py（LINE通知したい場合）
import logging
import urllib.parse
import urllib.request

import boto3

ssm = boto3.client('ssm')

logger = logging.getLogger()
logger.setLevel(logging.INFO)


class Line:
    def __init__(self):
        self.alarm_access_tokens: list[str] = self._get_alarm_access_tokens()

    def _get_alarm_access_tokens(self) -> list[str]:
        """パラメータストアからLINE通知のアクセストークンを取得"""
        params = ssm.get_parameters(
            Names=[
                '/line-notify/token',
            ],
            WithDecryption=True
        )
        
        return [param['Value'] for param in params['Parameters']]
        
    
    def line_notify_alarm(self, message: str) -> None:
        """LINE通知を行う"""
        url = "https://notify-api.line.me/api/notify"
        data = {'message': '\n' + message }

        # 複数のLINE通知に対応する
        for access_token in self.alarm_access_tokens:
            headers = { 
                'Authorization': f'Bearer {access_token}',
                'Content-Type': 'application/x-www-form-urlencoded'
            }
            
            req = urllib.request.Request(url, urllib.parse.urlencode(data).encode('utf-8'), headers)
            req.get_method = lambda: 'POST'
            urllib.request.urlopen(req)

```

```text:function/requirements.txt（バージョンは適宜必要なものを）
boto3==1.34.51
pillow==10.2.0
```

```yaml:samconfig.yaml
version: 0.1
default:
  deploy:
    parameters:
      stack_name: "STACK_NAME"  # 適当なCloudFormationスタック名
      s3_bucket: "BUCKET_NAME"  # 適当なバケット名
      s3_prefix: ""             # プレフィックスを指定する場合は記入
      region: "us-east-1"       # 米国東部 (バージニア北部) リージョンにある必要がある
      capabilities: "CAPABILITY_IAM"
```

```yaml:template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: SAM Template for ResizeImage
Globals:
  Function:
    Timeout: 3

Resources:
  Function:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: ResizeImage
      CodeUri: function/
      Handler: lambda_function.lambda_handler
      Runtime: python3.11

Outputs:
  Function:
    Description: "ResizeImage Lambda Function ARN"
    Value: !GetAtt Function.Arn
  FunctionIamRole:
    Description: "Implicit IAM Role created for ResizeImage function"
    Value: !GetAtt FunctionRole.Arn
```

# デプロイ・CloudFrontへの関連付け

## 前提条件
- IAMロールを作成できる権限があること

## S3バケットの作成
- `samconfig.yaml` で記載したS3バケットを作成する

## ローカル（WSL）での作業
1. **ビルド**
    - ルートディレクトリで実行する
    - コンテナを使用してビルドを行う
    - 短いスパンでビルドするとエラーとなることがあった
        -> `.aws-sam` ディレクトリを削除するとビルド可能となった
    ```shell
    sam build --use-container --build-image public.ecr.aws/sam/build-python3.11:1
    ```
    　
1. **デプロイ**
    ```
    sam deploy
    ```

## AWSコンソールでの作業
1. **S3バケットにバケットポリシーを付与（初回デプロイ後のみ）**
    ```json
    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Principal": {
                    "AWS": "Lambda関数IAMロールのARN"
                },
                "Action": [
                    "s3:PutObject",
                    "s3:GutObject",
                    "s3:ListBucket"
                ]
                "Resource": [
                    "arn:aws:s3:::BUCKET_NAME",
                    "arn:aws:s3:::BUCKET_NAME/*"
                ]
            }
        ]
    }
    ```
    ※ もしOACでS3にアクセス制限をかける場合、CloudFrontでコピーしたバケットポリシーをそのまま貼り付けると、実装の関係上リサイズ時にエラーが発生する。
    S3に画像がない場合に404を返してほしいので、下記のように`Action`に`s3:ListBucket`、`Resource`に`arn:aws:s3:::BUCKET_NAME`を追加。
    ```json
    {
        "Sid": "AllowCloudFrontServicePrincipal",
        "Effect": "Allow",
        "Principal": {
            "Service": "cloudfront.amazonaws.com"
        },
        "Action": [
            "s3:GetObject",
            "s3:ListBucket"
        ],
        "Resource": [
            "arn:aws:s3:::BUCKET_NAME",
            "arn:aws:s3:::BUCKET_NAME/*"
        ],
         "Condition": {
            "StringEquals": {
                 "AWS:SourceArn": "arn:aws:cloudfront::012345678901:distribution/ABCDEFGHIJKLMN"
            }
        }
    }
    ```
1. **Lambda関数のIAMロールにIAMポリシー付与（初回デプロイ後のみ）**
    ※ LINE通知を行わないのであれば不要。
    ※ LINE通知を行う場合はパラメータストアから値を取得する権限、KMSで復号する権限が必要。（省略）
1. **Lambda関数のバージョン発行**
    - `$LATEST` やエイリアスではなく、Lambda 関数の番号付きバージョンを使用する必要がある
    - デプロイしたときは忘れずに実行する
    - 方法
        - リージョン「バージニア北部」にあるLambda関数「ResizeImage」を開く
        - 「バージョン > 新しいバージョンの発行」の順でクリック
        - 「バージョンの説明」を記入して「発行」をクリック
        - 発効後に表示された画面の真ん中右あたりにある「関数のARN」をコピーしておく
1. **CloudFrontへの関連付け**
    - 関連付けを行うCloudFrontディストリビューションを開く
    - 「ビヘイビア」をクリック
    - 関連付けを行うパターンを1つ選び「編集」をクリック
    - - 下のほうにある「関数の関連付け」で「オリジンレスポンス」の「関数ARN/名前」にある既存のARNを先ほどコピーしたARNに置き換える
    - 「変更を保存」をクリック


# 最後に
- この記事は実際に実装したときのことを思い出しながら書いています。場合によっては不足があるかもしれません
- 不足している情報、間違い、改善案などありましたらコメント頂けますと幸いです