HTTP/2 GOAWAY와 Kubernetes 롤링 업데이트 — 5xx가 튀는 진짜 원인

롤링 업데이트 중에 5xx가 잠깐 튀는 현상, 다들 한 번쯤 본 적 있을 거다. 대시보드에서 스파이크가 잠깐 올라오고 금방 사라지니 "그냥 롤링이라 그런가보다" 하고 넘어간다. 근데 사실 이게 대부분 HTTP/2 GOAWAY 처리 문제다. 최근 팀 내부에서 ingress-nginx가 2026년 3월로 EOL 된 뒤 컨트롤러 교체 검토를 하면서 이 부분을 다시 깊게 들여다봤는데, 정리해두면 도움이 될 것 같아 남긴다.
왜 HTTP/1.1은 조용하고 HTTP/2만 시끄러운가
HTTP/1.1은 연결 하나에 요청 하나가 실제로 흐른다. 백엔드가 preStop에서 sleep 15 걸어두고 SIGTERM 받고 종료를 시작하면, 진행 중인 요청이 끝난 뒤 연결이 자연스럽게 닫힌다. 클라이언트(엔보이/nginx)는 다음 요청에 새 연결을 맺으면 그만이다.
HTTP/2는 다르다. 하나의 TCP 연결에 스트림이 다중화된다. 백엔드가 죽으려 할 때 이 연결을 어떻게 정리하느냐가 문제의 본질이다. RFC 7540은 이 상황을 위해 GOAWAY 프레임을 정의해 뒀다. "이 연결에서 앞으로 새 스트림은 안 받는다. 다만 이미 시작된 스트림 N번까지는 처리해줄게" 라는 신호다.
문제는 대부분의 구현이 GOAWAY를 두 번 나눠서 보낸다는 점이다. 이게 잘 알려져 있지 않다.
Graceful GOAWAY의 두 단계
Envoy, nginx, Go의 http2 서버 모두 비슷한 로직을 쓴다. 종료 신호가 오면 먼저 Last-Stream-ID = 2^31 - 1 인 GOAWAY를 하나 보낸다. 이걸 "warning GOAWAY" 라고 부른다. 큰 스트림 ID를 실어서 "지금 진행 중인 건 다 처리할 거다"라고 알리는 용도다. 이 시점에서는 클라이언트가 여전히 새 스트림을 열 수 있다.
일정 유예 시간(보통 30초, 설정 가능)이 지나면 두 번째 GOAWAY를 보낸다. 이번에는 Last-Stream-ID를 실제 처리 중인 마지막 스트림 ID로 명시한다. 그 이후에 도착한 스트림은 REFUSED_STREAM 에러로 리셋된다.
여기서 사고가 난다. 왜냐하면:
- 첫 번째 GOAWAY와 두 번째 GOAWAY 사이의 유예 시간이 짧으면, 그 사이에 도착한 요청이 REFUSED_STREAM으로 잘려나간다.
- 클라이언트가 첫 번째 GOAWAY를 받은 뒤에도 계속 이 연결을 재사용한다. 새 연결을 미리 열지 않는 클라이언트가 의외로 많다.
- 두 번째 GOAWAY와 실제 TCP FIN 사이에도 race가 있다. FIN이 먼저 도착하면 in-flight 요청이 연결 종료로 실패한다.
nginx 진영 이슈 트래커에도 이 GOAWAY 관련 리포트가 꾸준히 올라오는데, gRPC 트래픽에서 유독 잘 재현된다. gRPC는 스트림이 오래 살아 있고 HTTP/2 연결 재사용률이 매우 높기 때문이다.
Kubernetes 컴포넌트가 이 상황에 개입하는 지점
Pod 종료 시퀀스를 시간축으로 늘어놓으면 이렇게 된다.
- t=0: kubelet이 SIGTERM 전송, Endpoints/EndpointSlice에서 pod 제거 요청 발생
- t=~수 ms~수 초: EndpointSlice 업데이트가 API server를 거쳐 각 노드의 kube-proxy, 그리고 Ingress controller로 전파
- t=terminationGracePeriodSeconds: SIGKILL
이 사이에 아무런 방어가 없으면 Ingress는 "여전히 살아있는 것으로 보이는" Pod IP로 요청을 계속 보낸다. 그래서 대부분의 컨트롤러가 preStop hook + sleep으로 SIGTERM 전에 라우팅 테이블에서 빠질 시간을 벌어준다.
그런데 HTTP/2에서는 이걸로 부족하다. 이유는 컨트롤러가 새 요청을 다른 Pod으로 라우팅하더라도, 종료 중인 Pod과 열려 있던 기존 HTTP/2 연결은 그대로 살아 있기 때문이다. 이 연결에서 흐르던 스트림들은 여전히 죽는 Pod에게 간다. 그리고 죽는 Pod은 위에서 말한 GOAWAY 시퀀스를 밟는다.
실전 튜닝 포인트
몇 가지가 물려 있어서 하나만 만져서는 잘 안 낫는다. 우리 팀이 만졌던 지점들.
첫째, 백엔드의 GOAWAY 유예 시간을 실제 rolling update 관점의 여유보다 짧게 잡지 않는다. Go 서버라면 http2.Server.IdleTimeout이 아니라 shutdown 로직에서 첫 GOAWAY → drain → 두 번째 GOAWAY까지의 간격을 명시적으로 설정하고, 이 시간이 preStop sleep + terminationGracePeriodSeconds 안에 여유 있게 들어가는지 본다. 30초를 유지하면 preStop sleep을 최소 5~10초로 잡고, terminationGracePeriodSeconds를 60초 정도로 두는 게 안전하다.
둘째, 앞단 프록시(Envoy/nginx)가 upstream GOAWAY를 받았을 때의 동작을 확인한다. Envoy는 GOAWAY를 받으면 그 upstream host에 대한 새 스트림을 즉시 다른 연결로 보낸다. 반면 몇몇 커스텀 클라이언트, 특히 gRPC-go 오래된 버전은 warning GOAWAY 이후에도 잠깐 새 스트림을 이 연결로 밀어넣는다. MaxConnectionAge 옵션을 백엔드 gRPC 서버에 걸어서 강제로 연결을 주기적으로 순환시키는 게 훨씬 안정적이다.
셋째, Ingress에서 이 사이클을 애초에 짧게 만든다. MaxConnectionAgeGrace를 짧게(예: 10s) 잡으면 warning GOAWAY와 최종 GOAWAY 사이에 in-flight 요청이 완료될 여지가 생긴다. 다만 너무 짧으면 롱 리빙 스트림이 잘려서 클라이언트에서 재연결 폭풍이 일어난다. P95 요청 시간의 3~5배 정도로 잡는 게 우리 경우엔 맞았다.
ingress-nginx EOL 관련 여담
2026년 3월로 kubernetes/ingress-nginx 프로젝트가 아카이빙되면서 대체 Ingress를 고르는 팀이 많다. 대체 후보(InGate, Gateway API 기반의 Envoy Gateway, Cilium Ingress, Traefik 등)를 검토할 때 이 HTTP/2 graceful shutdown 시나리오를 벤치마크에 꼭 넣어보길 권한다. 특히 gRPC 트래픽 비중이 높다면 컨트롤러마다 이 부분 구현이 은근히 다르다. Envoy 기반 컨트롤러들이 대체로 이 시나리오를 잘 다루는 편인데, 세팅 노브가 많아서 처음에 잘못 잡으면 오히려 상황이 나빠질 수 있다. 벤치마크 시나리오는 단순하다. 부하 상태에서 백엔드 Deployment를 kubectl rollout restart 하고 클라이언트 측 5xx와 클라이언트-side reset 카운트를 본다.
정리
롤링 업데이트 5xx의 대부분은 사실 앱 버그가 아니라 이 GOAWAY 처리 타이밍 문제다. preStop sleep을 늘려도 안 잡히면 HTTP/2 레이어를 의심하는 게 순서고, 특히 gRPC 트래픽이면 거의 확정이라고 봐도 된다. 이 문제는 컨트롤러/서버/클라이언트 세 곳의 타이밍이 얽혀 있어서 한 방에 해결되지 않는다. 우리 팀도 아직 완벽히 잡았다고는 못 하겠고, 컨트롤러 교체하면서 다시 검증해봐야 할 것 같다. 혹시 다른 팀은 어떻게 다루고 있는지 궁금하다.