MoreRSS

site iconLixueduan | 李学端修改

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

Inoreader Feedly Follow Feedbin Local Reader

Lixueduan | 李学端的 RSS 预览

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 资源管理的未来方向。

系列回顾

DRA P3:DRA 工作流程与源码分析

2026-05-21 04:00:00

dra-p3.jpg

前两篇我们完成了 DRA 的部署实战和核心概念拆解,知道了 ResourceSlice、DeviceClass、ResourceClaim 各自的职责和协作方式。

但还有一个问题没回答:从 Pod 提交到 GPU 可用,中间到底发生了什么?每个组件具体做了哪些事?

本篇逐阶段拆解 DRA 的端到端工作流,每个阶段结合 NVIDIA DRA Driver 源码分析,然后深入调度器分配算法。

1. 端到端工作流概览

以 P1 的 gpu-test-pod 为例,从 Pod 提交到 GPU 可用,完整流程分为六个阶段:

阶段 触发者 动作
1. 设备注册 DRA Driver NVML 扫描 GPU → 注册到 Kubelet → 发布 ResourceSlice
2. 分类定义 Admin / Helm 创建 DeviceClass(CEL 选择器)
3. 用户声明需求 User 创建 Pod + ResourceClaimTemplate → Controller 生成 ResourceClaim
4. 调度器分配 Scheduler PreFilter 构建分配器 → Filter 选节点 + 选设备 → Reserve → PreBind 持久化
5. 设备准备与注入 Kubelet → Driver → CDI NodePrepareResources → 生成 CDI 描述 → 容器运行时注入设备
6. Pod 运行与清理 Kubelet + Controller NodeUnprepareResources → Controller 清除分配 → 设备回可用

DRA 端到端工作流

下面逐阶段展开,每个阶段结合 NVIDIA DRA Driver 源码(cmd/gpu-kubelet-plugin/)分析。

2. 阶段一:设备注册

2.1 流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
DRA Driver(DaemonSet)启动
 ├─ 1. 调用 NVML 扫描节点上的 GPU 设备
 │ → 收集每个 GPU 的属性:型号、架构、显存、CUDA 版本、驱动版本、UUID...
 ├─ 2. 注册到 Kubelet(通过 plugins_registry socket)
 │ → Kubelet 发现 DRA Plugin,建立 gRPC 连接
 └─ 3. 发布 ResourceSlice 到 API Server
 → 构建 ResourceSlice 对象并发布,包含所有 GPU 设备及其属性
 → 调度器、Kubelet、用户都可以通过 kubectl 查看

和 DevicePlugin 的区别:DevicePlugin 通过 ListAndWatch() gRPC 把设备列表上报给 Kubelet,Kubelet 再更新到 Node 的 capacity 字段,只有数量。DRA Driver 直接创建 ResourceSlice 对象,包含完整的设备属性,调度器可见。

2.2 源码分析

NewDriver() 是整个驱动的初始化入口,核心步骤对应设备注册的全过程(省略了 DynamicMIG 处理、健康检查启动等中间步骤):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// cmd/gpu-kubelet-plugin/driver.go:70
func NewDriver(ctx context.Context, config *Config) (*driver, error) {
 // 1. 初始化 NVML,枚举所有设备
 state, err := NewDeviceState(ctx, config)
 // ...
 driver := &driver{
 state: state,
 pulock: flock.NewFlock(filepath.Join(config.DriverPluginPath(), DriverPrepUprepFlockFileName)),
 }
 // 2. 注册 DRA Plugin,暴露 Prepare/Unprepare 接口
 helper, err := kubeletplugin.Start(ctx, driver,
 kubeletplugin.DriverName(DriverName),
 kubeletplugin.Serialize(false), // 禁用自带序列化,改用 flock 控制
 // ...
 )
 driver.pluginhelper = helper
 // 3. 发布初始 ResourceSlice 到 API Server
 if err := driver.publishResources(ctx, config); err != nil {
 return nil, err
 }
 return driver, nil
}

设备发现的逻辑在 nvlib.go 中:

1
2
3
4
5
6
7
8
// cmd/gpu-kubelet-plugin/nvlib.go:174
func (l deviceLib) enumerateAllPossibleDevices() (*PerGPUAllocatableDevices, error) {
 perGPUAllocatable, err := l.GetPerGpuAllocatableDevices() // 遍历 NVML 获取所有物理 GPU
 if featuregates.Enabled(featuregates.PassthroughSupport) {
 err = l.enumerateGpuVfioDevices(perGPUAllocatable) // 额外枚举 VFIO PCI 设备
 }
 return perGPUAllocatable, nil
}

每个 GPU 的信息封装为 GpuInfo 结构体(deviceinfo.go),包含 UUID、型号、架构、显存、CUDA 计算能力、驱动版本、MIG 能力等完整属性。

对比 DevicePlugin:同样通过 NVML 发现设备,但结果只上报为 nvidia.com/gpu:4 这样一个整数。

ResourceSlice 发布由 publishResources() 完成,有两种模式:

  • DynamicMIG 模式:调用 GenerateDriverResources 生成资源,每张物理 GPU 一个 ResourceSlice(K8s 1.35+ 会进一步拆分为 G+1 个)
  • 普通模式:一个节点一个 ResourceSlice,包含所有 GPU 设备
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// cmd/gpu-kubelet-plugin/driver.go:455
func (d *driver) publishResources(ctx context.Context, config *Config) error {
 if featuregates.Enabled(featuregates.DynamicMIG) {
 resources := d.GenerateDriverResources(config.flags.nodeName)
 return d.pluginhelper.PublishResources(ctx, resources)
 }
 // 普通模式:所有设备放进一个 Slice
 var resourceSlice resourceslice.Slice
 for _, devices := range d.state.perGPUAllocatable.allocatablesMap {
 for _, device := range devices {
 resourceSlice.Devices = append(resourceSlice.Devices, device.GetDevice())
 }
 }
 resources := resourceslice.DriverResources{
 Pools: map[string]resourceslice.Pool{
 config.flags.nodeName: {Slices: []resourceslice.Slice{resourceSlice}},
 },
 }
 return d.pluginhelper.PublishResources(ctx, resources)
}

3. 阶段二:分类定义

管理员创建 DeviceClass,定义"什么样的设备属于这一类":

1
2
3
4
5
6
7
8
apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata:
 name: gpu.nvidia.com
spec:
 selectors:
 - cel:
 expression: device.driver == 'gpu.nvidia.com' && device.attributes['gpu.nvidia.com'].type == 'gpu'

CEL 表达式可以组合任意属性条件,例如只包含 A100:

1
2
3
4
5
6
7
spec:
 selectors:
 - cel:
 expression: |
 device.driver == 'gpu.nvidia.com' &&
 device.attributes['gpu.nvidia.com'].type == 'gpu' &&
 device.attributes['gpu.nvidia.com'].productName == 'NVIDIA A100'

这一步一般在安装 DRA Driver 时由 Helm Chart 自动创建,用户通常不需要手动操作。

4. 阶段三:用户声明需求

用户创建 ResourceClaimTemplate 和 Pod:

 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
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
 name: single-gpu
spec:
 spec:
 devices:
 requests:
 - name: gpu
 exactly:
 deviceClassName: gpu.nvidia.com
 allocationMode: ExactCount
 count: 1
---
apiVersion: v1
kind: Pod
metadata:
 name: gpu-test-pod
spec:
 containers:
 - name: cuda-container
 image: nvidia/cuda:12.1.0-base-ubuntu22.04
 command: ["nvidia-smi", "-L"]
 resources:
 claims:
 - name: gpu-claim
 resourceClaims:
 - name: gpu-claim
 resourceClaimTemplateName: single-gpu

Pod 提交后,ResourceClaim Controller 根据 Template 自动创建 ResourceClaim(Pod 作为 OwnerReference,垃圾回收自动清理),然后 Pod 进入调度队列。

ResourceClaim Controller 的逻辑:

  1. 创建 Claim:Pod 引用 ResourceClaimTemplate 时,Controller 为每个 claim 生成 ResourceClaim 对象,GenerateName 避免命名冲突,Pod 作为 OwnerReference 保证随 Pod 一起删除
  2. 补充预留:正常情况下调度器的 PreBind 已完成预留;Controller 作为回退,为已分配但因冲突等原因未预留的 Claim 补充添加 ReservedFor 条目
  3. 清理终止 Pod 的 Claim:当 Pod 终止后,移除 ReservedFor 条目;当 ReservedFor 为空时,清除分配并移除 Finalizer;基于模板生成的 Claim 最终由垃圾回收器删除(Controller 也会在确认 Pod 不再运行时主动删除)

ResourceClaim 的状态流转和回收机制如下图所示:

ResourceClaim 生命周期

5. 阶段四:调度器分配

这是 DRA 和 DevicePlugin 差异最大的阶段。DRA 的调度器实现了调度框架的多个扩展点:

DRA 调度器扩展点流程

调度器不仅选节点,还选定了具体设备。分配结果在调度阶段就确定了,写入 ResourceClaim.status.allocation,后续 Driver 和 Kubelet 都基于这个结果工作。

调度器内部有三层 Claim 跟踪机制防止并发冲突:

调度器三层 Claim 跟踪机制

对比 DevicePlugin:调度器只检查 nvidia.com/gpu 数量够不够,具体用哪张 GPU 由 Kubelet 本地的 Allocate() 决定。

5.1 调度器扩展点实现

DRA 调度器插件 DynamicResources 实现了调度框架的 9 个扩展点接口:PreEnqueue、PreFilter、Filter、PostFilter、Score、Reserve(含 Unreserve)、EnqueueExtensions、PreBind、SignPlugin。

PreEnqueue — 验证所有 ResourceClaim 存在且未被删除,不满足的 Pod 留在不可调度队列。

PreFilter — 收集所有 Claim、构建分配器,是调度周期中最重的准备工作:

 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
// pkg/scheduler/framework/plugins/dynamicresources/dynamicresources.go:444
func (pl *DynamicResources) PreFilter(ctx context.Context, state fwk.CycleState, pod *v1.Pod, nodes []fwk.NodeInfo) (*fwk.PreFilterResult, *fwk.Status) {
 // ...
 s := &stateData{}
 state.Write(stateKey, s)
 // 收集 Pod 引用的所有 ResourceClaim
 userClaims, err := pl.podResourceClaims(pod)
 // 处理扩展资源
 extendedResourceClaim, status := pl.preFilterExtendedResources(pod, logger, s)
 // 对已分配的 Claim,提取 NodeSelector
 for index, claim := range claims.all() {
 if claim.Status.Allocation != nil {
 if claim.Status.Allocation.NodeSelector != nil {
 nodeSelector, err := nodeaffinity.NewNodeSelector(claim.Status.Allocation.NodeSelector)
 s.informationsForClaim[index].availableOnNodes = nodeSelector
 }
 } else {
 numClaimsToAllocate++
 // 验证 DeviceClass 存在
 for _, request := range claim.Spec.Devices.Requests {
 // ...
 }
 }
 }
 // 收集全局已分配状态 + 列出所有 ResourceSlice + 创建结构化分配器
 if numClaimsToAllocate > 0 {
 // EnableDRAConsumableCapacity 启用时使用 GatherAllocatedState,否则使用 ListAllAllocatedDevices
 allocatedState, err := pl.draManager.ResourceClaims().GatherAllocatedState()
 slices, err := pl.draManager.ResourceSlices().ListWithDeviceTaintRules()
 features := AllocatorFeatures(pl.fts)
 allocator, err := structured.NewAllocator(ctx, features, *allocatedState, pl.draManager.DeviceClasses(), slices, pl.celCache)
 s.allocator = allocator
 s.nodeAllocations = make(map[string]nodeAllocation)
 }
 // ...
}

Filter — 对每个候选节点调用分配器,判断节点是否满足需求:

 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
// pkg/scheduler/framework/plugins/dynamicresources/dynamicresources.go:733
func (pl *DynamicResources) Filter(ctx context.Context, cs fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) *fwk.Status {
 // ...
 // 构建待分配 Claim 列表
 claimsToAllocate := make([]*resourceapi.ResourceClaim, 0, state.claims.len())
 for index, claim := range state.claims.toAllocate() {
 if state.informationsForClaim[index].allocation != nil {
 pendingResult = append(pendingResult, *state.informationsForClaim[index].allocation)
 continue
 }
 claimsToAllocate = append(claimsToAllocate, claim)
 }
 // 调用分配器
 allocationResult, err := state.allocator.Allocate(allocCtx, node, claimsToAllocate)
 switch {
 case errors.Is(err, structured.ErrFailedAllocationOnNode):
 return statusUnschedulable(logger, err.Error(), ...)
 // ...
 }
 // 缓存分配结果
 state.nodeAllocations[node.Name] = nodeAllocation{
 allocationResults: allocations,
 // ...
 }
 // ...
}

PostFilter — 所有节点都不满足时,对未被其他 Pod 预留的 Claim 同时清除 Allocation、ReservedFor 和 Devices 三个字段,清除后 Pod 会在 Claim 状态变更事件触发时重新入队调度。

Reserve — 选定节点后,将 Claim 标记为进行中分配,防止并发冲突:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// pkg/scheduler/framework/plugins/dynamicresources/dynamicresources.go:1129
func (pl *DynamicResources) Reserve(ctx context.Context, cs fwk.CycleState, pod *v1.Pod, nodeName string) (status *fwk.Status) {
 // ...
 if state.allocator != nil {
 allocations, ok := state.nodeAllocations[nodeName]
 for index, claim := range state.claims.toAllocate() {
 allocation := &allocations.allocationResults[allocIndex]
 state.informationsForClaim[index].allocation = allocation
 claim = claim.DeepCopy()
 claim.Status.Allocation = allocation
 // 标记为"进行中分配"
 err := pl.draManager.ResourceClaims().SignalClaimPendingAllocation(claim.UID, claim)
 // ...
 }
 }
 // ...
}

PreBind — 将分配结果持久化到 API Server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// pkg/scheduler/framework/plugins/dynamicresources/dynamicresources.go:1317
func (pl *DynamicResources) PreBind(ctx context.Context, cs fwk.CycleState, pod *v1.Pod, nodeName string) *fwk.Status {
 // ...
 for index, claim := range state.claims.all() {
 if !resourceclaim.IsReservedForPod(pod, claim, pl.fts.EnableDRAWorkloadResourceClaims) {
 claim, err := pl.bindClaim(ctx, state, podGroupState, index, pod, nodeName)
 // ...
 }
 }
 // ...
}

bindClaim 内部通过 retry.RetryOnConflictclaim.Status.Allocationclaim.Status.ReservedFor 写入 API Server。

Unreserve — 调度失败时回滚:移除进行中分配(MaybeRemoveClaimPendingAllocation),仅在成功移除时恢复 AssumeCache(AssumedClaimRestore),并通过 strategic-merge-patch 移除 Pod 的 ReservedFor 条目。此外还会清理 PodGroup 的 pendingAllocations、扩展资源 Claim 以及 NodeAllocatable 资源状态。

5.2 三层 Claim 跟踪机制

调度器内部通过 claimTrackerdra_manager.go)维护三层跟踪,防止并发调度导致资源冲突:

  • 层级 1:Informer + AssumeCache — PreBind 写入 API Server 后立即更新本地缓存,不等 Informer 同步
  • 层级 2:In-flight Allocations — Reserve 到 PreBind 期间,将 Claim 标记为进行中分配,防止其他 Pod 并发分配相同设备
  • 层级 3:allocatedDevices — 响应式维护所有已分配设备 ID 集合,PreFilter 阶段获取并合并 In-flight 的分配

5.3 深入:结构化分配器

调度器 Filter 阶段的核心是结构化分配器(Structured Allocator),负责从 ResourceSlice 的设备中找到满足 Claim 需求的设备组合。

分配器分层

层级 支持的特性
stable AdminAccess, PrioritizedList, PartitionableDevices, DeviceTaints
incubating stable + DeviceBindingAndStatus, ConsumableCapacity
experimental incubating + ListTypeAttributes

调度器按稳定性排序选择:选第一个支持所需 Feature Gate 集的分配器。各层完全独立,当孵化层代码足够成熟时,整体晋升到稳定层。

分配算法分三个阶段:

Phase 1 — 收集池(GatherPools):收集与目标节点相关的 ResourceSlice,按 (Driver, PoolName) 分组,构建候选设备池。

Phase 2 — 验证请求:检查选择器使用 CEL、验证 DeviceClass 引用、确定设备数量或列表。

Phase 3 — 递归搜索

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
allocateOne(remainingClaims, currentAllocation)
 ├─ 基线情况:所有 Claim 已分配 → 返回成功
 ├─ FirstAvailable:按优先级顺序尝试子请求,第一个成功的胜出
 ├─ All 模式:按预计算列表顺序分配,每个设备必需
 └─ ExactCount 模式:遍历池/切片/设备,对每个候选:
 a. 跳过已使用的设备
 b. 检查 CEL 选择器
 c. 检查污点/容忍
 d. 检查约束(matchAttribute)
 e. 匹配则递归尝试下一个设备索引
 f. 失败时回溯(deallocate),尝试下一个候选

CEL 选择器评估两个优化:编译缓存(每表达式编译一次)、设备匹配缓存(缓存每个 (设备, 请求) 对的布尔结果)。

约束检查两种类型:matchAttribute(同值约束,如同型号 GPU)、distinctAttribute(异值约束)。分配时逐一检查,失败则回滚之前已添加的约束。注意 distinctAttribute 仅 incubating 及以上层级支持,stable 层只支持 matchAttribute

5.3.1 分配器选择与初始化

NewAllocatorallocator.go:127)按稳定性排序遍历 availableAllocators,选第一个 supportedFeatures 包含所需 Feature Gate 集的分配器。三层分配器传入的参数略有不同:stable 传入 AllocatedDevices,incubating 和 experimental 传入完整的 allocatedState

5.3.2 Allocate — 收集池与验证请求

stable 分配器的 Allocateallocator_stable.go:110)分三阶段执行:

Phase 1:收集池GatherPoolspools_stable.go:58)遍历所有 ResourceSlice,按 (Driver, PoolName) 分组构建候选设备池。含 SharedCounters 的 Slice 直接加入(无需节点匹配);其余按 Slice 级别的 NodeName/AllNodes/NodeSelector 和 PerDeviceNodeSelection(设备级别节点选择)进行节点匹配。

Phase 2:验证请求 — 对每个 Claim 的每个 Request 调用 validateDeviceRequest,确定设备选择器和候选设备列表,同时构建约束。注意 DistinctAttribute 仅 incubating 及以上层级支持,stable 层遇到会报错。

Phase 3:递归搜索 — 调用 allocateOne(deviceIndices{}, false) 启动分配。

5.3.3 allocateOne — 递归搜索与回溯

allocateOne 是分配算法的递归函数(allocator_stable.go:766),逐一为每个 Claim 的每个 Request 选择设备。逻辑和 5.1 节的伪代码一致,这里补充几个实现细节:

allocateDeviceallocator_stable.go:1105)— 检查设备可用性并标记分配,成功时返回回滚函数:

  1. 跳过已占用设备(deviceInUse),AdminAccess 允许分配其他 Claim 已占用的设备(但不允许同一 Claim 内重复分配)
  2. 检查 PartitionableDevices 的计数器容量(checkAvailableCounters
  3. 检查污点/容忍(taintPreventsAllocation
  4. 逐一检查约束(constraint.add),失败时回滚之前已添加的约束
  5. 标记为已分配,返回 deallocate 闭包用于回溯

6. 阶段五:设备准备与注入

6.1 流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
Kubelet 收到绑定的 Pod
 ├─ 1. PrepareResources(4 阶段)
 │ ├─ 阶段 1:验证
 │ │ → 获取每个 ResourceClaim,验证 Pod 在 ReservedFor 中
 │ │ → 解析每个需要的 DRA 驱动
 │ ├─ 阶段 2:缓存更新 + Checkpoint
 │ │ → 将 ClaimInfo 加入缓存
 │ │ → 如果已 Prepared → 跳过;否则构建 gRPC 批次
 │ ├─ 阶段 3:gRPC 调用
 │ │ → 调用 Driver 的 NodePrepareResources
 │ │ → Driver 返回 CDI 设备 ID
 │ └─ 阶段 4:标记已准备 + 最终 Checkpoint
 ├─ 2. GetResources → 为容器运行时提供 CDI 设备列表
 └─ 3. 容器运行时注入
 → 根据 CDI 描述文件挂载设备文件、驱动库,注入环境变量

Kubelet DRA Manager 的 Checkpoint 机制保证重启后状态恢复。还有一个协调循环(每 60 秒)扫描不活跃 Pod 的 Claim 执行 Unprepare。

6.2 NodePrepareResources

上面是 Kubelet DRA Manager 的流程。当 Kubelet 通过 gRPC 调用 Driver 的 NodePrepareResources 时,进入 NVIDIA Driver 的处理逻辑。调用入口是 nodePrepareResource()driver.go:373),获取全局 flock 后委托给 DeviceState.Prepare()device_state.go:229):

 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
func (s *DeviceState) Prepare(ctx context.Context, claim *resourceapi.ResourceClaim) ([]kubeletplugin.Device, error) {
 s.Lock()
 defer s.Unlock()

 cp, err := s.getCheckpoint(ctx)
 // 如果 Claim 已经 PrepareCompleted → 直接返回缓存结果(幂等性)
 preparedClaim, exists := cp.V2.PreparedClaims[claimUID]
 if exists && preparedClaim.CheckpointState == ClaimCheckpointStatePrepareCompleted {
 return preparedClaim.PreparedDevices.GetDevices(), nil
 }
 // 如果处于 PrepareStarted(上次崩溃未完成),先回滚
 if exists && preparedClaim.CheckpointState == ClaimCheckpointStatePrepareStarted {
 s.unpreparePartiallyPrepairedClaim(claimUID, preparedClaim, cp)
 }
 // 更新 Checkpoint:标记为 PrepareStarted
 s.updateCheckpoint(ctx, func(cp *Checkpoint) {
 cp.V2.PreparedClaims[claimUID] = PreparedClaim{CheckpointState: ClaimCheckpointStatePrepareStarted, ...}
 })
 // 核心逻辑:按调度器的分配结果准备设备
 preparedDevices, err := s.prepareDevices(ctx, claim)
 // 生成 CDI 描述文件
 s.cdi.CreateClaimSpecFile(claimUID, preparedDevices)
 // 更新 Checkpoint:标记为 PrepareCompleted
 s.updateCheckpoint(ctx, func(cp *Checkpoint) {
 cp.V2.PreparedClaims[claimUID] = PreparedClaim{CheckpointState: ClaimCheckpointStatePrepareCompleted, PreparedDevices: preparedDevices}
 })
 return preparedDevices.GetDevices(), nil
}

几个设计要点:

  • Checkpoint 持久化:本地 JSON 文件持久化每个 Claim 的准备状态,保证重启后状态不丢失(传统 DevicePlugin 进程内无持久化,重启后依赖 Kubelet 通过 ListAndWatch 重新同步)
  • 幂等性:Claim 已 PrepareCompleted 则直接返回缓存结果,不会重复准备
  • 两阶段状态:PrepareStarted → PrepareCompleted。PrepareStarted 阶段崩溃则重启后回滚
  • 分配结果由调度器决定prepareDevices() 的输入是 claim.Status.Allocation,Driver 只需按分配结果准备,不需要自己选设备

6.3 CDI 设备注入

CDI(Container Device Interface)是容器运行时级别的设备注入标准。DRA Driver 在 Prepare 阶段生成 CDI 描述文件,最终靠它把 GPU 注入到容器里。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// cmd/gpu-kubelet-plugin/cdi.go:181
func (cdi *CDIHandler) CreateClaimSpecFile(claimUID string, preparedDevices PreparedDevices) error {
 commonEdits, err := cdi.GetCommonEditsCached() // 通用容器编辑(缓存 5 分钟)
 for _, group := range preparedDevices {
 for _, dev := range group.Devices {
 dname := fmt.Sprintf("%s-%s", claimUID, dev.CanonicalName()) // claim 专属名称
 if dev.Type() == GpuDeviceType {
 dspecsgpu, _ := cdi.GetDeviceSpecsByUUIDCached(dev.Gpu.Info.UUID) // 从缓存获取
 // ...
 }
 if dev.Type() == PreparedMigDeviceType { /* MIG:父 GPU 规格 + MIG 设备节点 */ }
 if dev.Type() == VfioDeviceType { /* VFIO:PCI 设备规格 */ }
 }
 }
 // ...
}

生成的 CDI 设备名称格式:k8s.gpu.nvidia.com/claim=<claimUID>-<canonicalName>

CDI 设备 ID 返回给 Kubelet 后,Kubelet 通过 GetResources 为容器运行时提供 CDI 设备列表,容器运行时根据 CDI 描述文件完成挂载设备文件、驱动库、注入环境变量。Unprepare 时删除 CDI 描述文件,容器运行时就不再能访问这些设备。

对比 DevicePlugin:Allocate() 返回设备路径和环境变量的列表,Kubelet 自己手动挂载,每个 DevicePlugin 各自实现。DRA 通过 CDI 标准化了设备注入流程。

6.4 并发控制

DRA Driver 通过全局文件锁(flock)串行化所有 Prepare/Unprepare 操作,保证同一时刻只有一个操作在执行。驱动在调用 kubeletplugin.Start() 时显式设置了 Serialize(false),禁用 kubeletplugin 自带的序列化机制,转而通过 flock 自行控制并发。

7. 阶段六:Pod 运行与清理

7.1 流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Pod 终止
 ├─ 1. Kubelet 清理
 │ → UnprepareResources:调用 Driver 的 NodeUnprepareResources
 │ → 移除 Pod 引用,当没有 Pod 引用时执行 Unprepare
 ├─ 2. ResourceClaim Controller 清理
 │ → 移除 ReservedFor 条目
 │ → 当 ReservedFor 为空时:清除分配、移除 Finalizer、删除从模板生成的 Claim
 └─ 3. 设备回到可分配状态

7.2 NodeUnprepareResources

1
2
3
4
5
6
7
8
9
// cmd/gpu-kubelet-plugin/driver.go:420
func (d *driver) nodeUnprepareResource(ctx context.Context, claimRef kubeletplugin.NamespacedObject) error {
 release, err := d.pulock.Acquire(ctx, flock.WithTimeout(10*time.Second))
 if err != nil {
 return fmt.Errorf("error acquiring prep/unprep lock: %w", err)
 }
 defer release()
 return d.state.Unprepare(ctx, claimRef)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// cmd/gpu-kubelet-plugin/device_state.go:426
func (s *DeviceState) Unprepare(ctx context.Context, claimRef kubeletplugin.NamespacedObject) error {
 s.Lock()
 defer s.Unlock()

 checkpoint, err := s.getCheckpoint(ctx)
 pc, exists := checkpoint.V2.PreparedClaims[claimUID]
 if !exists {
 return nil // 不在 Checkpoint → 直接返回(幂等性)
 }
 switch pc.CheckpointState {
 case ClaimCheckpointStatePrepareStarted: // 上次崩溃未完成 → 回滚
 s.unpreparePartiallyPrepairedClaim(claimUID, pc, checkpoint)
 case ClaimCheckpointStatePrepareCompleted: // 正常完成 → 清理
 s.unprepareDevices(ctx, claimUID, pc.PreparedDevices)
 }
 s.cdi.DeleteClaimSpecFile(claimUID) // 删除 CDI 描述文件
 s.deleteClaimFromCheckpoint(ctx, claimRef) // 从 Checkpoint 中删除该 Claim
 return nil
}

unprepareDevices() 按设备类型执行相应清理(如 VFIO 反配置、MIG 设备删除等)。CDI 描述文件的删除和 Checkpoint 中 Claim 的移除由调用方 Unprepare() 负责,不在 unprepareDevices() 内部。

8. 小结

从 Pod 提交到 GPU 可用,DRA 的工作流程可以概括为:Driver 注册设备 → 用户声明需求 → 调度器分配具体设备 → Kubelet 准备并注入 → Pod 终止后清理回收。

  1. DRA Driver 通过 NVML 发现设备 → 注册到 Kubelet → 发布 ResourceSlice
  2. 用户创建 Pod + ResourceClaimTemplate → Controller 生成 ResourceClaim
  3. 调度器:PreFilter 构建分配器 → Filter 递归搜索 + 回溯选定具体设备 → Reserve 标记进行中 → PreBind 持久化结果
  4. Kubelet:NodePrepareResources → Driver 生成 CDI 描述 → 容器运行时注入设备
  5. Pod 终止:Kubelet NodeUnprepareResources 清理设备 → Controller 清除分配 → GC 删除 Claim → 设备回可用

和 DevicePlugin 的根本区别:DevicePlugin 要自己管发现、分配、注入全流程;DRA Driver 只需实现 NodePrepareResources/NodeUnprepareResources,分配决策由调度器完成。

Token Killer: RTK,给你的 AI 编程代理瘦个身

2026-05-20 04:00:00

rtk.jpg

RTK (Rust Token Killer) 是一个 CLI 代理工具,它的核心思路是在命令输出到达 AI 之前把噪音过滤压缩掉,从而实现平均省 60-90% 的 token 的效果。

你有没有算过,Claude Code 一个小时的会话,光 git statuscargo test 这些命令的输出就吃掉多少 token?

原因在于大多数 CLI 命令的输出是给人看的——带进度条、带彩色、带提示语、带格式化。但对 AI 来说,git push 输出的 15 行 “Enumerating objects…” 毫无意义,它只需要知道 “ok main”。

快速上手

安装

1
2
3
4
5
# Homebrew(推荐)
brew install rtk

# 或脚本安装
curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh

验证一下:

1
2
rtk --version # 应该显示 rtk 0.39.x
rtk gain # 应该显示 token 节省统计

配置

一条命令搞定——给你的 AI 工具装上 Hook:

1
2
3
4
5
6
7
# 对于 Claude Code(默认)
rtk init -g 
# 对于 Cursor 
rtk init -g --agent cursor 
# 对于 Windsurf
rtk init -g --agent windsurf 
# 更多工具使用 rtk init --help 查看

使用

配置完 RTK 之后重启 AI 工具就行。

之后你在 AI 会话里执行 git status,它会被自动重写成 rtk git status——AI 收到的是压缩后的输出,但你完全无感。

效果对比:

1
2
3
4
5
6
7
8
# 普通 git push (~200 tokens) # rtk git push (~10 tokens)
Enumerating objects: 5, done. ok main
Counting objects: 100% (5/5), done.
Delta compression using up to 8 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), done.
To github.com:user/repo.git
 abc123..def456 main -> main
1
2
3
4
5
6
# 普通 cargo test (200+ 行) # rtk cargo test (~20 行)
running 15 tests FAILED: 2/15 tests
test utils::test_parse ... ok test_edge_case: assertion failed
test utils::test_format ... ok test_overflow: panic at utils.rs:18
test utils::test_edge_case ... FAILED
...150 行 backtrace...

30 分钟典型会话的估算节省:

命令 频次 原始 token rtk 后 节省
git status 10x 3,000 600 -80%
cargo test 5x 25,000 2,500 -90%
git diff 5x 10,000 2,500 -75%
cat/read 20x 40,000 12,000 -70%
合计 ~118,000 ~23,900 -80%

rtk-gain.png

它是怎么工作的

核心思路

RTK 是一个 CLI 代理,拦截命令输出并压缩后再交给 AI

1
2
3
4
5
AI Agent ──"git status"──> RTK ──> git ──> 原始输出
 过滤压缩
AI Agent <──────────────── RTK <──┘

对不同类型的命令,RTK 用不同策略压缩:

  • git 操作(add/commit/push):成功时只输出 ok,省 92%
  • 测试命令(cargo test/pytest):只显示失败的测试,省 90%+
  • lint/tsc:按规则分组错误,省 80%+
  • 文件列表(ls/tree):压缩为带计数的树状结构,省 80%
  • 代码阅读(read/smart):可选去除注释、折叠函数体,省 30-90%

RTK 不认识的命令会原样执行,不做压缩——永远不会因为 RTK 导致命令失败

Hook 自动重写

rtk init -g 会在你的 AI 工具配置里加一个 Hook,让每次 Bash 命令执行前自动走 RTK。以 Claude Code 为例,它在 ~/.claude/settings.json 里加了一个 PreToolUse Hook:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
 "hooks": {
 "PreToolUse": [
 {
 "matcher": "Bash",
 "hooks": [
 {
 "type": "command",
 "command": "rtk hook claude"
 }
 ]
 }
 ]
 }
}

每次 Claude Code 执行 Bash 命令前,这个 Hook 会先触发 rtk hook claude,由 RTK 接管命令的执行和输出压缩。

1
2
3
4
Claude Code 执行 "git status"
 → Hook 拦截,改写为 "rtk git status"
 → RTK 执行并压缩输出
 → Claude 收到精简结果

两套过滤体系

RTK 支持两种方式添加命令过滤:

  • Rust 模块src/cmds/):适合需要复杂解析的命令,比如 pytest 用状态机追踪测试状态、go test 用 NDJSON 流式解析
  • TOML DSLsrc/filters/*.toml):声明式规则,适合简单的行过滤/替换,比如 helmshellcheck

内置了 100+ 命令支持,你也可以在 .rtk/filters.toml 里写自定义规则。

实用命令速查

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 文件操作
rtk ls . # 压缩目录树
rtk read file.rs # 智能读取
rtk read file.rs -l aggressive # 只保留函数签名
rtk grep "pattern" . # 按文件分组搜索

# Git
rtk git status / diff / log # 压缩输出
rtk git add / commit / push # 极简输出

# 测试
rtk cargo test / pytest / go test # 只显示失败

# 包管理
rtk pnpm list / pip list # 压缩依赖树

# 查看节省
rtk gain # 总览
rtk gain --graph # 30 天趋势图
rtk gain --quota -t pro # 按订阅估算省钱
rtk discover # 发现未优化的命令

全局标志:-u 开启超压缩模式(ASCII 图标 + 内联格式),-v/-vv/-vvv 逐级调试。

小结

RTK 是一个 CLI 代理工具——Token Killer 名字很贴切,在命令输出到达 AI 之前把噪音过滤掉。它解决了一个很实际问题:AI 编程代理执行命令时大量无意义输出导致的 token 浪费。 用起来没什么感知,看账单才知道省了多少,时不时跑下 rtk gain 看看又省了多少 Token 哈哈。

Kubernetes GPU 虚拟化实战:HAMi DRA 模式完整指南

2026-05-14 20:00:00

hami-dra-quickstart.jpg

HAMi 是目前 Kubernetes 上最活跃的开源 vGPU 方案,能够将一块物理 GPU 按显存和算力细粒度地切分为多个虚拟 GPU,供不同 Pod 共享。

本文聚焦 HAMi DRA 模式的部署与使用:安装 HAMi DRA 驱动后,分别用原生模式和兼容模式提交 Pod,验证 GPU 切分是否生效。


Kubernetes 在 1.34 中正式 GA 了 DRA(Dynamic Resource Allocation,动态资源分配)。DRA 的核心改进是让调度器参与资源分配,在 Pod 调度阶段就精确匹配设备属性,避免了 DevicePlugin “调度到节点后才发现资源不够” 的问题。

HAMi 最近的版本已经正式接入了 DRA,用户既可以使用原生 DRA 模式,也可以用 DevicePlugin 兼容模式无缝迁移。

什么是 HAMi

HAMi(异构 AI 计算虚拟化中间件)是一个用于管理 Kubernetes 集群中异构 AI 计算设备的开源平台。前身为 k8s-vGPU-scheduler,HAMi 可在多个容器和工作负载之间实现设备共享。

HAMi 是云原生计算基金会(CNCF)的 Sandbox 项目,并被收录于 CNCF 技术全景图和 CNAI 技术全景图。

HAMi 生态全景

核心特性

设备共享

  • 多设备支持:兼容多种异构 AI 计算设备(GPU、NPU 等)
  • 共享访问:多个容器可同时共享设备,提高资源利用率

内存管理

  • 硬限制:在容器内强制执行严格的内存限制,防止资源冲突
  • 动态分配:根据工作负载需求按需分配设备内存
  • 灵活单位:支持按 MB 或占总设备内存百分比的方式指定内存分配

设备规格

  • 类型选择:可请求特定类型的异构 AI 计算设备
  • UUID 定向:使用设备 UUID 精确指定特定设备

易用性

  • 对工作负载透明:容器内无需修改代码
  • 简单部署:使用 Helm 轻松安装和卸载,配置简单

开放治理

  • 社区驱动:由互联网、金融、制造业、云服务等多个领域的组织联合发起
  • 中立发展:作为开源项目由 CNCF 管理

HAMi 安装

前提条件:

  • K8s 1.34 及以上版本,同时开启 DRAConsumableCapacity Feature Gate

    • 1.34-1.35 DRAConsumableCapacity 默认未开启,需要手动配置
  • Container Runtime 必须开启 CDI

  • NVIDIA GPU 驱动 440 及以上版本

特别是第一条,DRAConsumableCapacity 在 1.36 才默认开启,1.34、1.35 需手动配置。

GPU Operator 安装

安装 GPU Operator 时需要关闭 DevicePlugin:

1
2
3
4
5
6
7
8
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia && helm repo update

helm upgrade --install --wait gpu-operator \
 -n gpu-operator --create-namespace \
 nvidia/gpu-operator \
 --version=v26.3.1 \
 --set driver.enabled=true \
 --set devicePlugin.enabled=false

--set devicePlugin.enabled=false:关闭 DevicePlugin,避免与后续安装的 DRA Driver 冲突。

安装 cert-manager

HAMi DRA Webhook 需要 TLS 证书,因此需要提前安装 cert-manager 用于自动签发。

1
2
3
4
5
6
helm repo add cert-manager https://charts.jetstack.io
helm repo update

helm install cert-manager cert-manager/cert-manager \
 -n cert-manager --create-namespace \
 --set crds.enabled=true

Helm 安装 HAMi

为节点打上 gpu=on 标签,未标记的节点不会被 HAMi 接管。

1
2
3
#kubectl label nodes {nodeid} gpu=on

kubectl label nodes ecs-a10-sh gpu=on

使用以下命令添加 HAMi 图表仓库:

1
helm repo add hami-charts https://project-hami.github.io/HAMi/

用以下命令进行安装:

1
2
# 核心是通过 --set dra.enabled=true 开启 DRA 模式
helm -n hami-system install hami hami-charts/hami --set dra.enabled=true --create-namespace

注意:DRA 模式与传统模式不兼容,请勿同时启用。

另外如果 GPU 驱动是主机预装,非 GPU Operator 安装,则安装时需额外指定:

1
--set hami-dra.drivers.nvidia.containerDriver=false

验证

正常情况下,会在 hami-system 下启动以下 Pod

1
2
3
4
5
root@ECS-A10-SH:/data/nfs/shared-skills-cicd# k -n hami-system get po
NAME READY STATUS RESTARTS AGE
hami-dra-driver-kubelet-plugin-hflbh 1/1 Running 0 2m49s
hami-hami-dra-monitor-7b484d5f95-rlkcg 1/1 Running 0 22m
hami-hami-dra-webhook-64bfdc6b86-d4nlr 1/1 Running 0 22m

使用

查看 ResourceSlice

查看 dra-driver 是否正常发布 resourceslice:

1
2
3
root@ECS-A10-SH:/data/nfs/shared-skills-cicd# kubectl get resourceslice
NAME NODE DRIVER POOL AGE
ecs-a10-sh-hami-core-gpu.project-hami.io-hnn6d ecs-a10-sh hami-core-gpu.project-hami.io ecs-a10-sh 119s

详情如下:

 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
50
51
52
53
root@ECS-A10-SH:/data/nfs/shared-skills-cicd# kubectl get resourceslice ecs-a10-sh-hami-core-gpu.project-hami.io-hnn6d -oyaml
apiVersion: resource.k8s.io/v1
kind: ResourceSlice
metadata:
 creationTimestamp: "2026-05-13T09:28:56Z"
 generateName: ecs-a10-sh-hami-core-gpu.project-hami.io-
 generation: 1
 name: ecs-a10-sh-hami-core-gpu.project-hami.io-hnn6d
 ownerReferences:
 - apiVersion: v1
 controller: true
 kind: Node
 name: ecs-a10-sh
 uid: 76c7db94-fe0b-44ea-9b07-8bdb6132888b
 resourceVersion: "61417761"
 uid: 46d46b45-108e-45e3-98f2-000a091571d3
spec:
 devices:
 - attributes:
 architecture:
 string: Ampere
 brand:
 string: Nvidia
 cudaComputeCapability:
 version: 8.6.0
 cudaDriverVersion:
 version: 12.4.0
 driverVersion:
 version: 550.144.3
 minor:
 int: 0
 pcieBusID:
 string: 0000:65:01.0
 productName:
 string: NVIDIA A10
 resource.kubernetes.io/pcieRoot:
 string: pci0000:64
 type:
 string: hami-gpu
 uuid:
 string: GPU-f1c7d08c-ae21-13e7-0de0-9eb14ff71eaf
 capacity:
 cores:
 value: "100"
 memory:
 value: 23028Mi
 name: hami-gpu-0
 driver: hami-core-gpu.project-hami.io
 nodeName: ecs-a10-sh
 pool:
 generation: 1
 name: ecs-a10-sh
 resourceSliceCount: 1

可以看到,ResourceSlice 记录了 GPU 的架构、型号、显存等信息。

提交任务:DRA 原生模式

DRA 原生使用流程是先创建 ResourceClaim,然后创建 Pod 使用该 ResourceClaim。

DRA 资源分配流程

提交任务

ResourceClaim 以及对应 Pod 完整 yaml 如下:

 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
# DRA 原生模式 - 手动创建 ResourceClaim
# 申请 10G 显存 + 50 cores 的 A10 GPU

apiVersion: resource.k8s.io/v1
kind: ResourceClaim
metadata:
 name: gpu-half-claim
spec:
 devices:
 requests:
 - name: gpu
 exactly:
 deviceClassName: hami-core-gpu.project-hami.io
 allocationMode: ExactCount
 count: 1
 capacity:
 requests:
 cores: 50
 memory: "10Gi"
---
apiVersion: v1
kind: Pod
metadata:
 name: gpu-test-dra-native
spec:
 containers:
 - name: cuda
 image: 172.31.0.2:5000/nvidia/cuda:13.0.1-base-ubi9
 command: ["sleep", "3600"]
 resources:
 claims:
 - name: gpu
 resourceClaims:
 - name: gpu
 resourceClaimName: gpu-half-claim
 restartPolicy: Never

查看调度情况

通过 ResourceClaim 可以看到资源分配情况:

 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
root@ECS-A10-SH:~/lixd/deploy/gpu/hami/examples# k get po
NAME READY STATUS RESTARTS AGE
gpu-test-dra-native 1/1 Running 0 88s
root@ECS-A10-SH:~/lixd/deploy/gpu/hami/examples# k get resourceclaim
NAME STATE AGE
gpu-half-claim allocated,reserved 21s
root@ECS-A10-SH:~/lixd/deploy/gpu/hami/examples# k get resourceclaim gpu-half-claim -oyaml
apiVersion: resource.k8s.io/v1
kind: ResourceClaim
metadata:
 name: gpu-half-claim
 namespace: default
spec:
 devices:
 requests:
 - exactly:
 allocationMode: ExactCount
 capacity:
 requests:
 cores: "50"
 memory: 10Gi
 count: 1
 deviceClassName: hami-core-gpu.project-hami.io
 name: gpu
status:
 allocation:
 devices:
 results:
 - consumedCapacity:
 cores: "50"
 memory: 10Gi
 device: hami-gpu-0
 driver: hami-core-gpu.project-hami.io
 pool: ecs-a10-sh
 request: gpu
 shareID: 6108e68f-a7ec-4a30-9782-634885c0c728
 nodeSelector:
 nodeSelectorTerms:
 - matchFields:
 - key: metadata.name
 operator: In
 values:
 - ecs-a10-sh
 reservedFor:
 - name: gpu-test-dra-native
 resource: pods
 uid: d99dc6df-092c-4f3a-ac55-cfb88c017af7

效果

Pod 中执行 nvidia-smi 看到显存是我们申请的 10G,说明 HAMi 生效了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[HAMI-core Msg(51:140707774973760:libvgpu.c:870)]: Initializing.....
Wed May 13 10:58:20 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.144.03 Driver Version: 550.144.03 CUDA Version: 13.0 |
|-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 NVIDIA A10 On | 00000000:65:01.0 Off | 0 |
| 0% 32C P8 22W / 150W | 0MiB / 10240MiB | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
| No running processes found |
+-----------------------------------------------------------------------------------------+
[HAMI-core Msg(51:140707774973760:multiprocess_memory_limit.c:703)]: Cleanup on exit for PID 51
[HAMI-core Msg(51:140707774973760:multiprocess_memory_limit.c:739)]: Exit cleanup complete for PID 51

提交任务:DevicePlugin 兼容模式

原生 DRA 模式需要手动创建 ResourceClaim,对存量业务不够友好。

为了便于大家迁移,HAMi 提供了兼容模式:用户仍然像 DevicePlugin 那样申请资源,由 HAMi DRA Webhook 自动拦截并转换为 ResourceClaim,调度器分配后再挂载到 Pod。

提交任务

和使用 DevicePlugin 一样,正常在 resources 中申请资源即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 兼容模式 - 按传统方式申请 GPU,HAMi webhook 自动转换为 ResourceClaim
# 申请 1 块 GPU,10Gi 显存 + 50% 算力

apiVersion: v1
kind: Pod
metadata:
 name: gpu-test-compatible
spec:
 containers:
 - name: cuda
 image: 172.31.0.2:5000/nvidia/cuda:13.0.1-base-ubi9
 command: ["sleep", "3600"]
 resources:
 limits:
 nvidia.com/gpu: 1
 nvidia.com/gpumem: 10240
 nvidia.com/gpucores: 50
 restartPolicy: Never

查看调度情况

HAMi 会根据 nvidia.com/gpumemnvidia.com/gpucores 自动生成 ResourceClaim,并绑定到 Pod。

对应的 ResourceClaim 如下:

 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
50
51
52
53
root@ECS-A10-SH:~/lixd/deploy/gpu/hami/examples# k get resourceclaim
NAME STATE AGE
default-gpu-test-compatible-cuda allocated,reserved 2m47s
root@ECS-A10-SH:~/lixd/deploy/gpu/hami/examples# k get resourceclaim default-gpu-test-compatible-cuda -oyaml
apiVersion: resource.k8s.io/v1
kind: ResourceClaim
metadata:
 creationTimestamp: "2026-05-13T11:14:06Z"
 finalizers:
 - resource.kubernetes.io/delete-protection
 name: default-gpu-test-compatible-cuda
 namespace: default
 resourceVersion: "61451167"
 uid: 8212ef37-f71c-45ca-ac4a-f94ead923eef
spec:
 devices:
 requests:
 - exactly:
 allocationMode: ExactCount
 capacity:
 requests:
 cores: "50"
 memory: "10737418240"
 count: 1
 deviceClassName: hami-core-gpu.project-hami.io
 selectors:
 - cel:
 expression: device.attributes["hami-core-gpu.project-hami.io"].type ==
 "hami-gpu"
 name: gpu
status:
 allocation:
 devices:
 results:
 - consumedCapacity:
 cores: "50"
 memory: 10Gi
 device: hami-gpu-0
 driver: hami-core-gpu.project-hami.io
 pool: ecs-a10-sh
 request: gpu
 shareID: a8dba99f-7841-41ad-9f07-5ec39ddee543
 nodeSelector:
 nodeSelectorTerms:
 - matchFields:
 - key: metadata.name
 operator: In
 values:
 - ecs-a10-sh
 reservedFor:
 - name: gpu-test-compatible
 resource: pods
 uid: 173a6d7f-665b-4b2d-961c-f550d70f7484

核心配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
spec:
 devices:
 requests:
 - exactly:
 allocationMode: ExactCount
 capacity:
 requests:
 cores: "50"
 memory: "10737418240"
 count: 1

对比原始 Pod 的资源申请:

1
2
3
4
5
resources:
 limits:
 nvidia.com/gpu: 1
 nvidia.com/gpumem: 10240
 nvidia.com/gpucores: 50

Webhook 转换映射关系:

原始资源申请 ResourceClaim 字段
nvidia.com/gpu: 1 requests.count: 1
nvidia.com/gpumem: 10240 requests.capacity.memory
nvidia.com/gpucores: 50 requests.capacity.cores

效果

同样的,显存为 10240M,说明 HAMi 也生效了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
root@ECS-A10-SH:~/lixd/deploy/gpu/hami/examples# k exec -it gpu-test-compatible -- nvidia-smi
[HAMI-core Msg(57:139707024262976:libvgpu.c:870)]: Initializing.....
Wed May 13 11:21:39 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.144.03 Driver Version: 550.144.03 CUDA Version: 13.0 |
|-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 NVIDIA A10 On | 00000000:65:01.0 Off | 0 |
| 0% 32C P8 22W / 150W | 0MiB / 10240MiB | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
| No running processes found |
+-----------------------------------------------------------------------------------------+
[HAMI-core Msg(57:139707024262976:multiprocess_memory_limit.c:703)]: Cleanup on exit for PID 57
[HAMI-core Msg(57:139707024262976:multiprocess_memory_limit.c:739)]: Exit cleanup complete for PID 57

小结

本文围绕 HAMi DRA 模式完成了从安装到验证的完整实践:

  1. 部署 HAMi DRA:关闭 DevicePlugin 后通过 Helm 安装 HAMi,开启 dra.enabled=true
  2. DRA 原生模式:手动创建 ResourceClaim 声明显存与算力,Pod 通过 resourceClaim 引用
  3. DevicePlugin 兼容模式:沿用传统 nvidia.com/gpu 等资源申请,HAMi DRA Webhook 自动转换为 ResourceClaim,存量业务零改造即可迁移

两种模式的核心差异在于 ResourceClaim 的创建方式——原生模式手动管理、兼容模式自动生成,底层调度与切分逻辑一致。