AWS CloudFront 와 Lambda@Edge로 A/B 테스트하기

김현수·2022년 11월 15일
1

A/B testing on AWS CloudFront with Lambda@Edge

AWS Lambda@Edge는 나온지 얼마 안 된 기능으로, 2017년 7월부터 사용 가능했습니다.

Lambda@Edge는 Lambda 함수를 CloudFront CDN의 엣지 위치에서 실행할 수 있게 해줍니다. 이는 백엔드에 요청을 보내거나 컨텐츠 캐싱이나 클라이언트와의 지리적인 근접성을 포기하지 않고도 CDN에 지능을 심을 수 있다는 뜻입니다.

Buldit@Wipro Digital 웹사이트(아직 배포가 안 돼서 링크는 없음)를 새로 개발하면서 저희는 A/B 테스트 를 하고 싶었습니다.

웹사이트는 AWS CloudFront로 배포하므로, Lambda@Edge를 사용하기로 결정했습니다.

유즈 케이스

CDN을 통해 제공되는 정적 웹사이트 혹은 SPA를 갖고 있다고 해봅시다. 두개의 버전을 실제 사용자에게 실험을 하고자 합니다. 캐싱할 수 없는, 백엔드에서 생성되는 컨텐츠(예를 들면, 캐싱할 수 없는 REST API 요청)는 CDN을 사용하지 않기 때문에, SPA와 정적 콘텐츠에 한정해 논하겠습니다.

A/B 테스트는 반드시 모든 트래픽을 반 씩 가져가는 두 개의 버전이 있다는 것을 의미하지 않습니다. 저는 “A”“B” 보다 “메인 Main”“실험 Experiment”이라고 부르는 걸 선호하는데, 기본 버전과 두번째 버전이 있다는 점을 명확히 해주기 때문입니다. 왜 이것이 중요한지는 뒤에서 설명하겠습니다.

또한, 테스트는 컨텐츠(전체 웹 사이트 혹은 API)의 완벽한 버전들로 진행할 것입니다. 이것이 로고 이미지같은 하나의 요소만을 바꾸는 것보다 훨씬 실제적인 유즈 케이스이기 때문입니다.

실험군으로 가는 트래픽의 비율은 문제의 매개변수일 뿐이며 로직은 여러 실험 버전으로 쉽게 확장될 수 있지만… 일단은 간단하게 해보겠습니다.

서버 사이드 vs 클라이언트 사이드 A/B 테스트

일반적으로, A/B 테스트에는 클라이언트 사이드와 서버 사이드라는 두 가지 접근법이 존재하는데, 이는 두 가지 버전의 트래픽을 전환시킬 지능을 어디에 둘 지에 달려있습니다.

이 글은 서버 사이드 A/B 테스트를 다룹니다. 이는 예를 들어 페이지가 S3에서 단순히(with no brain) 제공된다면 하나 뿐인 옵션일 것입니다. 하지만 프론트엔드를 “오염”시키고 싶지 않거나 백엔드에 추가적인 로직을 작성하지 않을 때에도 좋은 선택이 될 수 있습니다.

시나리오

시나리오를 요약해 봅시다:

  • 프론트엔드 A/B 테스트: 정적 컨텐츠, SPA, 혹은 일반적으로 캐싱될 수 있는 컨텐츠
  • 컨텐츠는 CloudFront CDN으로 제공되며 CDN의 장점인 캐싱과 지리적 근접성을 포기하고 싶지 않음
  • 전환 로직으로 프론트엔드 코드를 오염시켜선 안 됨(혹은 오염시키고 싶지 않음). 따라서 로직은 손실되지 않도록 CDN에 있어야 함.
    Lambda@Edge는 CDN에 로직을 올리는 방법입니다.
  • 편의상, 컨텐츠를 S3에서 바로 제공함. 하지만 아이디어는 http/https 소스로 확장될 수 있음.
  • MainExperiment라는 두개의 완성된 버전이 있음. 이 버전들은 두개의 분리된 S3 버킷(분리된 CloudFront 배포 오리진)에서 제공됨.
  • 랜덤하게 사용자의 일부를 Experiment로 전환해야 함.
  • 사용자는 세션동안 같은 버전에 머물러야 함 (로그인을 거치지 않으므로 브라우저 “세션”을 말함).

마지막 요구사항은 매우 중요합니다. 사용자가 매 클릭마다 한 버전에서 다른 버전으로 이동하거나, 더 심하게는 같은 페이지에서 다른 버전의 컨텐츠를 받아보는 상황을 원하지 않기 때문입니다.

CloudFront 개발자 가이드AWS 블로그에 Lambda@Edge를 활용한 몇 개의 A/B 테스트 예제가 있습니다. 두 개의 예제 모두 사용자를 버전에 묶어두기 위해 쿠키를 사용하지만, 둘 다 쿠키를 세팅하기 위해 외부 로직을 필요로 합니다. 프론트엔드나 백엔드 코드를 수정하지 않을 것이 요구 사항이었으므로, 이 예제들은 우리가 활용하기에 충분하지 않으며 CDN이 쿠키를 세팅하게끔 해야 합니다.

작동 방식

3개의 함수를 사용해 작동하는 방법을 소개합니다.

X-Source라는 쿠키를 사용해 사용자를 브라우저 세션 동안 한 가지 버전에 묶어놓을 것입니다. 하지만 클라이언트가 웹사이트에 접속했을 때 쿠키를 가지고 있지 않을 수 있습니다…

  1. 브라우저 요청은 가장 가까운 AWS 엣지 위치로부터 연결됩니다. 요청은 X-Source 쿠키를 갖고 있을 수도 있습니다.
  2. Viewer Request Lambda@Edge 함수는 모든 요청마다 실행됩니다. 만약 쿠키가 요청에 담겨있지 않다면, 주사위를 굴려 사용자를 어느 버전으로 보낼지 결정하고 그 결정에 따라 쿠키를 추가합니다.
  3. Distribution은 요청이 캐시 히트를 하는지 결정합니다. X-Source 쿠키를 전달하고 쿠키가 캐시 키의 일부분이 됩니다.(아래의 CloudFront 세팅을 참고하세요)
  4. 만일 요청이 캐시 미스라면, Origin Request 함수를 호출하게 됩니다. 이 함수는 오직 캐시 미스가 발생했을 때만 실행되며 캐시 히트 시에는 오버헤드가 발생하지 않습니다. 기본적으로, Distribution은 캐시 미스를 Main 오리진으로 보냅니다. 만일 쿠키가 Experiment를 가리킨다면, 요청 오리진은 수정됩니다.
  5. 컨텐츠가 각 S3 버킷에서 제공됩니다. S3는 쿠키를 신경쓰지 않습니다.
  6. 오리진으로부터의 응답은 Origin Response 함수를 호출합니다. 이는 오직 캐시 미스 시에만 발생합니다. 이 함수는 X-Source를 세팅하기 위해 Set-Cookie 헤더를 추가합니다. 쿠키는 브라우저가 아니라 Viewer Request에 의해 추가되었을 수 있다는 것을 기억하십시오.
  7. Set-Cookie 헤더를 포함한 꾸며진(decorated) 응답은 Distribution에 의해 캐싱됩니다. 캐시 키는 객체 URI와 X-Cookie입니다.
  8. 엣지 위치에서 브라우저로 응답이 반환됩니다.
  9. 브라우저는 Set-Cookie 헤더를 컴파일하고 쿠키를 세팅합니다. 버전은 세션 동안 안정적으로 유지됩니다.
  10. 캐시 히트의 경우, 응답은 엣지 위치에서 즉시 반환되며, 이 응답은 캐싱된 Set-Cookie 헤더를 포함할 것입니다(7)

이 접근법의 장점

이 방법은 CDN 캐싱을 레버리징할 수 있어서 컨텐츠의 두 버전 모두 캐싱할 수 있습니다.

매 요청마다 실행되는 Viewer Request 함수의 추가적인 오버헤드는 무시할 수 있을 정도입니다. 모든 컨텐츠를 위해 단일한 함수를 사용하고 있으므로, 대부분의 시간 동안 “핫”하며 “콜드 스타트” 레이턴시를 피할 수 있습니다.(Node.js의 콜드 스타트는 빠르고 Lambda@Edge의 콜드 스타트는 그보다 더 빠릅니다)
제가 봤을 때의 함수 실행 시간은 대부분 <1 ms였고, 몇 가지 케이스 정도만 15..20ms 였습니다.

애플리케이션을 따로 수정하지 않아도 됩니다.

기본 동작은 Main에서 컨텐츠를 제공하는 것입니다. 이는 A/B 테스트를 하냐 마냐는 단지 Viewer Request라는 함수를 거치냐 안 거치냐의 차이임을 뜻합니다. 전환은 사용자에게 (거의) 원자적이며, (일반적으로) CDN의 분산 및 매우 궁극적으로 일관된 특성에 상관 없이 항상 동일한 엣지 위치에 히트합니다.

CloudFront 배포 세팅

CloudFront 배포는 두 개의 오리진을 가지며, 각각 MainExperiment 소스를 가리킵니다. 예제에는 두 개의 S3 버킷이 있으며 둘 모두 OAI에 의해 접근이 제한됩니다.

Default(*) 동작(Behaviour)Main 오리진을 가리킵니다.

이 동작은 반드시 화이트리스트로 X-Source 쿠키를 전달해야 합니다. 이 쿠키가 캐시 키의 일부가 됩니다. Experiment 오리진을 위한 동작은 필요하지 않습니다. 왜냐하면 오리진은 함수에 의해 동적으로 전환되기 때문입니다.

Lambda functions

함수를 구현하기 위해 Node.js를 사용하겠습니다.

Viewer Request

이 함수는 모든 클라이언트 요청을 캐시히트인지 아닌지를 결정하기 이전에 가로챕니다.

'use strict';

const sourceCoookie = 'X-Source';
const sourceMain = 'main';
const sourceExperiment = 'experiment';
const experimentTraffic = 0.5;

// Viewer request handler
exports.handler = (event, context, callback) => {
    const request = event.Records[0].cf.request;
    const headers = request.headers;

    // Look for source cookie
    if ( headers.cookie ) {
        for (let i = 0; i < headers.cookie.length; i++) {        
            if (headers.cookie[i].value.indexOf(sourceCoookie) >= 0) {
                console.log('Source cookie found. Forwarding request as-is');
                // Forward request as-is
                callback(null, request);
                return;
            }         
        }       
    }

    console.log('Source cookie has not been found. Throwing dice...');
    const source = ( Math.random() < experimentTraffic ) ? sourceExperiment : sourceMain;
    console.log(`Source: ${source}`)

    // Add Source cookie
    const cookie = `${sourceCoookie}=${source}`
    console.log(`Adding cookie header: ${cookie}`);
    headers.cookie = headers.cookie || [];
    headers.cookie.push({ key:'Cookie', value: cookie });

    // Forwarding request
    callback(null, request);
};

이 함수는 X-Source 쿠키를 찾습니다. 만약 쿠키가 없다면, 제공할 버전을 결정하기 위해 주사위를 굴리고 요청에 쿠키를 추가합니다.

Origin Request

이 함수는 캐시 미스된 요청만을 가로챕니다.

'use strict';

const sourceCoookie = 'X-Source';
const sourceMain = 'main';
const sourceExperiment = 'experiment';
const experimentBucketName = 'my-experiment.s3.amazonaws.com';
const experimentBucketRegion = 'eu-west-1';

// Origin Request handler
exports.handler = (event, context, callback) => {
    const request = event.Records[0].cf.request;
    const headers = request.headers;

    const source = decideSource(headers);

    // If Source is Experiment, change Origin and Host header
    if ( source === sourceExperiment ) {
        console.log('Setting Origin to experiment bucket');
        // Specify Origin
        request.origin = {
            s3: {
                authMethod: 'origin-access-identity',
                domainName: experimentBucketName,
                path: '',
                region: experimentBucketRegion    
            }
        };

        // Also set Host header to prevent “The request signature we calculated does not match the signature you provided” error
        headers['host'] = [{key: 'host', value: experimentBucketName }];
    }
    // No need to change anything if Source was Main or undefined
    
    callback(null, request);
};


// Decide source based on source cookie.
const decideSource = function(headers) {
    const sourceMainCookie = `${sourceCoookie}=${sourceMain}`;
    const sourceExperimenCookie = `${sourceCoookie}=${sourceExperiment}`;
    
    // Remember a single cookie header entry may contains multiple cookies
    if (headers.cookie) {
        // ...ugly but simple enough for now
        for (let i = 0; i < headers.cookie.length; i++) {        
            if (headers.cookie[i].value.indexOf(sourceExperimenCookie) >= 0) {
                console.log('Experiment Source cookie found');
                return sourceExperiment;
            }
            if (headers.cookie[i].value.indexOf(sourceMainCookie) >= 0) {
                console.log('Main Source cookie found');
                return sourceMain;
            }            
        }
    }
    console.log('No Source cookie found (Origin undecided)');
}

만일 X-Source가 존재하고 그것이 Experiment를 가리키고 있다면, 오리진이 변경됩니다. 그렇지 않다면 동작 설정에 따라 Main을 가리키며 그대로 유지됩니다.

Host 헤더는 그에 따라서 바뀌어야 하며, 그렇지 않으면 예외가 발생합니다. 예외는 “요청 서명이 일치하지 않음”을 설명할 것입니다.

Origin Response

이 함수는 캐시 미스된 응답만을 가로챕니다.

'use strict';

const sourceCoookie = 'X-Source';
const sourceMain = 'main';
const sourceExperiment = 'experiment';
const cookiePath = '/';

// Origin Response handler
exports.handler = (event, context, callback) => {
    const request = event.Records[0].cf.request;
    const requestHeaders = request.headers;
    const response = event.Records[0].cf.response;    

    const sourceMainCookie = `${sourceCoookie}=${sourceMain}`;
    const sourceExperimenCookie = `${sourceCoookie}=${sourceExperiment}`;    

    // Look for Source cookie
    // A single cookie header entry may contains multiple cookies, so it looks for a partial match
    if (requestHeaders.cookie) {
        for (let i = 0; i < requestHeaders.cookie.length; i++) {    
            // ...ugly but simple enough for now   
            if (requestHeaders.cookie[i].value.indexOf(sourceExperimenCookie) >= 0) {
                console.log('Experiment Source cookie found');
                setCookie(response, sourceExperimenCookie);
                callback(null, response);
                return;
            }
            if (requestHeaders.cookie[i].value.indexOf(sourceMainCookie) >= 0) {
                console.log('Main Source cookie found');
                setCookie(response, sourceMainCookie);
                callback(null, response);
                return;
            }            
        }
    }
    
    // If request contains no Source cookie, do nothing and forward the response as-is
    console.log('No Source cookie found');
    callback(null, response);
}

// Add set-cookie header (including path)
const setCookie = function(response, cookie) {
    const cookieValue = `${cookie}; Path=${cookiePath}`;
    console.log(`Setting cookie ${cookieValue}`);
    response.headers['set-cookie'] = [{ key: "Set-Cookie", value: cookieValue }];    
}

함수로 전달된 이벤트는 Viewer Request로 꾸며진 요청과 오리진으로부터의 응답을 포함합니다.

브라우저가 모든 미래 요청에 똑같은 쿠키를 보내도록 Set-Cookie 헤더를 추가합니다. 쿠키 Path는 객체의 경로와 상관없이 하나의 쿠키가 있다는 것을 보장합니다.

코드에 모든 매개변수가 고정되어있다는 것을 눈치채셨을 것입니다. 이는 제 실력 탓이 아닙니다. Lambda@Edge는 함수에 환경 변수로써 설정을 전달하는 것을 지원하지 않기 때문입니다.

매개변수를 고정하지 않으려면 함수 빌드 및 배포 시 매개변수를 삽입해야 합니다.

함수 실행 역할(Function Execution Role)

Lambda@Edge 함수는 “일반적인” Lambda와 같이 실행 역할을 필요로 합니다. 외부 리소스에 접근하지 않는 경우 (하지 않는 것이 좋습니다), 표준 AWSLambdaBasicExecutionRole이면 충분합니다. 이를 통해 함수가 CloudWatch에 로그를 보낼 수 있습니다.

Lambda@Edge를 CloudFront 배포에 붙이기

Lambda와 CloudFront 배포 간의 연결은 동작(Behaviour) 레벨에 있습니다.

동작을 수정하고 테스트를 하기 전 변경 사항이 완전히 전파될 때까지 기다리십시오.

모든 함수는 반드시 숫자로 된 버전으로 배포되어야 합니다($LATEST를 지원하지 않음). 이는 공식 문서에 적혀있지만 그렇게 명백한 제한(obvious limitation)은 아닙니다.

깨달음들 Gotchas

저는 Lambda@Edge가 꽤 새롭다고 생각합니다. 공식문서는 기껏해야 드물고, 예제는 대개 나이브하고 현실적이지 않습니다. 머리가 깨질 것 같은 cut&try 과정에서 많은 깨달음의 순간이 있었습니다. 몇 가지는 공식 문서에 있지만, 대부분은 숨겨져 있거나 전혀 분명하지 않습니다 (적어도 저에게는요).

N.Virginia AWS 리전만 지원한다

CloudFront Distributions에 붙이기 위해선 반드시 람다 함수를 us-east-1 리전에 만들어야 합니다.

이 제한은 글로벌 CDN에 다른 것을 붙일 때 동일하게 적용됩니다. 예를 들면 Amazon Certificate Manager에 저장된 SSL 인증서를 사용자 지정 도메인에서 https를 사용할 때도 마찬가지입니다.

Lamba@Edge에 환경을 넘길 수 없다

앞서 말했듯, 환경 변수가 지원되지 않기 때문에 설정이 코드에 고정되어야 합니다.

Lamba@Edge에는 이외에도 주목할만한 제한들을 갖고 있습니다.

CloudWatch 리전 간 실행 로그 로밍

Lambda@Edge 함수의 실행 로그는 일반적인 람다 함수와 같이 CloudWatch로 보내집니다. 하지만 리전 로그가 끝나니는 위치는 명확하지 않습니다.

로그는 함수가 실행되는 엣지 위치의 리전으로 이동합니다.

이는 클라이언트 요청이 연결되는 엣지 위치입니다. 이는 주로 클라이언트와 가장 가까운 로케이션이지만, 반드시 그렇지는 않습니다. 런던에서 보낸 제 요청이 LHR(런던 서부) 엣지 위치에서 AMS(암스테르담)으로 전환되는 것을 본 적이 있습니다. 결과적으로, Lambda@Edge 로그는 eu-west-2 리전과 eu-central-1 리전을 전환하며 로깅하게 됩니다.

로그 그룹은 함수가 저장된(실행되는 리전이 아님) 리전의 이름으로 정규화됩니다. 따라서 로그 그룹은 언제나 /aws/lambda/us-east-1.<function-name>… 입니다. 단 함수가 디버깅을 위해 AWS 콘솔에서 실행하면서 eu-east-1 에서 실행할 때는 제외입니다. 이 경우는 로그 그룹이 /aws/lambda/<function-name>이 됩니다.

오리진을 바꿀 때 Host 헤더도 바꿔야 한다

만일 오리진을 바꾼다면, 그에 따라 Host 헤더도 바꿔야 한다는 점을 잊지마세요. 그래야 “The request signature we calculated does not match the signature you provided” 에러를 피할 수 있습니다.

Origin Request에서만 오리진을 바꿔야 한다

요청 오리진은 Origin Request에서만 바뀔 수 있으며, Viewer Request에서는 불가능합니다. 이 방식으로 해야 응답이 캐싱될 수 있습니다.

오리진을 결정하는 요소(위의 예시에선 쿠키)를 전달하도록 기본 동작(Default Behaviour)를 설정하는 걸 잊지 마세요. 그렇지 않으면 Origin Request 함수가 그 요소를 받을 수 없습니다.

이 깨달음은 다음 깨달음을 가져다 줬습니다…

전달된 쿠키는 캐시 키의 일부분이다

전달된 쿠키는 오리진(예를 들어, S3)이 무시할 지라도 객체 URI와 함께 캐시 키의 일부분이 됩니다.

캐시 무효화는 객체의 모든 버전을 삭제한다
무효화는 URI로만 이루어집니다. 객체의 한 버전만을 무효화할 수 있는 방법은 없습니다. (예시: X-Source=Experiment 인 것만 무효화는 불가능)

Lambda@Edge로 쓰인 함수 삭제하기

이것이 아마도 가장 빡치는 깨달음이었습니다.

CloudFront 배포와 연결된 동안에는 분명히 Lambda 함수를 삭제할 수 없습니다. 명확하지 않은 것은, 함수를 삭제하기 전에 CDN 복제(replication)에서 함수가 완전히 제거될 때까지 기다려야 한다는 것입니다.

꽤 최근까지도 복제된 함수를 제거할 수 있는 방법이 없었는데, 유저들을 많이… 실망케 했죠.

이 글을 쓰는 시점(2018년 2월)엔, CloudFront 배포에 대한 모든 연결을 제거하면 함수 복제본이 삭제 됩니다. … 조금 있다가요.

삭제되는 데 소요되는 시간을 정확히 측정할 수 없습니다. 공식문서에도 나와있지 않으며 AWS 콘솔 또는 API/CLI로 복제 상태를 확인할 수 있는 방법도 없습니다. Distribution deployment와 관계없어 보이며, 분리된 함수를 제거하는데 30분에서 24시간이라는 유동적인 시간이 걸렸습니다.

이로 인해 엣지에서 사용되는 Lambda는 CloudFront 또는 Terraform과 같은 상태 저장 인프라 프로비저닝(stateful infrastructure provisioning) 도구로 쉽게 관리할 수 없습니다.

결론

Lambda@Edge는 흥미로운 유즈 케이스들을 공개했습니다. 불행하게도, 공식문서는 여전히 빈약하고, 예제들은 그다지 쓸모있지 않고 도구 지원이 없거나 매우 적습니다.

이러한 많은 제약이 가능한 사용을 줄입니다. 또한 엣지에서 실행하는 동안 무거운 프로세스나 외부 리소스에 접근하는 것을 피해야 합니다. 그렇지 않으면 CDN의 장점을 포기하는 셈이니까요.

한계랑 관계 없이, A/B 테스트를 구현하는 것은 그렇게 복잡하지 않습니다.

0개의 댓글