TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

gRPC in Action on a Small Cluster: etcd, CRI, Hubble, OTLP

Status: Draft (2026-10-17), measured. Measurements were taken via read-only access from 2026-10-17 03:00 to 03:05 (KST) using Prometheus instant queries (GET via the API server's service proxy), kubectl get·logs, as a regular user on control node A, and inside the Cilium agent ss·ps·curl -s http://127.0.0.1:2381/metrics, inside the Cilium agent cilium-dbg status·hubble observe --last 3 . Target versions: kubernetes v1.34.10, etcd v3.6.5, containerd 1.7.27 (4 nodes) and 2.3.5 (4 nodes), Cilium/Hubble v1.20.1, OTel Collector 0.159.0. Raw data is in the working directory measure/grpc/ and only masked values have been transcribed into this article.

Table of Contents

1. Intuition: Intercoms in a Building

A cluster is like a building with many intercoms. The management office (apiserver) talks to the safe (etcd) all day, the floor manager (kubelet) asks the kitchen (containerd) "Who is here now?" every second, the security office (hubble-relay) tunes into the CCTV (Hubble) for every floor at once, and the audit team (Collector) sends collected logs to the archive (Tempo). All of these lines are gRPC, but how many lines are laid and how often they ring are completely different from one another. how many lines are laid and how often they ringare completely different from one another.

2. Diagram

작은 클러스터 안에서 실제로 도는 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 를 열어 합친다
Diagram: gRPC paths and measured figures in a small cluster

3. apiserver → etcd: One Line per Resource

3.1 Number of Connections

Counting etcd client connections from control node A using ss -Htn state established "( sport = :2379 )" , we found there were 247, and all counterparts were 127.0.0.1. Because of the kubeadm-style setup, apiserver only connects to etcd on the same node (processes on that node checked withps are just kube-apiserver and etcd).

In Part 1, we mentioned "one connection per channel, multiplexing calls over it," so why are there 247 connections? The Kubernetes source code provides the answer. etcd3.go The comment above the logger variable in states, "Currently a separate etcd client is created for each CRD. The correct fix is not to do this," and points to kubernetes#111476 . In reality, newETCD3Storage creates a new client (= new grpc-go channel = new TCP) via newETCD3Client every time it is called.

Metric at the same time Value Source
etcd client TCP (Node A) 247 ss
API resources listable 196 kubectl api-resources --verbs=list
CRDs among them 138 kubectl get crd
etcd Watch streams / watchers (Node A) 242 · 242 etcd /metrics
Open Watch streams from apiserver (three nodes) 242 · 239 · 239 grpc_client_started_total − handled_total

The numbers show a pattern where each resource gets its own client, and a Watch stream for the watch cache is attached over that client (the remaining 5 out of 247 appear to be clients for health checks and DB size monitoring, though unconfirmed and estimated). Unary Range and Txn also flow over the same connections. While etcd clients can group multiple watches of the same context into a single gRPC stream (theclient/v3/watch.go map in streams ), this grouping does not happen because clients are separated per resource.

The more CRDs a cluster has, the more connections, goroutines, and memory scale in proportion to the number of resources. Although Kubernetes main is currently replacing the metrics collection libraries for these clients (Part 8), the "client per resource" architecture itself remains unchanged in v1.34.10, which we verified here.

3.2 Calls and Bytes

Past 1-hour ratio (rate(grpc_client_handled_total{job="apiserver"}[1h]), sum of three nodes).

Method Type Per Second Description (Including Estimates)
KV/Range Unary 30.4 get/list. Most are handled by the watch cache, with only some reaching etcd.
KV/Txn Unary 12.0 create, update, and delete are all conditional Txns.
Lease/LeaseGrant Unary 0.31 Lease for objects with TTLs (e.g., events) (estimated).
Maintenance/Status Unary 0.18 DB size and status check (estimated).
KV/Compact Unary 0.0034 About once every 5 minutes. Matches the default compaction interval of 5 minutes.
Watch/Watch Bidi Stream Start 0.002, received messages 181 Long-lived stream (Section 4.6.2)

etcd request latency measured by apiserver (etcd_request_duration_seconds, 1 hour) was: get p50 2.7ms, p99 22ms; list p50 2.8ms, p99 42ms; update p50 13.6ms, p99 25ms. Writes are about 5 times slower than reads because they go through Raft consensus and disk synchronization.

gRPC bytes self-measured by etcd (etcd_network_client_grpc_sent_bytes_total·received_bytes_total, Node A, 58.4 days since startup) were 883 GiB sent and 50 GiB received, averaging 183 KiB vs 10 KiB per second. etcd is predominantly a "sending" serverbecause responses and watch events are much larger than requests.

4. kubelet → containerd: Polling Lists Every Second

4.1 Connection

kubelet's CRI client connects to the runtime service and image service. create a separate channel (NewRemoteRuntimeService, NewRemoteImageService). Plaintext, :authority is localhost, and the receive limit is 16MiB instead of the default gRPC 4MiB (maxMsgSize, because the container list can grow large). Both go over Unix sockets /run/containerd/containerd.sock .

When checking from control node A to ss -Hx , the number of established sockets on the containerd.sock side was 4. Two of them are the kubelet channels above, and the owners of the other two could not be verified because processes cannot be viewed with regular user privileges (unverified). On the same node, the number of sockets on the containerd.sock.ttrpc side was 22and there were 22 containerd-shim processes as well. It matches the numbers that shim connects to containerd via lightweight ttrpc instead of gRPC (the structure is Reading containerd and CRI).

4.2 Call Distribution

Total for 8 nodes, last 1 hour(sum by (operation_type) (rate(kubelet_runtime_operations_total[1h]))*3600).

CRI Call Per Hour Per Node Per Second Why this much (Rationale)
list_containers 44,509 1.55 PLEG lists again every 1 second(kubelet.go:206 genericPlegRelistPeriod) + others
list_podsandbox 44,030 1.53 Same as above
exec_sync 11,338 — exec probes (total 3.1 per second)
status 5,757 0.20 Check runtime status every 5 seconds(kubelet.go:1826)
version 2,823 0.10 About every 10 seconds (same as node status update interval, estimated)
list_images 1,154 0.04 Interval unverified
container_status 464
podsandbox_status 285
image_status 143
stop_podsandbox 102 Pod cleanup
create_container · start_container 70 · 70 Matched pairs
run_podsandbox 52 About 52 pods created per hour
remove_container · remove_podsandbox 50 · 50
attach · exec 36 · 22 kubectl exec , etc.
pull_image 2 mostly cache hits

5. Hubble and hubble-relay: Collecting and restreaming streams

The Cilium agent runs a Hubble gRPC server on each node. The configuration (cilium-config, read) is hubble-listen-address: :4244, uses server TLS, and has a local Unix socket /var/run/cilium/hubble.sock . Hubble on node A was recording 58.5 flows per second with its 4,095-slot flow ring buffer full.

hubble-relay aggregates these per-node servers into one. The sequence seen from logs and configuration is as follows.

  1. Upon startup, it opens a gRPC health server on port 4222 and the main server on 4245 (server-side TLS disabled, client-side TLS enabled).
  2. hubble-peer service (443 → 4244) to open a Peer/Notify server stream. This service is a internalTrafficPolicy: Local , so the agent on the node with the relay responds. The first attempt was connection refused(UNAVAILABLE) and it reconnected 30 seconds later.
  3. As soon as it received the PEER_ADDED of the 8 nodes via the stream, it opened an mTLS connection to 4244 on each node (with server names in the format 노드이름.default.hubble-grpc.cilium.io ). All eight connections were Connected within 0.3ms.
  4. When a user calls the relay's hubble observe using GetFlows , the relay opens GetFlows streams to each node (retrieveFlowsFromPeer), sorts the received flows chronologically (sortFlows), and returns them as a single stream.

Connecting directly to each node without using load balancing contrasts with Part 5. Because the relay needs "every node" rather than "any node," the service is used only to provide the list of nodes, and actual connections are opened directly to the node addresses. As nodes scale up, the Notify stream informs the relay, eliminating DNS re-resolution issues.

6. OTLP: Collector → Tempo

6.1 Configuration and Flow

The trace pipeline in the Collector configuration (ConfigMap, read) is the otlp receiver (gRPC 4317, HTTP 4318) → memory_limiter → k8s_attributes → resource → batch(512 or 5s) → otlp_grpc/tempo exporter (plaintext, queue 2,000, max retry 5 min). OTLP/gRPC has only a single method service.

service TraceService {
  rpc Export(ExportTraceServiceRequest) returns (ExportTraceServiceResponse) {}
}

Source: open-telemetry/opentelemetry-proto opentelemetry/proto/collector/trace/v1/trace_service.proto(main, accessed 2026-10-17; latest release v1.11.1), Apache-2.0

of the response partial_success to signal "partially received," and if it fails, it determines whether to retry according to the table defined by the OTLP specification. The specification treats CANCELLED, DEADLINE_EXCEEDED, ABORTED, OUT_OF_RANGE, UNAVAILABLE, and DATA_LOSS as retryable, and RESOURCE_EX RetryInfo HETERO_EXHAUSTED only when the server returnsOTLP specification). This is a great example of part 3's "The status code chosen by the app determines the client's action."

6.2 Measured Data

Metric Value Query
Spans received (cumulative, 17.4 days) 93,892,856 otelcol_receiver_accepted_spans(transport=grpc)
Spans received (last 1 hour) 180,239 per hour (50.1 per second) rate(...[1h])*3600
Spans sent (last 1 hour) 180,204 per hour otelcol_exporter_sent_spans
Average batch size 222.3 spans batch_send_size_sum / _count
Batches sent due to size / due to time 131,014 / 291,267 batch_size_trigger_send, timeout_trigger_send
Tempo's /tempopb.Pusher/PushBytesV2 0.226 per second tempo_request_duration_seconds_count
Message size 65% are 16KiB or smaller, 97% are 64KiB or smaller tempo_request_message_bytes_bucket
Latency Both p50 and p99 are within the 10ms bucket Same histogram

7. Summary: Four Connection Shapes

Path Connection Call Shape Notable Points
apiserver → etcd About 247 TCP per node (varies by resource) Unary dozens per second + long-lived bidirectional Watch Connections proportional to the number of CRDs
kubelet → containerd 2 Unix sockets (runtime and image) Unary, list requests 1.5 times per second Polling cost (PLEG)
relay → Hubble 1 mTLS per node Server streaming fan-out + merge Service used for listing, connections made directly
Collector → Tempo Single service address Unary Export, batched App-level retries and OTLP status code table

8. Limitations

9. Next Up

Part 7 explores how grpc-go's channels, subchannels, and transports are implemented in the source code, and Part 8 examines metric pitfalls to watch out for when looking at these numbers.

TT's Word. The management office ran 247 telephone lines into the safe! It's like having a separate phone in every single room. Calls go through great, but the phone bill (memory) scales with the number of rooms!

References

All materials were accessed on 2026-10-17.

Sign in to like

Comments

No comments yet.

Sign in to leave a comment