CloudFront なコンテンツにサーバレスな A/B テスト環境を構築

# 概要
CloudFront + S3 で静的なコンテンツを配信するの便利ですよね。
簡単な LP やサービスであればこの構成で事足ります。

この LP の CV 数を上げたい！ などの欲求が生まれたときに A/B テストして改善していくのが定説ですが、
静的なコンテンツを返しているだけなのでパターン分けが困難です。

今回は Lambda@Edge を使って動的に表示するコンテンツを変え、A/B テストする環境を構築します。
またリソースの管理のしやすさや Infrastructure as Code の観点から Serverless Framework を使った実装になっています。

# Lambda@Edge とは
簡単にいうと CloudFront のイベントをトリガーに起動する Lambda function のことです。
イベントは全部で 4 つあってそれぞれに 1 function 割り当てることができます。

- CloudFront がビューワーからリクエストを受信した後 (ビューワーリクエスト)
- CloudFront がリクエストをオリジンサーバーに転送する前 (オリジンリクエスト)
- CloudFront がオリジンからレスポンスを受信した後 (オリジンレスポンス)
- CloudFront がビューワーにレスポンスを転送する前 (ビューワーレスポンス)

公式の画像がわかりやすかったので拝借します。

<img src="https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/images/cloudfront-events-that-trigger-lambda-functions.png" />

Lambda@Edge には様々なユースケースがあり A/B テストもそのうちの一つとして紹介されていましたが、
実装まで落とし込んだ例がなかったため今回記事にした次第です。

# 構成

基本的に上の画像と同じ構成なのですが A/B テストするために 2 パターンのページを用意します。
それらをそれぞれ違う S3 Backet にアップし、複数の Origin として CloudFront で配信します。
その CloudFront に対して Lambda@Edge でイベントをフックして動的に Origin を振り分けるという仕組みです。

![Untitled Diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119099/2e49fae5-997a-93aa-b629-a10db1b65ea8.png)

用意する関数は 3 つでそれぞれ以下のような役割があります。
① リクエストが有った際にランダムでパターン分けに必要な Cookie を設定する
② ① で設定した Cookie の値を元に Origin を動的に振り分ける
③ ① で設定した Cookie を設定する（分析のため）

# 実装
Serverless Framework を使って実装していきます。

<details><summary>serverless.yml</summary><div>

```yaml:serverless.yml

service:
  name: edge-abtest

package:
  exclude:
    - 'node_modules/**'

custom:
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules: true
  defaultRegion: us-east-1
  defaultEnvironmentGroup: dev
  region: ${opt:region, self:custom.defaultRegion}
  stage: ${opt:stage, env:USER}
  objectPrefix: '${self:service}-${self:custom.stage}'


# Add the serverless-webpack plugin
plugins:
  - serverless-webpack
  - '@silvermine/serverless-plugin-cloudfront-lambda-edge'

provider:
  name: aws
  runtime: nodejs10.x
  environment:
    AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1
    SLS_SVC_NAME: ${self:service}
    SLS_STAGE: ${self:custom.stage}
  region: us-east-1
  memorySize: 128
  timeout: 5

functions:
  cookie:
    handler: handler.cookie
    lambdaAtEdge:
      distribution: 'WebsiteDistribution'
      eventType: 'viewer-request'
  test:
    handler: handler.test
    lambdaAtEdge:
      distribution: 'WebsiteDistribution'
      eventType: 'origin-request'
  setOriginCookie:
    handler: handler.originCookie
    lambdaAtEdge:
      distribution: 'WebsiteDistribution'
      eventType: 'origin-response'

resources:
   Resources:
    WebsiteBucketA:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ab-test-a
        AccessControl: PublicRead
        WebsiteConfiguration:
          IndexDocument: index.html
          ErrorDocument: error.html
    WebsiteBucketB:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ab-test-b
        AccessControl: PublicRead
        WebsiteConfiguration:
          IndexDocument: index.html
          ErrorDocument: error.html
    WebsiteLog:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ab-test-log
    WebsiteBucketPolicyA:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket:
          Ref: WebsiteBucketA
        PolicyDocument:
          Statement:
            - Effect: Allow
              Action: s3:GetObject
              Resource:
                Fn::Sub: arn:aws:s3:::ab-test-a/*
              Principal: "*"
    WebsiteBucketPolicyB:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket:
          Ref: WebsiteBucketB
        PolicyDocument:
          Statement:
            - Effect: Allow
              Action: s3:GetObject
              Resource:
                Fn::Sub: arn:aws:s3:::ab-test-b/*
              Principal: "*"
    WebsiteDistribution:
      Type: 'AWS::CloudFront::Distribution'
      Properties:
        DistributionConfig:
          DefaultCacheBehavior:
            TargetOriginId: 'WebsiteBucketOriginA'
            ViewerProtocolPolicy: 'redirect-to-https'
            DefaultTTL: 600
            MaxTTL: 600
            Compress: true
            ForwardedValues:
              QueryString: false
              Cookies:
                Forward: 'whitelist'
                WhitelistedNames: ['X-Source']
          DefaultRootObject: 'index.html'
          Enabled: true
          PriceClass: 'PriceClass_100'
          HttpVersion: 'http2'
          ViewerCertificate:
            CloudFrontDefaultCertificate: true
          Origins:
            - Id: 'WebsiteBucketOriginA'
              DomainName: { 'Fn::GetAtt': [ 'WebsiteBucketA', 'DomainName' ] }
              S3OriginConfig: {}
            - Id: 'WebsiteBucketOriginB'
              DomainName: { 'Fn::GetAtt': [ 'WebsiteBucketB', 'DomainName' ] }
              S3OriginConfig: {}
          CustomErrorResponses:
            - ErrorCachingMinTTL: 300
              ErrorCode: 404
              ResponseCode: 200
              ResponsePagePath: /index.html
            - ErrorCachingMinTTL: 300
              ErrorCode: 403
              ResponseCode: 200
              ResponsePagePath: /index.html
          Logging:
            Bucket: ab-test-log.s3.amazonaws.com
            IncludeCookies: true
            Prefix: cloudfront/

```
</div></details>

重要な点としては serverless で Edge を組み立てる際に `serverless-plugin-cloudfront-lambda-edge` というプラグインを使っています。
https://github.com/silvermine/serverless-plugin-cloudfront-lambda-edge

このプラグインを使う際は環境変数に `SLS_SVC_NAME` と `SLS_STAG` を設定しないと動かないみたいです。
あと実装時点、今後もずっとそうだと思われますが Edge は us-east-1 でしか作ることができないので region 指定は必須です。
またメモリやタイムアウトに厳しく `memorySize` と `timeout` を設定しないといけませんでした。


<details><summary>Lambda</summary><div>

```ts:handler.ts
import { CloudFrontRequestHandler, CloudFrontHeaders } from "aws-lambda";
import "source-map-support/register";

const sourceCoookie = "X-Source";
const sourceA = "a";
const sourceB = "b";
const bBucketName = "ab-test-b.s3.amazonaws.com";
const cookiePath = "/";

export const cookie: CloudFrontRequestHandler = (event, _, callback) => {
  const request = event.Records[0].cf.request;
  const headers = request.headers;

  if (headers.cookie) {
    for (let i = 0; i < headers.cookie.length; i++) {
      if (headers.cookie[i].value.indexOf(sourceCoookie) >= 0) {
        callback(null, request);
        return;
      }
    }
  }

  const source = Math.random() < 0.5 ? sourceB : sourceA;
  console.log(`Source: ${source}`);

  const cookie = `${sourceCoookie}=${source}`;
  headers.cookie = headers.cookie || [];
  headers.cookie.push({ key: "Cookie", value: cookie });

  callback(null, request);
};

export const test: CloudFrontRequestHandler = (event, _, callback) => {
  const request = event.Records[0].cf.request;
  const headers = request.headers;

  const source = decideSource(headers);
  if (source === sourceB) {
    const backet: {
      authMethod: "origin-access-identity" | "none";
      domainName: string;
      path: string;
      region: string;
      customHeaders: CloudFrontHeaders | null;
    } = {
      authMethod: "none",
      domainName: bBucketName,
      path: "",
      region: "",
      customHeaders: {},
    };
    request.origin = { s3: backet };

    headers["host"] = [{ key: "host", value: bBucketName }];
  }

  callback(null, request);
};

export const originCookie = (event, _, callback) => {
  const request = event.Records[0].cf.request;
  const requestHeaders = request.headers;
  const response = event.Records[0].cf.response;

  const sourceACookie = `${sourceCoookie}=${sourceA}`;
  const sourceBCookie = `${sourceCoookie}=${sourceB}`;

  if (requestHeaders.cookie) {
    for (let i = 0; i < requestHeaders.cookie.length; i++) {
      if (requestHeaders.cookie[i].value.indexOf(sourceBCookie) >= 0) {
        console.log("Experiment Source cookie found");
        setCookie(response, sourceBCookie);
        callback(null, response);
        return;
      }
      if (requestHeaders.cookie[i].value.indexOf(sourceACookie) >= 0) {
        console.log("Main Source cookie found");
        setCookie(response, sourceACookie);
        callback(null, response);
        return;
      }
    }
  }
  callback(null, response);
};

const setCookie = function (response, cookie) {
  const cookieValue = `${cookie}; Path=${cookiePath}`;
  console.log(`Setting cookie ${cookieValue}`);
  response.headers["set-cookie"] = [{ key: "Set-Cookie", value: cookieValue }];
};

const decideSource = function (headers) {
  const sourceACookie = `${sourceCoookie}=${sourceA}`;
  const sourceBCookie = `${sourceCoookie}=${sourceB}`;

  if (headers.cookie) {
    for (let i = 0; i < headers.cookie.length; i++) {
      if (headers.cookie[i].value.indexOf(sourceACookie) >= 0) {
        return sourceA;
      }
      if (headers.cookie[i].value.indexOf(sourceBCookie) >= 0) {
        return sourceB;
      }
    }
  }
};

```

</div></details>

Lambda に関しては公式が実装例を出してくれてるので参考にしながら書きました。
https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-examples.html


ここまで来たらデプロイします。

すると S3 バケットが 3 つ（コンテンツ格納用で 2 つと log 用に 1 つ）と CloudFront が 1 つ Lambda Function が 3 つできるはずです。

あとはそれぞれの S3 にコンテンツを配置します。

アクセスしてみると以下のように表示されます。（わかりやすいようにタイトルを A にしています。）
![スクリーンショット 2020-06-07 8.48.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119099/1def46a9-4286-3ec6-6b0e-4541f6e159d0.png)

Cookie を見ると `a` が設定されていることがわかります。
![スクリーンショット 2020-06-07 8.47.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119099/cbb5f687-b2bd-ccd7-9085-f1ab646dedb8.png)

value を `b` に変えてアクセスしてみるとタイトルが B に変わります。

![スクリーンショット 2020-06-07 8.48.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119099/2f459370-4d63-ee90-d081-a9dc1c471b91.png)

# 分析
CloudFront ではアクセスログを S3 に貯めることができます。
このときログに Cookie を含めておくと A or B を判別することができます。

また今回やってはないですが CloudFront のログを Athena で見ることも可能なので SQL で集計することもできそうです。
https://docs.aws.amazon.com/ja_jp/athena/latest/ug/cloudfront-logs.html

# 参考
- https://qiita.com/zaru/items/38a83606b72e2e41c4ce
- https://medium.com/buildit/a-b-testing-on-aws-cloudfront-with-lambda-edge-a22dd82e9d12