Testes A/B flexíveis com AWS Lambda@Edge

Publicados: 2022-03-11

As redes de entrega de conteúdo (CDNs) como o Amazon CloudFront eram, até recentemente, uma parte relativamente simples da infraestrutura da web. Tradicionalmente, os aplicativos da Web eram projetados em torno deles, tratando-os principalmente como um cache passivo em vez de um componente ativo.

Lambda@Edge e tecnologias semelhantes mudaram tudo isso e abriram um mundo de possibilidades ao introduzir uma nova camada de lógica entre um aplicativo da web e seus usuários. Disponível desde meados de 2017, o Lambda@Edge é um novo recurso da AWS que apresenta o conceito de execução de código na forma de Lambdas diretamente nos servidores de borda do CloudFront.

Uma das novas possibilidades que o Lambda@Edge oferece é uma solução limpa para testes A/B do lado do servidor. O teste A/B é um método comum de testar o desempenho de várias variações de um site, mostrando-as ao mesmo tempo para diferentes segmentos do público do site.

O que o Lambda@Edge significa para testes A/B

O principal desafio técnico no teste A/B é segmentar adequadamente o tráfego de entrada sem afetar a qualidade dos dados do experimento ou o próprio site de forma alguma.

Existem duas rotas principais para implementá-lo: lado do cliente e lado do servidor .

  • O lado do cliente envolve a execução de um pouco de código JavaScript no navegador do usuário final que escolhe qual variante será mostrada. Existem algumas desvantagens significativas nessa abordagem - mais notavelmente, ela pode retardar a renderização e causar cintilação ou outros problemas de renderização. Isso significa que sites que buscam otimizar seu tempo de carregamento ou possuem altos padrões de UX tenderão a evitar essa abordagem.
  • O teste A/B do lado do servidor elimina a maioria desses problemas, pois a decisão sobre qual variante retornar é tomada inteiramente do lado do host. O navegador simplesmente renderiza cada variante normalmente como se fosse a versão padrão do site.

Com isso em mente, você pode se perguntar por que todos não usam simplesmente o teste A/B do lado do servidor. Infelizmente, a abordagem do lado do servidor não é tão fácil de implementar quanto a abordagem do lado do cliente, e configurar um experimento geralmente requer alguma forma de intervenção no código do lado do servidor ou na configuração do servidor.

Para complicar ainda mais as coisas, aplicativos Web modernos, como SPAs, geralmente são servidos como pacotes de código estático diretamente de um bucket do S3, sem envolver um servidor Web. Mesmo quando um servidor da Web está envolvido, geralmente não é viável alterar a lógica do lado do servidor para configurar um teste A/B. A presença de uma CDN representa mais um obstáculo, pois o cache pode afetar o tamanho dos segmentos ou, inversamente, esse tipo de segmentação de tráfego pode diminuir o desempenho da CDN.

O que o Lambda@Edge oferece é uma maneira de rotear solicitações de usuários entre variantes de um experimento antes mesmo de atingirem seus servidores. Um exemplo básico desse caso de uso pode ser encontrado diretamente na documentação da AWS. Embora útil como prova de conceito, um ambiente de produção com vários experimentos simultâneos provavelmente precisaria de algo mais flexível e robusto.

Além disso, depois de trabalhar um pouco com o Lambda@Edge, você provavelmente perceberá que existem algumas nuances a serem observadas ao construir sua arquitetura.

Por exemplo, a implantação do Edge Lambdas leva tempo e seus logs são distribuídos nas regiões da AWS. Esteja atento a isso se precisar depurar sua configuração para evitar erros 502.

Este tutorial apresentará aos desenvolvedores da AWS uma maneira de implementar testes A/B do lado do servidor usando Lambda@Edge de uma maneira que possa ser reutilizada em experimentos sem modificar e reimplantar os Lambdas de borda. Ele se baseia na abordagem do exemplo na documentação da AWS e em outros tutoriais semelhantes, mas em vez de codificar as regras de alocação de tráfego no próprio Lambda, as regras são recuperadas periodicamente de um arquivo de configuração no S3 que você pode alterar a qualquer momento.

Visão geral de nossa abordagem de teste A/B Lambda@Edge

A ideia básica por trás dessa abordagem é fazer com que a CDN atribua cada usuário a um segmento e, em seguida, roteie o usuário para a configuração de origem associada. O CloudFront permite que a distribuição aponte para origens S3 ou personalizadas e, nessa abordagem, oferecemos suporte a ambos.

O mapeamento de segmentos para variantes de experimentos será armazenado em um arquivo JSON no S3. O S3 é escolhido aqui por simplicidade, mas também pode ser recuperado de um banco de dados ou qualquer outra forma de armazenamento que o Lambda de borda possa acessar.

Observação: existem algumas limitações - consulte o artigo Aproveitando dados externos no Lambda@Edge no blog da AWS para obter mais informações.

Implementação

O Lambda@Edge pode ser acionado por quatro tipos diferentes de eventos do CloudFront:

O Lambda@Edge pode ser acionado por quatro tipos diferentes de eventos do CloudFront

Nesse caso, executaremos um Lambda em cada um dos três eventos a seguir:

  • Solicitação de visualizador
  • Solicitação de origem
  • Resposta do espectador

Cada evento implementará uma etapa no seguinte processo:

  • abtesting-lambda-vreq : A maior parte da lógica está contida neste lambda. Primeiro, um cookie de ID exclusivo é lido ou gerado para a solicitação recebida e, em seguida, é reduzido para um intervalo [0, 1]. O mapa de alocação de tráfego é então obtido do S3 e armazenado em cache nas execuções. E, finalmente, o valor com hash down é usado para escolher uma configuração de origem, que é passada como um cabeçalho codificado em JSON para o próximo Lambda.
  • abtesting-lambda-oreq : lê a configuração de origem do Lambda anterior e roteia a solicitação de acordo.
  • abtesting-lambda-vres : Isso apenas adiciona o cabeçalho Set-Cookie para salvar o cookie de ID exclusivo no navegador do usuário.

Vamos também configurar três buckets do S3, dois dos quais conterão o conteúdo de cada uma das variantes do experimento, enquanto o terceiro conterá o arquivo JSON com o mapa de alocação de tráfego.

Para este tutorial, os buckets terão a seguinte aparência:

  • abtesting-ttblog -a public
    • index.html
  • abtesting-ttblog-b public
    • index.html
  • abtesting-ttblog-map privado
    • map.json

Código fonte

Primeiro, vamos começar com o mapa de alocação de tráfego:

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" } } } ] }

Cada segmento tem um peso de tráfego, que será usado para alocar uma quantidade correspondente de tráfego. Também incluímos a configuração de origem e host. O formato de configuração de origem é descrito na documentação da 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); };

Aqui, geramos explicitamente um ID exclusivo para este tutorial, mas é bastante comum que a maioria dos sites tenha algum outro ID de cliente disponível que possa ser usado. Isso também eliminaria a necessidade da resposta do espectador Lambda.

Para considerações de desempenho, as regras de alocação de tráfego são armazenadas em cache nas invocações do Lambda em vez de buscá-las no S3 em cada solicitação. Neste exemplo, configuramos um TTL de cache de 1 hora.

Observe que o cabeçalho X-ABTesting-Segment-Origin precisa estar na lista de permissões do CloudFront; caso contrário, ele será apagado da solicitação antes de atingir a solicitação de origem 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); };

A solicitação de origem do Lambda é bastante direta. A configuração de origem e o host são lidos a partir do cabeçalho X-ABTesting-Origin que foi gerado na etapa anterior e injetado na solicitação. Isso instrui o CloudFront a rotear a solicitação para a origem correspondente em caso de falta de cache.

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); }

Por fim, a resposta do visualizador Lambda é responsável por retornar o cookie de ID exclusivo gerado no cabeçalho Set-Cookie . Conforme mencionado acima, se um ID de cliente exclusivo já for usado, esse Lambda poderá ser completamente omitido.

De fato, mesmo neste caso, o cookie pode ser definido com um redirecionamento pelo Lambda da solicitação do visualizador. No entanto, isso pode adicionar alguma latência, portanto, neste caso, preferimos fazer isso em um único ciclo de solicitação-resposta.

O código também pode ser encontrado no GitHub.

Permissões do Lambda

Como acontece com qualquer Lambda de borda, você pode usar o blueprint do CloudFront ao criar o Lambda. Caso contrário, você precisará criar uma função personalizada e anexar o modelo de política “Basic Lambda@Edge Permissions”.

Para o Lambda de solicitação do visualizador, você também precisará permitir o acesso ao bucket do S3 que contém o arquivo de alocação de tráfego.

Implantando os Lambdas

A configuração do Edge Lambdas é um pouco diferente do fluxo de trabalho padrão do Lambda. Na página de configuração do Lamba, clique em “Add trigger” e selecione CloudFront. Isso abrirá uma pequena caixa de diálogo que permite associar este Lambda a uma distribuição do CloudFront.

Selecione o evento apropriado para cada um dos três Lambdas e pressione “Deploy”. Isso iniciará o processo de implantação do código de função nos servidores de borda do CloudFront.

Observação: se você precisar modificar um Lambda de borda e reimplantá-lo, primeiro será necessário publicar manualmente uma nova versão.

Configurações do CloudFront

Para que uma distribuição do CloudFront possa rotear o tráfego para uma origem, você precisará configurar cada uma separadamente no painel de origens.

A única configuração que você precisará alterar é colocar na lista de permissões o cabeçalho X-ABTesting-Segment-Origin . No console do CloudFront , escolha sua distribuição e pressione editar para alterar as configurações da distribuição.

Na página Edit Behavior , selecione Whitelist no menu suspenso na opção Cache Based on Selected Request Headers e adicione um cabeçalho X-ABTesting-Segment-Origin personalizado à lista:

Configurações do CloudFront

Se você implantou os Lambdas de borda conforme descrito na seção anterior, eles já devem estar associados à sua distribuição e listados na última seção da página Editar comportamento .

Uma boa solução com pequenas ressalvas

O teste A/B do lado do servidor pode ser desafiador para implementar corretamente em sites de alto tráfego implantados por trás de serviços CDN, como o CloudFront. Neste artigo, demonstramos como o Lambda@Edge pode ser empregado como uma nova solução para esse problema, ocultando os detalhes de implementação na própria CDN, além de oferecer uma solução limpa e confiável para executar experimentos A/B.

No entanto, Lambda@Edge tem algumas desvantagens. Mais importante ainda, essas invocações adicionais do Lambda entre eventos do CloudFront podem somar em termos de latência e custo, portanto, seu impacto em uma distribuição do CloudFront deve ser cuidadosamente medido primeiro.

Além disso, Lambda@Edge é um recurso relativamente recente e ainda em evolução da AWS, então, naturalmente, ainda parece um pouco áspero nas bordas. Usuários mais conservadores ainda podem querer esperar algum tempo antes de colocá-lo em um ponto tão crítico de sua infraestrutura.

Dito isto, as soluções exclusivas que ele oferece o tornam um recurso indispensável das CDNs, portanto, não é razoável esperar que ele se torne muito mais amplamente adotado no futuro.


Selo de parceiro de consultoria avançada da AWS