dev.to

A Kubernetes rolling update with maxUnavailable: 0 still drops requests

maxUnavailable: 0인 Kubernetes 롤링 업데이트에서도 요청이 실패합니다

Kubernetes Deployment에 maxUnavailable: 0을 설정하고 graceful shutdown과 preStop 훅을 적용해도 keep-alive 연결 때문에 요청이 실패할 수 있습니다. 실험에서는 종료 전 애플리케이션이 연결을 닫도록 바꾼 뒤 실패가 0건이 됐지만, 댓글은 유휴 연결과 HTTP/2의 경우 추가 검증이 필요하다고 지적합니다.

AI 요약

Kubernetes Deployment에 maxUnavailable: 0을 설정하면 새 Pod가 준비되기 전 기존 Pod를 내리지 않습니다. 하지만 이 설정만으로 배포 중 요청 실패를 막지는 못합니다. 글쓴이는 DigitalOcean Kubernetes 1.36.3의 2개 노드에서 Python HTTP 서버 4개를 실행하고, LoadBalancer Service 뒤에 HTTP/1.1 keep-alive 클라이언트 20개를 연결해 롤링 업데이트를 시험했습니다. 각 실행은 약 110초 동안 요청 약 19,000건을 보내고 시작 25초 뒤 배포를 시작했습니다.

실험 결과

먼저 배포 없이 같은 부하를 보낸 대조군에서는 요청 19,380건 모두 성공했습니다. 애플리케이션이 SIGTERM을 받자마자 종료하면 19,302건 중 51건이 실패했습니다. RemoteDisconnected 33건, ConnectionRefusedError 13건, ConnectionResetError 4건이었으며 HTTP 5xx 오류는 없었습니다. 상태 코드만 집계하는 모니터링으로는 실패를 놓칠 수 있습니다.

다음으로 새 연결을 받지 않고 진행 중인 요청을 마친 뒤 종료하는 graceful shutdown을 적용했습니다. 실패는 25건으로 줄었지만 사라지지 않았습니다. 이어 Pod가 종료되기 전 5초를 기다리는 preStop 훅을 추가하자 실패는 20건이 됐습니다.

글쓴이는 EndpointSlice에서 Pod 주소가 빠지는 시각과 Pod가 SIGTERM을 받은 시각을 각각 기록했습니다. preStop이 없을 때는 SIGTERM 이후 중앙값 0.57초가 지나서야 엔드포인트가 제거됐습니다. preStop을 넣으면 엔드포인트가 SIGTERM보다 중앙값 0.06초 먼저 빠졌습니다. 따라서 preStop은 새 요청이 종료 중인 Pod로 향하는 경쟁 조건을 해결했지만, 남은 실패의 원인은 따로 있었습니다.

실패 원인과 해결

남은 실패는 SIGTERM을 받은 뒤 정확히 3초가 지나 발생했습니다. 애플리케이션의 graceful shutdown 처리기가 3초 뒤 os._exit를 호출했기 때문입니다. 로드밸런서는 이미 맺은 keep-alive 연결을 보유하고 있었고, 새 연결을 거부하거나 Pod를 엔드포인트에서 제거해도 기존 연결은 계속 살아 있었습니다. 프로세스가 종료되면 그 연결이 끊기면서 요청이 실패했습니다.

해결책은 Kubernetes 설정이 아니라 애플리케이션에서 연결 풀을 비우는 방식입니다. 종료 대기 중인 상태에서는 응답에 Connection: close 헤더를 넣고 연결을 닫도록 했습니다. 클라이언트가 응답을 받은 뒤 연결을 재사용하지 않으므로 풀에 남은 연결이 서서히 빠집니다. 이후 프로세스를 종료하되, 이 과정이 끝날 만큼 terminationGracePeriodSeconds를 충분히 설정해야 합니다.

이 변경 뒤 실행 두 번에서 각각 25,884건과 25,871건의 요청이 모두 성공했습니다. 글쓴이가 정리한 조건은 세 가지입니다. preStop으로 엔드포인트를 프로세스 종료 신호보다 먼저 제거하고, graceful shutdown으로 진행 중인 요청을 마치며, 애플리케이션이 기존 연결을 직접 닫아야 합니다. 테스트할 때는 HTTP 상태 코드뿐 아니라 연결 실패도 세라고 권합니다. 글쓴이는 테스트 클러스터를 삭제한 뒤에도 LoadBalancer가 별도 과금 자원으로 남을 수 있으니 정리 여부를 확인하라고 덧붙입니다.

dev.to 반응

  • @_firelinks — 해결 방식은 연결 풀의 모든 연결이 드레이닝 시간 동안 요청을 보내야 한다는 가정에 기대고 있습니다. 부하 테스트에서는 20개 연결이 모두 바쁘므로 각 연결이 몇 밀리초 안에 Connection: close를 받습니다. 하지만 3초보다 긴 시간 동안 유휴 상태인 연결은 헤더를 받을 응답이 없습니다. os._exit가 실행될 때도 연결이 열려 있고, 클라이언트가 그 연결을 풀에서 꺼내면 같은 RemoteDisconnected 오류가 발생합니다. 10초마다 호출하는 클라이언트나 최대 부하에 맞춰 크게 잡았지만 한가한 시간에는 대부분 쉬는 연결 풀에서 이런 상황이 흔합니다. 연결마다 5초에 한 번 요청하는 식으로 다시 시험해 보세요. 실패가 재현되면 종료 직전에 연결을 닫는 대신 더 일찍 유휴 연결을 닫아야 합니다. 서버가 연결을 닫는 순간 클라이언트도 요청을 보내려는 경쟁 조건은 남으므로, 응답 바이트를 하나도 받기 전에 실패한 멱등 요청은 클라이언트가 재시도해야 합니다. 또 Connection 헤더는 HTTP/1.1에만 적용됩니다. HTTP/2에서는 사용할 수 없으며 이에 해당하는 드레이닝 신호는 GOAWAY입니다. 따라서 gRPC 서비스는 다른 종료 절차가 필요합니다.

원문: dev.to / 번역·요약: Trawling