はじめに
Google Optimizeのサービス終了というニュースが飛び込んできたり、A/Bテストツールの代替案を模索している方も多いかと思います。理想を言えば、このようなマーケティングツールは何かしらの有償ツールを利用することが、マーケティング・エンジニア工数対効果の上でも圧倒的に優れています。一方で、何かしらの理由で内製化せざるを得ないケースもあるかと思うので、今回の記事ではAWSのリソースを使ってA/Bテストを実装する方法を検討してみました。
A/Bテストとは
A/Bテストとは、2つの異なるバージョンのウェブページやアプリケーションを同時にテストし、どちらが目的のKPI(例: コンバージョン率やクリック率.etc)において優れているかを比較する手法です。
具体的には、「A」という元のデザインやコンテンツと、変更を加えた「B」の新しいバージョンを、ランダムに分けられたユーザーグループに提示します。ユーザーの反応を分析することで、どちらのバージョンがユーザーにとって魅力的であるか、またどちらがビジネスの目的をより効果的に達成するかを明らかにする手法になります。
A/Bテスト基盤構築を行う上での留意事項
A/Bテスト基盤構築には、テストの対象となるユーザー群、コンテンツ切り替えロジックの実装箇所、そしてA/Bテスト計測方法を、要件に合わせて適切に選択する必要があります。以下にて、それぞれの要素に関する留意点を挙げます。
A/Bテスト対象の定義
まずは、どのユーザー群を対象とするのかの定義が必要になります。概ね以下に切り分けられるかと思います。
- ランダムに、または特定の比率で振り分けられるユーザー群。
- 特定の属性や条件を持つユーザー群。
- 上記の組み合わせを利用してのテスト設計。
コンテンツ・トラフィックの切り替え
コンテンツを出し分けるための判定ロジックをシステム全体の設計上のどの位置に配置するかも重要です。アプリケーション内外で棲み分けることができそうです。
-
アプリケーションサーバー外での振り分け:
この場合、CloudFront
×Lamda@Edge
などのエッジコンピューティングや、Nginx
などリバースプロキシサーバーを活用して実現します。リクエストにランダムで特定のパラメータを付与し、オリジンに転送したり、特定の割合でトラフィック分散を行ってオリジンに転送するケースになります。 -
アプリケーションサーバー内での振り分け:
A/Bテスト用にリクエストユーザーに紐づいた「UUID」や「属性」を利用して、コンテンツを"オリジン側で"振り分けます。ミドルウェアパターンを用いて実装、アプリケーション内でリダイレクトを行うことなども考えられます。また、AWSでは、機能フラグを拡張したサービスを提供するCloud Watch Evidently
を利用することで、A/Bテストを実現することができます。
効果測定方法
-
サードパーティの分析ツールを利用:
クライアント側でのCV発生時にサーバー側で発行したcookie、クエリパラメータ/パスの値をdataLayer
などに埋め込んでGoogle Analytics(GA)などのツールに転送・分析を行うケースです。 -
AWSの分析ツールを利用:
CloudWatch
には、機能フラグやA/Bテストを実現できるCloudWatch Evidently
というサービスがあります。このサービスでは、カスタムメトリクスや、フロントエンド側のパフォーマンスを計測できる、CloudWatch RUM
を使うことでA/Bテストの計測を詳細に行うことができます。 -
独自の分析基盤を構築:
ユーザーの行動履歴を別のアクセスログに出力し、データベースに保管。この方法では、詳細な分析やカスタマイズが容易になる。
AWSリソースを用いたA/Bテスト基盤構築アーキテクチャ案
上記が全体像になります。本記事では合計5つのサンプルをご紹介します。なお、ここで紹介する内容は、あくまでも参考であるため、実際はビジネスサイドの要件や機能・非機能要件によって様々な追加リソースが必要になることを留意ください。
①エッジサイドで実現(VPC外に配置)
まず一つ目は、AWSのCDNサービスであるCloudFront
上で利用することができるエッジコンピューティングLamda@Edge
を用いて実現する案です。Lamda@Edge
は、CloudFront
のキャッシュ前後のリクエスト・レスポンス、合計4箇所の処理に割り込むことができます。
この設計では、トラフィックの振り分け処理をアプリケーション外、かつよりエンドユーザーに近い位置で実現します。例えば、以下のような処理です。
-
Viewer Request
で、リクエストヘッダーにA/Bテスト用のCookieを設定 -
Origin Request
で、リクエストヘッダーのCookieを利用してユーザーをA/Bオリジンのどちらかに一貫して振り分ける -
Origin Response
で、ブラウザ側にSet-Cookie
を行う
ビューアリクエストのLambda@edge関数
'use strict';
// the `pool` cookie designates the user pool that the request belongs to
const cookieName = 'pool';
// returns cookies as an associative array, given a CloudFront request headers array
const parseCookies = require('./common.js').parseCookies;
// returns either 'a' or 'b', with a default probability of 1:1
const choosePool = (chance = 2) => Math.floor(Math.random()*chance) === 0 ? 'b' : 'a';
//if the request does not have a pool cookie - assign one
exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
const parsedCookies = parseCookies(headers);
if(!parsedCookies || !parsedCookies[cookieName]){
let targetPool = choosePool(); //pass a Number as argument to change the chance that user is assigned to Pool 'a' or 'b'
headers['cookie'] = [{ key: 'cookie', value: `${cookieName}=${targetPool}`}]
}
callback(null, request);
};
オリジンリクエストの Lambda@edge 関数
'use strict';
// the S3 origins that correspond to content for Pool A and Pool B
const origins = require('./origins_config.js');
const parseCookies = require('./common.js').parseCookies;
// the `pool` cookie determines which origin to route to
const cookieName = 'pool';
// changes request origin depending on value of the `pool` cookie
exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
const requestOrigin = request.origin.s3;
const parsedCookies = parseCookies(headers);
let targetPool = parsedCookies[cookieName];
let s3Origin = `${origins[targetPool]}.s3.us-east-1.amazonaws.com`;
requestOrigin.region = 'us-east-1';
requestOrigin.domainName = s3Origin;
headers['host'] = [{ key: 'host', value: s3Origin }];
callback(null, request);
};
オリジンレスポンスの Lambda@edge 関数
'use strict';
// the S3 origins that correspond to content for Pool A and Pool B
const origins = require('./origins_config.js');
//returns a set-cookie header based on where the content was served from
exports.handler = (event, context, callback) => {
const response = event.Records[0].cf.response; //response from the origin
const reqHeaders = event.Records[0].cf.request; //request from cloudfront
let poolorigin = 'a'; //default origin pool
if(reqHeaders.origin.s3.domainName.indexOf(origins.b) === 0){
poolorigin = 'b';
}
response.headers['Set-Cookie'] = [{key:'Set-Cookie', value: `pool=${poolorigin}`}];
callback(null, response);
};
詳細はクラスメソッドさんの以下の記事、並びにAWS Summit Onlin
のハンズオンが非常にわかりやすいので是非ご覧ください。
-
メリット:
エンドユーザーに近い箇所で実現されるため、キャッシュを利用すれば低レイテンシを実現することが可能。リクエストやレスポンスのヘッダー、URL、クエリストリングなど、多様なデータに基づいてカスタムの振り分けロジックを実装でき、オリジン側と処理を切り分けることが可能。特に、グローバルにサービスを展開している場合や、低遅延を要求されるアプリケーションにはメリットが高い。 -
デメリット:
一番のネックはコスト。高トラフィックのサイトでの利用は、コストが高くなる可能性がある。特に通常のLamdaと異なり、無料枠もない。また、エッジで実行される特性上、エラーハンドリングが煩雑になる可能性がある。さらに、Lamda関数
にはリージョン制約も存在する。 -
A/Bテスト計測方法:
A/Bテストの成果計測に関しては、datalayer
を使ってGoogleAnalytics
等へ送信することが考えられます。
②ALBの前段で実現(VPC内に配置)
前段ではVPC外・エッジサイドでのLamda実行でしたが、ここではVPC内でのLamda実行という形になります。全体の処理内容はほぼ同様です。
ただし、ライフサイクルに違いがある点が注意です。Lamda@Edge
の場合は、ライフサイクルがViewer Request
/Origin Request
/Origin Response
/Viewer Response
と4つに分かれているため、デプロイするLamda
関数も4つ必要になります。
一方、API Gateway
とLambda
の組み合わせでは、API Gateway
が入力リクエストを受け取り、そのリクエストをLambda
関数にトリガーとして渡します。そして、Lambda
関数は処理を行い、結果をレスポンスとして返します。このプロセスは、1つのリクエスト-レスポンスサイクルの中で行われます。
-
メリット:
Lamda@Edge
と比較し、一定の無料枠が提供されるため安価(月に100万リクエストまで無料)になります。また、ログは単一のリージョンのCloudWatch Logsに集約されるため、デバッグや問題の特定が比較的容易です。さまざまなツールやプラグインを使用して、開発環境でのテストやデバッグを効率的に行うことができる点もメリットになります。 -
デメリット:
エッジ側で実行されるLamda@Edge
と比較するとパフォーマンスは落ちます。実行の度に従量課金が発生するため、高トラフィックの場合はコストが高くなる可能性が高いです。 -
A/Bテスト計測方法:
A/Bテストの成果計測に関しては、上記同様にdatalayer
を使ってGoogleAnalytics
等へ送信することが考えられます。
UUIDやテストパターンの保持
ご覧になられた通り、上記のアーキテクチャ図には、読み書きが高速なMemCached
やDynamoDB
を配置しています。複雑なテストパターンを実現する場合は、UUID等をクライアントのCookie
やlocalStorage
に保存、同DBにテストパターンを保管することでオリジンへの出し分けを実現します。
③ALB単独で実現する(VPC内に配置)
今回ご紹介する例の中で一番シンプルかつ、簡単に設定できる例になります。AWSのApplication Load Balancer
には紐付けたターゲットグループに対して、どの割合でトラフィックを割り当てるのかを、listener Rule
を使って設定することができる、加重ターゲットグループ
という機能が存在します。
-
メリット:
追加のコンポーネントやサービスを導入することなく、ALBの機能のみでA/Bテストを実施することができます。また、ALBの機能を使用するため、追加のコストは発生しません。 -
デメリット:
加重ルーティングルールの範囲内でのみトラフィックの振り分けを制御できるため、詳細な条件やユーザーのセグメンテーションなどの高度なルーティングを行うことが難しくなります。加えて、ALBのスティッキーセッション
機能を使用しても、特定のユーザーが常に同じバージョンにアクセスすることを保証するのは難しい場合があります。 -
A/Bテスト計測方法:
A/Bテストの成果計測に関しては、上記同様にdatalayer
を使ってGoogleAnalytics
等へ送信することが考えられます。
④Cloud Watch Evidentlyで実現(アプリケーション内に組み込む)
CloudWatch Evidently
は主に2つの機能から構成されています。機能フラグ(Feature Flag)とA/Bテストです。機能フラグ(Feature Flag)は、コードをデプロイせずに機能を有効または無効にすることで、システムの振る舞いを変更できる開発手法です。アプリケーション側にAWS SDK
を用いてCloudWatch Evidently
から送信される値(ex.boolean
)を受け取り、その値によってコンテンツを切り替える実装を行います。
Amplify×Cognitoを使ったCloudWatch Evidentlyのサンプルコード
// モジュールとライブラリのインポート
import logo from './logo.svg';
import './App.css';
import Amplify from "aws-amplify";
import awsExports from "./aws-exports";
import {withAuthenticator} from '@aws-amplify/ui-react'
import Evidently from "aws-sdk/clients/evidently";
import {Auth} from "@aws-amplify/auth";
import {useEffect, useState} from "react";
// AWS Amplifyの設定をロード
Amplify.configure(awsExports);
// Evidentlyを使用してフィーチャーの評価を取得する非同期関数
async function getEvaluateFeature(){
// 現在のユーザーのクレデンシャルを取得
const credentials = await Auth.currentCredentials();
// 現在のユーザーの情報を取得
const userinfo = await Auth.currentUserInfo();
console.log(userinfo)
// Evidentlyクライアントの初期化
const evidently = new Evidently({
endpoint: 'https://evidently.ap-northeast-1.amazonaws.com',
credentials: credentials,
region: 'ap-northeast-1'
});
// APIリクエストの構造を定義
const evaluateFeatureRequest = {
entityId: userinfo.username, // entityIdをユーザー名に設定
feature: 'example-feature-1', // フィーチャー名
project: "Evidently-Test-Project", // プロジェクト名
};
// evaluateFeature APIを呼び出してプロミスを返す
return evidently.evaluateFeature(evaluateFeatureRequest).promise();
}
function App() {
// フィーチャーのバリエーションをstateとして持つ
const [variation, setVariation] = useState();
// コンポーネントがマウントされた際に実行されるuseEffect
useEffect(() => {
(async() =>{
const res = await getEvaluateFeature();
console.log(res)
console.log(res.value)
setVariation(res.value.boolValue) // バリエーションの値をstateにセット
})()
},[])
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
{ variation && // variationがtrueの場合のみ表示
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
}
</header>
</div>
);
}
// withAuthenticatorを使用して認証フローを追加
export default withAuthenticator(App)
A/Bテストは、通常パターンAに対するCVR(コンバージョン率)とCTR(クリック率)をパターンBに対してテストし、2つのうちどちらがより効果的であるかを判断することにより、単一の機能の複数のバージョンを比較できます。CloudWatch Evidently
ではカスタムメトリクスやCloudWatch RUM
と連携することが出るため、機能フラグによる効果を確認することができます。
Evidently のPutProjectEvents API を使用してメトリクスの結果を Evidently に送信するサンプルコード
// getPageLoadTime関数を定義します。この関数はページの読み込み時間を計算し、それをEvidentlyプロジェクトへと送信します。
const getPageLoadTime = () => {
// 現在の時間とstartTimeの差を取得して、微小な乗数をかけてtimeSpentを計算
const timeSpent = (new Date().getTime() - startTime.getTime()) * 1.000001;
// pageLoadTimeDataをJSON形式の文字列で構築
// このデータにはページの読み込み時間とユーザー詳細が含まれる
const pageLoadTimeData = `{
"details": {
"pageLoadTime": ${timeSpent}
},
"UserDetails": { "userId": "${id}", "sessionId": "${id}"}
}`;
// putProjectEventsRequestを構築
// このリクエストデータは、Evidentlyのプロジェクトにイベントを送信するためのもの
const putProjectEventsRequest = {
project: 'EvidentlySampleApp',
events: [
{
timestamp: new Date(), // イベントのタイムスタンプ
type: 'aws.evidently.custom', // イベントのタイプ
data: JSON.parse(pageLoadTimeData) // 上記で構築したデータをJSONとして解析
},
],
};
// イベントデータをEvidentlyプロジェクトに送信
client.putProjectEvents(putProjectEventsRequest).promise();
}
-
メリット:
AWS上のリソースでコンテンツの切り替えから計測まで一貫で実施できること。カナリアリリースなどの機能フラグを活用することもできる。 -
デメリット:
Google Analytics
との連携はできない。同ツールと比較するとまだまだ、CloudWatch
,CloudWatch RUM
は計測には機能的にまだまだ不十分な点が多い。 -
A/Bテスト計測方法:
CloudWatch Evidently
上でカスタムメトリクスを使って計測を行う。
⑤独自構築
最後は独自構築を行うケースです。EC2
上のNginx
をリバースプロキシサーバーとしてトラフィックを分散させる方法や、アクセスログをNginx
経由でRedshift
などに転送・分析するケースなど以下の企業様では実践されているようでした。また、アプリケーション内でmiddlewareパターンを実装し、アプリケーション内で処理の出しわけを切り替えるといった例もあります。詳細は割愛しますが、以下参考になりそうな記事を紹介させて頂きます。