Published on

Go gRPC 데드라인 초과 해결 - context·LB·Keepalive

Authors

서버/클라이언트가 모두 정상처럼 보이는데도 Go gRPC 호출이 간헐적으로 rpc error: code = DeadlineExceeded desc = context deadline exceeded로 터지면, 대개 문제는 타임아웃 값 자체가 아니라 타임아웃이 “왜” 소진되는지에 있습니다. 특히 운영 환경에서는 (1) context의 데드라인 전파, (2) L4/L7 로드밸런서/프록시의 유휴 타임아웃과 커넥션 재사용, (3) keepalive/HTTP2 설정 불일치가 겹치며 “가끔만” 실패하는 패턴이 자주 나옵니다.

이 글은 Go gRPC 기준으로 원인 분류 → 관측 포인트 → 설정/코드 수정 순서로 정리합니다. 쿠버네티스/EKS에서 겪는 네트워크성 타임아웃 진단은 관점이 유사하니, 인프라 레벨 점검은 Kubernetes apiserver i/o timeout 원인과 해결, 서비스 장애 스냅샷은 EKS에서 503 Service Unavailable 원인 10분 진단도 함께 참고하면 좋습니다.

1) DeadlineExceeded의 의미부터 정확히 잡기

DeadlineExceeded는 크게 두 가지 상황을 의미합니다.

  1. 클라이언트 측 context 데드라인이 먼저 만료
    • 네트워크 지연, 서버 처리 지연, 재시도/큐잉, 커넥션 재수립 비용 등으로 전체 시간이 초과
  2. 서버가 응답을 주기 전에 경로 중간에서 끊김/정체
    • LB idle timeout, NAT/Conntrack 문제, HTTP/2 keepalive 정책 불일치

중요한 점은 gRPC는 HTTP/2 기반이라, “TCP는 살아있는데 HTTP/2 스트림이 막혀있는” 상태도 생길 수 있다는 겁니다. 이때 애플리케이션 레벨에서 보면 그냥 데드라인 초과로 보입니다.

2) 재현 가능한 “관측”부터: 어떤 시간이 소진되는가

해결은 관측이 반입니다. 다음 3가지를 먼저 확보하세요.

2.1 클라이언트 데드라인/타임아웃 값 로그화

요청마다 데드라인이 얼마 남았는지 찍으면, 상위 호출이 이미 촉박한 데드라인을 내려보내는 문제를 빠르게 잡을 수 있습니다.

// client_interceptor.go
package grpcutil

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
)

func DeadlineLoggingUnaryClientInterceptor() grpc.UnaryClientInterceptor {
	return func(
		ctx context.Context,
		method string,
		req, reply any,
		cc *grpc.ClientConn,
		invoker grpc.UnaryInvoker,
		opts ...grpc.CallOption,
	) error {
		if dl, ok := ctx.Deadline(); ok {
			log.Printf("grpc call=%s deadline_in=%s", method, time.Until(dl))
		} else {
			log.Printf("grpc call=%s deadline=none", method)
		}
		start := time.Now()
		err := invoker(ctx, method, req, reply, cc, opts...)
		log.Printf("grpc call=%s took=%s err=%v", method, time.Since(start), err)
		return err
	}
}

2.2 서버 처리 시간/큐잉 시간 분리

서버가 실제로 오래 걸리는지(핸들러 처리), 아니면 요청이 도착도 못하는지를 분리해야 합니다. 서버 인터셉터로 핸들러 시간을 측정하세요.

// server_interceptor.go
package grpcutil

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
)

func TimingUnaryServerInterceptor() grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req any,
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (any, error) {
		start := time.Now()
		resp, err := handler(ctx, req)
		log.Printf("grpc handler=%s took=%s err=%v", info.FullMethod, time.Since(start), err)
		return resp, err
	}
}
  • 클라이언트는 3초 걸렸다고 하는데 서버 핸들러는 10ms라면, 네트워크/커넥션/LB 쪽입니다.
  • 서버 핸들러가 2.9초라면, 서버 성능/락/DB 쪽입니다.

2.3 gRPC 내부 로그(HTTP/2, keepalive, name resolver)

운영에서 상시 켜기는 부담되지만, 문제 구간에서만 활성화해보면 큰 힌트를 줍니다.

export GRPC_GO_LOG_SEVERITY_LEVEL=info
export GRPC_GO_LOG_VERBOSITY_LEVEL=2
# 필요 시
export GRPC_TRACE=http,transport_security,keepalive,clientconn

3) context 설계: “타임아웃을 늘리기” 전에 해야 할 것

3.1 상위 context 데드라인을 무작정 전파하지 않기

HTTP 요청 컨텍스트를 그대로 gRPC에 전달하면, 프론트/게이트웨이 타임아웃 정책에 끌려가며 백엔드가 불필요하게 촉박해집니다.

  • 외부 요청: 2초
  • 내부 gRPC 호출: 1~1.5초만 남은 상태로 시작 → 조금만 지연돼도 DeadlineExceeded

권장 패턴은 상위 데드라인을 존중하되, 내부 호출에 최소 예산을 확보하는 것입니다.

func withBudget(ctx context.Context, max time.Duration) (context.Context, context.CancelFunc) {
	// 상위 데드라인이 없으면 max를 그대로 사용
	if dl, ok := ctx.Deadline(); ok {
		remain := time.Until(dl)
		if remain <= 0 {
			return context.WithTimeout(ctx, 0)
		}
		if remain < max {
			// 상위가 더 촉박하면 그 안에서만 움직인다
			return context.WithTimeout(ctx, remain)
		}
	}
	return context.WithTimeout(ctx, max)
}

// 사용 예
ctx2, cancel := withBudget(r.Context(), 800*time.Millisecond)
defer cancel()
err := client.DoSomething(ctx2, req)

3.2 서버에서 ctx.Done()을 “진짜로” 존중하기

서버가 데드라인을 무시하고 DB/외부 API를 계속 기다리면, 클라이언트는 이미 타임아웃인데 서버 자원은 계속 묶입니다(동시성 저하 → 다음 요청도 느려짐 → 연쇄 DeadlineExceeded).

  • DB 쿼리: QueryContext
  • HTTP 호출: http.NewRequestWithContext
  • 고루틴/채널: select { case <-ctx.Done(): ... }
func (s *Svc) Get(ctx context.Context, id string) (*Resp, error) {
	row := s.db.QueryRowContext(ctx, "SELECT ... WHERE id=?", id)
	// ...
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	default:
	}
	return &Resp{}, nil
}

3.3 재시도/백오프가 데드라인을 잡아먹는지 확인

클라이언트에서 재시도를 구현했거나(또는 프록시가 재시도), 재시도 횟수×백오프가 데드라인을 초과할 수 있습니다.

  • 데드라인 2초
  • 3회 재시도, 백오프 300ms/600ms/1200ms → 이미 합이 2.1초

정책을 세울 때는 “요청 전체 예산”에서 재시도 예산을 먼저 떼어내는 방식이 안전합니다.

4) LB/프록시에서 흔한 원인: Idle timeout과 HTTP/2

간헐적 DeadlineExceeded의 대표 원인은 유휴 커넥션을 중간 장비가 먼저 끊어버리는 상황입니다.

  • 클라이언트는 커넥션이 살아있다고 믿고 기존 커넥션으로 스트림을 열려 함
  • 실제로는 LB/NAT가 유휴로 정리해버려 첫 패킷부터 재전송/재수립이 발생
  • 핸드셰이크/리졸브/재연결 비용이 데드라인을 먹고 실패

4.1 증상 패턴

  • 트래픽이 꾸준할 때는 괜찮고, 한동안 조용했다가 다시 호출하면 첫 요청이 실패
  • 동일 메서드가 “가끔만” 실패
  • 서버 로그에는 해당 호출이 거의 안 찍히거나, 매우 짧게 찍힘

4.2 해결 방향

  1. LB idle timeout을 늘리거나
  2. gRPC keepalive를 적절히 보내 중간 장비가 커넥션을 정리하지 않게 하거나
  3. 커넥션이 죽었을 때 빠르게 감지하고 재연결하도록 설정

쿠버네티스/EKS 환경에서 이런 “중간에서 끊김”은 503/timeout으로도 나타납니다. 인프라 관점의 빠른 분류는 EKS에서 503 Service Unavailable 원인 10분 진단 프레임이 그대로 적용됩니다.

5) Go gRPC Keepalive: 클라이언트/서버 설정 예시

5.1 클라이언트 keepalive 설정

핵심은 Time(핑 주기)와 Timeout(핑 응답 대기)을 LB idle timeout보다 짧게 잡는 것입니다. 단, 너무 공격적으로 하면 프록시/서버가 “핑이 너무 잦다”고 연결을 끊을 수 있습니다.

import (
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/keepalive"
)

ka := keepalive.ClientParameters{
	Time:                30 * time.Second, // 유휴 시에도 30초마다 ping
	Timeout:             10 * time.Second, // ping ack 대기
	PermitWithoutStream: true,             // 스트림 없어도 ping
}

conn, err := grpc.NewClient(
	addr,
	grpc.WithTransportCredentials(creds),
	grpc.WithKeepaliveParams(ka),
)

5.2 서버 keepalive 정책(너무 잦은 핑 차단/허용)

서버는 클라이언트 핑을 허용할지 정책을 가집니다. 클라이언트가 PermitWithoutStream: true를 쓰면 서버도 그에 맞춰야 불필요한 disconnect를 줄입니다.

import (
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/keepalive"
)

srv := grpc.NewServer(
	grpc.KeepaliveParams(keepalive.ServerParameters{
		MaxConnectionIdle:     5 * time.Minute,
		MaxConnectionAge:      30 * time.Minute,
		MaxConnectionAgeGrace: 2 * time.Minute,
		Time:                 2 * time.Minute, // 서버도 필요 시 ping
		Timeout:              20 * time.Second,
	}),
	grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
		MinTime:             20 * time.Second, // 이보다 잦은 ping은 비정상으로 판단 가능
		PermitWithoutStream: true,
	}),
)

운영 팁

  • LB idle timeout(예: 60s, 350s 등) 값을 먼저 확인하고, ClientParameters.Time을 그보다 짧게.
  • 서버 MinTime은 클라이언트 Time보다 작거나 같게 맞추지 않으면, 서버가 연결을 끊을 수 있습니다.

6) 로드밸런싱(LB)과 name resolver: “한 인스턴스만 느린” 문제

DeadlineExceeded가 특정 Pod/인스턴스에서만 난다면, 평균 타임아웃을 늘려도 해결되지 않습니다. 이때는 로드밸런싱/엔드포인트 선택을 봐야 합니다.

6.1 pick_first vs round_robin

Go gRPC는 기본이 pick_first 성격이라(환경/리졸버에 따라 다름) 한 커넥션에 붙어버리면 특정 백엔드가 느릴 때 계속 영향을 받을 수 있습니다.

import (
	"google.golang.org/grpc"
	"google.golang.org/grpc/resolver"
)

// round_robin을 쓰려면 서비스 디스커버리/리졸버가 다중 주소를 제공해야 함
conn, err := grpc.NewClient(
	addr,
	grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
)

_ = resolver.Get("dns") // 예: dns:///svc.namespace:50051
  • dns:///로 다중 A 레코드를 받는 구성 또는 xDS 기반 구성이면 효과가 큽니다.
  • 쿠버네티스에서 ClusterIP는 L4 레벨에서 분산되지만, gRPC는 커넥션을 오래 유지하므로 “결국 한 Pod에 고정”되는 체감이 생길 수 있습니다.

6.2 느린 인스턴스 격리: outlier detection/health check

  • readiness probe가 지나치게 관대하면 “느린데 살아있는” Pod가 계속 트래픽을 받습니다.
  • 애플리케이션 레벨 health(스레드풀/DB 커넥션 고갈 등)를 반영해야 합니다.

여기서도 10분 내 원인 분류 프레임은 K8s Pod CrashLoopBackOff 원인 7가지와 해결처럼 “증상→레벨→원인”으로 가져가면 빠릅니다(크래시가 아니어도, Pod 레벨 관측 습관이 도움).

7) 커넥션 재사용과 풀링: 매 요청 Dial은 금물

gRPC ClientConn비싸고, 매 요청마다 새로 만들면 DNS/핸드셰이크/HTTP2 세션 설정 비용이 데드라인을 갉아먹습니다.

  • 올바른 패턴: 프로세스 시작 시 Dial → 재사용
  • 잘못된 패턴: 요청마다 Dial/Close
// good: singleton conn
var conn *grpc.ClientConn

func Init() error {
	c, err := grpc.NewClient(addr, grpc.WithTransportCredentials(creds))
	if err != nil { return err }
	conn = c
	return nil
}

func Handler(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
	client := pb.NewMyServiceClient(conn)
	ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()
	return client.Call(ctx, req)
}

8) 실전 체크리스트(원인별 빠른 처방)

8.1 서버 핸들러가 느리다

  • DB/외부 API latency 확인, 쿼리 플랜/인덱스, 커넥션 풀 고갈
  • ctx 취소 전파 확인
  • 서버 리소스(CPU throttling, GC, lock contention)

8.2 서버는 빠른데 클라이언트만 DeadlineExceeded

  • LB idle timeout / NAT idle timeout / 방화벽 세션 타임아웃
  • keepalive 설정 불일치(서버가 잦은 ping 차단)
  • 요청 직전 DNS 지연/리졸브 문제
  • 매 요청 Dial 여부

8.3 “조용하다가 첫 요청만” 실패

  • 전형적인 idle timeout
  • PermitWithoutStream: true + 적절한 Time으로 keepalive
  • 또는 애초에 LB idle timeout을 늘림

8.4 특정 Pod/노드에서만 발생

  • 해당 Pod의 CPU throttling, GC stop-the-world, DB 연결 문제
  • readiness/liveness 기준 강화
  • 로드밸런싱 정책 점검(한 엔드포인트에 고정되는지)

9) 결론: DeadlineExceeded는 ‘시간 예산’과 ‘경로’를 함께 봐야 한다

Go gRPC의 데드라인 초과는 단순히 timeout=10s로 올린다고 끝나지 않는 경우가 많습니다.

  • context: 데드라인 전파/예산 배분/취소 전파
  • LB/프록시: idle timeout, HTTP/2 연결 유지 정책
  • keepalive: 클라이언트/서버 정책 정합성
  • 커넥션 재사용: Dial 비용 제거
  • LB 전략: 느린 인스턴스에 고정되지 않게 분산/격리

운영에서 간헐 장애는 대부분 여러 레이어가 합쳐져 나타납니다. 위 순서대로 “서버가 느린가?” → “경로에서 끊기는가?” → “유휴 커넥션/keepalive인가?”를 분리해 나가면, DeadlineExceeded를 재발 없이 정리할 수 있습니다.