ステータス: ドラフト(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 つの建物内の内線電話
- 2. 図
- 3. apiserver → etcd: リソースごとに 1 本
- 4. kubelet → containerd: 1 秒ごとに問い合わせるリスト
- 5. Hubble と hubble-relay: ストリームを集約して再送する
- 6. OTLP: Collector → Tempo
- 7. まとめ: 4 つの接続パターン
- 8. 制限事項
- 9. 次回予告
- 参考
1. 直感: 1 つの建物内の内線電話
クラスターは内線電話が多い建物に似ています。管理室(apiserver)は金庫(etcd)と一日中通話しており、フロアごとの管理人(kubelet)は厨房(containerd)に 1秒ごとに「今誰がいるか」を尋ね、警備室(hubble-relay)は各フロアの CCTV(Hubble)をまとめて映し出し、監査チーム(Collector)は収集した記録を保管庫(Tempo)に送信します。 回線はすべて gRPC ですが、 何本の回線が敷設されておりどれほどの頻度で呼び出されるかは、それぞれまったく異なります。
2. 図
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 | 大部分がキャッシュヒット |
- 1 時間あたりの約 11 万回のうち、 80% がリスト取得です。ポッドに変更がなくても PLEG は 1 秒ごとに問い合わせます。この無駄を削減するための仕組みが、ランタイムがイベントをプッシュする Evented PLEG(CRI の
GetContainerEventsサーバー側ストリーミング)ですが、v1.34.10 の機能ゲートのテーブルではEventedPLEGはまだアルファ段階でありデフォルトで無効です(kube_features.go:1277). - 実際の作業(ポッドやコンテナの作成)は 1 時間あたり数十回です。パート 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 を使用し、ローカルの Unix ドメインソケット /var/run/cilium/hubble.sock を使用します。ノード A の Hubble は、フローのリングバッファ 4,095 スロットが埋まった状態で、秒あたり 58.5 個のフローを記録していました。
hubble-relay はこれらのノードごとのサーバーを 1 つにまとめます。ログと設定から読み取れる動作順序は次のとおりです。
- 起動すると、ポート 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の形式)。 8 つの接続はすべて 0.3ms 以内にConnectedとなりました。 - ユーザーが
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のバケット内 | 同じヒストグラム |
- 秒間50.1スパン ÷ バッチ222.3 = 秒間0.225バッチであり、Tempoが受信した内部Pushは 秒間0.226 です。Collectorのバッチ1つがOTLPの
Export1回になり、Tempo内でそのまま1回のPushになるという一連の流れが数値として噛み合います。 - バッチの69%は512個に満たず、5秒のタイムアウトで送信されました。トラフィックが少ないクラスターであるため、遅延(最大5秒)を許容してバッチフリングしていると言えます。
- Tempoの内部にも
/grpc.health.v1.Health/Checkが秒間0.133回(7.5秒に1回)入ってきます。Tempo内のクライアントプールがリングのメンバーに対して定期的にヘルスチェックを行っているものとみられます(推測、Tempo・dskitのソースコードは未確認)。Tempoのtempo_grpc_concurrent_streams_limitは100であり、指標名からして1つの接続あたりの最大同時ストリーム数を表すgrpc_concurrent_streams_by_conn_maxは20でした(指標の定義は未確認)。 - 失敗の記録は第4部4.2節にあります(再接続はUNAVAILABLE、遅延はDEADLINE_EXCEEDED)。
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. 制限事項
- 1つのノード(コントロールプレーンノードA)でのみ
ss·psを確認しました。ワーカーノードのcontainerd 2.3側の接続数は計測していません(未計測)。 - Collector → TempoのTCP接続数はポッド内で
ssを実行する必要があるため計測しませんでした(未計測)。 - HubbleのgRPC指標(
hubble-metrics-server :9965)はPrometheusがスクレイピングしていないため、フロー数以外は確認できませんでした。 - etcdの指標はノードAの1つ、1回の読み取り分です。残りの2つのメンバーは読み取っていません。
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.