Device Plugin 的十年:从「让 K8s 认识新硬件」到算力调度的问题域
一句话结论:Device Plugin 的本质,是把「让 Kubernetes 认识一种新硬件」这件事,从改内核代码降维成了写一个 gRPC 服务。而它的边界与局限——只能表达整数、调度决策黑盒、无拓扑感知、无故障协同——恰好勾勒出了大模型时代算力调度的完整问题域。
今天做 AI Infra,大概率不会从零写一个 device plugin,但一定会在这套机制的边界上做选型:训练集群选 Volcano 还是 KAI、推理集群选 HAMi 还是 MIG、要不要切 DRA、边缘具身用 KubeEdge 还是裸 K8s。
本文的机制描述逐条核对自 Kubernetes 官方文档与 proto 源码、NVIDIA/昇腾/HAMi/KubeEdge 各项目仓库。核对中发现原稿有十余处需要更正,其中两处是硬伤,已在文中以 ⚠️ 标注。
一、设计动机:为什么要有 Device Plugin
Kubernetes 原生只认识 CPU、内存和本地存储。让每一种硬件的枚举、健康检查、分配逻辑都进 kubelet 主干,在工程上是不可持续的——每出一个新硬件就要改一次核心代码。
设计提案里说得很直接:我们需要一个厂商无关的插件化方案,让硬件厂商在不写 Kubernetes 代码的前提下,把自己的资源报给 kubelet,并监控其健康状态。
这条路在 2017 年走通了第一程。三个版本节点的官方记录:
| 版本 | 事件 | CHANGELOG 原文 |
|---|---|---|
| 1.8 | 进入 alpha | [alpha] Add the Hardware Device Plugins API. |
| 1.10 | 升 beta,默认开启 | The DevicePlugins feature has graduated to beta, and is now enabled by default |
| 1.11 | 移除内置 GPU 路径 | Support for the alpha.kubernetes.io/nvidia-gpu resource, which was deprecated in 1.10, has been removed. Please use the resource exposed by DevicePlugins instead (nvidia.com/gpu). |
此后近十年,nvidia.com/gpu 这种扩展资源成了 GPU 集群的事实标准接口。
而 DRA(Dynamic Resource Allocation)在 1.34 正式 GA,并从 1.35 起特性门控锁定(locked,无法再关闭)。官方 Feature Gates 表的原话是:
You can no longer disable or opt out of this feature or behavior (it is locked)
两条技术路线的交接,是这篇文章后半段的主线。
⚠️ 一处引用要改:原稿引了一句「官方文档的表述是 ‘DRA replaces device plugins for accessing resources from workloads’」。这句话在 Kubernetes 官方文档里查无出处(DRA 概念页、dra-api、dra-features、Device Plugins 页、set-up-dra-cluster 全文检索均未命中)。
最接近的两处官方原文是:
- WG Device Management 官方博客:“The working group’s primary deliverable is Dynamic Resource Allocation (DRA), a new framework that replaces the rigid device plugin model with a flexible, declarative API.”
- DRA 概念页:“These benefits provide significant improvements in the device allocation workflow when compared to device plugins, which require per-container device requests, don’t support device sharing, and don’t support expression-based device filtering.”
引用时用这两句之一。
二、机制解析:kubelet 与插件之间的 gRPC 握手
Device Plugin 的运行模型非常克制:每个插件是一个部署在节点上的 DaemonSet 进程,通过 Unix socket 与 kubelet 通信,对外暴露一组 gRPC 接口。
2.1 五个方法,不是四个
⚠️ 更正(硬伤之一):原稿写「核心只有四个方法」,实际 proto 里
service DevicePlugin有 5 个 rpc,漏掉的是PreStartContainer:
1 | service DevicePlugin { |
另外要区分:Register 不在这个服务里——它属于另一个独立的 service Registration。官方文档也强调,GetPreferredAllocation 与 PreStartContainer 都是可选的,其可用性由 DevicePluginOptions 里的标志位声明。
2.2 注册、长连与两个容易忽略的工程细节
sequenceDiagram
participant P as Device Plugin (DaemonSet)
participant K as kubelet DeviceManager
participant A as API Server / Scheduler
Note over P: 启动:向 kubelet 的固定 socket 发起 Register
P->>K: Register(资源名, socket路径, API版本)
K->>P: 建立到插件 socket 的 gRPC 连接
K->>P: ListAndWatch(Empty)
P-->>K: stream.ListAndWatchResponse(设备ID + 健康状态)
K->>A: 以 Extended Resource 形式上报 allocatable<br/>(如 nvidia.com/gpu: 8)
Note over A: kubectl describe node 可见
Note over K: 调度器选中节点后,Pod 进入创建流程
K->>P: Allocate(容器请求的设备ID列表)
P-->>K: 设备节点路径 + 环境变量 + 挂载点
K->>K: 交给容器运行时注入(CDI / nvidia-container-runtime)细节一:socket 路径是硬编码的。 官方文档原文:
under the host path
/var/lib/kubelet/device-plugins/(this path is hardcoded and is not affected by the kubelet’s--root-diror any other configuration)
这个「不受 --root-dir 影响」是个常见的排查陷阱——改了 kubelet 的 root-dir,插件 socket 位置不会跟着变。另外 kubelet 启动时会清掉该目录下所有已存在的 socket,所以插件必须能感知 kubelet 重启并自动重新注册。
细节二:NVIDIA 插件靠 fsnotify 监听 kubelet socket 的创建事件来重注册。 源码里是这么写的:
1 | // Detect a kubelet restart by watching for a newly created 'pluginapi.KubeletSocket' file. |
ListAndWatch 是一条服务端流式长连接(proto 里是 returns (stream ...)),插件持续把设备状态推给 kubelet。GPU 出现 ECC 错误或 Xid 掉卡,插件标记为 Unhealthy,kubelet 就把它从可分配资源里摘掉。
⚠️ 一处措辞要精确:官方原文是「the kubelet will decrease the allocatable count for this resource on the Node… Capacity count for the resource will not change」——下降的是
allocatable,capacity不变。所以那个经典现象「
nvidia-smi能看到卡、但kubectl describe node里 GPU 数量是 0」,严格说是Allocatable: nvidia.com/gpu: 0而Capacity仍是原值。
Allocate 并不真的「分配」显存,它返回的是环境变量(NVIDIA_VISIBLE_DEVICES、ASCEND_VISIBLE_DEVICES——两个名字都在各自插件的源码里确认过)、设备节点路径、挂载点和 CDI 设备名。真正的设备注入由容器运行时(配合 CDI 或 nvidia-container-toolkit / Ascend Docker Runtime)完成。
补充一句:time-slicing / MPS 下
Allocate更不做显存隔离——这一点在第四节展开。
2.3 Bind 严格在前,Allocate 严格在后
这是一个高频疑问,而且前提里藏着一个常见误解需要先澄清:
调度器从头到尾不联系 device plugin。 它只看节点对象上的 Extended Resource 计数;真正调用插件
Allocate()的是目标节点上的 kubelet,时机是容器创建路径中。
完整时序:
sequenceDiagram
autonumber
actor U as kubectl
participant A as API Server
participant S as kube-scheduler
participant K as kubelet (PodWorker + DeviceManager)
participant P as Device Plugin
participant R as 容器运行时 (CRI)
U->>A: 创建 Pod(无 nodeName)
Note over A,S: spec.nodeName 为空 → 进入调度队列
rect rgb(235, 245, 255)
Note over S: ── 调度周期:全程不触碰任何插件 ──
S->>S: Filter:节点 GPU 计数 ≥ 请求量<br/>(计数源自节点 status,由 ListAndWatch 汇聚上报)
S->>S: Score:候选节点排序
S->>S: Reserve/Assume:在调度器本地缓存里把该节点 GPU 计数 +1
S->>S: Permit:放行(coscheduling 等在此等待整批)
S->>S: PreBind:卷 attach、hostPort 预留
S->>A: Bind:POST /pods/{name}/binding
A-->>A: spec.nodeName = NodeX ✅ 绑定完成
Note over S: 调度器使命结束,此后不再参与此 Pod
end
rect rgb(235, 255, 240)
Note over K,R: ── 节点侧:绑定之后 ──
K->>A: watch 到分给自己的 Pod
K->>K: 准入检查 → PodManager 注册 → SyncPod
K->>K: DeviceManager.devicesToAllocate:为本容器挑选具体设备 ID
opt 插件声明 GetPreferredAllocationAvailable
K->>P: GetPreferredAllocation(候选设备集)
P-->>K: 返回建议优先的设备 ID
end
K->>P: Allocate(设备 ID 列表) ← gRPC 一元调用
P-->>K: Envs + 设备节点路径 + 挂载点
K->>K: 分配结果写入 checkpoint
K->>R: RunPodSandbox → CreateContainer → StartContainer
R-->>K: 容器运行
K->>A: 上报 Pod Status → Running
end从外部可观察的状态看,这条链对应 Pod 的状态流转:Pending(未调度) → Bind → Pending(ContainerCreating) → Allocate 发生在这里 → Running。
「两个分配」不是一回事,这是最容易混淆的点:
| 调度器的 Reserve/Assume | kubelet 的 Allocate | |
|---|---|---|
| 时机 | Bind 之前,调度周期内 | Bind 之后,容器创建时 |
| 性质 | 记账式虚拟占用(本地缓存 +1) | 真实选定具体设备 ID 并锁定 |
| 粒度 | 只知道「数量够不够」 | 精确到 /dev/nvidia0 这种实例级 |
| 失败回滚 | Bind 失败则 un-assume,计数 −1 | 失败不会解绑,Pod 留在节点上重试 |
| 谁执行 | kube-scheduler | kubelet → 插件 gRPC |
调度器在 Filter 阶段看到的 GPU 计数,是节点 status 里 Extended Resource 的 capacity/allocatable——数据源头正是 device plugin 的 ListAndWatch 推给 kubelet、再由 kubelet 汇聚上报的。所以插件间接参与了调度,但调度决策时刻没有任何针对插件的 RPC。
2.4 三个语义细节
Allocate 是按容器粒度的。 一个 Pod 里多个容器各请求多少资源,kubelet 就分别调用多少次——不是每个 Pod 一次。
分配是粘性的。 kubelet 把(Pod UID, 容器, 资源名)→ 设备 ID 列表的映射持久化在 kubelet_internal_checkpoint。容器 crash 重启、甚至 kubelet 自身重启后,再次 Allocate 会命中已有分配、返回同一批设备——训练任务里这至关重要,因为 NCCL 通信组绑定的设备不能漂移。
Allocate 失败的后果是「卡住」而非「重调度」。 假设刚绑定完设备就变 Unhealthy:devicesToAllocate 会从健康集合里挑选,健康集合不足则报错;kubelet 记事件、按 backoff 重试,Pod 长期停在 ContainerCreating。绑定关系不会回退——要打破这种僵局得靠删除 Pod 重新调度,或者上层系统(Volcano 重调度、昇腾插件的故障自愈)介入。
这是 device plugin 模型「调度与分配割裂」的直接代价。
释放发生在 Pod 终止路径:容器停止、Pod 被删除或驱逐时,DeviceManager 才把设备 ID 归还到可分配集合并更新 checkpoint。
2.5 DRA 把时序整个反了过来
这个顺序在 DRA 下发生了根本翻转,也是理解两者差异的最直观切口:
- Device Plugin:「先定节点,再在节点上选设备」——调度器选完节点、Bind 完成后,设备选择才在 kubelet 本地发生,调度器对「选了哪张卡」完全无感。这正是黑盒、无拓扑感知的根源。
- DRA + 结构化参数:「选设备和选节点在同一个调度闭环」——scheduler 在调度周期内直接为 ResourceClaim 挑选具体设备并写回 ResourceClaim status,然后才 Bind。设备分配从节点本地事件升级为 API Server 上可观察、可约束的调度原语。
排障时的证据在哪找,就以此分界:
| 阶段 | 看什么 |
|---|---|
| Bind 之前 | 调度器日志、节点 nvidia.com/gpu 计数、FailedScheduling 事件 |
| Bind 之后 | kubelet 日志(device_allocate 相关事件)、kubectl describe pod 的 Events、Error Allocating 类报错,最后才是插件自身日志 |
两个世界以 Bind 为界,泾渭分明。
机制部分的更深细节(注册流程源码、ListAndWatch 实现、Allocate 返回结构)见 NVIDIA device plugin for Kubernetes 原理分析,那里逐节拆过。
三、深水区:扩展资源模型的四个根本局限
理解了机制,才能看清它为什么不够用。Device Plugin 把硬件抽象成「资源名 + 整数数量」,这个模型在 AI 算力场景暴露了四个结构性问题:
第一,只能表达整数个数,无法表达分数与属性。 nvidia.com/gpu: 1 意味着独占一张卡,Pod 没法申请「半张卡」或「20GB 显存」;调度器也看不到卡的型号、显存大小、NVLink 拓扑这些属性,做不了「给我一张显存 ≥ 40GB 的卡」这类请求。
第二,调度决策对插件是黑盒。 kube-scheduler 只看节点上的 Extended Resource 数量做过滤打分,插件无法参与调度决策。
⚠️ 更正(硬伤之二):原稿在这里举的例子是「NVIDIA 插件 time-slicing 配了
replicas: 4但节点 capacity 仍显示 1,因为 kubelet 只把原始设备数上报为 capacity」。这个论据是反的。NVIDIA 官方文档明确写着:
On a machine with one GPU, the following config map configures Kubernetes so that the node advertises four GPU resources. A machine with two GPUs advertises eight GPUs, and so on.
也就是说
replicas: 4会让单卡节点的 capacity/allocatable 变成 4(4 张物理卡就是 16)。这个更正其实让论点更强——time-slicing 恰恰是插件通过
ListAndWatch虚增设备数来影响调度的正面例子:kubelet 上报的就是插件通告的数量,插件想报几个就报几个。正确的表述是「time-slicing 靠虚增设备数来实现超卖」,而不是「kubelet 不理插件」。真正的黑盒在于:插件通告的是数量,不是属性——它能告诉你「有 4 个 GPU」,但没法告诉你「这 4 个是同一张物理卡的时间片,共享显存和故障域」。
第三,没有拓扑感知。 八卡训练任务被调度到两台没有高速互联的机器上,通信效率直接塌方——原生调度器对 NVLink、灵衢总线、RDMA 拓扑一无所知。
第四,无故障协同与重调度语义。 设备坏了只能从可用列表里摘掉,正在跑的 Pod 怎么办?训练任务怎么恢复?这些都需要插件和上层调度系统自己发明约定。
所有后续演进——DRA、GPU Operator、Volcano、HAMi——本质上都是在不同层面补这四个洞。
四、演进:DRA 用 ResourceClaim 重写语义
Dynamic Resource Allocation 是对上述局限的正面回答。它在 Pod 规格里引入了新的原语:ResourceClaim / ResourceClaimTemplate(声明「我要什么」而不是「我要几个」,可带属性选择器)、DeviceClass(厂商预定义的设备模板)、ResourceSlice(DRA 驱动上报设备用),以及配套的 DRA Driver(kubelet 插件 + scheduler 插件)。
最关键的进步是 Structured Parameters(1.32 beta):设备能力以结构化属性暴露给调度器,让 kube-scheduler 能自己做属性过滤和拓扑对齐,而不是把决策黑盒地交给某个节点上的驱动。
⚠️ 两处原语更正:
- 原稿把
ResourceClass列进了 DRA 原语清单——它已经过时。ResourceClass是 classic DRA(KEP-3063,1.26–1.31) 的集群级 class;structured-parameters DRA(KEP-4381)改用了DeviceClass。当前官方 API 参考里只有DeviceClass。- 清单里应补上
ResourceSlice。
DRA 的版本脉络要分两段说(这是很容易搞混的地方):
| 阶段 | 设计 | KEP | 版本 |
|---|---|---|---|
| classic DRA | PodSchedulingContext 协商模型 |
KEP-3063 | alpha 1.26,1.32 已 withdrawn、代码移除 |
| structured parameters DRA | 结构化参数 + DeviceClass |
KEP-4381 | alpha 1.30—1.31,beta 1.32,stable 1.34 |
KEP-3063 的 README 自己说明了这个角色反转:
Originally, this KEP introduced DRA in Kubernetes 1.26 and the “structured parameters” KEP … added an extension. Now the roles are reversed: #4381 defines the base functionality and this KEP is an optional extension.
两者用的是同一个 feature gate 名 DynamicResourceAllocation,所以只看 gate 名会以为是一条线——实际底下换过一轮设计。
Device Plugin vs DRA 对照:
| 维度 | Device Plugin | DRA |
|---|---|---|
| 引入版本 | 1.8 alpha(2017),1.26 stable | 1.26 alpha(classic)/ 1.30 alpha(structured)→ 1.34 GA |
| 资源模型 | Extended Resource,整数计数 | ResourceClaim,可带属性选择器 |
| 调度参与 | kube-scheduler 只看数量,插件不参与 | 驱动提供结构化参数,调度器原生过滤 |
| 分数/属性 | 不支持 | 原生支持(按显存、MIG profile 请求) |
| 设备配置 | Allocate 返回 env/mount |
NodePrepareResources 等生命周期钩子 |
| 设备选择时机 | Bind 之后,节点本地 | Bind 之前,调度周期内 |
| 共享与拓扑 | 需借助外部项目(HAMi、Volcano) | 机制内建 |
| 生态成熟度 | NVIDIA/昇腾/网络插件全面支持 | NVIDIA DRA driver 已可用,部分能力 alpha |
现状是过渡态:NVIDIA GPU Operator 支持 DRA Driver 与 Device Plugin 二选一部署,官方原文是「A cluster can have either a GPUCluster resource for DRA or a ClusterPolicy resource for the Device Plugin, but not both」;昇腾的 Ascend Device Plugin 文档标注支持 Kubernetes 1.17.x—1.36.x,处于跟随 DRA 演进的节奏上。
⚠️ 一处时间线要改:原稿写「1.34 GA 后还补上了 Pod 级的资源健康状态上报(同时覆盖 DRA 和 Device Plugin 两种路径)」。实际是——该能力最早(1.31 alpha)是给 Device Plugin 路径做的(KEP-4680);DRA 路径是随 1.34 一起引入的 alpha 特性(不是 GA 之后补的),且到 1.36 才 beta。
DRA 的完整架构(四大支柱、五个特性、DeviceClass / ResourceClaim 的 YAML 示例、迁移路径)见 告别 Device Plugin:深度解析 DRA。
五、大模型时代的三条实战主线
Device Plugin 及其生态在 AI 场景的分化,可以沿三条主线看:训练要的是「整批资源不空转」,推理要的是「单位成本吞吐最大」,具身要的是「确定性时延」。
| 场景 | 核心诉求 | 代表组件 | 解决的问题 |
|---|---|---|---|
| 分布式训练 | 整批同时启动、拓扑对齐 | Volcano、Kueue、KAI Scheduler | Gang scheduling、队列、拓扑感知 |
| 大模型推理 | 单卡承载多模型、显存切分 | HAMi、time-slicing、MIG | GPU 共享、显存/算力限额 |
| 高性能网络 | RDMA/SR-IOV 直通 | sriov-network-device-plugin、k8s-rdma-shared-dev-plugin | 训练通信低延迟 |
| 异构算力 | NPU/GPU/国产卡统一调度 | 昇腾 Ascend Device Plugin、HAMi | 多后端统一接口 |
| 边缘具身 | 云边协同、设备孪生 | KubeEdge(DeviceModel/Device CRD + Mapper) | 边缘设备管理与数据下行 |
| 虚机 GPU | Passthrough / vGPU | kubevirt-gpu-device-plugin(NVIDIA 出品) | VM 场景的 GPU 透传 |
5.1 主线一:分布式训练——gang scheduling 与故障自愈
原生 kube-scheduler 把每个 Pod 当成独立的放置决策,这在「64 个 H100 worker 必须整体启动」的场景下是灾难:调度器先收下 60 个,剩下 4 个 Pending,已启动的 60 个 rank 在 GPU 上空烧,等一个永远不会完成的 rendezvous。
Volcano(2019 年 3 月由华为云开源,调度器基于 kube-batch 演化)为此引入了 PodGroup 抽象,配合队列、DRF 公平共享、网络拓扑感知调度,把「AI 训练任务」变成一等公民。
⚠️ 一处字段名要精确:原稿写「PodGroup 抽象和
minAvailable语义」。实际是——PodGroup 的 spec 字段叫minMember(“minMember indicates the minimum number of pods or tasks running under the PodGroup”),而minAvailable是 VolcanoJob 的字段。两者语义相近但归属不同。
昇腾的 Ascend Device Plugin 在这条线上走得更远——它把 Device Plugin 从「资源上报器」升级成了「故障自愈的参与者」:
-volcanoType参数原生存在(源码main.go里flag.Bool("volcanoType", false, "Specifies whether to use volcano for scheduling")),Helm chart 默认开启,文档明确「重调度场景下必须使用 Volcano」;-autoStowing支持故障芯片自动隔离、恢复后自动回归——但要注意两个限制:文档已标注该字段「已日落」(deprecated),且仅在volcanoType=true时生效;- NPU 设备重调度:节点或芯片故障后,系统把训练任务重调度到健康设备上、重启训练容器、隔离故障芯片——这是 MindCluster 的断点续训能力,重调度由 Volcano 执行;
- 故障感知走的是订阅而非轮询:插件启动时调用 DCMI 故障订阅接口注册监测,故障发生时由驱动主动上报(驱动不支持订阅时才退化为轮询),而且芯片故障与网络故障都上报(
fault_type含CardNetworkUnhealthy)。
KAI Scheduler 与 Kueue 在这条线上继续演进,但分工要分清:
⚠️ 一处归属要改:原稿写「KAI Scheduler、Kueue 把『配额、抢占、多集群』做进了调度语义」。核实结果——KAI 归属 NVIDIA(来自 Run:ai 的调度引擎,现为 CNCF Sandbox),有分层队列/配额、抢占、DRF、拓扑感知,但**「多集群」在 KAI 文档里查无**;「多集群」是 Kueue 的 MultiKueue(“dispatch workloads to other clusters with free capacity”)。
5.2 主线二:推理服务——把「一 Pod 一卡」改成「一卡 N 片」
推理是 GPU 共享需求最强烈的地方。一个 4B 模型只占 8GB 显存,独占一张 80GB 的 A100 是巨大的浪费。当前解法分三层:
Time-slicing:NVIDIA device plugin 原生支持,通过 ConfigMap 定义 replicas,把一张物理卡暴露成 N 个逻辑副本,由 CUDA 驱动做时间片轮转。实现最轻,但节点上的 GPU 数量会被虚增为 replicas × 物理卡数(见第三节的更正),而且没有显存和故障隔离。
MIG:A100/H100 等硬件级切分,每个实例有独立的显存和 SM,隔离由硬件保证。适合强隔离需求,但切分 profile 需要预先配置、实例数有限。
HAMi:在 Device Plugin 之上做了更彻底的改造,四步链路是——Mutating Webhook 改写 Pod(如果 Pod 请求 HAMi 资源且没指定别的调度器,就把 schedulerName 设成 hami-scheduler)→ 自研调度器做 Filter/Bind + 内部设备打分 → 设备插件在 Allocate() 时读 annotations,注入 libvgpu.so、/etc/ld.so.preload 和虚拟化环境变量(如 CUDA_DEVICE_MEMORY_LIMIT_0=3000m)→ 容器内 libvgpu.so 拦截 CUDA/NVML 调用执行实际限额。Pod 可以这样申请:
1 | resources: |
gpumem 单位是 MiB、gpucores 是百分比——两个都在官方文档里核实过。
⚠️ 三处更正:
- 删掉「4 张降到 2 张、利用率 50%→100%」这组数字——HAMi 官方 case-studies、文档、博客里都没有这个表述。官方最接近的数字是 SNOW Corp 的「2× fewer GPUs」和 DaoCloud 的「>80% average GPU utilization」,口径与原文不同,不能混用。
- 删掉「NVIDIA KAI + HAMi:8 张 A100 跑 16 个 Qwen3-4B,每个半卡」——KAI×HAMi 集成本身真实存在(HAMi-core 自 KAI Scheduler v0.16.4 起被内置用于显存硬隔离),但没有这个实验。HAMi 官方 benchmark 用的是 2×A100-SXM4-40GB 跑 Qwen3-8B,模型和规模都对不上。
- 「HAMi 用统一资源键前缀做异构屏蔽」不成立——各厂商用各自的资源名前缀。官方文档明写 “Other vendors use their own resource names”:NVIDIA 是
nvidia.com/gpumem,昇腾是huawei.com/Ascend910B3-memory,寒武纪是cambricon.com/mlu.smlu.vmemory。异构的「统一」体现在同一套 API 与管理体验,不是资源键前缀统一。一处核实为正确、保留:HAMi 支持的设备类型数确实是 14 类——官方 device-supported 表(标注 v2.10.0)正好 14 行:NVIDIA GPU、AMD GPU(ROCm)、AWS Neuron、Biren、Cambricon MLU、Enflame GCU、Huawei Ascend NPU、Hygon HCU、Iluvatar、Kunlunxin XPU、MetaX、Mthreads、Teco DPU、Vastai。
HAMi 的底层原理(LD_PRELOAD 劫持
cuMemAlloc的完整链路、五步调度链路、DRA 模式)见 深入解析 K8s GPU 复用:从通用方案到 HAMi 异构架构全解。
5.3 主线三:具身智能——边缘设备管理与确定性时延
具身智能把算力需求从数据中心推到了物理世界,其 GPU 负载结构与大模型推理完全不同:不是按用户请求量弹性波动,而是随部署单元数线性扩展、需要确定性时延——一条每分钟处理 1000 个零件的产线机械臂,无法容忍 500ms 的 GPU 调度抖动。
(1)设备接入与数字孪生:KubeEdge 的声明式设备模型
KubeEdge 用 DeviceModel / Device 两个 CRD 描述物理设备(传感器、摄像头、机械臂控制器),通过 Mapper 组件对接具体协议。完整链路是:
云端创建 Device CRD → Device Controller 创建一个 ConfigMap 存放设备属性与 visitor 定义 → 借助 edge controller 既有的 ConfigMap 同步机制下发到边缘 → 边缘 Mapper 容器读取该 ConfigMap,据此与物理设备通信 → DeviceTwin 把孪生状态回报云端
下发通道是 CloudHub(websocket server)→ EdgeHub(websocket client)。
⚠️ 一处协议归类要改:原稿把 MQTT 列为 Mapper 对接的设备协议。MQTT 不是设备侧 mapper 协议——它是 EventBus / 数据推送(
pushMethod.mqtt)用的消息通道。Mapper 侧真正对接的协议是官方 mappers-go 实现的那几个:Modbus、Bluetooth、ONVIF、OPC-UA。
(2)边缘 GPU 与仿真算力
边缘节点跑推理需要 GPU 直通(device plugin 标准用法),训练侧则依赖 GPU 集群上的物理仿真——Isaac Sim 这类高保真接触动力学仿真本身就是 GPU 密集型,仿真精度直接影响 sim-to-real 迁移成功率。
(3)云边协同调度
KubeEdge 的核心卖点之一是边缘自治——官方原文:
Edge Autonomy: Ensure edge nodes run autonomously and the applications in edge run normally, when the cloud-edge network is unstable or edge is offline and restarted.
⚠️ 一处表述要收:原稿写「KubeEdge 在边缘节点本地保留完整的 Kubernetes API 能力」。这过度了——边缘侧是 Edged(轻量 kubelet)+ MetaManager(把元数据存到轻量数据库 SQLite),并不是在边缘跑一个完整的 kube-apiserver。官方自己的说法是「Kubernetes-native support … just like a traditional Kubernetes cluster in the Cloud」。
「云断边不断」这个结论是对的,只是机制要说准。
(4)具身场景的另一个延伸:KubeVirt 路线
把 GPU 通过 vfio-pci 直通给虚拟机,配合 kubevirt-gpu-device-plugin 把 VFIO 设备和 vGPU 暴露给 VM 调度。对于需要完整操作系统隔离的具身仿真环境或传统工业软件,这是比容器更现实的选择。
⚠️ 一处归属要改:
kubevirt-gpu-device-plugin属于 NVIDIA(github.com/NVIDIA/kubevirt-gpu-device-plugin),不是 KubeVirt 组织的项目——github.com/kubevirt/kubevirt-gpu-device-plugin这个仓库不存在。
六、多调度器真的会在哪里冲突?
既然 §2.3 已经确立了「调度器不调用 Allocate」,那么一个自然的追问是:多个调度器共存时,会在哪里冲突?
答案:冲突不在 Allocate,而在 Assume。
- Allocate 侧不可能冲突:它由目标节点的 kubelet 调用,每节点一个 kubelet、串行处理 SyncPod——节点侧只有一个写者。
- 冲突发生在 Assume/Reserve 阶段:调度器算「这个节点还剩几张卡」,不是读节点上的实时值,而是拿自己 informer 缓存里的 Pod 列表求和,再用节点 status 里的 capacity 去减。两个调度器各有各的缓存、同步有先后——A 把最后一张卡 assume 掉(本地缓存 +1),B 的缓存还没看到那个 Pod,于是也 assume 了同一张卡,两个都 Bind 成功。
后果比想象的轻,但形态很坑。 kubelet 会兜住这一层:DeviceManager 用 kubelet_internal_checkpoint 记账,devicesToAllocate 会跳过已在 allocatedDevices 里的设备 ID——同一个 /dev/nvidia0 不会分给两个 Pod。
所以失败形态不是「两个 Pod 悄悄共享一张卡」,而是:
第二个 Pod 卡在
ContainerCreating,报Insufficient nvidia.com/gpu。
而这恰好呼应 §2.4 那条语义——Allocate 失败不会解绑,Pod 留在节点上按 backoff 重试。于是它变成一个僵局:Pod 既没跑起来,也没被重新调度。
这个僵局其实是「调度与分配割裂」的意外好处:
上游允许多写者,下游保证单写者。 无论上面挂几个调度器、缓存多乱,节点侧只有一份权威账本。
⚠️ 一个我没有核实的推论:DRA 把设备分配提前到调度周期内、写回
ResourceClaim.status.allocation——设备是在 API Server 上的对象里定好的,kubelet 的NodePrepareResources更像是「按单准备」而不是「从池子里挑」。这意味着 DRA 下那道节点侧的最后防线可能变薄了:同样的陈旧缓存问题,在 Device Plugin 下会退化成「Pod 卡住」,在 DRA 下会不会退化成「两个 ResourceClaim 拿到同一张卡」,取决于 DRA driver 的实现。这一点建议实测,我没有找到明确结论。
四层解法,从浅到深:
| 层 | 手段 | 能解决什么 |
|---|---|---|
| Pod 层 | spec.schedulerName 显式声明 |
解决「谁处理这个 Pod」——但不解决共享节点视图 |
| 配额层 | namespace + ResourceQuota | 在配额层面划界,防止跨调度器的资源争抢 |
| 调度器层 | 缩短 informer 同步延迟、PriorityClass 全局对齐 | 缩小冲突窗口,不能消除 |
| 节点层(最彻底) | 节点池物理隔离:taint + nodeSelector/affinity | 两个调度器管的节点集合不重叠 → 视图冲突从根上消失 |
只有节点池物理隔离能根治,其余都是缩小窗口。
多调度器共存的其他两个坑(抢占互相不知情、拓扑约束不互通)、四种共存模式与组合拳建议,见 Kubernetes 多调度器共存实践指南 与 跨调度器抢占:机制、冲突根因与选型实践。本节只补「从 Bind/Allocate 时序看冲突」这个角度。
七、算力池化的三层解构
把上面所有内容收拢,今天所谓的「AI 算力池化」其实可以解构成三层,每层的成熟度和瓶颈都不同:
设备虚拟化层——把物理卡切成可调度的最小单元。 硬件路线(MIG、vGPU)隔离强但切分粒度粗;软件路线(HAMi vGPU、time-slicing)灵活但算力隔离依赖驱动时间片,硬限制能力因厂商和驱动世代而异。这一层的根本约束是:软件虚拟化无法凭空造出硬件没有提供的隔离边界。
调度语义层——表达「要什么样的资源、怎么放、故障怎么办」。 Device Plugin 把这层做成了黑盒,DRA 用结构化参数把它拉回了调度器原生视野,Volcano/KAI 在其上补齐了 gang、队列、抢占、拓扑。演进方向很清晰:从「数量调度」走向「属性与拓扑调度」。
异构屏蔽层——让 NVIDIA、昇腾、海光、寒武纪在同一套 YAML 下可互换。 HAMi 用统一的管理体验做了一层薄封装,代价是各后端能力差异需要用户自己核对支持矩阵(连资源键前缀都是各厂商自己的)。这一层最难标准化,因为「通用抽象」和「发挥每家硬件特性」天然冲突。
如果要跨域借鉴一个视角:Device Plugin 的 ListAndWatch 是事件驱动的状态上报,但硬件侧的感知大多是周期轮询——这和 TCP 拥塞控制里「ACK 驱动的窗口调整 + 发送方周期性探测带宽」的结构是同构的。算力调度面对的核心张力也一样:
如何用最小的探测开销,在最短时间内逼近真实的资源边界,又不在边界附近反复震荡。
DRA 的结构化参数、Volcano 的拓扑感知、HAMi 的动态 MIG 实例缓存复用,本质上都是在优化这个「探测—收敛—避免震荡」的闭环。
八、写在最后
Device Plugin 用一个 gRPC 接口加一个 Unix socket,撑起了近十年异构算力接入的事实标准;它的局限(整数、黑盒、无拓扑)则催生了 DRA、Volcano、HAMi 这一整代新组件。
从 2017 到 2025,从 alpha 到 GA 到被替代,Device Plugin 的生命周期本身就是云原生基础设施演化的一个缩影:
机制解决「接入」,演进解决「表达」,生态解决「场景」。
理解了这三层的关系,就能在任何新硬件、新调度器出现时,快速找到它在这张地图上的位置。
相关阅读
- 为什么 Device Plugin 不必比 kubelet 先启动:节点级组件的启动顺序与容错——姊妹篇:5 分钟注册宽限期的精确行为、capacity/allocatable 的差异、Karpenter 循环扩容的真实事故
- NVIDIA device plugin for Kubernetes 原理分析——注册服务、ListAndWatch、Allocate 的源码级拆解
- 告别 Device Plugin:深度解析 DRA——DRA 四大支柱、五个特性与完整 YAML 示例
- 深入解析 K8s GPU 复用:从通用方案到 HAMi 异构架构全解——LD_PRELOAD 劫持与 HAMi 调度链路
- Kubernetes 多调度器共存实践指南——四种共存模式与三个坑
- 跨调度器抢占:机制、冲突根因与选型实践——跨调度器抢占为什么没有原生支持
- DRA 拓扑感知调度——DRA 的拓扑对齐能力
- 从 kubelet 到沙盒:高级运行时与低级运行时的映射全景——Device Plugin 与 DRA 在运行时栈里的位置
核实说明:本文的版本脉络核对自 Kubernetes CHANGELOG(1.8/1.10/1.11/1.32)与 KEP 仓库(KEP-3573 Device Plugins、KEP-3063 classic DRA、KEP-4381 Structured Parameters、KEP-4680 Resource Health);gRPC 接口核对自
staging/src/k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1/api.proto;机制细节核对自 Device Plugins 官方概念页与 NVIDIA k8s-device-plugin 源码;DRA 语义核对自官方 DRA 概念页与 GPU Operator 文档;生态部分核对自 HAMi 官方文档与源码、Ascend mind-cluster 仓库、KubeEdge 官方文档、Volcano 官方文档、KAI Scheduler / Kueue 仓库。原稿更正汇总:gRPC 方法数(5 个而非 4 个,漏了
PreStartContainer)、time-slicing 会让节点 GPU 数虚增为 replicas×物理卡数(而非「仍显示 1」)、「DRA replaces device plugins」引文查无出处、ResourceClass已被DeviceClass取代且应补ResourceSlice、资源健康状态上报的时间线(DRA 路径是 1.34 alpha 而非 GA 之后)、Unhealthy 下降的是allocatable而非capacity、Volcano 的字段归属(PodGroup 用minMember,minAvailable属 VolcanoJob)、「多集群」应归于 Kueue 而非 KAI、昇腾-autoStowing已日落且仅volcanoType=true时生效、KubeEdge 的 MQTT 归类(是 EventBus 通道非 Mapper 协议)与「完整 K8s API 能力」的过度表述、kubevirt-gpu-device-plugin归属 NVIDIA。另有两组具体数字因查无出处已删除(HAMi 的「4→2 张 / 50%→100%」与「8×A100 跑 16 个 Qwen3-4B」)。一处核实为正确并保留:HAMi 支持的设备类型确为 14 类。