TT Lab
요금제

한국어

시작하기
블로그

블로그

작은 클러스터에서 실제로 도는 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)에 1초마다 「지금 누가 있나」를 묻고, 경비실(hubble-relay)은 각 층 CCTV(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개일까요. 쿠버네티스 소스가 답을 적어 두었습니다. 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 대부분 캐시 적중

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 는 이 노드별 서버들을 하나로 묶습니다. 로그와 설정으로 본 순서는 이렇습니다.

  1. 기동하면 4222 포트에 gRPC 헬스 서버, 4245 에 본 서버를 엽니다(서버 쪽 TLS 끔, 클라이언트 쪽 TLS 켬).
  2. hubble-peer 서비스(443 → 4244)로 Peer/Notify 서버 스트리밍을 엽니다. 이 서비스는 internalTrafficPolicy: Local 이라 릴레이가 있는 노드의 에이전트가 답합니다. 첫 시도는 connection refused(UNAVAILABLE)였고 30초 뒤 다시 붙었습니다.
  3. 스트림으로 노드 8개의 PEER_ADDED 를 받자마자 노드마다 4244 로 mTLS 연결을 열었습니다(서버 이름은 노드이름.default.hubble-grpc.cilium.io 꼴). 여덟 연결이 0.3ms 안에 모두 Connected 였습니다.
  4. 사용자가 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 버킷 안 같은 히스토그램

7. 정리: 연결 모양 넷

경로 연결 호출 모양 눈여겨볼 점
apiserver → etcd 노드당 TCP 약 247(자원마다) 단항 초당 수십 + 오래 사는 양방향 Watch CRD 수에 비례하는 연결
kubelet → containerd 유닉스 소켓 2(런타임·이미지) 단항, 초당 1.5번 목록 조회 폴링 비용(PLEG)
relay → Hubble 노드마다 mTLS 1 서버 스트리밍 팬아웃 + 병합 서비스는 목록용, 연결은 직접
Collector → Tempo 서비스 주소 하나 단항 Export, 묶음 단위 앱 수준 재시도와 OTLP 상태 코드 표

8. 한계

9. 다음 편으로

여기 나온 grpc-go 의 채널·서브채널·전송이 소스에서 어떻게 생겼는지는 7편에서, 이 숫자들을 볼 때 조심할 지표 함정은 8편에서 봅니다.

티티 한마디. 관리실이 금고에 전화선을 247가닥이나 깔았대요. 방마다 따로 전화기를 놓은 셈이죠. 통화는 잘 되지만 전화비(메모리)는 방 수만큼 나와요!

참고

모든 자료는 2026-10-17 에 열람했습니다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다