Published on

gRPC 마이크로서비스 503·데드라인 초과 디버깅

Authors

서버 간 통신을 gRPC로 바꾸면 “HTTP/2라 빠르다”, “IDL이 있으니 안전하다” 같은 장점이 먼저 보입니다. 하지만 운영에서는 에러가 훨씬 ‘압축된 형태’로 나타납니다. 대표가 UNAVAILABLE(503)DEADLINE_EXCEEDED입니다. 둘 다 “요청이 실패했다”는 결과는 같지만, 원인 레이어가 완전히 다릅니다.

이 글은 **마이크로서비스 환경(특히 Kubernetes/EKS, Envoy/ALB/NLB, 서비스 메시)**에서 gRPC 503·데드라인 초과를 재현 → 분리 → 계측 → 수정 순서로 디버깅하는 방법을 정리합니다.


1) 에러 코드 해석: 503은 HTTP가 아니라 gRPC 상태다

먼저 용어를 정리해야 합니다.

  • gRPC의 UNAVAILABLE는 종종 HTTP status 503으로 매핑되어 보이지만, 실제 의미는 “현재 서버에 도달할 수 없거나(연결 실패), 연결이 중간에 끊겼거나, 로드밸런서/프록시가 업스트림을 못 찾는다”에 가깝습니다.
  • DEADLINE_EXCEEDED는 “서버가 늦게 응답했다” 뿐 아니라, 클라이언트/프록시가 타임아웃을 먼저 선언했을 수도 있습니다.

실무에서 가장 중요한 질문은 두 가지입니다.

  1. 요청이 서버까지 도달했나? (서버 로그/메트릭/트레이스에 흔적이 있는가)
  2. 도달했다면 어디서 시간이 소비됐나? (큐잉/DB/외부 API/GC/스레드 풀)

이 두 질문만 끝까지 밀어붙이면 대부분 해결됩니다.


2) 증상 분류: 503 vs DEADLINE_EXCEEDED 빠른 분기표

A. UNAVAILABLE(503)가 많은 경우

  • DNS/Service discovery 문제 (CoreDNS, headless service, endpoint stale)
  • L4/L7 로드밸런서가 gRPC를 제대로 못 받음 (HTTP/2, h2c, idle timeout)
  • 프록시(Envoy/Nginx) 업스트림 연결 실패
  • 서버 프로세스 크래시/재시작, readiness 실패로 엔드포인트가 빠짐
  • 커넥션/FD 고갈(서버가 accept 못 함)

B. DEADLINE_EXCEEDED가 많은 경우

  • 서버 처리 지연(스레드 풀 고갈, 락 경합, DB 커넥션 대기)
  • 클라이언트 deadline이 너무 짧음
  • 프록시/Ingress에서 더 짧은 timeout을 강제
  • 재시도 정책이 오히려 꼬여서 tail latency 폭발

이 분류가 중요한 이유는, 503은 네트워크/인프라/연결 수립 쪽을 먼저 보고, deadline 초과는 서버 내부 병목을 먼저 보는 게 시간 절약이기 때문입니다.


3) “서버에 도달했는지” 확인하는 최소 계측

3.1 서버 접근 로그/인터셉터로 요청 흔적 남기기

gRPC 서버는 HTTP access log가 기본으로 없기 때문에, 인터셉터로 최소한의 메타데이터를 남겨야 합니다.

Go 서버 예시(Unary Interceptor)

grpc.NewServer(
  grpc.UnaryInterceptor(func(
    ctx context.Context,
    req any,
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
  ) (any, error) {
    start := time.Now()
    md, _ := metadata.FromIncomingContext(ctx)

    resp, err := handler(ctx, req)

    st, _ := status.FromError(err)
    log.Printf(
      "method=%s code=%s dur=%s peer=%v xrid=%v",
      info.FullMethod,
      st.Code(),
      time.Since(start),
      peerFromCtx(ctx),
      first(md.Get("x-request-id")),
    )
    return resp, err
  }),
)
  • 로그에 찍히지 않으면: 서버까지 요청이 안 온 것(프록시/네트워크/디스커버리)일 확률이 큽니다.
  • 로그는 찍히는데 deadline: 서버 내부 병목 또는 다운스트림 호출 타임아웃을 의심합니다.

3.2 클라이언트에서 deadline/재시도/호출 시간 로깅

클라이언트는 “내가 몇 초를 줬는지”를 반드시 남겨야 합니다.

ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()

start := time.Now()
resp, err := c.GetUser(ctx, &pb.GetUserRequest{Id: "42"})
log.Printf("GetUser dur=%s err=%v", time.Since(start), err)

운영에서 자주 생기는 실수: 서버는 2초 안에 응답할 수 있는데, 클라이언트가 300ms deadline을 걸어놓고 “서버 느리다”로 결론내는 경우입니다.


4) gRPC 503(UNAVAILABLE) 디버깅: 연결 레이어부터

4.1 로드밸런서/Ingress의 HTTP/2, idle timeout 확인

gRPC는 HTTP/2 장기 연결을 전제로 합니다. 따라서 Ingress/ALB/NLB/프록시의 idle timeout이 짧으면 유휴 커넥션이 끊기고 다음 요청에서 UNAVAILABLE가 튀는 패턴이 나옵니다(특히 트래픽이 듬성듬성한 배치/관리 API).

  • ALB Ingress를 쓴다면 idle timeout(기본 60초)과 keepalive 정책을 반드시 확인하세요. gRPC 자체 문제처럼 보이지만 사실은 L7 타임아웃인 경우가 많습니다.

문맥상 함께 보면 좋은 글: EKS ALB Ingress 504(60초) idle_timeout 해결

4.2 kube-proxy/IPVS/conntrack 이슈로 간헐적 연결 실패

노드 네트워킹 계층에서 연결이 끊기면 애플리케이션은 보통 UNAVAILABLE로만 봅니다.

체크 포인트:

  • 특정 노드에서만 발생하는가? (Pod가 어느 노드에 뜨면 터지는지)
  • kubectl get endpoints가 정상인데도 연결이 실패하는가?
  • conntrack 테이블이 포화되는가? (nf_conntrack_max, drop 증가)

EKS에서 kube-proxy 모드(IPVS/iptables) 변경 이후 통신 장애가 난 사례는 재현도 어렵고 증상이 비슷합니다. 관련 경험 글: EKS kube-proxy를 IPVS로 바꾼 뒤 통신 장애 복구

4.3 서버의 파일 디스크립터(FD)/소켓 고갈

서버가 커넥션을 더 못 받으면, 클라이언트는 연결 실패 → UNAVAILABLE로 관측합니다.

리눅스에서 다음을 확인하세요.

  • 프로세스 FD 사용량
  • ulimit -n / systemd LimitNOFILE
  • TIME_WAIT 폭증(특히 L4 LB 앞에서 짧은 커넥션을 반복하는 구조)

FD 고갈은 gRPC에서도 흔합니다. 특히 스트리밍을 많이 쓰거나, 프록시/사이드카가 붙으면 소켓 수가 급격히 늘 수 있습니다. 참고: 리눅스 Too many open files 해결 - ulimit·systemd·Nginx


5) DEADLINE_EXCEEDED 디버깅: “서버가 늦은가, 기다림이 짧은가”

5.1 서버 처리 시간 vs 큐잉 시간 분리

서버가 느린 게 아니라 서버가 일할 기회를 못 얻는 경우가 많습니다.

  • 스레드/워커 풀 고갈
  • 동시성 제한(세마포어)로 대기
  • CPU throttling (K8s limits)
  • GC stop-the-world

가장 빠른 방법은 **서버에서 ‘핸들러 진입 시각’과 ‘응답 시각’**을 찍고, 추가로 큐잉이 있다면 큐 대기 시간을 분리해서 기록하는 것입니다.

5.2 DB 커넥션 대기(HikariCP/풀 고갈)로 deadline 초과

Java/Spring 기반 gRPC 서버에서 deadline 초과의 상위 원인은 DB 커넥션 풀 고갈입니다.

  • 애플리케이션은 “DB 쿼리 시간이 길다”로 보이지만
  • 실제론 “커넥션을 얻기까지 기다리다” 타임아웃이 나는 경우

Spring Boot라면 HikariCP 메트릭(Active/Idle/Pending)과 함께, 커넥션 획득 시간(최소 로그/메트릭)을 꼭 보세요.

관련 글: Spring Boot 3에서 HikariCP 커넥션 고갈 원인 9가지

5.3 클라이언트 deadline/프록시 timeout 정렬

운영에서 권장하는 규칙:

  • 클라이언트 deadline < 프록시 timeout < 서버 내부 타임아웃 같은 식으로 뒤죽박죽이면 디버깅이 지옥이 됩니다.
  • 보통은 서버 내부 다운스트림 타임아웃 < 클라이언트 deadline < 프록시 idle timeout처럼, “안쪽이 먼저 포기하고 바깥은 그 결과를 받는” 구조가 관측 가능성을 높입니다.

예시(권장 패턴):

  • 서버가 DB에 400ms 타임아웃
  • 서버 전체 처리 deadline 800ms
  • 클라이언트 deadline 1s
  • Ingress idle timeout 60s(혹은 더)

이렇게 하면 DB가 느릴 때 서버가 먼저 실패를 결정하고, 클라이언트는 DEADLINE_EXCEEDED가 아니라 더 구체적인 에러로 받을 여지가 생깁니다.


6) 재시도(retry)와 데드라인의 함정: tail latency를 폭발시키지 말 것

gRPC는 재시도 정책을 잘못 넣으면 장애를 증폭시킵니다.

  • 서버가 느려져서 deadline이 가까워짐
  • 클라이언트가 재시도
  • 서버 부하가 더 증가
  • 더 많은 요청이 deadline 초과

권장 체크:

  • 재시도는 멱등(idempotent) 요청에만
  • 재시도 횟수 제한 + 지수 백오프 + jitter
  • “서버가 처리 중인데 클라이언트가 포기”하는 중복 실행을 줄이기 위해, 가능하면 요청 ID 기반 중복 방지 고려

Envoy를 쓴다면 x-envoy-attempt-count 같은 헤더/메타데이터로 재시도 횟수를 추적해 “한 요청이 몇 번이나 날아갔는지”를 관측하세요.


7) 현장에서 바로 쓰는 디버깅 플레이북(체크리스트)

7.1 10분 내 1차 결론 내기

  1. 서버 로그(인터셉터)에 요청이 찍히나?
    • No → 네트워크/LB/DNS/엔드포인트/readiness
    • Yes → 서버 내부 병목/다운스트림
  2. 에러가 503(UNAVAILABLE)인가, DEADLINE_EXCEEDED인가?
  3. 특정 노드/특정 AZ/특정 버전에서만 발생하나? (배포 직후면 더더욱)
  4. 클라이언트 deadline 값이 무엇인가? (코드/설정)

7.2 Kubernetes/EKS에서 추가로 보는 것

  • kubectl describe pod에서 readiness probe 실패/재시작
  • kubectl get endpoints가 비어있거나 수가 흔들림
  • 노드 리소스 압박(CPU throttling, 메모리)
  • conntrack/네트워크 드롭

7.3 서버 리소스/커넥션 관측

  • FD 사용량(Too many open files 전조)
  • gRPC keepalive 설정(서버/클라이언트)
  • 스레드 풀/이벤트 루프 백로그
  • DB 풀 pending

8) 예방: 운영 친화적인 gRPC 설정 템플릿

아래는 “장기 연결이 중간 장비에서 끊겨도 빨리 감지하고, 무한 대기 없이 실패를 관측 가능하게 만드는” 쪽에 초점을 둔 예시입니다.

Go client keepalive + timeout 예시

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

conn, err := grpc.NewClient(
  target,
  grpc.WithTransportCredentials(insecure.NewCredentials()),
  grpc.WithKeepaliveParams(ka),
)
if err != nil { panic(err) }

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
_, err = pb.NewUserClient(conn).GetUser(ctx, &pb.GetUserRequest{Id: "42"})

주의: keepalive는 인프라(LB, 방화벽) 정책과 충돌할 수 있으니, 무작정 짧게 잡지 말고 “유휴 커넥션이 끊기는 주기”를 측정한 뒤 조정하세요.


9) 마무리: 503과 deadline은 ‘현상’이고, 답은 레이어 분리에 있다

gRPC에서 503과 deadline 초과는 에러 메시지가 짧아 더 막막하게 느껴지지만, 역으로 말하면 레이어를 분리해서 증거를 모으면 해결이 빠릅니다.

  • 503(UNAVAILABLE): “연결이 왜 안 되는가” → LB/프록시/네트워크/FD/엔드포인트
  • DEADLINE_EXCEEDED: “왜 늦는가” → 서버 큐잉/스레드풀/DB 풀/다운스트림 타임아웃 정렬

다음 액션으로는 (1) 서버 인터셉터로 최소 로그를 남기고, (2) 클라이언트 deadline/재시도 정책을 명시화하고, (3) LB/Ingress idle timeout과 keepalive를 정렬하는 것부터 시작하면 대부분의 케이스가 정리됩니다.