배포와 인프라10 분 읽기

페이지 160개가 스타일 없이 서비스되고 있었습니다. 제 브라우저에서만 멀쩡했습니다

repo 전체 미러 업로드가 공용 스타일시트를 넉 달 전 파일로 덮었습니다. 파일은 있었고, 크기만 2.5KB였습니다. 잃어버린 원본은 Chrome 디스크 캐시에서 복구했습니다.

#deployment#gotchas#reality-check#debugging#static-sites
Left panel: a 2,581-byte stylesheet with v1 class names matching none of the live v3 markup. Right panel: a 24KB stylesheet served under a new filename to bypass the edge cache.
파일은 있었습니다. 2,581바이트짜리 넉 달 전 파일이었습니다.

제보는 짧았습니다. "그 페이지 열리는데 내용이 없어."

열어봤습니다. 제 브라우저에서는 멀쩡했습니다. 이럴 때 보통 캐시나 지역 문제를 의심하게 되는데, 이번엔 그 의심이 정확히 반대였습니다. 정상으로 보이는 쪽이 제 브라우저였습니다.

질문 하나 드리겠습니다. 당신 사이트가 지금 방문자에게 실제로 무엇을 주는지, 당신 브라우저 말고 어디서 확인하십니까?

내용은 다 있었습니다

HTML을 받아 보니 본문이 전부 있었습니다. 제목도, 문단도, 링크도 다 있습니다. 없는 건 스타일이었습니다.

assets/style.css 를 열었습니다. 2,581바이트. 넉 달 전 판이었습니다. 클래스 이름이 header.top, .card, .grid — 1세대 마크업 기준입니다.

그런데 지금 라이브에 있는 페이지 160개는 3세대 마크업입니다. site-header, product-hero, doc-summary-item. 즉 매칭되는 규칙이 하나도 없습니다. 앱 상세 페이지, 법무 페이지, 지원 페이지, 그리고 앱 디렉터리까지 전부 무스타일로 서비스되고 있었습니다.

범인은 배포 스크립트였습니다. upload.sh 안의 lftp mirror --reverse 는 repo 전체를 라이브에 덮어씁니다. 그리고 그 repo는 라이브보다 한 세대 낡은 사본이었습니다. 미러는 "동기화"처럼 들리지만 실제로는 한 방향 덮어쓰기입니다.

브라우저 캐시가 사고를 통째로 가립니다

이게 며칠 동안 안 보였던 이유입니다. 제 브라우저에는 이전 요청 때 받은 3세대 CSS가 남아 있었습니다. 그래서 같은 URL을 열어도 화면이 정상입니다. 서버는 이미 망가진 파일을 주고 있는데, 저만 그걸 안 받습니다.

원본이 실제로 무엇을 주는지 보는 방법은 둘입니다.

  • 쿼리스트링을 붙여 요청합니다. style.css?cachebust=1 처럼 하면 캐시 키가 달라져 새로 받습니다.
  • 서버에서 자기 도메인을 curl 합니다. 다만 이건 엣지를 볼 수도 있습니다 — 이번에 원본을 24KB로 고친 뒤에도 계속 2,581이 와서, 그제서야 Cloudflare가 물고 있다는 걸 알았습니다.

여기서 얻은 규칙 하나. ls -la 로 "파일이 있다"를 확인하는 것은 아무 의미가 없습니다. 이번 사고의 전부가 2.5KB냐 24KB냐였습니다. 존재가 아니라 크기와 내용을 봐야 합니다.

여기서 당신이라면?

원본이 덮였고, 덮어쓴 쪽 repo에는 최신 CSS가 없습니다. 생성기는 다른 머신에 있습니다. 백업에도 없습니다. 선택지가 둘입니다.

  1. 3세대 CSS를 처음부터 다시 쓴다.
  2. 어딘가 남아 있는 사본을 찾는다.

1번은 160페이지가 실제로 쓰는 클래스를 전부 복원해야 하고, 미묘하게 다른 결과가 나옵니다. 저는 2번을 시도했습니다. 그리고 사본은 있었습니다 — 제 브라우저 안에 있었습니다. 사고를 가리고 있던 바로 그 캐시입니다.

Chrome 디스크 캐시에서 자산을 복구합니다

~/Library/Caches/Google/Chrome/<Profile>/Cache/Cache_Data/

이 안의 캐시 파일들에서 gzip 매직 바이트 \x1f\x8b 를 찾고, 그 지점부터 zlib.decompressobj(16 + zlib.MAX_WBITS) 로 풀면 원본 텍스트가 나옵니다.

신뢰할 수 있느냐가 문제인데, 판정 근거가 있었습니다. 서로 다른 캐시 항목 두 개에서 나온 결과가 바이트 단위로 동일했습니다. 서로 다른 시점의 두 요청이 같은 파일을 받았다는 뜻입니다.

그리고 검증은 파일 크기가 아니라 내용으로 했습니다.

  • 중괄호 균형이 맞는가 (잘린 파일이 아닌가)
  • 라이브 페이지들이 쓰는 클래스 집합을 이 CSS가 전부 정의하는가

엣지가 물고 있으면 원본 수정만으로는 안 고쳐집니다

원본을 복구해 올렸는데도 방문자에게는 여전히 옛 CSS가 갔습니다. Cloudflare가 캐시하고 있었고, 저희에게는 purge 수단이 없었습니다.

그래서 한 번도 캐시된 적 없는 새 파일명으로 배포했습니다. style-v3.css 를 올리고 페이지의 링크를 그쪽으로 돌렸습니다. 새 URL이니 엣지에 항목이 없고, 즉시 반영됩니다. 원본 style.css 도 같은 내용으로 갱신해 뒀습니다 — 캐시가 만료되면 그쪽도 스스로 정상이 됩니다. 사이트가 자꾸 옛 버전만 보였던 이유에서 다룬 것과 같은 벽이고, 이번엔 우회로가 파일명이었습니다.

세대가 섞인 사이트는 스타일시트를 갈라야 합니다

복구하면서 실제 분포를 셌습니다. 전체 442페이지 중 3세대가 160, 공용 CSS를 실제로 링크하는 페이지는 163(나머지는 인라인 스타일). 그 163 중 3개만 1세대 마크업이었습니다.

그 3개 때문에 하나의 CSS로 합치려 하면 .wrap 처럼 양쪽이 다 쓰는 선택자에서 반드시 충돌합니다. 그래서 style-legacy.css 로 분리했습니다. 판정 방법은 단순합니다 — 페이지가 쓰는 class 집합과, 각 CSS가 정의하는 집합을 대조합니다.

자가진단 3개

  • 배포 스크립트에 mirror --reversersync --delete 같은 전체 덮어쓰기가 있습니까? 그 소스가 라이브보다 최신이라는 보장이 있습니까?
  • 공용 자산의 라이브 응답을 캐시 우회로 받아본 적이 있습니까? (?cachebust=1) 크기가 예상과 같습니까?
  • 지금 라이브 페이지가 쓰는 클래스 중, 공용 CSS에 정의가 없는 것이 있습니까?

솔직한 부분

재발 방지로 upload.sh 에 확인 게이트(OOTSSU_UPLOAD_CONFIRM=yes)와 "라이브가 더 새로울 수 있으니 바꾼 파일만 올려라"는 경고를 넣었습니다. 하지만 이건 사람이 읽어야 작동하는 방어입니다.

진짜 교훈은 그쪽이 아닙니다. 전체 미러는 되돌리기 어려운 배포라는 것, 그리고 오래된 사본을 정본으로 삼는 도구는 조용히 파괴적이라는 것입니다. 이 스크립트는 실패한 적이 없습니다. 매번 성공적으로, 넉 달 전 파일을 라이브에 올렸습니다.

그리고 사고를 며칠 동안 못 본 이유가 캐시라는 점이 제일 껄끄럽습니다. 캐시는 잃어버린 CSS를 되찾아준 은인이면서, 동시에 사고를 숨긴 범인이었습니다.

지금 딱 하나만 해보세요. 사이트의 공용 CSS를 ?cachebust=1 붙여서 받아보고 바이트 수를 확인해 보시기 바랍니다. 예상하신 숫자가 나오나요?

유튜브

페이지 160개가 스타일 없이 서비스되고 있었습니다. 제 브라우저에서만 멀쩡했습니다

관련 글