Cloudfront+Lambda@edgeを利用してサーバーレスに画像をリサイズ・変換して返却するCDNを構築したお話
By: Masashi Yamamoto
Tags: Serverless, AWS Lambda, Cdn, Aws Cloudfront, S3

この記事は「Eureka Advent Calendar 2020」13日目の記事です。

こんにちは、2匹の猫様に尽くす傍、エンジニアを営んでいるSREチーム エンジニアの@marnieです。

だいぶ肌寒くなってきましたが、幸い我が家には長毛種の猫がいるので、心も体も温まります。長毛種の猫素晴らしい。はぁすばら。

さて。このまま猫自慢だけで押し切りたい所ですが。

直近は半分趣味・半分業務的な機会もあり、CloudFrontとLambda@Edge周りを触っていましたので、CloudFrontとLambda@Edgeを利用して画像を動的に変換するCDNの構築や実運用のTrafficに乗せてみての所感などについて、つらつらと書いていけたらと思います。

CloudFront+Lambda@Edge?

Lambda@EdgeはCloudFrontの機能の一つで、世界各地に散らばるCloudFrontのEdgeから近いRegionを自動的に選択して、CloudFrontの受信したリクエストに対して、連携してAWS Lambdaが動作させてくれる、複数のリージョンに自動でLambdaを展開してくれる機能になっています。

CloudFrontはs3をOriginに設定する事が可能ですので、今回はこのLambda@Edgeとs3を利用して、サーバーレスに要求された画像リソースのサイズを動的に変換して返す仕組みを作ってみたいと思います。

全体像

ばっくりと全体像を図で示すと以下の通りになります。

猫でもわかる処理のながれ

見たまんまですが、ばっくりとした流れは以下の３行です。

エッジにキャッシュがあればキャッシュを返す

キャッシュがなければ、s3に対するOriginRequestの前に挟み込まれたLambdaが呼ばれる

Lambdaはs3から画像を要求し、変換処理を行い、CloudFrontにレスポンスを返す

少しだけ補足しますと、Lambda@Edgeは以下の４種の起動タイミングを選択して、CloudFrontが受信したリクエストに対して任意の処理を挟む事ができます。

ViewerRequest

Cacheの有無に関わらずCloudFrontがリクエストを受信した時点で起動

OriginRequest

Cacheが存在せず、Originへの問い合わせが発生する時点で起動

ViewerResponse

Cacheの有無に関わらずCloouFrontがレスポンスを返す直前で起動

OriginRersponse

Cacheが存在せず、Originへの問い合わせが完了し、Cacheを保存する前に起動

毎回画像変換をしたいわけではないので、OriginRequestを使っています。OriginResponse時の起動タイミングを選択しても、同じような機能を満たす仕組みは構築可能ですが、筆者はOriginResponseでの起動タイミングではs3から返ってくるResponseBodyに触る事ができず、もう一度s3Getし直すのもなぁ、、と思い、今回はOriginRequestを選択しました。

インフラの定義とServerLessFramework

作成したサンプル では、デプロイの簡略化・インフラ・プログラムをコードで管理できるServerLessFrameworkを使っています。

ServerLessFrameworkは、インフラ・Lambdaのデプロイ/構築(内部的には CloudFormation Stack作成・更新)ができるIaCツールです。

serverless.ymlに宣言を追加し、デプロイコマンドを打つだけでデプロイできてとても便利です。

serverless.ymlで宣言した主要な部分のみ説明していきます。

L15~22

lambda関数についての宣言です。distributionで対象を指定し、origin-requestで動作する事を宣言しています。

functions:

execute:

handler: handler.execute

timeout: 6 #default

memorySize: 1024

lambdaAtEdge:

distribution: "TargetDistribution"

eventType: "origin-request"

L23~92

Lambdaが参照するDistrubitionとIAMについて宣言しています。

Lambda関数の使用するRoleについて記載

対象のBucketの指定

Lambda@Edgeと紐づけるCloudFrontDistibutionの作成

などを以下で行なっています。

エラーレスポンスやTTLなどはユースケースに応じると思いますので、お好みで良いと思いますが、アジア圏アクセスがメインの場合、PriceClassは200を選択する事を推奨します。(100はアジア圏にEdgeLocationがない)



Resources:

LambdaEdgeRole:

Type: AWS::IAM::Role

Properties:

AssumeRolePolicyDocument:

Statement:

- Effect: Allow

Principal:

Service:

- lambda.amazonaws.com

- edgelambda.amazonaws.com

Action:

- sts:AssumeRole

Policies:

- PolicyName: "IconImageResizeLambda"

PolicyDocument:

Version: "2012-10-17"

Statement:

- Effect: "Allow"

Action:

- "s3:GetObject"

Resource:

- arn:aws:s3:::${self:custom.environment.${self:provider.stage}.s3.bucket_name}/icon/* #NOTE:

ManagedPolicyArns:

- "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"

TargetDistribution:

Type: "AWS::CloudFront::Distribution"

Properties:

DistributionConfig:

Aliases:

- ${self:custom.environment.${self:provider.stage}.cloudfront.aliases} #NOTE:

DefaultCacheBehavior:

TargetOriginId: "ImageBucketOrigin"

ViewerProtocolPolicy: "redirect-to-https"

DefaultTTL: 31536000 # お好みで

MaxTTL: 31536000 # お好みで

Compress: true

ForwardedValues:

QueryString: true

Cookies:

Forward: "none"

Enabled: true

PriceClass: "PriceClass_100" # アジア圏からのアクセスがメインの場合PriceClass_200を推奨します

HttpVersion: "http2"

ViewerCertificate:

SslSupportMethod: "sni-only"

AcmCertificateArn: "" ${self:custom.environment.${self:provider.stage}.cloudfront.acm} # NOTE:

Origins:

- DomainName: ${self:custom.environment.${self:provider.stage}.s3.bucket_name}.s3.amazonaws.com #NOTE:

Id: ImageBucketOrigin

S3OriginConfig:

OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}

CustomErrorResponses: # NegativeCacheのTTL指定。お好みで。

- ErrorCode: 400

ErrorCachingMinTTL: 0

- ErrorCode: 403

ErrorCachingMinTTL: 0

- ErrorCode: 404

ErrorCachingMinTTL: 0

- ErrorCode: 405

ErrorCachingMinTTL: 0

- ErrorCode: 500

ErrorCachingMinTTL: 0

- ErrorCode: 502

ErrorCachingMinTTL: 0

- ErrorCode: 503

ErrorCachingMinTTL: 0

- ErrorCode: 504

ErrorCachingMinTTL: 0 resources:Resources:LambdaEdgeRole:Type: AWS::IAM::RoleProperties:AssumeRolePolicyDocument:Statement:- Effect: AllowPrincipal:Service:- lambda.amazonaws.com- edgelambda.amazonaws.comAction:- sts:AssumeRolePolicies:- PolicyName: "IconImageResizeLambda"PolicyDocument:Version: "2012-10-17"Statement:- Effect: "Allow"Action:- "s3:GetObject"Resource:- arn:aws:s3:::${self:custom.environment.${self:provider.stage}.s3.bucket_name}/icon/* #NOTE: @see env_sample.ymlManagedPolicyArns:- "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"TargetDistribution:Type: "AWS::CloudFront::Distribution"Properties:DistributionConfig:Aliases:- ${self:custom.environment.${self:provider.stage}.cloudfront.aliases} #NOTE: @see env_sample.ymlDefaultCacheBehavior:TargetOriginId: "ImageBucketOrigin"ViewerProtocolPolicy: "redirect-to-https"DefaultTTL: 31536000 # お好みでMaxTTL: 31536000 # お好みでCompress: trueForwardedValues:QueryString: trueCookies:Forward: "none"Enabled: truePriceClass: "PriceClass_100" # アジア圏からのアクセスがメインの場合PriceClass_200を推奨しますHttpVersion: "http2"ViewerCertificate:SslSupportMethod: "sni-only"AcmCertificateArn: "" ${self:custom.environment.${self:provider.stage}.cloudfront.acm} # NOTE: @see env_sample.ymlOrigins:- DomainName: ${self:custom.environment.${self:provider.stage}.s3.bucket_name}.s3.amazonaws.com #NOTE: @see env_sample.ymlId: ImageBucketOriginS3OriginConfig:OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}CustomErrorResponses: # NegativeCacheのTTL指定。お好みで。- ErrorCode: 400ErrorCachingMinTTL: 0- ErrorCode: 403ErrorCachingMinTTL: 0- ErrorCode: 404ErrorCachingMinTTL: 0- ErrorCode: 405ErrorCachingMinTTL: 0- ErrorCode: 500ErrorCachingMinTTL: 0- ErrorCode: 502ErrorCachingMinTTL: 0- ErrorCode: 503ErrorCachingMinTTL: 0- ErrorCode: 504ErrorCachingMinTTL: 0

ServerLessFrameworkは設定ファイルをうまく使う事でよしなに複数環境に横展開する、なども出来て中々便利ですので、是非試してみてください。

画像変換処理のサンプルと処理の流れ

肝心の画像の変換処理自体はsharpを利用してJavaScriptで実装します。

コードのサンプルを交えて大まかな流れを紹介していければと思います。

コードのサンプルはこちらです。

今回のサンプルはクエリパラメーター内のwidth,heightで指定されたサイズに変換する処理となっています。

サンプルではvalidationなど書いてますが、この辺はお好みで。

大事なのは event.Records[0].requstに大体必要な情報があるという事だけです。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html

const request = event.Records[0].cf.request;

// s3のパスから最初の/を取り除く

const path = decodeURIComponent(request.uri).substr(1);

const query = querystring.parse(request.querystring);

// validate

const validationResult = validation.validate(request, query);

if (!validationResult.success) {

return context.succeed(responseFactory.badRequest());

}

//parseQuery

let convertOptions = convertOptionFactory.createByQueryString(query);

//s3Target

const s3Params = {

Bucket: request.origin.s3.domainName.split(".")[0],

Key: path,

}; //parseEvent @see const request = event.Records[0].cf.request;// s3のパスから最初の/を取り除くconst path = decodeURIComponent(request.uri).substr(1);const query = querystring.parse(request.querystring);// validateconst validationResult = validation.validate(request, query);if (!validationResult.success) {return context.succeed(responseFactory.badRequest());//parseQuerylet convertOptions = convertOptionFactory.createByQueryString(query);//s3Targetconst s3Params = {Bucket: request.origin.s3.domainName.split(".")[0],Key: path,};

2. s3から画像を取得し、bodyとmetadataを変数に格納します。

let sharpBody;

s3.getObject(s3Params)

.promise()

.then((result) => {

sharpBody = sharp(result.Body);

return sharpBody.metadata();

})

後述する画像の引き伸ばし判定やフォーマット指定などで使うために

一度metadataを保管しています。

3. 変換処理

受け取ったmetadataとパラメータを比較して、引き伸ばさない判定を入れています。（ここはお好みで。)

変換処理の記述は、sharp.resize(…).rotate().toBuffer() のみです。

4. 変換処理の結果とmetadataのformatを組み合わせてレスポンスを作成し、返却する。

あとは、受け取った変換結果のバッファをbase64にして、formatをmetadataに合わせたら終了です。

.then((convertResult) => {

let body = convertResult.toString("base64");

const response = {

status: "200",

statusDescription: "OK",

headers: {

"content-type": [

{

key: "Content-Type",

value: `image/${convertOptions.format}`,

},

],

},

body: body,

bodyEncoding: "base64",

};

context.succeed(response);

})

5. (おまけ)エラーハンドリング

この辺はお好みで。若干ファイルがない時のエラーがわかりづらかったので、少しだけ変えてます。



switch (err.code) {

case "AccessDenied":

// s3:ListBucketがLambdaに権限としてない場合 s3GetObjectは403を返すので、404として扱う。(お好みで)

// https://aws.amazon.com/jp/premiumsupport/knowledge-center/s3-website-cloudfront-error-403/

return context.succeed(responseFactory.notFound());

default:

// それ以外は特にハンドリングしたいことがないので500+logging

console.log(err);

return context.succeed(responseFactory.internalError());

}

}); .catch((err) => {switch (err.code) {case "AccessDenied":// s3:ListBucketがLambdaに権限としてない場合 s3GetObjectは403を返すので、404として扱う。(お好みで)// @see return context.succeed(responseFactory.notFound());default:// それ以外は特にハンドリングしたいことがないので500+loggingconsole.log(err);return context.succeed(responseFactory.internalError());});

細かいところの記載を省くなどすれば、３０行程度でおそらく完結するようにかけます。簡単…

一点諸注意として,sharpはlinux-x64のものをインストールする必要があります。MacOS上でビルドした場合、Lambdaの実行環境(amazonLinux)と食い合わせが悪くs以下のようなエラーが出てしまいます。

'darwin-x64' binaries cannot be used on the 'linux-x64' platform. Please remove the 'node_modules/sharp')

こういう時もServerLessFrameworkでpluginでhookを記載すると、デプロイの一連のフローの中にhookとして入れ込むことができて便利です。

今回はresizeとrotate、meadataくらいしか紹介していませんが、

sharpはwebpなどへの変換やjpegのquality変更、公開するべきではないmetadataの削除なども行えます。

より高機能にするならば、SaaSの類似サービスで見られるような、UAを評価した適切なフォーマット選択なども面白いと思います。

実運用を考える際に考慮すべき制約（仕様)について

これだけの事がサーバーレスにやれるなんて最高！！という所なのですが、何個かの注意をしなければならない制約について記載します

Lambdaの同時実行数制限・バースト実行制限について

Lambdaを利用する以上、Lambdaの制約を受けますので、Lambdaのスケールアウトについては、事前に理解した上でTrafficとCacheのTTLなどを考慮して、事前のTraffic推測ないし、パーセントリリースによる同時実行数の引き上げの申請などをしておいた方が良いです。

また、同時実行数制限は引き上げ可能ですが、バースト実行を超えてからは分間のスケール限界が決まっていますので、Trafficによっては暖気運転をするのか？などの考慮が必要となります。

2. Lambda@Edgeのレスポンス容量制限について

Lambda@Edgeの制約として、レスポンスの最大容量が1MBという上限があります。(公式)

1MBを超えた場合、HTTP 502 エラー (Lambda Validation Error)が返りますので、もし最終的に返却する画像サイズが1MBを超える場合は

Lambda上で容量を評価して、qulaityを下げる, formatを変更する

どうしてもダメな場合はs3 pathを返す

などのワークアラウンドの検討も必要となります。

モニタリングについて

アラート対象にするような重要なメトリクスは以下かなと考えています。注意を払うのが望ましいと考えています。

CloudFrontの成功レスポンス率 (最重要)

CloudFront 4xx,5xxエラーレート

同時実行数(Lambdaの同時実行数が上限に達していないか?)

LambdaValidationError (502 Error)

Lambdaのエラー数

仕組み全体としてのヘルスチェックとしての意味合いとしてはCloudFrontの成功率が見れれば十分だとは思います。

4xxは意図しないクライアントの問い合わせ増加、攻撃などの検知にも役に立ちますし、LambdaValidationError,Errorは、プログラミングエラー、容量オーバーをしているレスポンスの検知などにも有効なので、独立して監視して良い項目かなと考えています。

同時実行数は予約しない場合リージョンで共有になるので、他の関数に影響を出す恐れもありますので、注視しておく項目です。予約した場合についてもスロットリングが起きている＝trafficに実行数が間に合っていない事が検知できます。

次に費用面やパフォーマンス面などのFYI的な情報については以下のような項目が望ましいと考えています。

cacheHitRate

OriginLatency (場合によってはアラート対象)

LambdaのdurationとLambdaの同時実行数

まとめ

制約はありつつも、そこまでの時間や工数を要さずにこういった 仕組みを作れる事は素晴らしいですね。

sharpは今回、画像のリサイズを中心にお伝えしましたが、フォーマットの変換、クオリティの変換など、様々な機能を持っています。

この手の機能を有したSaaSも勿論ありますので、今回記載した制約面を気にする場合などはSaaSを選ぶ方が良いと考えています。

ただし、既にCloudFrontを利用していて、s3をOriginにしたい場合や、制約面にそこまで不安を覚えない場合は、こちらの構成もさほど手間もかけず実装・実現できますし、画像変換以外にも色々な用途があると思いますので、ぜひ一度、Lambda@Edgeで遊んでみてください。

さて、明日は同じくSREチームのfukubaka0825さんの記事になります。

それではみなさま、良いお年を。