Skip to content
Published on

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

シェア
Authors

はじめに — オペレーターを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.productgpu.countgpu.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サブリソースを書き込むとき、パッチ本文にapiVersionkindが必須です。私は{ "status": status }だけを送っており、サーバーは「これは何のタイプだ」(/, Kind=)と400を投げたのです。第2部のコードのその2行(apiVersionkind)を入れると、すぐに解決しました。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)を実際のクラスター状態で掘り下げます。

参考資料