Flexible A/B-Tests mit AWS Lambda@Edge
Veröffentlicht: 2022-03-11Content Delivery Networks (CDNs) wie Amazon CloudFront waren bis vor kurzem ein relativ einfacher Teil der Webinfrastruktur. Traditionell wurden Webanwendungen um sie herum entwickelt und sie meist als passiver Cache statt als aktive Komponente behandelt.
Lambda@Edge und ähnliche Technologien haben all das geändert und eine ganze Welt von Möglichkeiten eröffnet, indem sie eine neue Ebene der Logik zwischen einer Webanwendung und ihren Benutzern eingeführt haben. Lambda@Edge ist seit Mitte 2017 verfügbar und ist eine neue Funktion in AWS, die das Konzept der Ausführung von Code in Form von Lambdas direkt auf den Edge-Servern von CloudFront einführt.
Eine der neuen Möglichkeiten, die Lambda@Edge bietet, ist eine saubere Lösung für serverseitige A/B-Tests. A/B-Tests sind eine gängige Methode, um die Leistung mehrerer Variationen einer Website zu testen, indem sie gleichzeitig verschiedenen Segmenten der Zielgruppe der Website gezeigt werden.
Was Lambda@Edge für A/B-Tests bedeutet
Die größte technische Herausforderung bei A/B-Tests besteht darin, den eingehenden Datenverkehr richtig zu segmentieren, ohne die Qualität der Testdaten oder der Website selbst in irgendeiner Weise zu beeinträchtigen.
Es gibt zwei Hauptrouten für die Implementierung: clientseitig und serverseitig .
- Auf der Client-Seite wird ein bisschen JavaScript-Code im Browser des Endbenutzers ausgeführt, der auswählt, welche Variante angezeigt wird. Dieser Ansatz hat einige erhebliche Nachteile – vor allem kann er sowohl das Rendering verlangsamen als auch Flimmern oder andere Rendering-Probleme verursachen. Dies bedeutet, dass Websites, die versuchen, ihre Ladezeit zu optimieren oder hohe Anforderungen an ihre UX stellen, diesen Ansatz tendenziell vermeiden.
- Serverseitige A/B- Tests beseitigen die meisten dieser Probleme, da die Entscheidung, welche Variante zurückgegeben werden soll, vollständig auf Seiten des Hosts getroffen wird. Der Browser stellt einfach jede Variante ganz normal dar, als wäre es die Standardversion der Website.
Vor diesem Hintergrund fragen Sie sich vielleicht, warum nicht jeder einfach serverseitige A/B-Tests verwendet. Leider ist der serverseitige Ansatz nicht so einfach zu implementieren wie der clientseitige Ansatz, und das Einrichten eines Experiments erfordert häufig Eingriffe in den serverseitigen Code oder die Serverkonfiguration.
Um die Sache noch komplizierter zu machen, werden moderne Webanwendungen wie SPAs oft als Bündel statischen Codes direkt aus einem S3-Bucket bereitgestellt, ohne dass ein Webserver überhaupt beteiligt ist. Selbst wenn ein Webserver beteiligt ist, ist es oft nicht möglich, die serverseitige Logik zu ändern, um einen A/B-Test einzurichten. Das Vorhandensein eines CDN stellt ein weiteres Hindernis dar, da das Caching die Segmentgröße beeinflussen kann oder umgekehrt diese Art der Verkehrssegmentierung die Leistung des CDN verringern kann.
Was Lambda@Edge bietet, ist eine Möglichkeit, Benutzeranfragen über Varianten eines Experiments zu leiten, bevor sie überhaupt Ihre Server erreichen. Ein einfaches Beispiel für diesen Anwendungsfall finden Sie direkt in der AWS-Dokumentation. Obwohl es als Proof of Concept nützlich ist, würde eine Produktionsumgebung mit mehreren gleichzeitigen Experimenten wahrscheinlich etwas Flexibleres und Robusteres benötigen.
Darüber hinaus werden Sie, nachdem Sie ein wenig mit Lambda@Edge gearbeitet haben, wahrscheinlich feststellen, dass es beim Erstellen Ihrer Architektur einige Nuancen zu beachten gilt.
Beispielsweise nimmt die Bereitstellung der Edge-Lambdas Zeit in Anspruch, und ihre Protokolle werden über AWS-Regionen verteilt. Denken Sie daran, wenn Sie Ihre Konfiguration debuggen müssen, um 502-Fehler zu vermeiden.
Dieses Tutorial führt AWS-Entwickler in eine Möglichkeit ein, serverseitige A/B-Tests mit Lambda@Edge so zu implementieren, dass sie in Experimenten wiederverwendet werden können, ohne die Edge-Lambdas zu ändern und erneut bereitzustellen. Es baut auf dem Ansatz des Beispiels in der AWS-Dokumentation und anderen ähnlichen Tutorials auf, aber anstatt die Verkehrszuweisungsregeln in Lambda selbst fest zu codieren, werden die Regeln regelmäßig aus einer Konfigurationsdatei auf S3 abgerufen, die Sie jederzeit ändern können.
Überblick über unseren Lambda@Edge A/B-Testansatz
Die Grundidee hinter diesem Ansatz besteht darin, dass das CDN jeden Benutzer einem Segment zuweist und den Benutzer dann an die zugehörige Ursprungskonfiguration weiterleitet. CloudFront ermöglicht, dass die Verteilung entweder auf S3- oder benutzerdefinierte Ursprünge verweist, und bei diesem Ansatz unterstützen wir beides.
Die Zuordnung von Segmenten zu Experimentvarianten wird in einer JSON-Datei auf S3 gespeichert. S3 wird hier der Einfachheit halber gewählt, aber dies könnte auch aus einer Datenbank oder einer anderen Form von Speicher abgerufen werden, auf die das Edge-Lambda zugreifen kann.
Hinweis: Es gibt einige Einschränkungen – weitere Informationen finden Sie im Artikel Leveraging external data in Lambda@Edge im AWS-Blog.
Implementierung
Lambda@Edge kann durch vier verschiedene Arten von CloudFront-Ereignissen ausgelöst werden:
In diesem Fall führen wir bei jedem der folgenden drei Ereignisse ein Lambda durch:
- Zuschaueranfrage
- Origin-Anfrage
- Reaktion des Zuschauers
Jedes Ereignis implementiert einen Schritt im folgenden Prozess:
- abtesting-lambda-vreq : Der größte Teil der Logik ist in diesem Lambda enthalten. Zuerst wird ein eindeutiges ID-Cookie für die eingehende Anfrage gelesen oder generiert und dann auf einen [0, 1]-Bereich heruntergehasht. Die Verkehrszuweisungskarte wird dann von S3 abgerufen und über Ausführungen hinweg zwischengespeichert. Und schließlich wird der gehashte Wert verwendet, um eine Ursprungskonfiguration auszuwählen, die als JSON-codierter Header an das nächste Lambda übergeben wird.
- abtesting-lambda-oreq : Liest die Ursprungskonfiguration aus dem vorherigen Lambda und leitet die Anfrage entsprechend weiter.
- abtesting-lambda-vres : Dies fügt nur den Set-Cookie- Header hinzu, um das eindeutige ID-Cookie im Browser des Benutzers zu speichern.
Lassen Sie uns auch drei S3-Buckets einrichten, von denen zwei den Inhalt jeder der Varianten des Experiments enthalten, während der dritte die JSON-Datei mit der Verkehrszuweisungskarte enthält.
Für dieses Tutorial sehen die Buckets so aus:
- abtesting-ttblog -eine Öffentlichkeit
- index.html
- abtesting-ttblog-b öffentlich
- index.html
- abtesting-ttblog-karte privat
- map.json
Quellcode
Beginnen wir zunächst mit der Verkehrszuweisungskarte:
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" } } } ] }
Jedes Segment hat eine Verkehrsgewichtung, die verwendet wird, um eine entsprechende Verkehrsmenge zuzuweisen. Wir schließen auch die Ursprungskonfiguration und den Host ein. Das ursprüngliche Konfigurationsformat wird in der AWS-Dokumentation beschrieben.
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); };
Hier generieren wir ausdrücklich eine eindeutige ID für dieses Tutorial, aber es ist ziemlich üblich, dass auf den meisten Websites eine andere Client-ID herumliegt, die stattdessen verwendet werden könnte. Dies würde auch die Notwendigkeit für das Betrachterreaktions-Lambda eliminieren.

Aus Leistungsgründen werden die Verkehrszuweisungsregeln über Lambda-Aufrufe hinweg zwischengespeichert, anstatt sie bei jeder Anfrage von S3 abzurufen. In diesem Beispiel richten wir eine Cache-TTL von 1 Stunde ein.
Beachten Sie, dass der X-ABTesting-Segment-Origin
Header in CloudFront auf die Whitelist gesetzt werden muss; andernfalls wird es aus der Anfrage gelöscht, bevor es das ursprüngliche Anfrage-Lambda erreicht.
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); };
Die Ursprungsanfrage Lambda ist ziemlich einfach. Die Ursprungskonfiguration und der Host werden aus dem X-ABTesting-Origin
Header gelesen, der im vorherigen Schritt generiert und in die Anfrage eingefügt wurde. Dadurch wird CloudFront angewiesen, die Anfrage im Falle eines Cache-Fehlers an den entsprechenden Ursprung weiterzuleiten.
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); }
Schließlich ist die Viewer-Antwort Lambda dafür verantwortlich, das generierte eindeutige ID-Cookie im Set-Cookie
Header zurückzugeben. Wenn wie oben erwähnt bereits eine eindeutige Client-ID verwendet wird, kann dieses Lambda vollständig weggelassen werden.
Tatsächlich kann das Cookie auch in diesem Fall mit einer Weiterleitung durch die Zuschaueranfrage Lambda gesetzt werden. Dies könnte jedoch zu einer gewissen Latenz führen, daher ziehen wir es in diesem Fall vor, dies in einem einzigen Anfrage-Antwort-Zyklus zu tun.
Der Code ist auch auf GitHub zu finden.
Lambda-Berechtigungen
Wie bei jedem Edge-Lambda können Sie beim Erstellen des Lambda den CloudFront-Blueprint verwenden. Andernfalls müssen Sie eine benutzerdefinierte Rolle erstellen und die Richtlinienvorlage „Basic Lambda@Edge Permissions“ anhängen.
Für die Viewer-Anforderung Lambda müssen Sie auch den Zugriff auf den S3-Bucket zulassen, der die Datenverkehrszuweisungsdatei enthält.
Bereitstellen der Lambdas
Das Einrichten von Edge-Lambdas unterscheidet sich etwas vom standardmäßigen Lambda-Workflow. Klicken Sie auf der Konfigurationsseite von Lamba auf „Trigger hinzufügen“ und wählen Sie CloudFront aus. Dadurch wird ein kleines Dialogfeld geöffnet, in dem Sie dieses Lambda einer CloudFront-Verteilung zuordnen können.
Wählen Sie das entsprechende Ereignis für jedes der drei Lambdas aus und klicken Sie auf „Bereitstellen“. Dadurch wird der Prozess der Bereitstellung des Funktionscodes auf den Edge-Servern von CloudFront gestartet.
Hinweis: Wenn Sie ein Edge-Lambda ändern und erneut bereitstellen müssen, müssen Sie zuerst manuell eine neue Version veröffentlichen.
CloudFront-Einstellungen
Damit eine CloudFront-Verteilung den Datenverkehr an einen Ursprung weiterleiten kann, müssen Sie jeden einzeln im Bereich „Ursprünge“ einrichten.
Die einzige Konfigurationseinstellung, die Sie ändern müssen, besteht darin, den X-ABTesting-Segment-Origin
Header auf die Whitelist zu setzen. Wählen Sie in der CloudFront-Konsole Ihre Verteilung aus und drücken Sie dann Bearbeiten, um die Einstellungen der Verteilung zu ändern.
Wählen Sie auf der Seite „ Verhalten bearbeiten “ im Dropdown-Menü der Option „ Cache basierend auf ausgewählten Anforderungsheadern “ die Option „ Whitelist “ aus und fügen Sie der Liste einen benutzerdefinierten X-ABTesting-Segment-Origin- Header hinzu:
Wenn Sie die Edge-Lambdas wie im vorherigen Abschnitt beschrieben bereitgestellt haben, sollten sie bereits Ihrer Verteilung zugeordnet und im letzten Abschnitt der Seite „ Verhalten bearbeiten “ aufgeführt sein.
Eine gute Lösung mit kleinen Einschränkungen
Serverseitige A/B-Tests können für stark frequentierte Websites, die hinter CDN-Diensten wie CloudFront bereitgestellt werden, schwierig zu implementieren sein. In diesem Artikel haben wir gezeigt, wie Lambda@Edge als neuartige Lösung für dieses Problem eingesetzt werden kann, indem die Implementierungsdetails im CDN selbst versteckt werden und gleichzeitig eine saubere und zuverlässige Lösung für die Durchführung von A/B-Experimenten angeboten wird.
Allerdings hat Lambda@Edge einige Nachteile. Am wichtigsten ist, dass sich diese zusätzlichen Lambda-Aufrufe zwischen CloudFront-Ereignissen sowohl in Bezug auf Latenz als auch auf Kosten summieren können, sodass ihre Auswirkungen auf eine CloudFront-Verteilung zuerst sorgfältig gemessen werden sollten.
Darüber hinaus ist Lambda@Edge eine relativ neue und sich noch entwickelnde Funktion von AWS, daher fühlt es sich an den Rändern natürlich immer noch etwas rau an. Konservativere Benutzer möchten vielleicht noch einige Zeit warten, bevor sie es an einem so kritischen Punkt ihrer Infrastruktur platzieren.
Abgesehen davon machen die einzigartigen Lösungen, die es bietet, es zu einem unverzichtbaren Merkmal von CDNs, so dass es nicht unangemessen ist, davon auszugehen, dass es in Zukunft viel weiter verbreitet werden wird.