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)의 완벽한 버전들로 진행할 것입니다. 이것이 로고 이미지같은 하나의 요소만을 바꾸는 것보다 훨씬 실제적인 유즈 케이스이기 때문입니다.
실험군으로 가는 트래픽의 비율은 문제의 매개변수일 뿐이며 로직은 여러 실험 버전으로 쉽게 확장될 수 있지만… 일단은 간단하게 해보겠습니다.
일반적으로, A/B 테스트에는 클라이언트 사이드와 서버 사이드라는 두 가지 접근법이 존재하는데, 이는 두 가지 버전의 트래픽을 전환시킬 지능을 어디에 둘 지에 달려있습니다.
이 글은 서버 사이드 A/B 테스트를 다룹니다. 이는 예를 들어 페이지가 S3에서 단순히(with no brain) 제공된다면 하나 뿐인 옵션일 것입니다. 하지만 프론트엔드를 “오염”시키고 싶지 않거나 백엔드에 추가적인 로직을 작성하지 않을 때에도 좋은 선택이 될 수 있습니다.
시나리오를 요약해 봅시다:
마지막 요구사항은 매우 중요합니다. 사용자가 매 클릭마다 한 버전에서 다른 버전으로 이동하거나, 더 심하게는 같은 페이지에서 다른 버전의 컨텐츠를 받아보는 상황을 원하지 않기 때문입니다.
CloudFront 개발자 가이드와 AWS 블로그에 Lambda@Edge를 활용한 몇 개의 A/B 테스트 예제가 있습니다. 두 개의 예제 모두 사용자를 버전에 묶어두기 위해 쿠키를 사용하지만, 둘 다 쿠키를 세팅하기 위해 외부 로직을 필요로 합니다. 프론트엔드나 백엔드 코드를 수정하지 않을 것이 요구 사항이었으므로, 이 예제들은 우리가 활용하기에 충분하지 않으며 CDN이 쿠키를 세팅하게끔 해야 합니다.
3개의 함수를 사용해 작동하는 방법을 소개합니다.
X-Source
라는 쿠키를 사용해 사용자를 브라우저 세션 동안 한 가지 버전에 묶어놓을 것입니다. 하지만 클라이언트가 웹사이트에 접속했을 때 쿠키를 가지고 있지 않을 수 있습니다…
X-Source
쿠키를 갖고 있을 수도 있습니다.X-Source
쿠키를 전달하고 쿠키가 캐시 키의 일부분이 됩니다.(아래의 CloudFront 세팅을 참고하세요)Main
오리진으로 보냅니다. 만일 쿠키가 Experiment
를 가리킨다면, 요청 오리진은 수정됩니다.X-Source
를 세팅하기 위해 Set-Cookie
헤더를 추가합니다. 쿠키는 브라우저가 아니라 Viewer Request에 의해 추가되었을 수 있다는 것을 기억하십시오.Set-Cookie
헤더를 포함한 꾸며진(decorated) 응답은 Distribution에 의해 캐싱됩니다. 캐시 키는 객체 URI와 X-Cookie
입니다.Set-Cookie
헤더를 컴파일하고 쿠키를 세팅합니다. 버전은 세션 동안 안정적으로 유지됩니다.Set-Cookie
헤더를 포함할 것입니다(7)이 방법은 CDN 캐싱을 레버리징할 수 있어서 컨텐츠의 두 버전 모두 캐싱할 수 있습니다.
매 요청마다 실행되는 Viewer Request 함수의 추가적인 오버헤드는 무시할 수 있을 정도입니다. 모든 컨텐츠를 위해 단일한 함수를 사용하고 있으므로, 대부분의 시간 동안 “핫”하며 “콜드 스타트” 레이턴시를 피할 수 있습니다.(Node.js의 콜드 스타트는 빠르고 Lambda@Edge의 콜드 스타트는 그보다 더 빠릅니다)
제가 봤을 때의 함수 실행 시간은 대부분 <1 ms였고, 몇 가지 케이스 정도만 15..20ms 였습니다.
애플리케이션을 따로 수정하지 않아도 됩니다.
기본 동작은 Main에서 컨텐츠를 제공하는 것입니다. 이는 A/B 테스트를 하냐 마냐는 단지 Viewer Request라는 함수를 거치냐 안 거치냐의 차이임을 뜻합니다. 전환은 사용자에게 (거의) 원자적이며, (일반적으로) CDN의 분산 및 매우 궁극적으로 일관된 특성에 상관 없이 항상 동일한 엣지 위치에 히트합니다.
CloudFront 배포는 두 개의 오리진을 가지며, 각각 Main과 Experiment 소스를 가리킵니다. 예제에는 두 개의 S3 버킷이 있으며 둘 모두 OAI에 의해 접근이 제한됩니다.
Default(*)
동작(Behaviour)은 Main 오리진을 가리킵니다.
이 동작은 반드시 화이트리스트로 X-Source
쿠키를 전달해야 합니다. 이 쿠키가 캐시 키의 일부가 됩니다. Experiment 오리진을 위한 동작은 필요하지 않습니다. 왜냐하면 오리진은 함수에 의해 동적으로 전환되기 때문입니다.
함수를 구현하기 위해 Node.js를 사용하겠습니다.
이 함수는 모든 클라이언트 요청을 캐시히트인지 아닌지를 결정하기 이전에 가로챕니다.
'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
쿠키를 찾습니다. 만약 쿠키가 없다면, 제공할 버전을 결정하기 위해 주사위를 굴리고 요청에 쿠키를 추가합니다.
이 함수는 캐시 미스된 요청만을 가로챕니다.
'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
헤더는 그에 따라서 바뀌어야 하며, 그렇지 않으면 예외가 발생합니다. 예외는 “요청 서명이 일치하지 않음”을 설명할 것입니다.
이 함수는 캐시 미스된 응답만을 가로챕니다.
'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는 함수에 환경 변수로써 설정을 전달하는 것을 지원하지 않기 때문입니다.
매개변수를 고정하지 않으려면 함수 빌드 및 배포 시 매개변수를 삽입해야 합니다.
Lambda@Edge 함수는 “일반적인” Lambda와 같이 실행 역할을 필요로 합니다. 외부 리소스에 접근하지 않는 경우 (하지 않는 것이 좋습니다), 표준 AWSLambdaBasicExecutionRole
이면 충분합니다. 이를 통해 함수가 CloudWatch에 로그를 보낼 수 있습니다.
Lambda와 CloudFront 배포 간의 연결은 동작(Behaviour) 레벨에 있습니다.
동작을 수정하고 테스트를 하기 전 변경 사항이 완전히 전파될 때까지 기다리십시오.
모든 함수는 반드시 숫자로 된 버전으로 배포되어야 합니다($LATEST를 지원하지 않음). 이는 공식 문서에 적혀있지만 그렇게 명백한 제한(obvious limitation)은 아닙니다.
저는 Lambda@Edge가 꽤 새롭다고 생각합니다. 공식문서는 기껏해야 드물고, 예제는 대개 나이브하고 현실적이지 않습니다. 머리가 깨질 것 같은 cut&try 과정에서 많은 깨달음의 순간이 있었습니다. 몇 가지는 공식 문서에 있지만, 대부분은 숨겨져 있거나 전혀 분명하지 않습니다 (적어도 저에게는요).
CloudFront Distributions에 붙이기 위해선 반드시 람다 함수를 us-east-1
리전에 만들어야 합니다.
이 제한은 글로벌 CDN에 다른 것을 붙일 때 동일하게 적용됩니다. 예를 들면 Amazon Certificate Manager에 저장된 SSL 인증서를 사용자 지정 도메인에서 https를 사용할 때도 마찬가지입니다.
앞서 말했듯, 환경 변수가 지원되지 않기 때문에 설정이 코드에 고정되어야 합니다.
Lamba@Edge에는 이외에도 주목할만한 제한들을 갖고 있습니다.
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
헤더도 바꿔야 한다는 점을 잊지마세요. 그래야 “The request signature we calculated does not match the signature you provided” 에러를 피할 수 있습니다.
요청 오리진은 Origin Request에서만 바뀔 수 있으며, Viewer Request에서는 불가능합니다. 이 방식으로 해야 응답이 캐싱될 수 있습니다.
오리진을 결정하는 요소(위의 예시에선 쿠키)를 전달하도록 기본 동작(Default Behaviour)를 설정하는 걸 잊지 마세요. 그렇지 않으면 Origin Request 함수가 그 요소를 받을 수 없습니다.
이 깨달음은 다음 깨달음을 가져다 줬습니다…
전달된 쿠키는 오리진(예를 들어, S3)이 무시할 지라도 객체 URI와 함께 캐시 키의 일부분이 됩니다.
캐시 무효화는 객체의 모든 버전을 삭제한다
무효화는 URI로만 이루어집니다. 객체의 한 버전만을 무효화할 수 있는 방법은 없습니다. (예시: X-Source=Experiment
인 것만 무효화는 불가능)
이것이 아마도 가장 빡치는 깨달음이었습니다.
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 테스트를 구현하는 것은 그렇게 복잡하지 않습니다.