ALB deregistration_delay를 30초로 뒀더니 배포마다 5xx가 튀던 이야기

배포는 빨라졌는데, 왜 그래프가 자꾸 뻘겋게 되나
배포 시간이 오래 걸리는 게 싫어서 ALB target group의 deregistration_delay를 30초까지 내렸다가, 몇 주 동안 배포 때마다 5xx가 조용히 튀는 걸 뒤늦게 알아챈 이야기다. 뭐 그렇게 대단한 사건은 아닌데, 원인이 좀 얄궂어서 기록으로 남긴다.
우리 팀은 ECS on EC2로 앱을 굴린다. 배포는 하루에 5~7번, 카나리 없이 rolling update. 처음에는 deregistration_delay=300 (기본값 5분)이었는데, 배포가 뭘 하나만 바꿔도 10분씩 걸리는 게 아까워서 30초로 내렸다. task 자체는 gracefully shutdown 잘 하니까 30초면 넉넉하다고 판단했다.
며칠 뒤부터 CloudWatch 대시보드에 5xx가 배포 시간대에 5~15개씩 튀기 시작했다. 트래픽 대비 0.02%도 안 되는 수준이라 자동 알람은 안 울렸다. 근데 팀 슬랙에 SRE 봇이 "지난 24시간 502 count: 68" 같은 걸 매일 아침 올려주는데, 어느 순간부터 이 숫자가 계속 두 자리 이상이었다. 배포와 시간대가 겹쳤다.
솔직히 처음엔 앱 문제인 줄 알았다. 근데 앱 로그에는 아무것도 없었다. ALB access log만 5xx를 찍고 있었다. target_status_code가 - 이거나 502. 이건 앱이 응답 못한 게 아니라, ALB가 target으로 요청을 보냈는데 커넥션이 뚝 끊긴 경우다.
30초는 왜 부족했나 — 실제로는 30초가 아니다
핵심을 먼저 말하면, ALB의 deregistration_delay는 "이 시간이 지나면 in-flight 요청까지 통째로 자른다"는 뜻이지 "이 시간 동안 신규 요청은 안 온다"가 아니다. 근데 신규 요청도 문제였다. 두 개가 겹쳤다.
첫 번째: ALB → target 사이의 keep-alive 커넥션. ALB는 클라이언트 요청마다 새 TCP를 여는 게 아니라, target 쪽으로 idle 커넥션을 유지한다. target을 deregister 시키면 ALB는 그 target으로 "새 요청"은 안 라우팅한다. 근데 이미 열려 있는 keep-alive 커넥션은 즉시 끊지 않는다. 그 커넥션으로 곧 도착할 요청은 계속 그 target으로 간다. task가 SIGTERM 받고 서버가 Connection: close로 응답하기 시작하면 그다음 요청부터 안 가지만, 그 사이 짧은 갭에 요청이 낀다.
두 번째: 우리 앱의 shutdown hook. terminationGracePeriodSeconds... 아니 ECS니까 stopTimeout. 이게 30초였다. deregistration_delay랑 똑같이 30초. 배포 컨트롤러가 old task에 SIGTERM 보내는 순간, stopTimeout 카운트가 시작된다. 앱은 SIGTERM 받자마자 HTTP listener를 닫는 게 아니라 shutdown hook 안에서 캐시 flush를 먼저 하는데, 이게 짧으면 3초, 길면 15초까지 걸렸다. 그동안에도 keep-alive 커넥션은 살아 있고, ALB는 아직 이 target을 "완전히 out"으로 여기지 않은 상태.
두 개가 겹치니까 결과적으로 "SIGTERM 직후 몇 초"에 도착한 요청이 커넥션 종료와 부딪히면서 502로 응답됐다.
그래서 뭘 바꿨나
일단 값 자체를 다시 손봤다. 여러 조합을 실제로 배포해가면서 5xx 카운트를 봤다.
deregistration_delay=30, stopTimeout=30: 문제의 상태. 5xx 5~15개/배포.deregistration_delay=60, stopTimeout=30: 여전히 5xx 튐. 3~8개.deregistration_delay=90, stopTimeout=60: 5xx 거의 사라짐. 0~2개.deregistration_delay=120, stopTimeout=60: 완전히 0.
값 감을 잡고 나서 앱 쪽도 손봤다. 원래 shutdown hook 순서가 이랬다.
SIGTERM 수신
├─ 캐시 flush
├─ DB 커넥션 정리
└─ HTTP 리스너 close
이걸 뒤집었다. HTTP listener를 먼저 close하고, 이미 처리 중인 요청만 끝날 때까지 기다린 뒤 나머지 정리를 했다.
SIGTERM 수신
├─ HTTP 리스너 close ← 신규 요청 거절 (커넥션 자체는 유지)
├─ in-flight 요청 완료 대기 (최대 20초)
├─ 캐시 flush
└─ DB 커넥션 정리
stopTimeout=60이면 이 순서로 여유가 넉넉했다. HTTP 리스너를 먼저 닫아버리면 ALB의 health check가 실패로 넘어가면서 훨씬 빠르게 unhealthy로 마킹되고, 그 순간부터 신규 요청 라우팅이 완전히 끊긴다.
왜 30초가 매력적으로 보였는지
돌이켜보면 처음에 30초로 내린 이유는 "배포 속도"였다. 근데 배포가 5분에서 10분으로 늘어봤자, 우리 팀에서 하루 총 배포 시간은 60분 늘어난다. 대신 사용자가 502를 보는 횟수는 매일 68회에서 0으로 줄었다. 어느 쪽이 더 중요한지는 계산할 것도 없었다.
또 하나. 배포 속도 자체는 deregistration_delay를 손대는 것보다 다른 지점에서 훨씬 크게 줄일 수 있었다. 우리 경우엔 ECS task 시작 시간(이미지 pull → 헬스체크 pass까지)이 병목이었지, 종료 대기가 아니었다. 시작 쪽을 손보는 게 원래 순서였다.
값 정할 때 참고한 것들
deregistration_delay 정할 때 어떤 값이 맞나 물어보는 사람이 종종 있는데, 우리 팀에서 결국 쓰는 룰은 대충 이렇다.
- p99 응답 시간 × 2 를 최소 하한으로 본다. p99이 5초면 최소 10초부터 검토.
- 여기에 앱의 shutdown hook 소요 시간을 더한다. 우리 앱은 20초 정도.
- 그리고 안전 버퍼 20~30초.
우리는 그래서 90~120초 근처에서 안정화됐다. 스트리밍이나 WebSocket 있는 팀은 이보다 더 길게 잡아야 한다. 반대로 진짜 sub-100ms만 처리하는 API 게이트웨이라면 60초 이하도 가능하다.
stopTimeout은 ECS에서 max 120초까지 올릴 수 있는데(ECS agent config 필요), 우리는 60초로 충분했다. Kubernetes라면 terminationGracePeriodSeconds인데 개념은 같다. 이 값은 deregistration_delay보다 무조건 짧아야 한다. 안 그러면 ALB가 커넥션을 이미 잘랐는데 앱은 아직 살아서 응답을 쓰는 이상한 상태가 된다.
마무리
문제 자체는 대단할 게 없는데, 배포마다 5xx가 몇 개씩 튀어도 총 요청 수 대비 0.02%면 자동 알람이 안 울리고, 대시보드 위에서만 조용히 쌓인다는 게 좀 무섭다. 다행히 매일 아침 그 숫자를 슬랙에 뿌리는 봇이 있었고, 그게 없었다면 몇 달 더 갔을 수도 있다.
혹시 배포 시간대에 이유 없이 5xx가 튀는 팀 있으면 deregistration_delay부터 확인해 보시길. 그리고 shutdown hook 안에서 HTTP 리스너를 언제 닫는지도. 이 두 개가 얽혀 있으면 앱 로그로는 절대 안 잡힌다.