AWS Lambda@Edgeを使用した柔軟なA/Bテスト
公開: 2022-03-11Amazon CloudFrontのようなコンテンツ配信ネットワーク(CDN)は、最近まで、Webインフラストラクチャの比較的単純な部分でした。 従来、Webアプリケーションはそれらを中心に設計されており、ほとんどの場合、アクティブコンポーネントではなくパッシブキャッシュとして扱われていました。
Lambda @ Edgeと同様のテクノロジーは、Webアプリケーションとそのユーザーの間に新しいロジックのレイヤーを導入することで、すべてを変え、可能性の全世界を開きました。 2017年半ばから利用可能になったLambda@Edgeは、AWSの新機能であり、CloudFrontのエッジサーバー上で直接Lambdasの形式でコードを実行するという概念を導入しています。
Lambda @ Edgeが提供する新しい可能性の1つは、サーバー側のA/Bテストに対するクリーンなソリューションです。 A / Bテストは、Webサイトのオーディエンスのさまざまなセグメントに同時に表示することにより、Webサイトの複数のバリエーションのパフォーマンスをテストする一般的な方法です。
Lambda@EdgeがA/Bテストで意味すること
A / Bテストの主な技術的課題は、実験のデータの品質やWebサイト自体に影響を与えることなく、着信トラフィックを適切にセグメント化することです。
それを実装するための2つの主要なルートがあります:クライアント側とサーバー側。
- クライアント側では、表示するバリアントを選択するエンドユーザーのブラウザでJavaScriptコードを実行する必要があります。 このアプローチにはいくつかの重大な欠点があります。特に、レンダリングが遅くなり、ちらつきやその他のレンダリングの問題が発生する可能性があります。 つまり、読み込み時間を最適化しようとするWebサイトや、UXの基準が高いWebサイトは、このアプローチを回避する傾向があります。
- サーバー側のA/Bテストでは、これらの問題のほとんどが解消されます。これは、返すバリアントの決定が完全にホスト側で行われるためです。 ブラウザは、Webサイトの標準バージョンであるかのように、各バリアントを通常どおりにレンダリングします。
そのことを念頭に置いて、なぜ誰もが単にサーバー側のA/Bテストを使用しないのか不思議に思うかもしれません。 残念ながら、サーバー側のアプローチはクライアント側のアプローチほど簡単には実装できず、実験を設定するには、サーバー側のコードまたはサーバー構成に何らかの形で介入する必要があります。
さらに複雑なことに、SPAのような最新のWebアプリケーションは、Webサーバーを使用せずに、S3バケットから直接静的コードのバンドルとして提供されることがよくあります。 Webサーバーが関係している場合でも、サーバー側のロジックを変更してA/Bテストを設定することは不可能な場合がよくあります。 キャッシングがセグメントサイズに影響を与える可能性があるため、または逆に、この種のトラフィックセグメンテーションはCDNのパフォーマンスを低下させる可能性があるため、CDNの存在はさらに別の障害をもたらします。
Lambda @ Edgeが提供するのは、サーバーに到達する前に、実験のバリエーション間でユーザーリクエストをルーティングする方法です。 このユースケースの基本的な例は、AWSのドキュメントに直接記載されています。 概念実証としては有用ですが、複数の同時実験を行う実稼働環境では、おそらくより柔軟で堅牢なものが必要になります。
さらに、Lambda @ Edgeを少し操作した後、アーキテクチャを構築するときに注意すべき微妙な違いがあることに気付くでしょう。
たとえば、エッジLambdaのデプロイには時間がかかり、ログはAWSリージョン全体に分散されます。 502エラーを回避するために構成をデバッグする必要がある場合は、これに注意してください。
このチュートリアルでは、AWS開発者に、エッジLambdaを変更および再デプロイすることなく、実験全体で再利用できる方法でLambda@Edgeを使用してサーバー側A/Bテストを実装する方法を紹介します。 AWSドキュメントや他の同様のチュートリアルの例のアプローチに基づいていますが、Lambda自体にトラフィック割り当てルールをハードコーディングする代わりに、ルールはS3の構成ファイルから定期的に取得されます。このルールはいつでも変更できます。
Lambda @ Edge A/Bテストアプローチの概要
このアプローチの背後にある基本的な考え方は、CDNに各ユーザーをセグメントに割り当てさせてから、ユーザーを関連するオリジン構成にルーティングすることです。 CloudFrontを使用すると、ディストリビューションはS3またはカスタムオリジンのいずれかを指すことができます。このアプローチでは、両方をサポートします。
セグメントの実験バリアントへのマッピングは、S3のJSONファイルに保存されます。 ここでは簡単にするためにS3を選択していますが、これはデータベースまたはエッジLambdaがアクセスできる他の形式のストレージから取得することもできます。
注:いくつかの制限があります。詳細については、AWSブログの記事「Lambda@Edgeの外部データの活用」を確認してください。
実装
Lambda @ Edgeは、4つの異なるタイプのCloudFrontイベントによってトリガーできます。
この場合、次の3つのイベントのそれぞれでLambdaを実行します。
- 視聴者リクエスト
- オリジンリクエスト
- 視聴者の反応
各イベントは、次のプロセスのステップを実装します。
- abtesting-lambda-vreq :ほとんどのロジックはこのラムダに含まれています。 最初に、着信要求に対して一意のID Cookieが読み取られるか生成され、次に[0、1]の範囲にハッシュされます。 次に、トラフィック割り当てマップがS3からフェッチされ、実行間でキャッシュされます。 そして最後に、ハッシュダウンされた値を使用してオリジン構成を選択します。これは、JSONエンコードされたヘッダーとして次のラムダに渡されます。
- abtesting-lambda-oreq :これは、前のLambdaからオリジン構成を読み取り、それに応じて要求をルーティングします。
- abtesting-lambda-vres :これは、 Set-Cookieヘッダーを追加して、ユーザーのブラウザーに一意のIDCookieを保存するだけです。
また、3つのS3バケットを設定しましょう。そのうちの2つには、実験の各バリアントのコンテンツが含まれ、3つ目には、トラフィック割り当てマップを含むJSONファイルが含まれます。
このチュートリアルでは、バケットは次のようになります。
- abtesting-ttblog -a public
- index.html
- abtesting-ttblog-b public
- index.html
- abtesting-ttblog-map private
- map.json
ソースコード
まず、トラフィック割り当てマップから始めましょう。
map.json
{ "segments": [ { "weight": 0.7, "host": "abtesting-ttblog-a.s3.amazonaws.com", "origin": { "s3": { "authMethod": "none", "domainName": "abtesting-ttblog-a.s3.amazonaws.com", "path": "", "region": "eu-west-1" } } }, { "weight": 0.3, "host": "abtesting-ttblog-b.s3.amazonaws.com", "origin": { "s3": { "authMethod": "none", "domainName": "abtesting-ttblog-b.s3.amazonaws.com", "path": "", "region": "eu-west-1" } } } ] }
各セグメントにはトラフィックの重みがあり、対応する量のトラフィックを割り当てるために使用されます。 オリジン構成とホストも含まれます。 オリジンの設定フォーマットは、AWSのドキュメントで説明されています。
abtesting-lambda-vreq
'use strict'; const aws = require('aws-sdk'); const COOKIE_KEY = 'abtesting-unique-id'; const s3 = new aws.S3({ region: 'eu-west-1' }); const s3Params = { Bucket: 'abtesting-ttblog-map', Key: 'map.json', }; const SEGMENT_MAP_TTL = 3600000; // TTL of 1 hour const fetchSegmentMapFromS3 = async () => { const response = await s3.getObject(s3Params).promise(); return JSON.parse(response.Body.toString('utf-8')); } // Cache the segment map across Lambda invocations let _segmentMap; let _lastFetchedSegmentMap = 0; const fetchSegmentMap = async () => { if (!_segmentMap || (Date.now() - _lastFetchedSegmentMap) > SEGMENT_MAP_TTL) { _segmentMap = await fetchSegmentMapFromS3(); _lastFetchedSegmentMap = Date.now(); } return _segmentMap; } // Just generate a random UUID const getRandomId = () => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); }; // This function will hash any string (our random UUID in this case) // to a [0, 1) range const hashToInterval = (s) => { let hash = 0, i = 0; while (i < s.length) { hash = ((hash << 5) - hash + s.charCodeAt(i++)) << 0; } return (hash + 2147483647) % 100 / 100; } const getCookie = (headers, cookieKey) => { if (headers.cookie) { for (let cookieHeader of headers.cookie) { const cookies = cookieHeader.value.split(';'); for (let cookie of cookies) { const [key, val] = cookie.split('='); if (key === cookieKey) { return val; } } } } return null; } const getSegment = async (p) => { const segmentMap = await fetchSegmentMap(); let weight = 0; for (const segment of segmentMap.segments) { weight += segment.weight; if (p < weight) { return segment; } } console.error(`No segment for value ${p}. Check the segment map.`); } exports.handler = async (event, context, callback) => { const request = event.Records[0].cf.request; const headers = request.headers; let uniqueId = getCookie(headers, COOKIE_KEY); if (uniqueId === null) { // This is what happens on the first visit: we'll generate a new // unique ID, then leave it the cookie header for the // viewer response lambda to set permanently later uniqueId = getRandomId(); const cookie = `${COOKIE_KEY}=${uniqueId}`; headers.cookie = headers.cookie || []; headers.cookie.push({ key: 'Cookie', value: cookie }); } // Get a value between 0 and 1 and use it to // resolve the traffic segment const p = hashToInterval(uniqueId); const segment = await getSegment(p); // Pass the origin data as a header to the origin request lambda // The header key below is whitelisted in Cloudfront const headerValue = JSON.stringify({ host: segment.host, origin: segment.origin }); headers['x-abtesting-segment-origin'] = [{ key: 'X-ABTesting-Segment-Origin', value: headerValue }]; callback(null, request); };
ここでは、このチュートリアルの一意のIDを明示的に生成しますが、ほとんどのWebサイトでは、代わりに使用できる他のクライアントIDが存在するのが一般的です。 これにより、視聴者の応答であるラムダも不要になります。

パフォーマンスを考慮して、トラフィック割り当てルールは、リクエストごとにS3からフェッチするのではなく、Lambda呼び出し全体でキャッシュされます。 この例では、1時間のキャッシュTTLを設定します。
X-ABTesting-Segment-Origin
ヘッダーはCloudFrontでホワイトリストに登録する必要があることに注意してください。 それ以外の場合は、オリジンリクエストのラムダに到達する前にリクエストから消去されます。
abtesting-lambda-oreq
'use strict'; const HEADER_KEY = 'x-abtesting-segment-origin'; // Origin Request handler exports.handler = (event, context, callback) => { const request = event.Records[0].cf.request; const headers = request.headers; const headerValue = headers[HEADER_KEY] && headers[HEADER_KEY][0] && headers[HEADER_KEY][0].value; if (headerValue) { const segment = JSON.parse(headerValue); headers['host'] = [{ key: 'host', value: segment.host }]; request.origin = segment.origin; } callback(null, request); };
オリジンリクエストのラムダは非常に簡単です。 オリジン構成とホストは、前のステップで生成され、リクエストに挿入されたX-ABTesting-Origin
ヘッダーから読み取られます。 これは、キャッシュミスが発生した場合に、対応するオリジンにリクエストをルーティングするようにCloudFrontに指示します。
abtesting-lambda-vres
'use strict'; const COOKIE_KEY = 'abtesting-unique-id'; const getCookie = (headers, cookieKey) => { if (headers.cookie) { for (let cookieHeader of headers.cookie) { const cookies = cookieHeader.value.split(';'); for (let cookie of cookies) { const [key, val] = cookie.split('='); if (key === cookieKey) { return val; } } } } return null; } const setCookie = function (response, cookie) { console.log(`Setting cookie ${cookie}`); response.headers['set-cookie'] = response.headers['set-cookie'] || []; response.headers['set-cookie'] = [{ key: "Set-Cookie", value: cookie }]; } exports.handler = (event, context, callback) => { const request = event.Records[0].cf.request; const headers = request.headers; const response = event.Records[0].cf.response; const cookieVal = getCookie(headers, COOKIE_KEY); if (cookieVal != null) { setCookie(response, `${COOKIE_KEY}=${cookieVal}`); callback(null, response); return; } console.log(`no ${COOKIE_KEY} cookie`); callback(null, response); }
最後に、ビューアの応答Lambdaは、生成された一意のIDCookieをSet-Cookie
ヘッダーに返す責任があります。 上記のように、一意のクライアントIDがすでに使用されている場合、このLambdaは完全に省略できます。
実際、この場合でも、閲覧者リクエストLambdaによるリダイレクトでCookieを設定できます。 ただし、これによりレイテンシが追加される可能性があるため、この場合は、単一の要求/応答サイクルで実行することをお勧めします。
コードはGitHubにもあります。
ラムダ権限
他のエッジLambdaと同様に、Lambdaを作成するときにCloudFrontブループリントを使用できます。 それ以外の場合は、カスタムロールを作成し、「Basic Lambda@EdgePermissions」ポリシーテンプレートを添付する必要があります。
ビューアリクエストLambdaの場合、トラフィック割り当てファイルを含むS3バケットへのアクセスも許可する必要があります。
ラムダのデプロイ
エッジラムダの設定は、標準のラムダワークフローとは多少異なります。 Lambaの設定ページで、「Add trigger」をクリックし、CloudFrontを選択します。 これにより、このLambdaをCloudFrontディストリビューションに関連付けることができる小さなダイアログが開きます。
3つのラムダのそれぞれに適切なイベントを選択し、「デプロイ」を押します。 これにより、機能コードをCloudFrontのエッジサーバーにデプロイするプロセスが開始されます。
注:エッジLambdaを変更して再デプロイする必要がある場合は、最初に新しいバージョンを手動で公開する必要があります。
CloudFront設定
CloudFrontディストリビューションがトラフィックをオリジンにルーティングできるようにするには、オリジンパネルでそれぞれを個別に設定する必要があります。
変更する必要がある唯一の構成設定は、 X-ABTesting-Segment-Origin
ヘッダーをホワイトリストに登録することです。 CloudFrontコンソールで、ディストリビューションを選択し、編集を押してディストリビューションの設定を変更します。
[動作の編集]ページで、[選択した要求ヘッダーに基づくキャッシュ]オプションのドロップダウンメニューから[ホワイトリスト]を選択し、カスタムX-ABTesting-Segment-Originヘッダーをリストに追加します。
前のセクションで説明したようにエッジラムダをデプロイした場合、それらはすでにディストリビューションに関連付けられており、[動作の編集]ページの最後のセクションにリストされているはずです。
マイナーな警告を伴う良い解決策
サーバー側のA/Bテストは、CloudFrontなどのCDNサービスの背後にデプロイされているトラフィックの多いWebサイトに適切に実装するのが難しい場合があります。 この記事では、実装の詳細をCDN自体に隠し、A / B実験を実行するためのクリーンで信頼性の高いソリューションを提供することで、Lambda@Edgeをこの問題の新しいソリューションとして使用する方法を示しました。
ただし、Lambda@Edgeにはいくつかの欠点があります。 最も重要なことは、CloudFrontイベント間のこれらの追加のLambda呼び出しは、レイテンシーとコストの両方の点で合計される可能性があるため、CloudFrontディストリビューションへの影響を最初に慎重に測定する必要があります。
さらに、Lambda @ EdgeはAWSの比較的最近の機能であり、まだ進化しているため、当然のことながら、エッジの周りは少し荒い感じがします。 より保守的なユーザーは、インフラストラクチャのこのような重要なポイントに配置する前に、しばらく待つことをお勧めします。
そうは言っても、それが提供する独自のソリューションは、CDNの不可欠な機能であるため、将来さらに広く採用されることを期待するのは不合理ではありません。