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のCodeUrilambda/function_name/って指定してaws cloudformation packageを実行するの方が、楽かなと気づきました。
自分でコードを書いてグレースケール変換なんかも追加したいなと思ったので、その際はより詳しくデプロイ方法も調べてやってみようと思います。

少しハマった所

  • Lambda@Edgeの場合はLambda環境変数を使用できないと知らずバケット名を環境変数から取得しようとしてデプロイでコケた
  • Lambda@Edgeの場合はアクセス元リージョンにCloudWatch Logsが出力できると知らずLambdaのログを見失った(日本からアクセスすればap-northeast-1のCWLにログ出力される)

参考