个性化阅读
专注于IT技术分析

使用AWS Lambda@Edge进行灵活的A/B测试

本文概述

直到最近, 像Amazon CloudFront之类的内容交付网络(CDN)还是Web基础架构中相对简单的一部分。传统上, Web应用程序是围绕它们设计的, 大多数情况下将它们视为被动缓存而不是主动组件。

Lambda@Edge和类似技术已经改变了一切, 并通过在Web应用程序及其用户之间引入新的逻辑层, 开辟了无限的可能性。 Lambda@Edge自2017年中开始提供, 是AWS的一项新功能, 它引入了以Lambdas形式直接在CloudFront的边缘服务器上执行代码的概念。

Lambda@Edge提供的新可能性之一是针对服务器端A/B测试的全新解决方案。 A/B测试是一种常见的测试网站多种变体的效果的方法, 可以同时向网站的不同细分受众群展示它们。

Lambda@Edge对A/B测试意味着什么

A/B测试的主要技术挑战是在不影响实验数据质量或网站本身的情况下, 正确划分传入流量。

实现它的主要途径有两个:客户端和服务器端。

  • 客户端需要在最终用户的浏览器中运行一些JavaScript代码, 以选择要显示的变体。这种方法有两个重大缺点-最值得注意的是, 它既会减慢渲染速度, 又会导致闪烁或其他渲染问题。这意味着寻求优化其加载时间或对其UX制定高标准的网站将倾向于避免这种方法。
  • 服务器端A/B测试消除了大多数此类问题, 因为返回哪种变体的决定完全在主机方面进行。浏览器简单地正常呈现每个变体, 就好像它是网站的标准版本一样。

考虑到这一点, 你可能想知道为什么每个人都不只是使用服务器端A/B测试。不幸的是, 服务器端方法不像客户端方法那样容易实施, 并且建立实验通常需要对服务器端代码或服务器配置进行某种形式的干预。

更为复杂的是, SPA之类的现代Web应用程序通常直接作为S3存储桶中的静态代码包, 而无需使用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可以访问的任何其他存储形式中检索到S3。

注意:有一些限制-请参阅AWS Blog上[利用电子邮件保护]中的利用外部数据一文, 以了解更多信息。

实作

可以通过四种不同类型的CloudFront事件来触发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文件。

在本教程中, 存储桶将如下所示:

  • 令人反感的-ttblog-公开
    • index.html
  • abtesting-ttblog-b公共
    • index.html
  • abtesting-ttblog-map私有
    • 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拉姆达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中获取它们。在此示例中, 我们将缓存TTL设置为1小时。

请注意, X-ABTesting-Segment-Origin标头需要在CloudFront中列入白名单;否则, 它将在到达原始请求Lambda之前从请求中删除。

abtesting拉姆达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在高速缓存未命中的情况下将请求路由到相应的来源。

令人讨厌的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控制台上, 选择你的分配, 然后按编辑以更改分配的设置。

在”编辑行为”页上, 从”基于选定的请求头进行缓存”选项的下拉菜单中选择”白名单”, 然后将自定义X-ABTesting-Segment-Origin标头添加到列表中:

CloudFront设置

如果你按照上一节中的说明部署了边缘Lambda, 则它们应该已经与你的发行版相关联, 并在”编辑行为”页面的最后一部分中列出。

小警告的好解决方案

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

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

此外, Lambda@Edge是AWS的一个相对较新且仍在发展中的功能, 因此自然地, 它的边缘仍然有些粗糙。更为保守的用户可能仍需要等待一段时间, 然后再将其放置在基础架构的关键位置。

话虽这么说, 它提供的独特解决方案使其成为CDN不可或缺的功能, 因此可以期望它在将来被更广泛地采用而并非不合理。


AWS高级咨询合作伙伴徽章
赞(0)
未经允许不得转载:srcmini » 使用AWS Lambda@Edge进行灵活的A/B测试

评论 抢沙发

评论前必须登录!