MoreRSS

site iconLixueduan | 李学端修改

博客名:指月小筑。专注云原生,Go,坚持分享最佳实践、经验干货。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Lixueduan | 李学端的 RSS 预览

Kubernetes 官方出品:一个 Controller 搞定 Job 排队和资源配额

2026-06-25 06:00:00

kueue-p1-intro.jpg

多个团队共用一个 Kubernetes 集群,A 团队提交了一批训练任务,几十张 GPU 很快就被占满;B 团队新提交的 Job 只能一直 Pending。 因为,而是 Kubernetes 原生采用"先到先得"的调度方式,没有 Job 队列,也没有多租户配额管理。

Kueue 正是 Kubernetes 官方为此提供的解决方案。它不替换 kube-scheduler,只负责 Job 的排队和准入,在此基础上实现资源配额管理和公平调度。

1. Kueue 简介

Kueue 是什么?

Kueue 是 Kubernetes SIGs 维护的官方 Job 级队列管理系统,负责决定 Job 何时准入(Admit)、何时被驱逐(Preempt),核心目标是管理资源配额和多租户公平调度。

和 Volcano 最大的区别:Kueue 不替换 kube-scheduler,它只管"排队和准入",调度还是交给原生调度器。

1
2
Volcano = 自定义调度器 + 队列管理 + 作业管理
Kueue = 队列管理 + 配额管理(调度交给 kube-scheduler)

Kueue 能做什么?

  • 多租户配额管理:通过 ClusterQueue 为不同团队划分资源配额

  • 公平调度:基于 Dominant Resource Shares(DRS)的公平共享算法,防止资源被单一团队长期独占

  • 队列排队:Job 提交后按优先级排队,配额不足时挂起等待

  • Cohort 借调:同一 Cohort 内的 ClusterQueue 可以借用彼此空闲配额,用完归还

  • 弹性配额:支持 nominalQuota(保底)+ borrowingLimit(借用上限)+ lendingLimit(出借上限)三层额度控制

  • 标准兼容:原生支持 K8s Job / JobSet / PyTorchJob / TFJob / RayJob 等,无需改 Job YAML

为什么需要 Kueue?

或者说 Kueue 解决了什么原生 K8s 解决不了的问题?

原生 Kubernetes 的资源管理是"先到先得",没有队列的概念:

场景 原生 Kubernetes Kueue 优势
配额管理 ResourceQuota 粗粒度控制 ClusterQueue 细粒度配额(按 GPU 型号/节点池划分) 精确到资源类型和 Flavor
多租户公平 先到先得,无法保障公平 Fair Sharing 基于 DRS 的公平分配 防止资源独占
Job 排队 Pending 后只能等 支持优先级队列、FIFO 策略 按优先级有序调度
跨团队借调 不支持 Cohort 机制,空闲配额自动借出、按需归还 提高集群利用率

举一个典型场景:公司有两个 AI 团队共享一个 GPU 集群,各有 50% 的 A100 配额。团队 A 没任务时,团队 B 可以借用全部 GPU;团队 A 提交任务后,Kueue 会通过 preemption 让团队 B 释放借用的资源,回到公平状态。

原生 K8s 做不到这一点。

Kueue vs Volcano 快速对比

维度 Kueue Volcano
定位 队列 + 配额管理(轻量) 批处理调度平台(重量)
架构 不替换调度器,旁路管理 自定义调度器,替换 kube-scheduler
归属 Kubernetes SIGs 官方 CNCF(华为发起)
部署复杂度 一个 Controller Scheduler + Controller + Admission 三个组件
Gang Scheduling 通过 All-or-Nothing with Ready Pods(超时机制) 原生 Gang Scheduling
Job API 标准 K8s Job(自动创建 Workload) 自定义 VolcanoJob(vcjob)

更多参考:Kueue 官方文档Volcano 系列文章

2. 核心概念

在部署之前,先理解 Kueue 的 5 个核心对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
┌─────────────────────────────────────────────────────┐
│ Cohort │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ClusterQueue A │ │ ClusterQueue B │ │
│ │ (nominal: 5GPU) │ │ (nominal: 5GPU) │ │
│ │ borrowLimit: 5 │ │ borrowLimit: 5 │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ ResourceFlavor: A100 / T4 / CPU │
└─────────────────────────────────────────────────────┘
 │ │
 LocalQueue A LocalQueue B ← Namespaced
 (team-a) (team-b)
 │ │
 Workload 1 Workload 2 ← 用户的 Job
  • ResourceFlavor:Flavor,代表不同类型的资源(如 A100 vs T4、Spot vs On-Demand),可以绑定 nodeLabels / taints

  • ClusterQueue:集群级队列,定义资源配额(nominalQuota / borrowingLimit / lendingLimit),是配额管理的核心

  • LocalQueue:命名空间级队列,用户直接和它打交道,指向一个 ClusterQueue

  • Cohort:ClusterQueue 的组,同组内可以互相借调空闲配额

  • Workload:Kueue 的调度单元,Kueue 会为每个 Job 自动创建对应的 Workload,用户一般不需要手动创建

调度流程

一个 Job 从提交到运行,经过 Kueue 的完整流程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
用户提交 Job (带 kueue.x-k8s.io/queue-name 标签)
Kueue 自动创建 Workload 对象
Workload 进入 LocalQueue → 找到对应的 ClusterQueue
检查 ClusterQueue 是否有足够配额?(允许从 Cohort 借调)
 ├── 没有 → 询问 Cluster Autoscaler 能否扩容? → 等待扩容
 ↓ (有配额 或 扩容完成)
检查 ClusterQueue 是否配置了 AdmissionCheck?
 ├── 有 → 等待所有 Check 通过 → 最终准入
 └── 没有 → 直接最终准入
Kueue 注入 nodeAffinity/tolerations (锁定算力)
Job Controller 创建 Pod
Kube Scheduler 分配 Pod 到具体节点
节点资源不足? → 触发 Cluster Autoscaler 真正扩容节点 (Provision)

kueue-workflow.svg

关键点:Kueue 只负责"准入"决策,Pod 真正调度到哪个节点还是 kube-scheduler 决定。

3. Kueue 部署

Kueue 部署非常轻量,只需要一个 Controller。

版本兼容性

Kueue 要求 Kubernetes 1.29+,本文使用 Kubernetes v1.36.1 部署最新的 Kueue v0.18.1

安装

官方提供了两种安装方式,推荐 Helm:

方式一:kubectl 直接安装

1
kubectl apply --server-side -f https://github.com/kubernetes-sigs/kueue/releases/download/v0.18.1/manifests.yaml

方式二:Helm 安装(推荐)

1
2
3
4
5
helm install kueue oci://registry.k8s.io/kueue/charts/kueue \
 --version=0.18.1 \
 --namespace kueue-system \
 --create-namespace \
 --wait --timeout 300s

无法访问 registry.k8s.io 可以从 GitHub 下载 chart 包安装:

1
2
3
4
helm install kueue https://github.com/kubernetes-sigs/kueue/releases/download/v0.18.1/kueue-0.18.1.tgz \
 --namespace kueue-system \
 --create-namespace \
 --wait --timeout 300s

卸载

1
helm uninstall kueue --namespace kueue-system

验证

1
kubectl -n kueue-system get po
1
2
NAME READY STATUS RESTARTS AGE
kueue-controller-manager-665fc6d58-whxcz 1/1 Running 0 60m

一个 Pod 就搞定了,这就是 Kueue 轻量的地方。

4. 快速上手:一个完整的 Demo

接下来我们跑一个最小化 Demo:创建 ResourceFlavor → ClusterQueue → LocalQueue → 提交 Job,完整走一遍 Kueue 的工作流程。

4.1 创建基础资源

Kueue 使用三种资源来管理作业排队:

  • ResourceFlavor:描述作业可用的硬件配置
  • ClusterQueue:定义资源池的配额(CPU、内存等)
  • LocalQueue:用户提交作业的命名空间级队列,映射到 ClusterQueue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
 name: "default-flavor"
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
 name: "cluster-queue"
spec:
 namespaceSelector: {} # 允许所有命名空间
 resourceGroups:
 - coveredResources: ["cpu", "memory"]
 flavors:
 - name: "default-flavor"
 resources:
 - name: "cpu"
 nominalQuota: 9
 - name: "memory"
 nominalQuota: 9Gi
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: LocalQueue
metadata:
 namespace: "default"
 name: "user-queue"
spec:
 clusterQueue: "cluster-queue"
1
kubectl apply -f queue.yaml

4.2 验证队列状态

1
2
3
4
5
# ClusterQueue 是否激活
kubectl get clusterqueue cluster-queue -o wide

# LocalQueue 是否就绪
kubectl get localqueue user-queue -n default
1
2
3
4
5
6
7
root@lixd-dev-4:~# kubectl get clusterqueue cluster-queue -o wide
NAME COHORT STRATEGY PENDING WORKLOADS ADMITTED WORKLOADS
cluster-queue BestEffortFIFO 0 0

root@lixd-dev-4:~# kubectl get localqueue user-queue -n default
NAME CLUSTERQUEUE PENDING WORKLOADS ADMITTED WORKLOADS
user-queue cluster-queue 0 0

队列创建成功,当前没有 Workload。

4.3 提交 Job

注意看,这里用的就是标准的 K8s Job,只需要加一个 label 就能接入 Kueue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: batch/v1
kind: Job
metadata:
 name: sample-job
 namespace: default
 labels:
 kueue.x-k8s.io/queue-name: user-queue  # 接入 Kueue 的关键
spec:
 parallelism: 3
 completions: 3
 template:
 spec:
 containers:
 - name: dummy-job
 image: registry.k8s.io/e2e-test-images/agnhost:2.53
 command: ["/bin/sh"]
 args: ["-c", "sleep 30"]
 resources:
 requests:
 cpu: "1"
 memory: "200Mi"
 restartPolicy: Never
1
kubectl apply -f job.yaml

4.4 验证

1
2
3
4
5
# 查看 Kueue 自动创建的 Workload
kubectl get workloads -n default

# 查看 Pod
kubectl get pods -n default
1
2
3
4
5
6
7
8
9
root@lixd-dev-4:~# kubectl get workloads -n default
NAME QUEUE RESERVED IN ADMITTED FINISHED AGE
job-sample-job-555d8 user-queue cluster-queue True 9s

root@lixd-dev-4:~# kubectl get pods -n default
NAME READY STATUS RESTARTS AGE
sample-job-j6xdp 1/1 Running 0 14s
sample-job-k4mtm 1/1 Running 0 14s
sample-job-nvjkz 1/1 Running 0 14s

可以看到 Kueue 自动为 Job 创建了对应的 Workload,Workload 状态为 Admitted(已准入),3 个 Pod 正常运行。

4.5 等待任务完成

1
2
# 等待任务完成
kubectl get jobs -n default -w
1
2
3
root@lixd-dev-4:~# kubectl get jobs -n default -w
NAME STATUS COMPLETIONS DURATION AGE
sample-job Complete 3/3 34s 44s

Job 完成后,Workload 也会被标记为 Finished:

1
kubectl get workloads -n default
1
2
3
root@lixd-dev-4:~# kubectl get workloads -n default
NAME QUEUE RESERVED IN ADMITTED FINISHED AGE
job-sample-job-555d8 user-queue cluster-queue True True 49s

到这里,我们完整走了一遍 Kueue 的工作流程:创建队列 → 提交 Job → Kueue 自动准入 → Pod 运行 → 任务完成

整个过程中,我们用的是标准的 K8s Job,唯一的变化就是加了一个 kueue.x-k8s.io/queue-name 标签。这就是 Kueue “不替换调度器、旁路管理"设计哲学的体现。

5. 小结

Kueue 是 Kubernetes SIGs 官方出品的 Job 队列与配额管理系统,它的核心设计哲学是”只管排队和准入,不动调度器",这让它非常轻量且对现有集群侵入性极小。

本文介绍了 Kueue 的背景、核心概念和部署方式,并通过一个最小化 Demo 走完了"创建队列 → 提交 Job → 自动准入 → 任务完成"的完整流程。

下一篇我们将深入解析 Kueue 的五大核心对象(ResourceFlavor / ClusterQueue / LocalQueue / Cohort / Workload),搞清楚它们各自的职责和配置细节。

K8s 1.36 ImageVolume GA:OCI 镜像不再只能跑容器

2026-06-17 06:00:00

k8s-1.36-image-volume-ga.jpg

OCI 规范建立的目的就是将容器镜像格式标准化,正如其名,OCI 镜像在之前一直用来跑容器,但现在它还能干更多事。

Kubernetes v1.36 里 ImageVolume 特性正式 GA 了,该特性允许我们把 OCI 镜像直接作为 Volume 挂载到 Pod 里。现在 OCI 镜像在 K8s 里不再只能跑容器了,模型权重、配置文件、安全签名、CI/CD 工件,只要是只读数据,都可以打包成 OCI 镜像供 Pod 挂载使用。

这个特性从 v1.31 以 Alpha 引入,经过 v1.33、v1.35 的 Beta 阶段,终于在 v1.36 正式 GA。GA 之后不用再手动开 Feature Gate,containerd 也原生支持了,开箱即用。

1. 背景

OCI(Open Container Initiative)是 2015 年在 Linux 基金会支持下成立的开放项目,Docker、CoreOS(后来被 Red Hat 收购)和容器行业的主要厂商一起,围绕容器格式和运行时制定了开放标准。Docker 捐出了自己的镜像格式作为基础,社区在此基础上逐步形成了 Runtime、Image 和 Distribution 三大规范。

2017 年 image-spec v1.0 发布,容器镜像的格式算是定下来了。在 OCI 标准下,运行一个容器的过程就是:从 Registry 下载 OCI 镜像 → 解压到 OCI Bundle → OCI Runtime 运行这个 Bundle。整个流程标准化之后,不同 Runtime、不同 Registry 之间可以互操作,不用强行绑定 Docker。

Docker 也把 libcontainer 的实现移动到 runC 捐给了 OCI,社区有了第一个 OCI Runtime 的参考实现。

OCI Artifacts

虽然 OCI 标准最初完全是围绕容器设计的,镜像格式里的 mediaType、config 结构都是为「跑容器」量身定做的。

但是如果我不想跑容器,只想把一堆文件打包分发呢?人们很快发现,OCI Registry 的分层存储和分发机制天然适合分发更多东西。

OCI 镜像的本质是什么?就是一堆只读的层(layer),加上一个 manifest 描述这些层的组织方式,再通过 Registry 的 API 完成分发。同时 OCI Registry 提供了一整套“可寻址、可校验、可去重、可控访问”的分发原语,然后这个模型并不绑定「容器」。

所以社区很自然地想到:能不能把 OCI Registry 当成一个通用的内容分发平台来用?

实际上社区很早就开始在 OCI Registry 里存非镜像内容了,但早期的做法都是 hack——把非镜像内容伪装成容器镜像塞进去,Registry 其实并不知道这些东西不是用来跑的:

  • Helm 从 3.0 开始支持把 Chart 推到 OCI Registry,算是最早的「Registry 当通用存储」的生产实践。
  • Cosign 直接把容器签名、SBOM 也存进 OCI Registry,用镜像层来承载签名数据。
  • ORAS(OCI Registry As Storage)更猛,WASM 模块、OPA 策略、Falco 规则都能往里塞,相当于把 OCI Registry 当成一个通用的对象存储来用。

OCI Artifacts 就是这么来的,把各种产物存进 OCI Registry、当成通用内容仓库来分发。

这些用法推动了 OCI 规范本身的演进。2024 年,image-spec v1.1.0 正式加入了 artifactType 字段,允许 Manifest 声明「我不是容器镜像,我是一个签名 / 一个 Helm Chart / 一个模型权重」。OCI 对非镜像内容的支持从社区 hack 变成了规范的一部分,OCI Registry 正式成为了一个通用的内容仓库。

OCI 格式演变时间线:从容器镜像到通用内容分发

K8s ImageVolume

现在 OCI Registry 已经变成了一个通用的内容仓库,但问题来了:Helm、Cosign、ORAS 这些工具都在往里塞东西,但到了 Kubernetes 这边,OCI 镜像还是只能拿来跑容器,缺少一个原生的消费方式。

ImageVolume 就是来补上这一块的。它允许在 Pod 中将 OCI 镜像直接作为 Volume 挂载,让 OCI Artifacts 在 K8s 里也能被原生消费,不再只是跑容器。就像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
kind: Pod
spec:
 containers:
 - 
 volumeMounts:
 - name: my-volume
 mountPath: /path/to/directory
 volumes:
 - name: my-volume
 image:
 reference: my-image:tag

ImageVolume 四大使用场景:共享配置、CI/CD 产物、安全签名、模型分发

不过有一点要注意,ImageVolume 挂载是只读的,如果需要在运行时修改挂载的文件,还是得用 PVC。后面会详细讨论这个限制。

ImageVolume 让 OCI Artifacts 在 K8s 里有了第一个原生的消费方式。不过这个能力并不是一步到位的,从 Alpha 到 GA 走了近两年。

2. ImageVolume 从 Alpha 到 GA

ImageVolume 这个特性来源于 KEP-4639,由 SIG Node 和 SIG Storage 共同推动。从 v1.31 Alpha 到 v1.36 GA 走了近两年,具体时间线如下:

阶段 K8s 版本 发布时间 Feature Gate 默认值 说明
Alpha v1.31 2024-08 false 需要手动开启 Feature Gate
Beta(默认关) v1.33 2025-04 false Beta 代码合入但仍默认关闭
Beta(默认关) v1.34 2025-08 false 移除 noexec 限制,仍默认关闭
Beta(默认开) v1.35 2025-12 true 首次默认启用
GA v1.36 2026-04 true(锁定) Feature Gate 锁定,v1.39 移除

Containerd v2.1.0 才正式支持 ImageVolume,而且没有回移到 v2.0.x 分支,所以用 containerd 的话必须升级到 v2.1.0+。

ImageVolume 概念图:容器镜像不再只跑容器,还能分发模型、配置、签名等各种只读数据

接下来过一遍每个阶段的变化。

2.1 subPath 支持(Alpha → Beta)

Alpha 阶段 ImageVolume 不支持 subPath,也就是说你只能挂载镜像的整个文件系统,没法只挂载其中的某个子目录。

Beta 阶段(v1.33)解除了这个限制,subPathsubPathExpr 都可以用了。对应的 CRI API 也新增了 image_sub_path 字段来支持这个功能。

现在你可以这样用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 只挂载镜像中的 models/Qwen2-0.5B 子目录到 /models/qwen2
containers:
 - name: app
 image: busybox:1.36
 volumeMounts:
 - name: model-volume
 mountPath: /models/qwen2
 subPath: Qwen2-0.5B  # 挂载镜像中的这个子路径
 readOnly: true
volumes:
 - name: model-volume
 image:
 reference: registry.example.com/models/all-models:v1
 pullPolicy: IfNotPresent
# 如果 subPath 指定的路径在镜像中不存在,容器创建会报错

很多时候一个镜像里会放多个目录,有了 subPath 就不用把整个镜像都挂进来了。

2.2 noexec 限制移除(Alpha → Beta)

Alpha 阶段 ImageVolume 挂载时强制加了 noexec 选项,挂载进来的文件不能被执行。

这个限制在 Beta 阶段(2025-06,PR #5354)被移除了。社区讨论后觉得 noexec 限制过于严格,ImageVolume 的主要用途是分发只读数据,强制 noexec 没有必要,反而限制了某些合理的使用场景,比如挂载包含可执行工具的镜像。

不过 ImageVolume 仍然是只读挂载(ro),读写支持还得等后续的 KEP。

2.3 Kubelet 监控指标(Alpha → Beta)

Beta 阶段新增了 3 个 Kubelet 指标,方便监控 ImageVolume 的使用情况。

  • kubelet_image_volume_requested_total — 请求的 ImageVolume 数量
  • kubelet_image_volume_mounted_succeed_total — ImageVolume 挂载成功的数量
  • kubelet_image_volume_mounted_errors_total — ImageVolume 挂载失败的数量

GA 阶段这些指标提升到了 BETA 稳定性级别,可以在 Prometheus 里配告警了。

2.4 Feature Gate 锁定(GA)

v1.36 GA 后,ImageVolume Feature Gate 被锁定为默认开启,没法关了。按照 K8s 的惯例,Feature Gate 会在 GA 后 3 个版本移除,也就是 v1.39 会彻底删掉这个 Gate。

所以现在的状态就是:

  • 不再需要手动开启 Feature Gate 了,v1.36 集群开箱即用
  • API 字段上的 +featureGate=ImageVolume 注解也被移除了
  • E2E 测试提升为 Conformance 级别,这是 GA 的标志之一

2.5 containerd 原生支持

这个虽然不是 K8s 代码的变化,但可能是对使用者影响最大的变化。

Alpha 阶段,containerd 不支持 ImageVolume,想玩的话只能自己动手。参考我之前的文章 K8s V1.31 新特性:ImageVolume, 我当时手动 checkout 了 containerd 的 PR #10579,编译替换二进制文件。编译倒是不难,但是那个 PR 还有个 bug,kubelet 没有把 readOnly 参数透传到 CRI mounts 配置中,导致 containerd 校验 readOnly 失败一直报错。。。没办法,还得手动注释掉校验逻辑,整个流程折腾下来真的挺崩溃的。

现在 containerd v2.1.0 已经原生支持 ImageVolume,直接用就行,不需要任何 hack。

CRI-O 的话从 v1.31 就支持了,v1.34 还增加了 subPath 支持,一直走在前面。

2.6 变化总结

整理一下:

变化项 Alpha (v1.31) Beta (v1.33-v1.35) GA (v1.36)
subPath ❌ 不支持 ✅ 支持 ✅ 支持
noexec 限制 强制 noexec 移除 无限制
监控指标 Alpha 级别 BETA 级别
Feature Gate 默认关,需手动开 v1.33/34 默认关,v1.35 默认开 锁定开启
containerd 需手动编译 PR v2.1.0 原生支持 v2.1.0+
CRI-O v1.31 支持 v1.34 支持 subPath 同左
挂载模式 只读 只读 只读

3. 现在怎么用

变化聊完了,实际用起来是什么感觉呢。GA 之后用起来比 Alpha 阶段简单太多了,不用再折腾 Feature Gate 和手动编译 containerd 了。

3.1 环境要求

  • Kubernetes >= v1.36
  • Container Runtime:
    • containerd >= v2.1.0
    • CRI-O >= v1.31(subPath 需要 >= v1.34)

就这么简单,不需要额外配置任何 Feature Gate。

本次验证环境是使用 KubeClipper 安装的 K8s 集群,版本如下:

  • Kubernetes v1.36.1
  • containerd v2.2.4

3.2 构建目标镜像

使用方式和 Alpha 阶段基本一致。先构建一个包含模型文件的 OCI 镜像,用 FROM scratch 就行,不需要任何基础镜像。

为了后面演示 subPath,这里在镜像里放两个模型目录,再放一个配置文件:

1
2
3
4
5
6
mkdir -p models/Qwen2-0.5B models/Llama2-7B
echo "qwen2 model weights" > models/Qwen2-0.5B/model.bin
echo "qwen2 config" > models/Qwen2-0.5B/config.json
echo "llama2 model weights" > models/Llama2-7B/model.bin
echo "llama2 config" > models/Llama2-7B/config.json
echo "app config v1" > app.conf

目录结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
image-builder/
├── Dockerfile
├── app.conf
└── models/
 ├── Qwen2-0.5B/
 │ ├── config.json
 │ └── model.bin
 └── Llama2-7B/
 ├── config.json
 └── model.bin

Dockerfile 如下:

1
2
3
FROM scratch
COPY ./models /models
COPY ./app.conf /app.conf

构建并推送到镜像仓库:

1
2
docker build -t registry.example.com/demo/image-volume:v1 .
docker push registry.example.com/demo/image-volume:v1

3.3 基本挂载

创建 Pod 挂载这个镜像:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: v1
kind: Pod
metadata:
 name: image-volume-demo
spec:
 containers:
 - name: app
 image: busybox:1.36
 command: ["sleep", "3600"]
 volumeMounts:
 - name: model-volume
 mountPath: /models
 readOnly: true
 volumes:
 - name: model-volume
 image:
 reference: registry.example.com/demo/image-volume:v1
 pullPolicy: IfNotPresent

应用到集群,等 Pod Running 后查看挂载内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ kubectl apply -f pod.yaml
pod/image-volume-demo created

$ kubectl get pod image-volume-demo
NAME READY STATUS RESTARTS AGE
image-volume-demo 1/1 Running 0 30s

$ kubectl exec image-volume-demo -- ls -la /models/
total 16
drwxr-xr-x 1 root root 4096 Jun 16 13:03 .
drwxr-xr-x 1 root root 4096 Jun 16 13:03 ..
-rw-r--r-- 1 root root 14 Jun 16 13:03 app.conf
drwxr-xr-x 2 root root 4096 Jun 16 13:03 Qwen2-0.5B
drwxr-xr-x 2 root root 4096 Jun 16 13:03 Llama2-7B

$ kubectl exec image-volume-demo -- cat /models/app.conf
app config v1

$ kubectl exec image-volume-demo -- cat /models/Qwen2-0.5B/config.json
qwen2 config

镜像里的文件都挂载进来了,跟预期一致。

3.4 subPath 挂载

上面那个镜像里放了两个模型目录,如果 Pod 只需要 Qwen2-0.5B,不需要把整个镜像都挂进来,用 subPath 就行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
apiVersion: v1
kind: Pod
metadata:
 name: image-volume-subpath
spec:
 containers:
 - name: app
 image: busybox:1.36
 command: ["sleep", "3600"]
 volumeMounts:
 - name: model-volume
 mountPath: /models/qwen2
 subPath: Qwen2-0.5B
 readOnly: true
 volumes:
 - name: model-volume
 image:
 reference: registry.example.com/demo/image-volume:v1
 pullPolicy: IfNotPresent

验证一下,挂载目录里只有 Qwen2-0.5B 的内容:

1
2
3
4
5
6
7
8
9
$ kubectl exec image-volume-subpath -- ls -la /models/qwen2/
total 12
drwxr-xr-x 2 root root 4096 Jun 16 13:03 .
drwxr-xr-x 3 root root 4096 Jun 16 13:03 ..
-rw-r--r-- 1 root root 14 Jun 16 13:03 config.json
-rw-r--r-- 1 root root 22 Jun 16 13:03 model.bin

$ kubectl exec image-volume-subpath -- cat /models/qwen2/config.json
qwen2 config

只挂载了 Qwen2-0.5B 目录,Llama2-7B 和 app.conf 都不在。如果 subPath 指定的路径在镜像中不存在,容器创建会直接报错:

1
2
$ kubectl get pod image-volume-subpath-err -o jsonpath='{.status.containerStatuses[0].state.waiting.message}'
failed to mount image volume: ImageVolumeMountFailed: failed to ensure image subpath "not-exist-dir" in "...": openat not-exist-dir: no such file or directory

3.5 只读挂载验证

ImageVolume 挂载是只读的,尝试写入会报 Read-only file system

1
2
$ kubectl exec image-volume-demo -- sh -c 'echo test > /models/test.txt'
sh: can't create /models/test.txt: Read-only file system

最后提几个实际使用中的注意事项。

ImageVolume 挂载是只读的,如果需要运行时修改文件还是得用 PVC,目前没有读写支持的 KEP。Pod 重建时 ImageVolume 会重新解析远端镜像,所以生产环境建议用 digest 而不是 tag 引用镜像,避免 Pod 重建后 tag 被覆盖导致拿到非预期版本。

镜像层共享能省磁盘,两个 ImageVolume 引用的镜像有相同层的话 containerd 只存一份,但大模型镜像多了也要注意节点磁盘压力。

4. 小结

OCI 从 2017 年 image-spec v1.0 发布到今天,走了一条挺清晰的路:先是容器镜像格式标准化,然后 OCI Registry 被社区 hack 成通用内容仓库,接着 image-spec v1.1.0 和 distribution-spec v1.1.0 把这种用法正式写入规范(artifactType + referrers API)。

OCI Artifacts 从社区变通变成了标准的一部分。ImageVolume GA 等于 Kubernetes 也跟上了,OCI Artifacts 在 K8s 里有了第一个原生的消费方式。

往后看,读写支持可能是下一个方向,目前只读挂载还是限制了些场景,社区已经有相关讨论。另外 ImageVolumeWithDigest 子特性在 v1.36 也 GA 了,Pod Status 里能直接看到挂载镜像的 digest,版本追溯方便不少。

AI 时代,你的 HPA 可能已经失灵了

2026-06-12 04:00:00

keda-ai-autoscaling.jpg

不知道你们有没有发现,进入 AI 时代之后,传统微服务那套 HPA 好像突然不好使了。

CPU 20%,内存 30%,监控面板一片岁月静好,但你的 AI 推理服务已经在排队了。HPA 看了一眼指标,嗯,一切正常,不用扩。

这不是 HPA 的 bug,是它背后那套「资源使用率等于负载压力」的逻辑,在 AI 推理场景下从根上就不成立。问题不在调参,在于观测信号本身就是失真的。

今天就来聊聊这件事的来龙去脉,以及目前我们认为最合理的解法,KEDA。

1. HPA 在 AI 场景下到底错在哪

先说清楚 HPA 的底层逻辑。

HPA(Horizontal Pod Autoscaler)的设计假设是,资源使用率跟负载压力成正比。CPU 高了说明忙,内存高了说明扛不住,扩就完事了。对传统 Web 服务来说,这个假设基本成立,请求来了 CPU 就涨,请求走了 CPU 就降,很线性。

但 AI 推理服务把这个假设的每一个环节都打破了。以图片生成服务为例。

hpa-metrics-failure

第一个问题,GPU 显存是常驻的

图片生成服务启动的时候,第一件事就是把模型权重加载到 GPU 显存里。比如 Stable Diffusion SDXL 这种级别的模型,光权重就要好几个 GB,加载完之后,这些显存就一直占着,不管你有没有请求,它都是满的。

所以你如果用 GPU 显存使用率做 HPA 指标,服务刚启动就已经「满」了。HPA 会觉得你一直需要扩容,即使压根没有任何请求在处理。

第二个问题,GPU 计算使用率跟请求量不成正比

生成一张图片,一般要迭代几十上百步,整个过程可能就要几秒到几十秒。在这个过程中,GPU 使用率直接飙到 90% 甚至 100%。

问题在于,一个请求就能把 GPU 跑满。1 个请求和 10 个请求,在 GPU 使用率上看起来差不多,都是满载。 HPA 那套「采样窗口取平均值再做判断」的逻辑,放到这里完全没意义,因为不管采多少秒,看到的都是满载。

更离谱的是,你按 GPU 使用率做 HPA,一个请求过来就触发扩容,扩出来的 Pod 加载模型要 30 秒到 2 分钟,等它准备好,原来那个请求早就处理完了。

你可能会想,那 CPU 呢?内存呢?

第三个问题,CPU 和内存指标跟 GPU 负载无关

推理服务的 CPU 主要做些预处理和调度,内存主要是模型权重的 CPU 侧缓存。0 个请求和 10 个请求的 CPU/内存使用,差别可能就是几个百分点。

所以,三个问题,三条路,全堵死了。HPA 最依赖的两个指标,在 AI 推理场景下,全部失真。

指标 传统 Web 服务 AI 推理服务
GPU 显存 随负载动态变化 模型加载后常驻满载
GPU 计算使用率 与请求量正相关 一个请求就能拉满,无法反映积压程度
CPU 使用率 与请求量正相关 仅做预处理,变化极小
内存使用率 与请求量正相关 主要是模型缓存,基本不变
HPA 扩容效果 准确 失真

这就是为什么 HPA 在 AI 场景下不是扩错了,就是扩晚了,要么干脆就不扩。它的设计假设被 AI 服务或者 GPU 的工作模式彻底打碎了。

那怎么办?

2. 别看机器忙不忙,看活多不多

HPA 之所以在 AI 场景下失灵,是因为它在看「机器忙不忙」。但 AI 推理的真正压力不在机器指标上,在「有多少活等着干」。

那「活」在哪?

  • 可能是消息队列里积压的消息
  • 可能是数据库里状态为 pending 的任务记录
  • 可能是对象存储里等着处理的文件
  • 也可能是外部系统通过 webhook 推过来的请求

形式不同本质一样,都是「有多少事还没干」。

对 AI 推理来说,最常见的载体是消息队列。图片生成、视频生成这些请求动辄几秒、几十秒甚至几分钟的,一般都会走异步处理。用户请求先进 MQ 排队,Worker 从队列里消费。队列里积压了多少消息,就是有多少待处理的推理请求。

这就是最真实的扩容信号。不根据指标看 GPU 忙不忙,直接看系统里有多少活没干完

demand-side-signal

KEDA 干的就是这件事。

KEDA 全称 Kubernetes Event-driven Autoscaling,核心思路特别简单,根据外部事件源的状态来决定扩缩容,而不是看 Pod 的资源使用率。消息队列里有积压就扩,没消息就缩,就是这么直接。

用一张表对比一下 HPA 和 KEDA 的核心差异。

对比维度 HPA KEDA
扩容信号来源 Pod 资源使用率(CPU/内存/自定义指标) 外部事件源(队列深度、消费延迟等)
信号本质 机器侧,看「忙不忙」 需求侧,看「活多不多」
Scale to Zero 不支持,minReplicaCount 最低为 1 原生支持
外部指标接入复杂度 需要 Prometheus Adapter + ServiceMonitor + 自定义指标 + HPA 规则 一个 ScaledObject CRD 搞定
适合场景 传统 Web 服务,资源使用率与负载线性相关 异步处理,负载压力体现在队列积压

KEDA 支持 60 多种 Scaler,包括 Kafka、RabbitMQ、Redis、Prometheus、Cron 等,每个 Scaler 对接一种外部事件源,直接读取事件源的指标来决定扩缩容。

其中几个关键差异值得说一下。

可以缩到零。 没有请求的时候直接把 Pod 数量缩到 0,有请求来了再拉起来。对 GPU 这种昂贵资源来说,不用的时候就不占是最优的。

扩容信号直接来自需求侧。 不再绕一圈去猜 GPU 忙不忙,直接看队列深度、消费延迟这些业务强相关指标。

配置更简单。 用 HPA 接外部指标,你得部署 Prometheus Adapter、写 ServiceMonitor、配自定义指标、再写 HPA 规则,一整套流水线。KEDA 就一个 ScaledObject CRD,声明一下触发器类型和参数就搞定了。

下面用一个 demo 把整个流程跑一遍,让大家有一个大致的印象。

3. 实战,用 RabbitMQ 驱动 KEDA 扩缩容

我们搞了一个 demo 项目,模拟一个典型的 AI 图片生成服务架构,演示完整的流程。 整体架构如下。

  • 1)用户提交「生成一张猫的图片」这样的请求
  • 2)API 接收后把任务写入 DB 做状态追踪,同时把 task ID 发到 RabbitMQ
  • 3)Worker 从队列消费任务,没有真正跑模型服务,而是 sleep 一段模拟 Stable Diffusion 那种几秒到几十秒的推理延迟,然后把任务标记为完成。
  • 4)KEDA 监控 RabbitMQ 队列深度,队列有积压就自动扩 Worker,没消息就缩到零。

keda-architecture


为什么选图片生成? 因为图片/视频生成是 KEDA 扩容最典型的 AI 场景。这类请求处理时间长,没法像 ChatGPT 那样实时返回,必须走异步架构。用户提交请求后拿到一个 task ID,过几秒再来查结果。这个架构和真实 Stable Diffusion API、ComfyUI 工作流、甚至 Sora 类视频生成服务的后端,逻辑是一样的。

为什么加了 DB? 因为真实的图片生成服务,用户提交请求后需要能查到任务状态,从 queued 到 processing 到 completed,这个生命周期需要持久化存储。MQ 只负责通知 Worker 有新任务,不负责存状态。

真实的 AI 推理当然不会用 sleep,这里用 sleep 只是为了模拟推理耗时,让你不用 GPU 也能跑通完整的 KEDA 扩缩容流程。架构、队列、扩缩容逻辑,跟生产环境一模一样。

代码和全部部署文件在 GitHub,keda-ai-queue-demo,下面逐个讲关键部分。

3.1 准备工作

需要一个跑着 Kubernetes 的集群,并且装好 KEDA。

1
2
3
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace

装完确认一下。

1
kubectl get pods -n keda

应该能看到 keda-operator 和 keda-operator-metrics-apiserver 两个 Pod 在运行。

3.2 部署

克隆项目,直接 apply 即可,镜像已推送到 Docker Hub。

1
2
git clone https://github.com/lixueduan/keda-ai-queue-demo.git
cd keda-ai-queue-demo

一行搞定部署,PostgreSQL、RabbitMQ、API、Worker、KEDA ScaledObject 全部拉起来。

1
kubectl apply -k deploy/

确认 Pod 状态。

1
kubectl get pods -n keda-ai-demo

应该看到 PostgreSQL、RabbitMQ、API 三个 Pod 在运行,Worker 的 replicas 是 0,因为队列里还没消息,KEDA 不会给它扩容。

3.3 核心配置,KEDA ScaledObject

整个事件驱动扩缩容的配置就这一个 YAML。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
 name: worker-scaler
 namespace: keda-ai-demo
spec:
 scaleTargetRef:
 name: worker
 pollingInterval: 5
 cooldownPeriod: 60
 minReplicaCount: 0
 maxReplicaCount: 10
 triggers:
 - type: rabbitmq
 metadata:
 queueName: inference-requests
 mode: QueueLength
 value: "5"

逐个解释一下。

scaleTargetRef: worker,KEDA 只管 Worker 这个 Deployment,API 和 PostgreSQL 跟 KEDA 无关。

pollingInterval: 5,每 5 秒检查一次队列状态。AI 推理服务对响应时间敏感,轮询间隔可以设短一点,默认是 30 秒。

cooldownPeriod: 60,队列清空后 60 秒才缩容。demo 里设短一点方便观察,实际使用建议设 300 秒以上,避免模型反复加载。

minReplicaCount: 0,启用 scale to zero,没请求的时候不占资源。

mode: QueueLengthvalue: "5"每个 Pod 处理 5 条消息。队列里有 23 条消息,KEDA 就算出需要 ceil(23/5) = 5 个 Pod。

至于 KEDA 怎么连接 RabbitMQ,它通过 TriggerAuthentication 从 Kubernetes Secret 里读取连接信息,不在 ScaledObject 里硬编码。具体可以看 demo 仓库的 deploy/keda.yaml

3.4 制造队列积压,观察扩容

端口转发 API。

1
kubectl port-forward -n keda-ai-demo svc/api 8080:8080

创建一批任务。

1
2
3
4
5
6
for i in $(seq 1 20); do
 curl -s -X POST http://127.0.0.1:8080/tasks \
 -H 'Content-Type: application/json' \
 -d "{\"input\":\"task $i\"}"
 echo
done

查一下任务状态。

1
curl -s http://127.0.0.1:8080/tasks/<task-id>

状态流转是 queued -> processing -> completed,整个过程都有时间戳可以看。

打开另一个终端,观察 Worker Pod 数量变化。

1
kubectl get pods -n keda-ai-demo -l app=worker -w

你会看到 Worker 从 0 个 Pod 开始,KEDA 检测到队列有积压后,根据我们的配置把 Worker 扩到 4 个 Pod 并发消费。队列清空、冷却期结束后,Worker 又缩回 0。

KEDA 的操作日志也能看到完整过程。

1
kubectl logs -n keda deploy/keda-operator -f | grep -E "Scale|scale"
1
2
3
Successfully updated ScaleTarget: Original Replicas Count: 0, New Replicas Count: 1
Successfully updated ScaleTarget: Original Replicas Count: 1, New Replicas Count: 4
Successfully set ScaleTarget replicas count to ScaledObject minReplicaCount: Original Replicas Count: 4, New Replicas Count: 0

0 -> 1 -> 4 -> 0,这就是 KEDA 事件驱动扩缩容的完整过程。

scaled-object-vs-job

3.5 整个流程

  1. 用户 POST 一个任务,API 写入 PostgreSQL,同时把 task ID 发到 RabbitMQ
  2. KEDA 每 5 秒检查 RabbitMQ 队列深度
  3. 队列有消息,触发器变为 active,KEDA 把 Worker 从 0 扩到计算出的副本数
  4. 扩出来的 Worker Pod 消费消息,标记任务为 processing,sleep 模拟推理,标记认为为 completed
  5. 队列消费完,冷却期结束,KEDA 把 Worker 缩回 0

清理环境。

1
kubectl delete namespace keda-ai-demo

4. 实际使用中需要注意的几个坑

配置写起来简单,但真正使用起来还是有一些需要注意的地方:

冷启动问题。 从 0 扩到 N 意味着 Pod 要从零开始启动,包括加载模型到 GPU。大型图片生成模型的加载可能要 30 秒到 2 分钟,这段时间请求只能排队。如果业务对延迟敏感,最好把 minReplicaCount 设为 1,始终保留一个热 Pod。

cooldownPeriod 别设太短。 缩容后又来请求,重新扩容 + 模型加载,这个周期可能要一两分钟。频繁缩容再扩容的代价很大,Pod 反复启停,GPU 资源白白浪费在模型加载上。建议至少设 300 秒,让流量真正稳定下来再缩。

HPA 会被 KEDA 接管。 KEDA 会在底层创建一个 HPA,所以同一个 Deployment 不能再手动创建 HPA,会冲突

轮询间隔要权衡。 pollingInterval 设得短,响应快,但对外部系统的 API 调用量也大。如果你有几百个 ScaledObject,每个 5 秒轮询一次,对消息队列的压力不小。根据实际场景在响应速度和资源消耗之间找个平衡点。

聊了这么多,最后收一下。

5. 小结

HPA 在 AI 推理场景下失灵,说到底是它的设计假设,资源使用率跟负载成正比,被 GPU 工作模式打破了。GPU 显存常驻、使用率脉冲、CPU/内存不跟负载走,HPA 看到的信号全是失真的。

KEDA 的思路是绕过资源指标,直接看需求侧的信号。消息队列里有多少消息在排队,就是最真实的扩容依据。

这个思路其实不只适用于 AI 推理,所有异步处理场景都有类似的扩缩容需求。只不过 AI 推理把这个问题放大了,因为 GPU 太贵了,扩错一台就是真金白银的浪费。

几个要点:

  • 别看机器忙不忙,看活多不多,队列深度就是 AI 推理服务最真实的扩容信号
  • KEDA 支持 scale to zero,GPU 不用的时候不占资源
  • 注意冷启动、cooldownPeriod、轮询间隔这几个关键参数
  • 没有银弹,KEDA 只是让扩容信号回归准确,模型加载的延迟还得靠预热或其他手段解决

如果你的 AI 推理服务还在用 HPA,建议重新审视一下扩容策略,可能它已经在「失灵」了,只是你还没注意到。

KubeClipper 1.6.0 发布:kcctl 优化与 K8s 1.36 支持

2026-06-03 04:00:00

kubeclipper-release-1.6.0.jpg

KubeClipper 发布了 1.6.0 版本:支持 Kubernetes 1.36,容器运行时从 Containerd 1.x 升级到 2.x,Calico 更新到 v3.31.5。kcctl 新增了 kcctl set clusterkcctl operation 等命令,并优化了 Registry 管理体验,修复了大量稳定性问题。

KubeClipper 是一个轻量便捷的 Kubernetes 多集群全生命周期管理工具,旨在提供易使用、易运维、极轻量、生产级的 Kubernetes 多集群管理服务,让运维工程师从繁复的配置和晦涩的命令行中解放出来,实现一站式管理跨区域、跨基础设施的多 K8S 集群。

🚀 5分钟快速体验

如果你是第一次接触 KubeClipper,可以通过以下步骤快速上手:

  1. 一键安装工具curl -sfL https://oss.kubeclipper.io/get-kubeclipper.sh | KC_REGION=cn bash -
  2. 部署服务kcctl deploy
  3. 创建集群kcctl create cluster --name demo --master YOUR_IP --untaint-master
  4. 访问界面:浏览器访问 http://YOUR_IP:8080,账号 admin/Thinkbig1

四条命令,从零到一个跑起来的 K8s 集群。

1. KubeClipper 1.6.0 新特性详解

1.6.0 的主要变化集中在三块:K8s 最新版本支持、Containerd v2 适配、kcctl 命令行改进。

本次更新的主要亮点:

  • 🔄 Kubernetes 1.36 支持:支持最新 K8s 版本
  • 🆕 Containerd v2 升级:版本新特性:Sandbox API、CDI/NRI 默认启用、User Namespaces 等重大更新
  • 🔄 Calico v3.31.5:版本新特性:eBPF 数据面安装简化、nftables GA、LRU 连接追踪等
  • 🛠️ kcctl 命令行优化:set cluster、operation、registry 等多项改进
  • 🔧 稳定性提升:修复部署失败、配置覆盖、资源泄漏等问题
  • 🧹 代码清理:移除未使用命令和冗余代码

1.1 Kubernetes 1.36 与组件升级

Kubernetes Calico Containerd
v1.36.1(默认) v3.31.5 v2.2.4
v1.35.0 v3.29.6 v1.7.29
v1.34.2 v3.29.6 v1.7.29

Containerd v2:容器运行时大版本跃迁

  • CDI 默认启用:标准化 GPU、FPGA 等硬件设备注入方式,对 AI/ML 工作负载意义重大
  • User Namespaces 支持(KEP-127):容器内 root 不再映射为主机 root,从根本上提升容器安全性
  • 配置格式升级至 config.toml version=3:CRI v1alpha2 移除,仅保留 CRI v1

Calico v3.31:eBPF 数据面走向成熟

  • eBPF 安装大幅简化:通过 Installation API 参数即可一键启用,无需再手动处理 kube-proxy 和 API Service Endpoint 配置
  • nftables 数据面 GA:配合 Kubernetes 1.31+ 的 nftables kube-proxy,提供比 iptables 更好的性能和可扩展性
  • LRU 连接追踪:eBPF conntrack 采用 LRU 策略自动淘汰旧连接,高并发场景下不再轻易丢连接

1.2 kcctl 命令行优化

kcctl set cluster

新增 kcctl set cluster 命令,支持在集群创建后修改外部访问配置:

1
2
3
4
5
# 设置外部 IP
kcctl set cluster my-cluster --external-ip 10.0.0.100

# 设置外部域名
kcctl set cluster my-cluster --external-domain k8s.example.com

同时 kcctl create cluster 也新增了 --external-port--external-domain-port 参数,支持在创建集群时直接指定外部访问端口。

kcctl operation 命令

1.6.0 重写了 kcctl operation 命令,支持按集群名称查看操作记录,还加了 TUI 交互模式:

1
2
3
4
5
6
7
8
# 通过集群名称查看操作记录
kcctl operation list -c my-cluster

# TUI 模式查看集群操作日志
kcctl operation logs -c my-cluster

# 查看指定操作的详细日志
kcctl operation logs <OPERATION_ID> --follow

还有三个新子命令:

1
2
3
4
5
6
7
8
# 查看操作详情
kcctl operation describe <OPERATION_ID>

# 重试失败的操作
kcctl operation retry <OPERATION_ID>

# 终止运行中的操作
kcctl operation terminate <OPERATION_ID>

旧的 kcctl logs 命令已标记为废弃,后续版本将移除。

kcctl registry 优化

  • 配置持久化kcctl registry deploy 完成后自动将节点信息(IP、端口、SSH 凭据)保存到 ~/.kc/registry-config.yaml,后续执行 listpushdelete 等命令时无需重复指定 --node 和 SSH 参数,CLI 参数优先级高于配置文件
  • tar 格式支持kcctl registry push 支持 tar 格式的镜像包

另外清理了多个废弃命令,修复了 CLI 参数解析和错误处理等问题。

1.3 稳定性提升

这一版包含大量的稳定性修复:

  • Containerd 配置保护:更新镜像仓库认证配置时,不再覆盖整个 config.toml,而是仅更新 registry.configs.{host}.auth 部分,保留用户已有的配置(如 GPU Operator 添加的配置)
  • 过期认证清理:当 Registry 从集群中移除时,自动清理 config.toml 中对应的认证信息
  • 部署修复:修复了 kcctl deploy 在 deploy-config configmap 已存在时失败的问题,改为 CreateOrUpdate 语义
  • 资源泄漏修复:修复了 backupstore 文件句柄泄漏、监控循环中单集群失败导致整体停止等问题
  • 代码清理:移除了未使用的 kcctl-proxy 命令(约 1100 行)和多个工具包中的未使用代码(约 600 行),精简工具链

2. 安装部署 KubeClipper 1.6.0

2.1 环境要求

  • 操作系统: CentOS 7+/Ubuntu 18.04+
  • 内核版本: 4.4+
  • 内存: 至少4GB RAM
  • 存储: 至少 20GB 可用空间
  • 网络: 节点间网络互通

2.2 安装 kcctl 工具

1
2
3
4
5
# 下载最新版本的 kcctl
curl -sfL https://oss.kubeclipper.io/get-kubeclipper.sh | KC_REGION=cn bash -

# 验证安装
kcctl version

输出示例:

1
2
root@lixd-dev-3:~# kcctl version
kcctl version: version.Info{Major:"1", Minor:"6", GitVersion:"v1.6.0", GitCommit:"2561b625224a9cfd30941190bd38b828dc0ef1bb", GitTreeState:"clean", BuildDate:"2026-06-02T09:05:33Z", GoVersion:"go1.24.2", Compiler:"gc", Platform:"linux/amd64"}

2.3 部署 KubeClipper

1
2
3
4
5
# 对于 aio 环境使用 kcctl deploy 即可完成部署
kcctl deploy

# 更多参数参考 kcctl deploy -h
# kcctl deploy --server $IPADDR_SERVER --agent $IPADDR_AGENT --pk-file /root/.ssh/id_rsa --pkg $PKG --ip-detect=interface=ens3 --v 5 

看到下面的 banner 就说明部署完成了:

1
2
3
4
5
6
7
8
 _ __ _ _____ _ _
| | / / | | / __ \ (_)
| |/ / _ _| |__ ___| / \/ |_ _ __ _ __ ___ _ __
| \| | | | '_ \ / _ \ | | | | '_ \| '_ \ / _ \ '__|
| |\ \ |_| | |_) | __/ \__/\ | | |_) | |_) | __/ |
\_| \_/\__,_|_.__/ \___|\____/_|_| .__/| .__/ \___|_|
 | | | |
 |_| |_|

安装过程中需要去阿里云下载离线安装包,大概 1 分钟即可下载完成。

2.4 访问 Web UI

安装完成后,打开浏览器,访问 http://$IP 即可进入 KubeClipper 控制台。

kc-console-login.jpg

您可以使用默认帐号密码 admin / Thinkbig1 进行登录。

3. 快速上手体验

3.1 创建 K8s 集群

用 kcctl 创建一个集群试试:

查看当前 agent 节点

1
2
3
4
5
6
root@lixd-dev-3:~# kcctl get node
+--------------------------------------+------------+---------+----------------+-------------+-----+---------+
| ID | HOSTNAME | REGION | IP | OS/ARCH | CPU | MEM |
+--------------------------------------+------------+---------+----------------+-------------+-----+---------+
| d6dff191-9a79-49bb-95fb-69c733c9fe7f | lixd-dev-3 | default | 172.16.131.146 | linux/amd64 | 8 | 15991Mi |
+--------------------------------------+------------+---------+----------------+-------------+-----+---------+

创建集群:

1
2
3
4
5
6
7
8
9
root@lixd-dev-3:~# kcctl create cluster --name demo --master 172.16.131.146 --untaint-master
[2026-06-02T09:47:29Z][INFO] use default containerd version 2.2.4
[2026-06-02T09:47:29Z][INFO] use default calico version v3.31.5
[2026-06-02T09:47:29Z][INFO] use default k8s version v1.36.1
+------+---------+--------------+--------------+------------+----------------------------+-------------------------------+
| NAME | REGION | MASTER COUNT | WORKER COUNT | STATUS | APISERVER CERTS EXPIRATION | CREATE TIMESTAMP |
+------+---------+--------------+--------------+------------+----------------------------+-------------------------------+
| demo | default | 1 | 0 | Installing | | 2026-06-02 09:47:29 +0000 UTC |
+------+---------+--------------+--------------+------------+----------------------------+-------------------------------+

大概两分钟装完,用以下命令看实时日志:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
root@lixd-dev-3:~# kcctl operation list -c demo
+--------------------------------------+---------+---------------+---------+---------------------------+---------------------+-------+
| ID | CLUSTER | NAME | STATUS | SPONSOR | CREATED AT | STEPS |
+--------------------------------------+---------+---------------+---------+---------------------------+---------------------+-------+
| 40bee830-6ff5-4ed9-b07f-47762e0055a8 | demo | CreateCluster | running | https-172.16.131.146-8080 | 2026-06-02 09:47:29 | 14 |
+--------------------------------------+---------+---------------+---------+---------------------------+---------------------+-------+
root@lixd-dev-3:~# kcctl operation logs -c demo
root@lixd-dev-3:~# kcctl operation logs 40bee830-6ff5-4ed9-b07f-47762e0055a8

───── Step: installRuntime (c11db224-d4bf-49a5-8fbb-4e3e8fc10490) [successful] 2026-06-02 09:47:29 ─────
 successful lixd-dev-3 (172.16.131.146) d6dff191-9a79-49bb-95fb-69c733c9fe7f [3s]
 [2026-06-02T09:47:29Z] + download from http://172.16.131.146:8081/containerd/2.2.4/amd64/manifest.json

 [2026-06-02T09:47:29Z] + download from http://172.16.131.146:8081/containerd/2.2.4/amd64/config... (truncated)

───── Step: nodeEnvSetup (f5c78839-4840-490b-a055-5d5f51f6ad4b) [successful] 2026-06-02 09:47:32 ─────
 successful lixd-dev-3 (172.16.131.146) d6dff191-9a79-49bb-95fb-69c733c9fe7f [1s]
 [2026-06-02T09:47:32Z] + /bin/bash -c
 systemctl stop firewalld || true
 systemctl disable firewalld || true
 setenforce 0
 sed -i s/^SELINUX=.*$/SELINUX=disabled/ /etc/selinux/config
 modprobe br_netfilt... (truncated)

───── Step: installExtension (97a39913-50ed-41cd-9b67-e476d57b7c7a) [successful] 2026-06-02 09:47:32 ─────
 successful lixd-dev-3 (172.16.131.146) d6dff191-9a79-49bb-95fb-69c733c9fe7f [33s]
 [2026-06-02T09:47:32Z] + download from http://172.16.131.146:8081/k8s-extension/v1/amd64/manifest.json

 [2026-06-02T09:47:32Z] + download from http://172.16.131.146:8081/k8s-extension/v1/amd64/config... (truncated)

新版还加了 TUI 日志查看器:

1
kcctl operation logs -c demo

kcctl-operation-logs-tui.png

集群装好了,用 kubectl 看一下状态:

1
2
3
4
5
6
7
8
root@lixd-dev-3:~# kubectl get cs
NAME STATUS MESSAGE ERROR
scheduler Healthy ok
controller-manager Healthy ok
etcd-0 Healthy ok
root@lixd-dev-3:~# kubectl get node -owide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
lixd-dev-3 Ready control-plane 4m16s v1.36.1 172.16.131.146 <none> Ubuntu 24.04.3 LTS 6.8.0-90-generic (amd64) containerd://2.2.4

几条命令,一个单节点 K8s 集群就起来了。

3.2 工作负载体验

1.5.0 版本引入的工作负载管理功能,可以直接在 Web UI 中管理 Deployment、StatefulSet 等工作负载。

kc-console-workload3.png

4. 小结

这一版主要干了三件事:跟上 K8s 最新版本、适配 Containerd v2、把 kcctl 顺手打磨了一遍。

组件升级:

  • Kubernetes 1.36: 支持最新版本
  • Containerd v2.2.4: CDI 默认启用、User Namespaces 支持、config.toml v3
  • Calico v3.31.5: eBPF 安装简化、nftables GA、LRU 连接追踪

命令行优化:

  • kcctl set cluster: 支持集群创建后修改外部访问配置
  • kcctl operation: 新增 describe/retry/terminate 子命令和 TUI 日志查看器
  • kcctl registry: 配置持久化,避免重复输入 –node 和 SSH 参数
  • kcctl create cluster: 支持 –external-domain 指定外部域名

稳定性提升:

  • 配置保护: 更新认证时仅修改 auth 部分,保留已有 containerd 配置
  • 部署修复: 修复 configmap 已存在时的部署失败问题
  • 证书管理: 防止重复证书更新操作,修复 kubeadm v1beta4 兼容问题
  • 代码清理: 移除 kcctl-proxy 和约 1700 行冗余代码,精简工具链

OpenSpec + Superpowers: SDD+TDD 双驱动 AI 编程工作流

2026-06-02 04:00:00

openspec+superpowers.jpg

随着 Vibe Coding 的流行,开源社区也是涌现出了大量的 AI 编程工作流,例如: Superpowers、Everything Claude Code、Spec Kit、OpenSpec、gstack、Get Shit Done 等等。 今天给大家分享的是 OpenSpec + Superpowers 的协同工作流。

项目介绍

OpenSpec

官方的描述是:Spec-driven development (SDD) for AI coding assistants.

即:OpenSpec 就是为 AI 编程助手打造的 Spec 驱动开发(SDD)框架

在 Vibe Coding 时代,Spec 是很重要的,在开始之前很有必要和 AI 在需求上达成一致。

使用 OpenSpec 之后每个变更(Change)对应一个文件夹,包含以下四类结构化文档:

  • proposal.md:变更意图,定义为什么做
  • specs/:具体 Spec,定义做什么
  • design.md:具体技术实现方案,定义怎么做
  • tasks.md:具体实现顺序,将变更拆分为一个个小任务

简而言之:OpenSpec 可以让 AI 在写代码之前先和人对齐需求,并提供完整的 Spec 生命周期管理,便于追踪。

Superpowers

官方的描述是:An agentic skills framework & software development methodology that works.

即:Superpowers 是一套 AI 编程 Agent 的技能体系 + 开发方法论

它的核心不是生成代码,而是通过一套可组合的"技能"系统,强制 AI 像资深工程师一样工作,遵循测试驱动开发、代码审查等最佳实践。

简而言之:Superpowers 可以让 AI 像资深工程师一样干活。

为什么选择 OpenSpec + Superpowers

Why OpenSpec

在 Vibe Coding 时代,文档的重要性大幅提高。古法编程时代,需求对齐主要靠开会,Vibe Coding 时代,则需要通过具体的 Spec 变更文档来对齐。而 OpenSpec 则是提供了很好的 Spec 生命周期管理功能,可以通过 Spec 追溯到具体变更细节。

Why Superpowers

AI 生成代码是很容易堆砌成屎💩山的(当然,古法编程也一样哈哈哈)。

因为 AI 上下文有限,很难掌控全局,因此每次 Vibe Coding 产出的或许都是局部最优解,但是放到整体看就不一定合适,另外 AI 生成的代码都不够精简,代码量是很大的,毕竟 Vibe Coding 主打一个快。

这也是为什么 Github 上很多开源项目都吐槽遭受到了 PR 攻击。Agent 批量扫描代码提交 PR,可能整个过程都没有人介入,但是维护者要挨个 review,面对成堆的 PR 直摇头。

Superpowers 通过强制的 TDD、YAGNI、DRY 等工程原则,能很大程度上能够提升代码质量。

协同工作流

协同工作的核心思想是:用 OpenSpec 生成和管理 Spec,然后用 Superpowers 这个"资深工程师"来执行 Spec。

单独使用各有短板,二者结合刚好形成 “规格驱动开发(SDD) + 测试驱动开发(TDD)” 的完整工作流。

  • OpenSpec (SDD)对齐设计需求,在编码前生成结构化变更提案(proposal)、需求规范(specs)、技术设计(design)和任务拆解(tasks)
  • Superpowers (TDD)保证代码质量,强制 AI 遵循 TDD、YAGNI、DRY 等工程原则

单独使用 OpenSpec 时工作流是这样的:openspec-workflow.jpg流程简单,文档驱动,但代码实现阶段(apply)的质量保障较弱

单独使用 Superpowers 时的工作流是这样的:

superpowers-workflow.jpg产出代码质量高,但前期的需求输入和最终的设计决策缺乏一个结构化的、可长期保存的载体,容易丢失在聊天历史中。

二者协作后的工作流是这样的:combined-workflow.jpg

就是使用 /superpowers:brainstorm → /superpowers:write-plan → /superpowers:subagent-driven-development 来替代比较弱的 /opsx:apply 以提升代码质量。

安装与使用

OpenSpec

安装

OpenSpec 直接通过 npm 安装

1
2
# openspec
npm install -g @fission-ai/openspec@latest

然后在项目根目录进行初始化

1
openspec init

需要在项目目录下执行 init 命令初始化,初始化之后就可以在 claude 终端里使用了。

之后,重新打开 claude 就可以用上 OpenSpec 了。

OpenSpec 使用整体流程就是围绕 proposal 的:

  • 创建 proposal:根据需求生成完整的 spec
  • 应用 proposal:定义好 spec 之后就开始实现
  • 归档 proposal:实现完成后对 proposal 进行归档,至此这个任务就算是完成了。

创建 proposal

1
2
# /opsx:propose "your idea"
/opsx:propose "创建一个 Todo 应用"

命令执行完成后会自动生成:openspec/changes/create-todo-app/ 目录、提案文档、设计模板、任务清单

应用 proposal

当 proposal 完全确定下来之后,就可以让 AI 根据 Spec 一步一步实现了。

1
2
# /openspec:apply $proposal
/opsx:apply

归档 proposal

当 proposal 实现完成后,就可以进行归档保存。

1
2
# /opsx:archive $proposal
/opsx:archive add-task-priority

Superpowers

安装

Superpowers Claude Code 插件

1
2
3
# 在 claude code 终端执行
/plugin marketplace add https://github.com/obra/superpowers-marketplace.git
/plugin install superpowers@superpowers-marketplace

使用

Superpowers 安装后会在 Claude Code 中注册多个技能,可手动触发,部分环节会自动流转,比如最常见的几个 Skill:

  • brainstorming:头脑风暴,澄清需求和技术细节
  • write-plan:基于 brainstorming 结果生成实现计划,brainstorming 完成后自动进入
  • subagent-driven-development:按计划启动子代理逐任务实现,强制 TDD + 两阶段代码审查,plan 写好后自动开始

Demo

OpenSpec 生成 Spec

第一步:使用 OpenSpec 生成初步 Spec

1
2
3
4
5
6
7
/opsx:propose "创建一个单页网页版Todo应用。它必须包含以下核心功能:
1. 一个输入框和一个'添加'按钮,用于创建新任务。
2. 每个任务项显示为一行,包含任务文本。
3. 每个任务项前有一个复选框,点击可将任务标记为'已完成'(视觉上需有区分,如删除线)。
4. 每个任务项旁有一个'删除'按钮,点击可移除该任务。
5. 所有任务数据必须在浏览器刷新后依然保留(使用localStorage)。
额外要求:代码结构应清晰,便于未来添加'筛选'或'优先级'等功能。"

效果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
⏺ 所有产物已创建。以下是总结:

 变更:web-todo-app
 位置:openspec/changes/web-todo-app/

 已创建产物:
 - proposal.md — 动机:浏览器原生 Todo 应用,具备 localStorage
 持久化功能,可作为未来扩展(筛选、优先级)的基础
 - design.md — 技术方法:原生 JS,3 文件结构(index.html + style.css +
 app.js),集中状态与显式重渲染周期,时间戳 ID
 - specs/ — 3 个能力规范:
 - task-management — 创建、切换完成状态、删除任务
 - task-persistence — localStorage 保存/加载,处理损坏数据
 - task-ui — 输入表单、任务列表渲染(删除线已完成)、空状态、键盘支持
 - tasks.md — 8 组共 21 个实现任务,涵盖项目结构 → 集成验证

 所有产物已创建!已准备好进行实现。运行 /opsx:apply 以开始实现。

Superpowers 细化 Spec

第二步:使用 Superpowers 继续细化上一步的 Spec

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
读取以下 OpenSpec change 文件(必须完整读取,不要摘要):
- openspec/changes/<change-name>/proposal.md
- openspec/changes/<change-name>/design.md
- openspec/changes/<change-name>/tasks.md
- openspec/changes/<change-name>/specs/ 下所有 spec.md(如有)

然后使用 superpowers 进行头脑风暴,深度技术设计,并传入以下上下文:

---
Change: <change-name>
上游需求(来自 OpenSpec,不要重写):
- 目标:<从 proposal.md 提取>
- 架构约束:<从 design.md 提取>
- 任务边界:<从 tasks.md 提取>

约束:
1. OpenSpec 是需求的事实源,不要重新定义需求,不要重写 proposal/spec
2. 你的任务是基于已有需求做深度技术设计:实现方案、技术风险、测试策略、边界条件
3. 如发现 delta spec 缺少验收场景,只能回写 OpenSpec delta spec,不要在 Design Doc 中创建第二份需求 spec
4. 跳过上下文探索,直接进入设计提问
---

完成后产出:
- Design Doc → docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md
 (frontmatter 包含:change: <change-name>, role: technical-design, canonical_spec: openspec)
- delta spec 补充(如有)→ openspec/changes/<change-name>/specs/<capability>/spec.md

输出如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
产出完成。

 Design Doc → docs/superpowers/specs/2026-05-26-web-todo-app-design.md
 - 7 个章节:实现方案(状态流、ID 生成、持久化、渲染策略、事件委托、添加流程、存储告警)、技术风
险矩阵、测试策略、边界条件表、扩展性分析

 Delta Spec 补充 → 回写到 OpenSpec 三个 spec 文件,新增 5 个缺失场景:

 ┌──────────────────┬──────────────────────────┬──────────────────────────────────┐
 │ Spec │ 新增场景 │ 原因 │
 ├──────────────────┼──────────────────────────┼──────────────────────────────────┤
 │ task-management │ whitespace-only 输入拒绝 │ "empty" 定义不完整 │
 ├──────────────────┼──────────────────────────┼──────────────────────────────────┤
 │ task-persistence │ localStorage 写入失败 │ 隐私模式/配额是真实故障路径 │
 ├──────────────────┼──────────────────────────┼──────────────────────────────────┤
 │ task-persistence │ 部分有效数据恢复 │ 单条损坏不应导致整个列表丢失 │
 ├──────────────────┼──────────────────────────┼──────────────────────────────────┤
 │ task-ui │ 渲染 HTML/script 内容 │ XSS 是安全关键边界 │
 ├──────────────────┼──────────────────────────┼──────────────────────────────────┤
 │ task-ui │ Enter 键空输入拒绝 │ 键盘路径需要与按钮路径相同的守卫 │
 └──────────────────┴──────────────────────────┴──────────────────────────────────┘

 关键技术决策摘要:
 - 渲染:DOM API (createElement + textContent),绝不用 innerHTML — 从根本上防止 XSS
 - 事件:事件委托(dataset.action 派发)— 配合 full re-render 模式,避免监听器泄漏
 - ID:Date.now().toString(36) + 随机后缀 — 比纯时间戳更抗碰撞
 - 持久化失败:try/catch + 非阻塞告警横幅,应用在内存中保持正常运行

Superpowers Write Plan

第三步:使用 Superpowers write plan,根据 Design 文档生成 Plan。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
使用 superpowers writing-plans,基于 Design Doc 创建实现计划。

计划要求:
- 保存至 docs/superpowers/plans/YYYY-MM-DD-<feature>.md
- 引用设计文档,拆分为可执行任务
- Plan 文件头必须包含:
 ---
 change: <openspec-change-name>
 design-doc: docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md
 base-ref: <先运行 git rev-parse HEAD 记录当前提交>
 ---

计划创建后:
1. 创建新分支:git checkout -b <change-name>
2. 使用 superpowers subagent-driven-development 执行实现

逐任务实现,每完成一个任务:
1. 在 tasks.md 中勾选([ ][x]2. 提交代码,commit message 体现设计意图

实现中发现 spec 不完整时的处理:
- 缺少验收场景/边界条件 → 直接编辑 delta spec + design.md,追加 tasks.md 任务
- 接口变更/新组件/数据流变化 → 重新 superpowers brainstorming 更新 Design Doc
- 全新能力需求 → /opsx:new 创建新的 OpenSpec change
- 新增任务超过初始任务数的 50% → 考虑拆分为新 change

Superpowers 实现

第四步:使用 Superpowers 的 subagent-driven-development 实现

输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
⏺ 计划已创建。现在创建分支并提交计划。

⏺ Bash(git checkout -b web-todo-app)
 ⎿ Switched to a new branch 'web-todo-app'

⏺ Bash(git add docs/superpowers/plans/2026-05-26-web-todo-app.md && git commit -m "chore: add
 implementation plan for web-todo-app")
 ⎿ ok 1 file changed, 158 insertions(+)
 ok web-tod

⏺ 现在使用 subagent 驱动的开发来执行实现。每个任务都有一个专门的 agent。我将逐个任务地开始执行。

⏺ 开始执行。T1–T3
 是无依赖的骨架任务,我将按顺序实现它们(它们共享项目上下文,子代理将需要完整设计)。让我从 T1
 开始。

⏺ Agent(T1: Create index.html)
 ⎿ Initializing…

✢ Creating HTML structure… (1m 39s · ↓ 2.8k tokens)
 ⎿ ◼ T1: HTML structure
 ◻ T9: Integration verification
 ◻ T4: State operations + ID generation
 ◻ T5: Persistence layer
 ◻ T8: Completed task styling
 … +4 pending

......
......

 ⏺ 所有9项任务已实现并提交。这是摘要:

 分支:web-todo-app (11 次提交)

 交付物:
 - 实现计划:docs/superpowers/plans/2026-05-26-web-todo-app.md
 - 设计文档:docs/superpowers/specs/2026-05-26-web-todo-app-design.md
 - 增量规范(5个新场景已回写):
 - task-management:空白输入拒绝
 - task-persistence:写入失败 + 部分有效数据恢复
 - task-ui:XSS 安全渲染 + 回车键空输入保护
 - 应用文件:index.html + style.css + app.js

 应用内关键设计决策:
 - textContent (从不 innerHTML) — XSS 安全
 - 事件委托,通过 dataset.action — 与全量重新渲染兼容,无监听器泄漏
 - generateId() = base-36 时间戳 + 随机后缀 — 碰撞安全
 - try/catch 在 saveTasks() 中 — 隐私浏览/配额失败 → 非阻塞横幅
 - isValidTask() 过滤器在 loadTasks() 中 — 部分损坏不会破坏整个列表

验证

第五步:OpenSpec + Superpowers 双重验证,分别验证是否完成。

1
2
使用 openspec-verify-change 做完整验证,然后 superpowers 的 finishing-a-development-branch
处理分支。验证不通过则修复。

OpenSpec 归档

第六步:OpenSpec 归档 Spec,纯 OpenSpec 操作:

1
/opsx:archive

所有变更将被保存至 openspec/changes/archive/,支持版本追溯与团队共享。

Comet 工具分享

之前按照 Demo 中的流程,通过 Prompt 串联基本实现了 OpenSpec + Superpowers 的协同工作流。

然后刚好最近在 Github 上发现了 Comet 工具,它把整个协作流程自动化了。

comet-runner.png

Comet 工作流

Comet 将 OpenSpec、Superpowers 串联为五阶段自动化流水线:

  1. Open(打开变更):openspec-explore + openspec-propose,生成 proposal、specs、design、tasks
  2. Design(深度设计):superpowers:brainstorm + writing-plans,细化技术方案,补充缺失场景
  3. Build(实现):superpowers:subagent-driven-development,TDD 实现 + 两阶段代码审查
  4. Verify(验证):openspec-verify-change + superpowers:finishing-a-development-branch,双重验证
  5. Archive(归档):openspec-archive-change,同步 delta spec、标注状态

同时 Comet 注册了以下技能,其中 5 个主流程技能对应协同工作流的各个阶段,另外 2 个是快捷路径:

  • /comet:主入口,自动检测当前阶段并分派到对应子命令
  • /comet-open:阶段 1 — 打开变更(提案、设计、任务分解)
  • /comet-design:阶段 2 — 深度设计(头脑风暴、设计文档)
  • /comet-build:阶段 3 — 规划与构建(实现计划、代码提交)
  • /comet-verify:阶段 4 — 验证与完成(测试、验证报告)
  • /comet-archive:阶段 5 — 归档(delta spec 同步、状态标注)
  • /comet-hotfix:快捷路径 — 快速 bug 修复,跳过头脑风暴,不需要能力设计
  • /comet-tweak:快捷路径 — 小改动,如文案调整、配置调整、文档或 Prompt 优化

一般我们只会用到/comet 命令,由 Comet 根据当前状态自动判断下一步该执行什么。

同时为了提升效率,对于一些小改动 comet 也提供了 /comet-hotfix/comet-tweak 命令。

安装

comet 通过 npm 安装

1
npm install -g @rpamis/comet

使用

首先到项目根目录进行初始化

1
2
cd your-project
comet init

然后直接使用 comet 命令即可。

1
/comet "帮我实现一个 TODO 应用"

comet 会自动按照 open-design-build-verify-archive五阶段工作流执行。

这里以 kcctl 命令优化为例,展示具体流程:

开始

首先会判断当前处于什么阶段,这里是新需求,所以是重头开始

1
2
3
4
5
6
$ /comet "帮我优化 kcctl 中的 operation 和 logs 命令,希望提高日志展示上的可读性"

# 先自动检测当前阶段
- 按照 Comet 流程,先检测活跃 Change 和 Preset。
# 判断,如果是 hotfix、tweak 会走简化流程
- 没有活跃 Change,这不是 hotfix 也不是 tweak(是功能优化改进),需要进入 /comet-open 创建新变更。

阶段一:Open

然后自动进入第一阶段 Open,也就是 openspec-explore + openspec-propose 生成 Spec。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 进入第一阶段 open
- 按照 comet-open 流程,先探索问题空间。

⏺ Skill(openspec-explore)
 ⎿  Successfully loaded skill
 
⏺ Bash(openspec new change "improve-kcctl-operation-logs")
 ⎿  - Creating change 'improve-kcctl-operation-logs'...
 ✔ Created change 'improve-kcctl-operation-logs' at openspec/changes/improve-kcctl-operation-logs/ (schema: spec-driven)
 
 ⏺ 设计和规范已就绪,并行创建。
 ⏺ Change 创建完成。现在初始化 comet 状态并运行 open 阶段守卫。

阶段完成后会运行阶段守卫(就是一个检测脚本),确保当前阶段真的完成了。当前 open 阶段则是判断 OpenSpec 的 Spec(proposal、specs、design、tasks) 是否正常生成了,如果检测发现没有则不会进入下一阶段。

通过脚本硬性检测,保证 AI 不会漏掉任何一个阶段。

阶段二:Design

检测通过后自动进入第二阶段,Superpowers brainstorming:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 进入第二阶段 desgin
Open 阶段完成,自动流转到 design 阶段。继续 /comet-design。

⏺ 交接包已生成。现在进入 brainstorming 阶段做深度技术设计。

⏺ Skill(brainstorming)
 ⎿  Successfully loaded skill

⏺ 我先阅读一下交接上下文,并探究当前的代码库结构。

⏺ Explore(Explore kcctl command patterns)
 ⎿  Done (18 tool uses · 57.7k tokens · 1m 43s)
 (ctrl+o to expand)

✽ Offering visual companion… (5m 47s · ↑ 8.4k tokens)
 ⎿  ✔ Explore project context for operation/logs design
 ◼ Offer visual companion for TUI layout questions
 ◻ Spec self-review
 ◻ Write design doc
 ◻ User reviews spec
 
⏺ 好,先启动了可视化服务器。现在进入第一个设计问题。

 TUI 日志查看界面的布局是核心设计决策。spec 里写的是"步骤列表在左,日志内容在右"的分栏布局,但还有别的可能性。让我用浏览器展示几个布局方案给你比较。
 
⏺ 浏览器里展示了 3 种 TUI 布局方案,请打开 http://localhost:59683 查看:

 - A: Split Panel — 左侧步骤列表,右侧日志内容,点步骤切换日志
 - B: Stacked — 顶部步骤横排,下方日志区,纵向日志空间更大
 - C: Tree View — 树形展开折叠,日志内嵌在步骤下方,最简洁

 看看哪个直觉上最好用?

✻ Sautéed for 1m 19s

❯ 方案 A 

⏺ 设计方案已基本成型,让我整理一下完整设计然后给你确认。


⏺ 好,现在写 Design Doc。
⏺ Design Doc 已写入 docs/superpowers/specs/2026-05-27-kcctl-operation-logs-design.md。请审阅一下,有要改的告诉我,没问题的话我们进入实施计划。

❯ 继续吧

这个就是 Superpowers 头脑风暴的优点,可以确认很多细节上的需求,避免后续做出来不是自己想要的。

阶段三:Build

然后进入第三阶段:build,使用 Superpowers 的 subagent-driven-development 开始实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
⏺ Design 阶段通过,自动流转到 build。现在调用 comet-build。

⏺ Skill(comet-build)
 ⎿  Successfully loaded skill
 
⏺ Step 1: 制定实施计划。
⏺ Step 3: 工作区隔离。这个变更涉及 ~15 个文件(新增 pkg/cli/operation/ 整个目录 + 修改 api.go/root.go/logs.go),推荐创建分支。

⏺ Step 4: 选择执行方式。计划有 20 个任务,跨 5 个 phase,推荐 subagent-driven-development。
执行方式?计划有 20 个任务,跨 5 个 phase,推荐 subagent 模式。

❯ 1. Subagent-driven (Recommended)
 任务独立、复杂度高,子 agent 并行执行 + 双阶段审查。适合 20 个任务的规模
 2. Executing-plans
 任务简单、轻量快速,顺序执行。适合小规模
 3. Type something.
 

⏺ 好的,我现在已经掌握了所有上下文。让我创建任务追踪并开始分派子代理。任务1.1-1.4是小型API客户端的修改,可以作为一个批次处理;2.1-2.5是操作子命令;3.1-3.4是纯文本日志;4.1-4.6是TUI;5.1-5.4是测试

⏺ 开始第一阶段。正在派遣实现者子代理执行 API 客户端扩展。

✢ Implementing API client extensions… (2m 9s · ↓ 3.9k tokens · almost done thinking)
 ⎿  ◼ Phase 1: API Client Extensions (1.1-1.4)
 ◻ Phase 2: Operation Subcommand Framework (2.1-2.5)
 ◻ Phase 3: Operation Logs Plain-Text Mode (3.1-3.4)
 ◻ Phase 4: TUI Log Viewer (4.1-4.6)
 ◻ Phase 5: Tests (5.1-5.4)
....
✽ Pontificating… (42m 30s · ↓ 68.8k tokens · almost done thinking)
 ⎿  Tip: Use /clear to start fresh when switching topics and free up context
 
 
- 所有 5 个阶段都已完成。让我进行最终的构建+测试验证,然后运行构建守卫。

这一阶段耗时会比较长,AI 会根据前面的计划挨个实现,用上 Superpowers 之后,代码质量比直接 opsx:apply 会好不少。

阶段四:Verify

Build 完成后会自动进入 Verify 阶段,对照 Spec 以及代码,确认所有功能都已经实现完成。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
⏺ Build 阶段全部通过,自动流转到 verify。进入 comet-verify。
⏺ 已评估为完整验证模式。让我再用 base-ref 复核提交区间。

⏺ 所有制品都已完成。现在让我根据完整的验证检查清单手动执行完整的验证。

⏺ 验证发现了 1 个 FAIL 和 2 个 minor 问题。主要问题是 kcctl operation list 缺少 sponsor 列。让我修复。


⏺ 所有验证问题已修复。现在运行收尾流程。

⏺ Skill(finishing-a-development-branch)
 ⎿  Successfully loaded skill

发现问题也会自动进行修复

阶段五:Archive

最后就是归档了,Verify 之后自动流转到 Archive:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
⏺ Verify 阶段通过,自动流转到 archive。进入最后阶段。

⏺ Skill(comet-archive)
 ⎿  Successfully loaded skill
 
⏺ 归档完成,8/8 步骤全部成功。

 brainstorming → delta spec → 实施 → 验证 → 主 spec 覆盖 → design doc 标注 → 归档 ✓

 Comet 流程全部完成。

至此,整个流程就完成了。

小结

OpenSpec + Superpowers 组合的核心思想是:用 OpenSpec 生成和管理 Spec,然后用 Superpowers 这个"资深工程师"来执行 Spec。

单独使用各有短板,二者结合刚好形成 “规格驱动开发(SDD) + 测试驱动开发(TDD)” 的完整工作流。

  • OpenSpec (SDD)对齐设计需求,在编码前生成结构化变更提案(proposal)、需求规范(specs)、技术设计(design)和任务拆解(tasks)
  • Superpowers (TDD)保证代码质量,强制 AI 遵循 TDD、YAGNI、DRY 等工程原则

combined-workflow.jpg

如果觉得手动切换两个工具比较繁琐,可以尝试 Comet 将整个流程自动化。

DRA P4:从零开发自己的 DRA 驱动

2026-05-27 04:00:00

dra-p4.jpg

前三篇我们完成了 DRA 的部署实战、核心概念拆解和工作流程分析。但一直使用的是 NVIDIA 官方的 DRA Driver,如果我们的硬件不是 GPU,或者只是想暴露自定义资源给 Pod 使用,该怎么办?

本文从零实现一个自定义 DRA Driver —— i-dra-driver,沿用之前 Device Plugin 文章 中的 “gopher” 资源隐喻,将节点上的文件作为设备暴露给 Pod。通过对比同一个功能在 DevicePlugin 和 DRA 两种框架下的实现差异,加深对 DRA 机制的理解。

完整源码: github.com/lixd/i-dra-driver

1. DevicePlugin vs DRA:实现差异对比

在开始编码之前,先通过一张表直观对比两种框架在实现同一个功能时的差异:

维度 DevicePlugin DRA
设备发现 ListAndWatch gRPC 上报设备列表 创建 ResourceSlice API 对象
资源可见性 Node capacity 字段(只有数量) ResourceSlice 对象(含完整属性)
调度参与 调度器不参与,kubelet 本地分配 调度器参与,PreFilter/Filter/Reserve
设备选择 只能按数量申请 CEL 表达式精确匹配属性
设备注入 Allocate 返回 env/mounts CDI spec 定义注入规则
注册方式 kubelet Registration gRPC plugins_registry socket
核心接口 ListAndWatch + Allocate PrepareResourceClaims + UnprepareResourceClaims
辅助库 无(直接实现 gRPC) k8s.io/dynamic-resource-allocation/kubeletplugin

一句话总结:DevicePlugin 是 “kubelet 本地管理”,DRA 是 “API Server 全局调度 + CDI 标准注入”。

DevicePlugin vs DRA

2. 设备发现与 ResourceSlice

2.1 设备发现

DevicePlugin 通过 ListAndWatch 流式上报设备,而 DRA Driver 需要将设备信息写入 ResourceSlice API 对象。

pkg/device/discovery.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type DeviceInfo struct {
 Name string
 Type string
 Size int64
}

func Discover(devicePath string) ([]DeviceInfo, error) {
 var devices []DeviceInfo

 info, err := os.Stat(devicePath)
 if err != nil {
 if os.IsNotExist(err) {
 klog.Warningf("device path %s does not exist", devicePath)
 return devices, nil
 }
 return nil, fmt.Errorf("stat device path %s failed: %w", devicePath, err)
 }

 err = filepath.WalkDir(devicePath, func(path string, d fs.DirEntry, err error) error {
 // ...遍历目录,每个文件作为一个设备...
 devices = append(devices, DeviceInfo{
 Name: d.Name(),
 Type: "gopher",
 Size: fi.Size(),
 })
 return nil
 })
 return devices, err
}

与 DevicePlugin 的区别

  • DevicePlugin 的 ListAndWatch 是一个长连接流,设备变化时主动推送
  • DRA Driver 的 Discover 是一个普通函数,调用后返回当前设备列表,变化通过定期 rescan 或后续更新 ResourceSlice 来体现

2.2 构建 ResourceSlice

pkg/device/resourceslice.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func BuildDriverResources(nodeName string, devices []DeviceInfo) resourceslice.DriverResources {
 driverDevices := make([]resourceapi.Device, 0, len(devices))

 for _, dev := range devices {
 driverDevices = append(driverDevices, resourceapi.Device{
 Name: dev.Name,
 Attributes: map[resourceapi.QualifiedName]resourceapi.DeviceAttribute{
 resourceapi.QualifiedName(common.DriverName + "/type"): {
 StringValue: ptrTo(dev.Type),
 },
 },
 Capacity: map[resourceapi.QualifiedName]resourceapi.DeviceCapacity{
 resourceapi.QualifiedName(common.DriverName + "/size"): {
 Value: *resource.NewQuantity(dev.Size, resource.DecimalSI),
 },
 },
 })
 }

 return resourceslice.DriverResources{
 Pools: map[string]resourceslice.Pool{
 nodeName: {
 Slices: []resourceslice.Slice{
 {Devices: driverDevices},
 },
 },
 },
 }
}

关键点

  • Attributes 存放字符串/布尔/版本等设备属性,可用于 CEL 选择器筛选
  • Capacity 存放数值型容量信息(如文件大小、GPU 显存)
  • 这比 DevicePlugin 只能上报数量(nvidia.com/gpu: "4")强大得多

3. DRAPlugin 接口实现

这是 DRA Driver 的核心:实现 kubeletplugin.DRAPlugin 接口的三个方法。

3.1 接口定义

1
2
3
4
5
6
7
type DRAPlugin interface {
 PrepareResourceClaims(ctx context.Context, claims []*resourceapi.ResourceClaim) (
 map[types.UID]PrepareResult, error)
 UnprepareResourceClaims(ctx context.Context, claims []NamespacedObject) (
 map[types.UID]error, error)
 HandleError(ctx context.Context, err error, msg string)
}

3.2 PrepareResourceClaims

当 kubelet 需要为 Pod 准备设备时调用。我们需要:

  1. 从 ResourceClaim 的 status.allocation 中提取已分配的设备
  2. 为这些设备创建 CDI spec
  3. 返回 CDI device ID 列表
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func (p *GopherDRAPlugin) PrepareResourceClaims(ctx context.Context,
 claims []*resourceapi.ResourceClaim) (map[types.UID]kubeletplugin.PrepareResult, error) {

 result := make(map[types.UID]kubeletplugin.PrepareResult)

 for _, claim := range claims {
 // 提取已分配的 gopher 设备名称
 deviceNames := p.extractAllocatedDevices(claim)

 // 创建 CDI spec 文件,返回 CDI device ID
 cdiDeviceIDs, err := p.cdiHandler.CreateClaimSpec(
 string(claim.UID), deviceNames)

 // 构建返回结果
 for _, req := range claim.Status.Allocation.Devices.Results {
 if req.Driver == common.DriverName {
 devices = append(devices, kubeletplugin.Device{
 Requests: []string{req.Request},
 PoolName: req.Pool,
 DeviceName: req.Device,
 CDIDeviceIDs: cdiDeviceIDs,
 })
 }
 }

 result[claim.UID] = kubeletplugin.PrepareResult{Devices: devices}
 }
 return result, nil
}

与 DevicePlugin Allocate 的区别

DevicePlugin Allocate DRA PrepareResourceClaims
输入 容器维度(ContainerAllocateRequest) ResourceClaim 维度(可能跨容器)
设备确定 kubelet 本地决定 调度器已分配,status.allocation 指定
注入方式 返回 env/mounts/devices 返回 CDI device ID
幂等性 非强制 必须幂等

3.3 UnprepareResourceClaims

Pod 删除时调用,清理 CDI spec:

1
2
3
4
5
6
7
8
9
func (p *GopherDRAPlugin) UnprepareResourceClaims(ctx context.Context,
 claims []kubeletplugin.NamespacedObject) (map[types.UID]error, error) {

 result := make(map[types.UID]error)
 for _, claim := range claims {
 result[claim.UID] = p.cdiHandler.DeleteClaimSpec(string(claim.UID))
 }
 return result, nil
}

注意:此方法只收到 NamespacedObject(UID + Name + Namespace),拿不到完整的 ResourceClaim 对象,因为 claim 可能已被删除。必须幂等

3.4 HandleError

辅助库的后台错误回调:

1
2
3
4
5
6
7
func (p *GopherDRAPlugin) HandleError(ctx context.Context, err error, msg string) {
 if errors.Is(err, kubeletplugin.ErrRecoverable) {
 klog.Warningf("recoverable error: %s: %v", msg, err)
 return
 }
 klog.Fatalf("fatal error: %s: %v", msg, err)
}

可恢复错误(如 ResourceSlice 更新暂时失败)只记录日志,致命错误则退出进程。

4. CDI 设备注入

CDI (Container Device Interface) 是 DRA 的设备注入机制。Driver 创建 CDI spec 文件,容器运行时读取后按 spec 将设备注入容器。

对于我们的 “gopher” 设备,CDI spec 主要负责注入环境变量。文件挂载通过 Pod 的 hostPath volume 实现(因为普通文件的 bind mount 通过 CDI 注入时存在兼容性问题,hostPath volume 是更可靠的方式)。

pkg/cdi/handler.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (h *Handler) CreateClaimSpec(claimUID string, deviceNames []string) ([]string, error) {
 spec := &cdispec.Spec{
 Kind: h.kind(), // "gopher.example.com/gopher"
 Devices: []cdispec.Device{},
 }

 var cdiDeviceIDs []string
 for _, devName := range deviceNames {
 cdiDeviceName := fmt.Sprintf("%s-%s", claimUID, devName)
 cdiDeviceID := fmt.Sprintf("%s/%s=%s", h.vendor, h.class, cdiDeviceName)
 cdiDeviceIDs = append(cdiDeviceIDs, cdiDeviceID)

 device := cdispec.Device{
 Name: cdiDeviceName,
 ContainerEdits: cdispec.ContainerEdits{
 Env: []string{
 fmt.Sprintf("GOPHER=%s", strings.Join(deviceNames, ",")),
 },
 },
 }
 spec.Devices = append(spec.Devices, device)
 }

 specName := cdiapi.GenerateTransientSpecName(h.vendor, h.class, claimUID)
 return cdiDeviceIDs, h.cache.WriteSpec(spec, specName)
}

与 DevicePlugin Allocate 的注入方式对比

DevicePlugin DRA + CDI
环境变量 Allocate 返回 Envs map CDI spec 的 ContainerEdits.Env
文件挂载 Allocate 返回 Mounts CDI spec 的 ContainerEdits.Mounts 或 Pod hostPath volume
设备节点 Allocate 返回 Devices CDI spec 的 ContainerEdits.DeviceNodes
标准 Kubernetes 私有 OCI 标准规范

CDI 是开放容器标准,不绑定 Kubernetes,容器运行时原生支持。对于 GPU 等真实硬件设备,CDI 的 DeviceNodesMounts 是首选注入方式;对于文件类的简单设备,环境变量 + hostPath volume 的组合更实用。

5. 主入口

cmd/main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func main() {
 // 解析参数
 flag.StringVar(&nodeName, "node-name", "", "Node name (or NODE_NAME env)")
 flag.StringVar(&devicePath, "device-path", "/etc/gophers", "Device path")
 flag.DurationVar(&rescanInterval, "rescan-interval", 60*time.Second, "Rescan interval")

 // 创建 k8s 客户端
 cfg, _ := rest.InClusterConfig()
 kubeClient, _ := kubernetes.NewForConfig(cfg)

 // 创建 CDI handler
 cdiHandler, _ := cdi.NewHandler(driverName, "gopher.example.com", "gopher", devicePath)

 // 创建 DRA Plugin
 draPlugin := plugin.NewGopherDRAPlugin(cdiHandler)

 // 启动 DRA Plugin Helper(注册到 kubelet + gRPC server)
 helper, _ := kubeletplugin.Start(ctx, draPlugin,
 kubeletplugin.DriverName(driverName),
 kubeletplugin.KubeClient(kubeClient),
 kubeletplugin.NodeName(nodeName),
 )
 defer helper.Stop()

 // 发现设备并发布 ResourceSlice
 devices, _ := device.Discover(devicePath)
 resources := device.BuildDriverResources(nodeName, devices)
 helper.PublishResources(ctx, resources)

 // 定期 rescan
 go rescanLoop(ctx, helper, nodeName, devicePath, rescanInterval)

 <-ctx.Done()
}

与 DevicePlugin 启动流程对比

步骤 DevicePlugin DRA Driver
启动 gRPC Server 手动创建 grpc.Server + 监听 unix socket kubeletplugin.Start 自动处理
注册到 Kubelet 手动连接 KubeletSocket 发送 RegisterRequest kubeletplugin.Start 自动处理
发布设备信息 ListAndWatch 流式推送 helper.PublishResources() 创建 ResourceSlice
监听 Kubelet 重启 手动 fsnotify 监听 socket 辅助库自动处理

DRA 的 kubeletplugin.Start 一行代码就解决了 DevicePlugin 需要几十行代码处理的注册和生命周期管理。

6. 部署与测试

以下在 Kubernetes v1.35.0 + containerd v1.7.29 的单节点集群上实际部署验证。

6.1 构建镜像

1
2
3
4
cd i-dra-driver
make build-image
# 或
docker build -t docker.io/lixd96/i-dra-driver:latest .

6.2 前置条件

  • Kubernetes 1.32+ 且启用 DRA feature gate(v1.34+ 已默认启用)
  • Container Runtime 启用 CDI(containerd v1.7+ 默认启用)

6.3 准备设备文件

在节点上创建测试用的 gopher 设备文件:

1
2
3
mkdir -p /etc/gophers
echo 'hello from gopher-a' > /etc/gophers/gopher-a
echo 'hello from gopher-b' > /etc/gophers/gopher-b

6.4 部署 DRA Driver

1
2
3
4
5
6
7
8
# RBAC
kubectl apply -f deploy/rbac.yaml

# DaemonSet
kubectl apply -f deploy/daemonset.yaml

# DeviceClass
kubectl apply -f deploy/deviceclass.yaml

验证 Driver Pod 已启动:

1
2
3
$ kubectl get pod -n kube-system -l app=i-dra-driver
NAME READY STATUS RESTARTS AGE
i-dra-driver-t654d 1/1 Running 0 9s

查看 Driver 日志,确认设备发现和 ResourceSlice 发布成功:

1
2
3
4
5
6
$ kubectl logs -n kube-system -l app=i-dra-driver
I0520 04:07:44] starting i-dra-driver: node=ecs-a10-sh driver=gopher.example.com device-path=/etc/gophers rescan=1m0s
I0520 04:07:44] discovered device: gopher-a (size=20)
I0520 04:07:44] discovered device: gopher-b (size=20)
I0520 04:07:45] published initial ResourceSlice with 2 devices
I0520 04:07:45] i-dra-driver started successfully

6.5 验证 ResourceSlice

1
2
3
$ kubectl get resourceslice | grep gopher
NAME NODE DRIVER POOL AGE
00000-gopher.example.com-ecs-a10-sh-nj7lm ecs-a10-sh gopher.example.com ecs-a10-sh 18s

查看详情,确认设备属性和容量信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ kubectl get resourceslice 00000-gopher.example.com-ecs-a10-sh-nj7lm -oyaml
apiVersion: resource.k8s.io/v1
kind: ResourceSlice
spec:
 devices:
 - attributes:
 gopher.example.com/type:
 string: gopher
 capacity:
 gopher.example.com/size:
 value: "20"
 name: gopher-a
 - attributes:
 gopher.example.com/type:
 string: gopher
 capacity:
 gopher.example.com/size:
 value: "20"
 name: gopher-b
 driver: gopher.example.com
 nodeName: ecs-a10-sh
 pool:
 generation: 1
 name: ecs-a10-sh
 resourceSliceCount: 1

可以看到,与 DevicePlugin 只能在 Node capacity 上显示 nvidia.com/gpu: "4" 不同,DRA 的 ResourceSlice 记录了每个设备的完整属性(type=gopher)和容量(size=20)。

6.6 验证 DeviceClass

1
2
3
$ kubectl get deviceclass | grep gopher
NAME AGE
gopher.example.com 9m

查看 DeviceClass 详情,确认 CEL 选择器:

1
2
3
4
5
6
7
8
9
$ kubectl get deviceclass gopher.example.com -oyaml
apiVersion: resource.k8s.io/v1
kind: DeviceClass
spec:
 selectors:
 - cel:
 expression: >-
 device.driver == 'gopher.example.com'
 && device.attributes['gopher.example.com'].type == 'gopher'

CEL 表达式定义了"什么样的设备属于这个 Class":driver 是 gopher.example.com 且 type 是 gopher。用户申请资源时只需指定 deviceClassName: gopher.example.com,不需要关心具体的设备名。

6.7 运行测试 Pod

1
kubectl apply -f deploy/test-pod.yaml

验证 Pod 状态:

1
2
3
$ kubectl get pod gopher-test-pod -o wide
NAME READY STATUS RESTARTS AGE IP NODE
gopher-test-pod 1/1 Running 0 15s 172.25.20.52 ecs-a10-sh

查看 ResourceClaim 分配结果:

1
2
3
$ kubectl get resourceclaim
NAME STATE AGE
gopher-test-pod-gopher-claim-9chj8 allocated,reserved 28s

状态为 allocated,reserved,说明调度器已成功分配设备并预留。查看分配详情:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$ kubectl get resourceclaim gopher-test-pod-gopher-claim-9chj8 -oyaml
status:
 allocation:
 devices:
 results:
 - device: gopher-a  # 分配了 gopher-a 设备
 driver: gopher.example.com
 pool: ecs-a10-sh
 request: gopher
 nodeSelector:
 nodeSelectorTerms:
 - matchFields:
 - key: metadata.name
 operator: In
 values:
 - ecs-a10-sh  # 绑定到 ecs-a10-sh 节点
 reservedFor:
 - name: gopher-test-pod  # 预留给该 Pod

查看 Pod 日志,确认设备已成功注入:

1
2
3
4
5
$ kubectl logs gopher-test-pod
GOPHER env: gopher-a
Gopher device allocated successfully!
Device file: /etc/gophers/gopher-a
hello from gopher-a

环境变量 GOPHER=gopher-a 通过 CDI spec 注入,文件内容 hello from gopher-a 通过 hostPath volume 读取。整个 DRA 流程从设备注册到 Pod 使用,全链路打通!

进一步在 Pod 内部验证:

1
2
3
4
5
6
7
# CDI 注入的环境变量
$ kubectl exec gopher-test-pod -- env | grep GOPHER
GOPHER=gopher-a

# hostPath volume 挂载的设备文件
$ kubectl exec gopher-test-pod -- cat /etc/gophers/gopher-a
hello from gopher-a

6.8 全链路数据流回顾

从 Driver 启动到 Pod 使用设备,完整数据流如下:

1
2
3
4
5
6
7
8
9
Driver 扫描文件 → ResourceSlice(gopher-a, gopher-b)
Pod 提交 → ResourceClaimTemplate(申请 1 个 gopher)
调度器:匹配 DeviceClass → 选出 gopher-a → 绑定节点 ecs-a10-sh
Kubelet:NodePrepareResources → CDI spec 注入 GOPHER=gopher-a
容器启动:env GOPHER=gopher-a + hostPath /etc/gophers/gopher-a

对比 DevicePlugin 的流程:

1
2
3
4
5
6
7
8
9
DevicePlugin 扫描文件 → ListAndWatch 上报数量(2)→ Node capacity: gopher: "2"
Pod 提交 → resources.requests: gopher: "1"
调度器:只看数量够不够 → 随机选节点
Kubelet:Allocate → 返回 env GOPHER=gopher-a
容器启动:env GOPHER=gopher-a

核心区别:DRA 的调度器在调度阶段就选好了具体设备并绑定节点,不会出现 DevicePlugin 中"调度到节点后才发现资源不匹配"的问题。

7. 小结

本文通过实现 i-dra-driver,从代码层面对比了 DevicePlugin 和 DRA 的核心差异:

  1. 设备发现ListAndWatch gRPC 流 → ResourceSlice API 对象
  2. 调度参与:kubelet 本地分配 → 调度器全局调度
  3. 设备注入:私有 env/mounts → OCI 标准的 CDI spec
  4. 样板代码:手动管理 gRPC/kubelet 注册 → kubeletplugin 辅助库一行搞定

DRA 的设计理念是让设备信息对调度器可见、让设备注入遵循开放标准、让驱动开发者专注于业务逻辑。虽然目前只在 Kubernetes 1.34 GA,但作为 DevicePlugin 的继任者,DRA 是 Kubernetes 资源管理的未来方向。

系列回顾