AWSを使ったA/Bテスト基盤を独自構築する方法を考えてみた

# はじめに

[Google Optimizeのサービス終了](https://support.google.com/optimize/answer/12979939?hl=ja)というニュースが飛び込んできたり、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テスト基盤構築アーキテクチャ案

![名称未設定ファイル-AWS A_Bテスト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/cad8dd02-3954-60ac-f5f8-6158cf848bea.png)

上記が全体像になります。本記事では合計5つのサンプルをご紹介します。なお、ここで紹介する内容は、あくまでも参考であるため、実際はビジネスサイドの要件や機能・非機能要件によって様々な追加リソースが必要になることを留意ください。

## ①エッジサイドで実現（VPC外に配置）

![名称未設定ファイル-AWS A_Bテスト (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/ae2a502d-ef22-1652-8665-81a60f705585.png)

まず一つ目は、AWSのCDNサービスである`CloudFront`上で利用することができるエッジコンピューティング`Lamda@Edge`を用いて実現する案です。`Lamda@Edge`は、`CloudFront`のキャッシュ前後のリクエスト・レスポンス、合計4箇所の処理に割り込むことができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/507c9dc2-27fe-0b8b-2d18-b55188ad997e.png)

この設計では、トラフィックの振り分け処理をアプリケーション外、かつよりエンドユーザーに近い位置で実現します。例えば、以下のような処理です。

- `Viewer Request`で、リクエストヘッダーにA/Bテスト用のCookieを設定
- `Origin Request`で、リクエストヘッダーのCookieを利用してユーザーをA/Bオリジンのどちらかに一貫して振り分ける
- `Origin Response`で、ブラウザ側に`Set-Cookie`を行う

<details><summary>ビューアリクエストのLambda@edge関数</summary><div>

```js:
'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);
};
```

</div></details>

<details><summary>オリジンリクエストの Lambda@edge 関数</summary><div>

```js:
'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);
};
```

</div></details>

<details><summary>オリジンレスポンスの Lambda@edge 関数</summary><div>

```js:
'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);
};
```

</div></details>

詳細はクラスメソッドさんの以下の記事、並びに`AWS Summit Onlin`のハンズオンが非常にわかりやすいので是非ご覧ください。

https://dev.classmethod.jp/articles/a-b-testing-with-cloudfront-and-lambda-at-edge/

https://github.com/justasitsounds/lambda-edge-lab

- **メリット**:
エンドユーザーに近い箇所で実現されるため、キャッシュを利用すれば低レイテンシを実現することが可能。リクエストやレスポンスのヘッダー、URL、クエリストリングなど、多様なデータに基づいてカスタムの振り分けロジックを実装でき、オリジン側と処理を切り分けることが可能。特に、グローバルにサービスを展開している場合や、低遅延を要求されるアプリケーションにはメリットが高い。

- **デメリット**:
一番のネックはコスト。高トラフィックのサイトでの利用は、コストが高くなる可能性がある。特に通常のLamdaと異なり、無料枠もない。また、エッジで実行される特性上、エラーハンドリングが煩雑になる可能性がある。さらに、`Lamda関数`にはリージョン制約も存在する。

- **A/Bテスト計測方法**:
A/Bテストの成果計測に関しては、`datalayer`を使って`GoogleAnalytics`等へ送信することが考えられます。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions-restrictions.html

## ②ALBの前段で実現（VPC内に配置）

![名称未設定ファイル-AWS A_Bテスト (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/6acf56c3-310f-10e3-9d85-ed0c52361912.png)

前段ではVPC外・エッジサイドでのLamda実行でしたが、ここではVPC内でのLamda実行という形になります。全体の処理内容はほぼ同様です。

ただし、ライフサイクルに違いがある点が注意です。`Lamda@Edge`の場合は、ライフサイクルが`Viewer Request`/`Origin Request`/`Origin Response`/`Viewer Response`と４つに分かれているため、デプロイする`Lamda`関数も4つ必要になります。

一方、`API Gateway`と`Lambda`の組み合わせでは、`API Gateway`が入力リクエストを受け取り、そのリクエストを`Lambda`関数にトリガーとして渡します。そして、`Lambda`関数は処理を行い、結果をレスポンスとして返します。このプロセスは、1つのリクエスト-レスポンスサイクルの中で行われます。

- **メリット**:
`Lamda@Edge`と比較し、一定の無料枠が提供されるため安価（月に100万リクエストまで無料）になります。また、ログは単一のリージョンのCloudWatch Logsに集約されるため、デバッグや問題の特定が比較的容易です。さまざまなツールやプラグインを使用して、開発環境でのテストやデバッグを効率的に行うことができる点もメリットになります。

- **デメリット**:
エッジ側で実行される`Lamda@Edge`と比較するとパフォーマンスは落ちます。実行の度に従量課金が発生するため、高トラフィックの場合はコストが高くなる可能性が高いです。

- **A/Bテスト計測方法**:
A/Bテストの成果計測に関しては、上記同様に`datalayer`を使って`GoogleAnalytics`等へ送信することが考えられます。

:::note info
UUIDやテストパターンの保持
ご覧になられた通り、上記のアーキテクチャ図には、読み書きが高速な`MemCached`や`DynamoDB`を配置しています。複雑なテストパターンを実現する場合は、UUID等をクライアントの`Cookie`や`localStorage`に保存、同DBにテストパターンを保管することでオリジンへの出し分けを実現します。
:::

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_policies_examples_lambda-access-dynamodb.html

## ③ALB単独で実現する（VPC内に配置）

![名称未設定ファイル-AWS A_Bテスト (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/6d2aaa76-5cc9-2034-9b33-388949f3c80a.png)

今回ご紹介する例の中で一番シンプルかつ、簡単に設定できる例になります。AWSの`Application Load Balancer`には紐付けたターゲットグループに対して、どの割合でトラフィックを割り当てるのかを、`listener　Rule`を使って設定することができる、`加重ターゲットグループ`という機能が存在します。

![2019-10-06_17-27-58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/89f95744-691b-e036-7de1-85c87044d668.png)

https://repost.aws/ja/knowledge-center/elb-make-weighted-target-groups-for-alb

- **メリット**:
追加のコンポーネントやサービスを導入することなく、ALBの機能のみでA/Bテストを実施することができます。また、ALBの機能を使用するため、追加のコストは発生しません。

- **デメリット**:
加重ルーティングルールの範囲内でのみトラフィックの振り分けを制御できるため、詳細な条件やユーザーのセグメンテーションなどの高度なルーティングを行うことが難しくなります。加えて、ALBの`スティッキーセッション`機能を使用しても、特定のユーザーが常に同じバージョンにアクセスすることを保証するのは難しい場合があります。

- **A/Bテスト計測方法**:
A/Bテストの成果計測に関しては、上記同様に`datalayer`を使って`GoogleAnalytics`等へ送信することが考えられます。

## ④Cloud Watch Evidentlyで実現(アプリケーション内に組み込む)

![名称未設定ファイル-AWS A_Bテスト (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/f160102e-578b-01d8-de93-ffe4d4b41e47.png)

`CloudWatch Evidently`は主に2つの機能から構成されています。機能フラグ（Feature Flag）とA/Bテストです。機能フラグ（Feature Flag）は、コードをデプロイせずに機能を有効または無効にすることで、システムの振る舞いを変更できる開発手法です。アプリケーション側に`AWS　SDK`を用いて`CloudWatch Evidently`から送信される値(ex.`boolean`)を受け取り、その値によってコンテンツを切り替える実装を行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/2fc1ef9a-49e1-88ea-acc1-92d984d1fa70.png)

<details><summary>Amplify×Cognitoを使ったCloudWatch Evidentlyのサンプルコード</summary><div>

```jsx:
// モジュールとライブラリのインポート
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)

```

</div></details>



A/Bテストは、通常パターンAに対するCVR（コンバージョン率）とCTR（クリック率)をパターンBに対してテストし、2つのうちどちらがより効果的であるかを判断することにより、単一の機能の複数のバージョンを比較できます。`CloudWatch Evidently`ではカスタムメトリクスや`CloudWatch RUM`と連携することが出るため、機能フラグによる効果を確認することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/8aecae4a-149e-360f-101d-c58f02c51e9d.png)

<details><summary>Evidently のPutProjectEvents API を使用してメトリクスの結果を Evidently に送信するサンプルコード</summary><div>

```js:
// 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();
}
```

</div></details>

- **メリット**:
AWS上のリソースでコンテンツの切り替えから計測まで一貫で実施できること。カナリアリリースなどの機能フラグを活用することもできる。

- **デメリット**:
`Google Analytics`との連携はできない。同ツールと比較するとまだまだ、`CloudWatch`, `CloudWatch RUM`は計測には機能的にまだまだ不十分な点が多い。

- **A/Bテスト計測方法**:
`CloudWatch Evidently`上でカスタムメトリクスを使って計測を行う。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch-Evidently-sample-application.html

https://aws.amazon.com/jp/blogs/news/cloudwatch-evidently/

## ⑤独自構築

![名称未設定ファイル-AWS A_Bテスト (5).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/70425e8d-f1d1-656c-6918-e10658d0454b.png)

最後は独自構築を行うケースです。`EC2`上の`Nginx`をリバースプロキシサーバーとしてトラフィックを分散させる方法や、アクセスログを`Nginx`経由で`Redshift`などに転送・分析するケースなど以下の企業様では実践されているようでした。また、アプリケーション内でmiddlewareパターンを実装し、アプリケーション内で処理の出しわけを切り替えるといった例もあります。詳細は割愛しますが、以下参考になりそうな記事を紹介させて頂きます。

https://www.slideshare.net/RecruitLifestyle/ngxmrubyab

https://tech.macloud.jp/entry/2020/10/29/122304

https://tech.uzabase.com/entry/2022/12/25/183334