Tests A/B flexibles avec AWS Lambda@Edge

Publié: 2022-03-11

Les réseaux de diffusion de contenu (CDN) comme Amazon CloudFront étaient, jusqu'à récemment, une partie relativement simple de l'infrastructure Web. Traditionnellement, les applications Web étaient conçues autour d'eux, les traitant principalement comme un cache passif au lieu d'un composant actif.

Lambda@Edge et les technologies similaires ont changé tout cela et ouvert tout un monde de possibilités en introduisant une nouvelle couche de logique entre une application Web et ses utilisateurs. Disponible depuis mi-2017, Lambda@Edge est une nouvelle fonctionnalité d'AWS qui introduit le concept d'exécution de code sous forme de Lambdas directement sur les serveurs périphériques de CloudFront.

L'une des nouvelles possibilités offertes par Lambda@Edge est une solution propre pour les tests A/B côté serveur. Les tests A/B sont une méthode courante pour tester les performances de plusieurs variantes d'un site Web en les montrant en même temps à différents segments de l'audience du site Web.

Ce que Lambda@Edge signifie pour les tests A/B

Le principal défi technique des tests A/B est de segmenter correctement le trafic entrant sans affecter ni la qualité des données de l'expérience ni le site Web lui-même.

Il existe deux voies principales pour l'implémenter : côté client et côté serveur .

  • Le côté client implique l'exécution d'un peu de code JavaScript dans le navigateur de l'utilisateur final qui choisit la variante qui sera affichée. Cette approche présente quelques inconvénients importants, notamment le fait qu'elle peut à la fois ralentir le rendu et provoquer des scintillements ou d'autres problèmes de rendu. Cela signifie que les sites Web qui cherchent à optimiser leur temps de chargement ou qui ont des normes élevées pour leur UX auront tendance à éviter cette approche.
  • Les tests A/B côté serveur éliminent la plupart de ces problèmes, car la décision sur la variante à renvoyer est entièrement prise du côté de l'hôte. Le navigateur restitue simplement chaque variante normalement comme s'il s'agissait de la version standard du site Web.

Dans cet esprit, vous vous demandez peut-être pourquoi tout le monde n'utilise pas simplement les tests A/B côté serveur. Malheureusement, l'approche côté serveur n'est pas aussi facile à mettre en œuvre que l'approche côté client, et la mise en place d'une expérience nécessite souvent une certaine forme d'intervention sur le code côté serveur ou la configuration du serveur.

Pour compliquer encore plus les choses, les applications Web modernes telles que les SPA sont souvent servies sous forme d'ensembles de code statique directement à partir d'un compartiment S3 sans même impliquer un serveur Web. Même lorsqu'un serveur Web est impliqué, il n'est souvent pas possible de modifier la logique côté serveur pour configurer un test A/B. La présence d'un CDN pose un autre obstacle, car la mise en cache peut affecter la taille des segments ou, à l'inverse, ce type de segmentation du trafic peut réduire les performances du CDN.

Ce que propose Lambda@Edge, c'est un moyen d'acheminer les demandes des utilisateurs entre les variantes d'une expérience avant même qu'elles n'atteignent vos serveurs. Un exemple de base de ce cas d'utilisation peut être trouvé directement dans la documentation AWS. Bien qu'utile comme preuve de concept, un environnement de production avec plusieurs expériences simultanées aurait probablement besoin de quelque chose de plus flexible et robuste.

De plus, après avoir travaillé un peu avec Lambda@Edge, vous vous rendrez probablement compte qu'il y a certaines nuances à prendre en compte lors de la construction de votre architecture.

Par exemple, le déploiement des Lambda périphériques prend du temps et leurs journaux sont distribués dans les régions AWS. Tenez-en compte si vous devez déboguer votre configuration pour éviter les erreurs 502.

Ce didacticiel présentera aux développeurs AWS un moyen de mettre en œuvre des tests A/B côté serveur à l'aide de Lambda@Edge d'une manière qui peut être réutilisée dans les expériences sans modifier ni redéployer les Lambdas périphériques. Il s'appuie sur l'approche de l'exemple de la documentation AWS et d'autres didacticiels similaires, mais au lieu de coder en dur les règles d'allocation du trafic dans Lambda lui-même, les règles sont périodiquement récupérées à partir d'un fichier de configuration sur S3 que vous pouvez modifier à tout moment.

Présentation de notre approche de test A/B Lambda@Edge

L'idée de base derrière cette approche est que le CDN affecte chaque utilisateur à un segment, puis achemine l'utilisateur vers la configuration d'origine associée. CloudFront permet à la distribution de pointer vers des origines S3 ou personnalisées, et dans cette approche, nous prenons en charge les deux.

Le mappage des segments aux variantes de test sera stocké dans un fichier JSON sur S3. S3 est choisi ici pour sa simplicité, mais cela pourrait également être récupéré à partir d'une base de données ou de toute autre forme de stockage à laquelle la périphérie Lambda peut accéder.

Remarque : il existe certaines limitations - consultez l'article Tirer parti des données externes dans Lambda@Edge sur le blog AWS pour plus d'informations.

Mise en œuvre

Lambda@Edge peut être déclenché par quatre types d'événements CloudFront différents :

Lambda@Edge peut être déclenché par quatre types différents d'événements CloudFront

Dans ce cas, nous exécuterons une Lambda sur chacun des trois événements suivants :

  • Demande de spectateur
  • Demande d'origine
  • Réponse du spectateur

Chaque événement mettra en œuvre une étape dans le processus suivant :

  • abtesting-lambda-vreq : La majeure partie de la logique est contenue dans ce lambda. Tout d'abord, un cookie d'identification unique est lu ou généré pour la demande entrante, puis il est haché jusqu'à une plage [0, 1]. La carte d'allocation du trafic est ensuite extraite de S3 et mise en cache lors des exécutions. Et enfin, la valeur hachée est utilisée pour choisir une configuration d'origine, qui est transmise en tant qu'en-tête codé JSON au Lambda suivant.
  • abtesting-lambda-oreq : Cela lit la configuration d'origine du Lambda précédent et achemine la demande en conséquence.
  • abtesting-lambda-vres : cela ajoute simplement l'en-tête Set-Cookie pour enregistrer le cookie d'identification unique sur le navigateur de l'utilisateur.

Configurons également trois buckets S3, dont deux contiendront le contenu de chacune des variantes de l'expérience, tandis que le troisième contiendra le fichier JSON avec la carte d'allocation du trafic.

Pour ce tutoriel, les buckets ressembleront à ceci :

  • abtesting-ttblog -a public
    • index.html
  • abtesting-ttblog-b public
    • index.html
  • abtesting-ttblog-carte privée
    • map.json

Code source

Commençons d'abord par la carte de répartition du trafic :

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

Chaque segment a un poids de trafic, qui sera utilisé pour allouer une quantité de trafic correspondante. Nous incluons également la configuration d'origine et l'hôte. Le format de configuration d'origine est décrit dans la documentation 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); };

Ici, nous générons explicitement un identifiant unique pour ce didacticiel, mais il est assez courant que la plupart des sites Web aient un autre identifiant client qui pourrait être utilisé à la place. Cela éliminerait également le besoin de la réponse du spectateur Lambda.

Pour des raisons de performances, les règles d'allocation du trafic sont mises en cache dans les appels Lambda au lieu de les récupérer à partir de S3 à chaque demande. Dans cet exemple, nous avons mis en place un cache TTL de 1 heure.

Notez que l' X-ABTesting-Segment-Origin doit figurer sur la liste blanche dans CloudFront ; sinon, il sera effacé de la demande avant qu'il n'atteigne la demande d'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 requête d'origine Lambda est assez simple. La configuration d'origine et l'hôte sont lus à partir de l'en-tête X-ABTesting-Origin qui a été généré à l'étape précédente et injecté dans la requête. Cela indique à CloudFront d'acheminer la demande vers l'origine correspondante en cas d'échec du 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); }

Enfin, la réponse de l'utilisateur Lambda est chargée de renvoyer le cookie d'ID unique généré dans l'en-tête Set-Cookie . Comme mentionné ci-dessus, si un ID client unique est déjà utilisé, ce Lambda peut être complètement omis.

En fait, même dans ce cas, le cookie peut être défini avec une redirection par la requête du spectateur Lambda. Cependant, cela pourrait ajouter un peu de latence, donc dans ce cas, nous préférons le faire en un seul cycle requête-réponse.

Le code peut également être trouvé sur GitHub.

Autorisations Lambda

Comme avec n'importe quel Lambda périphérique, vous pouvez utiliser le blueprint CloudFront lors de la création du Lambda. Sinon, vous devrez créer un rôle personnalisé et attacher le modèle de stratégie « Basic Lambda@Edge Permissions ».

Pour la demande de visionneuse Lambda, vous devrez également autoriser l'accès au compartiment S3 qui contient le fichier d'allocation du trafic.

Déployer les Lambda

La configuration de Lambdas périphériques est quelque peu différente du flux de travail Lambda standard. Sur la page de configuration de Lamba, cliquez sur « Ajouter un déclencheur » et sélectionnez CloudFront. Cela ouvrira une petite boîte de dialogue qui vous permettra d'associer ce Lambda à une distribution CloudFront.

Sélectionnez l'événement approprié pour chacun des trois Lambdas et appuyez sur "Déployer". Cela lancera le processus de déploiement du code de fonction sur les serveurs périphériques de CloudFront.

Remarque : si vous devez modifier un Lambda périphérique et le redéployer, vous devez d'abord publier manuellement une nouvelle version.

Paramètres CloudFront

Pour qu'une distribution CloudFront puisse acheminer le trafic vers une origine, vous devez configurer chacune séparément dans le panneau des origines.

Le seul paramètre de configuration que vous devrez modifier est de mettre en liste blanche l' X-ABTesting-Segment-Origin . Sur la console CloudFront , choisissez votre distribution, puis appuyez sur modifier pour modifier les paramètres de la distribution.

Sur la page Modifier le comportement , sélectionnez Liste blanche dans le menu déroulant de l'option Cache basé sur les en-têtes de requête sélectionnés et ajoutez un en-tête X-ABTesting-Segment-Origin personnalisé à la liste :

Paramètres CloudFront

Si vous avez déployé les Lambda périphériques comme décrit dans la section précédente, ils doivent déjà être associés à votre distribution et répertoriés dans la dernière section de la page Modifier le comportement .

Une bonne solution avec des mises en garde mineures

Les tests A/B côté serveur peuvent être difficiles à mettre en œuvre correctement pour les sites Web à fort trafic déployés derrière des services CDN tels que CloudFront. Dans cet article, nous avons démontré comment Lambda@Edge peut être utilisé comme une nouvelle solution à ce problème en cachant les détails de mise en œuvre dans le CDN lui-même, tout en offrant une solution propre et fiable pour exécuter des expériences A/B.

Cependant, Lambda@Edge présente quelques inconvénients. Plus important encore, ces appels Lambda supplémentaires entre les événements CloudFront peuvent s'additionner en termes de latence et de coût, de sorte que leur impact sur une distribution CloudFront doit être soigneusement mesuré en premier.

De plus, Lambda@Edge est une fonctionnalité d'AWS relativement récente et en constante évolution, donc naturellement, elle semble encore un peu rugueuse sur les bords. Les utilisateurs plus conservateurs voudront peut-être encore attendre un certain temps avant de le placer à un point aussi critique de leur infrastructure.

Cela étant dit, les solutions uniques qu'il offre en font une caractéristique indispensable des CDN, il n'est donc pas déraisonnable de s'attendre à ce qu'il soit beaucoup plus largement adopté à l'avenir.


Badge de partenaire consultant avancé AWS