- Published on
Go gRPC 데드라인 초과 해결 - context·LB·Keepalive
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트가 모두 정상처럼 보이는데도 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는 크게 두 가지 상황을 의미합니다.
- 클라이언트 측 context 데드라인이 먼저 만료
- 네트워크 지연, 서버 처리 지연, 재시도/큐잉, 커넥션 재수립 비용 등으로 전체 시간이 초과
- 서버가 응답을 주기 전에 경로 중간에서 끊김/정체
- 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 해결 방향
- LB idle timeout을 늘리거나
- gRPC keepalive를 적절히 보내 중간 장비가 커넥션을 정리하지 않게 하거나
- 커넥션이 죽었을 때 빠르게 감지하고 재연결하도록 설정
쿠버네티스/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를 재발 없이 정리할 수 있습니다.