hub.askrs.com이 재부팅 뒤에 502를 냈다. 배포는 몇 시간 전에 손을 뗀 상태였고 화면까지 열어 확인했었다. pm2 list를 보니 살아 있는 프로세스 이름이 전환 전 슬롯이었다. Nginx upstream은 새 슬롯 포트를 가리키고 있었으니, 요청이 닿는 포트에는 아무도 없던 셈이다.
부팅 때 되살아나는 건 지금 돌던 프로세스가 아니라 pm2가 마지막으로 저장해 둔 목록이다. 그 저장본이 전환 이전 상태로 남아 있었다. 전환 스크립트에는 pm2 save가 분명히 들어 있는데, 그 줄까지 도달하지 못한 것이다.
공개 헬스체크가 간격 0으로 나눴다
전환 스크립트의 공개 헬스체크는 재시도 횟수와 간격을 환경변수로 받는다. 기본은 30회 1초다. Nginx worker가 빠지는 데 60초가 걸리는데 30초 예산으로는 모자랄 때가 있어서, 예산이 부족하면 횟수를 늘리는 보정을 며칠 전에 넣어 뒀다. 그 계산이 (최소초 + 간격 - 1) / 간격이었다.
문제는 호출하는 쪽이다. 이전 런타임을 내린 직후 안정성을 보는 단계는 지금 당장 200이 나오는지만 묻고 싶어서 재시도 1회, 간격 0초로 부른다. 0은 정수 검사와 5 이하 검사를 그대로 통과한다. 예산은 0이 되고, 0은 90보다 작으니 보정 분기로 들어가고, 거기서 간격 0으로 나눈다. bash가 division by 0을 내면서 대입이 실패하고, 함수는 curl을 한 번도 쏘지 않은 채 빠져나온다.
호출부가 `|| fail`로 묶여 있어서 배포는 그 자리에서 멈췄다. 남은 문장은 "Hub public DNS health was not 2xx after prior-runtime retirement"였다. 2xx가 아니었던 게 아니라 확인 자체를 못 한 건데, 로그만 보면 사이트가 아픈 것처럼 읽힌다. 엉뚱한 곳을 먼저 들여다보게 만드는 문장이었다.
if [[ -z "${ASKRS_DEPLOY_HEALTH_ATTEMPTS:-}" && -z "${ASKRS_DEPLOY_HEALTH_DELAY_SECONDS:-}" ]]; then
minimum_seconds=$(( NGINX_WORKER_SHUTDOWN_TIMEOUT_SECONDS + 30 ))
budget_seconds=$(( attempts * delay ))
if (( delay > 0 && budget_seconds < minimum_seconds )); then
attempts=$(( (minimum_seconds + delay - 1) / delay ))
fi
fi 코드 발췌 · 호출자가 값을 직접 넘겼으면 보정하지 않고, 간격이 0이면 나누지 않는다.
Nginx는 새 슬롯, 저장된 목록은 옛 슬롯
전환 순서를 보면 왜 하필 그 자리였는지가 보인다. Nginx upstream을 후보 포트로 돌리는 단계, 이전 런타임을 pm2 목록에서 지우는 단계가 모두 끝난 뒤에 은퇴 직후 안정성 확인이 온다. pm2 save는 그보다 28줄 뒤다. 그러니까 중단된 시점의 서버는 새 릴리스로 정상 서비스 중이었고, 디스크에 저장된 목록만 전환 이전 세계에 남아 있었다.
블루는 8792, 그린은 8793이다. 저장본은 이전 슬롯 이름과 이전 릴리스 디렉터리를 들고 있었으니, 부팅하면 그 슬롯이 그 포트로 올라온다. Nginx는 반대편 포트를 보고 있다. 아무도 듣지 않는 포트로 프록시하면 502다.
여기서 운이 좋았던 부분이 있다. Nginx 설정이 아직 옛 포트를 가리키고 있었다면 서버는 이전 릴리스를 조용히 서빙했을 것이다. 502는 최소한 소리를 낸다. 겉으로 멀쩡한데 몇 시간 전 코드가 돌고 있는 상태보다는, 열리지 않는 편이 훨씬 빨리 발견된다.
조용히 옛 코드를 서빙하는 것보다 502가 낫다는 걸 이번에 알았다.
pm2 save 실패를 경고에서 중단으로 올렸다
커밋 9b40faf에서 세 가지를 바꿨다. 예산 보정은 두 환경변수가 모두 비어 있을 때만 적용한다. 한 번만 찔러 보겠다고 말한 호출자는 그렇게 하겠다는 뜻이니 덮어쓸 이유가 없다. 그리고 보정 안에서도 간격이 0보다 클 때만 나눈다. 두 겹으로 막아 뒀다.
`pm2 save || log "warning:..."` 한 줄이었던 것을 실패하면 배포를 끊도록 바꿨다. 이 단계가 조용히 넘어가면 다음 재부팅까지 아무도 모른다. 마지막 마무리 작업일수록 실패를 경고로 흘리면 안 되는 이유가 여기 있다.
저장했다는 사실만으로는 부족해서 덤프를 다시 읽는 검사도 붙였다. 저장 파일을 JSON으로 파싱해 후보 슬롯 이름이 들어 있는지, 실행 경로가 이번 릴리스 디렉터리를 가리키는지 확인한다. 실제 덤프로 맞는 조합은 통과하고 슬롯이나 릴리스가 어긋난 조합은 거부하는 것까지 봤다. 계약 검사 네 개를 추가했고, 각각 되돌렸을 때 빨간불이 뜨는지도 확인했다.
시끄럽게 죽어 준 쪽이 운이 좋았다
- 재시도 간격을 0으로 넘기는 호출부가 있으면 나눗셈 보정이 그 값을 먼저 걸러 내야 한다
- 부팅 뒤 살아나는 것은 지금 도는 프로세스가 아니라 마지막으로 저장된 목록이다
- 전환 마무리 단계의 실패는 경고 로그가 아니라 배포 중단으로 둔다
- 저장했다는 사실 대신 저장된 내용을 다시 읽어서 확인한다