概要
下記URLのAWS公式ブログにあるような、AWSのCloudFrontとLambda@Edgeを使った画像ファイル自動リサイズ構成を一括で構築するSAM(ServerlessApplicationModel)のテンプレートを作成してみました。
Amazon Web Services ブログ: Amazon CloudFront & Lambda@Edge で画像をリサイズする
CloudFrontのViewerRequestとOriginResponseの通信部分(上の図の①と③部分)に関数の処理を挟み、クエリパラメータで指定のサイズに元画像をリサイズした画像を自動的に作成してレスポンスで返却します。
画像のリサイズ処理にはsharpというNode.jsのライブラリを利用しているので、LambdaのランタイムもNodejs14.xとなっています。
参考: CloudFront開発者ガイド: 関数を使用してエッジでカスタマイズ
元記事からのカスタマイズ内容
実現したいことや実現方式は元記事(前述のAWSブログ記事)とほぼ変わりません。
元記事をベースに主に下記のような私的カスタマイズをしています。
- SAMのテンプレートでサクッと構築可能にしている(★今回一番やりたかったこと)
- ViewerRequestの処理をLambda@EdgeではなくCloudFront Functionsで行っている
- Nodejsのランタイムは執筆時点でサポートされている最新バージョンのNodejs14.xを使用
- ViewerRequestとOriginResponseの関数を極力シンプルになるようにカスタマイズ(※)
※ 例として元ネタ記事ではリサイズ画像のサイズを"d=100×100"など縦と横の両方を指定する実装でしたが、私版では"w=100"などと横幅の指定のみにしてみました。
関数の実装内容では他にもいろいろ細かい違いがあります(詳細は割愛します)。
元ネタ記事に掲載のプログラムコードからの変更内容は私的なカスタマイズであり改善ではありません(私はシンプルにすることを主眼において実装したので、元ネタ記事の実装の方が優れている部分もあるかと思います)。
また、動作確認はしていますが長時間をかけてテストしたワケではなく何かしら要改善点はあるかもしれません。当記事掲載のプログラムコードについてはご参考までにどうぞ。
SAMテンプレート
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より一式をダウンロードしてsamコマンドでbuildとdeployを行うことでリソースを構築できます。
(手順は後述します)
このテンプレートでのデプロイが成功するとCloudFormationスタックで下記のリソースが作成されます。
- S3バケット(CloudFrontのオリジン)とバケットポリシー
- CloudFrontディストリビューション
- CloudFrontOriginAccessIdentity(CloudFrontからS3バケットへのアクセスを許可させるためのリソース)
- CloudFront Function
- Lambda関数
- IAMロール(Lambda関数用)
- SSMパラメータストアのパラメータ(Lambda関数からの参照用)
ViewerRequest関数のポイント解説
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します。
{
"request": {
"headers": {},
"method": "GET",
"querystring": {
"w": {
"value": "100"
}
},
"uri": "/images/sample.png",
"cookies": {}
}
}
OriginResponse関数のポイント解説
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の環境を利用すると必要なものが揃っているので便利です。
リソースの取得(GitHubよりclone)
GitHubからリソース一式を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
(備考1)
"--use-container"パラメータの指定によりビルドのためのコンテナイメージがダウンロードされます。
"--use-container"パラメータを指定すると、Cloud9環境のデフォルトのEBSストレージサイズ(10G)だと容量が足りなくてエラーになります。その場合は下記URLの情報を参考に20Gなどに拡張します。
Cloud9ユーザーガイド:環境で使用されている Amazon EBS ボリュームのサイズ変更
(備考2)
画像処理ライブラリである"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に関数がアタッチされていることを確認できると思います。
動作確認の方法
- 作成されたS3バケットの"images/"フォルダの下に任意のpngあるいはjpegの拡張子で画像をアップロードして、WebブラウザでCloudFrontの公開ドメインのURLから画像が参照可能であることを確認します。
- 次に、同じ画像のURLの末尾に"?w=100"などと任意のサイズを指定したパラメータをつけて参照し、指定サイズにリサイズされた画像が参照可能であることを確認します。
- 成功した場合には、S3バケット上に"images/w100/"のようにクエリパラメータに指定した値のサブフォルダが作成されており、その下にリサイズ画像が格納されています。
- リサイズ画像作成処理はリサイズ画像が存在しない場合のみ行われ、一度作成したものはS3バケットに永続化されてエッジロケーションにもキャッシュされます。
補足・注意点など
指定可能なパラメータについて
"samconfig.toml"ファイルの"parameter_overrides"でパラメータを指定できます。
指定可能なパラメータの詳細は"template.yaml"の"Parameters"セクションを参照下さい。
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"
初回構築後、スタックをアップデートする際には「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年)としています。
Parameters:
省略:
CacheControlValue:
Type: String
Default: 'max-age=31536000'
Description: 'The cache-control value to set in the resized image S3Object Metadata'
パラメータで指定した設定値はSSM(SystemsManager)パラメータストアに'Sample-OriginResponseFunction-Params'というキーでjson文字列で保存されており、OriginResponseのLambda@Edge関数から参照しています。
Lambda@Edgeでパラメータストアを使う場合はregion指定が必要
今回はパラメータストアもLambdaもバージニア北部(us-east-1)に作成していますが、EdgeのLambdaが実際に実行されるのはエッジロケーションになります。
明示的にリージョンを指定しないとアクセス元が東京である場合は東京リージョンのパラメータストアを参照しに行ってしまいます。
const ssm = new AWS.SSM({region: 'us-east-1'});
下記ClassMethodさん技術ブログを参考にさせて頂きました。
参考: Lambda@EdgeでAWS Systems Managerのパラメータストアを使う
CloudFront Functionではconstやletが使えない
最初は普通に使えると思い込んでコーディングした後、動作確認時に使えないことに気づいて"var"で修正しました。
参考: CloudFront Functions の JavaScript runtime 機能
Lambda@Edge関数のバージョン指定について
現状ではLambda@Edgeはバージョンまで指定してLambdaのARNを設定する必要があり、"$LATEST"などのAliasを利用することができません。
初回のデプロイの後にLambda@Edge関数のコードを更新し、Edgeにも修正を反映させたい場合は新しいバージョンを発行してCloudFrontディストリビューションのOriginResponseの設定にも反映させる必要があります。
SAMテンプレートのLambdaバージョン発行部分(初回用バージョン)
※現在CloudFrontから参照されているバージョンは消せないので、直近の定義は消さずに新しい定義を追加する必要があります。
OriginResponseFunctionVersion1:
Type: AWS::Lambda::Version
Properties:
FunctionName: !Ref OriginResponseFunction
Description: 'init version'
新しいVersionを追加した場合は、参照している部分も修正する。
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の下記の部分が該当します。
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}
CloudFormationスタックの削除に失敗する場合がある
当記事のSAMテンプレートで各種リソース構築後、不要になったら作成に使われたCloudFormationスタックを削除することで各種リソース(S3バケット以外)は削除されます。
しかし、CloudFrontのOriginResponseに関連付いているLambda関数はおそらく削除に失敗します。
この場合、OriginResponseのLambda関数のリソースのみスキップしてスタック削除を継続し、Lambda関数のみ時間をおいてから個別に削除してください。
最後に
元ネタのAWS技術ブログの記事は、書かれたのが結構古い(2018)のもあり、今となっては記事の内容の通りにやってみようとしてもなかなか大変だったりします。
テンプレートからサクッと仕組みを構築できれば便利かもしれません。
当記事の内容がなにかの参考になれば幸いです。