GreenSnap TECH BLOG

GreenSnapのエンジニアチームの取り組みや使っている技術を紹介します

Lambda@Edgeで画像リサイズをGUIでイチから設定する

はじめに

aws.amazon.com こちらの記事を参考に画像をリサイズするLambda@Edgeを作ろうとしたが環境構築がCloudFormationを使って0の状態から作成されており、すでに本番環境で動いてるS3やcloudfrontに対して適用するのにGUIからやりたかったのでこれを実際にGUIで設定しようとしたらどうなるのか?というのをやってみました。
一部、元の記事の設定ではうまく動かない部分やハマった部分についても記述します。もしかしたらうまく設定できない方がいれば何かの参考になるかもしれません。

CloudFormationのテンプレートを読み解く

yamlファイルは元の記事を見てもらうとして、やってることは以下のようなことになります。

  1. Lambda@Edge用のIAMRoleを作る
  2. S3のバケットを用意し、1で作ったIAMRoleに権限付与する
  3. viewer-requestとorigin-responseのlambda関数を作成する。
  4. CloudFrontを用意する。2で作ったS3をオリジンにし、3で作ったlambda関数をlambdaEdge関数として適用する

ただ今回はGUIでやっていくので多少順番を入れ替えて以下のような作業工程で完成を目指します。

  1. Lambda@Edge用のIAMRoleを作る
  2. S3のバケットを用意し、1で作ったIAMRoleに権限付与する
  3. 2で作ったS3をオリジンとするCloudFrontを用意する
  4. viewer-requestとorigin-responseのlambda関数を作成する
  5. lambdaEdge関数にデプロイする

これらを順番にGUIで設定していけば画像をリサイズするLamda@Edgeが完成し
cloudfrontドメイン/path?d=100x100
のようなURLで画像ファイルにアクセスすると100x100のリサイズされた画像が表示されるようになります。 順番に設定してみましょう。

1. Lambda@Edge用のIAMRoleを作る

  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"

CloudFormationでいうところこの部分を作成します

IAMRoleの管理画面から、カスタム信頼ポリシーを選択し
信頼関係に

  • edgelambda.amazonaws.com
  • lambda.amazonaws.com

の二つを追加したIAMRoleを作成します

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": [
                    "edgelambda.amazonaws.com",
                    "lambda.amazonaws.com"
                ]
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

IAMRoleは以上です。このIAMRoleをS3やlambda関数に使っていきます。

2. S3のバケットを用意し、手順1で作ったIAMRoleに権限付与する

  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}/*

CloudFormationでいうところこの部分を作成します。

S3バケットを作成し PutObjectを1で作ったIAMRoleに権限付与し、 GetObjectとListBucketは全体公開します。
ListBucketの権限はCloudFormation側にはないのですが、これを追加しておかないと後々うまく動かない部分があるので付けておいたほうが良いです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::XXXXXX:role/EdgeLambdaRole" // 手順1で作成したIAMRole
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::lambdaedge-test/*"
        },
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::lambdaedge-test/*"
        },
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::lambdaedge-test"
        }
    ]
}

3. 2で作ったS3をオリジンとするCloudFrontを用意する

  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'

CloudFormationでいうところこの部分を作成します。

先にキャッシュポリシーを作成します。
キャッシュキーの設定のクエリ文字列に「d」を設定すれば他はデフォルトでOKです。

CloudFrontを作成します。
オリジンを2で作成したS3を選択します。
キャッシュポリシーを先ほど作成したキャッシュポリシーを選択します。

以上でCloudFrontの設定は完了です。
ためしにS3に画像をアップロードしてCloudFrontのドメインで表示されればOKです。

4. viewer-requestとorigin-responseのlambda関数を作成し、lambdaEdge関数にデプロイする

※lambda関数はus-east-1のリージョンに作成します

 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"

CloudFormationでいうところこの部分を作成します。
どちらもzipにしたソースコードをS3にアップロードしてそれをlambda関数にしていますが
Viewer-Request関数に関しては直接ソースコードを記述しても問題ありません。 Origin-Response関数もS3にアップしてますが直接zipをアップロードしてもデプロイ可能なので好みの方法でデプロイしてください。
注意点としては、S3のバケットを両方とも正しく設定することと、decodeURIしてrequest.uriの値を変数にいれておかないと、S3にアップされたファイル名にマルチバイト文字が含まれているとエラーになります。

    // read the required path. Ex: uri /images/100x100/webp/image.jpg
    let path = decodeURI(request.uri); //decodeURIしておく

5. Lambda@Edgeへデプロイする

Viewer-Request関数とOrigin-Response関数が作成できたらCloudFrontのlambdaEdgeにデプロイします。

Viewer-Request関数のLambda@Edgeへのデプロイ

Lambdaの右上のActionボタンから「Lambda@Edgeへのデプロイ」を選択します。

Distributionに作成したCloudFrontを選択し、CloudFront eventにViewer requestを選択しデプロイします。

Origin-Response関数のLambda@Edgeへのデプロイ 同様の手順でOrigin-Response関数もLambda@Edgeへのデプロイします。

デプロイがうまく行っていれば、CloudFront側のビヘイビアの設定をみるとLambda@Edgeが紐づいているのがわかります。

以上ですべての設定が完了です。 S3に画像をアップロードして cloudfrontドメイン/path?d=100x100
cloudfrontドメイン/path?d=200x200
クエリパラメーターをつけたURLにアクセスし、リサイズされた画像が表示されれば完成です。

その他補足

GUIで設定していく上でいくつかハマった点やおまけの情報をいくつか紹介します。

Lambda@Edgeへのデプロイでエラーになる場合

Lambda@Edgeへのデプロイで以下のようなエラーが表示された場合
IAMRoleが正しいか確認しましょう

Lambda@Edgeの関数をテストしたいとき

Viewer-Request関数の場合

cloudfront-access-request-in-response のテンプレートを選択しuriとquerystringの部分を修正する

{
//省略
          "clientIp": "2001:cdba::3257:9652",
          "uri": "/images/test-image.jpg", //s3のファイルのパス
          "querystring": "d=200x200", //リサイズの指定
          "method": "GET"
//省略
}

これでテストを実行し、成功するかどうかでチェックできます。

Origin-Response関数の場合

cloudfront-access-request-in-response のテンプレートを選択しuri,querystring,responseのstatusを変更します S3のimages/test-image.jpg のファイルがあると想定して

{
  "Records": [
// 省略
          "clientIp": "2001:cdba::3257:9652",
          "uri": "/images/200x200s/webp/test-image.jpg", //viewer-requestのfunctionが返してくるpath
          "querystring": "d=200x200", //リサイズの指定
          "method": "GET"
        },
        "response": { 
          "status": "404",  // 404にする
          "statusDescription": "Not Found",
//省略
  ]
}

この設定でテスト実行した結果 S3のパスimages/test-image.jpg の画像がリサイズされたものが /images/200x200s/webp/test-image.jpg に作成されていれば成功しています。

CloudFrontのLambda@Edgeの関数のURLにアクセスしてエラーになる場合

AccessDeniedが表示される場合
S3のバケットポリシーにs3:ListBucketの権限が付与されているか確認しましょう。
存在しない画像URLにアクセスした時に404が帰る状態になっていないとLambda@Edgeの関数がうまく動きません。

アスペクト比を保ったままリサイズしたい

現在の実装だと正方形に切り取ることしかできないのでアスペクト比を保ったままリサイズしようとするとOrigin-Response関数に少し手を加えないといけません。 具体的にはクエリパラメーターのmodeというキーの値を元にS3から取得した元画像をどのように切り取るかを決定する処理を追加します。
以下のように修正します。

exports.handler = (event, context, callback) => {
  let response = event.Records[0].cf.response;

  //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 = decodeURI(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, mode, requiredFormat, imageName;
    let startIndex;

    try {
      match = key.match(/(.*)\/(\d+)x(\d+)([sivh]?)\/(.*)\/(.*)/);
      prefix = match[1];
      width = parseInt(match[2], 10);
      height = parseInt(match[3], 10);
      mode = match[4] ? match[4] : "s";

      // correction for jpg required for 'Sharp'
      requiredFormat = match[5] == "jpg" ? "jpeg" : match[5];
      imageName = match[6];
      originalKey = prefix + "/" + imageName;
    }
    catch (err) {
      // no prefix exist for image..
      match = key.match(/(\d+)x(\d+)([sivh]?)\/(.*)\/(.*)/);
      width = parseInt(match[1], 10);
      height = parseInt(match[2], 10);
      mode = match[3] ? match[3] : "s";

      // correction for jpg required for 'Sharp'
      requiredFormat = match[4] == "jpg" ? "jpeg" : match[4];
      imageName = match[5];
      originalKey = imageName;
    }

    // get the source image file
    S3.getObject({ Bucket: BUCKET, Key: originalKey }).promise()
      // perform the resize operation
      .then(data => {
        let sharpConfig = {};
        /*
         * mode
         *  - s(square)     : 中央を正方形に切り取り(デフォルト)
         *  - i(inside)     : アスペクト比を変えずに指定の正方形サイズに収まる様に切り取り
         *  - v(vertical)   : アスペクト比を変えずに、縦にサイズを合わせる
         *  - h(horizontal) : アスペクト比を変えずに、横にサイズを合わせる
         */
        switch(mode){
          case 'i':
            sharpConfig.fit = 'inside';
            sharpConfig.width = width;
            sharpConfig.height = height;
            break;
          case 'v':
            sharpConfig.height = height;
            break;
          case 'h':
            sharpConfig.width = width;
            break;
          default:
            sharpConfig.width = width;
            sharpConfig.height = height;
        }
        return Sharp(data.Body)
            .resize(sharpConfig)
            .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);
  }
};

修正後デプロイした後動作確認します。

元画像

d=300x300でアクセス

d=300x300&mode=i でアクセス

以上のようにリサイズされていれば成功です。

最後に

長い文章になってしまい、やはりLambda@Edgeを手動で設定するのは大変ということがわかりました。
大人しくCloudFormationを勉強したいと思います。

GreenSnapではエンジニアを募集中です。サーバーサイド、iOS、Androidエンジニアはもちろんのこと これから作成するデータ基盤とそれを活用できるデータサイエンティストの方も募集しています。
一人ひとりが幅広くいろんな技術領域を担当しているので、特定の技術だけを担当するより多くの技術に触れたい方や挑戦したい方は是非お声がけください。

www.wantedly.com