Lambda@Edgeを使って画像をリサイズしてみた
はじめに
おはようございます、加藤です。今回は、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
各ファイルを作成します。
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です。
'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); } };
'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です。
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にログ出力される)