Test A/B flessibili con AWS Lambda@Edge
Pubblicato: 2022-03-11Le reti di distribuzione dei contenuti (CDN) come Amazon CloudFront erano, fino a poco tempo fa, una parte relativamente semplice dell'infrastruttura web. Tradizionalmente, le applicazioni Web sono state progettate attorno a loro, trattandole principalmente come una cache passiva anziché come un componente attivo.
Lambda@Edge e tecnologie simili hanno cambiato tutto questo e hanno aperto un intero mondo di possibilità introducendo un nuovo livello di logica tra un'applicazione web e i suoi utenti. Disponibile da metà 2017, Lambda@Edge è una nuova funzionalità di AWS che introduce il concetto di esecuzione di codice sotto forma di Lambda direttamente sui server perimetrali di CloudFront.
Una delle nuove possibilità offerte da Lambda@Edge è una soluzione pulita per i test A/B lato server. Il test A/B è un metodo comune per testare le prestazioni di più varianti di un sito Web mostrandole contemporaneamente a diversi segmenti del pubblico del sito Web.
Cosa significa Lambda@Edge per i test A/B
La principale sfida tecnica nei test A/B è segmentare correttamente il traffico in entrata senza influire in alcun modo né sulla qualità dei dati dell'esperimento né sul sito Web stesso.
Esistono due percorsi principali per implementarlo: lato client e lato server .
- Il lato client implica l'esecuzione di un po' di codice JavaScript nel browser dell'utente finale che sceglie quale variante verrà mostrata. Ci sono un paio di aspetti negativi significativi in questo approccio, in particolare, può rallentare il rendering e causare sfarfallio o altri problemi di rendering. Ciò significa che i siti Web che cercano di ottimizzare il tempo di caricamento o hanno standard elevati per la loro UX tenderanno a evitare questo approccio.
- Il test A/B lato server elimina la maggior parte di questi problemi, poiché la decisione su quale variante restituire viene presa interamente dal lato dell'host. Il browser riproduce normalmente ogni variante come se fosse la versione standard del sito web.
Con questo in mente, potresti chiederti perché tutti non usano semplicemente il test A/B lato server. Sfortunatamente, l'approccio lato server non è facile da implementare come l'approccio lato client e l'impostazione di un esperimento spesso richiede una qualche forma di intervento sul codice lato server o sulla configurazione del server.
Per complicare ulteriormente le cose, le moderne applicazioni Web come le SPA sono spesso servite come pacchetti di codice statico direttamente da un bucket S3 senza nemmeno coinvolgere un server Web. Anche quando è coinvolto un server Web, spesso non è possibile modificare la logica lato server per impostare un test A/B. La presenza di una CDN pone un ulteriore ostacolo, poiché la memorizzazione nella cache potrebbe influire sulle dimensioni dei segmenti o, al contrario, questo tipo di segmentazione del traffico può ridurre le prestazioni della CDN.
Quello che offre Lambda@Edge è un modo per indirizzare le richieste degli utenti attraverso le varianti di un esperimento prima ancora che raggiungano i tuoi server. Un esempio di base di questo caso d'uso può essere trovato direttamente nella documentazione di AWS. Sebbene utile come prova del concetto, un ambiente di produzione con più esperimenti simultanei avrebbe probabilmente bisogno di qualcosa di più flessibile e robusto.
Inoltre, dopo aver lavorato un po' con Lambda@Edge, probabilmente ti renderai conto che ci sono alcune sfumature di cui devi essere consapevole quando costruisci la tua architettura.
Ad esempio, la distribuzione degli edge Lambda richiede tempo e i relativi log vengono distribuiti tra le regioni AWS. Tienilo presente se devi eseguire il debug della tua configurazione per evitare errori 502.
Questo tutorial introdurrà gli sviluppatori AWS a un modo per implementare i test A/B lato server utilizzando Lambda@Edge in un modo che può essere riutilizzato negli esperimenti senza modificare e ridistribuire i Lambda edge. Si basa sull'approccio dell'esempio nella documentazione AWS e in altri tutorial simili, ma invece di codificare le regole di allocazione del traffico nella Lambda stessa, le regole vengono periodicamente recuperate da un file di configurazione su S3 che puoi modificare in qualsiasi momento.
Panoramica del nostro approccio di test A/B Lambda@Edge
L'idea di base alla base di questo approccio è di fare in modo che la CDN assegni ogni utente a un segmento e quindi instrada l'utente alla configurazione di origine associata. CloudFront consente alla distribuzione di puntare a origini S3 o personalizzate e, in questo approccio, supportiamo entrambi.
La mappatura dei segmenti per sperimentare le varianti verrà archiviata in un file JSON su S3. S3 viene scelto qui per semplicità, ma può anche essere recuperato da un database o da qualsiasi altra forma di archiviazione a cui l'Edge Lambda può accedere.
Nota: esistono alcune limitazioni: consulta l'articolo Sfruttare i dati esterni in Lambda@Edge sul blog AWS per ulteriori informazioni.
Implementazione
Lambda@Edge può essere attivato da quattro diversi tipi di eventi CloudFront:
In questo caso, eseguiremo un Lambda su ciascuno dei tre eventi seguenti:
- Richiesta del visualizzatore
- Richiesta di origine
- Risposta del visualizzatore
Ogni evento implementerà un passaggio nel seguente processo:
- abtesting-lambda-vreq : la maggior parte della logica è contenuta in questo lambda. In primo luogo, viene letto o generato un cookie ID univoco per la richiesta in arrivo, quindi viene eseguito l'hashing fino a un intervallo [0, 1]. La mappa di allocazione del traffico viene quindi recuperata da S3 e memorizzata nella cache tra le esecuzioni. Infine, il valore con hash down viene utilizzato per scegliere una configurazione di origine, che viene passata come intestazione con codifica JSON al Lambda successivo.
- abtesting-lambda-oreq : legge la configurazione di origine dalla Lambda precedente e instrada la richiesta di conseguenza.
- abtesting-lambda-vres : questo aggiunge semplicemente l'intestazione Set-Cookie per salvare il cookie ID univoco sul browser dell'utente.
Impostiamo anche tre bucket S3, due dei quali conterranno il contenuto di ciascuna delle varianti dell'esperimento, mentre il terzo conterrà il file JSON con la mappa di allocazione del traffico.
Per questo tutorial, i bucket avranno questo aspetto:
- abtesting-ttblog -un pubblico
- indice.html
- abtesting-ttblog-b pubblico
- indice.html
- abtesting-ttblog-map privata
- map.json
Codice sorgente
Innanzitutto, iniziamo con la mappa di allocazione del traffico:
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" } } } ] }Ogni segmento ha un peso del traffico, che verrà utilizzato per allocare una quantità di traffico corrispondente. Includiamo anche la configurazione di origine e l'host. Il formato di configurazione dell'origine è descritto nella documentazione di 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); };Qui, generiamo esplicitamente un ID univoco per questo tutorial, ma è abbastanza comune per la maggior parte dei siti Web avere qualche altro ID client in giro che potrebbe essere utilizzato invece. Ciò eliminerebbe anche la necessità della risposta del visualizzatore Lambda.

Per considerazioni sulle prestazioni, le regole di allocazione del traffico vengono memorizzate nella cache tra le chiamate Lambda invece di recuperarle da S3 a ogni richiesta. In questo esempio, impostiamo una cache TTL di 1 ora.
Tieni presente che l' X-ABTesting-Segment-Origin deve essere inserita nella whitelist in CloudFront; in caso contrario, verrà cancellato dalla richiesta prima che raggiunga la richiesta di origine 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); }; La richiesta di origine Lambda è piuttosto semplice. La configurazione di origine e l'host vengono letti dall'intestazione X-ABTesting-Origin generata nel passaggio precedente e inseriti nella richiesta. Questo indica a CloudFront di instradare la richiesta all'origine corrispondente in caso di mancanza di 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); } Infine, la risposta del visualizzatore Lambda è responsabile della restituzione del cookie ID univoco generato nell'intestazione Set-Cookie . Come accennato in precedenza, se è già utilizzato un ID client univoco, questo Lambda può essere completamente omesso.
Infatti, anche in questo caso, il cookie può essere impostato con un reindirizzamento dalla richiesta del viewer Lambda. Tuttavia, questo potrebbe aggiungere un po' di latenza, quindi in questo caso preferiamo farlo in un unico ciclo di richiesta-risposta.
Il codice può essere trovato anche su GitHub.
Autorizzazioni Lambda
Come con qualsiasi Lambda edge, puoi utilizzare il progetto CloudFront durante la creazione di Lambda. In caso contrario, dovrai creare un ruolo personalizzato e allegare il modello di criteri "Autorizzazioni Lambda di base@Edge".
Per la richiesta del visualizzatore Lambda, dovrai anche consentire l'accesso al bucket S3 che contiene il file di allocazione del traffico.
Distribuzione dei Lambda
La configurazione di Edge Lambda è in qualche modo diversa dal flusso di lavoro Lambda standard. Nella pagina di configurazione di Lamba, fai clic su "Aggiungi trigger" e seleziona CloudFront. Si aprirà una piccola finestra di dialogo che ti consentirà di associare questa Lambda a una distribuzione CloudFront.
Seleziona l'evento appropriato per ciascuno dei tre Lambda e premi "Distribuisci". Ciò avvierà il processo di distribuzione del codice funzione agli edge server di CloudFront.
Nota: se devi modificare un Lambda edge e ridistribuirlo, devi prima pubblicare manualmente una nuova versione.
Impostazioni di CloudFront
Affinché una distribuzione CloudFront sia in grado di instradare il traffico verso un'origine, dovrai configurarle separatamente nel pannello delle origini.
L'unica impostazione di configurazione che devi modificare è inserire nella whitelist l' X-ABTesting-Segment-Origin . Sulla console CloudFront , scegli la tua distribuzione e quindi premi Modifica per modificare le impostazioni della distribuzione.
Nella pagina Modifica comportamento , seleziona Whitelist dal menu a discesa dell'opzione Cache basata sulle intestazioni delle richieste selezionate e aggiungi un'intestazione X-ABTesting-Segment-Origin personalizzata all'elenco:
Se hai distribuito gli edge Lambda come descritto nella sezione precedente, dovrebbero essere già associati alla tua distribuzione ed elencati nell'ultima sezione della pagina Modifica comportamento .
Una buona soluzione con piccoli avvertimenti
Il test A/B lato server può essere difficile da implementare correttamente per i siti Web ad alto traffico distribuiti dietro servizi CDN come CloudFront. In questo articolo, abbiamo dimostrato come Lambda@Edge può essere impiegato come una nuova soluzione a questo problema nascondendo i dettagli di implementazione nella stessa CDN, offrendo anche una soluzione pulita e affidabile per eseguire esperimenti A/B.
Tuttavia, Lambda@Edge presenta alcuni inconvenienti. Ancora più importante, queste chiamate Lambda aggiuntive tra gli eventi CloudFront possono sommarsi sia in termini di latenza che di costo, quindi il loro impatto su una distribuzione CloudFront dovrebbe essere prima misurato attentamente.
Inoltre, Lambda@Edge è una funzionalità di AWS relativamente recente e ancora in evoluzione, quindi, naturalmente, sembra ancora un po' ruvida. Gli utenti più prudenti potrebbero comunque voler aspettare del tempo prima di posizionarlo in un punto così critico della loro infrastruttura.
Detto questo, le soluzioni uniche che offre lo rendono una caratteristica indispensabile delle CDN, quindi non è irragionevole aspettarsi che venga adottato molto più ampiamente in futuro.
