Amazon Web Services ブログ
Amazon CloudFront & Lambda@Edge で画像をリサイズする
多くの画像に対してリサイズを行ったり、新しいデザインレイアウトにウォーターマークを付与したり、ブラウザのサポートのためにフォーマットの最適化を行ったことはありませんか?
画像毎に事前処理を行う必要なく、必要に応じてその場ですぐに画像を自動生成できないかとおもったことはありませんか? Lambda@Edge はそれらを可能にし、ユーザーの利便性を向上させ、帯域使用量を削減します。
Lambda@Edge の準備:
AWS Lambda はサーバーのプロビジョニングや管理の必要なしにコードを実行できます。
そして利用量に応じて支払いを行います 。 つまりコードが実行されていないときは無料です! Lambda は自動スケーリングするとともに耐障害性を兼ね備えています。 Lambda@Edge はビューワー (※訳注: クライアント) に近い複数の AWS ロケーションでコードを実行することで、 Lambda の利点をエッジに拡張します。
Amazon CloudFront ディストリビューションのキャッシュ動作毎に、次の CloudFront イベントの 1 つ以上が発生した時に Lambda 関数を実行させる最大 4 つのトリガーを追加できます:
- CloudFront ビューワーリクエスト – CloudFront がビューワーからリクエストを受け取った後、リクエストされたオブジェクトがエッジキャッシュにあるかどうかを確認する前に関数が実行されます。
- CloudFront オリジンリクエスト – CloudFront がリクエストをオリジンに転送する場合にのみ関数が実行されます。リクエストされたオブジェクトがエッジキャッシュにある場合は実行されません。
- CloudFront オリジンレスポンス – CloudFront がオリジンからのレスポンスを受け取った後、レスポンス内のオブジェクトをキャッシュする前に関数が実行されます。
- CloudFront ビューワーレスポンス – リクエストされたオブジェクトがビューワーに返される前に関数が実行されます。オブジェクトがすでにエッジキャッシュに存在するかどうかに関係なく関数が実行されます。
これらのトリガーの詳細については、 開発者ガイド をご確認ください。
基本的なユースケースを示すために、次の 4 つを例にして説明します。
- クエリパラメータでサイズを指定して画像をその場でリサイズする。
- ビューワーに応じて、最適化した画像フォーマットを提供する。例えば、 Chrome/Android ブラウザーには WebP 、その他のブラウザーには JPEG を提供する。
- 画像サイズのホワイトリストを定義して、生成および提供を許可する。
- 要求された画像サイズ/フォーマットが存在しない場合のみ、リサイズ操作を実行する。
ビューワーはムンバイ (※訳注: インド) にいて、オリジンが ‘us-east-1’ (AWS の バージニア北部リージョン) にあると仮定しましょう。 URL の構造は https://static.mydomain.com/images/image.jpg?d=100×100 で、オリジナルの高解像度画像は ‘/images’ 以下に存在し、クエリパラメータ ‘d’ は要求した画像サイズ (幅x高さ) を指定します。
リサイズした画像をその場で提供するために、 Labmda@Edge をどのように使用するかを見てみましょう。
下の図は、画像生成ワークフローのアーキテクチャです。ビューワーは Lambda@Edge ビューワーリクエスト関数とオリジンレスポンス関数が関連付けられた CloudFront ディストリビューションを介して画像をリクエストします。これらの関数は適切なオリジン URL (キャッシュキーとして利用される) を生成し、画像生成に適切なロジックを実行します。最後に、新しく生成された画像が提供され、次のリクエストのためにキャッシュされます。
このために、以下の2つのコンポーネントを利用します:
- CloudFront ディストリビューションに関連付けられた 2 つの Lambda@Edge トリガー、すなわちビューワーリクエストとオリジンレスポンス。
- オリジンとして使用する Amazon S3。
ステップ 1 から 5 で何が起こるのかを確認しましょう。
- ステップ 1: リクエストされた画像 URI は、 Lambda@Edge のビューワーリクエスト関数で操作され、適切なサイズとフォーマットになります。これは、リクエストがキャッシュにヒットする前に発生します。下記のコードスニペット 1 を参照してください。
- ステップ 2: CloudFront はオリジンから画像オブジェクトを読み取ります。
- ステップ 3: 必要な画像がすでに S3 バケットに存在する場合、またはステップ 5 で生成され保存されていた場合、 CloudFront はビューワーに画像オブジェクトを返します。この時、画像がキャッシュされます。
- ステップ 4: キャッシュされた画像オブジェクトがビューワーに返されます。
- ステップ 5: リサイズ操作はオリジンに画像が存在しない場合にのみ実行されます。 S3 バケット (オリジン) にネットワーク呼び出しが行われ、オリジナル画像を取得しリサイズを行います。生成された画像は CloudFront に送信する前に S3 バケットに永続化されます。
注: ステップ 2,3 および 5 はキャッシュのオブジェクトが古い場合、もしくは存在しない場合のみ実行されます。画像のような静的リソースは、キャッシュヒット率を改善するために、できるだけ長い Time to Live (TTL) を持つ必要があります。
Lambda@Edge 関数を深掘りする:
-
Viewer-Request 関数
コードスニペット 1 – リクエスト URI を操作する
'use strict';
const querystring = require('querystring');
// defines the allowed dimensions, default dimensions and how much variance from allowed
// dimension is allowed.
const variables = {
allowedDimension : [ {w:100,h:100}, {w:200,h:200}, {w:300,h:300}, {w:400,h:400} ],
defaultDimension : {w:200,h:200},
variance: 20,
webpExtension: 'webp'
};
exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// parse the querystrings key-value pairs. In our case it would be d=100x100
const params = querystring.parse(request.querystring);
// fetch the uri of original image
let fwdUri = request.uri;
// if there is no dimension attribute, just pass the request
if(!params.d){
callback(null, request);
return;
}
// read the dimension parameter value = width x height and split it by 'x'
const dimensionMatch = params.d.split("x");
// set the width and height parameters
let width = dimensionMatch[1];
let height = dimensionMatch[2];
// parse the prefix, image name and extension from the uri.
// In our case /images/image.jpg
const match = fwdUri.match(/(.*)\/(.*)\.(.*)/);
let prefix = match[1];
let imageName = match[2];
let extension = match[3];
// define variable to be set to true if requested dimension is allowed.
let matchFound = false;
// calculate the acceptable variance. If image dimension is 105 and is within acceptable
// range, then in our case, the dimension would be corrected to 100.
let variancePercent = (variables.variance/100);
for (let dimension of variables.allowedDimension) {
let minWidth = dimension.w - (dimension.w * variancePercent);
let maxWidth = dimension.w + (dimension.w * variancePercent);
if(width >= minWidth && width <= maxWidth){
width = dimension.w;
height = dimension.h;
matchFound = true;
break;
}
}
// if no match is found from allowed dimension with variance then set to default
//dimensions.
if(!matchFound){
width = variables.defaultDimension.w;
height = variables.defaultDimension.h;
}
// read the accept header to determine if webP is supported.
let accept = headers['accept']?headers['accept'][0].value:"";
let url = [];
// build the new uri to be forwarded upstream
url.push(prefix);
url.push(width+"x"+height);
// check support for webp
if (accept.includes(variables.webpExtension)) {
url.push(variables.webpExtension);
}
else{
url.push(extension);
}
url.push(imageName+"."+extension);
fwdUri = url.join("/");
// final modified url is of format /images/200x200/webp/image.jpg
request.uri = fwdUri;
callback(null, request);
};
コードスニペット 1 の説明:
上記のコードでは、ビューワーの ‘Accept’ ヘッダーに基いて異なる画像フォーマットを提供するために、入力された URI を操作します。また、入力されたサイズをホワイトリストと照合し、最も近い許容サイズに変換します。そのため、標準でないサイズ (例: 105wx100h) のリクエストが来た場合でも、ホワイトリストのサイズ (100wx100h) を提供するすることができます。この仕組みにより、どの画像サイズが生成されキャッシュされるかをより詳細に制御でき、キャッシュヒット率を更に改善しレイテンシを低減することができます。さらに、悪意のあるユーザーが多くの不要な画像サイズを生成することを防ぐことができます。最終的に変更された URI は、これらがすべて反映されています。
例えば、入力された URI パターンが ‘pathPrefix/image-name?d=widthxheight’ の場合、変換された URI パターンは ‘pathPrefix/widthxheight/<requiredFormat>/image-name’ となります。 <requiredFormat> はリクエストの ‘Accept’ ヘッダーに基いて webp か jpg になります。
-
Origin-Response 関数
コードスニペット 2 – 画像オブジェクトが存在するかを確認し、必要に応じて画像リサイズを実行する
'use strict';
const http = require('http');
const https = require('https');
const querystring = require('querystring');
const AWS = require('aws-sdk');
const S3 = new AWS.S3({
signatureVersion: 'v4',
});
const Sharp = require('sharp');
// set the S3 endpoints
const BUCKET = 'image-resize-${AWS::AccountId}-us-east-1';
exports.handler = (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) {
let request = event.Records[0].cf.request;
let params = querystring.parse(request.querystring);
// if there is no dimension attribute, just pass the response
if (!params.d) {
callback(null, response);
return;
}
// read the dimension parameter value = width x height and split it by 'x'
let dimensionMatch = params.d.split("x");
// read the required path. Ex: uri /images/100x100/webp/image.jpg
let path = request.uri;
// read the S3 key from the path variable.
// Ex: path variable /images/100x100/webp/image.jpg
let key = path.substring(1);
// parse the prefix, width, height and image name
// Ex: key=images/200x200/webp/image.jpg
let prefix, originalKey, match, width, height, requiredFormat, imageName;
let startIndex;
try {
match = key.match(/(.*)\/(\d+)x(\d+)\/(.*)\/(.*)/);
prefix = match[1];
width = parseInt(match[2], 10);
height = parseInt(match[3], 10);
// correction for jpg required for 'Sharp'
requiredFormat = match[4] == "jpg" ? "jpeg" : match[4];
imageName = match[5];
originalKey = prefix + "/" + imageName;
}
catch (err) {
// no prefix exist for image..
console.log("no prefix present..");
match = key.match(/(\d+)x(\d+)\/(.*)\/(.*)/);
width = parseInt(match[1], 10);
height = parseInt(match[2], 10);
// correction for jpg required for 'Sharp'
requiredFormat = match[3] == "jpg" ? "jpeg" : match[3];
imageName = match[4];
originalKey = imageName;
}
// get the source image file
S3.getObject({ Bucket: BUCKET, Key: originalKey }).promise()
// perform the resize operation
.then(data => Sharp(data.Body)
.resize(width, height)
.toFormat(requiredFormat)
.toBuffer()
)
.then(buffer => {
// save the resized object to S3 bucket with appropriate object key.
S3.putObject({
Body: buffer,
Bucket: BUCKET,
ContentType: 'image/' + requiredFormat,
CacheControl: 'max-age=31536000',
Key: key,
StorageClass: 'STANDARD'
}).promise()
// even if there is exception in saving the object we send back the generated
// image back to viewer below
.catch(() => { console.log("Exception while writing resized image to bucket")});
// generate a binary response with resized image
response.status = 200;
response.body = buffer.toString('base64');
response.bodyEncoding = 'base64';
response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/' + requiredFormat }];
callback(null, response);
})
.catch( err => {
console.log("Exception while reading source image :%j",err);
});
} // end of if block checking response statusCode
else {
// allow the response to pass through
callback(null, response);
}
};
コードスニペット 2 の説明:
この関数は CloudFront がオリジンからレスポンスを受け取った後、キャッシュに保存する前に実行されます。この関数では、以下のステップの順に実行されます。
- オリジンレスポンスのステータスコードをチェックして、 Amazon S3 バケットに画像オブジェクトが存在するかを確認します。
- 画像オブジェクトが存在する場合は、そのまま CloudFront のレスポンスサイクルを続行します。
- 画像オブジェクトが S3 バケットに存在しない場合は、オリジナル画像を取得してリサイズ操作を行いバッファに入力、リサイズ画像に正しいプレフィックスとメタデータを付与して S3 バケットに永続化します。
- 画像のリサイズが行われた場合は、メモリ内のリサイズ画像を元にバイナリレスポンスを生成し、適切なステータスコードとヘッダーを返します。
注: S3 バケットと Lambda 関数を実行する Edge ロケーションが異なるリージョンの場合、Amazon S3 から Lambda 関数へのリージョン間データ送信(アウト)料金が発生しますのでご注意ください。これらの費用は画像生成毎に1回発生します。
Amazon CloudFront ディストリビューション内でこれらのイベントトリガーを設定する方法については、こちらの ブログ を参照してください。
生成された画像は S3 バケットに保存され、以下のようにコンソールに表示されます。
コンテンツ管理システムから適切な URL を呼び出すことで、必要な画像フォーマットとサイズを事前に生成することも可能です。さらに、適切なウォーターマークを付与するようにコードを拡張することもできます。
設定ステップを一つずつ説明します:
Step 1: デプロイパッケージの作成
画像リサイズ関数は ‘libvips’ ネイティブ拡張を必要とする ‘Sharp’ モジュールを使用します。 Lambda 関数のコードは Lambda 実行環境で動作するように依存関係を含んだ状態でビルドおよびパッケージ化されている必要があります。これらを実現する方法のひとつが、あなたの環境に ‘docker’ をインストールしてから、 Docker コンテナを使ってパッケージをローカルにビルドすることです。
プロジェクトのディレクトリ構造は、以下を想定しています。
– <your-project>
— dist/
— lambda/viewer-request-function
— lambda/origin-response-function
— Dockerfile
コードスニペット (1 および 2) はそれぞれのディレクトリ内に配置します。 デプロイパッケージを作成する方法については、 デプロイパッケージの作成 (Node.js) を参照してください。
Dockerfile:
プロジェクトのルートディレクトリにて、次のコードを実行します:
- Dockerfile は Amazon Linux をダウンロードし、 Node.js 6.10 と依存関係をインストールするように設定されています。
docker build --tag amazonlinux:nodejs .
また、 Amazon Linux AMI を使用して t2.micro インスタンスをセットアップし、依存関係をインストールすることもできます。
- 上記のコードスニペット 2 で使用している BUCKET 変数 の AWS アカウント ID を更新します。 AWS CloudFormation テンプレートは、 ‘image-resize-${AWS::AccountId}-us-east-1’ のパターンで S3 バケットを作成します。例えば、あなたの AWS アカウント ID が 123456789012 の場合、 BUCKET 変数は ‘image-resize-123456789012-us-east-1’ に更新します。既に同名の S3 バケットが存在する場合はこの変数を適切な値に変更し、 CloudFront ディストリビューションのオリジン設定を同じものにしてください。
- ‘sharp’ および ‘querystring’ モジュールの依存関係をインストールし、 ‘Origin-Response’ 関数をコンパイルします。
docker run --rm --volume ${PWD}/lambda/origin-response-function:/build amazonlinux:nodejs /bin/bash -c "source ~/.bashrc; npm init -f -y; npm install sharp --save; npm install querystring --save; npm install --only=prod"
- ‘querystring’ モジュールの依存関係をインストールし、 ‘Viewer-Request’ 関数をコンパイルします。
docker run --rm --volume ${PWD}/lambda/viewer-request-function:/build amazonlinux:nodejs /bin/bash -c "source ~/.bashrc; npm init -f -y; npm install querystring --save; npm install --only=prod"
- ‘Origin-Response’ 関数をパッケージングします。
mkdir -p dist && cd lambda/origin-response-function && zip -FS -q -r ../../dist/origin-response-function.zip * && cd ../..
- ‘Viewer-Request’ 関数をパッケージングします。
mkdir -p dist && cd lambda/viewer-request-function && zip -FS -q -r ../../dist/viewer-request-function.zip * && cd ../..
- AWS コンソールから、デプロイするファイルを格納する S3 バケットを us-east-1 リージョンに作成し、上記のステップで作成した zip ファイルをアップロードします。これらのファイルはデプロイ時に CloudFormation テンプレートから参照されます。
Step 2: Lambda@Edge 関数をデプロイする
AWS コンソールを us-east-1 リージョンに切り替えて、 下記の CloudFormation テンプレートを使用して Lambda@Edge 関数をデプロイします。テンプレート内の <code-bucket> をデプロイするファイルを格納した S3 バケット名に更新します。
CloudFormation テンプレート:
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Resources:
ImageBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
Properties:
AccessControl: PublicRead
BucketName: !Sub image-resize-${AWS::AccountId}-${AWS::Region}
ImageBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref ImageBucket
PolicyDocument:
Statement:
- Action:
- s3:GetObject
Effect: Allow
Principal: "*"
Resource: !Sub arn:aws:s3:::${ImageBucket}/*
- Action:
- s3:PutObject
Effect: Allow
Principal:
AWS: !GetAtt EdgeLambdaRole.Arn
Resource: !Sub arn:aws:s3:::${ImageBucket}/*
- Action:
- s3:GetObject
Effect: Allow
Principal:
AWS: !GetAtt EdgeLambdaRole.Arn
Resource: !Sub arn:aws:s3:::${ImageBucket}/*
EdgeLambdaRole:
Type: "AWS::IAM::Role"
Properties:
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"
ViewerRequestFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: s3://<code-bucket>/viewer-request-function.zip
Handler: index.handler
Runtime: nodejs6.10
MemorySize: 128
Timeout: 1
Role: !GetAtt EdgeLambdaRole.Arn
ViewerRequestFunctionVersion:
Type: "AWS::Lambda::Version"
Properties:
FunctionName: !Ref ViewerRequestFunction
Description: "A version of ViewerRequestFunction"
OriginResponseFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: s3://<code-bucket>/origin-response-function.zip
Handler: index.handler
Runtime: nodejs6.10
MemorySize: 512
Timeout: 5
Role: !GetAtt EdgeLambdaRole.Arn
OriginResponseFunctionVersion:
Type: "AWS::Lambda::Version"
Properties:
FunctionName: !Ref OriginResponseFunction
Description: "A version of OriginResponseFunction"
MyDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Origins:
- DomainName: !Sub ${ImageBucket}.s3.amazonaws.com
Id: myS3Origin
S3OriginConfig: {}
Enabled: 'true'
Comment: distribution for content delivery
DefaultRootObject: index.html
DefaultCacheBehavior:
TargetOriginId: myS3Origin
LambdaFunctionAssociations:
- EventType: 'viewer-request'
LambdaFunctionARN: !Ref ViewerRequestFunctionVersion
- EventType: 'origin-response'
LambdaFunctionARN: !Ref OriginResponseFunctionVersion
ForwardedValues:
QueryString: 'true'
QueryStringCacheKeys:
- d
Cookies:
Forward: 'none'
ViewerProtocolPolicy: allow-all
MinTTL: '100'
SmoothStreaming: 'false'
Compress: 'true'
PriceClass: PriceClass_All
ViewerCertificate:
CloudFrontDefaultCertificate: 'true'
Outputs:
ImageBucket:
Value: !Ref ImageBucket
Export:
Name: !Sub "${AWS::StackName}-ImageBucket"
MyDistribution:
Value: !Ref MyDistribution
Export:
Name: !Sub "${AWS::StackName}-MyDistribution"
この CloudFormation テンプレートは以下を作成します:
- ビューワーリクエスト Lambda 関数
- オリジンレスポンス Lambda 関数
- 両方の関数に必要な実行ロールを作成し関連付け
- 命名規則 ‘image-resize-${AWS::AccountId}-${AWS::Region}’ を持つ S3 バケットを作成し、下記で説明するバケットポリシーを適用
{ "Version": "2008-10-17", "Statement": [ { "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::image-resize-ACCOUNTNUMBER-us-east-1/*" }, { "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam:: ACCOUNTNUMBER:role/service-role/ROLENAME" }, "Action": "s3:PutObject", "Resource": "arn:aws:s3:::image-resize-ACCOUNTNUMBER-us-east-1/*" }, { "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::ACCOUNTNUMBER:role/service-role/ROLENAME" }, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::image-resize-ACCOUNTNUMBER-us-east-1/*" } ] }
このポリシーは以下を許可します:
- CloudFront がオブジェクトを取得する。
- Lambda 関数がオリジナル画像を読み取り、 S3 バケットにリサイズ画像を書き込む。
- CloudFront ディストリビューション
- S3 オリジンを持ち、特定のクエリパラメータを許可する。 ( このケースでは画像サイズを指定する ‘d’)
- デフォルト動作のオリジンレスポンスとビューワーリクエストのトリガーとして Lambda@Edge 関数 のバージョン番号を関連付けする。
ソリューションのテスト:
- 作成したオリジン S3 バケットに高解像度の画像ファイル (image.jpg とします) を ‘images’ ディレクトリにアップロードします。
- お気に入りのブラウザで以下の URL を開きます。
https://{cloudfront-domain}/images/image.jpg?d=100x100
URLの説明
- cloudfront-domain – 上記の CloudFormation テンプレートを使用して作成された CloudFront ディストリビューションのドメイン名 (※訳注: CloudFormation テンプレートから作成したスタックの出力: MyDistribution キーの値に ディストリビューション ID が表示されています。 CloudFront コンソールからディストリビューション ID を元にドメイン名を確認してください。)
- 100×100 – 画像サイズの幅と高さ、クエリパラメータの ‘d’ の値を 200×200 や 300×300 に変更することでサイズを変えられます。
指定したサイズにリサイズされた画像が表示されます。
トラブルシューティング:
トリガーを作成すると、 Lambda 関数がトリガーされるたびに Lambda は自動的に Amazon CloudWatch Logs にログを送信し始めます。 Node.js の console.log() 関数を使用してデバッグログを追加し、問題のトレースとトラブルシューティングを行うこともできます。 Lambda 関数のロギングの追加については、 Lambda 開発者ガイドの ログ作成 (Node.js) をご確認ください。
Lambda は関数が実行される場所に最も近い AWS リージョンに CloudWatch Logs のログストリームを作成します。各ログストリームの命名規則は ‘/aws/lambda/us-east-1.function-name’ です。 function-name は CloudFormation テンプレートをデプロイした時に作成された関数の論理名となります。
さらに、 CloudWatch メトリックスにて、関数の呼び出し, 所要時間, エラー, スロットリングの詳細に関連する全てのメトリックスを確認することができます。 CloudWatch Logs およびメトリックスの詳細については、 開発者ガイド をご確認ください。
まとめ:
その場ですぐに画像をリサイズするために、Lambda@Edge を使用する主な利点は以下の2つです:
- 画像を前処理する必要が無くなるため、ストレージ, 帯域, 計算コストを削減できます。これは、 A/B テストを行ったり、新しいデザインレイアウトにすばやく移行したい場合に便利です。
- ホスト型のサーバーからサーバーレスアーキテクチャに画像リサイズ処理をオフロードすることで、インフラストラクチャをよりシンプルにすることができます。
Amazon CloudFront や AWS Lambda@Edge を初めてお使いの場合は、サービスを開始する方法の詳細について Amazon CloudFront の開始方法 や AWS Lambda@Edge 開発者ガイド を参照することをおすすめします。
原文: Resizing Images with Amazon CloudFront & Lambda@Edge | AWS CDN Blog
(翻訳: SA 藤原)