9월 8일 오후 4시 1분, dev 프론트엔드 배포 job이 Switch origin 단계에서 멈춰 있었다. CloudFront 배포 상태는 InProgress였고, 6분 40초가 지나도 Deployed로 바뀌지 않았다. 바꾼 것은 오리진 하나의 OriginPath 값 하나였다. 나흘 전 같은 변경을 재 봤을 때는 1분 7초였다.
하나의 서비스를 고객사마다 따로 쓰는 멀티 테넌트 SaaS로 바꾸는 프로젝트에서, 프론트엔드를 S3와 CloudFront로 서비스하는 Jenkins 파이프라인을 만들던 중이었다. 원래는 빌드 결과물을 S3 서비스 위치로 복사하는 단순한 구조였다. 그걸 “CloudFront가 어느 릴리스를 볼지 가리키는 포인터만 바꾸는” 구조로 바꾼 것은 오로지 카나리 배포 때문이었다. 그런데 바로 그날 오전에 카나리를 dev에 넣지 않기로 정했다. 포인터 방식은 카나리를 위한 구조였고, 카나리가 빠지자 설정 전파를 기다리는 비용만 남았다.
promote는 파일이 아니라 설정을 복사한다
출발점에서는 태그별 보관본(release/mo/{태그}/)을 mo/로 복사해서 서비스했고 롤백도 같은 복사였다. 이 복사에 sync 대신 cp를 쓰게 된 경위는 롤백 때 index.html을 건너뛰는 aws s3 sync에 따로 적었다.
카나리 이야기는 프론트엔드가 아니라 CloudFront Function에서 시작됐다. 기본 behavior(*)에 붙은 함수는 모든 API 요청이 지나가서 잘못 배포하면 뒤쪽 전체가 에러를 받는다. CloudFront Continuous Deployment(CD)는 primary distribution 옆에 staging distribution을 두고 요청 일부를 staging으로 보낸 뒤, 확인이 끝나면 promote로 staging 설정을 primary에 복사한다. 가중치 라우팅은 최대 15%라 처음 그린 15%, 50%, 100% 세 단계 중 50%는 처음부터 불가능했다. 함수 하나 안에 신·구 로직을 같이 두고 테넌트 ID 해시로 나누는 대안도 있었지만, CD는 함수만이 아니라 캐시 정책, 오리진, behavior까지 CDN 설정 전체를 실제 경로로 시험할 수 있어서 CD를 바탕으로 깔았다.
정적 프론트엔드도 같은 방식으로 미리 보고 싶어지자 문제가 생겼다. 버킷에 staging/mo/를 하나 더 두고 staging의 OriginPath를 /staging으로 거는 방법은 promote 한 번에 무너진다. promote는 S3 파일을 옮기지 않고 distribution 설정만 복사하니, 승격하고 나면 primary의 OriginPath도 /staging이 된다. 다음 카나리 배포로 staging/mo/를 덮어쓰는 순간 그게 곧바로 실서비스에 나간다.
staging OriginPath: /staging → staging/mo/ 를 봄 ← 신버전 확인
promote
primary OriginPath: /staging → primary 도 staging/mo/ 를 봄
그래서 바뀌는 것이 설정뿐이게 만들었다. 파일은 보관본 그대로 두고 OriginPath에 릴리스 태그를 직접 적는다. 배포는 OriginPath를 새 태그로, 롤백은 이전 태그로 바꾸는 일이 된다. OriginPath는 behavior가 아니라 오리진 단위 속성이라 모바일 웹, 관리자, 커머스 세 앱의 오리진을 s3-mo, s3-cpn, s3-ecommerce로 나눴다. CloudFront는 OriginPath 뒤에 요청 URI를 그대로 붙여 S3 키를 만들기 때문에 보관 경로에도 앱 이름을 한 겹 더 넣어 release/mo/{태그}/mo/index.html이 되게 했다.
promote가 성공하면 CD 정책이 꺼진다
트래픽은 가중치 대신 헤더로 나눴다. aws-cf-cd-로 시작하는 헤더를 붙인 요청만 staging으로 가니 실사용자 유입은 0이고, 개발자만 브라우저 확장으로 헤더를 붙여 새 버전을 먼저 본다. 9월 4일 밤에 정적 파일과 Function 두 갈래 모두 canary 배포부터 promote까지 끝까지 돌렸다.
헤더 없음 → primary → index-e3dcc798.js (릴리스 -29)
헤더 있음 → staging → index-c693f1e8.js (릴리스 -30)
그 E2E 도중에 canary로 새 릴리스를 올렸는데, 헤더를 붙이든 안 붙이든 같은 js 해시가 내려왔다. staging의 OriginPath는 새 태그로 정확히 바뀌어 있었다. 원인은 CD 정책이었다. Enabled가 false였고, 수정 시각은 그날 저녁 부품 검증으로 돌린 no-op promote 시점이었다.
promote가 성공하면 AWS가 CD 정책을 비활성으로 바꾸고 모든 트래픽을 primary로 보낸다. 공식 문서의 promote 절에 적혀 있는 동작인데 나는 실측으로 먼저 알았다. 정책이 꺼져 있으면 헤더 요청이 에러 없이 조용히 primary로 간다. 카나리가 안 되는데 아무것도 실패하지 않으니, canary 배포가 끝날 때마다 정책을 다시 켜게 했다.
# promote 가 성공하면 AWS 가 CD 정책을 자동 비활성화하므로, canary 배포 때마다 다시 켠다
if [ "${CANARY}" = "true" ]; then
cf-cd.sh policy ${CD_POLICY_ID} enable
fi
라우팅이 됐는지 확인하는 방법도 바꿨다. 같은 커밋으로 다시 빌드하면 index.html이 참조하는 해시가 같게 나올 때가 있어서(-10과 -30이 같았다) 해시 비교로는 구분이 안 됐다. 승격 job에는 staging 릴리스에만 있는 js 경로를 요청해 헤더 있음 200, 헤더 없음 403이 나오는지 보는 Probe 단계를 넣었다.
카나리를 dev에 넣지 않기로 하자 남은 것
9월 8일 오전, PoC를 정리해 dev에 정식으로 반영할 준비를 하면서 팀에서 카나리는 dev에 넣지 않기로 정했다. 가능성을 확인한 것으로 충분하고, 나중에 운영사 운영팀에 파이프라인을 넘길 때 이런 기능을 써 볼 수 있다고 제안만 하기로 했다.
그러고 나서 맞은 것이 첫머리의 6분 40초다. 포인터 방식에서는 배포 한 번이 CloudFront 설정 변경 한 번이고, 파이프라인은 그 설정이 전 세계 엣지에 퍼질 때까지 wait distribution-deployed로 기다린다. PoC에서 staging OriginPath를 바꾸고 되돌리는 데는 각각 1분 6초, 1분 7초가 걸렸는데, dev에서 같은 종류의 변경 하나가 6분을 넘겼다. 이 시간은 우리가 줄일 수 없다.
결정적인 건 롤백이었다. 포인터 방식의 장점으로 “즉시 롤백”을 꼽았는데, 롤백도 설정 변경이라 전파를 똑같이 기다린다. 느린 날에는 롤백이 6분 걸린다. 카나리가 없으면 포인터 방식이 남기는 것은 “복사 코드가 없어 깔끔하다”뿐이고, 그 대가로 배포 시간의 통제권을 CloudFront 전파에 넘긴다. --no-wait로 전파를 기다리지 않는 방법도 있었지만, 그러면 파이프라인이 “지금 반영됐다”를 확인할 수 없다. 운영팀에 넘길 파이프라인에 “끝났다고 나오지만 아직 아닐 수 있다”는 단서를 붙이고 싶지 않았다. 그날 오후 복사 방식으로 돌아가기로 했다.
아카이브 구조는 남기고 복사만 되살렸다
되돌리면서 보관 경로는 포인터 방식의 것, release/{앱}/{태그}/{앱}/을 그대로 뒀다. 그날까지 만든 릴리스가 모두 이 구조라, 경로를 옛날로 돌리면 그 릴리스들로 롤백할 수 없다. 바뀐 것은 복사 원본 경로 하나다. release/mo/{태그}/mo/를 mo/로 복사한다.
옮기는 순서가 중요했다. dev의 오리진 세 개는 아직 OriginPath로 release/…를 가리키고 있었고, mo/는 비어 있었다. OriginPath부터 비우면 CloudFront가 빈 mo/를 보게 되어 화면이 사라진다.
① 복사 파이프라인으로 mo / cpn / ecommerce 각 1회 deploy
{앱}/ 이 채워진다. OriginPath 가 아직 release/… 를 보므로 사용자 영향 없음
② 오리진의 OriginPath 를 "" 로 비운다 ← 마지막 CloudFront 설정 변경 (전파 1회)
이 순간부터 {앱}/ 을 서비스
③ 이후 배포는 S3 복사와 무효화만
②를 하는 김에 오리진도 하나로 합쳤다. OriginPath를 전부 비우면 세 오리진은 같은 버킷, 같은 OAC, 같은 빈 경로라 구별할 수 없는 세 쌍둥이가 된다. 어차피 해야 하는 설정 변경 한 번에 담으면 전파를 두 번 기다리지 않아도 된다. SA가 IaC로 오리진을 s3-frontend 하나로 통합하면서 OriginPath를 비웠다. 전환 뒤에는 mo/ 3,434개, cpn/ 122개, ecommerce/ 123개 객체가 서비스 위치에 있었고, 여섯 경로와 각 앱이 참조하는 js가 모두 200을 돌려줬다. 프론트엔드 파이프라인은 이제 CloudFront 설정을 건드리지 않으니 UpdateDistribution 권한도 필요 없다.
카나리 코드는 버리지 않고 레포의 별도 폴더에 남겼다. 다만 이 CloudFront 앞단 구조 자체도 결국 적용하지 않았고, 그 경위는 다 만들고 적용하지 않은 CloudFront 앞단에 있다.