- Published on
RustでKubernetes GPUオペレーターを作る — kube-rsで実際のクラスターを診断する
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — オペレーターをGoではなくRustで
- 第1部 — CRDをRust構造体に
- 第2部 — リコンサイルループ: ノードをスキャンして状態を書く
- 第3部 — 実際に踏んだ2つの落とし穴
- 第4部 — オペレーターが下した診断: 私のGPU艦隊は死んでいた
- 第5部 — ビルドと実行環境
- おわりに
- 参考資料
はじめに — オペレーターをGoではなくRustで
Kubernetesオペレーターは通常、Go(controller-runtime)で作ります。しかしkube-rsが成熟したことで、Rustでも本格的に作れるようになりました — 型安全性、小さなバイナリ、そして何よりもコンパイラーが拾ってくれる安心感です。この記事は理論ではなく実測です。実際に動いている8ノードのホームラボクラスター(k8s v1.32.5)を相手に、GPU状態を調べるオペレーターをRustで書いて起動し、オペレーターが吐き出した本当の結果と、その過程で踏んだ本当の落とし穴を記録します。
重要な設計判断が一つあります。オペレーターをクラスターの外側(out-of-cluster)で実行します。kube::Client::try_default()が~/.kube/configを読むため、開発・デバッグ中はコンテナイメージを焼かずにローカルバイナリでAPIサーバーに接続します — これはkube-rs開発の標準ワークフローです。
第1部 — CRDをRust構造体に
オペレーターの心臓はカスタムリソースです。kube-rsは#[derive(CustomResource)]一つで構造体をCRDに変えてくれます。
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
group = "homelab.youngju.dev",
version = "v1",
kind = "GpuInventory",
plural = "gpuinventories",
shortname = "gpuinv",
status = "GpuInventoryStatus"
)]
pub struct GpuInventorySpec {
#[serde(default)]
pub note: String,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, Default)]
pub struct GpuInventoryStatus {
pub observed_at: String,
pub total_gpu_nodes: i32,
pub ready_gpu_nodes: i32,
pub total_gpus: i32,
pub nodes: Vec<GpuNodeEntry>,
}
このderiveがGpuInventory::crd()というメソッドを生成し、オペレーターは起動時にそれをAPIサーバーにapplyして自分自身のCRDを自らインストールします。別途YAMLは不要です。
第2部 — リコンサイルループ: ノードをスキャンして状態を書く
コントローラーの本体はリコンサイル関数です。ここではすべてのノードを走査し、NVIDIA NFDラベル(nvidia.com/gpu.product、gpu.count、gpu.memory)とReady状態を取り出してCRのstatusに記録します。
async fn reconcile_inventory(obj: Arc<GpuInventory>, ctx: Arc<Ctx>) -> Result<Action, Error> {
let (status, _cm) = scan_cluster(&ctx.client).await?; // すべてのノードを巡回
let api: Api<GpuInventory> = Api::all(ctx.client.clone());
let patch = serde_json::json!({
"apiVersion": "homelab.youngju.dev/v1", // ← この2行が落とし穴だった (第3部)
"kind": "GpuInventory",
"status": status
});
api.patch_status(&obj.name_any(), &PatchParams::apply(FIELD_MANAGER).force(),
&Patch::Apply(&patch)).await?;
Ok(Action::requeue(Duration::from_secs(30)))
}
オペレーターは2つのコントローラーを1つのバイナリで起動します — (1) GpuInventory CRをリコンサイルする上記のループ、(2) ノードイベントごとにgpu-node-statusというConfigMapを更新する2つ目のループです。「複数のコントローラーが1つのプロセスで共存する」オペレーターの典型です。
第3部 — 実際に踏んだ2つの落とし穴
ここがこの記事の本当の核心です。最初は動きませんでした。
落とし穴1 — server-side applyの「invalid object type」
最初の実行でリコンサイルはノードをきちんとスキャンしたのですが(ログにgpu_nodes=4)、状態を書き込む瞬間にこのエラーが発生しました。
WARN reconciled GpuInventory cr=cluster gpu_nodes=4 ready=0 gpus=1
WARN inventory reconcile failed: kube error: ApiError:
invalid object type: /, Kind=: BadRequest (code: 400)
原因: サーバーサイドapply(Patch::Apply)でstatusサブリソースを書き込むとき、パッチ本文にapiVersionとkindが必須です。私は{ "status": status }だけを送っており、サーバーは「これは何のタイプだ」(/, Kind=)と400を投げたのです。第2部のコードのその2行(apiVersion、kind)を入れると、すぐに解決しました。Goのcontroller-runtimeはこれを自動的に埋めてくれますが、kube-rsでraw JSONとしてapplyするときは自分で入れる必要があります。
落とし穴2 — 状態の記録が自分自身を再び呼ぶ
修正すると、今度はリコンサイルが毎秒十数回も暴走しました。
INFO reconciled GpuInventory cr=cluster ... (15:54:06.484)
INFO reconciled GpuInventory cr=cluster ... (15:54:06.525)
INFO reconciled GpuInventory cr=cluster ... (15:54:06.628)
INFO reconciled GpuInventory cr=cluster ... (15:54:06.728) ← 100ms間隔で暴走
原因: リコンサイルがstatusにobserved_at(現在時刻)を毎回新しく書き込むのですが、そのstatus書き込みがCRを変更してwatchイベントを生み、それが再びリコンサイルを呼ぶ自己参照ループです。タイムスタンプが毎回変わるので、永遠に止まりません。オペレーター初心者が最もよく踏む落とし穴です。教科書的な解法は「意味のあるデータが実際に変わったときだけstatusを書く」(現在のstatusと比較して同一ならskip)です。この感覚は、Kubernetesプレイグラウンドでコントローラーパターンを手で動かしてみると素早く身につきます。
第4部 — オペレーターが下した診断: 私のGPU艦隊は死んでいた
2つの落とし穴を修正すると、オペレーターはクラスターのGPU状態をCRにきれいに記録しました。ところが、その結果は予想外のインシデントレポートでした。
$ kubectl get gpuinventory cluster -o yaml (状態の抜粋)
observed_at : 2026-07-10T15:54:10Z
total_gpu_nodes : 4
ready_gpu_nodes : 0 ← GPUノード4個のうちReady 0個
total_gpus : 1
nuc1 ready=false NVIDIA-GeForce-RTX-4070-Laptop-GPU-SHARED x1 mem=8188 vm-passthrough
nuc2 ready=false <unlabeled> x0 vm-passthrough
omen ready=false <unlabeled> x0 vm-passthrough
omen2 ready=false <unlabeled> x0 container
読み取れる物語は三層です。第一に、GPUノード4個すべてがNotReady — クラスターのGPU能力が丸ごとofflineです。第二に、NotReadyのノードはNFDラベルが消えて<unlabeled>として表示されます(生きているnuc1だけがRTX 4070として認識されています)。第三に、omen2だけがcontainerで、残りはvm-passthroughでワークロードラベルが分かれます — これは私がGPU Operator × KubeVirt編で説明したあのラベルスイッチの実物です。
つまり、私が作ったオペレーターが自分のクラスターの障害を自ら発見したのです。これがオペレーターを自分で書いてみる本当の価値です — 他人が作ったダッシュボードが「GPUノード異常」と教えてくれるのと、自分が書いた20行のリコンサイルループがその事実をstatusに書き記していくのを見るのとでは、理解の深さが違います。(原因は別の記事で扱うkubeletのswap問題でした。)
第5部 — ビルドと実行環境
正直に残す実測環境です。
クレート kube 0.96 (client+runtime+derive+rustls-tls), k8s-openapi 0.23 (v1_31)
ビルド cargo 1.97、リリースビルド初回16.7秒(依存関係含む)、増分2.6秒
実行 out-of-cluster、~/.kube/configでAPI(192.168.219.111:6443)に接続
対象 k8s v1.32.5、8ノード(cri-dockerd)、GPU Operator v25.3.0
TLSはopensslの代わりにrustls-tlsを選びました — システムのopenssl依存なしにどこでもビルドできるからです(オペレーターを複数のアーキテクチャにデプロイするとき特に便利です)。
おわりに
Rustでオペレーターを書いてみると、kube-rsは驚くほどcontroller-runtimeに似たメンタルモデルを与えてくれます — CRDの派生、リコンサイル関数、requeue。違うのはコンパイラーが型を強制するという点、そしてサーバーサイドapplyのような低レベルを自分で触らなければならないという点です(落とし穴1)。その代償として得られるのは「動けばだいたい正しい」という安心感であり、失うのはいくつかの利便性です。何よりも — オペレーターを自分で書くと、それが自分のインフラの真実を自分に返してくれます。自分のGPU艦隊が死んでいることも、自分が書いたリコンサイルループが最初に教えてくれたのですから。次回は、その死の原因(KubeVirt・GPU passthrough・kubelet)を実際のクラスター状態で掘り下げます。