使用 AWS Lambda@Edge 進行靈活的 A/B 測試
已發表: 2022-03-11直到最近,像 Amazon CloudFront 這樣的內容交付網絡 (CDN) 還是 Web 基礎設施中相對簡單的一部分。 傳統上,Web 應用程序是圍繞它們設計的,主要將它們視為被動緩存而不是主動組件。
Lambda@Edge 和類似技術通過在 Web 應用程序及其用戶之間引入新的邏輯層,改變了這一切,並開闢了一個充滿可能性的世界。 自 2017 年年中推出以來,Lambda@Edge 是 AWS 中的一項新功能,它引入了直接在 CloudFront 的邊緣服務器上以 Lambda 形式執行代碼的概念。
Lambda@Edge 提供的新可能性之一是服務器端 A/B 測試的干淨解決方案。 A/B 測試是一種測試網站多個變體性能的常用方法,方法是同時向網站受眾的不同部分展示它們。
Lambda@Edge 對 A/B 測試的意義
A/B 測試的主要技術挑戰是在不以任何方式影響實驗數據質量或網站本身的情況下正確分割傳入流量。
實現它有兩種主要途徑:客戶端和服務器端。
- 客戶端涉及在最終用戶的瀏覽器中運行一些 JavaScript 代碼,以選擇要顯示的變體。 這種方法有幾個明顯的缺點——最明顯的是,它既會減慢渲染速度,也會導致閃爍或其他渲染問題。 這意味著尋求優化加載時間或對其用戶體驗有高標準的網站將傾向於避免這種方法。
- 服務器端A/B 測試消除了大多數這些問題,因為返回哪個變體的決定完全在主機方面。 瀏覽器只是正常呈現每個變體,就好像它是網站的標準版本一樣。
考慮到這一點,您可能想知道為什麼每個人都不簡單地使用服務器端 A/B 測試。 不幸的是,服務器端方法不像客戶端方法那樣容易實現,並且設置實驗通常需要對服務器端代碼或服務器配置進行某種形式的干預。
更複雜的是,像 SPA 這樣的現代 Web 應用程序通常直接從 S3 存儲桶中作為靜態代碼包提供,甚至不涉及 Web 服務器。 即使涉及 Web 服務器,更改服務器端邏輯來設置 A/B 測試通常也不可行。 CDN 的存在帶來了另一個障礙,因為緩存可能會影響分段大小,或者相反,這種流量分段會降低 CDN 的性能。
Lambda@Edge 提供的是一種在用戶請求到達您的服務器之前跨實驗變體路由的方法。 此用例的基本示例可以直接在 AWS 文檔中找到。 雖然作為概念證明很有用,但具有多個並發實驗的生產環境可能需要更靈活和健壯的東西。
此外,在稍微使用 Lambda@Edge 之後,您可能會意識到在構建架構時需要注意一些細微差別。
例如,部署邊緣 Lambda 需要時間,並且它們的日誌分佈在 AWS 區域中。 如果您需要調試配置以避免 502 錯誤,請注意這一點。
本教程將向 AWS 開發人員介紹一種使用 Lambda@Edge 實現服務器端 A/B 測試的方法,這種方法可以在實驗中重複使用,而無需修改和重新部署邊緣 Lambda。 它建立在 AWS 文檔和其他類似教程中的示例方法的基礎上,但不是在 Lambda 本身中硬編碼流量分配規則,而是定期從 S3 上的配置文件中檢索規則,您可以隨時更改該配置文件。
我們的 Lambda@Edge A/B 測試方法概述
這種方法背後的基本思想是讓 CDN 將每個用戶分配給一個段,然後將用戶路由到相關的源配置。 CloudFront 允許分髮指向 S3 或自定義源,在這種方法中,我們支持兩者。
細分到實驗變體的映射將存儲在 S3 上的 JSON 文件中。 此處選擇 S3 是為了簡單起見,但這也可以從數據庫或邊緣 Lambda 可以訪問的任何其他形式的存儲中檢索。
注意:存在一些限制 - 請查看 AWS 博客上的文章利用 Lambda@Edge 中的外部數據了解更多信息。
執行
Lambda@Edge 可以由四種不同類型的 CloudFront 事件觸發:
在這種情況下,我們將對以下三個事件中的每一個運行 Lambda:
- 查看者請求
- 來源請求
- 觀眾回應
每個事件將在以下過程中實現一個步驟:
- abtesting-lambda-vreq :大部分邏輯都包含在這個 lambda 中。 首先,為傳入請求讀取或生成一個唯一 ID cookie,然後將其散列到 [0, 1] 範圍。 然後從 S3 獲取流量分配圖並在執行過程中進行緩存。 最後,散列值用於選擇原始配置,該原始配置作為 JSON 編碼的標頭傳遞給下一個 Lambda。
- abtesting-lambda-oreq :這會從之前的 Lambda 中讀取原始配置並相應地路由請求。
- abtesting-lambda-vres :這只是添加Set-Cookie標頭以將唯一 ID cookie 保存在用戶瀏覽器上。
我們還要設置三個 S3 存儲桶,其中兩個將包含每個實驗變體的內容,而第三個將包含帶有流量分配圖的 JSON 文件。
對於本教程,存儲桶將如下所示:
- abtesting-ttblog -a公眾
- 索引.html
- abtesting-ttblog-b公開
- 索引.html
- abtesting-ttblog-map私有
- 地圖.json
源代碼
首先,讓我們從流量分配圖開始:
地圖.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,但對於大多數網站來說,有一些其他客戶端 ID 可以用來代替是很常見的。 這也將消除對查看器響應 Lambda 的需要。

出於性能考慮,流量分配規則在 Lambda 調用中進行緩存,而不是在每次請求時從 S3 中獲取它們。 在本例中,我們設置了 1 小時的緩存 TTL。
請注意, X-ABTesting-Segment-Origin
標頭需要在 CloudFront 中列入白名單; 否則,它將在到達原始請求 Lambda 之前從請求中擦除。
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); };
原始請求 Lambda 非常簡單。 源配置和主機從上一步中生成並註入請求的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 負責在Set-Cookie
標頭中返回生成的唯一 ID cookie。 如上所述,如果已經使用了唯一的客戶端 ID,則可以完全省略此 Lambda。
事實上,即使在這種情況下,也可以通過查看器請求 Lambda 使用重定向設置 cookie。 但是,這可能會增加一些延遲,因此在這種情況下,我們更願意在單個請求-響應週期中執行此操作。
代碼也可以在 GitHub 上找到。
Lambda 權限
與任何邊緣 Lambda 一樣,您可以在創建 Lambda 時使用 CloudFront 藍圖。 否則,您將需要創建一個自定義角色並附加“基本 Lambda@Edge 權限”策略模板。
對於查看器請求 Lambda,您還需要允許訪問包含流量分配文件的 S3 存儲桶。
部署 Lambda
設置邊緣 Lambda 與標準 Lambda 工作流程有些不同。 在 Lamba 的配置頁面上,單擊“添加觸發器”並選擇 CloudFront。 這將打開一個小對話框,允許您將此 Lambda 與 CloudFront 分配相關聯。
為三個 Lambda 中的每一個選擇適當的事件,然後按“部署”。 這將開始將函數代碼部署到 CloudFront 的邊緣服務器的過程。
注意:如果您需要修改邊緣 Lambda 並重新部署它,您需要先手動發布新版本。
CloudFront 設置
為了讓 CloudFront 分配能夠將流量路由到源,您需要在源面板中分別設置每個。
您需要更改的唯一配置設置是將X-ABTesting-Segment-Origin
標頭列入白名單。 在CloudFront 控制台上,選擇您的分配,然後按編輯以更改分配的設置。
在Edit Behavior頁面上,從Cache Based on Selected Request Headers選項的下拉菜單中選擇Whitelist ,並將自定義X-ABTesting-Segment-Origin標頭添加到列表中:
如果您按照上一節中的描述部署了邊緣 Lambda,它們應該已經與您的分配相關聯並列在“編輯行為”頁面的最後一節中。
帶有小警告的好解決方案
對於部署在 CloudFront 等 CDN 服務後面的高流量網站,正確實施服務器端 A/B 測試可能具有挑戰性。 在本文中,我們展示瞭如何將 Lambda@Edge 用作解決此問題的新穎解決方案,方法是將實現細節隱藏在 CDN 本身中,同時還提供了一個乾淨可靠的解決方案來運行 A/B 實驗。
但是,Lambda@Edge 有一些缺點。 最重要的是,CloudFront 事件之間的這些額外 Lambda 調用可能會在延遲和成本方面加起來,因此應首先仔細衡量它們對 CloudFront 分配的影響。
此外,Lambda@Edge 是 AWS 的一個相對較新且仍在不斷發展的功能,因此自然而然地,它仍然感覺有點粗糙。 更保守的用戶可能仍希望等待一段時間才能將其置於其基礎架構的如此關鍵點。
話雖如此,它提供的獨特解決方案使其成為 CDN 不可或缺的功能,因此期望它在未來得到更廣泛的採用並非沒有道理。