この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
はじめに
おはようございます、加藤です。今回は、AWS公式ブログを参考にLambda@Edgeを使ってクエリパラメータによって自動で画像をリサイズする仕組みを作ってみます。
一度リサイズされた画像は保存され、次回以降のリクエストでは保存した画像を読み込みます。
これによって、PC、タブレット、スマートフォンなど必要なサイズが異なるデバイスに対して適切なサイズで画像を提供する事が可能になります。
前提
下記の環境で実行しました。Dockerがインストールされていない場合はhomebrew-cask
などでDockerをインストールしてください。
- Mac OS X
- 10.14.2
- Docker
- 18.09.0
$ sw_vers
ProductName: Mac OS X
ProductVersion: 10.14.2
BuildVersion: 18C54
$ docker --version
Docker version 18.09.0, build 4d60db4
やってみた
下記のブログを参考に進めていきます。実現したいことは完全に一致しているので、最初に読んでください。
Amazon CloudFront & Lambda@Edge で画像をリサイズする | Amazon Web Services ブログ
一部省略していますが、最終的に下記のようなディレクトリ構成になります。
.
├── Dockerfile
├── dist
│ ├── origin-response-function.zip
│ └── viewer-request-function.zip
├── lambda
│ ├── origin-response-function
│ │ └── index.js
│ └── viewer-request-function
│ └── index.js
└── template.yaml
ファイルとディレクトリを作成します。
mkdir resize-lambda-edge && cd resize-lambda-edge
mkdir -p dist lambda/origin-response-function lambda/viewer-request-function
touch Dockerfile lambda/origin-response-function/index.js lambda/viewer-request-function/index.js template.yaml
各ファイルを作成します。
Dockerfile
FROM amazonlinux
WORKDIR /tmp
#install the dependencies
RUN yum -y install gcc-c++ && yum -y install findutils
RUN touch ~/.bashrc && chmod +x ~/.bashrc
RUN curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.5/install.sh | bash
RUN source ~/.bashrc && nvm install 6.10
WORKDIR /build
<image-bucket>
は後ほど置換するので、そのままでOKです。
lambda/origin-response-function/index.js
'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-bucket>';
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;
}
console.log(BUCKET)
console.log(originalKey)
// 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);
console.log(BUCKET);
console.log(originalKey);
});
} // end of if block checking response statusCode
else {
// allow the response to pass through
callback(null, response);
}
};
lambda/viewer-request-function/index.js
'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);
};
<image-bucket>
、<code-bucket>
は後ほど置換するので、そのままでOKです。
template.yaml
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Resources:
ImageBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
Properties:
AccessControl: PublicRead
BucketName: <image-bucket>
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"
画像を格納するS3バケットと、パッケージしたLambda関数を格納するバケットが必要です。
[任意のバケット名]を書き換えてからコマンドを実行してください。
export IMAGE_BUCKET=[任意のバケット名]
export CODE_BUCKET=[任意のバケット名]
# バケット名を置換する
sed -i ".orig" "s/<image-bucket>/${IMAGE_BUCKET}/g" template.yaml lambda/origin-response-function/index.js
sed -i ".orig" "s/<code-bucket>/${CODE_BUCKET}/g" template.yaml
docker build --tag amazonlinux:nodejs .
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"
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"
cd lambda/origin-response-function && zip -FS -x "*.orig" -q -r ../../dist/origin-response-function.zip * && cd ../..
cd lambda/viewer-request-function && zip -FS -x "*.orig" -q -r ../../dist/viewer-request-function.zip * && cd ../..
# Lambda@Edgeで使用するLambda関数はバージニア北部(us-east-1)で作成する必要があります。その為、リージョンを明示して作業しています。
aws --region us-east-1 s3 mb s3://${CODE_BUCKET}
aws s3 cp dist/origin-response-function.zip s3://${CODE_BUCKET}/
aws s3 cp dist/viewer-request-function.zip s3://${CODE_BUCKET}/
# CloudFrontを作成するので、約20分かかります。
aws --region us-east-1 cloudformation deploy --template-file template.yaml --stack-name resize-lambda-edge2 --capabilities CAPABILITY_IAM
# サンプル画像をDLしてS3にPUT
curl -L http://www.cor-art.com/best/tenkei/down/SA001.JPG -o image.jpg
aws s3 cp image.jpg s3://images${IMAGE_BUCKET}/images/
# 作成されたCloudFrontのDomainNameを確認する。
aws cloudfront list-distributions
ブラウザからアクセスして、リサイズされている事を確認する。
- オリジナル
- https://{cloudfront-domain}/images/image.jpg
- リサイズ
- https://{cloudfront-domain}/images/image.jpg?d=100x100
- https://{cloudfront-domain}/images/image.jpg?d=200x200
- https://{cloudfront-domain}/images/image.jpg?d=300x300
- https://{cloudfront-domain}/images/image.jpg?d=400x400
2回目以降のアクセスでは、しっかりとHit from cloudfront
を確認でき、体感的な速度向上も確認できました。
あとがき
書き終えた後に、これってSAMだから手動でパッケージ化(ZIP化)せずに、CloudFormationのCodeUri
にlambda/function_name/
って指定してaws cloudformation package
を実行するの方が、楽かなと気づきました。
自分でコードを書いてグレースケール変換なんかも追加したいなと思ったので、その際はより詳しくデプロイ方法も調べてやってみようと思います。
少しハマった所
- Lambda@Edgeの場合はLambda環境変数を使用できないと知らずバケット名を環境変数から取得しようとしてデプロイでコケた
- Lambda@Edgeの場合はアクセス元リージョンにCloudWatch Logsが出力できると知らずLambdaのログを見失った(日本からアクセスすればap-northeast-1のCWLにログ出力される)