Kubernetes GPU 调度与设备插件排障实战:从 Device Plugin 到 CDI
1. 从一张"显卡插上去却没人用"的工单说起
先说个真实场景。集群里新到了一批卡,物理机 lspci 能看到设备,nvidia-smi 也正常输出,但业务方提交的 Pod 死活起不来,事件里就一句 Insufficient nvidia.com/gpu。运维第一反应是"卡坏了",第二反应是"驱动没装",折腾半天发现卡好好的、驱动也好好的,问题出在 Kubernetes 压根不知道这张卡的存在。
这个场景几乎是我这几年处理 GPU 集群问题时遇到频率最高的一类。它背后牵扯的东西比想象中多:设备怎么被 K8s 发现、怎么被调度、怎么被容器真正拿到手里、驱动和运行时怎么配合、版本怎么对齐。任何一个环节断掉,表现都是"卡在,但用不了"。
这篇东西就是围绕这条链路展开的。我会从选型开始讲——到底该用原生 Device Plugin 还是直接上 GPU Operator,CDI 又是什么时候该引入;然后拆原理,把 kubelet 的 device manager、Allocate 接口、容器运行时这几层讲透;最后落到排障,把那些"看起来像驱动问题其实是调度问题""看起来像调度问题其实是 CDI 没开"的坑一个个摆出来。适合正在搭 GPU 集群、或者已经被 GPU 调度问题折磨过一轮的同行看,新手也能顺着链路建立完整认知。
需要提前说明的是,下面涉及的版本、参数、命令都基于我实际跑过的环境(K8s 1.26 到 1.29、NVIDIA 驱动 535/550 系列、containerd 1.7),不同版本细节会有差异,但排查思路是通用的。
2. 选型这件事:原生 Device Plugin、GPU Operator 与 CDI 的边界
2.1 原生 Device Plugin 方案适合什么样的团队
原生方案说白了就是:你自己装驱动、自己装 nvidia-container-toolkit、自己部署 nvidia-device-plugin 的 DaemonSet,然后配 RuntimeClass。它的优点是链路透明,每一层你都知道装了什么、版本是多少,出问题好定位。
我一般建议这几类团队走原生路线:集群规模不大(几十张卡以内)、驱动版本长期固定、有专人维护节点镜像、对"黑盒组件"比较警惕。因为 GPU Operator 会往集群里塞一堆 DaemonSet 和 CRD,一旦它自己出问题,排查成本反而更高。
原生方案的核心组件其实就三个:
- 节点侧驱动:
nvidia-driver,负责让内核认识卡。 - 容器运行时钩子:
nvidia-container-toolkit,负责在容器启动时把设备节点和驱动库注入进去。 - K8s 侧插件:
nvidia-device-plugin,负责把 GPU 作为扩展资源上报给 kubelet。
三者版本必须互相兼容,这是原生方案最容易翻车的地方。我见过驱动 550 配 toolkit 1.11 结果容器里 libcuda.so 找不到的案例,就是因为 toolkit 版本太老不认识新驱动的库路径。
2.2 GPU Operator 到底帮你做了什么
GPU Operator 本质是把上面那三个组件(外加 DCGM、Node Feature Discovery、MIG Manager 等)全部 Operator 化,用 CRD 声明式管理。你写一个 ClusterPolicy,它就自动在带 GPU 的节点上把驱动、toolkit、plugin 全装好。
它最大的价值不是"省事",而是版本矩阵被官方验证过。NVIDIA 会保证某个 Operator 版本对应的驱动、toolkit、plugin 组合是兼容的,这对没有专职 GPU 运维的团队来说是刚需。
但有几个坑必须提前知道:
- Operator 默认会自己装驱动,如果你的节点镜像里已经有驱动,必须显式设置
driver.enabled=false,否则它会尝试覆盖,轻则报错重则把节点搞挂。 - 它依赖 Node Feature Discovery 打标签,如果 NFD 没正常工作,Operator 会认为节点没有 GPU,什么都不部署。
- 升级 Operator 版本时,它会滚动重启所有 GPU 相关 DaemonSet,正在跑训练的 Pod 可能被驱逐,务必配好 PDB 和升级窗口。
2.3 CDI 是什么,什么时候必须开
CDI(Container Device Interface)是容器设备注入的一套标准规范。在它出现之前,设备注入靠的是运行时钩子(nvidia-container-runtime)在容器创建时动态改 OCI spec。CDI 则把"这个设备需要哪些节点、哪些库、哪些环境变量"提前写成一份声明文件,运行时直接读。
为什么现在越来越强调 CDI?因为 K8s 1.26 之后,kubelet 的 Device Plugin 机制开始支持把设备信息以 CDI 形式传给运行时,链路更干净,也更容易和 MIG、时间片共享这些高级特性配合。
判断要不要开 CDI,我的经验是:
| 场景 | 是否建议开 CDI |
|---|---|
| 单卡整卡分配,版本较老 | 可不开,传统钩子够用 |
| 需要 MIG 切分 | 强烈建议开 |
| 需要 GPU 时间片共享 | 必须开 |
| containerd 1.7+ / CRI-O 1.25+ | 建议开,原生支持好 |
| 集群里混用多种设备类型 | 建议开,声明式更清晰 |
开启方式不复杂,containerd 里在 enable_cdi = true 并配好 cdi_spec_dirs,然后 Device Plugin 侧加 --enable-cdi 参数即可。但要注意,开了 CDI 之后,原来依赖运行时钩子的老镜像可能行为变化,需要回归测试。
3. 拆开看:一张 GPU 从物理设备到 Pod 里可用,中间发生了什么
3.1 kubelet 的 Device Manager 是怎么记账的
很多人以为 GPU 调度是 scheduler 直接管设备,其实不是。scheduler 只认"扩展资源"这个数字,比如节点上报 nvidia.com/gpu: 4,它就当有 4 个可分配单位。真正决定"哪个 Pod 用哪张卡"的是 kubelet 的 Device Manager。
流程是这样的:Device Plugin 通过 gRPC 向 kubelet 注册,声明自己管理 nvidia.com/gpu 这个资源,并上报设备列表(每个设备有 ID 和健康状态)。kubelet 的 Device Manager 维护一张分配表,Pod 调度到节点后,kubelet 在 admit 阶段调用 Device Plugin 的 Allocate 接口,拿到"这个容器需要挂哪些设备节点、哪些环境变量"。
这里有个关键点:Device Manager 的分配是节点本地行为,scheduler 不参与。所以如果你看到 Pod 被调度到某节点却起不来,问题大概率在 Device Manager 到 Plugin 这一段,而不是调度器。
3.2 Allocate 接口返回的到底是什么
Allocate 返回的是一组 ContainerAllocateResponse,里面包含:
- 设备节点路径:比如
/dev/nvidia0、/dev/nvidiactl、/dev/nvidia-uvm。 - 环境变量:比如
NVIDIA_VISIBLE_DEVICES=0。 - 挂载点:驱动库目录,比如
/usr/lib/x86_64-linux-gnu/libcuda.so。
传统模式下,这些信息由 kubelet 传给容器运行时,运行时再调用 nvidia-container-runtime 钩子去实际注入。CDI 模式下,Plugin 会把这些信息写成 CDI spec 文件,运行时直接读文件,不再走钩子。
理解这一点非常重要,因为排障时你要判断的正是"信息有没有正确传到运行时"。如果 Allocate 返回正常但容器里没设备,问题就在运行时侧;如果 Allocate 根本没被调用,问题在 Device Manager 或 Plugin 注册环节。
3.3 容器运行时这一层最容易出什么岔子
运行时这层的典型故障有三类:
第一类是 RuntimeClass 没配对。如果你用的是 nvidia 这个 RuntimeClass,但 containerd 配置里对应的 runtime 名字写错了,容器会用默认 runc 启动,结果就是设备节点没注入,容器里 nvidia-smi 报"command not found"或"no devices found"。
第二类是 toolkit 版本与驱动不匹配。toolkit 负责告诉运行时"驱动库在哪",如果它内置的路径列表和实际驱动安装路径对不上,就会出现容器里找不到 libnvidia-ml.so 的情况。这个错误很隐蔽,因为节点上一切正常。
第三类是 CDI spec 目录权限问题。CDI 文件通常放在 /etc/cdi 或 /var/run/cdi,如果 containerd 进程没有读权限,它会静默忽略这些文件,表现就是"CDI 开了但没生效"。
我一般排查运行时这层,会先看 containerd 日志里有没有 CDI 或 nvidia 相关报错,再进容器 ls /dev/nvidia* 确认设备节点在不在,最后 ldconfig -p | grep nvidia 看库能不能找到。三步基本能定位到具体是哪一类。
4. 排障实战:那些"看起来是 A 其实是 B"的经典案例
4.1 案例一:Pod 一直 Pending,事件说资源不足
这是最经典的一类。表面看是"节点没卡了",但实际可能有好几种原因。
排查链路:
- 先
kubectl describe node <node>看Allocatable里有没有nvidia.com/gpu。如果没有,说明 Device Plugin 没上报成功。 - 看 Device Plugin 的 Pod 日志,有没有
Registered device plugin字样。没有的话,检查它和 kubelet 的 socket 路径是否一致(默认/var/lib/kubelet/device-plugins)。 - 如果上报了但数量不对,检查是不是有卡处于 unhealthy 状态。Plugin 会把不健康的卡排除,导致可分配数变少。
- 如果数量对但 Pod 还是 Pending,看是不是有别的 Pod 占着但没释放。这种情况常见于 Pod 卡在 Terminating,Device Manager 的分配表没回收。
我遇到过一次特别隐蔽的:节点上报了 4 张卡,但实际只有 3 张能用,第 4 张因为 ECC 错误被标记 unhealthy。业务方申请 4 卡的任务永远 Pending,而 nvidia-smi 看四张卡都在。最后是靠 Plugin 日志里的 unhealthy device 关键字才定位到。
4.2 案例二:容器起来了,但 nvidia-smi 报 "No devices were found"
这个报错和上一个完全不同,Pod 是 Running 的,只是容器里看不到卡。
根因通常有三个:
- RuntimeClass 没指定或指定错。Pod spec 里漏了
runtimeClassName: nvidia,容器用默认运行时启动,自然没设备。 - CDI 和钩子冲突。同时开了 CDI 又保留了老钩子,两者注入逻辑打架,结果设备节点挂了一半。
- 设备节点权限问题。容器以非 root 运行,而
/dev/nvidia*权限是660 root:root,非 root 用户读不到。这种情况要么调整设备权限,要么在 Pod 里配securityContext。
我个人的经验是,遇到这个报错先别急着查驱动,先 kubectl get pod -o yaml | grep runtimeClassName,十有八九是这里的问题。确认没问题再进容器 ls -l /dev/nvidia* 看节点在不在、权限对不对。
4.3 案例三:MIG 模式下资源数量对不上
MIG(Multi-Instance GPU)是把一张物理卡切成多个实例。开了 MIG 之后,节点上报的资源名会变成 nvidia.com/mig-1g.5gb 这种带 profile 的形式,数量也不再是物理卡数。
常见坑是:MIG 配置改了但 Plugin 没重启。MIG 的切分是在节点侧用 nvidia-smi mig 做的,Plugin 启动时读取当前 MIG 布局并上报。如果你改了切分但没重启 Plugin,它上报的还是旧布局,调度就会错乱。
另一个坑是 MIG 和 CDI 的配合。MIG 实例的设备节点路径和整卡不同,必须靠 CDI 才能正确注入。如果没开 CDI,容器里拿到的可能是整卡而不是 MIG 实例,隔离就失效了。
排查 MIG 问题,我一般按这个顺序:nvidia-smi -L 看物理卡,nvidia-smi mig -lgip 看支持的 profile,nvidia-smi mig -lgi 看当前实例,然后对比 kubectl describe node 里的资源名和数量。三者对不上,就是配置和上报不同步。
4.4 案例四:升级驱动后集群集体失灵
这个坑杀伤力最大。驱动升级后,节点重启,nvidia-smi 正常,但所有 GPU Pod 起不来。
原因通常是 toolkit 和 plugin 还是旧版本,不认识新驱动的库路径或设备节点命名。NVIDIA 驱动大版本升级有时会改 libnvidia-* 的路径或新增设备节点,老 toolkit 的注入规则就失效了。
我的做法是:驱动升级必须和 toolkit、plugin、Operator 版本一起规划,参考官方的兼容矩阵,不要单独升驱动。升级顺序也有讲究——先升 toolkit 和 plugin,再升驱动,最后重启节点。反过来做,中间会有一段所有 GPU Pod 都不可用的窗口。
5. 版本矩阵与配置清单:一份可以直接抄的对照表
5.1 组件兼容性对照
下面这张表是我根据实际跑过的组合整理的,不是官方矩阵的照搬,而是"我验证过能跑"的组合:
| K8s 版本 | containerd | 驱动 | toolkit | device-plugin | 是否开 CDI |
|---|---|---|---|---|---|
| 1.26 | 1.6.x | 535 | 1.13 | 0.14 | 否 |
| 1.27 | 1.7.x | 535 | 1.14 | 0.14 | 可选 |
| 1.28 | 1.7.x | 550 | 1.15 | 0.15 | 建议 |
| 1.29 | 1.7.x | 550 | 1.16 | 0.15 | 建议 |
需要强调的是,驱动版本和 CUDA 版本是两回事。驱动向下兼容多个 CUDA 版本,但容器里的 CUDA 版本由镜像决定。所以选型时先确定业务需要的 CUDA 版本,再反推最低驱动版本,最后选 toolkit 和 plugin。
5.2 关键配置项清单
containerd 侧需要确认的配置:
Device Plugin 侧的关键参数:
--enable-cdi=true:开启 CDI 模式。--fail-on-init-error=true:初始化失败直接退出,避免"假装正常"。--pass-device-specs=true:把设备 spec 透传给运行时,CDI 模式下需要。
Pod 侧的最小配置:
注意 resources.limits 里写 GPU 是必须的,写在 requests 里不生效——这是 K8s 扩展资源的规则,requests 会被自动设成和 limits 相同。
6. 几个我踩过之后才明白的细节
6.1 关于时间片共享的取舍
GPU 时间片共享(time-slicing)能让多个 Pod 共用一张卡,配置上是在 Device Plugin 的 ConfigMap 里声明 sharing.timeSlicing.resources。听起来很美,但有几个现实问题:
- 没有显存隔离。多个 Pod 共享同一张卡的显存,一个 Pod 显存爆了会拖垮其他 Pod。
- 没有算力隔离。时间片是轮转调度,一个重负载 Pod 会明显影响同卡其他 Pod 的延迟。
- 监控变复杂。DCGM 上报的指标是整卡维度,很难拆到单个 Pod。
我的建议是:时间片共享只适合推理类、负载轻、对延迟不敏感的场景。训练任务千万别用,一个任务跑飞全卡遭殃。真要做隔离,上 MIG,虽然切分粒度粗,但隔离是硬件级的。
6.2 关于节点标签和污点
GPU 节点通常要打污点,防止普通 Pod 调度上去占资源。但污点配置有个细节:如果你用 NoSchedule,已经调度上去的 Pod 不受影响;用 NoExecute 会驱逐现有 Pod。生产环境我一般用 NoSchedule,配合节点亲和性让 GPU Pod 显式声明。
标签方面,除了 K8s 自带的 nvidia.com/gpu.present,我习惯再加几个自定义标签,比如 gpu-type=a100、gpu-mig=enabled,方便业务方用 nodeSelector 精确选节点。这比让业务方去猜资源名要友好得多。
6.3 关于监控和告警
GPU 集群不监控等于裸奔。我一般会部署 DCGM Exporter,把 GPU 利用率、显存、温度、ECC 错误、XID 错误都采集到 Prometheus。
几个必须配的告警:
- XID 错误:这是驱动层报的硬件/驱动异常,出现就要关注,某些 XID 码意味着卡要挂了。
- ECC 双比特错误:不可纠正错误,出现基本要换卡。
- 显存使用率持续 100%:可能是显存泄漏。
- GPU 利用率长期为 0 但 Pod 在跑:可能是任务卡死或没真正用上 GPU。
我踩过一次坑:业务方反馈任务慢,查了半天发现容器里根本没拿到 GPU,任务在 CPU 上跑。如果当时有"Pod 声明了 GPU 但 GPU 利用率为 0"的告警,五分钟就能定位。
6.4 关于镜像里的 CUDA 版本
容器里的 CUDA 版本由镜像决定,和节点驱动是解耦的。但有个约束:驱动版本必须 >= 镜像 CUDA 版本要求的最低驱动。比如 CUDA 12.2 要求驱动至少 535,如果你节点是 525,容器里就会报 "CUDA driver version is insufficient"。
这个报错经常被误判成"驱动没装",其实是版本不够。排查时先 nvidia-smi 看节点驱动版本,再看镜像的 CUDA 版本,对照 NVIDIA 的兼容表确认。
7. 写在最后的一点个人体会
GPU 集群这套东西,难的不是装,是"装完之后怎么知道它真的在工作"。我见过太多集群,nvidia-smi 一切正常,但业务方实际用不上卡,或者用上了但性能只有一半。根因往往不在某个组件坏了,而在组件之间的衔接没对齐。
我的习惯是,每搭完一个 GPU 集群,必做三件事:跑一个最小 CUDA 容器验证设备可见,跑一个多卡任务验证 NCCL 通信,跑一个长时间任务观察显存和利用率曲线。这三件事过了,才算真正交付。
另外,版本管理一定要做台账。驱动、toolkit、plugin、Operator、CUDA、K8s,六个版本记清楚,升级前先查兼容矩阵。GPU 这套生态的版本耦合比普通中间件紧得多,凭感觉升级迟早出事。
至于 CDI,我的态度是:新集群直接开,老集群评估后再开。它是趋势,早用早省心,但迁移时一定要做回归测试,别在生产窗口里第一次验证。