概要
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 がビューワーにレスポンスを転送する前 (ビューワーレスポンス)
公式の画像がわかりやすかったので拝借します。
Lambda@Edge には様々なユースケースがあり A/B テストもそのうちの一つとして紹介されていましたが、
実装まで落とし込んだ例がなかったため今回記事にした次第です。
構成
基本的に上の画像と同じ構成なのですが A/B テストするために 2 パターンのページを用意します。
それらをそれぞれ違う S3 Backet にアップし、複数の Origin として CloudFront で配信します。
その CloudFront に対して Lambda@Edge でイベントをフックして動的に Origin を振り分けるという仕組みです。
用意する関数は 3 つでそれぞれ以下のような役割があります。
① リクエストが有った際にランダムでパターン分けに必要な Cookie を設定する
② ① で設定した Cookie の値を元に Origin を動的に振り分ける
③ ① で設定した Cookie を設定する(分析のため)
実装
Serverless Framework を使って実装していきます。
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/
重要な点としては 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
を設定しないといけませんでした。
Lambda
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;
}
}
}
};
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 にしています。)
Cookie を見ると a
が設定されていることがわかります。
value を b
に変えてアクセスしてみるとタイトルが B に変わります。
分析
CloudFront ではアクセスログを S3 に貯めることができます。
このときログに Cookie を含めておくと A or B を判別することができます。
また今回やってはないですが CloudFront のログを Athena で見ることも可能なので SQL で集計することもできそうです。
https://docs.aws.amazon.com/ja_jp/athena/latest/ug/cloudfront-logs.html