T-CREATOR

Nginx split_clients で A/B テスト:粘着性と計測を両立するルーティング設計

Nginx split_clients で A/B テスト:粘着性と計測を両立するルーティング設計

Web サービスの改善には A/B テストが欠かせません。新しいデザインや機能を段階的に導入し、ユーザーの反応を計測することで、データに基づいた意思決定が可能になります。

Nginx の split_clients モジュールを使えば、アプリケーションコードを変更することなく、リバースプロキシ層で A/B テストを実現できるのです。しかも、ユーザーごとに一貫した体験を提供する「粘着性」と、正確な「計測」を両立できます。

本記事では、Nginx の split_clients を使った実践的な A/B テストの設計方法を、コード例とともに詳しく解説していきます。

背景

A/B テストとリバースプロキシ層での実装

A/B テストは、ユーザーを複数のグループに分割し、それぞれ異なるバージョンを提供することで、どちらがより効果的かを検証する手法です。

従来、A/B テストはアプリケーション層で実装されることが多く、コードの変更やデプロイが必要でした。しかし、リバースプロキシ層で実装することで、以下のメリットが得られます。

  • アプリケーションコードの変更が不要
  • 複数のバックエンドサーバーへの振り分けが容易
  • インフラレベルでの制御が可能
  • デプロイとは独立してテストの開始・終了が可能

Nginx の split_clients モジュール

Nginx には標準で split_clients というモジュールが組み込まれており、指定した変数をハッシュ化して、その値に基づいてトラフィックを分割できます。

以下の図は、split_clients を使った基本的なトラフィック分割の仕組みを示しています。

mermaidflowchart TB
    user["ユーザー"] -->|リクエスト| nginx["Nginx<br/>split_clients"]
    nginx -->|ハッシュ計算| hash["ハッシュ値<br/>0-100%"]
    hash -->|0-50%| backend_a["バックエンド A<br/>(既存版)"]
    hash -->|51-100%| backend_b["バックエンド B<br/>(新版)"]
    backend_a -->|レスポンス| user
    backend_b -->|レスポンス| user

このモジュールの特徴は、同じ入力値(例:ユーザー ID や IP アドレス)に対して常に同じハッシュ値を生成することです。これにより、同じユーザーは常に同じバージョンにアクセスする「粘着性」が実現できます。

課題

A/B テストで求められる要件

実用的な A/B テストを実装するには、以下の要件を満たす必要があります。

粘着性(Session Stickiness)

同じユーザーが複数回アクセスした際、毎回同じバージョンを表示する必要があります。バージョンがころころ変わると、ユーザー体験が損なわれ、正確な計測もできません。

公平な分割

トラフィックを指定した比率で正確に分割する必要があります。50:50 のテストであれば、できるだけ半分ずつに分かれることが望ましいでしょう。

計測可能性

どのユーザーがどのバージョンを見たかを記録し、後で分析できる必要があります。Google Analytics などの分析ツールと連携できることも重要です。

柔軟な制御

テストの比率を簡単に変更できたり、特定の条件(地域、デバイスなど)でグループを分けたりできると便利ですね。

以下の図は、A/B テスト実装における課題を示しています。

mermaidflowchart LR
    subgraph challenges["A/B テストの課題"]
        sticky["粘着性<br/>同じユーザーは<br/>同じ版へ"]
        fair["公平な分割<br/>指定比率で<br/>均等に"]
        measure["計測可能性<br/>バージョン識別<br/>分析連携"]
        control["柔軟な制御<br/>比率変更<br/>条件分岐"]
    end

    sticky -.->|両立が<br/>必要| fair
    fair -.->|両立が<br/>必要| measure
    measure -.->|両立が<br/>必要| control

IP アドレスベースの限界

最も単純な実装は、IP アドレスをハッシュ化してグループ分けする方法です。しかし、この方法には以下の問題があります。

  • 同じ Wi-Fi を使う複数のユーザーが同じグループになる
  • モバイルユーザーは IP アドレスが頻繁に変わる可能性がある
  • プロキシや VPN 経由のアクセスで正確性が低下する
  • ユーザー個人を特定できないため、詳細な分析が困難

そのため、Cookie を使ったユーザー識別と組み合わせることが推奨されます。

解決策

split_clients の基本構文

Nginx の split_clients ディレクティブは、指定した文字列をハッシュ化し、その結果に基づいて変数に値を設定します。

基本的な構文は以下の通りです。

nginx# split_clients の基本構文
split_clients "${入力値}" $変数名 {
    割合1 値1;
    割合2 値2;
    *      デフォルト値;
}

ハッシュ値は 0 から 4294967295 までの範囲で生成され、パーセンテージで分割比率を指定できます。

Cookie を使ってユーザーを識別し、同じユーザーには常に同じバージョンを提供する実装を見ていきましょう。

まず、Cookie が存在しない場合は新規に生成し、既存の Cookie がある場合はその値を使用します。

nginx# Cookie の取得または生成
map $cookie_ab_test_id $ab_user_id {
    # Cookie が存在する場合はその値を使用
    default $cookie_ab_test_id;
    # Cookie が存在しない場合はランダム ID を生成
    "" $request_id;
}

次に、このユーザー ID を使って split_clients でグループ分けします。

nginx# ユーザー ID をハッシュ化してグループ分け
split_clients "${ab_user_id}" $ab_variant {
    50% "A";  # 50% のユーザーをグループ A に
    50% "B";  # 50% のユーザーをグループ B に
}

この設定により、同じ ab_user_id を持つユーザーは常に同じグループに割り当てられます。

バックエンドへのルーティング

グループに応じて異なるバックエンドサーバーへリクエストを振り分けます。

nginx# グループに応じたバックエンドの選択
map $ab_variant $backend {
    "A" "backend_a";
    "B" "backend_b";
    default "backend_a";
}

upstream ブロックで各バックエンドを定義します。

nginx# バックエンド A(既存版)の定義
upstream backend_a {
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
}

# バックエンド B(新版)の定義
upstream backend_b {
    server 10.0.2.10:8080;
    server 10.0.2.11:8080;
}

ユーザーのブラウザに Cookie を保存し、かつ計測用のヘッダーも追加します。

nginx# レスポンスヘッダーの追加
location / {
    # Cookie の設定(有効期限 30 日)
    add_header Set-Cookie "ab_test_id=$ab_user_id; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax" always;

    # 計測用のカスタムヘッダー
    add_header X-AB-Variant $ab_variant always;

    # バックエンドへのプロキシ
    proxy_pass http://$backend;
}

HttpOnly フラグにより JavaScript からの Cookie アクセスを防ぎ、Secure フラグで HTTPS 通信時のみ送信するようにしています。

以下の図は、Cookie ベースの粘着性を実現する全体的なフローを示しています。

mermaidsequenceDiagram
    participant User as ユーザー
    participant Nginx as Nginx
    participant Backend_A as バックエンド A
    participant Backend_B as バックエンド B

    User->>Nginx: 初回リクエスト<br/>(Cookie なし)
    Nginx->>Nginx: request_id 生成<br/>→ ab_user_id
    Nginx->>Nginx: split_clients で<br/>グループ判定
    alt グループ A
        Nginx->>Backend_A: リクエスト転送
        Backend_A->>Nginx: レスポンス
    else グループ B
        Nginx->>Backend_B: リクエスト転送
        Backend_B->>Nginx: レスポンス
    end
    Nginx->>User: レスポンス<br/>+ Cookie 設定<br/>+ X-AB-Variant ヘッダー

    User->>Nginx: 2回目以降<br/>(Cookie あり)
    Nginx->>Nginx: Cookie から<br/>ab_user_id 取得
    Nginx->>Nginx: 同じグループに<br/>割り当て(粘着性)

具体例

完全な設定ファイル

実際に動作する Nginx の設定ファイルを段階的に見ていきましょう。

まず、http コンテキストでの基本設定から始めます。

nginx# http コンテキスト
http {
    # ログフォーマットに AB テスト情報を追加
    log_format ab_test '$remote_addr - $remote_user [$time_local] '
                       '"$request" $status $body_bytes_sent '
                       '"$http_referer" "$http_user_agent" '
                       'ab_id=$ab_user_id ab_variant=$ab_variant';

    # アクセスログの設定
    access_log /var/log/nginx/access.log ab_test;

Cookie からユーザー ID を取得または生成する map ディレクティブを設定します。

nginx    # Cookie が存在しない場合は request_id を使用
    map $cookie_ab_test_id $ab_user_id {
        default $cookie_ab_test_id;
        ""      $request_id;
    }

ユーザー ID をハッシュ化してグループ分けを行います。

nginx    # 50:50 で A/B テストグループに分割
    split_clients "${ab_user_id}" $ab_variant {
        50% "A";
        50% "B";
    }

グループに応じたバックエンドサーバーを選択します。

nginx    # グループに応じたバックエンドの選択
    map $ab_variant $backend {
        "A" "backend_a";
        "B" "backend_b";
        default "backend_a";
    }

バックエンドサーバーの定義を行います。

nginx    # バックエンドサーバーの定義
    upstream backend_a {
        server 10.0.1.10:8080 max_fails=3 fail_timeout=30s;
        server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
        keepalive 32;
    }

    upstream backend_b {
        server 10.0.2.10:8080 max_fails=3 fail_timeout=30s;
        server 10.0.2.11:8080 max_fails=3 fail_timeout=30s;
        keepalive 32;
    }

server コンテキストでリクエストを処理します。

nginx    server {
        listen 80;
        server_name example.com;

        # HTTP から HTTPS へリダイレクト
        return 301 https://$server_name$request_uri;
    }

    server {
        listen 443 ssl http2;
        server_name example.com;

        # SSL 証明書の設定
        ssl_certificate /etc/nginx/ssl/example.com.crt;
        ssl_certificate_key /etc/nginx/ssl/example.com.key;

メインの location ブロックで Cookie とヘッダーを設定します。

nginx        location / {
            # Cookie の設定(30日間有効)
            add_header Set-Cookie "ab_test_id=$ab_user_id; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax" always;

            # 計測用ヘッダーの追加
            add_header X-AB-Variant $ab_variant always;
            add_header X-AB-User-ID $ab_user_id always;

            # バックエンドへのプロキシ設定
            proxy_pass http://$backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            # AB テスト情報をバックエンドに転送
            proxy_set_header X-AB-Variant $ab_variant;
            proxy_set_header X-AB-User-ID $ab_user_id;
        }
    }
}

比率の変更方法

テストの進行に応じて、グループの比率を変更したい場合があります。例えば、新バージョンを 10% のユーザーにのみ公開し、徐々に増やしていく段階的ロールアウトが可能です。

nginx# 90:10 の分割(カナリアリリース)
split_clients "${ab_user_id}" $ab_variant {
    90% "A";  # 既存版に 90%
    10% "B";  # 新版に 10%
}

問題がなければ、徐々に新バージョンの比率を増やしていきます。

nginx# 70:30 の分割
split_clients "${ab_user_id}" $ab_variant {
    70% "A";
    30% "B";
}

# 50:50 の分割
split_clients "${ab_user_id}" $ab_variant {
    50% "A";
    50% "B";
}

# 最終的に 100% 新版へ
split_clients "${ab_user_id}" $ab_variant {
    0%   "A";
    100% "B";
}

比率を変更する際は、Nginx の設定をリロードするだけで済みます。

bash# 設定ファイルの構文チェック
nginx -t

# 設定のリロード(ダウンタイムなし)
nginx -s reload

複数条件での分割

デバイスタイプや地域など、複数の条件を組み合わせた分割も可能です。

まず、デバイスタイプを判定します。

nginx# デバイスタイプの判定
map $http_user_agent $device_type {
    default "desktop";
    ~*mobile "mobile";
    ~*tablet "tablet";
}

デバイスタイプとユーザー ID を組み合わせてハッシュ化します。

nginx# デバイスタイプごとに異なる分割
split_clients "${ab_user_id}${device_type}" $ab_variant {
    50% "A";
    50% "B";
}

この設定により、同じユーザーでもデバイスが変わると異なるグループに割り当てられる可能性があります。デバイスごとに一貫性を保ちたい場合に有効です。

Google Analytics との連携

A/B テストの結果を Google Analytics で計測するには、カスタムディメンションを使用します。

HTML テンプレートに以下のようなスクリプトを追加します。

javascript// Google Analytics のトラッキングコード
gtag('config', 'GA_MEASUREMENT_ID', {
  custom_map: {
    dimension1: 'ab_variant',
  },
});

// レスポンスヘッダーから AB バリアント情報を取得
// バックエンドで HTML に埋め込む必要があります
gtag('event', 'ab_test_view', {
  ab_variant: '{{ ab_variant }}', // サーバーサイドで埋め込み
});

または、JavaScript で直接ヘッダーを読み取る方法もあります(ただし、セキュリティ上の理由で制限される場合があります)。

javascript// Fetch API でヘッダーを取得する例
fetch(window.location.href).then((response) => {
  const variant = response.headers.get('X-AB-Variant');
  if (variant) {
    gtag('event', 'ab_test_view', {
      ab_variant: variant,
    });
  }
});

より簡単な方法として、バックエンドで HTML に直接 data-* 属性として埋め込むことをお勧めします。

html<!-- バックエンドで生成する HTML -->
<body data-ab-variant="A">
  <!-- コンテンツ -->
</body>

<script>
  // data 属性から取得
  const variant = document.body.dataset.abVariant;
  gtag('event', 'ab_test_view', {
    ab_variant: variant,
  });
</script>

エラー処理とフォールバック

A/B テスト実装時は、エラー処理も重要です。片方のバックエンドが停止した場合の対処方法を見ていきましょう。

nginx# バックエンド B が利用できない場合のフォールバック
location / {
    # エラー時のフォールバック設定
    error_page 502 503 504 = @fallback;

    # Cookie とヘッダーの設定
    add_header Set-Cookie "ab_test_id=$ab_user_id; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax" always;
    add_header X-AB-Variant $ab_variant always;

    # プロキシ設定
    proxy_pass http://$backend;
    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_connect_timeout 5s;
    proxy_send_timeout 10s;
    proxy_read_timeout 10s;
}

フォールバック用の location ブロックを定義します。

nginx# フォールバック処理
location @fallback {
    # バックエンド A に固定でフォールバック
    proxy_pass http://backend_a;

    # フォールバックしたことを示すヘッダー
    add_header X-AB-Fallback "true" always;
    add_header X-AB-Original-Variant $ab_variant always;
}

この設定により、以下のエラーシナリオに対応できます。

エラー 502: Bad Gateway

バックエンドサーバーが無効な応答を返した場合に、自動的にフォールバックが実行されます。

エラー 503: Service Unavailable

バックエンドサーバーが一時的に利用できない場合、ユーザーはエラーページを見ることなく代替バックエンドにアクセスできます。

エラー 504: Gateway Timeout

バックエンドサーバーからの応答がタイムアウトした場合も、同様にフォールバック処理が行われます。

エラーログには詳細な情報を記録して、問題の診断を容易にします。

nginx# エラーログの詳細設定
error_log /var/log/nginx/error.log warn;

# ログレベルを debug に変更して詳細な情報を取得
# error_log /var/log/nginx/error.log debug;

設定のテスト方法

Nginx の設定が正しく動作するか、実際にテストしてみましょう。

まず、構文チェックを実行します。

bash# 構文チェック
nginx -t

エラーがなければ、以下のような出力が表示されます。

swiftnginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

curl コマンドで動作確認を行います。

bash# Cookie なしでリクエスト(新規ユーザーをシミュレート)
curl -i https://example.com/

# レスポンスヘッダーを確認
# Set-Cookie: ab_test_id=...
# X-AB-Variant: A または B

取得した Cookie を使って再度リクエストし、粘着性を確認します。

bash# 取得した Cookie を使って再リクエスト
curl -i -H "Cookie: ab_test_id=..." https://example.com/

# 同じバリアント(A または B)が返されることを確認

複数回テストして、グループが固定されることを確認しましょう。

まとめ

Nginx の split_clients モジュールを使うことで、アプリケーションコードを変更せずに A/B テストを実装できます。本記事では、以下の重要なポイントを解説しました。

Cookie ベースの粘着性

ユーザー ID を Cookie に保存することで、同じユーザーには常に同じバージョンを提供できます。これにより一貫したユーザー体験が実現され、正確な計測も可能になります。

柔軟なトラフィック分割

split_clients のパーセンテージ指定により、50:50 だけでなく 90:10 などの任意の比率でトラフィックを分割できます。段階的なロールアウトにも対応できるでしょう。

計測との連携

カスタムヘッダーやログフォーマットの工夫により、Google Analytics などの分析ツールと連携できます。バックエンドにも AB テスト情報を転送することで、詳細な分析が可能です。

堅牢なエラー処理

フォールバック機能により、片方のバックエンドに問題が発生してもサービスを継続できます。ユーザーにエラーを見せることなく、安定した運用が実現できますね。

運用の容易さ

設定ファイルの変更とリロードだけで、比率の変更やテストの開始・終了ができます。デプロイとは独立して制御できるため、迅速な意思決定が可能です。

Nginx の split_clients を活用することで、インフラレベルで制御可能な、柔軟で堅牢な A/B テストシステムを構築できます。ぜひ、あなたのサービスでも試してみてください。

関連リンク