오후 4시 33분에 dev 프론트엔드 롤백 job을 돌렸다. 6분 뒤 화면이 하얗게 비었고, 브라우저는 mo/assets/js/index-c693f1e8.js를 요청했다가 403을 받고 있었다. 현재 서비스 중인 태그를 적어 두는 CURRENT 파일은 이미 #9로 바뀌어 있었다.
하나의 서비스를 여러 고객사가 각자 쓰는 멀티 테넌트 SaaS로 바꾸는 프로젝트에서 프론트엔드를 S3와 CloudFront로 서비스하는 Jenkins 파이프라인을 만들던 중이었다. 빌드할 때마다 결과물을 release/mo/{태그}/에 보관해 두고, 배포든 롤백이든 그 보관본을 서비스 위치 mo/로 복사하는 구조다. 원인은 그 복사에 쓴 aws s3 sync였다. 롤백은 같은 이름의 파일을 무조건 다시 쓰는 일인데, sync는 비교해서 건너뛰는 명령이다.
index.html만 롤백되지 않았다
당시 서비스 반영 단계에서는 파일 종류마다 Cache-Control을 다르게 주려고 sync를 세 번 나눠 돌렸다. 세 번 모두 --delete가 붙어 있었다.
# [1/3] assets/ 밖의 루트 파일 (favicon.ico, manifest.json 등)
aws s3 sync "$SRC" "$DST" --delete \
--cache-control "public, max-age=86400" \
--exclude "assets/*" --exclude "*.html"
# [2/3] assets/ 하위 전부. 파일명에 해시가 붙는다
aws s3 sync "$SRC" "$DST" --delete \
--cache-control "public, max-age=31536000, immutable" \
--exclude "*" --include "assets/*"
# [3/3] index.html
aws s3 sync "$SRC" "$DST" --delete \
--cache-control "no-cache, no-store, must-revalidate" \
--exclude "*" --include "*.html"
S3를 직접 보니 라이브 상태가 어긋나 있었다. mo/assets/js/에는 롤백 대상인 #9 빌드의 index-2461dab6.js가 있는데, mo/index.html은 여전히 #10 빌드의 index-c693f1e8.js를 가리키고 있었다. index.html 세 벌을 비교하면 이유가 보인다.
| 크기 | LastModified (UTC) | ETag | |
|---|---|---|---|
롤백 소스 release/…-9-…/index.html | 6733 | 04:23:03 | 82984f17… |
라이브 mo/index.html | 6733 | 07:31:19 | 01bc5920… |
직전 배포 release/…-10-…/index.html | 6733 | 07:30:48 | 01bc5920… |
라이브 ETag가 #10과 같다. 롤백 중에 index.html은 한 바이트도 바뀌지 않았다.
aws s3 sync는 목적지에 파일이 없거나, 크기가 다르거나, 소스가 더 새로울 때만 복사한다. 롤백의 index.html은 셋 다 해당하지 않았다. 참조하는 해시 문자열이 늘 8자라 바이트 수가 6733으로 같았고, 롤백 소스는 정의상 라이브보다 오래된 파일이다. 롤백은 sync가 “바꿀 필요 없음”으로 판단하는 조건을 거의 항상 갖춘 작업이다.
그래서 반쪽짜리 롤백이 됐다. assets는 파일명이 달라 #9로 복사됐고, #10의 옛 파일은 --delete로 지워졌다. index.html은 #10 그대로 남아 이미 지워진 js를 불렀다. 404가 아니라 403인 것은 OAC 버킷 정책에 s3:ListBucket이 없어서 S3가 없는 객체를 403 AccessDenied로 답하기 때문이다. 권한 문제처럼 보이지만 실제로는 파일이 없다는 뜻이었다.
돌아보면 403이 난 게 다행이었다. --delete가 없었다면 #10의 assets가 그대로 남아 화면은 멀쩡히 떴을 것이다. 롤백을 눌렀는데 롤백이 안 된 채로 아무도 몰랐을 수 있다.
복사는 전부 cp로 하고 index.html을 마지막에 올린다
처음에는 이름이 고정된 파일, 즉 index.html과 루트 파일만 cp로 바꾸고 assets는 sync로 두었다. 해시 파일명이라 같은 이름이면 같은 내용이라고 봤기 때문이다. manifest.json도 양쪽이 1,396,938바이트로 같아서 index.html과 같은 식으로 건너뛰어질 수 있었다.
그런데 같은 커밋으로 만든 두 릴리스를 비교하니 assets 3,431개 중 1,829개(53%)의 해시가 달랐다. 빌드가 재현되지 않으니 sync가 아껴 주는 것은 이름이 같은 1,602개(약 130MB)뿐이었다. 그 정도를 아끼려고 명령 두 종류를 섞어 둘 이유가 없어서 assets도 cp로 바꿨다. 이렇게 하면 “무조건 덮어쓴다”는 규칙 하나만 남는다. 캐시 정책을 바꿔 다시 배포할 때도 sync는 내용이 같은 파일의 메타데이터를 고치지 않지만, cp는 전부 다시 쓰므로 새 Cache-Control이 확실히 들어간다.
# Cache-Control은 여기서만 붙인다 (배포·롤백 공통). index.html은 마지막
# assets/ 해시 파일명 → 1년 immutable
aws s3 cp "$SRC" "$DST" --recursive \
--cache-control "public, max-age=31536000, immutable" \
--exclude "*" --include "assets/*"
# 루트 파일 → 1일
aws s3 cp "$SRC" "$DST" --recursive \
--cache-control "public, max-age=86400" \
--exclude "assets/*" --exclude "*.html"
# index.html → 캐시 금지. 이 파일이 올라가는 순간 화면이 바뀐다
aws s3 cp "$SRC" "$DST" --recursive \
--cache-control "no-cache, no-store, must-revalidate" \
--exclude "*" --include "*.html"
| 순서 | 대상 | Cache-Control | 이유 |
|---|---|---|---|
| 1 | assets/* | 1년, immutable | 내용이 바뀌면 파일명이 바뀐다 |
| 2 | 루트 파일(favicon 등) | 1일 | |
| 3 | index.html | 캐시 금지 | 이 파일이 올라가는 순간 화면이 전환된다 |
새 index.html이 가리키는 js가 먼저 올라가 있어야 하니 순서가 중요하다. --recursive는 강제 덮어쓰기 옵션이 아니라 하위 경로까지 복사하라는 뜻이다. 목적지를 보지 않고 쓰는 것은 cp라는 명령 자체의 성질이다.
옛 파일은 무효화가 끝난 뒤에 지운다
예전 방식에서는 각 sync의 --delete가 옛 파일을 그 자리에서 지웠다. 파일 3천여 개를 올리는 데는 시간이 걸리고, 그 사이 옛 index.html을 받은 브라우저는 방금 지워진 파일을 요청한다. CloudFront 엣지도 무효화가 끝나기 전까지는 옛 index.html을 내준다. 그래서 순서를 추가, 전환, 무효화, 삭제로 바꿨다. 중간에 실패해도 옛 버전이 온전히 남아 있다.
삭제는 맨 끝 단계에서 sync --delete로 한 번 한다. 처음에는 --size-only를 붙였다가 뺐다. dryrun으로 비교해 보니 있으나 없으나 결과가 같았고, 판단 기준을 크기 하나로 좁히는 옵션이라 이번 사고와 같은 방향의 함정이기도 했다. 대신 이 단계에서 복사가 0건인지 검사한다. 앞에서 cp로 전부 덮어썼으니 여기서는 삭제만 일어나야 정상이다.
# 앞에서 cp로 전부 덮어썼으므로 여기서는 삭제만 일어나야 정상
aws s3 sync "$SRC" "$DST" --delete > $LOG
DEL=$(grep -c "^delete:" $LOG || true) # grep -c는 0건이면 exit 1이라 || true가 필요하다
CP=$(grep -c "^copy:" $LOG || true)
echo "삭제 ${DEL} / 복사 ${CP} (복사는 0 이어야 정상)"
if [ "$CP" -ne 0 ]; then
echo "!! 복사가 발생. 앞 단계가 놓친 파일이 있고 Cache-Control 이 비어 있을 수 있음"
fi
복사가 잡히면 앞의 cp가 무언가를 놓쳤다는 뜻이고, 이 sync에는 --cache-control이 없으니 그 파일은 Cache-Control 없이 올라간다.
같이 넣은 것들
보관 단계에서 index.html이 가리키는
assets/*.js가 S3에 실제로 있는지 검사하고, 없으면 파이프라인을 멈춘다. 깨진 상태에 돌려 보니MISSING assets/js/index-c693f1e8.js를 정확히 잡았다.Cache-Control은 배포와 롤백이 모두 지나는 복사 단계에서만 지정한다. 그래야 롤백 때도 값이 맞게 들어간다. 보관본의 메타데이터에 기대지 않는다.
같은
mo/를 두 빌드가 동시에 쓰지 않도록disableConcurrentBuilds()를 걸었다.release/에 Lifecycle 만료를 걸면 그 기간이 곧 되돌릴 수 있는 범위가 된다. 현재 태그를 적어 두는CURRENT파일도release/아래에 있어서 만료 규칙을 걸 때 같이 따져야 한다.
복사 대신 CloudFront 오리진 경로만 바꾸는 포인터 방식으로 카나리까지 돌려 보다가 이 복사 방식으로 돌아온 과정은 카나리를 만들어 놓고 복사 방식으로 돌아왔다에서 다룬다.