背景
-
CloudFront
とS3
で画像を配信する際、任意のサイズでリサイズしたいという要望があった - Lambda@Edge で画像リサイズをする記事はいろいろあったが、Pythonで行っているものは案外少なく四苦八苦した(Lambda@Edge自体も初めてだった)
環境
- Widows11
- WSL2
- Visial Studio Code
前提
- ビルドを行うWSLでDockerコマンドが使用できること
- sam コマンド(AWS SAM CLI)が使用できること(インストール方法は下記参照)
AWS SAM CLIのインストール - CloudFront と S3で画像を配信する仕組みは構築済み
Lambda@Edgeを使用する上での制限
下記をよく読まないと無駄な苦労をする(自分)
エッジ関数に対する制限
簡単な仕様説明
- リサイズしたい画像のURLパスの直前に「/{width}_{height}/」をつけると、指定サイズの画像を返す
- 例)https://ルートドメイン/images/example.png を300×300にしたい場合
-> https://ルートドメイン/images/300_300/example.png
※ https://ルートドメイン/300_300/images/example.png だとリサイズされない
- 例)https://ルートドメイン/images/example.png を300×300にしたい場合
- エラーが発生した場合はLINE通知を行う
※ この記事では LINE Notify のやり方については詳しく説明しない
注意事項
- IP制限などを行っておらずどこからでもリクエストを受け付ける場合、無限に画像を作成されてしまう恐れがある。(この記事のコードではその対処ができていない。対処方法は下記の参考サイト参照。)
- エラーが発生したときにすぐに検知できるようLINE通知を用いているが、実装を誤ると大量の通知が来る恐れがある
- 関数ログ出力場所については下記参照
AWS Lambda@Edgeのログはどこ?AWS Lambda@Edgeのログ出力先について
参考サイト
-
Amazon CloudFront & Lambda@Edge で画像をリサイズする
-> AWSのブログ。 Lambda@Edgeでのリサイズの流れやSAMでデプロイする方法などがある。ただしランタイムはNode.js
ディレクトリ構成
resize-image(ルートディレクトリ)
├─ function
│ ├─ lambda_function.py
│ ├─ line.py
│ └─ requirements.txt
├─ samconfig.yaml
└─ template.yaml
各ファイルの内容
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
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)
function/requirements.txt(バージョンは適宜必要なものを)
boto3==1.34.51
pillow==10.2.0
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"
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)での作業
-
ビルド
- ルートディレクトリで実行する
- コンテナを使用してビルドを行う
- 短いスパンでビルドするとエラーとなることがあった
->.aws-sam
ディレクトリを削除するとビルド可能となった
sam build --use-container --build-image public.ecr.aws/sam/build-python3.11:1
-
デプロイ
sam deploy
AWSコンソールでの作業
-
S3バケットにバケットポリシーを付与(初回デプロイ後のみ)
※ もしOACでS3にアクセス制限をかける場合、CloudFrontでコピーしたバケットポリシーをそのまま貼り付けると、実装の関係上リサイズ時にエラーが発生する。
{ "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/*" ] } ] }
S3に画像がない場合に404を返してほしいので、下記のようにAction
にs3:ListBucket
、Resource
にarn:aws:s3:::BUCKET_NAME
を追加。{ "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" } } }
-
Lambda関数のIAMロールにIAMポリシー付与(初回デプロイ後のみ)
※ LINE通知を行わないのであれば不要。
※ LINE通知を行う場合はパラメータストアから値を取得する権限、KMSで復号する権限が必要。(省略) -
Lambda関数のバージョン発行
-
$LATEST
やエイリアスではなく、Lambda 関数の番号付きバージョンを使用する必要がある - デプロイしたときは忘れずに実行する
- 方法
- リージョン「バージニア北部」にあるLambda関数「ResizeImage」を開く
- 「バージョン > 新しいバージョンの発行」の順でクリック
- 「バージョンの説明」を記入して「発行」をクリック
- 発効後に表示された画面の真ん中右あたりにある「関数のARN」をコピーしておく
-
-
CloudFrontへの関連付け
- 関連付けを行うCloudFrontディストリビューションを開く
- 「ビヘイビア」をクリック
- 関連付けを行うパターンを1つ選び「編集」をクリック
-
- 下のほうにある「関数の関連付け」で「オリジンレスポンス」の「関数ARN/名前」にある既存のARNを先ほどコピーしたARNに置き換える
- 「変更を保存」をクリック
最後に
- この記事は実際に実装したときのことを思い出しながら書いています。場合によっては不足があるかもしれません
- 不足している情報、間違い、改善案などありましたらコメント頂けますと幸いです