TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

小型集群中实际运行的 gRPC:etcd、CRI、Hubble、OTLP

状态:草案(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. 直观理解:大楼内部的分机电话

集群类似于一栋拥有许多分机电话的大楼。管理室(apiserver)整天都在和保险柜(etcd)通话,各楼层的管理员(kubelet)每秒向厨房(containerd)询问一次“现在有谁在里面”,保安室(hubble-relay)同时开启了各楼层的闭路电视(Hubble),审计团队(Collector)则将收集到的记录发送到档案馆(Tempo)。所有线路都是 gRPC,但 铺设了多少根线以及呼叫的频繁程度则完全各不相同。

2. 图解

작은 클러스터 안에서 실제로 도는 gRPC 제어 노드 A (같은 구성 3대) kube-apiserver etcd 클라이언트 etcd 3.6 :2379 gRPC TCP 247개 자원마다 클라이언트 단항 Range·Txn 초당 약 42회, 양방향 Watch 스트림 apiserver 당 약 240개(etcd 쪽 242). 대상은 127.0.0.1 모든 노드: kubelet → containerd 도 gRPC(아래) 모든 노드 (8대) kubelet CRI 클라이언트 2개 containerd unix 소켓 Hubble :4244 8대 합계 초당 약 30회: 목록 조회가 대부분(PLEG) 관측 파이프라인 앱들 OTel Collector OTLP :4317 Tempo OTLP :4317 스팬 초당 약 50개 → 배치 평균 222개 → Export 초당 0.23회 Tempo 안에서도 PushBytesV2·헬스 체크가 gRPC hubble-relay 노드 8곳에 mTLS Peer.Notify 로 노드 목록을 받고 노드마다 GetFlows 를 열어 합친다
图解:小型集群内部的 gRPC 路径与实测数据

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 大多数命中缓存

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 将这些各个节点的服务器汇聚为一个。通过日志和配置梳理出的顺序如下:

  1. 启动后,在端口 4222 上打开 gRPC 健康检查服务器,在 4245 上打开主服务器(关闭服务器端 TLS,开启客户端端 TLS)。
  2. hubble-peer 服务(443 → 4244)打开 Peer/Notify 服务器流式传输。该服务是 internalTrafficPolicy: Local ,因此由 relay 所在节点的代理进行响应。初次尝试是 connection refused(UNAVAILABLE),并在 30 秒后重新连接。
  3. 通过流接收到 8 个节点的 PEER_ADDED 后,立即向每个节点的 4244 端口打开了 mTLS 连接(服务器名称格式为 노드이름.default.hubble-grpc.cilium.io )。这八个连接在 0.3ms 内全部变为 Connected 。
  4. 当用户通过 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 的桶内 相同的直方图

7. 总结:四种连接形态

路径 连接 调用形态 值得注意的点
apiserver → etcd 每个节点约 247 个 TCP(视资源而定) 一元调用数十次/秒 + 长命双向 Watch 与 CRD 数量成正比的连接
kubelet → containerd 2 个 Unix 套接字(运行时、镜像) 一元调用,列表查询 1.5 次/秒 轮询开销(PLEG)
relay → Hubble 每个节点 1 个 mTLS 服务端流式扇出 + 合并 服务用于列表,连接直连
Collector → Tempo 单个服务地址 一元 Export,按批次 应用级重试与 OTLP 状态码表

8. 局限性

9. 下篇预告

本文中出现的 grpc-go 的 Channel、SubChannel、Transport 在源码中是如何实现的将在第 7 篇中介绍,观察这些数字时需警惕的指标陷阱将在第 8 篇中介绍。

Titi 的一言。 管理处竟然在保险箱里拉了 247 条电话线。这就好比每个房间都单独放了一台电话。虽然通话很顺畅,但话费(内存)却和房间数量一样多!

参考

所有资料均于 2026-10-17 查阅。

登录后即可点赞

评论

还没有评论。

登录后即可发表评论