Skip to content

필사 모드: 用 Rust 编写 Kubernetes GPU Operator — 用 kube-rs 诊断真实集群

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 用 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.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",   // ← 这两行就是那个陷阱 (第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 子资源时,补丁正文里必须带上 apiVersionkind。我只发了 { "status": status },于是服务器就以"这到底是什么类型"(/, Kind=)为由抛了个 400。把第 2 部分代码里的那两行(apiVersionkind)加上,立刻就解决了。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)。第三,只有 omen2container,其余都是 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)。

参考资料

현재 단락 (1/71)

Kubernetes Operator 通常用 Go(controller-runtime)来写。但随着 [kube-rs](https://kube.rs/) 的成熟,用 Rust 也能认真地写出来...

작성 글자: 0원문 글자: 4,901작성 단락: 0/71