상태: 초안 (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. 직관: 전화 통화와 라디오
- 2. 그림: 네 가지 방식
- 3. 흐름 제어: 받는 쪽이 속도를 정한다
- 4. deadline 과 취소의 전파
- 5. 재시도와 hedging
- 6. keepalive: 죽은 연결을 빨리 알아채기
- 7. 한계
- 8. 작은 클러스터와의 연결
- 참고
1. 직관: 전화 통화와 라디오
- 단항은 문자 한 통에 답장 한 통입니다.
- 서버 스트리밍은 라디오입니다. 한 번 주파수를 맞추면(요청) 방송이 계속 흘러옵니다. Hubble 의 흐름 관찰(
GetFlows --follow)이 이렇습니다. - 클라이언트 스트리밍은 사진 여러 장을 연달아 보내고 마지막에 「잘 받았다」 한 통을 받는 것입니다.
- 양방향은 전화 통화입니다. 둘이 각자 말하고 듣습니다. etcd 의 Watch 와 LeaseKeepAlive 가 이렇습니다.
네 방식 모두 와이어에서는 같은 HTTP/2 스트림이고, 차이는 메시지가 몇 개 오가느냐와 누가 먼저 끝을 알리느냐뿐입니다.
2. 그림: 네 가지 방식
| 방식 | 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 의 특징 둘:
- 창 크기를 스스로 키웁니다. 핑으로 왕복 시간과 대역폭을 재서 대역폭×지연(BDP)을 추정하고, 창을 최대 16MiB 까지 늘립니다
(
bdp_estimator.go:30의bdpLimit). 먼 거리의 큰 스트림이 64KiB 창에 묶여 느려지지 않게 하려는 것입니다. WithInitialWindowSize·WithInitialConnWindowSize로 창을 직접 정하면 이 자동 조정이 꺼집니다(옵션이StaticWindowSize = true를 함께 켬,dialoptions.go:211). 튜닝하려고 값을 넣었다가 오히려 느려질 수 있는 지점입니다.
4. deadline 과 취소의 전파
- 클라이언트가 0.5초 deadline 으로 부르면
grpc-timeout: 500m가 나갑니다(3편 3.1절). - 서버는 그 값으로 자기 컨텍스트에 기한을 겁니다. 120ms 를 쓰고 다음 서비스를 같은 컨텍스트로 부르면, grpc-go 는 남은 380ms 를 다시
grpc-timeout으로 계산해 보냅니다. 기한이 사슬을 따라 줄어들며 내려가는 셈입니다. - 기한이 지나면 클라이언트는 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 기동) |
- 재시작은 UNAVAILABLE(연결 거부), 느림은 DEADLINE_EXCEEDED 로 나뉘어 남았습니다. 3편 표의 「라이브러리가 만드는 경우」 그대로입니다.
- 실패한 요청은 Collector 자체의 재시도(
retry_on_failure, 최대 5분)로 다시 보냈고, 끝내 못 보낸 묶음 179개(10-05 에 169, 10-13 에 10)를 버렸습니다. 지표로는 실패 스팬 11,624개, 같은 기간 받은 스팬 9,389만 개의 0.012% 입니다. - 이 재시도는 gRPC 라이브러리 재시도(5절)가 아니라 앱 수준 재시도입니다. OTLP 명세가 어떤 코드를 재시도할지 따로 정해 두었습니다(6편).
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 입니다. 읽어 보면 규칙이 분명합니다.
- 이미 커밋된 호출은 재시도하지 않습니다. 서버가 응답 머리말을 보냈다면(응답이 Trailers-Only 가 아니면) 서버 앱이 무언가를 시작했다는 뜻이라 멈춥니다.
- 상태 코드가
retryableStatusCodes에 있어야 합니다. 어떤 코드가 안전한지는 앱마다 달라 기본 목록이 없습니다(statuscodes.md 끝 문단). maxAttempts는 원래 호출을 포함한 횟수이고, 5를 넘기면 5로 잘립니다(dialoptions.go:43defaultMaxCallAttempts).- 대기 시간은
initialBackoff × multiplier^n을maxBackoff로 자르고 0.8~1.2배 무작위를 곱합니다. 서버가 트레일러에grpc-retry-pushback-ms를 주면 그 값을 그대로 쓰고, 음수면 재시도하지 않습니다. - 재시도 조절(throttling). 서버 이름마다 토큰 통이 있어 실패마다 1을 빼고 성공마다
tokenRatio를 더하며, 절반 이하로 떨어지면 재시도를 멈춥니다 (clientconn.go:1733retryThrottler). 서버가 아플 때 재시도가 부하를 몇 배로 키우는 일을 막습니다. - 다시 보내려면 보낸 메시지를 들고 있어야 합니다. 호출당 256KiB 를 넘으면 그 순간 커밋되어 더는 재시도할 수 없습니다
(
rpc_util.go:205,stream.go:940). - 투명 재시도. 요청이 서버 앱에 닿지 못한 것이 확실하면(전송 전 실패, REFUSED_STREAM, GOAWAY 의 마지막 스트림 번호보다 큰 스트림) 정책과 상관없이 다시 보냅니다(A6).
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 는 어떻게 맞췄나
- kube-apiserver 의 etcd 클라이언트는 30초마다 핑, 10초 안에 답이 없으면 끊기로 설정합니다
(
etcd3.go:63). etcd 가 죽은 것을 빨리 알아채려는 공격적인 값이라고 주석이 적고 있습니다. - grpc-go 서버 기본값(최소 5분)이었다면 이 핑은 위반입니다. etcd 는 서버 쪽 최소 간격을 5초로 낮춰 둡니다(etcd v3.6.5
server/embed/config.go:69DefaultGRPCKeepAliveMinTime, 서버 핑 간격 2시간·응답 기한 20초). - 그래서 두 구성요소는 30초 핑 > 5초 하한으로 맞물립니다. 직접 만든 gRPC 서버에 클라이언트 keepalive 를 켤 때는 서버의
MinTime도 함께 낮춰야 합니다.
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. 한계
- 흐름 제어 창이 BDP 추정으로 커지는 과정, keepalive 위반으로 GOAWAY 가 나는 장면, 재시도 정책이 실제로 몇 번 다시 보내는지는 재현하지 않았습니다(미실측).
- 4.2절의 10-05·10-09 지연의 원인(Tempo 쪽 디스크·메모리 등)은 확인하지 못했습니다(미확인).
- etcd 클라이언트의 자체 재시도 인터셉터와 gRPC 서비스 설정 재시도의 관계는 다루지 않았습니다.
8. 작은 클러스터와의 연결
- 직접 만든 gRPC 서비스가 다른 서비스를 부른다면 들어온 ctx 를 그대로 넘기는지부터 봅니다. 그것만으로 4절의 연쇄 취소가 공짜로 따라옵니다.
- 클라이언트 keepalive 를 켰다면 서버의
EnforcementPolicy.MinTime을 그보다 짧게 둡니다. etcd 가 그렇게 합니다. - Collector 처럼 앱 수준 재시도가 있는 경우, gRPC 서비스 설정 재시도를 또 켜면 시도 횟수가 곱해집니다. 둘 중 하나만 둡니다.
티티 한마디. 랠리가 길어지면 심판이 「남은 시간 380ms!」 하고 다음 심판에게 넘겨요. 시계를 새로 맞추면(Background) 경기가 끝났는데도 혼자 치고 있게 돼요!
참고
모든 자료는 2026-10-17 에 열람했습니다.
- gRFC A6 Client Retries, A8 Client-side Keepalive
- grpc/grpc v1.84.0:
doc/keepalive.md,doc/PROTOCOL-HTTP2.md,doc/statuscodes.md - grpc/grpc-go v1.83.2:
stream.go,clientconn.go,dialoptions.go,rpc_util.go,keepalive/keepalive.go,internal/transport/defaults.go,internal/transport/bdp_estimator.go - RFC 9113 HTTP/2: https://www.rfc-editor.org/rfc/rfc9113 (흐름 제어, 초기 창 65,535)
- kubernetes v1.34.10:
staging/src/k8s.io/cri-client/pkg/remote_runtime.go,remote_image.go,storagebackend/factory/etcd3.go - etcd v3.6.5:
server/embed/config.go,api/etcdserverpb/rpc.proto - opentelemetry-collector v0.159.0:
exporter/exporterhelper/internal/timeout_sender.go - 인용 코드: grpc-go v1.83.2, Apache License 2.0.