Kubernetes 解体新書Part 4 インターフェース

CPI と cloud-controller-manager

ノードの情報を調べ、ロードバランサーを作る。Kubernetes の依頼をクラウドの操作につなぐ仕組みを見ます。

  • L1 Web ターミナル
  • 更新 2026年10月6日

クラウド上のノードには、どのリージョンやゾーンにあるかを示すラベルが付きます。 Kubernetes 本体だけでは分からない情報を、クラウドに問い合わせる担当がいるためです。

この章では、その連携を担う cloud-controller-manager (CCM) を見ます。 ノードの準備やロードバランサーの作成を例に、Kubernetes の依頼がクラウドの操作につながる流れを追います。

GOAL この章のゴール

  • クラウド連携が担当する仕事を説明できる
  • ノードの初期化時に、情報が設定される流れを追える
  • ロードバランサーを作る担当がいない場合の表示を確認できる

CPI と CCM

ノードがどのゾーンにあるかを調べるには、そのノードを動かしているクラウドに問い合わせます。 ロードバランサーを作るときも、クラウドごとの API を呼ぶ必要があります。 こうしたクラウド固有の仕事をまとめて動かすプログラムが CCM です。

以前は Kubernetes 本体に含まれていた処理を分けたことで、クラウド側の対応を Kubernetes 本体とは別に更新しやすくなりました。 CCM の中からクラウドごとの処理を呼ぶための取り決めを、CPI と呼びます。

CPI は Go の interface (k8s.io/cloud-provider の cloudprovider.Interface) で、LoadBalancer / InstancesV2 / Routes などのメソッドを持ちます。 CRI や CSI が socket 越しの gRPC で別プロセスと話すのに対し、CPI は同じプロセスの中で呼ばれる Go の関数です。 kubernetes.io の「Developing Cloud Controller Manager」によると、各クラウドはこの interface を満たす Go のパッケージを書き、Kubernetes 本体の cloud-controller-manager の main.go を雛形にして自分のパッケージを import したバイナリをビルドします。 Node / Route / Service の controller の実装は本体側にあり、クラウドごとに違うのはそこから呼ばれるパッケージだけです。

Go の interface で済む理由は、呼ぶ側の位置にあります。 CRI や CSI は各ノードの kubelet が呼ぶので、kubelet を変えずにランタイムや driver を差し替えるには別プロセスと socket で話す必要がありました。 CPI はクラスタ側の controller が呼ぶもので、controller 自体をクラウドごとに別のバイナリとしてビルドして配ればよいのです。

3 つの controller

cloud-controller-manager の 3 つの controllerNode controller は Node を watch してクラウドのインスタンス情報を書き込む。Route controller は Node の podCIDR を watch してクラウドの経路表を書く。Service controller は type: LoadBalancer の Service を watch してクラウドの LB を作る。全て apiserver を watch し、クラウドの API を呼ぶ。kube-apiservercloud-controller-manager (--cloud-provider=external)Node controllerproviderID / addresses / zoneRoute controllerpodCIDR → 経路表Service controllertype: LoadBalancer → LBwatch Nodewatch Nodewatch ServiceEC2 DescribeInstancesメタデータ取得VPC CreateRouteルートテーブルELB CreateLoadBalancerターゲット登録破線: watch / 実線: クラウドの API 呼び出し (AWS の例)
図 13 つの controller は全て apiserver を watch し、変化があるとクラウドの API を呼ぶ。kubelet は関与しない。
  • Node controller:Node を watch し、クラウドの API から取った情報を Node に書き込みます。kubernetes.io が挙げる仕事は、インスタンスの一意な ID (providerID)、リージョンや zone のラベル、ホスト名とネットワークアドレス、そしてインスタンスがクラウド側で削除されていたら Node オブジェクトを消すこと、の 4 つです。
  • Route controller:ノード間で Pod が通信できるよう、クラウドの経路表に「この Node の podCIDR はこのインスタンスへ」という経路を書きます。クラウドによっては Pod ネットワークのアドレスブロックの割り当ても担当します。Cilium のようにオーバーレイを使う構成では不要です。
  • Service controller:type: LoadBalancer の Service を watch し、クラウドのロードバランサーを作って設定します。kubernetes.io は、対象をロードバランサーのほか IP アドレス、パケットフィルタ、ヘルスチェックのようなクラウドの部品だとしています。

AWS で言えば、Node controller は EC2 の DescribeInstances、Route controller は VPC の CreateRoute、Service controller は ELB の CreateLoadBalancer にあたる API を呼ぶプロセスです。

uninitialized taint

Node controller の仕事は、ノードが参加した直後から始まります。

Node の初期化: uninitialized taint が外れるまでkubelet が --cloud-provider=external で起動すると、Node を登録するときに node.cloudprovider.kubernetes.io/uninitialized の taint を付ける。CCM の Node controller がクラウドから providerID と addresses を取得して書き込み、taint を外す。それまで通常の Pod はスケジュールされない。kubelet--cloud-provider=externalNode を登録Nodetaints:- …/uninitializedproviderID: (空)CCM Node controllerwatchインスタンス情報を取得クラウド APIEC2 / KubeVirt VMI書き込みNode (初期化済み)taints: (外れた)providerID: kubevirt://…addresses: InternalIP …labels: topology.kubernetes.io/zonetaint がある間は通常の Pod は乗らない
図 2kubelet が付けた taint を、CCM の Node controller がクラウドの情報を書き込んでから外す。

kubernetes.io の「Cloud Controller Manager Administration」によると、--cloud-provider=external を指定したコンポーネントは、初期化のときに node.cloudprovider.kubernetes.io/uninitialized という taint を NoSchedule の効果で Node に付けます。 これは「外部の controller による 2 回目の初期化が済むまで、Pod を乗せるな」という印です。 CCM の Node controller は taint 付きの Node を見つけると、クラウドの API からインスタンス情報を取得して providerID と addresses とラベルを書き込み、最後に taint を外します。 この動きを使うには、kubelet だけでなく kube-controller-manager にも --cloud-provider=external を指定します。 CCM 自身の Pod はこの taint が付いたノードにも乗れるよう、toleration (taint を許容する指定) を持ちます。

同じドキュメントは、この順序の理由も書いています。 scheduler は Node のリージョンやインスタンスの種類 (GPU や spot など) を見て Pod の置き場所を決めることがあるので、その情報が無いうちに Pod を乗せるわけにはいかない、という理由です。 裏返すと、CCM が動いていないクラスタでは新しいノードがいつまでも schedulable にならない、と同じドキュメントは注意しています。

in-tree の削除

クラウドプロバイダーの in-tree から out-of-tree への移行年表1.26 (2022 年 12 月) で OpenStack、1.27 (2023 年 4 月) で AWS の in-tree 削除、1.29 (2023 年 12 月) で in-tree プロバイダーを既定で無効化、1.30 (2024 年 4 月) で Azure と vSphere 削除、1.31 (2024 年 8 月) で GCE を削除して全 in-tree 連携の削除完了 (KEP-2395)。2022.121.26OpenStack 削除2023.41.27AWS 削除2023.121.29in-tree を既定で無効2024.41.30Azure / vSphere 削除2024.81.31GCE 削除で完了
図 32022 年の OpenStack から 2024 年の GCE まで、クラウドごとに順に in-tree コードが消えた。

このクラスタの CCM

あなたの tenant クラスタにも CCM が居ます。 ただし少し変わった置き方です。

このクラスタの kubevirt-cloud-controller-manager: Node controller は動き、Service controller は無効管理クラスタ側で動く kubevirt CCM が tenant クラスタの Node を watch し、VirtualMachineInstance の情報から providerID と addresses を埋めて taint を外す。LoadBalancer 機能は無効なので、type: LoadBalancer の Service は EXTERNAL-IP が pending のまま。あなたの tenant クラスタNodeproviderID: kubevirt://<vmi 名>addresses: InternalIP (VM の IP)taint: 無しService type: LoadBalancerEXTERNAL-IP: <pending>(誰も watch していない)Node controller の仕事だけが見える管理クラスタkubevirt-cloud-controller-managertenant と管理クラスタ両方の kubeconfig を持つVirtualMachineInstance名前、IP、zone / region ラベル読むService controller (無効)有効なら管理クラスタ側に Service を作るproviderID / addresses を書く何もしない
図 4kubevirt CCM は管理クラスタ側で動き、tenant の kubeconfig で Node を watch する。Service controller は無効化されている。

kubevirt-cloud-controller-manager の README によると、この CCM は tenant クラスタの kubeconfig を --kubeconfig で、管理クラスタ (README では infrastructure cluster) の kubeconfig を cloud-config で受け取り、2 つのクラスタをまたいで動きます。 この環境では管理クラスタ側に置かれています。 「クラウドの API」にあたるのは KubeVirt の VirtualMachineInstance で、そこから VM の名前と IP を読んで Node に書き込み、VM の zone と region を Node のラベルに写します。 Node controller としては完全に動いています。

一方で、この環境では LoadBalancer 機能を無効にしてあります。 README によると、有効なら tenant の type: LoadBalancer に対して管理クラスタ側に Service を作り、tenant の VM をその宛先にする動きをしますが、ここではその controller が何もしません。 「Service controller が居ない世界」を、安全に観察できる環境です。

実機: CCM が書いたものを読む

Node の providerID と addresses を読む
kubectl get node -o json | jq '.items[0] | {providerID: .spec.providerID, taints: .spec.taints, addresses: .status.addresses, zone: .metadata.labels["topology.kubernetes.io/zone"]}'

出力例

{
  "providerID": "kubevirt://<vm 名>",
  "taints": null,
  "addresses": [
    { "type": "InternalIP", "address": "10.0.x.x" },
    { "type": "Hostname", "address": "<node>" }
  ],
  "zone": "..."
}

providerID の kubevirt:// という接頭辞は、CPI の実装 (プロバイダー) の名前です。 AWS なら aws:///ap-northeast-1a/i-0abc... の形になります。 taints が無いのは、Node controller が初期化を終えて uninitialized を外した後だからです。

kubelet 側の指定は、ノードの中で確かめられます。 kubeadm はこのフラグを /var/lib/kubelet/kubeadm-flags.env に書くので、config.yaml の方には出てきません。

kubelet が external を指定していることを確かめるノードの中で実行
cat /var/lib/kubelet/kubeadm-flags.env

出力例

KUBELET_KUBEADM_ARGS="--cloud-provider=external --container-runtime-endpoint=unix:///run/containerd/containerd.sock ..."

次に、Service controller が居ないことを確かめます。

type: LoadBalancer を作って pending を観察する
kubectl create deployment hello --image=nginx:1.29 --port=80
kubectl expose deployment hello --type=LoadBalancer --port=80
kubectl get svc hello -w

出力例

NAME    TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
hello   LoadBalancer   10.96.45.12    <pending>     80:31234/TCP   5s
hello   LoadBalancer   10.96.45.12    <pending>     80:31234/TCP   60s

いつまで待っても <pending> のままです。 Ctrl-C で止めてください。 kubernetes.io によると、ロードバランサーの作成は非同期に行われ、できあがった情報は Service の .status.loadBalancer に書かれます。 それを書くのは Service controller で、居ないので誰も書かない。 ClusterIP と NodePort (各ノードの固定ポートで受ける入口。出力の 31234) は kube-apiserver が割り当てるので、その 2 つは埋まっています。 type: LoadBalancer は ClusterIP、NodePort、クラウドのロードバランサーの 3 つでできていて、最後の 1 つだけが欠けている状態です。

イベントにも何も出ないことを見る
kubectl describe svc hello | sed -n '/Events/,$p'
kubectl delete svc,deployment hello

出力例

Events:  <none>

Service controller が居て失敗したなら、ロードバランサーの同期に失敗したというイベントが出ます。 何も出ないのは、watch している者が居ないからです。 EKS で同じ <pending> を見たら、まず CCM (cloud-provider-aws、または AWS Load Balancer Controller) のログを見る、という判断がここから導けます。

ふりかえり

Qnode.cloudprovider.kubernetes.io/uninitialized の taint を外すのは誰?

kubelet が –cloud-provider=external で起動時に付け、CCM の Node controller がクラウドの情報を書き込んだ後に外します。CCM が居ないと永遠に残ります。

QCPI が Go の interface として定義されていて、gRPC を使わないのはなぜ?

CRI / CSI は kubelet が別プロセスを呼ぶので socket が要ります。CPI は kube-controller-manager から抜き出した controller 群 (CCM) が直接使う interface で、クラウドごとに別のバイナリをビルドします。

Qこのクラスタで type: LoadBalancer の EXTERNAL-IP が pending のままになる理由は?

ClusterIP と NodePort は apiserver が割り当てるので埋まります。クラウドのロードバランサーを作って status に書くのは CCM の Service controller で、この環境では無効化されています。

参考