Published on

Kubernetes gRPC UNAVAILABLE·RST_STREAM 원인과 Envoy·NGINX 대응

Authors

서버 간 통신을 gRPC로 바꾼 뒤부터, 쿠버네티스 환경에서 간헐적으로 UNAVAILABLE가 터지거나 RST_STREAM이 보이기 시작하면 디버깅 난이도가 급격히 올라갑니다. HTTP/2 위에서 동작하는 gRPC 특성상 “TCP는 살아 있는데 스트림만 죽는” 상황이 흔하고, 그 원인이 애플리케이션이 아니라 Ingress/Service Mesh/로드밸런서/커널/리소스 압박 등 여러 층에 걸쳐 발생하기 때문입니다.

이 글은 다음 두 가지를 목표로 합니다.

  • 증상을 로그/메트릭/패킷 레벨에서 분해해 “누가 스트림을 리셋했는지”를 좁히는 방법
  • Envoy(ISTIO 포함)·NGINX Ingress에서 HTTP/2·gRPC 친화적으로 타임아웃/keepalive/드레인/리트라이를 조정하는 실전 튜닝

관련해서 L7 타임아웃/스트리밍 끊김을 NGINX Ingress 관점에서 더 깊게 다룬 글도 함께 참고하면 좋습니다: Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝

gRPC에서 UNAVAILABLE·RST_STREAM이 의미하는 것

UNAVAILABLE는 “원인”이 아니라 “결과”

클라이언트 SDK에서 StatusCode.UNAVAILABLE는 보통 다음 범주 중 하나의 결과입니다.

  • 연결 수립 실패(서버/프록시 다운, DNS, 라우팅)
  • 중간 프록시가 연결/스트림을 종료(timeout, drain, max connection age)
  • 서버가 과부하로 응답 못함(큐 적체, 스레드/이벤트루프 정체)

즉 UNAVAILABLE만 가지고는 부족하고, 반드시 동시에 발생한 HTTP/2 레벨 이벤트(RST_STREAM, GOAWAY) 또는 프록시 로그를 같이 봐야 합니다.

RST_STREAM은 “HTTP/2 스트림만” 강제 종료

RST_STREAM은 TCP 연결 전체를 끊지 않고, 특정 스트림만 리셋합니다. 흔한 경우:

  • 프록시/서버의 per-stream timeout
  • upstream에서 응답 헤더를 못 받음(업스트림 연결 실패)
  • max concurrent streams 초과
  • 서버가 CANCEL/INTERNAL 등으로 스트림 종료

핵심은 누가 RST를 보냈는지입니다.

1단계: 증상을 “어느 레이어에서 끊겼는지”로 분류

A. 클라이언트 로그에서 gRPC status + debug string 확보

가능하면 클라이언트에서 gRPC channel debug를 켜서 grpc-status-details-bin 또는 에러 문자열을 확보합니다.

  • Java: -Dio.grpc.netty.shaded.io.netty.handler.logging.LoggingHandler.level=DEBUG
  • Go: GRPC_GO_LOG_VERBOSITY_LEVEL=99, GRPC_GO_LOG_SEVERITY_LEVEL=info

그리고 에러 발생 시점에 다음을 함께 기록합니다.

  • 대상 authority/host, method
  • deadline(타임아웃)
  • 재시도 여부

B. Envoy/NGINX 액세스 로그에서 response_flags/grpc_status 확인

Envoy는 response_flags가 매우 강력한 힌트입니다(예: UT upstream timeout, UC upstream connection termination 등).

NGINX Ingress는 gRPC일 때도 upstream 상태/타임아웃 흔적이 남습니다.

C. 서버(Pod) 로그에서 “정상 종료 vs 강제 종료” 구분

서버가 정상적으로 스트림을 닫았는지, SIGTERM/드레인 중이었는지, OOMKilled/재시작이었는지 확인합니다. 특히 OOMKilled는 gRPC에선 UNAVAILABLE처럼 보이는 경우가 많습니다. 메모리 이슈 진단은 아래 글이 도움이 됩니다: Kubernetes OOMKilled 진단과 메모리 누수 추적 실전

2단계: Kubernetes에서 자주 터지는 “진짜 원인” 10가지

1) Ingress/Proxy 타임아웃이 gRPC 스트림보다 짧음

gRPC는 스트리밍/장기 연결이 흔합니다. 그런데 Ingress 기본값은 HTTP 요청 기준이라 idle timeout이 짧을 수 있습니다.

  • 증상: 특정 시간(예: 60s, 300s)마다 규칙적으로 끊김
  • 로그: upstream timeout, 504 유사, Envoy UT

2) Keepalive 미스매치로 중간 장비가 연결을 정리

클라이언트는 keepalive ping을 보내는데, 프록시가 이를 “과도한 ping”으로 보고 차단하거나 반대로 프록시/LB가 idle로 보고 끊습니다.

  • 증상: 트래픽이 적을수록 더 자주 끊김
  • 힌트: Envoy too_many_pings, NLB/ALB idle timeout, gRPC keepalive 설정

3) Pod 재시작/롤링 업데이트 중 드레인 미흡

Kubernetes는 Pod를 내릴 때 SIGTERM을 보내고 terminationGracePeriodSeconds 동안 기다립니다. 하지만 gRPC 서버가 graceful shutdown을 제대로 구현하지 않으면 활성 스트림이 리셋됩니다.

  • 증상: 배포 시간대에만 오류 급증
  • 해결: preStop + drain, readiness gate, connection draining

4) Envoy/Istio의 outlier detection/서킷 브레이커

일시적 지연이 발생하면 Envoy가 upstream을 eject하거나 circuit breaker가 걸려 UNAVAILABLE가 늘 수 있습니다.

  • 증상: 특정 Pod로만 실패 집중
  • 힌트: Envoy stats cluster.outlier_detection.*, upstream_cx_overflow

5) HTTP/2 max concurrent streams 부족

프록시/서버가 허용하는 동시 스트림 수가 낮으면, 부하 시 RST_STREAM(REFUSED_STREAM) 또는 유사 증상이 납니다.

6) HPA/오토스케일로 인한 급격한 연결 재분배

스케일 인/아웃이 잦으면 연결이 자주 이동하고, 드레인이 없으면 스트림이 끊깁니다.

7) Node 레벨 conntrack/NAT 테이블 압박

특히 많은 짧은 연결이 섞이면 conntrack이 가득 차서 드랍이 발생합니다.

  • 힌트: 노드 dmesgnf_conntrack: table full

8) L4/L7 로드밸런서 idle timeout

AWS NLB/ALB, GCP LB 등은 기본 idle timeout이 있고, gRPC 장기 연결에서 영향을 줍니다.

9) 서버의 이벤트루프/스레드풀 정체

서버가 CPU starvation이나 GC stop-the-world로 헤더를 제때 못 보내면 upstream timeout으로 보입니다.

10) MTU/PMTUD 문제로 인한 특정 패킷 드랍

HTTP/2는 프레임이 커질 수 있고, 경로 MTU 문제는 “가끔만” 발생해 진단을 어렵게 만듭니다.

Envoy에서 UNAVAILABLE·RST_STREAM을 줄이는 핵심 튜닝

아래 예시는 **Envoy를 Ingress Gateway(또는 standalone proxy)**로 쓴다는 가정입니다. Istio라면 EnvoyFilter 또는 DestinationRule/Gateway/VirtualService로 대응합니다.

1) 업스트림 HTTP/2 옵션 명시(동시 스트림, keepalive)

static_resources:
  clusters:
  - name: grpc_upstream
    type: STRICT_DNS
    connect_timeout: 2s
    lb_policy: ROUND_ROBIN
    http2_protocol_options:
      max_concurrent_streams: 1024
      initial_stream_window_size: 65536
      initial_connection_window_size: 1048576
    upstream_connection_options:
      tcp_keepalive:
        keepalive_time: 60
        keepalive_interval: 10
        keepalive_probes: 3
    load_assignment:
      cluster_name: grpc_upstream
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: my-grpc.default.svc.cluster.local
                port_value: 50051
  • max_concurrent_streams가 낮으면 부하 시 RST가 늘 수 있습니다.
  • TCP keepalive는 L4 idle timeout 회피에 도움되지만, L7 keepalive ping과는 별개입니다.

2) route timeout을 gRPC에 맞게(특히 streaming)

Envoy의 route timeout은 기본이 존재하고, gRPC 스트리밍에는 보통 무제한 또는 매우 크게 둡니다.

route_config:
  name: local_route
  virtual_hosts:
  - name: grpc
    domains: ["*"]
    routes:
    - match:
        prefix: "/"
      route:
        cluster: grpc_upstream
        timeout: 0s            # gRPC streaming이면 0 권장(무제한)
        idle_timeout: 0s       # 필요 시 조정
        retry_policy:
          retry_on: cancelled,deadline-exceeded,resource-exhausted,unavailable
          num_retries: 2
          per_try_timeout: 2s

주의할 점:

  • 무조건 재시도는 중복 요청을 만들 수 있습니다(특히 non-idempotent). gRPC 메서드 특성에 따라 제한하세요.
  • per_try_timeout이 너무 짧으면 정상 요청도 RST로 보일 수 있습니다.

3) 드레인/GOAWAY를 “배포 친화적”으로

Envoy는 드레인 시 GOAWAY를 보내고 연결을 정리합니다. 이 과정이 너무 급하면 클라이언트는 UNAVAILABLE로 봅니다.

  • 배포 시 커넥션 드레인 시간 확보
  • readiness를 먼저 내리고, 충분히 기다린 뒤 종료

Istio라면 terminationDrainDuration 같은 설정이 영향을 줍니다.

NGINX Ingress에서 gRPC 끊김을 줄이는 튜닝

NGINX Ingress는 gRPC를 grpc_pass로 프록시합니다. 핵심은 타임아웃/keepalive/HTTP2입니다.

1) Ingress annotations로 gRPC 타임아웃 확장

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: grpc-ingress
  annotations:
    nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "5"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-next-upstream: "error timeout http_502 http_503 http_504"
    nginx.ingress.kubernetes.io/proxy-next-upstream-tries: "2"
spec:
  ingressClassName: nginx
  rules:
  - host: grpc.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: my-grpc
            port:
              number: 50051
  • streaming이면 proxy-read-timeout이 사실상 “idle timeout” 역할을 합니다.
  • proxy-next-upstream은 gRPC에서 만능은 아니지만, 특정 실패(업스트림 연결 실패/타임아웃)에는 도움이 됩니다.

2) ConfigMap에서 keepalive 및 HTTP/2 관련 설정 확인

NGINX Ingress Controller의 ConfigMap(전역)에서 다음을 점검합니다.

  • upstream keepalive 연결 수
  • HTTP/2 활성화 여부(대개 기본 활성)
  • keep-alive/keep-alive-requests/upstream-keepalive-connections

환경마다 키 이름이 조금씩 다르므로 사용 중인 ingress-nginx 버전 문서를 기준으로 확인하세요.

3) 배포/스케일 인 시 graceful shutdown 보장

Ingress만 튜닝해도, 백엔드 Pod가 종료되며 스트림을 끊으면 의미가 없습니다.

  • preStop 훅으로 readiness 먼저 down
  • terminationGracePeriodSeconds를 충분히 크게
  • gRPC 서버에서 GracefulStop()(Go), Server.shutdown()(Java) 등 구현

재현과 진단을 빠르게 하는 커맨드/도구

grpcurl로 간단 재현(헤더/리플렉션 기반)

grpcurl -v -plaintext \
  -d '{"id":"123"}' \
  my-grpc.default.svc.cluster.local:50051 \
  my.package.Service/Get

TLS라면 -cacert, -authority 등을 함께 사용합니다.

Envoy admin stats에서 타임아웃/리셋 카운터 확인

kubectl -n istio-system port-forward deploy/istio-ingressgateway 15000:15000
curl -s localhost:15000/stats | egrep 'upstream_rq_timeout|upstream_cx_destroy|http2|reset'

upstream_rq_timeout이 증가하면 “업스트림 응답 지연/타임아웃” 가능성이 큽니다.

Pod 종료 이벤트/재시작 확인

kubectl get pod -n default -w
kubectl describe pod my-grpc-xxxxx | egrep -n 'Reason|OOMKilled|Last State|Exit Code|Killed'

OOMKilled나 잦은 재시작이 보이면 애플리케이션/리소스부터 고쳐야 합니다.

실전 체크리스트: 원인별로 이렇게 좁혀라

규칙적으로 N초마다 끊긴다

  • Ingress/Envoy route timeout, idle timeout
  • LB idle timeout
  • keepalive 미스매치

배포 시간대에만 끊긴다

  • readiness 먼저 내리는지
  • preStop으로 드레인하는지
  • termination grace가 충분한지

부하가 높을 때만 RST_STREAM이 늘어난다

  • max concurrent streams
  • Envoy circuit breaker, pending requests overflow
  • 서버 스레드풀/이벤트루프 포화

특정 노드/특정 Pod로만 치우친다

  • 노드 conntrack/커널 드랍
  • 해당 Pod의 리소스 압박(OOM/CPU throttling)

마무리: “UNAVAILABLE”를 없애려면 관측 지점을 늘려야 한다

Kubernetes에서 gRPC UNAVAILABLERST_STREAM은 대부분 단일 원인이 아니라, 타임아웃·keepalive·드레인·리소스가 서로 충돌하면서 생깁니다. 해결의 핵심은 다음 순서입니다.

  1. 클라이언트 gRPC status + 프록시(Envoy/NGINX) 로그를 같은 타임라인에 놓기
  2. timeout/idle/keepalive를 gRPC 스트리밍 특성에 맞게 재정의
  3. 배포/스케일 이벤트에서 스트림이 안전하게 마이그레이션되도록 드레인/Graceful shutdown 구현
  4. OOM/CPU throttling 같은 “서버가 답을 못하는 상태”를 먼저 제거

위 튜닝을 적용했는데도 간헐 장애가 남는다면, 다음 단계로는 패킷 캡처(노드/사이드카)와 LB idle timeout, conntrack, MTU까지 내려가서 보는 것이 가장 빠릅니다.