Elastyczne testy A/B z AWS Lambda@Edge

Opublikowany: 2022-03-11

Sieci dostarczania treści (CDN), takie jak Amazon CloudFront, były do ​​niedawna stosunkowo prostą częścią infrastruktury internetowej. Tradycyjnie wokół nich projektowano aplikacje webowe, traktując je głównie jako pasywną pamięć podręczną zamiast aktywnego komponentu.

Lambda@Edge i podobne technologie zmieniły to wszystko i otworzyły cały świat możliwości, wprowadzając nową warstwę logiki między aplikacją internetową a jej użytkownikami. Dostępna od połowy 2017 r. Lambda@Edge to nowa funkcja w AWS, która wprowadza koncepcję wykonywania kodu w postaci Lambd bezpośrednio na serwerach brzegowych CloudFront.

Jedną z nowych możliwości oferowanych przez Lambda@Edge jest czyste rozwiązanie do testowania A/B po stronie serwera. Testy A/B to powszechna metoda testowania wydajności wielu odmian witryny internetowej poprzez jednoczesne wyświetlanie ich różnym segmentom odbiorców witryny.

Co oznacza Lambda@Edge dla testów A/B

Głównym wyzwaniem technicznym w testach A/B jest odpowiednia segmentacja ruchu przychodzącego bez wpływu na jakość danych eksperymentu ani na samą stronę.

Istnieją dwie główne drogi jego implementacji: po stronie klienta i po stronie serwera .

  • Po stronie klienta jest uruchamiany fragment kodu JavaScript w przeglądarce użytkownika końcowego, który wybiera, który wariant zostanie wyświetlony. To podejście ma kilka istotnych wad — przede wszystkim może spowolnić renderowanie i powodować migotanie lub inne problemy z renderowaniem. Oznacza to, że strony internetowe, które starają się zoptymalizować czas ładowania lub mają wysokie standardy UX, będą raczej unikać tego podejścia.
  • Testy A/B po stronie serwera eliminują większość tych problemów, ponieważ decyzja o tym, który wariant ma zostać zwrócony, jest podejmowana wyłącznie po stronie hosta. Przeglądarka po prostu renderuje każdy wariant normalnie, tak jakby była to standardowa wersja strony internetowej.

Mając to na uwadze, możesz się zastanawiać, dlaczego wszyscy nie używają po prostu testów A/B po stronie serwera. Niestety podejście po stronie serwera nie jest tak łatwe do wdrożenia jak podejście po stronie klienta, a skonfigurowanie eksperymentu często wymaga jakiejś formy interwencji w kodzie po stronie serwera lub w konfiguracji serwera.

Aby jeszcze bardziej skomplikować sprawę, nowoczesne aplikacje internetowe, takie jak SPA, są często podawane jako pakiety kodu statycznego bezpośrednio z zasobnika S3, nawet bez angażowania serwera WWW. Nawet jeśli w grę wchodzi serwer sieciowy, często nie jest możliwa zmiana logiki po stronie serwera w celu skonfigurowania testu A/B. Obecność CDN stanowi kolejną przeszkodę, ponieważ buforowanie może wpływać na rozmiary segmentów lub odwrotnie, ten rodzaj segmentacji ruchu może obniżyć wydajność CDN.

To, co oferuje Lambda@Edge, to sposób na kierowanie żądań użytkowników przez warianty eksperymentu, zanim dotrą one do Twoich serwerów. Podstawowy przykład tego przypadku użycia można znaleźć bezpośrednio w dokumentacji AWS. Chociaż środowisko produkcyjne z wieloma równoległymi eksperymentami jest przydatne jako dowód koncepcji, prawdopodobnie wymagałoby czegoś bardziej elastycznego i niezawodnego.

Co więcej, po krótkiej pracy z Lambda@Edge, prawdopodobnie zdasz sobie sprawę, że istnieją pewne niuanse, o których należy pamiętać podczas tworzenia swojej architektury.

Na przykład wdrożenie krawędzi Lambdas zajmuje trochę czasu, a ich logi są rozprowadzane w regionach AWS. Pamiętaj o tym, jeśli musisz debugować konfigurację, aby uniknąć błędów 502.

Ten samouczek wprowadzi programistów AWS w sposób implementowania testów A/B po stronie serwera przy użyciu Lambda@Edge w sposób, który można ponownie wykorzystać w eksperymentach bez modyfikowania i ponownego wdrażania lambd brzegowych. Opiera się na podejściu z przykładu w dokumentacji AWS i innych podobnych samouczkach, ale zamiast zakodować na stałe reguły alokacji ruchu w samej Lambdzie, reguły są okresowo pobierane z pliku konfiguracyjnego na S3, który możesz zmienić w dowolnym momencie.

Przegląd naszego podejścia do testowania Lambda@Edge A/B

Podstawową ideą tego podejścia jest przypisanie przez CDN każdego użytkownika do segmentu, a następnie przekierowanie użytkownika do powiązanej konfiguracji pochodzenia. CloudFront umożliwia dystrybucję wskazującą na źródła S3 lub niestandardowe, a w tym podejściu obsługujemy oba.

Mapowanie segmentów do wariantów eksperymentalnych będzie przechowywane w pliku JSON na S3. S3 wybrano tutaj dla uproszczenia, ale można go również pobrać z bazy danych lub innej formy przechowywania, do której może uzyskać dostęp krawędź Lambda.

Uwaga: Istnieją pewne ograniczenia - sprawdź artykuł Wykorzystanie danych zewnętrznych w Lambda@Edge na blogu AWS, aby uzyskać więcej informacji.

Realizacja

Lambda@Edge może być wyzwalany przez cztery różne typy zdarzeń CloudFront:

Lambda@Edge może być wyzwalany przez cztery różne typy zdarzeń CloudFront

W tym przypadku uruchomimy Lambdę w każdym z następujących trzech zdarzeń:

  • Prośba widza
  • Żądanie pochodzenia
  • Odpowiedź widza

Każde wydarzenie będzie realizowało krok w następującym procesie:

  • abtesting-lambda-vreq : Większość logiki jest zawarta w tej lambdzie. Po pierwsze, unikalny identyfikator cookie jest odczytywany lub generowany dla przychodzącego żądania, a następnie jest haszowany do zakresu [0, 1]. Mapa alokacji ruchu jest następnie pobierana z S3 i buforowana w różnych wykonaniach. I na koniec, zaszyfrowana wartość służy do wyboru konfiguracji pochodzenia, która jest przekazywana jako nagłówek zakodowany w formacie JSON do następnej Lambdy.
  • abtesting-lambda-oreq : Odczytuje konfigurację pochodzenia z poprzedniej Lambdy i odpowiednio kieruje żądanie.
  • abtesting-lambda-vres : To po prostu dodaje nagłówek Set-Cookie , aby zapisać plik cookie z unikalnym identyfikatorem w przeglądarce użytkownika.

Skonfigurujmy też trzy zasobniki S3, z których dwa będą zawierały zawartość każdego z wariantów eksperymentu, a trzeci plik JSON z mapą alokacji ruchu.

W tym samouczku zasobniki będą wyglądać tak:

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

Kod źródłowy

Najpierw zacznijmy od mapy alokacji ruchu:

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

Każdy segment ma wagę ruchu, która zostanie użyta do przydzielenia odpowiedniej ilości ruchu. Uwzględniamy również konfigurację pochodzenia i hosta. Format konfiguracji pochodzenia jest opisany w dokumentacji 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); };

W tym miejscu jawnie generujemy unikalny identyfikator dla tego samouczka, ale większość witryn często ma inny identyfikator klienta, którego można użyć zamiast tego. Wyeliminowałoby to również potrzebę odpowiedzi widza Lambda.

Ze względów wydajnościowych reguły alokacji ruchu są buforowane w wywołaniach Lambda zamiast pobierania ich z S3 przy każdym żądaniu. W tym przykładzie ustawiamy TTL pamięci podręcznej na 1 godzinę.

Pamiętaj, że nagłówek X-ABTesting-Segment-Origin musi być umieszczony na białej liście w CloudFront; w przeciwnym razie zostanie wyczyszczony z żądania, zanim dotrze do żądania pochodzenia 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); };

Żądanie pochodzenia Lambda jest dość proste. Konfiguracja pochodzenia i host są odczytywane z nagłówka X-ABTesting-Origin , który został wygenerowany w poprzednim kroku i wstrzyknięty do żądania. Instruuje to CloudFront, aby skierować żądanie do odpowiedniego źródła w przypadku braku pamięci podręcznej.

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

Wreszcie odpowiedź przeglądarki Lambda jest odpowiedzialna za zwrócenie wygenerowanego pliku cookie z unikalnym identyfikatorem w nagłówku Set-Cookie . Jak wspomniano powyżej, jeśli unikalny identyfikator klienta jest już używany, tę Lambdę można całkowicie pominąć.

W rzeczywistości nawet w tym przypadku plik cookie może zostać ustawiony z przekierowaniem przez żądanie przeglądarki Lambda. Może to jednak zwiększyć opóźnienie, więc w tym przypadku wolimy to zrobić w jednym cyklu żądanie-odpowiedź.

Kod można również znaleźć na GitHub.

Uprawnienia lambda

Podobnie jak w przypadku każdej krawędzi Lambda, możesz użyć planu CloudFront podczas tworzenia Lambdy. W przeciwnym razie musisz utworzyć niestandardową rolę i dołączyć szablon zasad „Basic Lambda@Edge Permissions”.

W przypadku żądania przeglądarki Lambda musisz również zezwolić na dostęp do zasobnika S3, który zawiera plik alokacji ruchu.

Wdrażanie lambd

Konfigurowanie lambd brzegowych różni się nieco od standardowego przepływu pracy Lambda. Na stronie konfiguracji Lamby kliknij „Dodaj wyzwalacz” i wybierz CloudFront. Spowoduje to otwarcie małego okna dialogowego, które pozwala powiązać tę Lambdę z dystrybucją CloudFront.

Wybierz odpowiednie zdarzenie dla każdej z trzech Lambd i naciśnij „Wdróż”. Rozpocznie to proces wdrażania kodu funkcji na serwerach brzegowych CloudFront.

Uwaga: Jeśli chcesz zmodyfikować lambdę krawędziową i ponownie ją wdrożyć, musisz najpierw ręcznie opublikować nową wersję.

Ustawienia CloudFrontu

Aby dystrybucja CloudFront mogła kierować ruch do źródła, musisz skonfigurować każdy z nich osobno w panelu źródeł.

Jedynym ustawieniem konfiguracji, które musisz zmienić, jest dodanie nagłówka X-ABTesting-Segment-Origin do białej listy. W konsoli CloudFront wybierz swoją dystrybucję, a następnie naciśnij edytuj, aby zmienić ustawienia dystrybucji.

Na stronie Edytuj zachowanie wybierz Biała lista z menu rozwijanego w opcji Cache Based on Selected Request Headers i dodaj do listy niestandardowy nagłówek X-ABTesting-Segment-Origin :

Ustawienia CloudFrontu

Jeśli wdrożyłeś lambdy brzegowe zgodnie z opisem w poprzedniej sekcji, powinny one być już skojarzone z twoją dystrybucją i wymienione w ostatniej sekcji strony Edytuj zachowanie .

Dobre rozwiązanie z drobnymi zastrzeżeniami

Testy A/B po stronie serwera mogą być trudne do prawidłowego wdrożenia w przypadku witryn o dużym natężeniu ruchu, które są wdrażane za usługami CDN, takimi jak CloudFront. W tym artykule pokazaliśmy, w jaki sposób Lambda@Edge można wykorzystać jako nowatorskie rozwiązanie tego problemu, ukrywając szczegóły implementacji w samym CDN, jednocześnie oferując czyste i niezawodne rozwiązanie do przeprowadzania eksperymentów A/B.

Jednak Lambda@Edge ma kilka wad. Co najważniejsze, te dodatkowe wywołania Lambda między zdarzeniami CloudFront mogą się sumować zarówno pod względem opóźnień, jak i kosztów, więc ich wpływ na dystrybucję CloudFront należy najpierw dokładnie zmierzyć.

Co więcej, Lambda@Edge jest stosunkowo niedawną i wciąż ewoluującą funkcją AWS, więc naturalnie nadal wydaje się nieco szorstki na krawędziach. Bardziej konserwatywni użytkownicy mogą nadal chcieć poczekać trochę czasu przed umieszczeniem go w tak krytycznym punkcie swojej infrastruktury.

Biorąc to pod uwagę, unikalne rozwiązania, które oferuje, sprawiają, że jest nieodzowną cechą sieci CDN, więc nie jest nierozsądne oczekiwać, że w przyszłości stanie się znacznie szerzej przyjęta.


Odznaka zaawansowanego partnera konsultingowego AWS