Published on

EKS에서 gRPC DEADLINE_EXCEEDED 폭증 해결

Authors

서버가 멀쩡해 보이는데도 gRPC DEADLINE_EXCEEDED가 갑자기 폭증하는 순간이 있습니다. 특히 EKS에서는 오토스케일링/노드 교체, 코어DNS/네트워크 경로, L7/L4 로드밸런서(Envoy/NLB/ALB), Pod 리소스 압박, 커넥션 드레이닝 미흡이 겹치면 “지연이 살짝 늘어난 것”이 “타임아웃 폭발”로 증폭됩니다.

이 글은 EKS에서 DEADLINE_EXCEEDED가 폭증했을 때,

  1. 빠르게 범위를 좁히는 관측 포인트,
  2. 흔한 원인별 증상 패턴,
  3. 즉시 완화(Stop the bleeding),
  4. 근본 해결(재발 방지) 를 실전 체크리스트 형태로 정리합니다.

1) DEADLINE_EXCEEDED의 의미를 먼저 분해하기

gRPC의 DEADLINE_EXCEEDED는 “서버가 에러를 반환했다”라기보다 클라이언트가 설정한 deadline 내에 응답을 못 받았다는 뜻입니다. 즉 원인은 크게 네 갈래입니다.

  1. 서버 처리시간 증가: CPU throttling, GC pause, 락 경합, DB/외부 호출 지연
  2. 네트워크/프록시 경로 지연: DNS 지연, 패킷 드랍/재전송, 프록시 큐잉
  3. 연결/스트림 관리 문제: keepalive/idle timeout, 커넥션 재사용 실패, 드레이닝 미흡
  4. 클라이언트 deadline 설계 문제: 너무 짧은 deadline, 재시도 폭주로 인한 self-amplification

EKS에서는 2~3번이 특히 자주 원인이 됩니다. “서버 CPU는 낮은데 타임아웃이 난다”가 대표적 신호입니다.


2) 폭증 시 가장 먼저 확인할 6가지 관측 포인트

2.1 gRPC 상태코드/지연 히스토그램을 분리

DEADLINE_EXCEEDED만 보면 원인이 안 보입니다. 최소한 다음을 분리해 봅니다.

  • p50/p90/p99 latency (클라이언트 관측 vs 서버 관측)
  • UNAVAILABLE 동반 여부(연결 실패/리셋)
  • 재시도 횟수 증가 여부
  • 특정 메서드만 폭증하는지(핫 메서드)

Prometheus를 쓴다면(예: grpc-go, grpc-java) 대개 아래와 같은 메트릭이 있습니다.

# 클라이언트 관측 p99
histogram_quantile(0.99,
  sum by (le, grpc_method) (rate(grpc_client_handling_seconds_bucket[5m]))
)

# DEADLINE_EXCEEDED 비율
sum by (grpc_method) (rate(grpc_client_handled_total{grpc_code="DEADLINE_EXCEEDED"}[5m]))
/
sum by (grpc_method) (rate(grpc_client_handled_total[5m]))

클라이언트 p99만 튀고 서버 p99는 안정적이면 네트워크/프록시/드레이닝 쪽을 먼저 의심합니다.

2.2 Pod/Node 리소스: CPU throttling과 네트워크 drop

EKS에서 “CPU 사용률은 낮은데 느리다”는 상황은 CPU throttling일 수 있습니다. requests/limits가 타이트하면 CFS throttling으로 tail latency가 튑니다.

  • container_cpu_cfs_throttled_periods_total
  • container_cpu_usage_seconds_total
# throttling 비율(대략)
rate(container_cpu_cfs_throttled_periods_total[5m])
/
rate(container_cpu_cfs_periods_total[5m])

또한 노드 네트워크 드랍/재전송은 gRPC에 치명적입니다. 노드 레벨에서 ethtool -S, ss -s, netstat -s도 확인합니다.

2.3 CoreDNS 지연/오류

서비스 디스커버리가 흔들리면 연결 재시도와 함께 deadline이 쉽게 소진됩니다.

  • CoreDNS SERVFAIL, timeout, no such host 증가
  • ndots/search domain로 인한 쓸데없는 질의 폭증

2.4 로드밸런서/프록시 타임아웃과 드레이닝

Envoy/Istio, Nginx, ALB/NLB를 경유한다면 다음이 자주 원인입니다.

  • idle timeout이 짧아 커넥션이 중간에 끊김
  • Pod 종료 시 드레이닝 없이 커넥션이 강제 종료
  • L7에서 헤더/프레임 처리 지연

2.5 오토스케일링 이벤트(노드 교체/스케일 인)

DEADLINE_EXCEEDED 폭증이 노드 스케일 인/교체 타이밍과 맞물리면, “정상 트래픽 + 재시도”가 겹쳐 장애처럼 보일 수 있습니다.

Karpenter를 사용 중이라면 노드가 제때 늘지 않거나(과부하 지속), 반대로 너무 공격적으로 교체되며 커넥션이 흔들릴 수 있습니다. 관련 점검은 아래 글도 함께 참고하세요.

2.6 로그 비용 폭증(부수 증상)

타임아웃/재시도 폭주가 시작되면 애플리케이션/프록시 로그가 급증해 CloudWatch Logs 비용이 같이 튀는 경우가 많습니다. 장애 대응 중 비용도 함께 막아야 합니다.


3) 흔한 원인별 “증상 패턴”과 해결책

3.1 Pod 종료/노드 교체 시 커넥션 드레이닝 미흡

증상

  • 배포/스케일 인 직후 DEADLINE_EXCEEDEDUNAVAILABLE가 동시에 증가
  • 특정 노드/Pod로 트래픽이 몰린 뒤 갑자기 타임아웃
  • 서버 로그에는 처리 시작 로그가 없거나, 중간에 끊긴 흔적

원인

  • Kubernetes는 Pod에 SIGTERM을 보내고 terminationGracePeriodSeconds 동안 종료를 기다립니다.
  • 하지만 서비스 엔드포인트에서 제거되기 전에 또는 프록시/클라이언트가 커넥션을 재사용하는 동안 Pod가 죽으면 in-flight RPC가 깨집니다.

해결

  1. preStop + 충분한 grace period로 커넥션 드레이닝 시간을 확보
  2. readiness를 먼저 내려서 새 요청 유입을 차단
  3. 서버는 GracefulStop()(grpc-go) 등으로 in-flight를 정리
# deployment 예시
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
      - name: app
        lifecycle:
          preStop:
            exec:
              command: ["/bin/sh", "-c", "sleep 15"]
        readinessProbe:
          httpGet:
            path: /healthz
            port: 8080
          periodSeconds: 5

grpc-go 서버라면:

// SIGTERM 수신 시 graceful stop
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT)

go func() {
  <-ch
  grpcServer.GracefulStop() // in-flight RPC 종료 대기
}()

Pod가 Terminating에 오래 걸리거나 finalizer로 막히면 드레이닝이 꼬여 장애가 길어질 수 있습니다. 아래 글의 디버깅 체크도 유용합니다.


3.2 클라이언트 deadline이 지나치게 짧거나, 재시도가 폭주

증상

  • 서버 p99는 200ms인데 클라이언트는 1s deadline으로 DEADLINE_EXCEEDED가 발생
  • 에러가 늘자마자 QPS가 더 증가(재시도 증폭)

해결

  • deadline은 “정상 p99 + 버퍼 + 네트워크 변동”을 기준으로 잡습니다.
  • 재시도는 지터 포함 exponential backoff, 최대 재시도 횟수 제한, 서킷 브레이커가 필요합니다.

grpc-go 클라이언트 예시(간단한 deadline + per-RPC timeout):

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

resp, err := client.SomeRPC(ctx, req)

재시도를 직접 구현한다면(개념 예시):

for i := 0; i < 3; i++ {
  ctx, cancel := context.WithTimeout(parent, 2*time.Second)
  resp, err := client.SomeRPC(ctx, req)
  cancel()

  if err == nil {
    return resp, nil
  }
  time.Sleep(backoffWithJitter(i))
}
return nil, err

핵심은 “타임아웃이 났으니 즉시 재시도”가 아니라, 시스템을 더 느리게 만드는 재시도 폭주를 막는 것입니다.


3.3 CPU throttling / GC pause로 인한 tail latency 상승

증상

  • 평균 CPU는 낮은데 p99가 튐
  • 특정 시간대(트래픽 버스트)에서만 급격히 악화

해결

  • limits를 너무 낮게 잡지 않습니다(특히 Go/Java).
  • 가능하면 CPU limit을 제거하거나(클러스터 정책에 따라) requests를 올려 스케줄링 품질을 확보합니다.
  • JVM은 GC 로그/heap sizing을 재점검합니다.

Kubernetes 리소스 예시:

resources:
  requests:
    cpu: "500m"
    memory: "512Mi"
  limits:
    cpu: "2000m"
    memory: "1024Mi"

3.4 CoreDNS/네임해결 이슈로 연결 지연

증상

  • 애플리케이션 로그에 no such host, i/o timeout이 간헐적으로 등장
  • 재시작/스케일 아웃 시 새 Pod에서만 더 심함(초기 DNS warm-up)

해결

  • CoreDNS 리소스/replica 확장
  • ndots 과도 설정으로 불필요한 질의가 늘지 않는지 확인
  • gRPC 채널을 매 요청마다 새로 만들지 말고(안티패턴) 커넥션 재사용

클라이언트 안티패턴 예:

// 매 요청마다 Dial -> DNS/handshake 비용 폭발
conn, _ := grpc.Dial(target, grpc.WithInsecure())
defer conn.Close()
client := pb.NewServiceClient(conn)

개선:

// 프로세스 시작 시 1회 Dial 후 재사용
var (
  conn   *grpc.ClientConn
  client pb.ServiceClient
)

func initClient() {
  c, err := grpc.Dial(target,
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithBlock(),
  )
  if err != nil { panic(err) }
  conn = c
  client = pb.NewServiceClient(conn)
}

3.5 로드밸런서/프록시 idle timeout, keepalive 불일치

증상

  • 일정 시간 유휴 후 첫 요청이 자주 타임아웃
  • RST_STREAM, connection reset by peer 동반

해결

  • 프록시/로드밸런서의 idle timeout을 gRPC 사용 패턴에 맞게 조정
  • gRPC keepalive를 과도하게 공격적으로 두면 오히려 중간 장비에서 차단될 수 있으니,
    • 클라이언트 keepalive 주기
    • 서버 enforcement policy
    • LB idle timeout 을 서로 일관되게 맞춥니다.

grpc-go keepalive 예시:

ka := keepalive.ClientParameters{
  Time:                30 * time.Second, // ping 주기
  Timeout:             10 * time.Second,
  PermitWithoutStream: true,
}

conn, err := grpc.Dial(target,
  grpc.WithTransportCredentials(insecure.NewCredentials()),
  grpc.WithKeepaliveParams(ka),
)

4) “지금 당장” 폭증을 멈추는 응급 처치(Stop the bleeding)

  1. 클라이언트 deadline을 임시로 상향(예: 1s → 2~3s)하고 에러율을 즉시 낮춰 재시도 폭주를 완화
  2. 재시도 제한/서킷 브레이커를 즉시 적용(가능하면 feature flag)
  3. HPA/Karpenter로 용량을 빠르게 확보(CPU throttling/큐잉이 원인일 때 효과)
  4. 배포/노드 교체가 원인 같으면 스케일 인/디스러션을 일시 중단(PDB, Karpenter disruption 설정)
  5. 로그가 폭주하면 샘플링/레벨 조정으로 CloudWatch 비용과 I/O 병목을 같이 차단

5) 근본 해결을 위한 체크리스트(재발 방지)

5.1 SLO 기반 deadline 설계

  • 메서드별 정상 p99를 기준으로 deadline을 다르게 설정
  • “서버 처리 + 네트워크 + 큐잉” 버퍼를 포함

5.2 드레이닝 표준화

  • 모든 gRPC 서버에 graceful shutdown 패턴 적용
  • preStop, terminationGracePeriodSeconds, readiness 연동을 템플릿화
  • PDB로 동시에 죽는 Pod 수 제한

5.3 리소스/스케일링 안정화

  • CPU throttling을 메트릭으로 상시 감시
  • HPA는 CPU만 보지 말고 RPS, in-flight, 큐 길이 같은 신호를 추가
  • 노드 스케일링(Karpenter/Cluster Autoscaler) 이벤트와 에러율 상관관계를 대시보드로 고정

5.4 네트워크/프록시 타임아웃 정합성

  • client keepalive / server enforcement / LB idle timeout을 문서화
  • 서비스 메시를 쓰면 per-route timeout, outlier detection, connection pool 설정을 점검

6) 실전 디버깅 플로우(30분 안에 범인 찾기)

  1. 폭증 시점을 기준으로 배포/스케일링/노드 교체 이벤트 확인
  2. DEADLINE_EXCEEDED가 특정 메서드/특정 클라이언트에서만 발생하는지 분리
  3. 클라이언트 p99 vs 서버 p99 비교(네트워크/프록시 vs 서버 처리)
  4. throttling/GC/DB 지연 같은 “서버 내부 지연”을 배제 또는 확정
  5. 드레이닝/idle timeout/keepalive 불일치 확인
  6. 재시도 정책으로 인한 증폭 여부 확인(에러 직후 QPS 상승하면 거의 확정)

마무리

EKS에서 gRPC DEADLINE_EXCEEDED 폭증은 대개 “단일 버그”가 아니라 타임아웃 설계 + 드레이닝 + 스케일링 + 네트워크 경로가 동시에 흔들리며 발생합니다.

가장 효과적인 접근은 (1) 클라이언트/서버 관측을 분리해 지연의 위치를 찾고, (2) 드레이닝과 재시도 정책으로 증폭을 막고, (3) 리소스/타임아웃 정합성을 표준화해 재발을 방지하는 것입니다.

원하시면 사용 중인 조합(Envoy/Istio 여부, NLB/ALB, 언어(grpc-go/grpc-java), HPA/Karpenter 설정, 현재 deadline 값)을 기준으로 원인 후보를 우선순위로 정렬한 맞춤 점검표도 만들어 드리겠습니다.