为什么 Device Plugin 不必比 kubelet 先启动:节点级组件的启动顺序与容错
一句话结论:Device Plugin Pod 做不到、也不需要做到比 kubelet 先启动。Kubernetes 为这条链路设计的容错机制是「kubelet 兜底等待 + 插件自动重注册」——靠的是反方向的容错,而不是启动顺序的保证。
节点级组件的启动顺序靠 systemd 依赖图(kubelet 之前),Pod 级组件的顺序靠调度屏障 + 容错重试(kubelet 之后),而不是试图让后者插入前者。
这篇是 Device Plugin 的十年 的姊妹篇。那篇讲机制与问题域,这篇讲启动顺序与容错——也就是「这套机制解决了什么、又故意没解决什么」。
本文的行为描述逐条核对自 Kubernetes 官方文档与 release-1.34 源码。核对中发现原稿有五处需要更正,其中一处是把 capacity 与 allocatable 搞反了,已在文中以 ⚠️ 标注。
一、为什么顺序不能反
原因很直接:kubelet 每次启动都会删除 /var/lib/kubelet/device-plugins/ 目录下的所有既有 Unix socket,然后才重新创建自己的 kubelet.sock 等待注册。
官方 Device Plugins 文档「Handling kubelet restarts」一节的原文:
A device plugin is expected to detect kubelet restarts and re-register itself with the new kubelet instance. A new kubelet instance deletes all the existing Unix sockets under
/var/lib/kubelet/device-plugins(the hardcoded path for device plugins) when it starts. A device plugin can monitor the deletion of its Unix socket and re-register itself upon such an event.
源码里的顺序也印证了这一点——server.go 的 Start() 先清理目录、再监听 socket:
1 | // pkg/kubelet/cm/devicemanager/plugin/v1beta1/server.go |
而 CleanupPluginDirectory() 的注释写得很直白:
1 | // CleanupPluginDirectory is to remove all existing unix sockets |
(一个细节:它删的是该目录下的 socket 文件,会跳过 checkpoint 文件——不是无差别删目录。)
所以如果插件先于 kubelet 启动,它注册用的 socket 会被 kubelet 直接清掉,注册等于作废。 正确的顺序必须是 kubelet 先起、插件后注册,这个方向无法反转。
二、节点冷启动的完整时序
timeline
title 节点冷启动时间线
section 系统层
systemd : 加载 unit 依赖图 (containerd → network)
containerd : 容器运行时就绪
section kubelet 层
kubelet 启动 : 删除旧 device-plugin socket,重建注册目录
: 读取 staticPodPath 拉起静态 Pod(控制面)
: 启动内部 DeviceManager,扫描注册目录等待插件
section 调度层
节点 NotReady → Ready : 等 CRI RuntimeReady + CNI NetworkReady
DaemonSet Controller : 发现新节点,创建 device-plugin Pod
kube-scheduler : 把 device-plugin Pod bind 到该节点
kubelet(本节点) : 拉起 device-plugin 容器
插件 Register : 写入自己的 socket,通过 kubelet.sock 发起注册
kubelet : ListAndWatch 建流,capacity/allocatable 更新为真实值
section 业务层
业务 Pod : 才能被成功调度到该节点关键在于两条独立路径的汇合点:kubelet 起来后节点并不会立即 Ready——但原因不是等 DeviceManager(见 §四的更正),而是等 CRI 与 CNI;而 device plugin 作为普通 DaemonSet Pod,必须等调度器把它调度到这个节点、再由 kubelet 自己拉起。
所以「插件晚于 kubelet 启动」是常态而非异常。
三、kubelet 如何容忍插件短暂缺席
这是整套机制的核心。分三种情况,每种依赖的机制不同。
3.1 kubelet 自身重启(节点不重启)
kubelet 新实例会删除所有旧 socket,插件的 FS watcher 监测到 kubelet 重启,会自动向新 kubelet 重新注册。
⚠️ 更正一处机制描述:原稿写「NVIDIA 插件同时 watch 自己的 socket 文件和 OS 信号,任何一侧变化都触发重注册」。核实结果——它不监听自己的 socket 被删。
看源码:fsnotify 监听的是
device-plugins/目录,但触发条件被限定为event.Name == kubeletSocket && Op&Create:
1
2
3
4
5
6 case event := <-watcher.Events:
if o.kubeletSocket != "" && event.Name == o.kubeletSocket &&
event.Op&fsnotify.Create == fsnotify.Create {
klog.Infof("inotify: %s created, restarting.", o.kubeletSocket)
goto restart
}即只在
kubelet.sock被创建(= kubelet 重启)时重启插件。插件自己的 socket 被删(Remove事件)不命中这个分支。OS 信号那半是对的,但要区分信号语义:SIGHUP 触发重启(重注册),SIGINT / SIGTERM / SIGQUIT 触发退出。
3.2 插件 Pod 重启(kubelet 不动)—— 5 分钟宽限期
⚠️ 更正(这是本文最实质的一处):原稿写「capacity 置零但保留 endpoint 缓存一小段时间……超时后才把资源从 Node 对象彻底移除」。capacity 和 allocatable 搞反了,而且「超时后移除」也不对。
源码里的实际行为是两段:
| 阶段 | capacity | allocatable | 资源还在 Node 对象里吗 |
|---|---|---|---|
| 插件故障,宽限期内 | 保持 N | → 0 | 在 |
| 宽限期满 | → 0 | → 0 | 仍然在(不是移除) |
宽限期常量是 pkg/kubelet/cm/devicemanager/types.go 里的:
1 | // endpointStopGracePeriod indicates the grace period after an endpoint is stopped |
「超时后置 0 而不是移除」是 v1.10 由 PR #59835 刻意改的,setters.go 里有一行注释解释得很清楚:
Set the capacity of the removed resource to 0 instead of removing the resource from the node status. This is to indicate that the resource is managed by device plugin and had been registered before.
这个更正有实用价值:它解释了为什么节点上 GPU 出问题时你看到的是 nvidia.com/gpu: 0 而不是这个资源条目整个消失——这是设计意图,不是异常。
另一条容易混的路径:真正会出现「capacity 临时降到 0」的是 kubelet 重启场景——
readCheckpoint()为 checkpoint 里登记过的每个资源建一个空的healthyDevices并挂上 stopped endpoint,源码注释写着 “so that the resource capacity will stay zero till the corresponding device plugin re-registers”。这与「插件故障」是两条不同的路径,别混。
宽限期内重注册则恢复:5 分钟内插件重连(PluginConnected() 会用新 endpoint 覆盖旧的 stopped endpoint,stopTime 归零)+ ListAndWatch 重新上报 → capacity/allocatable 恢复为 N。
正在运行的 Pod 不受影响——kubelet 在本地 checkpoint(kubelet_internal_checkpoint)里持久化了 Pod → 设备的映射:
1 | type PodDevicesEntry struct { |
devicesToAllocate() 会先查这个映射:
// Gets list of devices that have already been allocated. This can happen if a container restarts for example.
⚠️ 一处语义要限定:复用是按 Pod UID + 容器名命中的。所以「Pod 重建时命中缓存拿到同一批设备」只在「同 UID 重建」时成立——即容器重启、kubelet 重启、节点重启。一个全新 Pod(新 UID)是重新分配,不命中缓存。
3.3 节点整体冷启动
前两条机制的组合,加上调度器层面的天然屏障——节点 Ready 之前业务 Pod 调度不进来。
⚠️ 但原稿把这道屏障的成因说错了。它写「DeviceManager 未就绪通常意味着节点不会 Ready」,并把这个当屏障。DeviceManager 不参与 Node Ready 判定——详见 §四。所以正确的说法是:这道屏障与 DeviceManager 无关,它挡的是「CRI/CNI 没就绪」,而 DeviceManager 的缺失是穿透这道屏障的——这正是下面那个漏洞的成因。
漏洞的形态:节点已 Ready、DeviceManager 因驱动问题还在 crash-loop,此时业务 Pod 已被调度过来,kubelet 准入时会报:
1 | Node didn't have enough resource: nvidia.com/gpu, requested: 1, used: 0, capacity: 0 |
这个报错串逐字匹配源码(pkg/kubelet/lifecycle/predicate.go):
1 | func (e *InsufficientResourceError) Error() string { |
四、两类服务的启动保障对比
| 维度 | Device Plugin (DaemonSet) | kube-proxy / CNI (DaemonSet) | 控制面 (Static Pod) |
|---|---|---|---|
| 启动主体 | kubelet(经调度器 bind 后) | kubelet(经调度器 bind 后) | kubelet 直读 manifest,不经调度器 |
| 依赖 kubelet 先启动 | 是 | 是 | 是(但 kubelet 读到 manifest 就立即拉起,几乎同步) |
| 常用保障手段 | system-node-critical + 重注册逻辑 |
system-node-critical + readinessGate |
无需保障——静态 Pod 就是设计给节点重启场景的 |
| 缺失时节点行为 | allocatable 归零,节点仍 Ready | 节点 NotReady(NetworkReady 上报失败) |
kubelet 起不来或控制面瘫痪 |
| 依赖的容错机制 | checkpoint + 5 分钟注册宽限期 | kubelet 重启时重建 | kubelet 周期性重读 manifest 目录 |
⚠️ 一处更正(比 capacity/allocatable 那处更影响结论):原稿写「kubelet 的 readiness 上报要等关键组件(CNI、DeviceManager 等)就绪」。
DeviceManager 不参与 Node Ready。 源码里
ReadyCondition()聚合的是:
来源 内容 runtimeErrorsCRI RuntimeReady未就绪 / 运行时 down / PLEG 等 healthChecks 失败networkErrorsCNI 的 NetworkReadystorageErrorsvolume / CSI 插件错误 shutdown 节点关机状态 有意思的是
containerManager.Status确实被传进了ReadyCondition,但它只有一个字段SoftRequirements error,而且只被拼成 WARNING 文本附加到 message 上,不改变 Ready 的 True/False。DeviceManager 唯一暴露的 health checker 只在SystemdWatchdog特性开启时喂给 systemd watchdog,与 Node Ready 无关。所以「节点 Ready 了 GPU 却不可用」不是「屏障的漏洞」,而是本来就没有这道屏障——DeviceManager 只影响它自己管理的扩展资源是否被广告、allocatable 是否为 0。
system-node-critical 的实际作用是保障运行时(资源紧张时不被驱逐、调度优先),而不是保障启动顺序——一个还没被调度到节点的 DaemonSet Pod,优先级再高也起不来(DaemonSet 文档明确 Pod 由默认调度器 bind 到节点)。
五、两个常见误区与排查
误区一:给 device plugin 加 initContainer 或 priorityClassName 就能保证先起
priorityClassName 只影响驱逐与调度优先级,不影响 kubelet 与 DaemonSet 的先后。
而且 GPU 场景下 initContainer 请求
nvidia.com/gpu会引入新的竞态——这不是理论推测,Kubernetes 有一条几乎逐字对应的 issue(#138446,标题 “Kubelet restart triggers UnexpectedAdmissionError for pods using device plugins in both initContainers and containers”):pods have initContainers and containers both requesting the same device plugin resources. After kubelet restart, the pod is killed with:
Allocate failed due to no healthy devices present; cannot allocate unhealthy devices <resource>
After kubelet restart, the device manager tries to re-allocate for the completed initContainer and fails.复现步骤就是「initContainer 与主容器都请求同一 device plugin 资源 + 重启 kubelet」。
UnexpectedAdmissionError这个 reason 常量定义在pkg/kubelet/cm/admission/errors.go(不是kubelet_pods.go或status/)。同类 issue 还有 #123971、#117955、#125579,修复在 PR #137919 / #143126。
真正的顺序保证只能靠「节点 Ready 之前业务 Pod 进不来」这个调度屏障——但如 §三 所说,这道屏障挡不住 DeviceManager 的问题。
误区二:节点 Ready 了 GPU 就能用
节点 Ready 只代表 CRI 和 CNI 就绪,DeviceManager 是完全独立的就绪路径(见 §四)。排障按这个顺序查:
1 | # 1. 看 capacity/allocatable 是不是 0 |
两条命令的核实:checkpoint 文件的路径与文件名完全正确(
/var/lib/kubelet/device-plugins/kubelet_internal_checkpoint,文件名无后缀,目录是device-plugins不是plugins/);kubectl describe node会依次输出Capacity:/Allocatable:/Allocated resources:三节,扩展资源在三节里都有,grep -A2足以命中。
六、一个真实的生产事故:Karpenter 的循环扩容
这条值得单独讲,因为它把上面所有机制串成了一个真实故障。
issue 是真实存在的:aws/karpenter-provider-aws#1459,标题 “Race condition with nvidia-device-plugin?”。原文摘录:
Karpenter provisions a new GPU node correctly, however it also immediately schedules the Riva pod on to this new node.
At this point some sort of timer must start counting down, because approx 60 seconds later the Riva pod transitions state to
OutOfnvidia.com/gpuand is now stuck.This is because the node must start up enough to load the nvidia-device-plugin daemonset (in order to get the
nvidia.com/gpuresource added to the node) however this takes longer than 60 seconds usually.These stuck pods remain and seem to be blocking the nvidia.com/gpu resource as at this point Karpenter attempts to provision another new node, leading to a loop which can result in multiple nodes being repeatedly launched over and over again.
实际事件长这样:
1 | Warning OutOfnvidia.com/gpu 4m52s kubelet |
OutOfnvidia.com/gpu不是排版错误。 它是 K8s 源码里拼出来的真实字符串——pkg/kubelet/lifecycle/predicate.go:
1
2 InsufficientResourcePrefix = "OutOf"
reason = fmt.Sprintf("%s%s", InsufficientResourcePrefix, re.ResourceName)即
"OutOf" + "nvidia.com/gpu"=OutOfnvidia.com/gpu。它是 Pod 的 Reason(同时也是 kubelet event 的 reason),不是 Phase;kubectl get pods在非正常状态下显示的 STATUS 就是它。
「60 秒」的性质要说准:它是 issue 作者观测到的近似耗时(“approx 60 seconds”、“takes longer than 60 seconds usually”),不是 kubelet 里某个 60 秒的硬超时——kubelet 侧没有这个定时器。
根因比「拉镜像慢」更深:issue 讨论串里最终定位到的是 Karpenter 把 Pod 预先 bind 到节点(pre-binding),叠加 kubelet 启动时把扩展资源清零。维护者结论是 Karpenter 不应预绑定(PR #1856 移除了预绑定,PR #1476 让新节点先带上 GPU 资源)。拉镜像慢只是触发这个窗口的诱因之一,不是根因。
正确解法:把顺序依赖显式化到调度约束里
⚠️ 但原稿给的解法里,标签的语义用错了。 原稿说「等插件成功注册并打上
nvidia.com/gpu.present=true后才允许业务 Pod 调度进来」——标签名是对的,语义不对。
nvidia.com/gpu.present是**「节点上有没有 GPU」的发现标签**,由 GPU Operator 的 node-labeling 控制器打上,源自 NFD 的 GPU PCI 标签(feature.node.kubernetes.io/pci-10de.present,10de是 NVIDIA 的 PCI vendor ID)。也就是说——GPU 一被探测到就打上了,早于 device plugin 注册成功。拿它当「插件已注册」的门闩,逻辑上不等价。补充几个容易混的:
- GFD 不打这个标签。GFD 打的是
nvidia.com/gpu.count/gpu.product/gpu.memory/gpu.family等,文档的 “Generated Labels” 表里 grep “present” 零命中。- device plugin 自己打的是
nvidia.com/vgpu.present(vGPU,不是 GPU)。- k8s-device-plugin 的 Helm chart 把
nvidia.com/gpu.present当作强制部署的 opt-in 标签用(写在 nodeAffinity 里)。- 反映 device-plugin 部署状态的标签另有其人:
nvidia.com/gpu.deploy.device-plugin。
所以正确的解法是:业务侧用 nodeSelector + toleration 配合一个真正代表「插件已就绪」的信号——比如 GPU Operator 的 nvidia.com/gpu.deploy.device-plugin,或者干脆用你自己的就绪探针/Operator 在插件注册成功后才打的自定义标签。核心思路不变:把顺序依赖显式化到调度约束里,而不是抢启动顺序。
七、一句话收束
Kubernetes 的设计哲学在这里很清楚:
节点级组件的启动顺序靠 systemd 依赖图(kubelet 之前),Pod 级组件的顺序靠调度屏障 + 容错重试(kubelet 之后),而不是试图让后者插入前者。
理解了这个分层,「怎么保证 X 比 Y 先起」这类问题的答案就自然浮现:要么把 X 做成静态 Pod 进入 kubelet 之前的层,要么接受 X 晚起、用调度约束把依赖 X 的负载挡在门外。
相关阅读
- Device Plugin 的十年:从「让 K8s 认识新硬件」到算力调度的问题域——姊妹篇:机制、四个根本局限、DRA 演进与三条实战主线
- NVIDIA device plugin for Kubernetes 原理分析——注册服务、ListAndWatch、Allocate 的源码级拆解
- 从 kubelet 到沙盒:高级运行时与低级运行时的映射全景——kubelet 与 CRI 的分工
- K8s GPU 复用与 HAMi 异构架构全解——设备插件层做 GPU 切分的实现
核实说明:本文行为描述逐条核对自 Kubernetes 官方文档(Device Plugins 概念页、Pod Priority、DaemonSet、Static Pods、Node Status)与
kubernetes/kubernetesrelease-1.34源码(pkg/kubelet/cm/devicemanager/{manager.go,types.go,endpoint.go,plugin/v1beta1/server.go}、pkg/kubelet/nodestatus/setters.go、pkg/kubelet/lifecycle/predicate.go、pkg/kubelet/cm/admission/errors.go、pkg/kubelet/config/file.go)、NVIDIA k8s-device-plugin 与 GPU Operator 源码、Karpenter issue #1459、Kubernetes issue #60176 / #138446。原稿更正汇总:插件故障时的 capacity/allocatable 行为搞反了(宽限期内是 allocatable→0、capacity 保持 N;宽限期满才 capacity→0 且资源保留在 Node 对象中而非移除)、DeviceManager 不参与 Node Ready 判定、#60176 是 issue 而非 PR(修复者是 PR #60856,v1.9 及更早是「从 Node 对象整条移除」而非「清零」)、NVIDIA 插件不监听自己 socket 被删(只匹配 kubelet.sock 的 Create 事件)且 OS 信号语义要区分(SIGHUP 重注册、SIGINT/TERM/QUIT 退出)、checkpoint 复用按 Pod UID + 容器名命中(仅「同 UID 重建」成立)、
nvidia.com/gpu.present是 GPU 存在性发现标签而非「插件已注册」信号(由 GPU Operator 依 NFD 结果打,早于插件注册)、「60 秒」是观测近似值而非硬超时。一处核实为正确并保留:OutOfnvidia.com/gpu是 K8s 源码拼出的真实字符串,不是排版错误。
