状态:草案(2026-10-17),实测。 测量时间为 2026-10-17 03:00~03:05 (KST),仅进行读取。Prometheus 即时查询(通过 API 服务器的服务代理进行 GET),
kubectl get·logs,在控制节点 A 上以普通用户身份执行ss·ps·curl -s http://127.0.0.1:2381/metrics,Cilium 代理内的cilium-dbg status·hubble observe --last 3。目标版本:kubernetes v1.34.10、etcd v3.6.5、containerd 1.7.27(4 个)·2.3.5(4 个)、Cilium·Hubble v1.20.1、OTel Collector 0.159.0。原始数据存放在工作目录measure/grpc/中,文中仅替换了被掩盖的值。
目录
- 1. 直观理解:大楼内部的分机电话
- 2. 图解
- 3. apiserver → etcd:每种资源一条线
- 4. kubelet → containerd:每秒轮询的列表
- 5. Hubble 与 hubble-relay:汇聚流并重新转发
- 6. OTLP:Collector → Tempo
- 7. 总结:四种连接形态
- 8. 局限性
- 9. 预告:下一篇
- 参考资料
1. 直观理解:大楼内部的分机电话
集群类似于一栋拥有许多分机电话的大楼。管理室(apiserver)整天都在和保险柜(etcd)通话,各楼层的管理员(kubelet)每秒向厨房(containerd)询问一次“现在有谁在里面”,保安室(hubble-relay)同时开启了各楼层的闭路电视(Hubble),审计团队(Collector)则将收集到的记录发送到档案馆(Tempo)。所有线路都是 gRPC,但 铺设了多少根线以及呼叫的频繁程度则完全各不相同。
2. 图解
3. apiserver → etcd:每种资源一条线
3.1 连接数
在控制节点 A 上用 ss -Htn state established "( sport = :2379 )" 统计 etcd 的客户端连接数,结果为 247 个,且对方全部是 127.0.0.1。由于采用 kubeadm 式的架构,apiserver 仅连接到同一节点上的 etcd(通过ps 确认该节点的进程只有 kube-apiserver 和 etcd)。
在第 1 篇中提到过“一个通道对应一个连接,所有的调用都复用该通道发送”,那为什么会有 247 个连接呢?Kubernetes 源码给出了答案。
etcd3.go 的
日志变量上方的注释写道:“目前每个 CRD 创建一个 etcd 客户端,正确的解决方法是不这样做”,并指向了 kubernetes#111476 。实际上, newETCD3Storage 在每次被调用时,都会通过 newETCD3Client 创建一个新的客户端(= 新的 grpc-go 通道 = 新的 TCP)。
| 同一时间的数字 | 值 | 出处 |
|---|---|---|
| etcd 的客户端 TCP(节点 A) | 247 | ss |
| 可列出列表的 API 资源 | 196 | kubectl api-resources --verbs=list |
| 其中属于 CRD 的 | 138 | kubectl get crd |
| etcd 的 Watch 流·观察者(节点 A) | 242 · 242 | etcd /metrics |
| apiserver 打开的 Watch 流(三台) | 242 · 239 · 239 | grpc_client_started_total − handled_total |
从数字中可以看出这种形态:每种资源对应一个客户端,并且在该客户端上附加了一个用于 watch 缓存的 Watch 流(247 减去 242 剩下的 5 个推测为健康检查和数据库大小监控用的客户端,但未得到证实)。一元 Range 和 Txn 也通过相同的连接流动。etcd 客户端可以将同一上下文中的多个 watch 绑定到一个 gRPC 流中(client/v3/watch.go 的 streams 映射),但由于客户端是按资源分别独立的,这种打包合并并没有发生。
集群中 CRD 越多,连接数、Goroutine 数量和内存消耗就会与资源数量成正比地增加。虽然 Kubernetes main 分支正在更换这些客户端的指标收集库(第 8 篇),但“每种资源一个客户端”的结构本身在本次确认的 v1.34.10 中依然如故。
3.2 调用与字节数
这是最近 1 小时的比例(rate(grpc_client_handled_total{job="apiserver"}[1h]),三台机器合计)。
| 方法 | 类型 | 每秒次数 | 说明(包含推测) |
|---|---|---|---|
| KV/Range | 一元 | 30.4 | get·list。大部分由 watch 缓存接收,真正到达 etcd 的只是一部分 |
| KV/Txn | 一元 | 12.0 | create、update、delete 全部都是条件 Txn |
| Lease/LeaseGrant | 一元 | 0.31 | 用于带有 TTL 的对象(如事件等)的租约(推测) |
| Maintenance/Status | 一元 | 0.18 | 数据库大小·状态检查(推测) |
| KV/Compact | 一元 | 0.0034 | 大约每 5 分钟一次。与默认压缩周期 5 分钟相符 |
| Watch/Watch | 双向流 | 启动 0.002,接收消息 181 | 长寿连接(第 4 篇第 6.2 节) |
apiserver 测得的 etcd 请求延迟(etcd_request_duration_seconds,1 小时)为:get p50 2.7ms·p99 22ms,list p50 2.8ms·p99 42ms,update p50 13.6ms·p99 25ms。写入比读取慢大约 5 倍,是因为需要经过 Raft 共识和磁盘同步。
etcd 自身测得的 gRPC 字节数(etcd_network_client_grpc_sent_bytes_total·received_bytes_total,节点 A,启动后 58.4 天)为:发送方 883GiB,接收方 50GiB,平均每秒 183KiB 对 10KiB。 etcd 基本上是一个“发送”服务器,因为响应和 watch 事件远比请求大。
4. kubelet → containerd:每秒轮询的列表
4.1 连接
kubelet 的 CRI 客户端为运行时服务和镜像服务 分别创建了独立的通道 (NewRemoteRuntimeService,
NewRemoteImageService)。明文, :authority 为 localhost,接收上限为 16MiB 而非 gRPC 默认的 4MiB(maxMsgSize,因为容器列表可能会变大)。两者都通过 Unix 套接字 /run/containerd/containerd.sock 进行通信。
在控制节点 A 上用 ss -Hx 查看时, containerd.sock 侧建立的套接字有 4 个。其中两个是上述的 kubelet 通道,另外两个的归属由于普通用户权限无法查看进程而未能确认(未确认)。在同一节点上, containerd.sock.ttrpc 侧的套接字有 22 个,
containerd-shim 进程也正好是 22 个。这从数字上印证了 shim 是通过轻量级的 ttrpc 而不是 gRPC 连接到 containerd 的(结构详见 containerd 与 CRI 读取).
4.2 调用分布
8 个节点合计,最近 1 小时(sum by (operation_type) (rate(kubelet_runtime_operations_total[1h]))*3600)。
| CRI 调用 | 每小时 | 每节点每秒 | 为什么是这个量(依据) |
|---|---|---|---|
| list_containers | 44,509 | 1.55 | PLEG 每秒重新读取列表(kubelet.go:206 genericPlegRelistPeriod) + 其他 |
| list_podsandbox | 44,030 | 1.53 | 同上 |
| exec_sync | 11,338 | — | exec 探针(合计每秒 3.1) |
| status | 5,757 | 0.20 | 每 5 秒检查一次运行时状态(kubelet.go:1826) |
| version | 2,823 | 0.10 | 大约每 10 秒一次(与节点状态更新周期相同,推测) |
| list_images | 1,154 | 0.04 | 周期未确认 |
| container_status | 464 | ||
| podsandbox_status | 285 | ||
| image_status | 143 | ||
| stop_podsandbox | 102 | 清理 Pod | |
| create_container · start_container | 70 · 70 | 成对出现 | |
| run_podsandbox | 52 | 每小时创建约 52 个 Pod | |
| remove_container · remove_podsandbox | 50 · 50 | ||
| attach · exec | 36 · 22 | kubectl exec 等 |
|
| pull_image | 2 | 大多数命中缓存 |
- 在每小时约 11 万次调用中, 80% 是列表查询。即使 Pod 没有发生变化,PLEG 也会每秒进行查询。为了减少这种浪费,运行时推出了由事件驱动推送的 Evented PLEG(CRI
GetContainerEvents服务器流式传输),但在 v1.34.10 的特性门控表中,EventedPLEG仍然处于 Alpha 阶段且默认关闭(kube_features.go:1277). - 实际的工作(创建 Pod 和容器)每小时只有几十次。结合第 4 篇第 4.1 节的延迟表来看,比较慢的是这些不频繁的调用,而频繁的列表查询在 5ms 内即可完成。
- 累计错误(
kubelet_runtime_operations_errors_total)依次为:exec_sync 1,420、run_podsandbox 140、container_status 139、remove_container 123、list_podsandbox 114。由于错误标签中没有状态码,因此无法区分是 DEADLINE_EXCEEDED 还是 NOT_FOUND(未确认)。
5. Hubble 与 hubble-relay:汇聚流并重新转发
Cilium 代理在每个节点上运行一个 Hubble gRPC 服务器。配置(cilium-config,读取)为 hubble-listen-address: :4244,使用服务器 TLS,本地 Unix 套接字为 /var/run/cilium/hubble.sock 。节点 A 上的 Hubble 的流量环形缓冲区(4,095 个槽位)已满,正以每秒 58.5 个的速度记录流量。
hubble-relay 将这些各个节点的服务器汇聚为一个。通过日志和配置梳理出的顺序如下:
- 启动后,在端口 4222 上打开 gRPC 健康检查服务器,在 4245 上打开主服务器(关闭服务器端 TLS,开启客户端端 TLS)。
hubble-peer服务(443 → 4244)打开Peer/Notify服务器流式传输。该服务是internalTrafficPolicy: Local,因此由 relay 所在节点的代理进行响应。初次尝试是connection refused(UNAVAILABLE),并在 30 秒后重新连接。- 通过流接收到 8 个节点的
PEER_ADDED后,立即向每个节点的 4244 端口打开了 mTLS 连接(服务器名称格式为노드이름.default.hubble-grpc.cilium.io)。这八个连接在 0.3ms 内全部变为Connected。 - 当用户通过
hubble observe调用 relay 的GetFlows时,relay 会向每个节点打开GetFlows流(retrieveFlowsFromPeer),将收到的流量按时间顺序排序后(sortFlows),作为一个流返回给用户。
不使用负载均衡,而是 直接连接到每个节点,这一点与第 5 篇形成了对比。由于 relay 需要的不是“任意节点”,而是“所有节点”,因此该服务仅用于告知节点列表,实际连接则是直接通过节点地址打开的。当节点增加时,Notify 流会进行通知,因此也不存在 DNS 重新解析的问题。
6. OTLP:Collector → Tempo
6.1 配置与流程
Collector 配置(ConfigMap,读取)中的 trace 管道为: otlp 接收器(gRPC 4317,HTTP 4318)→ memory_limiter → k8s_attributes → resource → batch(512 个或 5 秒)→
otlp_grpc/tempo 导出器(明文,队列 2,000,最大重试 5 分钟)。OTLP/gRPC 的服务只有一个方法。
service TraceService {
rpc Export(ExportTraceServiceRequest) returns (ExportTraceServiceResponse) {}
}
出处:open-telemetry/opentelemetry-proto opentelemetry/proto/collector/trace/v1/trace_service.proto(main,2026-10-17 查阅;最新版本 v1.11.1),Apache-2.0
响应的 partial_success 可以告知“仅收到一部分”,若失败则根据 OTLP 规范制定的表格决定是否重试。规范将 CANCELLED、DEADLINE_EXCEEDED、
ABORTED、OUT_OF_RANGE、UNAVAILABLE、DATA_LOSS 视为重试对象,将 RESOURCE_EXHAUSTED 视为仅在服务器 RetryInfo 给出时才重试(OTLP 规范)。
这是第 3 篇中“应用所选的状态码决定客户端行为”的好例子。
6.2 实测
| 指标 | 值 | 查询 |
|---|---|---|
| 收到的 Span(累计,17.4 天) | 93,892,856 | otelcol_receiver_accepted_spans(transport=grpc) |
| 收到的 Span(最近 1 小时) | 180,239/小时(50.1/秒) | rate(...[1h])*3600 |
| 发送的 Span(最近 1 小时) | 180,204/小时 | otelcol_exporter_sent_spans |
| 批次平均大小 | 222.3 个 Span | batch_send_size_sum / _count |
| 因大小发送的批次 / 因时间发送的 | 131,014 / 291,267 | batch_size_trigger_send, timeout_trigger_send |
Tempo 的 /tempopb.Pusher/PushBytesV2 |
0.226/秒 | tempo_request_duration_seconds_count |
| 该消息大小 | 65% 在 16KiB 以下,97% 在 64KiB 以下 | tempo_request_message_bytes_bucket |
| 该延迟 | p50 和 p99 均在 10ms 的桶内 | 相同的直方图 |
- 50.1 个 Span/秒 ÷ 222.3 个批次 = 0.225 个批次/秒,而 Tempo 收到的内部 Push 为 0.226/秒 。Collector 的一个批次成为 OTLP
Export的一次调用,并在 Tempo 内部直接作为一次 Push,这一链条在数字上吻合。 - 69% 的批次未能填满 512 个便因 5 秒超时限制而发出。由于这是流量较小的集群,因此是在权衡延迟(最多 5 秒)后进行打包。
- Tempo 内部也会收到
/grpc.health.v1.Health/Check,频率为 0.133 次/秒(约每 7.5 秒一次)。这似乎是 Tempo 内的客户端池在定期对环成员进行健康检查(推测,未查阅 Tempo 和 dskit 源码)。Tempo 的tempo_grpc_concurrent_streams_limit为 100,从指标名称来看,表示单个连接的最大并发流的grpc_concurrent_streams_by_conn_max为 20(未确认指标定义)。 - 失败记录在第 4 篇第 4.2 节中(重启为 UNAVAILABLE,缓慢为 DEADLINE_EXCEEDED)。
7. 总结:四种连接形态
| 路径 | 连接 | 调用形态 | 值得注意的点 |
|---|---|---|---|
| apiserver → etcd | 每个节点约 247 个 TCP(视资源而定) | 一元调用数十次/秒 + 长命双向 Watch | 与 CRD 数量成正比的连接 |
| kubelet → containerd | 2 个 Unix 套接字(运行时、镜像) | 一元调用,列表查询 1.5 次/秒 | 轮询开销(PLEG) |
| relay → Hubble | 每个节点 1 个 mTLS | 服务端流式扇出 + 合并 | 服务用于列表,连接直连 |
| Collector → Tempo | 单个服务地址 | 一元 Export,按批次 | 应用级重试与 OTLP 状态码表 |
8. 局限性
- 仅在单个节点(控制节点 A)上查看了
ss·ps。未统计工作节点上 containerd 2.3 侧的连接数(未实测)。 - Collector → Tempo 的 TCP 连接数由于需要在 Pod 内运行
ss而未进行统计(未实测)。 - Hubble 的 gRPC 指标(
hubble-metrics-server :9965)未被 Prometheus 抓取,因此除了流量之外未能查看其他内容。 - etcd 的指标来自节点 A 的单次读取。未读取其余两个成员。
9. 下篇预告
本文中出现的 grpc-go 的 Channel、SubChannel、Transport 在源码中是如何实现的将在第 7 篇中介绍,观察这些数字时需警惕的指标陷阱将在第 8 篇中介绍。
Titi 的一言。 管理处竟然在保险箱里拉了 247 条电话线。这就好比每个房间都单独放了一台电话。虽然通话很顺畅,但话费(内存)却和房间数量一样多!
参考
所有资料均于 2026-10-17 查阅。
- kubernetes v1.34.10:
storagebackend/factory/etcd3.go,cri-client/pkg/remote_runtime.go,cri-client/pkg/remote_image.go,cri-client/pkg/utils.go,pkg/kubelet/kubelet.go; issue #111476 - etcd v3.6.5:
client/v3/watch.go,api/etcdserverpb/rpc.proto - cilium v1.20.1:
api/v1/observer/observer.proto,api/v1/peer/peer.proto,pkg/hubble/relay/observer/server.go,pkg/hubble/relay/observer/observer.go - OpenTelemetry:
trace_service.proto, OTLP 规范 - 相关文章: containerd 与 CRI 读取, Cilium 读取
- 引用代码:opentelemetry-proto,Apache License 2.0。