状态:草案(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 的两个特点:
- 窗口大小是 自动扩大的。 它通过 ping 测量往返时间和带宽来估算带宽时延积(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 秒)内等待应用结束(推测:其中混杂了关闭缓慢的 Pod)。由于桶边界为 0.005、0.0125 等比较稀疏,因此列表查询的“5ms 以下”意味着在第一个桶内。最近 6 小时内是否有 kubelet_runtime_operations_errors_total CRI 超时错误,由于没有错误类型标签而无法区分(未确认)。
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 Pod 重启(00:42 终止,00:44 启动) |
- 重启表现为 UNAVAILABLE(拒绝连接),缓慢表现为 DEADLINE_EXCEEDED ,分别记录了下来。这正如第 3 篇表格中的“库产生的情况”一样。
- 失败的请求通过 Collector 自身的重试机制(
retry_on_failure,最多 5 分钟)进行了重新发送,最终丢弃了未能发送的 179 批数据(10-05 丢弃 169 批,10-13 丢弃 10 批)。从指标来看,失败 Span 为 11,624 个,占同期收到 Span 总数 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 。阅读后会发现规则非常清晰。
- 已经提交(committed)的调用不会被重试。 如果服务器发送了响应头部(即响应不只是 Trailers-Only),则意味着服务器端应用已经开始处理,因此会停止重试。
- 状态码必须在
retryableStatusCodes中。哪些代码是安全的因应用而异,因此没有默认列表(statuscodes.md 末尾段落)。 maxAttempts包括原始调用在内的次数,如果超过 5 则截断为 5(dialoptions.go:43defaultMaxCallAttempts).- 等待时间将
initialBackoff × multiplier^n用maxBackoff截断并乘以 0.8~1.2 倍 的随机数。如果服务器在 Trailer 中给出了grpc-retry-pushback-ms,则直接使用该值,如果为负数则不重试。 - 重试限流(throttling)。 每个服务器名称都有一个令牌桶,每次失败减 1,每次成功加
tokenRatio,当降到一半以下时停止重试 (clientconn.go:1733retryThrottler)。这可以防止服务器故障时重试放大数倍负载。 - 要重新发送,必须保留发送的消息。如果每次调用超过 256KiB,则在该瞬间会被提交,无法再次重试
(
rpc_util.go:205,stream.go:940). - 透明重试(Transparent Retry)。 如果确定请求未到达服务器应用(传输前失败、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(Ping 间隔) |
关闭(无限)。开启时至少 10 秒 | defaults.go:32, keepalive.go 注释 |
客户端 Timeout |
20 秒 | 同文件第 33 行 |
服务端 Time |
2 小时 | 第 38 行 |
服务端允许的最小 Ping 间隔(EnforcementPolicy.MinTime) |
5 分钟 | 第 40 行 |
无流时的 Ping 许可(PermitWithoutStream) |
拒绝 | keepalive.go |
| 违规允许次数 | 2 次,第三次发送 GOAWAY | http2_server.go:881 maxPingStrikes |
连接最大寿命(MaxConnectionAge) |
无限 | defaults.go:36 |
最常见的事故是双方配置不一致。如果客户端每 30 秒发送一次 ping,而服务器坚持默认值(5 分钟),服务器就会认为 ping「过于频繁」并
GOAWAY(ENHANCE_YOUR_CALM, "too_many_pings") 断开连接(handlePing)。
客户端会将其视为 RESOURCE_EXHAUSTED 或 UNAVAILABLE,并且 grpc-go 客户端从这时起会将 ping 间隔翻倍(keepalive.go 注释)。
6.1 实测:apiserver 和 etcd 是如何协调的
- kube-apiserver 的 etcd 客户端设置为 每 30 秒发送一次 ping,若 10 秒内未收到响应则断开连接(
etcd3.go:63)。注释中提到,这是一个为了能快速检测出 etcd 宕机而采取的激进数值。 - 如果使用的是 grpc-go 服务器默认值(最小 5 分钟),那么这个 ping 就会违规。etcd 将服务器端的最小间隔降低到了 5 秒(etcd v3.6.5
server/embed/config.go:69DefaultGRPCKeepAliveMinTime,服务器 ping 间隔 2 小时,响应超时 20 秒)。 - 因此,这两个组件通过“30 秒 ping > 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 服务配置重试,尝试次数就会相乘。请只保留其中一个。
Titi 的话。 如果拉力赛变长了,裁判会喊「剩余时间 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 许可证。