使用 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@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标头添加到列表中:

CloudFront 设置

如果您按照上一节中的描述部署了边缘 Lambda,它们应该已经与您的分配相关联并列在“编辑行为”页面的最后一节中。

带有小警告的好解决方案

对于部署在 CloudFront 等 CDN 服务后面的高流量网站,正确实施服务器端 A/B 测试可能具有挑战性。 在本文中,我们展示了如何将 Lambda@Edge 用作解决此问题的新颖解决方案,方法是将实现细节隐藏在 CDN 本身中,同时还提供了一个干净可靠的解决方案来运行 A/B 实验。

但是,Lambda@Edge 有一些缺点。 最重要的是,CloudFront 事件之间的这些额外 Lambda 调用可能会在延迟和成本方面加起来,因此应首先仔细衡量它们对 CloudFront 分配的影响。

此外,Lambda@Edge 是 AWS 的一个相对较新且仍在不断发展的功能,因此自然而然地,它仍然感觉有点粗糙。 更保守的用户可能仍希望等待一段时间才能将其置于其基础架构的如此关键点。

话虽如此,它提供的独特解决方案使其成为 CDN 不可或缺的功能,因此期望它在未来得到更广泛的采用并非没有道理。


AWS 高级咨询合作伙伴徽章