Гибкое A/B-тестирование с AWS Lambda@Edge
Опубликовано: 2022-03-11Сети доставки контента (CDN), такие как Amazon CloudFront, до недавнего времени были относительно простой частью веб-инфраструктуры. Традиционно веб-приложения разрабатывались вокруг них, рассматривая их в основном как пассивный кеш, а не как активный компонент.
Lambda@Edge и подобные технологии изменили все это и открыли целый мир возможностей, введя новый уровень логики между веб-приложением и его пользователями. Доступная с середины 2017 года, Lambda@Edge — это новая функция AWS, которая представляет концепцию выполнения кода в форме Lambdas непосредственно на пограничных серверах CloudFront.
Одна из новых возможностей, которые предлагает Lambda@Edge, — чистое решение для A/B-тестирования на стороне сервера. A/B-тестирование — это распространенный метод тестирования производительности нескольких вариантов веб-сайта путем их одновременного показа разным сегментам аудитории веб-сайта.
Что Lambda@Edge означает для A/B-тестирования
Основная техническая задача A/B-тестирования — правильно сегментировать входящий трафик, не влияя ни на качество данных эксперимента, ни на сам сайт.
Есть два основных пути его реализации: на стороне клиента и на стороне сервера .
- Клиентская сторона включает в себя запуск кода JavaScript в браузере конечного пользователя, который выбирает, какой вариант будет показан. У этого подхода есть несколько существенных недостатков — в первую очередь, он может замедлять рендеринг и вызывать мерцание или другие проблемы с рендерингом. Это означает, что веб-сайты, которые стремятся оптимизировать время загрузки или имеют высокие стандарты UX, будут избегать этого подхода.
- A/B -тестирование на стороне сервера устраняет большинство этих проблем, поскольку решение о том, какой вариант вернуть, полностью принимается хостом. Браузер просто нормально отображает каждый вариант, как если бы это была стандартная версия веб-сайта.
Имея это в виду, вы можете задаться вопросом, почему все просто не используют A/B-тестирование на стороне сервера. К сожалению, подход на стороне сервера не так прост в реализации, как подход на стороне клиента, и настройка эксперимента часто требует некоторого вмешательства в код на стороне сервера или в конфигурацию сервера.
Еще больше усложняет ситуацию то, что современные веб-приложения, такие как SPA, часто представляют собой пакеты статического кода непосредственно из корзины S3, даже без участия веб-сервера. Даже когда задействован веб-сервер, часто невозможно изменить логику на стороне сервера для настройки A/B-тестирования. Наличие CDN создает еще одно препятствие, так как кэширование может повлиять на размеры сегментов или, наоборот, такого рода сегментация трафика может снизить производительность CDN.
Что предлагает Lambda@Edge, так это способ маршрутизации пользовательских запросов между вариантами эксперимента еще до того, как они поступят на ваши серверы. Базовый пример этого варианта использования можно найти непосредственно в документации AWS. Хотя это полезно в качестве доказательства концепции, производственная среда с несколькими одновременными экспериментами, вероятно, потребует чего-то более гибкого и надежного.
Более того, немного поработав с Lambda@Edge, вы, вероятно, поймете, что есть некоторые нюансы, о которых следует помнить при построении вашей архитектуры.
Например, развертывание периферийных Lambdas требует времени, а их журналы распределяются по регионам AWS. Помните об этом, если вам нужно отладить конфигурацию, чтобы избежать ошибок 502.
В этом руководстве разработчики AWS познакомятся со способом реализации A/B-тестирования на стороне сервера с использованием Lambda@Edge таким образом, чтобы его можно было повторно использовать в экспериментах без изменения и повторного развертывания граничных Lambdas. Он основан на подходе примера из документации AWS и других подобных руководств, но вместо того, чтобы жестко прописывать правила распределения трафика в самой Lambda, правила периодически извлекаются из файла конфигурации на S3, который вы можете изменить в любое время.
Обзор нашего подхода к A/B-тестированию Lambda@Edge
Основная идея этого подхода заключается в том, чтобы CDN назначала каждого пользователя сегменту, а затем перенаправляла пользователя в соответствующую исходную конфигурацию. CloudFront позволяет дистрибутиву указывать либо на S3, либо на пользовательские источники, и в этом подходе мы поддерживаем оба варианта.
Сопоставление сегментов с вариантами эксперимента будет храниться в файле JSON на S3. S3 выбран здесь для простоты, но его также можно получить из базы данных или любого другого хранилища, к которому может получить доступ пограничная Lambda.
Примечание. Существуют некоторые ограничения. Дополнительные сведения см. в статье Использование внешних данных в Lambda@Edge в блоге AWS.
Реализация
Lambda@Edge может запускаться четырьмя различными типами событий CloudFront:
В этом случае мы будем запускать Lambda для каждого из следующих трех событий:
- Запрос зрителя
- Запрос происхождения
- Ответ зрителя
Каждое событие реализует шаг в следующем процессе:
- abtesting-lambda-vreq : большая часть логики содержится в этой лямбде. Сначала для входящего запроса считывается или создается файл cookie с уникальным идентификатором, а затем он хэшируется до диапазона [0, 1]. Затем карта распределения трафика извлекается из S3 и кэшируется при выполнении. И, наконец, хэшированное значение используется для выбора исходной конфигурации, которая передается в виде заголовка в кодировке JSON следующей лямбде.
- abtesting-lambda-oreq : считывает конфигурацию источника из предыдущей Lambda и соответствующим образом направляет запрос.
- abtesting-lambda-vres : это просто добавляет заголовок Set-Cookie для сохранения файла cookie с уникальным идентификатором в браузере пользователя.
Также настроим три корзины S3, две из которых будут содержать содержимое каждого из вариантов эксперимента, а третья — JSON-файл с картой распределения трафика.
Для этого урока ведра будут выглядеть так:
- abtesting-ttblog - общественность
- index.html
- abtesting-ttblog-b общественность
- index.html
- abtesting-ttblog-карта частная
- карта.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.
воздержание-лямбда-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); };
Здесь мы явно генерируем уникальный идентификатор для этого руководства, но для большинства веб-сайтов довольно часто используется какой-либо другой идентификатор клиента, который можно использовать вместо него. Это также устранило бы необходимость в ответе зрителя Lambda.

Из соображений производительности правила распределения трафика кэшируются при вызовах Lambda, а не извлекаются из S3 при каждом запросе. В этом примере мы установили TTL кэша равным 1 часу.
Обратите внимание, что заголовок X-ABTesting-Segment-Origin
должен быть внесен в белый список в CloudFront; в противном случае он будет стерт из запроса до того, как он достигнет исходного запроса 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 направить запрос в соответствующий источник в случае промаха кеша.
воздержание-лямбда-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 отвечает за возврат сгенерированного файла cookie с уникальным идентификатором в заголовке Set-Cookie
. Как упоминалось выше, если уникальный идентификатор клиента уже используется, эту лямбду можно вообще не указывать.
На самом деле, даже в этом случае куки можно установить с редиректом по запросу вьювера Lambda. Однако это может добавить некоторую задержку, поэтому в этом случае мы предпочитаем делать это в одном цикле запрос-ответ.
Код также можно найти на GitHub.
Лямбда-разрешения
Как и в случае любой периферийной Lambda, при создании Lambda можно использовать схему CloudFront. В противном случае вам потребуется создать пользовательскую роль и прикрепить шаблон политики «Основные разрешения Lambda@Edge».
Для запроса зрителя Lambda вам также потребуется разрешить доступ к корзине S3, содержащей файл распределения трафика.
Развертывание лямбда-выражений
Настройка Edge Lambdas несколько отличается от стандартного рабочего процесса Lambda. На странице конфигурации Lamba нажмите «Добавить триггер» и выберите CloudFront. Откроется небольшое диалоговое окно, позволяющее связать эту лямбду с раздачей CloudFront.
Выберите соответствующее событие для каждой из трех Lambdas и нажмите «Развернуть». Это запустит процесс развертывания кода функции на пограничных серверах CloudFront.
Примечание. Если вам нужно изменить пограничную Lambda и повторно развернуть ее, вам нужно сначала вручную опубликовать новую версию.
Настройки CloudFront
Чтобы раздача CloudFront могла направлять трафик в источник, вам потребуется настроить каждый из них отдельно на панели источников.
Единственный параметр конфигурации, который вам нужно изменить, — это внести в белый список заголовок X-ABTesting-Segment-Origin
. В консоли CloudFront выберите свой дистрибутив, а затем нажмите «Изменить», чтобы изменить настройки дистрибутива.
На странице « Редактировать поведение » выберите « Белый список» в раскрывающемся меню параметра « Кэш на основе выбранных заголовков запроса » и добавьте в список собственный заголовок X-ABTesting-Segment-Origin :
Если вы развернули пограничные Lambdas, как описано в предыдущем разделе, они уже должны быть связаны с вашим дистрибутивом и перечислены в последнем разделе страницы « Редактировать поведение ».
Хорошее решение с небольшими оговорками
Правильное проведение A/B-тестирования на стороне сервера может оказаться сложной задачей для веб-сайтов с высокой посещаемостью, развернутых за службами CDN, такими как CloudFront. В этой статье мы продемонстрировали, как Lambda@Edge можно использовать в качестве нового решения этой проблемы, скрывая детали реализации в самой CDN, а также предлагая чистое и надежное решение для проведения экспериментов A/B.
Однако у Lambda@Edge есть несколько недостатков. Самое главное, эти дополнительные вызовы Lambda между событиями CloudFront могут складываться с точки зрения задержки и стоимости, поэтому их влияние на раздачу CloudFront следует тщательно измерить в первую очередь.
Более того, Lambda@Edge — это относительно новая функция AWS, которая все еще развивается, поэтому, естественно, она все еще кажется немного грубой по краям. Более консервативные пользователи, возможно, все же захотят подождать некоторое время, прежде чем размещать его в такой критической точке своей инфраструктуры.
При этом уникальные решения, которые он предлагает, делают его незаменимой функцией CDN, поэтому вполне разумно ожидать, что в будущем он получит гораздо более широкое распространение.