Pruebas A/B flexibles con AWS Lambda@Edge
Publicado: 2022-03-11Las redes de entrega de contenido (CDN) como Amazon CloudFront eran, hasta hace poco, una parte relativamente simple de la infraestructura web. Tradicionalmente, las aplicaciones web se diseñaron en torno a ellos, tratándolos principalmente como un caché pasivo en lugar de un componente activo.
Lambda@Edge y tecnologías similares han cambiado todo eso y han abierto todo un mundo de posibilidades al introducir una nueva capa de lógica entre una aplicación web y sus usuarios. Disponible desde mediados de 2017, Lambda@Edge es una nueva característica de AWS que presenta el concepto de ejecución de código en forma de Lambdas directamente en los servidores perimetrales de CloudFront.
Una de las nuevas posibilidades que ofrece Lambda@Edge es una solución limpia para las pruebas A/B del lado del servidor. Las pruebas A/B son un método común para probar el rendimiento de múltiples variaciones de un sitio web mostrándolas al mismo tiempo a diferentes segmentos de la audiencia del sitio web.
Qué significa Lambda@Edge para las pruebas A/B
El principal desafío técnico en las pruebas A/B es segmentar adecuadamente el tráfico entrante sin afectar la calidad de los datos del experimento o el sitio web en sí.
Hay dos rutas principales para implementarlo: del lado del cliente y del lado del servidor .
- El lado del cliente implica ejecutar un poco de código JavaScript en el navegador del usuario final que elige qué variante se mostrará. Hay un par de desventajas significativas en este enfoque, en particular, puede ralentizar el renderizado y causar parpadeos u otros problemas de renderizado. Esto significa que los sitios web que buscan optimizar su tiempo de carga o tienen altos estándares para su UX tenderán a evitar este enfoque.
- Las pruebas A/B del lado del servidor eliminan la mayoría de estos problemas, ya que la decisión sobre qué variante devolver se toma completamente del lado del host. El navegador simplemente muestra cada variante normalmente como si fuera la versión estándar del sitio web.
Con eso en mente, es posible que se pregunte por qué no todos usan simplemente las pruebas A/B del lado del servidor. Desafortunadamente, el enfoque del lado del servidor no es tan fácil de implementar como el enfoque del lado del cliente, y configurar un experimento a menudo requiere algún tipo de intervención en el código del lado del servidor o en la configuración del servidor.
Para complicar aún más las cosas, las aplicaciones web modernas, como los SPA, a menudo se sirven como paquetes de código estático directamente desde un depósito S3 sin siquiera involucrar a un servidor web. Incluso cuando se trata de un servidor web, a menudo no es factible cambiar la lógica del lado del servidor para configurar una prueba A/B. La presencia de una CDN plantea otro obstáculo más, ya que el almacenamiento en caché puede afectar el tamaño de los segmentos o, por el contrario, este tipo de segmentación del tráfico puede reducir el rendimiento de la CDN.
Lo que ofrece Lambda@Edge es una forma de enrutar las solicitudes de los usuarios a través de variantes de un experimento incluso antes de que lleguen a sus servidores. Puede encontrar un ejemplo básico de este caso de uso directamente en la documentación de AWS. Si bien es útil como prueba de concepto, un entorno de producción con varios experimentos simultáneos probablemente necesite algo más flexible y sólido.
Además, después de trabajar un poco con Lambda@Edge, probablemente se dará cuenta de que hay algunos matices que debe tener en cuenta al desarrollar su arquitectura.
Por ejemplo, la implementación de las Lambdas perimetrales lleva tiempo y sus registros se distribuyen entre las regiones de AWS. Tenga esto en cuenta si necesita depurar su configuración para evitar errores 502.
Este tutorial presentará a los desarrolladores de AWS una forma de implementar pruebas A/B del lado del servidor mediante Lambda@Edge de una manera que se pueda reutilizar en experimentos sin modificar ni volver a implementar las Lambdas perimetrales. Se basa en el enfoque del ejemplo en la documentación de AWS y otros tutoriales similares, pero en lugar de codificar las reglas de asignación de tráfico en Lambda, las reglas se recuperan periódicamente de un archivo de configuración en S3 que puede cambiar en cualquier momento.
Descripción general de nuestro enfoque de prueba Lambda@Edge A/B
La idea básica detrás de este enfoque es que la CDN asigne a cada usuario a un segmento y luego enrute al usuario a la configuración de origen asociada. CloudFront permite que la distribución apunte a S3 o a orígenes personalizados y, en este enfoque, admitimos ambos.
El mapeo de segmentos para experimentar variantes se almacenará en un archivo JSON en S3. S3 se elige aquí por simplicidad, pero esto también podría recuperarse de una base de datos o cualquier otra forma de almacenamiento a la que pueda acceder Lambda perimetral.
Nota: Existen algunas limitaciones: consulte el artículo Aprovechamiento de datos externos en Lambda@Edge en el blog de AWS para obtener más información.
Implementación
Lambda@Edge puede ser activado por cuatro tipos diferentes de eventos de CloudFront:
En este caso, ejecutaremos una Lambda en cada uno de los siguientes tres eventos:
- Solicitud del espectador
- Solicitud de origen
- Respuesta del espectador
Cada evento implementará un paso en el siguiente proceso:
- abtesting-lambda-vreq : la mayor parte de la lógica está contenida en este lambda. En primer lugar, se lee o genera una cookie de identificación única para la solicitud entrante y luego se reduce a un rango [0, 1]. Luego, el mapa de asignación de tráfico se obtiene de S3 y se almacena en caché en todas las ejecuciones. Y finalmente, el valor reducido se usa para elegir una configuración de origen, que se pasa como un encabezado codificado en JSON al siguiente Lambda.
- abtesting-lambda-oreq : esto lee la configuración de origen del Lambda anterior y enruta la solicitud en consecuencia.
- abtesting-lambda-vres : Esto solo agrega el encabezado Set-Cookie para guardar la cookie de identificación única en el navegador del usuario.
Configuremos también tres cubos S3, dos de los cuales contendrán el contenido de cada una de las variantes del experimento, mientras que el tercero contendrá el archivo JSON con el mapa de asignación de tráfico.
Para este tutorial, los cubos se verán así:
- abtesting-ttblog -un público
- índice.html
- abtesting-ttblog-b público
- índice.html
- abtesting-ttblog-mapa privado
- mapa.json
Código fuente
Primero, comencemos con el mapa de asignación de tráfico:
mapa.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" } } } ] }
Cada segmento tiene un peso de tráfico, que se utilizará para asignar una cantidad de tráfico correspondiente. También incluimos la configuración de origen y host. El formato de configuración de origen se describe en la documentación de 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); };
Aquí, generamos explícitamente una ID única para este tutorial, pero es bastante común que la mayoría de los sitios web tengan alguna otra ID de cliente que podría usarse en su lugar. Esto también eliminaría la necesidad de la respuesta del espectador Lambda.

Por consideraciones de rendimiento, las reglas de asignación de tráfico se almacenan en caché en las invocaciones de Lambda en lugar de obtenerlas de S3 en cada solicitud. En este ejemplo, configuramos un TTL de caché de 1 hora.
Tenga en cuenta que el X-ABTesting-Segment-Origin
debe incluirse en la lista blanca en CloudFront; de lo contrario, se borrará de la solicitud antes de que llegue a la Lambda de solicitud de origen.
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); };
La solicitud de origen de Lambda es bastante sencilla. La configuración de origen y el host se leen del encabezado X-ABTesting-Origin
que se generó en el paso anterior y se inyectó en la solicitud. Esto le indica a CloudFront que enrute la solicitud al origen correspondiente en caso de que se pierda la memoria caché.
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); }
Finalmente, la Lambda de respuesta del espectador es responsable de devolver la cookie de ID única generada en el encabezado Set-Cookie
. Como se mencionó anteriormente, si ya se usa una ID de cliente única, este Lambda se puede omitir por completo.
De hecho, incluso en este caso, la cookie se puede configurar con una redirección mediante la solicitud Lambda del espectador. Sin embargo, esto podría agregar algo de latencia, por lo que, en este caso, preferimos hacerlo en un solo ciclo de solicitud y respuesta.
El código también se puede encontrar en GitHub.
Permisos Lambda
Al igual que con cualquier Lambda perimetral, puede usar el modelo de CloudFront al crear Lambda. De lo contrario, deberá crear un rol personalizado y adjuntar la plantilla de política "Permisos básicos de Lambda@Edge".
Para la Lambda de solicitud del espectador, también deberá permitir el acceso al depósito de S3 que contiene el archivo de asignación de tráfico.
Despliegue de las Lambdas
La configuración de Edge Lambdas es algo diferente al flujo de trabajo de Lambda estándar. En la página de configuración de Lamba, haga clic en "Agregar disparador" y seleccione CloudFront. Esto abrirá un pequeño cuadro de diálogo que le permitirá asociar este Lambda con una distribución de CloudFront.
Seleccione el evento apropiado para cada una de las tres Lambdas y presione "Implementar". Esto iniciará el proceso de implementación del código de función en los servidores perimetrales de CloudFront.
Nota: Si necesita modificar una Lambda perimetral y volver a implementarla, primero debe publicar manualmente una nueva versión.
Configuración de CloudFront
Para que una distribución de CloudFront pueda enrutar el tráfico a un origen, deberá configurar cada uno por separado en el panel de orígenes.
El único ajuste de configuración que deberá cambiar es incluir en la lista blanca el X-ABTesting-Segment-Origin
. En la consola de CloudFront , elija su distribución y luego presione editar para cambiar la configuración de la distribución.
En la página Editar comportamiento , seleccione Lista blanca en el menú desplegable en la opción Caché basado en encabezados de solicitud seleccionados y agregue un encabezado X-ABTesting-Segment-Origin personalizado a la lista:
Si implementó las Lambdas perimetrales como se describe en la sección anterior, ya deberían estar asociadas con su distribución y listadas en la última sección de la página Editar comportamiento .
Una buena solución con salvedades menores
Las pruebas A/B del lado del servidor pueden ser difíciles de implementar correctamente para sitios web de alto tráfico que se implementan detrás de servicios CDN como CloudFront. En este artículo, demostramos cómo se puede emplear Lambda@Edge como una solución novedosa para este problema al ocultar los detalles de implementación en la propia CDN, al mismo tiempo que se ofrece una solución limpia y confiable para ejecutar experimentos A/B.
Sin embargo, Lambda@Edge tiene algunos inconvenientes. Lo que es más importante, estas invocaciones adicionales de Lambda entre eventos de CloudFront pueden acumularse en términos de latencia y costo, por lo que su impacto en una distribución de CloudFront debe medirse cuidadosamente primero.
Además, Lambda@Edge es una característica relativamente reciente y aún en evolución de AWS, por lo que, naturalmente, todavía se siente un poco tosco. Es posible que los usuarios más conservadores deseen esperar un tiempo antes de colocarlo en un punto tan crítico de su infraestructura.
Dicho esto, las soluciones únicas que ofrece lo convierten en una característica indispensable de las CDN, por lo que no es descabellado esperar que se adopte mucho más ampliamente en el futuro.