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 agentss·ps·curl -s http://127.0.0.1:2381/metrics, inside the Cilium agentcilium-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 directorymeasure/grpc/and only masked values have been transcribed into this article.
Table of Contents
- 1. Intuition: Intercoms in a Building
- 2. Diagram
- 3. apiserver → etcd: One Line per Resource
- 4. kubelet → containerd: Polling Lists Every Second
- 5. Hubble and hubble-relay: Collecting and Re-streaming Streams
- 6. OTLP: Collector → Tempo
- 7. Summary: Four Connection Topologies
- 8. Limitations
- 9. Next Up
- References
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
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 |
- Out of about 110,000 times per hour, 80% are list queries. Even if pods do not change, PLEG asks every second. The goal of reducing this waste is Evented PLEG (CRI server streaming) where the runtime pushes events, but in the v1.34.10 feature gate table,
GetContainerEventsis still alpha and disabled by default(EventedPLEG).kube_features.go:1277). - Actual work (creating pods and containers) happens dozens of times per hour. Looking together with the latency table in Section 4.1 of Part 4, what is slow are these rare calls, while frequent list queries finish within 5ms.
- Cumulative errors (
kubelet_runtime_operations_errors_total) were in the order of exec_sync 1,420, run_podsandbox 140, container_status 139, remove_container 123, list_podsandbox 114. Because the error labels did not contain status codes, it was not possible to distinguish whether they were DEADLINE_EXCEEDED or NOT_FOUND (unconfirmed).
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.
- 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).
hubble-peerservice (443 → 4244) to open aPeer/Notifyserver stream. This service is ainternalTrafficPolicy: Local, so the agent on the node with the relay responds. The first attempt wasconnection refused(UNAVAILABLE) and it reconnected 30 seconds later.- As soon as it received the
PEER_ADDEDof 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 wereConnectedwithin 0.3ms. - When a user calls the relay's
hubble observeusingGetFlows, the relay opensGetFlowsstreams 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 |
- 50.1 spans per second ÷ 222.3 batch size = 0.225 batches per secondand the internal Push received by Tempo is 0.226 per second . A single batch from the Collector becomes one OTLP
Exportcall, which maps directly to a single Push inside Tempo, aligning the numbers perfectly. - 69% of the batches did not reach 512 items and were flushed due to the 5-second timeout. Since this is a low-traffic cluster, it trades latency (up to 5 seconds) for batching.
- Inside Tempo as well,
/grpc.health.v1.Health/Checkcomes in at 0.133 times per second (once every 7.5 seconds). This appears to be the client pool inside Tempo performing periodic health checks on ring members (estimated, as Tempo and dskit sources were not opened). Tempo'stempo_grpc_concurrent_streams_limitwas 100, and judging by the metric name,grpc_concurrent_streams_by_conn_max, which represents the maximum concurrent streams per connection, was 20 (metric definitions were not verified). - Failure logs can be found in section 4.2 of Part 4 (restarts are UNAVAILABLE, slowness is DEADLINE_EXCEEDED).
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
- Observations were limited to a single node (control plane node A).
ss·psConnections for containerd 2.3 on workers were not measured (unmeasured). - The number of TCP connections for Collector → Tempo was not measured because it required running
ssinside the pod (unmeasured). - Hubble's gRPC metrics (
hubble-metrics-server :9965) were not scraped by Prometheus, so only flow counts could be observed. - Etcd metrics are from a single read on node A. The other two members were not read.
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.
- 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 specification - Related Posts: Reading containerd and CRI, Reading Cilium
- Quoted Code: opentelemetry-proto, Apache License 2.0.