- Published on
用 Rust 编写 Kubernetes GPU Operator — 用 kube-rs 诊断真实集群
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 用 Rust 而非 Go 写 Operator
- 第 1 部分 — 把 CRD 变成 Rust 结构体
- 第 2 部分 — reconcile 循环:扫描节点并写入状态
- 第 3 部分 — 实际踩到的两个陷阱
- 第 4 部分 — Operator 给出的诊断:我的 GPU 舰队已经死了
- 第 5 部分 — 构建与运行环境
- 结语
- 参考资料
引言 — 用 Rust 而非 Go 写 Operator
Kubernetes Operator 通常用 Go(controller-runtime)来写。但随着 kube-rs 的成熟,用 Rust 也能认真地写出来了 — 类型安全、小巧的二进制,以及最重要的编译器替你兜底的那份安心感。这篇文章不是理论而是实测:面对一个真正在运行的 8 节点家庭实验室集群(k8s v1.32.5),我用 Rust 写了一个调查 GPU 状态的 Operator 并把它跑起来,记录下 Operator 吐出的真实结果,以及这个过程中踩到的真实陷阱。
一个关键设计:让 Operator 在集群**外部(out-of-cluster)**运行。由于 kube::Client::try_default() 会读取 ~/.kube/config,所以在开发和调试期间无需构建容器镜像,直接用本地二进制连上 API 服务器 — 这是 kube-rs 开发的标准工作流。
第 1 部分 — 把 CRD 变成 Rust 结构体
Operator 的心脏是自定义资源。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() 的方法,Operator 在启动时把它 apply 到 API 服务器,从而自己安装自己的 CRD。不需要额外的 YAML。
第 2 部分 — reconcile 循环:扫描节点并写入状态
控制器的主体是 reconcile 函数。这里会遍历所有节点,取出 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", // ← 这两行就是那个陷阱 (第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)))
}
Operator 用一个二进制启动两个控制器 —(1)reconcile GpuInventory CR 的上面那个循环,(2)在每次节点事件时更新一个叫 gpu-node-status 的 ConfigMap 的第二个循环。这是"多个控制器在一个进程中共存"的 Operator 的典型形态。
第 3 部分 — 实际踩到的两个陷阱
这里才是这篇文章真正的精华。一开始它根本跑不起来。
陷阱 1 — server-side apply 的 "invalid object type"
第一次运行时 reconcile 把节点扫描得好好的(日志里是 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 部分代码里的那两行(apiVersion、kind)加上,立刻就解决了。Go 的 controller-runtime 会自动帮你填这两个字段,但在 kube-rs 里以 raw JSON 形式 apply 时,就得自己填。
陷阱 2 — 状态的写入反复呼叫它自己
修好之后,这回 reconcile 又每秒十几次地狂奔了起来。
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 间隔狂奔
原因:reconcile 每次都往 status 里重新写入 observed_at(当前时刻),而这次 status 写入又改变了 CR、产生 watch 事件,事件再次触发 reconcile,形成一个自我引用的循环。时间戳每次都在变,所以它永远停不下来。这是 Operator 新手最常踩的陷阱。教科书式的解法是"只在有意义的数据真正发生变化时才写 status"(先和当前 status 比较,若相同则 skip)。这种感觉,在 Kubernetes 游乐场里亲手把控制器模式跑一遍,就能很快掌握。
第 4 部分 — Operator 给出的诊断:我的 GPU 舰队已经死了
修好这两个陷阱后,Operator 就把集群的 GPU 状态干净利落地记录进了 CR。可这份结果却是一份出乎意料的事故报告。
$ kubectl get gpuinventory cluster -o yaml (状态摘录)
observed_at : 2026-07-10T15:54:10Z
total_gpu_nodes : 4
ready_gpu_nodes : 0 ← 4 个 GPU 节点中 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
能读出来的故事有三层。第一,4 个 GPU 节点全部 NotReady — 集群的 GPU 能力整个都 offline 了。第二,NotReady 的节点因为 NFD 标签被清掉,显示为 <unlabeled>(只有活着的 nuc1 被识别为 RTX 4070)。第三,只有 omen2 是 container,其余都是 vm-passthrough,工作负载标签就此分道 — 这正是我在 GPU Operator × KubeVirt 篇里讲过的那个标签开关的实物。
也就是说,我亲手写的 Operator 自己发现了我集群的故障。这正是亲手写一个 Operator 的真正价值 — 别人做的仪表盘告诉你"GPU 节点异常",和看着自己写的 20 行 reconcile 循环把这个事实一行行写进 status,二者对理解的深度完全不同。(原因是 kubelet 的 swap 问题,留待另一篇再讲。)
第 5 部分 — 构建与运行环境
如实记录的实测环境。
crate kube 0.96 (client+runtime+derive+rustls-tls), k8s-openapi 0.23 (v1_31)
构建 cargo 1.97, 首次 release 构建 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 我选了 rustls-tls 而不是 openssl — 因为它不依赖系统 openssl,在哪儿都能构建(把 Operator 部署到多种架构时尤其方便)。
结语
用 Rust 写了一遍 Operator 之后发现,kube-rs 给的心智模型和 controller-runtime 惊人地相似 — CRD 派生、reconcile 函数、requeue。不同之处在于编译器会强制类型,以及像服务器端 apply 这样的底层细节得自己动手(陷阱 1)。作为代价换来的是"只要能跑起来,大体上就是对的"这份安心感,失去的则是若干便利。而最重要的是 — 亲手写一个 Operator,它会把我基础设施的真相回馈给我。我的 GPU 舰队已经死了这件事,也是我自己写的 reconcile 循环第一个告诉我的。下一篇,我会用真实的集群状态深挖这场死亡的原因(KubeVirt、GPU passthrough、kubelet)。