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. 直感: 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: リソースごとに 1 本

3.1 接続数

コントロールプレーンノード A で ss -Htn state established "( sport = :2379 )" を使って etcd へのクライアント接続数を数えると 247個であり、宛先はすべて 127.0.0.1 でした。 kubeadm 式の構成であるため、apiserver は同じノード上の etcd にのみ接続します(ps で確認した該当ノードのプロセスは kube-apiserver と etcd のみ)。

パート 1 では「1 つのチャンネルに 1 つの接続、その上で呼び出しを多重化する」と説明しましたが、なぜ 247 個もあるのでしょうか。Kubernetes のソースコードにその答えが記されています。 etcd3.go の ロガー変数の上のコメントには、「現在は CRD ごとに etcd クライアントを 1 つずつ作成しているが、正しい解決策はそうしないことである」とあり、 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 ストリーム(3 台) 242 · 239 · 239 grpc_client_started_total − handled_total

リソースごとに 1 つのクライアントがあり、そのクライアント上で watch キャッシュ用の Watch ストリームが 1 つずつ紐づく形が数値から読み取れます(247 − 242 の残りの 5 個はヘルスチェックおよび DB サイズ監視用のクライアントと推測されますが、未確認です)。同じ接続上で単項の Range や Txn も一緒に流れます。etcd クライアントは同じコンテキストの複数の watch を 1 つの gRPC ストリームにまとめることができますが(client/v3/watch.go の streams マップ)、 クライアントがリソースごとに分かれているため、そのまとめ処理が行われません。

CRD が多いクラスターほど、接続数、ゴルーチン、メモリがリソース数に比例して増加します。Kubernetes の main ブランチではこれらのクライアントのメトリクス収集ライブラリが変更されつつありますが(パート 8)、「リソースごとにクライアントを持つ」という構造自体は、今回確認した v1.34.10 でもそのまま残っています。

3.2 呼び出しとバイト数

直近 1 時間の割合(rate(grpc_client_handled_total{job="apiserver"}[1h])、3 台の合計)です。

メソッド 種類 秒あたり 説明(推測含む)
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 分に 1 回。デフォルトの圧縮間隔 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、コンテナリストが大きくなる可能性があるため)。どちらも Unix ドメインソケット /run/containerd/containerd.sock を経由します。

コントロールプレーンノード A で ss -Hx を使って確認すると、 containerd.sock 側の確立されたソケットは 4個でした。2 つは上記の kubelet のチャンネルであり、残りの 2 つの所有者は一般ユーザー権限ではプロセスを確認できないため不明です(未確認)。同じノード上で containerd.sock.ttrpc 側のソケットは 22個あり、 containerd-shim プロセスも 22 個ありました。shim は gRPC ではなく軽量な ttrpc で containerd に接続するという仕様が数値と一致しています (構造については containerd と CRI の読み取り).

を参照)。

4.2 呼び出しの分布sum by (operation_type) (rate(kubelet_runtime_operations_total[1h]))*36008 台の合計、直近 1 時間(

)です。 CRI 呼び出し 1 時間あたり ノードあたりの秒間
理由(根拠) 44,509 1.55 list_containerskubelet.go:206 genericPlegRelistPeriodPLEG が 1 秒ごとにリストを再取得(
)+その他 44,030 1.53 list_podsandbox
上記と同様 11,338 — exec_sync
exec プローブ(合計で秒あたり 3.1) 5,757 0.20 statuskubelet.go:1826)
5 秒ごとにランタイムの状態を確認( 2,823 0.10 version
約 10 秒ごと(ノード状態の更新間隔と同じと推測) 1,154 0.04 list_images
間隔未確認 464
container_status 285
podsandbox_status 143
image_status 102 stop_podsandbox
ポッドのクリーンアップ 70 · 70 create_container ・ start_container
ペアが一致 52 run_podsandbox
1 時間あたり約 52 個のポッドを作成 50 · 50
remove_container ・ remove_podsandbox 36 · 22 kubectl exec attach ・ 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 つにまとめます。ログと設定から読み取れる動作順序は次のとおりです。

  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 の形式)。 8 つの接続はすべて 0.3ms 以内に Connected となりました。
  4. ユーザーが hubble observe でリレーの GetFlows を呼び出すと、リレーはノードごとに GetFlows ストリームを開いて(retrieveFlowsFromPeer)、受信したフローを時間順に並べ替えて(sortFlows)1 つのストリームとして返します。

ロードバランスを使用せず、 ノードごとに直接接続するという点がパート 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 のサービスにはメソッドが 1 つだけ存在します。

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_EXهاUSTED はサーバーが RetryInfo を返す場合のみリトライとみなします(OTLP仕様)。 第3回の「アプリが選んだステータスコードがクライアントの行動を決める」の好例です。

6.2 実測

指標 値 クエリ
受信スパン(累積、17.4日) 93,892,856 otelcol_receiver_accepted_spans(transport=grpc)
受信スパン(直近1時間) 1時間あたり180,239(秒間50.1) rate(...[1h])*3600
送信スパン(直近1時間) 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. まとめ:4つの接続形状

パス 接続 呼び出しの形 注目すべき点
apiserver → etcd ノードあたり約247のTCP(リソースごと) 単項 秒間数十 + 長期間生存する双方向Watch CRD数に比例する接続
kubelet → containerd Unixソケット2つ(ランタイム・イメージ) 単項、秒間1.5回のリスト取得 ポーリングのコスト(PLEG)
relay → Hubble ノードごとにmTLS 1つ サーバー側ストリーミングのファンアウト + マージ サービスはリスト用、接続は直接
Collector → Tempo サービスアドレス1つ 単項Export、バッチ単位 アプリレベルのリトライとOTLPステータスコード表

8. 制限事項

9. 次回について

ここで登場したgrpc-goのチャネル・サブチャネル・トランスポートがソースコード上でどのように実装されているかは第7回で、これらの数値を見る際の注意すべき指標の罠については第8回で解説します。

ティティの一言: 管理室が金庫に電話線を247本も引いたんだってさ。部屋ごとに別々の電話機を置いたようなもんだね。通話はバッチリだけど、電話代(メモリ)は部屋の数だけかかるよ!

参考

すべての資料は2026-10-17時点のものです。

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます