CloudFront
lambda
ServerlessFramework
Lambda@Edge

AWS Lambda@Edge 開発入門 + Serverless Framework

Lambda@Edge でできること

Lambda@Edge は CloudFront のエッジサーバ上で実行できる Lambda です。様々なことが可能ですが、よく使われるユースケースは以下のようなものがあります。工夫次第で色んな活用方法があると思います。

  • URL リクエストパラメータに応じた動的な画像リサイズ
  • キャッシュキーの正規化などによるキャッシュヒット率の向上
  • AWS WAF / DynamoDB と組み合わせてリアルタイム性の高い不正アクセス防止
  • 簡易な動的 Web アプリケーション
  • proxy サーバ
  • SPA を SEO のためにダイナミックレンダリングをする

今回は具体的なユースケースの実装方法などは紹介しません。おおまかな Lambda@Edge の開発をするための導入記事です。

Lambda@Edge の流れ

lambda@edge.png

大きく2つの組み合わせで、4つの流れがあります。

  • Viewer: すべてのリクエストに対して Lambda を実行したい
  • Origin: キャッシュミスのリクエストに対して Lambda を実行したい
  • Request: ユーザから見てオリジン方面へのリクエスト
  • Response: オリジンから見てユーザ方面へのレスポンス

よく使うのは Viewer Request と Origin Response だと思います。

環境構築

Lambda は SAM や IntelliJ などのエディタサポートがありますが、CloudFront の設定なども必要になってくるため Serveless Framework を利用するほうが楽だと思います。まずは環境構築から。

$ npm install -g serverless
$ serverless create --template aws-nodejs --path ./sls-lambda
$ cd ./sls-lambda
$ npm init
$ npm install --save-dev --save-exact serverless-plugin-cloudfront-lambda-edge

本家だけでは Lambda@Edge サポートはされていないので serverless-plugin-cloudfront-lambda-edge プラグインを利用します。Lambda の作成はもちろん、CloudFront の設定も全てやってくれます。

作り方

サンプル設定ファイル

だらだらと書いていますが、そんなに難しいことはないです。

  • functions に作りたい Lambda の定義
    • Viewer Request などのイベントタイプを指定したりする
  • resources に CloudFront の定義
    • PriceClass は必ず PriceClass_AllPriceClass_200 にする

PriceClass を PriceClass_All 以外にした場合、日本からのアクセスは CloudWatch ログに出力されません。アメリカだけで利用するようなアプリケーションの場合は安価な方を選択しても構いません。

service: sls-lambda
plugins:
  - serverless-plugin-cloudfront-lambda-edge

package:
  exclude:
    - 'node_modules/**'

provider:
  name: aws
  runtime: nodejs8.10
  stage: ${opt:stage, 'development'}

custom:
  objectPrefix:

functions:
  sampleRewriter:
    name: 'sample-rewriter'
    handler: src/sampleRewriter.handler
    memorySize: 128
    timeout: 1
    lambdaAtEdge:
      distribution: 'WebsiteDistribution'
      eventType: 'viewer-request'
    events:
      - cloudwatchLog: '/aws/lambda/us-east-1.sample-rewriter'
  sampleResponse:
    name: 'sample-response'
    handler: src/sampleResponse.handler
    memorySize: 128
    timeout: 1
    lambdaAtEdge:
      distribution: 'WebsiteDistribution'
      eventType: 'origin-response'
resources:
  Resources:
    WebsiteBucket:
      Type: 'AWS::S3::Bucket'
      Properties:
        BucketName: 'sls-lambda-edge-sample'
        AccessControl: 'PublicRead'
        WebsiteConfiguration:
          IndexDocument: 'index.html'
          ErrorDocument: 'error.html'
    WebsiteDistribution:
      Type: 'AWS::CloudFront::Distribution'
      Properties:
        DistributionConfig:
          DefaultCacheBehavior:
            TargetOriginId: 'WebsiteBucketOrigin'
            ViewerProtocolPolicy: 'redirect-to-https'
            # DefaultTTL: 600 # ten minutes
            # MaxTTL: 600 # ten minutes
            Compress: true
            ForwardedValues:
              QueryString: true
              Cookies:
                Forward: 'none'
          DefaultRootObject: 'index.html'
          Enabled: true
          PriceClass: 'PriceClass_All'
          HttpVersion: 'http2'
          ViewerCertificate:
            CloudFrontDefaultCertificate: true
          Origins:
            -
              Id: 'WebsiteBucketOrigin'
              DomainName: { 'Fn::GetAtt': [ 'WebsiteBucket', 'DomainName' ] }
              S3OriginConfig: {}

サンプルコード

本当に何もしていないサンプルコードです。ここは普通の Lambda と変わらないですね。

sampleRewriter.js

'use strict'

module.exports.handler = (event, context, callback) => {
  const request = event.Records[0].cf.request
  callback(null, request)
}

sampleResponse.js

'use strict'

module.exports.handler = (event, context, callback) => {
  const response = event.Records[0].cf.response
  callback(null, response)
}

デプロイ

--state オプションで環境を指定し、--aws-profile で AWS プロファイルを指定します。

$ serverless deploy -v --stage development --aws-profile default

初回のデプロイは CloudFront を作成するため20分ほどかかります。そして次回以降も新しい Lambda@Edge の反映を待つので20分ほどかかります。これが Lambda@Edge のつらいところですね。実際には1〜2分ほどでほとんどのエッジサーバには反映しているので、実際にアクセスすると動作確認をすることが出来ます。

デバッグ

Lambda@Edge のデバッグはやりにくいことで有名です。そこで Lambda@Edge が受け取るデータをモックにして serverless を使ってローカル実行をさせます。こちらの JSON 形式をベースに必要に応じてカスタマイズをしていきます。

./mock ディレクトリなどに適当にいくつかのパターン JSON を作っておくと良いでしょう。

リクエストイベント

リクエストイベントでよく使うのは以下のキーです。

  • Records[0].cf.request.querystring
  • Records[0].cf.request.uri
  • Records[0].cf.request.method
  • Records[0].cf.request.headers

また Records[0].cf.request.headers は書き換え可能です。

{
  "Records": [
    {
      "cf": {
        "config": {
          "distributionDomainName": "d123.cloudfront.net",
          "distributionId": "EDFDVBD6EXAMPLE",
          "eventType": "viewer-request",
          "requestId": "MRVMF7KydIvxMWfJIglgwHQwZsbG2IhRJ07sn9AkKUFSHS9EXAMPLE=="
        },
        "request": {
          "clientIp": "2001:0db8:85a3:0:0:8a2e:0370:7334",
          "querystring": "size=large",
          "uri": "/picture.jpg",
          "method": "GET",
          "headers": {
            "host": [
              {
                "key": "Host",
                "value": "d111111abcdef8.cloudfront.net"
              }
            ],
            "user-agent": [
              {
                "key": "User-Agent",
                "value": "curl/7.51.0"
              }
            ]
          },
          "origin": {
            "custom": {
              "customHeaders": {
                "my-origin-custom-header": [
                  {
                    "key": "My-Origin-Custom-Header",
                    "value": "Test"
                  }
                ]
              },
              "domainName": "example.com",
              "keepaliveTimeout": 5,
              "path": "/custom_path",
              "port": 443,
              "protocol": "https",
              "readTimeout": 5,
              "sslProtocols": [
                "TLSv1",
                "TLSv1.1"
              ]
            },
            "s3": {
              "authMethod": "origin-access-identity",
              "customHeaders": {
                "my-origin-custom-header": [
                  {
                    "key": "My-Origin-Custom-Header",
                    "value": "Test"
                  }
                ]
              },
              "domainName": "my-bucket.s3.amazonaws.com",
              "path": "/s3_path",
              "region": "us-east-1"
            }
          }
        }
      }
    }
  ]
}

レスポンスイベント

レスポンスイベントは Records[0].cf.response の値を書き換えて callback に渡してあげる感じになります。

{
    "Records": [
        {
            "cf": {
                "config": {
                    "distributionDomainName": "d123.cloudfront.net",
                    "distributionId": "EDFDVBD6EXAMPLE",
                    "eventType": "viewer-response",
                    "requestId": "xGN7KWpVEmB9Dp7ctcVFQC4E-nrcOcEKS3QyAez--06dV7TEXAMPLE=="
                },
                "request": {
                    "clientIp": "2001:0db8:85a3:0:0:8a2e:0370:7334",
                    "method": "GET",
                    "uri": "/picture.jpg",
                    "querystring": "size=large",
                    "headers": {
                        "host": [
                            {
                                "key": "Host",
                                "value": "d111111abcdef8.cloudfront.net"
                            }
                        ],
                        "user-agent": [
                            {
                                "key": "User-Agent",
                                "value": "curl/7.18.1"
                            }
                        ]
                    }
                },
                "response": {
                    "status": "200",
                    "statusDescription": "OK",
                    "headers": {
                        "server": [
                            {
                                "key": "Server",
                                "value": "MyCustomOrigin"
                            }
                        ],
                        "set-cookie": [
                            {
                                "key": "Set-Cookie",
                                "value": "theme=light"
                            },
                            {
                                "key": "Set-Cookie",
                                "value": "sessionToken=abc123; Expires=Wed, 09 Jun 2021 10:18:14 GMT"
                            }
                        ]
                    }
                }
            }
        }
    ]
}

ローカル実行

invoke local でローカル実行できます。さきほどの JSON をモックデータとして利用します。

$ SLS_DEBUG=* serverless invoke local -f sampleRewriter -p ./mock/request.json
$ SLS_DEBUG=* serverless invoke local -f sampleResponse -p ./mock/response.json

これで大抵のことはローカルで動作確認をしながら開発をすることができると思います。

ログの確認

Lambda@Edge の Lambda 自体はバージニア北部 ( us-east-1 ) にある必要がありますが、CloudWatch のログはユーザがアクセスしたリージョンになります。なので日本だと東京 ( ap-northeast-1 ) にあります。気が付きにくいところなので注意してください。

(*) CloudFront の Price Class を All Edge もしくは Asia 込みにしないと日本からのアクセスはログに出力されません

また、ロググループ名には us-east-1 のプレフィックスが自動で付きます。

  • /aws/lambda/us-east-1.sample-rewriter

この影響で serverless logs コマンドで以下のように手軽に Lambda のログが確認できるはずが、対象のログファイルがないというエラーになってしまいます。

$ serverless logs -f sampleRewriter --stage development --aws-profile default --region ap-northeast-1

Serverless Error ---------------------------------------

  The specified log group does not exist.

さすがにログを流し見出来ない状況での開発はつらいので本家 Serverless が対応するまでは awslogs コマンドを利用しましょう。Mac でのインストールはこちら(環境によっては sudo が必要です)。

$ pip install awslogs --ignore-installed six

CloudWatch のロググループ名を確認します。

$ awslogs groups --aws-region ap-northeast-1

そして watch オプションでログを流し続けることが出来ます。

$ awslogs get --aws-region ap-northeast-1 -w /aws/lambda/us-east-1.sample-response

これでログを流し見ながら開発をすることができるようになりました。

Lambda@Edge 実践の感想

Lambda@Edge をプロダクション環境で使ってみた現時点で個人的な感想は Lambda@Edge は便利だけど、ちょっと使いにくいところがあるというイメージです。使いにくいと思ったところは以下のとおりです。

  • CloudFront への反映完了が20分以上かかる
    • Serverless Framework の完了が待たされるためトライアンドエラーしにくい
    • 前述したようにほとんどのエッジサーバには1〜2分で反映しているので動作確認自体はすぐできる
  • Node.js で外部ライブラリでネイティブライブラリがある場合は面倒くさい
    • これは通常の Lambda でも言えることですがリサイズの sharp などを利用するには Docker でビルドしたりする必要があり面倒くさい。Cloud Functions のようにデプロイ先でインストールして欲しい
  • ファイルサイズ制限がある
    • ビューワーは 1MB
    • オリジンは 50MB
    • まぁ、ここらへんは変な作りにしなければ回避は容易
  • レスポンスサイズがちょっと厳しい
    • ビューワーは 40KB
    • オリジンは 1MB
      • ビューワーはともかくオリジンが 1MB ということは、画像リサイズなどで大きいものは返すことが出来ない

とまぁネガティブっぽい感じありますが、大抵の利用シーンに置いては問題になることは少なく夢が広がる系のサービスなので積極的に使ってノウハウをためていきたいという感じです。

AWS は Lambda 自体のサービスアップデートも積極的に行っているので期待できますね。Aurora Serverless との連携とか、IntelliJ などのエディタサポートとか、Custom Runtime とか ALB に直接 Lambda のヒモ付とか…。