TT Lab
요금제

한국어

시작하기
블로그

블로그

스트리밍, deadline, 재시도, keepalive: 호출이 길어지거나 실패할 때

상태: 초안 (2026-10-17), 일부 실측. 기준 판 grpc/grpc-go v1.83.2, gRFC A6 클라이언트 재시도, grpc/grpc v1.84.0 doc/keepalive.md. 실측은 2026-10-17 03시대(KST)에 Prometheus·kubectl logs·노드의 etcd /metrics 를 읽기만 해서 얻었습니다. 흐름 제어 창이 실제로 커지는 모습, 재시도·keepalive 를 일부러 일으키는 실험은 하지 않았습니다(미실측, 필요한 것: grpc-go 예제 서버·클라이언트를 빌드할 Go 모듈 내려받기).

목차

1. 직관: 전화 통화와 라디오

네 방식 모두 와이어에서는 같은 HTTP/2 스트림이고, 차이는 메시지가 몇 개 오가느냐와 누가 먼저 끝을 알리느냐뿐입니다.

2. 그림: 네 가지 방식

네 가지 호출 방식: 화살표 하나가 메시지 하나 단항 rpc Hit(Rally) returns (Score) C S 서버 스트리밍 returns (stream Flow) C S 클라이언트 스트리밍 rpc Upload(stream Chunk) C S 양방향 rpc Watch(stream) returns (stream) C S 진한 실선: 클라이언트→서버, 회색 점선: 서버→클라이언트
그림: 네 가지 호출 방식의 메시지 흐름
방식 proto 선언 작은 클러스터에서 본 예
단항 rpc Range(RangeRequest) returns (RangeResponse) etcd Range·Txn, OTLP Export, CRI 전부
서버 스트리밍 returns (stream …) Hubble Observer/GetFlows, Peer/Notify, etcd Maintenance/Snapshot
클라이언트 스트리밍 rpc …(stream …) 지표에서 찾지 못함
양방향 rpc Watch(stream WatchRequest) returns (stream WatchResponse) etcd Watch·LeaseKeepAlive

(etcd 선언은 rpc.proto, Hubble 은 observer.proto·peer.proto에서 확인)

스트림은 공짜가 아닙니다. 공식 성능 안내는 스트림이 한 번 열리면 부하 분산으로 옮길 수 없고 디버깅이 어렵다며, 이득이 분명할 때만 쓰라고 권합니다 (Performance Best Practices). 5편의 쏠림 문제가 스트림에서 더 심한 이유입니다.

3. 흐름 제어: 받는 쪽이 속도를 정한다

서버 스트리밍에서 서버가 초당 만 개를 보내는데 클라이언트가 천 개만 처리하면 어떻게 될까요. HTTP/2 는 받는 쪽이 「이만큼 더 보내도 된다」고 창(window)을 열어 주는 방식으로 막습니다. 창은 스트림마다 하나, 연결 전체에 하나 있고, 기본 크기는 65,535바이트입니다(RFC 9113). 받는 쪽이 데이터를 소비하면 WINDOW_UPDATE 로 창을 다시 엽니다. 창이 0이면 보내는 쪽의 Send 가 막혀서 기다립니다. 이것이 gRPC 스트림의 배압(backpressure)입니다.

grpc-go 의 특징 둘:

4. deadline 과 취소의 전파

deadline 은 시각이 아니라 남은 시간으로 건너간다 게이트웨이 점수 서비스 저장소 grpc-timeout 500m grpc-timeout 380m 남은 시간 막대 (0.5초 예산) 출발할 때 500ms 남음 120ms 처리 뒤 380ms 남음 저장소 도착 380ms 남음 시간이 다 되면: 클라이언트는 DEADLINE_EXCEEDED 를 받고, RST_STREAM(CANCEL) 이 서버로 가서 그쪽 ctx 도 취소된다. 서버가 다시 부른 다음 호출에도 같은 ctx 를 넘겨야 연쇄로 멈춘다.
그림: deadline 이 남은 시간으로 전파되는 모습
  1. 클라이언트가 0.5초 deadline 으로 부르면 grpc-timeout: 500m 가 나갑니다(3편 3.1절).
  2. 서버는 그 값으로 자기 컨텍스트에 기한을 겁니다. 120ms 를 쓰고 다음 서비스를 같은 컨텍스트로 부르면, grpc-go 는 남은 380ms 를 다시 grpc-timeout 으로 계산해 보냅니다. 기한이 사슬을 따라 줄어들며 내려가는 셈입니다.
  3. 기한이 지나면 클라이언트는 DEADLINE_EXCEEDED 를 받고, 스트림에 RST_STREAM(CANCEL) 을 보냅니다. 서버 쪽 컨텍스트가 취소되므로 그 서버가 컨텍스트를 물려받아 부른 하위 호출도 연쇄로 멈춥니다.

함정은 2번의 「같은 컨텍스트로」입니다. Go 에서 서버 핸들러가 받은 ctx 대신 context.Background() 로 하위 호출을 하면 전파가 끊겨, 위에서는 이미 포기한 일을 아래에서 끝까지 합니다. 명세는 기한이 없는 요청을 무한으로 보라고 하므로(PROTOCOL-HTTP2.md Requests 절) 기본값에 기대면 안 됩니다.

4.1 실측: kubelet 의 CRI 기한과 실제 지연

kubelet 의 CRI 클라이언트는 호출마다 기한을 겁니다. 기본 runtimeRequestTimeout 2분, RunPodSandbox 는 그 두 배(4분), StopContainer 는 2분 + 종료 유예 시간입니다(remote_runtime.go:204, 379행). 반면 PullImage 는 기한 없이 취소만 겁니다(remote_image.go 의 PullImage, context.WithCancel). 이미지 받기는 크기에 따라 몇 분이 걸려도 정상이기 때문입니다.

8대 합계, 최근 6시간 히스토그램으로 계산했습니다.

histogram_quantile(0.99, sum by (operation_type, le) (rate(kubelet_runtime_operations_duration_seconds_bucket[6h])))
CRI 호출 p50 p99 걸린 기한
pull_image 35.8초 116.6초 없음(취소만)
stop_container 23.7초 47.2초 2분 + 유예
exec_sync(exec 프로브) 60ms 2.2초 프로브 timeoutSeconds
run_podsandbox 365ms 1.19초 4분
create_container 12ms 976ms 2분
start_container 64ms 464ms 2분
list_containers 5ms 이하 5ms 이하 2분

stop_container 의 p50 이 24초인 것은 런타임이 느려서가 아니라 SIGTERM 뒤 유예 시간(대개 30초) 안에 앱이 끝나기를 기다리기 때문입니다(추정: 종료가 느린 파드가 섞여 있음). 버킷 경계가 0.005, 0.0125 … 로 성기므로 목록 조회의 「5ms 이하」는 첫 버킷 안이라는 뜻입니다. 최근 6시간에 CRI 기한 초과 오류가 있었는지는 kubelet_runtime_operations_errors_total 에 오류 종류 라벨이 없어 구분하지 못했습니다(미확인).

4.2 실측: OTel Collector 의 5초 기한

Collector 의 내보내기는 요청마다 기본 5초 기한을 겁니다(opentelemetry-collector v0.159.0 timeout_sender.go:31). Collector 로그(기동한 2026-09-29 이후 전부, kubectl logs 읽기)에서 Tempo 로의 내보내기 실패를 상태 코드별로 세었습니다.

날짜(UTC) DEADLINE_EXCEEDED UNAVAILABLE 그때 일어난 일
2026-10-05 14:39~16:05 2,049 27 Tempo 응답 지연(원인 미확인)
2026-10-09 18:25~18:27 34 32 짧은 끊김(원인 미확인)
2026-10-13 00:40~00:46 0 175 Tempo 파드 재시작(00:42 종료, 00:44 기동)

5. 재시도와 hedging

gRPC 는 서비스 설정(service config) 의 메서드별 retryPolicy 로 라이브러리가 재시도하게 할 수 있습니다. 예를 들면 이렇습니다(A6 의 형식).

{"methodConfig": [{"name": [{"service": "tt.v1.Scoreboard"}],
  "retryPolicy": {"maxAttempts": 4, "initialBackoff": "0.1s", "maxBackoff": "1s",
                  "backoffMultiplier": 2, "retryableStatusCodes": ["UNAVAILABLE"]}}]}

grpc-go 에서 실제로 재시도를 정하는 함수는 csAttempt.shouldRetry 입니다. 읽어 보면 규칙이 분명합니다.

if a.transportStream != nil {
	if !a.transportStream.TrailersOnly() {
		return false, err
	}
	sps := a.transportStream.Trailer()["grpc-retry-pushback-ms"]
	...
}
rp := cs.methodConfig.RetryPolicy
if rp == nil || !rp.RetryableStatusCodes[code] {
	return false, err
}
if cs.retryThrottler.throttle() {
	return false, err
}
if cs.numRetries+1 >= rp.MaxAttempts {
	return false, fmt.Errorf("max retries exhausted: failed after %d attempts: %w", cs.numRetries+1, err)
}

출처: grpc/grpc-go v1.83.2 stream.go 727~768행 발췌, Apache-2.0

hedging 은 응답을 기다리지 않고 일정 간격(hedgingDelay)으로 같은 요청을 여러 서버에 미리 보내 가장 빠른 답을 쓰는 방식입니다(A6). 꼬리 지연을 줄이지만 멱등한 메서드에만 써야 합니다. grpc-go 1.83.2 에는 TODO(hedging) 주석만 있고(stream.go:634) 서비스 설정에서 hedgingPolicy 를 읽는 코드가 없습니다. Go 에서는 아직 쓸 수 없다고 보면 됩니다(다른 언어 구현은 확인하지 않음).

6. keepalive: 죽은 연결을 빨리 알아채기

TCP 연결은 상대가 사라져도(전원 차단, NAT 표에서 지워짐) 한동안 살아 있는 것처럼 보입니다. gRPC 는 HTTP/2 PING 을 보내 답이 없으면 연결을 끊습니다.

설정 grpc-go 기본값 근거
클라이언트 Time(핑 간격) 끔(무한). 켜면 최소 10초 defaults.go:32, keepalive.go 주석
클라이언트 Timeout 20초 같은 파일 33행
서버 Time 2시간 38행
서버가 허용하는 최소 핑 간격(EnforcementPolicy.MinTime) 5분 40행
스트림 없을 때 핑 허용(PermitWithoutStream) 거부 keepalive.go
위반 허용 횟수 2번, 세 번째에 GOAWAY http2_server.go:881 maxPingStrikes
연결 최대 수명(MaxConnectionAge) 무한 defaults.go:36

가장 흔한 사고는 양쪽 설정의 불일치입니다. 클라이언트가 30초마다 핑을 보내는데 서버가 기본값(5분)을 고집하면, 서버는 핑을 「너무 잦다」고 세다가 GOAWAY(ENHANCE_YOUR_CALM, "too_many_pings") 로 연결을 끊습니다(handlePing). 클라이언트는 이를 RESOURCE_EXHAUSTED 나 UNAVAILABLE 로 보게 되고, grpc-go 클라이언트는 다음부터 핑 간격을 두 배로 늘립니다(keepalive.go 주석).

6.1 실측: apiserver 와 etcd 는 어떻게 맞췄나

6.2 실측: 양방향 스트림은 얼마나 오래 사나

sum by (instance) (grpc_client_started_total{job="apiserver",grpc_method="Watch"})
  - sum by (instance) (grpc_client_handled_total{job="apiserver",grpc_method="Watch"})

시작했지만 아직 끝나지 않은 Watch 스트림이 apiserver 세 대에 각각 242·239·239개였습니다. 같은 시각 제어 노드 A 의 etcd 는 etcd_debugging_mvcc_watch_stream_total 242, etcd_debugging_mvcc_watcher_total 242 로 클라이언트 쪽 숫자와 정확히 맞았습니다. 최근 1시간 동안 끝난 Watch 는 초당 0.002개(시간당 약 8개)뿐이고, 받은 Watch 메시지는 세 대 합계 초당 약 181개였습니다. 스트림 하나가 며칠씩 살아 있으면서 변경 이벤트를 흘려보내는 모양입니다. 스트림이 왜 하필 약 240개인지는 6편에서 TCP 연결 수와 함께 봅니다.

7. 한계

8. 작은 클러스터와의 연결

티티 한마디. 랠리가 길어지면 심판이 「남은 시간 380ms!」 하고 다음 심판에게 넘겨요. 시계를 새로 맞추면(Background) 경기가 끝났는데도 혼자 치고 있게 돼요!

참고

모든 자료는 2026-10-17 에 열람했습니다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다