상태: 초안 (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: 1초마다 묻는 목록
- 5. Hubble 과 hubble-relay: 스트림을 모아 다시 흘리기
- 6. OTLP: Collector → Tempo
- 7. 정리: 연결 모양 넷
- 8. 한계
- 9. 다음 편으로
- 참고
1. 직관: 한 건물 안의 내선 전화
클러스터는 내선 전화가 많은 건물과 비슷합니다. 관리실(apiserver)은 금고(etcd)와 하루 종일 통화 중이고, 층마다 관리인(kubelet)은 주방(containerd)에 1초마다 「지금 누가 있나」를 묻고, 경비실(hubble-relay)은 각 층 CCTV(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개일까요. 쿠버네티스 소스가 답을 적어 두었습니다.
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 스트림·watcher(노드 A) | 242 · 242 | etcd /metrics |
| apiserver 의 열린 Watch 스트림(세 대) | 242 · 239 · 239 | grpc_client_started_total − handled_total |
자원 하나마다 클라이언트 하나, 그 클라이언트 위로 watch 캐시용 Watch 스트림 하나가 붙는 모양이 숫자로 보입니다(247 − 242 의 나머지 5개는
헬스 체크·DB 크기 감시용 클라이언트로 보이지만 확인하지 못함, 추정). 같은 연결 위로 단항 Range·Txn 도 함께 흐릅니다. etcd 클라이언트는 같은
컨텍스트의 watch 여럿을 gRPC 스트림 하나에 묶을 수 있지만(client/v3/watch.go 의 streams 맵),
클라이언트가 자원마다 따로라서 그 묶기가 일어나지 않습니다.
CRD 가 많은 클러스터일수록 연결·고루틴·메모리가 자원 수에 비례해 늘어납니다. 쿠버네티스 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 | DB 크기·상태 확인(추정) |
| 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: 1초마다 묻는 목록
4.1 연결
kubelet 의 CRI 클라이언트는 런타임 서비스와 이미지 서비스에 채널을 따로 만듭니다
(NewRemoteRuntimeService,
NewRemoteImageService). 평문, :authority 는 localhost,
수신 상한은 gRPC 기본 4MiB 대신 16MiB 입니다(maxMsgSize, 컨테이너 목록이 커질 수 있어서). 둘 다 유닉스 소켓 /run/containerd/containerd.sock 으로 갑니다.
제어 노드 A 에서 ss -Hx 로 보니 containerd.sock 쪽 수립된 소켓이 4개였습니다. 둘은 위의 kubelet 채널이고, 나머지 둘의 주인은
일반 사용자 권한으로는 프로세스를 볼 수 없어 확인하지 못했습니다(미확인). 같은 노드에서 containerd.sock.ttrpc 쪽 소켓은 22개였고
containerd-shim 프로세스도 22개였습니다. shim 은 gRPC 가 아니라 가벼운 ttrpc 로 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 가 1초마다 다시 목록을 읽음(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 | 파드 정리 | |
| create_container · start_container | 70 · 70 | 짝이 맞음 | |
| run_podsandbox | 52 | 시간당 파드 약 52개 생성 | |
| remove_container · remove_podsandbox | 50 · 50 | ||
| attach · exec | 36 · 22 | kubectl exec 등 |
|
| pull_image | 2 | 대부분 캐시 적중 |
- 시간당 약 11만 번 중 80% 가 목록 조회입니다. 파드가 바뀌지 않아도 PLEG 는 1초마다 묻습니다. 이 낭비를 줄이려는 것이 런타임이 이벤트를
밀어 주는 Evented PLEG(CRI
GetContainerEvents서버 스트리밍)이지만, v1.34.10 의 기능 게이트 표에서EventedPLEG는 아직 알파·기본 꺼짐입니다(kube_features.go:1277). - 실제 일(파드·컨테이너 만들기)은 시간당 수십 번입니다. 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 사용, 로컬
유닉스 소켓 /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이라 릴레이가 있는 노드의 에이전트가 답합니다. 첫 시도는connection refused(UNAVAILABLE)였고 30초 뒤 다시 붙었습니다.- 스트림으로 노드 8개의
PEER_ADDED를 받자마자 노드마다 4244 로 mTLS 연결을 열었습니다(서버 이름은노드이름.default.hubble-grpc.cilium.io꼴). 여덟 연결이 0.3ms 안에 모두Connected였습니다. - 사용자가
hubble observe로 릴레이의GetFlows를 부르면, 릴레이가 노드마다GetFlows스트림을 열어(retrieveFlowsFromPeer) 받은 흐름을 시간순으로 정렬해(sortFlows) 하나의 스트림으로 돌려줍니다.
부하 분산을 쓰지 않고 노드마다 직접 붙는다는 점이 5편과 대비됩니다. 릴레이는 「아무 노드나」가 아니라 「모든 노드」가 필요하기 때문에, 서비스는 노드 목록을 알려 주는 데만 쓰고 실제 연결은 노드 주소로 직접 엽니다. 노드가 늘면 Notify 스트림이 알려 주므로 DNS 재해석 문제도 없습니다.
6. OTLP: Collector → Tempo
6.1 설정과 흐름
Collector 설정(ConfigMap, 읽기)의 트레이스 파이프라인은 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 실측
| 지표 | 값 | 질의 |
|---|---|---|
| 받은 스팬(누적, 17.4일) | 93,892,856 | otelcol_receiver_accepted_spans(transport=grpc) |
| 받은 스팬(최근 1시간) | 시간당 180,239(초당 50.1) | rate(...[1h])*3600 |
| 보낸 스팬(최근 1시간) | 시간당 180,204 | otelcol_exporter_sent_spans |
| 묶음 평균 크기 | 222.3 스팬 | 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 스팬 ÷ 묶음 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 | 노드당 TCP 약 247(자원마다) | 단항 초당 수십 + 오래 사는 양방향 Watch | CRD 수에 비례하는 연결 |
| kubelet → containerd | 유닉스 소켓 2(런타임·이미지) | 단항, 초당 1.5번 목록 조회 | 폴링 비용(PLEG) |
| relay → Hubble | 노드마다 mTLS 1 | 서버 스트리밍 팬아웃 + 병합 | 서비스는 목록용, 연결은 직접 |
| Collector → Tempo | 서비스 주소 하나 | 단항 Export, 묶음 단위 | 앱 수준 재시도와 OTLP 상태 코드 표 |
8. 한계
- 노드 하나(제어 노드 A)에서만
ss·ps를 봤습니다. 워커의 containerd 2.3 쪽 연결 수는 재지 않았습니다(미실측). - Collector → Tempo 의 TCP 연결 수는 파드 안에서
ss를 실행해야 해서 재지 않았습니다(미실측). - Hubble 의 gRPC 지표(
hubble-metrics-server :9965)는 Prometheus 가 긁지 않아 흐름 수 외에는 보지 못했습니다. - etcd 의 지표는 노드 A 하나, 한 번 읽기입니다. 나머지 두 멤버는 읽지 않았습니다.
9. 다음 편으로
여기 나온 grpc-go 의 채널·서브채널·전송이 소스에서 어떻게 생겼는지는 7편에서, 이 숫자들을 볼 때 조심할 지표 함정은 8편에서 봅니다.
티티 한마디. 관리실이 금고에 전화선을 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; 이슈 #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.