AWS CDKでCloudFront Functionを使ったA/Bテストシステムを構築する


AWS CDK で CloudFront Function を使った A/B テストシステムを構築する

こちらは、 Building an A/B Testing System with CloudFront Functions using AWS CDK の和訳です。

前提

なぜこれが必要か

Amazon CloudFront + Amazon S3 は、 AWS でフロントエンドコンテンツを配信する上で定番の構成です。 また、Web サービスの改善において、A/B テストは欠かせない手法です。

しかし、従来の構成で A/B テストを実現するためにはフロントエンドのコード、または API への実装が必要になります。 アプリケーションロジックに対して手を入れることになるため、複雑性を高めてしまいます。

2025 年 4 月に、 CloudFront Functions でオリジンの切り替えを可能とするアップデートがありました。

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/WhatsNew.html https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/helper-functions-origin-modification.html#select-request-origin-id-helper-function

今回は CloudFront Functions で selectRequestOriginById() メソッドを使うことにより、簡単に A/B テストとして S3 バケットを使い分ける流れを確認します。

CloudFront Function を使うメリット

  • エッジでの高速処理: ユーザーに最も近いロケーションで瞬時に判定
  • シンプルな実装: JavaScript で簡潔に記述でき、デプロイも容易
  • インフラとして分離: アプリケーションコードと A/B テストロジックを分離し、保守性向上

これより、パフォーマンスを犠牲にすることなく効率的な A/B テストが実現できます。

必要な知識

  • AWS CDK(Cloud Development Kit)の基本的な使い方
  • TypeScript/JavaScript の基礎知識
  • AWS CloudFront と S3 の基本概念
  • A/B テストの概念

必要な環境

  • Node.js(v18 以上推奨)
  • AWS CLI 設定済み
  • AWS CDK CLI(npm install -g aws-cdk
  • TypeScript 環境

使用する AWS サービス

  • Amazon S3: 静的ウェブサイトのホスティング(2 つのバケット)
  • Amazon CloudFront: CDN としての配信と A/B テストロジック
  • CloudFront Function: リクエストルーティングの制御
  • Origin Access Control (OAC): S3 バケットへの安全なアクセス制御

準備

1. プロジェクトの初期化

# CDKプロジェクトの作成
mkdir cloudfront-ab-testing
cd cloudfront-ab-testing
cdk init app --language typescript

2. プロジェクト構造の準備

cloudfront-ab-testing/
├── lib/
│   ├── cloudfront-ab-testing.ts      # メインスタック
│   └── ab-test-function.js           # CloudFront Function
├── assets/
│   ├── site-a/
│   │   └── index.html               # バリアントA
│   └── site-b/
│       └── index.html               # バリアントB
└── package.json

手順

1. CloudFront Function の実装

A/B テストのロジックを担う CloudFront Function を作成します:

// lib/ab-test-function.js
import cf from "cloudfront";

function handler(event) {
  var request = event.request;
  var headers = request.headers;

  // X-AB-Testヘッダーをチェック
  if (headers["x-ab-test"] && headers["x-ab-test"].value === "variant-b") {
    // オリジンBを選択
    cf.selectRequestOriginById("BUCKET_B_ORIGIN_ID");
  } else {
    // デフォルトでオリジンAを選択(variant-aまたはヘッダーなし)
    cf.selectRequestOriginById("BUCKET_A_ORIGIN_ID");
  }

  return request;
}

2. S3 バケットの作成

2 つの S3 バケットを作成し、それぞれ異なるコンテンツをホストします:

// S3 Bucket A(バリアントA用)
const bucketA = new s3.Bucket(this, "BucketA", {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});

// S3 Bucket B(バリアントB用)
const bucketB = new s3.Bucket(this, "BucketB", {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});

3. Origin Access Control (OAC)の設定

S3 バケットへの安全なアクセスを確保するため OAC を設定:

const originAccessControl = new cloudfront.CfnOriginAccessControl(
  this,
  "OriginAccessControl",
  {
    originAccessControlConfig: {
      name: "ABTestOAC",
      originAccessControlOriginType: "s3",
      signingBehavior: "always",
      signingProtocol: "sigv4",
      description: "OAC for A/B testing CloudFront distribution",
    },
  }
);

4. カスタムキャッシュポリシーの作成

A/B テストヘッダーをキャッシュキーに含めるカスタムポリシー:

const customCachePolicy = new cloudfront.CfnCachePolicy(
  this,
  "ABTestCachePolicy",
  {
    cachePolicyConfig: {
      name: "ABTestCachePolicy",
      comment: "Cache policy that includes x-ab-test header as cache key",
      defaultTtl: 86400, // 1日
      maxTtl: 31536000, // 1年
      minTtl: 0,
      parametersInCacheKeyAndForwardedToOrigin: {
        enableAcceptEncodingGzip: true,
        enableAcceptEncodingBrotli: true,
        headersConfig: {
          headerBehavior: "whitelist",
          headers: ["x-ab-test"],
        },
        queryStringsConfig: {
          queryStringBehavior: "none",
        },
        cookiesConfig: {
          cookieBehavior: "none",
        },
      },
    },
  }
);

5. CloudFront Distribution の設定

複数オリジンと CloudFront Function を組み合わせた配信設定:

const distribution = new cloudfront.CfnDistribution(
  this,
  "CloudFrontDistribution",
  {
    distributionConfig: {
      enabled: true,
      priceClass: "PriceClass_100",
      origins: [
        {
          id: bucketAOriginId,
          domainName: bucketA.bucketDomainName,
          s3OriginConfig: {
            originAccessIdentity: "",
          },
          originAccessControlId: originAccessControl.attrId,
        },
        {
          id: bucketBOriginId,
          domainName: bucketB.bucketDomainName,
          s3OriginConfig: {
            originAccessIdentity: "",
          },
          originAccessControlId: originAccessControl.attrId,
        },
      ],
      defaultCacheBehavior: {
        targetOriginId: bucketAOriginId,
        viewerProtocolPolicy: "redirect-to-https",
        cachePolicyId: customCachePolicy.ref,
        compress: true,
        functionAssociations: [
          {
            eventType: "viewer-request",
            functionArn: abTestFunction.functionArn,
          },
        ],
      },
    },
  }
);

6. S3 バケットポリシーの設定

OAC を使用した CloudFront からのアクセス許可:

const bucketAPolicyStatement = new iam.PolicyStatement({
  actions: ["s3:GetObject"],
  resources: [bucketA.arnForObjects("*")],
  principals: [new iam.ServicePrincipal("cloudfront.amazonaws.com")],
  conditions: {
    StringEquals: {
      "AWS:SourceArn": `arn:aws:cloudfront::${this.account}:distribution/${distribution.ref}`,
    },
  },
});

bucketA.addToResourcePolicy(bucketAPolicyStatement);

7. コンテンツのデプロイ

S3 バケットへの静的ファイルのデプロイ:

// サイトAのファイルをBucketAにアップロード
new s3deploy.BucketDeployment(this, "DeploySiteA", {
  sources: [s3deploy.Source.asset("./assets/site-a")],
  destinationBucket: bucketA,
});

// サイトBのファイルをBucketBにアップロード
new s3deploy.BucketDeployment(this, "DeploySiteB", {
  sources: [s3deploy.Source.asset("./assets/site-b")],
  destinationBucket: bucketB,
});

8. 完全なスタック実装

これまでの手順を統合した完全なメインスタックの実装は以下のようになります:

import * as cdk from "aws-cdk-lib/core";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
import * as fs from "fs";
import * as path from "path";

export class CloudfrontAbTestingStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // S3 Bucket A (for variant-a)
    const bucketA = new s3.Bucket(this, "BucketA", {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // S3 Bucket B (for variant-b)
    const bucketB = new s3.Bucket(this, "BucketB", {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // Origin Access Control (OAC) - New recommended approach
    const originAccessControl = new cloudfront.CfnOriginAccessControl(
      this,
      "OriginAccessControl",
      {
        originAccessControlConfig: {
          name: "ABTestOAC",
          originAccessControlOriginType: "s3",
          signingBehavior: "always",
          signingProtocol: "sigv4",
          description: "OAC for A/B testing CloudFront distribution",
        },
      }
    );

    // Define Origin IDs as constants
    const bucketAOriginId = "BucketAOrigin";
    const bucketBOriginId = "BucketBOrigin";

    // CloudFront Function for A/B testing
    // Load Function code from external file
    const functionCodePath = path.join(__dirname, "ab-test-function.js");
    let functionCode = fs.readFileSync(functionCodePath, "utf8");

    // Replace placeholders with actual values
    functionCode = functionCode
      .replaceAll("BUCKET_A_ORIGIN_ID", bucketAOriginId)
      .replaceAll("BUCKET_B_ORIGIN_ID", bucketBOriginId);

    const abTestFunction = new cloudfront.Function(this, "ABTestFunction", {
      code: cloudfront.FunctionCode.fromInline(functionCode),
      comment:
        "A/B testing function to route requests based on X-AB-Test header",
      runtime: cloudfront.FunctionRuntime.JS_2_0, // Specify JavaScript Runtime 2.0
    });

    // Custom cache policy (include x-ab-test header in cache key)
    const customCachePolicy = new cloudfront.CfnCachePolicy(
      this,
      "ABTestCachePolicy",
      {
        cachePolicyConfig: {
          name: "ABTestCachePolicy",
          comment: "Cache policy that includes x-ab-test header as cache key",
          defaultTtl: 86400, // 1 day
          maxTtl: 31536000, // 1 year
          minTtl: 0,
          parametersInCacheKeyAndForwardedToOrigin: {
            enableAcceptEncodingGzip: true,
            enableAcceptEncodingBrotli: true,
            headersConfig: {
              headerBehavior: "whitelist",
              headers: ["x-ab-test"],
            },
            queryStringsConfig: {
              queryStringBehavior: "none",
            },
            cookiesConfig: {
              cookieBehavior: "none",
            },
          },
        },
      }
    );

    // CloudFront Distribution (using low-level API for precise control)
    const distribution = new cloudfront.CfnDistribution(
      this,
      "CloudFrontDistribution",
      {
        distributionConfig: {
          enabled: true,
          priceClass: "PriceClass_100",
          origins: [
            {
              id: bucketAOriginId,
              domainName: bucketA.bucketDomainName,
              s3OriginConfig: {
                originAccessIdentity: "", // Empty string when using OAC
              },
              originAccessControlId: originAccessControl.attrId,
            },
            {
              id: bucketBOriginId,
              domainName: bucketB.bucketDomainName,
              s3OriginConfig: {
                originAccessIdentity: "", // Empty string when using OAC
              },
              originAccessControlId: originAccessControl.attrId,
            },
          ],
          defaultCacheBehavior: {
            targetOriginId: bucketAOriginId,
            viewerProtocolPolicy: "redirect-to-https",
            cachePolicyId: customCachePolicy.ref, // Use custom cache policy
            compress: true,
            functionAssociations: [
              {
                eventType: "viewer-request",
                functionArn: abTestFunction.functionArn,
              },
            ],
          },
        },
      }
    );

    // Bucket A policy (for OAC) - Set after Distribution creation
    const bucketAPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      resources: [bucketA.arnForObjects("*")],
      principals: [new iam.ServicePrincipal("cloudfront.amazonaws.com")],
      conditions: {
        StringEquals: {
          "AWS:SourceArn": `arn:aws:cloudfront::${this.account}:distribution/${distribution.ref}`,
        },
      },
    });

    bucketA.addToResourcePolicy(bucketAPolicyStatement);

    // Bucket B policy (for OAC) - Set after Distribution creation
    const bucketBPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      resources: [bucketB.arnForObjects("*")],
      principals: [new iam.ServicePrincipal("cloudfront.amazonaws.com")],
      conditions: {
        StringEquals: {
          "AWS:SourceArn": `arn:aws:cloudfront::${this.account}:distribution/${distribution.ref}`,
        },
      },
    });

    bucketB.addToResourcePolicy(bucketBPolicyStatement);

    // Deploy HTML files to S3 buckets
    // Upload Site A files to BucketA
    new s3deploy.BucketDeployment(this, "DeploySiteA", {
      sources: [s3deploy.Source.asset("./assets/site-a")],
      destinationBucket: bucketA,
    });

    // Upload Site B files to BucketB
    new s3deploy.BucketDeployment(this, "DeploySiteB", {
      sources: [s3deploy.Source.asset("./assets/site-b")],
      destinationBucket: bucketB,
    });

    // Stack outputs
    new cdk.CfnOutput(this, "CloudFrontDomainName", {
      value: distribution.attrDomainName,
      description: "CloudFront Distribution domain name",
    });

    new cdk.CfnOutput(this, "BucketAName", {
      value: bucketA.bucketName,
      description: "S3 Bucket A name",
    });

    new cdk.CfnOutput(this, "BucketBName", {
      value: bucketB.bucketName,
      description: "S3 Bucket B name",
    });

    new cdk.CfnOutput(this, "ABTestHeaderExample", {
      value: "X-AB-Test: variant-a or X-AB-Test: variant-b",
      description: "A/B test header usage examples",
    });
  }
}

完全なサンプルプロジェクトはこちらです: https://github.com/tacck/cloudfront-ab-testing

9. デプロイと動作確認

# CDKスタックのデプロイ
cdk deploy

# A/Bテストの動作確認
# デフォルト(バリアントA)
curl -i https://[YOUR_CLOUDFRONT_DOMAIN]/index.html

# バリアントA(明示的指定)
curl -i -H 'X-AB-Test: variant-a' https://[YOUR_CLOUDFRONT_DOMAIN]/index.html

# バリアントB
curl -i -H 'X-AB-Test: variant-b' https://[YOUR_CLOUDFRONT_DOMAIN]/index.html

まとめ

この A/B テストシステムでは、以下の技術要素を組み合わせて実現しています:

主要な特徴

  • CloudFront Function: リクエストヘッダーに基づく動的なオリジン選択
  • Origin Access Control (OAC): 最新のセキュリティベストプラクティス
  • カスタムキャッシュポリシー: A/B テストヘッダーを考慮したキャッシュ戦略
  • Infrastructure as Code: CDK による再現可能なインフラ構築

メリット

  1. 低レイテンシ: CloudFront Function はエッジで実行されるため高速
  2. コスト効率: 実行は 100 万回無料、それ以降 100 万回ごとに 0.10 USD
  3. スケーラブル: CloudFront の全世界ネットワークを活用
  4. セキュア: OAC による適切なアクセス制御

応用可能性

  • 地域別コンテンツ配信
  • デバイス別最適化
  • 段階的機能リリース(カナリアデプロイ)
  • クエリパラメータによる A/B テスト
  • パーソナライゼーション

このシステムを基盤として、より複雑な A/B テストやトラフィック分散ロジックを実装することも可能です。 CDK を使用することで、インフラの変更も容易に管理できるため、継続的な改善とテストを行なうこともできます。