上周有个做算法平台的朋友甩过来一张kubectl describe node的截图,问我为什么节点上明明插着八张卡,Allocatable里却连一个nvidia.com/gpu都看不见。这种场景我见得太多了——机器装好了、驱动也装了、nvidia-smi在宿主机上跑得挺欢,可一进 K8s 就仿佛这些卡根本不存在。k8s集群纳管GPU节点这件事,看着只是"装个插件"的活儿,实际上横跨了宿主机内核、驱动版本、容器运行时、kubelet 设备插件框架、调度器资源模型五个层面,任何一层没对齐,表现出来都是同一个症状:Pod 卡在 Pending,控制器报Insufficient nvidia.com/gpu。
这篇东西面向的是手上真的有一批带卡机器、准备接进集群或者已经接进来但用得不顺的人。我会把整个链路从设计选型讲到实操落地,再讲到线上真正会咬人的那些坑。假设你至少能熟练敲kubectl,对 Pod、Deployment、DaemonSet 这些概念不陌生,但没深挖过 device plugin 的内部机制——这个前提刚好够用。文中涉及的具体版本号、参数值,都是我在实际环境里反复验证过的组合,你抄的时候记得按自己的驱动版本微调。
1. 先想清楚:为什么 GPU 纳管不能照抄 CPU 那套
1.1 GPU 在 K8s 眼里到底是个什么东西
CPU 和内存是 kubelet 自己就能算清楚的资源,内核暴露多少、cgroup 能约束多少,都是现成的。GPU 完全不是这个路数:它是一块挂在 PCIe 总线上的外设,内核里没有"剩余多少张卡"这种抽象,只有/dev/nvidia0到/dev/nvidiaN这一堆字符设备。K8s 的控制面天生不认识这些设备文件,所以它设计了一套扩展机制——Device Plugin,让第三方厂商自己写个小程序,通过 gRPC 向 kubelet 上报"我这台机器上有多少份某种资源"。
理解这一点非常关键,因为它决定了后面所有的行为边界。Device Plugin 只做三件事:上报资源数量、在 Pod 调度到本节点后告诉 kubelet"这个容器该挂哪些设备文件"、以及处理设备的健康状态。它不负责显存隔离、不负责算力隔离、不负责监控。换句话说,K8s 眼里的nvidia.com/gpu: 1本质上是"这个 Pod 独占一张卡的设备节点访问权",仅此而已。你在容器里能用到多少显存,取决于这张卡上还有没有别人在用,K8s 完全不拦。
我在早期项目里吃过这个亏:当时天真地以为申请 1 张卡就等于独占了 80G 显存,结果同一张卡被时间切片切成了四份,四个训练任务互相抢显存,最后集体 OOM。这个认知差是所有后续设计的地基,先把地基夯实。
还有一个容易忽略的点:GPU 资源是不可压缩的整数资源。CPU 你可以申请 0.5 核,内存可以申请 1Gi,但 GPU 在默认模型下只能是整数,写nvidia.com/gpu: 0.5会直接被 API Server 拒绝。这个约束直接引出了后面第 4 章要讲的切分方案。
1.2 三条接入路线,各自的代价不一样
给集群接 GPU,市面上主流有三条路,别一上来就抄别人的 YAML,先搞清楚自己在哪个场景。
第一条是手工装三件套:宿主机装驱动,容器运行时接上 GPU 支持,然后单独部署一个 Device Plugin 的 DaemonSet。这条路的优点是链路透明,出问题你能顺着每一环往下查;缺点是要维护的东西多,尤其是节点扩容或者驱动升级的时候,得靠脚本或者配置管理工具统一操作,很容易出现"新扩的三台机器版本和老机器不一致"的情况。
第二条是用 GPU Operator。它把驱动安装、容器运行时配置、Device Plugin、监控组件、甚至 MIG 管理全部打包成一组带编排逻辑的 Operator,你丢一个 CR 进去,它自己去收敛状态。好处是节点扩容几乎零人工,坏处是它内部封装层次很深,一旦某个环节卡住,日志分散在七八个 Pod 里,排查成本高。我一般建议规模超过二十个 GPU 节点的团队直接上 Operator,小规模反而手工更省心。
第三条是云托管节点池。云厂商在节点镜像层就把驱动和运行时都配好了,你只管打标签、装插件。这条路最省事,但也最被动——驱动版本、CUDA 版本基本被镜像锁死,想跑一个依赖特定 CUDA 版本的老模型,只能自己想办法。
下面这张表是我自己选型时常用的对照,参数维度按实际运维中影响最大的几项排:
| 对比维度 | 手工三件套 | GPU Operator | 云托管节点池 |
|---|---|---|---|
| 首次部署耗时 | 约 30 到 60 分钟/节点 | 15 分钟左右(含镜像拉取) | 5 分钟以内 |
| 驱动升级方式 | 手动或脚本批量 | 改 CR 字段滚动更新 | 换节点镜像,通常要重装 |
| 故障定位难度 | 低,链路清晰 | 中,需看多组件日志 | 高,底层不可见 |
| 版本可控性 | 完全可控 | 可控但受 Operator 支持矩阵限制 | 基本不可控 |
| 适合的团队规模 | 5 到 20 个 GPU 节点 | 20 个节点以上 | 业务优先、运维人力紧张 |
选型的时候还有一个隐藏变量:你是否需要 MIG 或时间切片。手工方案下你得自己改 Device Plugin 的 ConfigMap 并重启,Operator 方案下改 CR 就行。如果你的场景里"一个团队七八个人抢四张卡"是常态,那切分能力就是刚需,选型时权重要给足。
1.3 一个经常被跳过的设计决策:GPU 节点要不要独立成池
这个决策很多人是边做边想的,结果做出一锅粥。我的建议是从一开始就把 GPU 节点单独打成一个节点池,用标签和污点圈起来,理由有三个。
第一是隔离爆炸半径。GPU 节点上的驱动问题、Xid 错误、ECC 报错,都可能让整台机器不可用,混在通用池里会影响普通业务的调度。第二是资源不被浪费,如果不打污点,一个只需要 100m CPU 的日志采集 Pod 完全可能被调度到 GPU 节点上占个坑,虽然占用不大,但会让节点的可分配资源计算变得复杂。第三是便于做成本核算,GPU 节点的单位成本比普通节点高一个数量级,你总得知道钱花在哪个团队身上。
具体做法是给节点打上node-role.kubernetes.io/gpu=true之类的标签,同时加nvidia.com/gpu=present:NoSchedule的污点。这里有个细节:Device Plugin 自己的 DaemonSet 必须显式容忍这个污点,否则插件部署不上去,节点永远长不出 GPU 资源。新手在这一步卡住的概率极高,因为 DaemonSet 的 Pod 会一直 Pending,但不看事件的话很难联想到是污点问题。
2. 宿主机层:版本咬合比任何配置都重要
2.1 上机前先跑一遍的体检清单
拿到新机器,别急着装东西,先把下面这些命令跑一遍,把输出存档。这一步花五分钟,能省掉后面几小时的瞎猜。
# 1. 确认卡被系统识别,以及驱动版本、CUDA 版本 nvidia-smi nvidia-smi --query-gpu=index,name,memory.total,driver_version --format=csv # 2. 确认内核版本与已加载模块 uname -r lsmod | grep -E 'nvidia|nvidia_uvm|nvidia_drm' # 3. 确认设备节点是否存在 ls -l /dev/nvidia* # 4. 确认容器运行时版本 containerd --version # 或 docker --version # 5. 确认内核启动参数里没有把 IOMMU 之类的东西配错 cat /proc/cmdline重点看两处。一是lsmod里必须有nvidia_uvm,这个模块是 CUDA 应用在容器里正常工作的前提,缺了它你会看到容器内nvidia-smi正常但一跑训练就报CUDA error: unknown error,非常迷惑人。二是/dev/nvidia-uvm和/dev/nvidia-uvm-tools这两个设备节点要存在,如果lsmod有模块但设备节点没有,通常是 udev 规则没生效,重启一次或者手动nvidia-modprobe -u一般能解决。
注意:如果你用的是定制内核或者自己编译过内核模块,务必确认
nvidia.ko、nvidia-uvm.ko、nvidia-modeset.ko三个模块的版本和用户态驱动库版本完全一致。版本不一致时nvidia-smi会直接报Driver/library version mismatch,这种情况只能rmmod后重装,没有别的巧办法。
2.2 驱动、CUDA、运行时三者的版本对齐逻辑
这是最容易翻车的地方。很多人以为"装最新的就行",实际上这三个东西是三角咬合关系,任何一角超前或滞后都会出问题。
先说驱动和 CUDA。CUDA 程序依赖驱动里的用户态库libcuda.so,而驱动能支持的最高 CUDA 版本是固定的——比如某个驱动分支最高支持到 CUDA 12.x,你硬要跑一个编译时用了更高版本 CUDA Toolkit 的镜像,运行时会报CUDA driver version is insufficient for CUDA runtime version。反过来,驱动比 CUDA 新通常是兼容的,向后兼容性做得还不错。所以原则是:驱动版本按你镜像里最高的那个 CUDA 版本来选,而不是反过来。
再说运行时。容器里能看见 GPU,靠的是运行时在创建容器时把宿主机的驱动库和设备文件注入进去。老版本的 Docker 需要装nvidia-docker2,新版走的是nvidia-container-toolkit。containerd 用户则是在config.toml里配一个nvidia的 runtime handler 并设为默认。这里的关键是toolkit 的版本要跟运行时版本匹配,版本错配的典型症状是容器起不来,报failed to create task: OCI runtime create failed,日志里能看到nvidia-container-cli: initialization error。
我整理过一个简单的对照思路,供你排查时参考:
| 现象 | 大概率原因 | 验证方式 |
|---|---|---|
宿主机nvidia-smi正常,容器内报 command not found | 镜像里没装 CUDA 基础库,只有应用 | 换基础镜像打一层测试 |
容器内nvidia-smi报驱动版本不匹配 | 运行时没走 nvidia handler,看到的是镜像内的旧库 | cat /proc/1/environ看注入变量 |
容器内能跑nvidia-smi,训练报 CUDA unknown error | 缺少nvidia-uvm模块或设备节点 | lsmod | grep uvm、ls /dev/nvidia-uvm |
| Pod 起不来,runtime create failed | toolkit 与运行时版本错配 | journalctl -u containerd查详细报错 |
| 卡数量对不上,只看到一张 | NVIDIA_VISIBLE_DEVICES被覆盖成all之外的值 | 检查镜像 ENV 和 Pod env |
2.3 容器运行时的改造点,逐行说明白
以 containerd 为例,改config.toml这件事网上抄来抄去,但很少有人讲清楚每一行在干什么。我把关键段落拆开说。
[plugins."io.containerd.grpc.v1.cri".containerd] default_runtime_name = "nvidia" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia] privileged_without_host_devices = false runtime_type = "io.containerd.runc.v2" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia.options] BinaryName = "/usr/bin/nvidia-container-runtime"default_runtime_name = "nvidia"这一行决定了所有容器的默认行为。设成 nvidia 之后,每个容器创建时都会经过 toolkit 处理,它会自动检测镜像里是否有 CUDA 相关环境变量,有就注入驱动库,没有就安静地跳过。这一步的副作用是所有容器都会多一次检测开销,量大的集群里建议不要设默认,而是在需要 GPU 的 Pod 上通过 RuntimeClass 显式指定。
privileged_without_host_devices = false这一行的含义是:即使 Pod 是特权模式,也不再自动把宿主机所有设备都塞进去。这是个安全收敛,保持默认即可。
如果你走的是 RuntimeClass 路线,还要额外建一个对象:
apiVersion: node.k8s.io/v1 kind: RuntimeClass metadata: name: nvidia handler: nvidia然后在 Pod 的spec.runtimeClassName里引用它。这条路更干净,但代价是每个 GPU 工作负载都得记得写这一行,漏写的话容器能起来但看不见卡,又是一个隐蔽的坑。
改完配置记得systemctl restart containerd,重启前先crictl ps确认没有正在跑的关键业务,因为重启运行时会让所有容器网络短暂抖动。生产环境里这个操作一定要排进窗口期。
3. 从零到 Pod 跑起来:节点纳管的完整实操
3.1 部署 Device Plugin,以及为什么它老是起不来
驱动和运行时都通了之后,真正让 K8s "看见"显卡的是 Device Plugin。官方提供的是k8s-device-plugin,以 DaemonSet 形式部署,只跑在带 GPU 标签的节点上。
下面这份清单是我在生产里用的版本,字段做了精简,注释标出了几个必须改的地方:
apiVersion: apps/v1 kind: DaemonSet metadata: name: nvidia-device-plugin namespace: kube-system spec: selector: matchLabels: name: nvidia-device-plugin template: metadata: labels: name: nvidia-device-plugin spec: nodeSelector: node-role.kubernetes.io/gpu: "true" # 只调度到 GPU 节点 tolerations: - key: nvidia.com/gpu operator: Exists effect: NoSchedule # 必须容忍 GPU 污点 priorityClassName: system-node-critical containers: - name: nvidia-device-plugin image: nvcr.io/nvidia/k8s-device-plugin:v0.14.5 securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] volumeMounts: - name: device-plugin mountPath: /var/lib/kubelet/device-plugins volumes: - name: device-plugin hostPath: path: /var/lib/kubelet/device-plugins/var/lib/kubelet/device-plugins这个挂载点是命门。Device Plugin 通过这个目录下的 Unix socket 向 kubelet 注册自己,如果这个路径和 kubelet 实际的--root-dir下的路径对不上,插件会一直打印failed to dial或者干脆静默不注册。我见过一次是因为集群部署时把 kubelet 的根目录改到了/data/kubelet,结果插件按默认路径挂载,两边永远握不上手,查了整整一个下午。
部署完之后用下面几条命令确认状态:
# 插件 Pod 是否 Running kubectl -n kube-system get pod -l name=nvidia-device-plugin -o wide # 插件日志里应该能看到 "Starting GRPC server" 和注册成功的记录 kubectl -n kube-system logs -l name=nvidia-device-plugin --tail=50 # 节点上应该出现 nvidia.com/gpu 资源 kubectl get node <node-name> -o jsonpath='{.status.capacity}' kubectl describe node <node-name> | grep -A5 "Allocatable"正常情况下Capacity和Allocatable里都会出现nvidia.com/gpu: 8这样的条目。如果Capacity有而Allocatable没有,通常是节点上有其他资源挤占了配额,或者插件上报后又被健康检查置为不健康,去看插件日志里的health相关行。
提示:如果你的节点同时开启了 MIG,插件会上报
nvidia.com/mig-1g.5gb这类带规格的资源名,此时nvidia.com/gpu可能变成 0。这不是 bug,是设计如此——MIG 模式下按实例粒度上报。遇到这种情况先确认nvidia-smi -L的输出里有没有MIG 1g.5gb Device字样,别急着改插件配置。
3.2 节点标签体系:让调度器知道该把活儿派给谁
资源上报成功只是"能用",要做到"好用",还得把节点的差异描述清楚。K8s 官方和 NVIDIA 生态里有一套约定俗成的标签,插件会自动打上一部分,我们也可以手动补。
| 标签名 | 含义 | 是否自动 |
|---|---|---|
nvidia.com/gpu.product | 卡的具体型号,如 A100-SXM4-80GB | 是 |
nvidia.com/gpu.count | 卡的数量 | 是 |
nvidia.com/gpu.memory | 单卡显存大小,单位 MiB | 是 |
nvidia.com/gpu.family | 架构代际,如 ampere、hopper | 是 |
nvidia.com/cuda.driver.major | 驱动主版本号 | 是 |
node-role.kubernetes.io/gpu | 我们自定义的节点角色标签 | 否,手动 |
自动标签的好处是不用自己维护,卡换了标签跟着变;坏处是标签名比较长,写 nodeSelector 的时候容易拼错。我的做法是在上面再封装一层业务标签,比如gpu-tier: high表示大显存卡、gpu-tier: standard表示普通卡,业务方只需要认业务标签,底层的硬件差异由平台侧维护。
这样做的另一个好处是换卡不用改业务 YAML。某次我们一个机房的卡从上一代换到新一代,显存和算力都变了,但因为有业务标签这层抽象,所有训练任务的 YAML 一行没动,只是把标签的映射关系改了一下。
3.3 一个完整的端到端验证 Pod
节点纳管完之后,我习惯跑一个最小验证,比翻文档靠谱得多。这个 Pod 做三件事:确认能看见指定数量的卡、确认 CUDA 能实际跑起来、确认显存读取正常。
apiVersion: v1 kind: Pod metadata: name: gpu-smoke-test spec: restartPolicy: Never nodeSelector: node-role.kubernetes.io/gpu: "true" tolerations: - key: nvidia.com/gpu operator: Exists effect: NoSchedule containers: - name: cuda image: nvidia/cuda:12.2.0-base-ubuntu22.04 command: ["bash", "-c"] args: - | nvidia-smi nvidia-smi --query-gpu=index,name,memory.total,memory.used --format=csv echo "VISIBLE: $NVIDIA_VISIBLE_DEVICES" python3 -c "print('cuda check placeholder')" || true resources: limits: nvidia.com/gpu: 1跑起来之后kubectl logs应该能看到被分配的那张卡的详细信息,并且NVIDIA_VISIBLE_DEVICES的值是一个具体索引,而不是all。如果是all,说明运行时没把 Device Plugin 分配的设备 ID 传下去,问题往往出在 RuntimeClass 没生效或者 containerd 配置里缺少 nvidia handler。
这里补一个实操细节:申请 GPU 时只需要写limits,不要写requests。K8s 对扩展资源的规则是,如果只写 limits,requests 会自动等于 limits;如果两个都写了但值不同,API Server 会直接拒绝。这个规则和 CPU 内存不同,很多人第一次写会踩。
4. 资源切分与调度:真正的难点在这里
4.1 默认整卡独占模型下的资源账怎么算
默认模型下,一张卡就是一个不可分割的单位。这意味着如果你有 8 张卡,最多同时跑 8 个申请 1 卡的 Pod,第 9 个必须等。对于推理这种轻负载场景,一张 80G 的卡跑一个小模型可能只用了 12G 显存,剩下 68G 全浪费,成本效率非常难看。
这里要做一道很实在的算术题。假设一台 8 卡机器月成本是 C,那么单卡单月的成本就是 C/8。如果业务方一个推理服务实际只用 15% 的显存和 20% 的算力,整卡独占模式下的有效利用率就是 15%,等于你为了这 15% 付了 100% 的钱。规模上去之后这个数字非常吓人,所以只要业务形态允许,切分是必须做的。
但切分不是无脑开。要先回答一个问题:你的负载是显存瓶颈还是算力瓶颈。纯显存瓶颈(比如小 batch 的在线推理)适合时间切片,多开几份提高显存利用率;算力瓶颈(比如大 batch 训练)切了也没意义,因为卡本来就在满负荷跑,切分只会让多个任务互相拖慢。
4.2 时间切片与 MIG,两条路的取舍
时间切片(time-slicing)是 Device Plugin 层面的能力,配置很简单,改一个 ConfigMap:
version: v1 flags: migStrategy: none sharing: timeSlicing: resources: - name: nvidia.com/gpu replicas: 4这段配置的意思是"把每张物理卡虚拟成 4 份逻辑资源"。改完之后节点上的nvidia.com/gpu会变成原来的 4 倍,比如 8 张卡变成 32 个可分配单位。要注意的是,这 32 个单位之间没有任何隔离——四个 Pod 共享同一张卡的显存和算力,谁抢得多谁就快,显存超了大家一起 OOM。它本质上是"超卖",适合那些对延迟不敏感、能容忍互相干扰的离线任务或者低频推理。
MIG 是硬件级切分,把一张卡在物理上切成若干独立实例,每个实例有自己独立的显存和计算单元,隔离性接近真独占。代价是切分组合受硬件限制(比如 80G 卡常见的组合是 7 个 1g.10gb 或者 3 个 2g.20gb),而且切完之后不能动态改,要改得先停掉卡上所有任务重新配置。MIG 适合多租户场景,尤其是不同团队之间需要强隔离的时候。
我的实际取舍是这样的:内部研发环境用时间切片,图的是灵活、随时能改;对外提供服务的生产环境用 MIG,图的是稳定和隔离。两者混用的集群也有,靠不同的节点池隔开就行。
| 对比项 | 时间切片 | MIG |
|---|---|---|
| 隔离级别 | 无,共享显存与算力 | 硬件级,显存算力独立 |
| 切分比例 | 任意整数份 | 受硬件规格限制 |
| 是否可动态调整 | 改配置重启插件即可 | 需清空任务重新配置 |
| 资源上报方式 | nvidia.com/gpu数量翻倍 | 上报nvidia.com/mig-*规格 |
| 典型场景 | 研发环境、离线批处理 | 多租户生产、在线服务 |
| 故障影响面 | 一损俱损,一个 Pod OOM 可能拖垮同卡其他 Pod | 基本互不影响 |
4.3 资源碎片:为什么明明有卡却调度不上去
这个坑我在两个项目里都遇到过。场景是这样:集群里有 8 卡节点也有 4 卡节点,业务里既有申请 1 卡的推理 Pod,也有申请 8 卡的训练 Pod。跑一段时间之后,8 卡节点被一堆 1 卡的 Pod 各占一张,每台都剩零散几张,这时候来了个要 8 卡的训练任务,虽然集群总空闲卡数够,但没有任何一台机器满足,任务永远 Pending。
解法有三种,从轻到重。最轻的是给大任务用节点亲和性绑定到 8 卡节点,同时给 8 卡节点加污点只允许大任务进入——本质上是把节点池按粒度分层,小任务去 4 卡池,大任务去 8 卡池。中等的是用 Pod 的优先级和抢占,让大任务能挤掉小任务,但抢占会中断业务,要谨慎。最重的是引入拓扑感知的调度插件,按 NUMA 和 NVLink 拓扑做装箱,这个方案效果最好但运维复杂度也最高,一般只有在跑大规模分布式训练时才值得。
还有一个更隐蔽的碎片来源:同一台机器上多卡任务和单卡任务混部。比如一台 8 卡机器上跑了一个 2 卡任务,剩 6 卡,这时候来个 8 卡任务就进不来,来 6 个 1 卡任务反而能塞满。这种情况下用topologySpreadConstraints把同类任务打散到不同节点,能明显缓解。
5. 上线之后:监控、隔离与故障排查
5.1 需要长期盯住的几类指标
GPU 纳管不是部署完就完事,真正的工作量在运维阶段。我一般要求至少覆盖四类观测。
第一类是资源水位,包括每张卡的显存占用率、算力利用率、温度、功耗。这些靠 DCGM Exporter 采集,暴露成 Prometheus 格式,再配 Grafana 面板。显存占用率持续接近 100% 但算力利用率只有 10% 的组合,通常意味着模型有内存泄漏或者 batch 设置不合理,得推动业务侧优化。
第二类是硬件健康,重点是 Xid 错误码和 ECC 错误计数。Xid 是 NVIDIA 驱动上报的错误码,不同的码含义完全不同,比如 13 表示图形引擎异常、48 表示双比特 ECC 错误、79 表示 GPU 掉卡。我在告警规则里对 Xid 48 和 79 设了最高级别,因为这两种基本意味着卡要下线维修了。ECC 的可纠正错误计数如果持续增长,也要提前安排更换,别等到不可纠正错误爆发。
第三类是调度侧指标,包括 Pending 的 GPU Pod 数量、平均等待时长、节点 GPU 分配率。这几个指标能直接反映资源是否紧张,是扩容决策的依据。
第四类是运行时异常,比如容器启动失败率、runtime 相关错误日志量。这类指标平时不起眼,但一旦驱动或运行时升级出问题,它会第一时间飙起来。
5.2 常见问题速查与排查顺序
下面这张表是我这些年攒下来的,按"从最常见的开始"排序。遇到问题时从上往下走,基本能覆盖八成情况。
| 现象 | 优先排查方向 | 具体动作 |
|---|---|---|
| Pod Pending,事件报 Insufficient nvidia.com/gpu | 节点资源是否上报、污点是否容忍 | describe node看 Allocatable,检查 Pod 的 tolerations |
| 节点 Allocatable 里没有 nvidia.com/gpu | 插件是否在该节点运行、socket 路径是否匹配 | 看 DaemonSet Pod 状态和日志,核对 kubelet 根目录 |
| 插件 Running 但日志报注册失败 | 宿主机 device-plugins 目录权限或已被占用 | 检查目录权限,确认没有残留的旧 socket 文件 |
| 容器内 nvidia-smi 报驱动版本不匹配 | 运行时未走 nvidia handler | 检查 RuntimeClass 或 containerd 默认运行时配置 |
| 容器内 nvidia-smi 正常,训练报 CUDA unknown error | 缺少 uvm 模块或设备节点 | lsmod | grep uvm,检查/dev/nvidia-uvm |
| 显存 OOM 但监控显示卡上只有自己 | 镜像内设置了过大的显存预分配 | 检查TF_FORCE_GPU_ALLOW_GROWTH等环境变量 |
| 卡突然从节点消失 | 硬件掉卡或 Xid 错误 | dmesg -T | grep -i xid,nvidia-smi -q查详细状态 |
| 时间切片生效但性能严重下降 | 多 Pod 争抢算力 | 降低 replicas 或改用 MIG |
排查的时候有个通用心法:永远从宿主机往下查,不要从 Pod 往上猜。先在宿主机上确认nvidia-smi正常、设备节点齐全、模块加载完整,再确认容器运行时能创建带 GPU 的容器(可以用crictl run或者简单的docker run --gpus all验证),最后才看 K8s 层。这个顺序能帮你快速定位问题在哪一层,避免在错误的层面浪费大量时间。
注意:驱动升级是 GPU 集群里风险最高的操作之一。升级前务必确认新驱动支持的所有 CUDA 版本,并且逐节点滚动升级而非批量重启。升级完成后,那个节点上的所有 GPU Pod 都需要重建才能用上新驱动,因为运行中的容器挂载的是旧版本的驱动库。这一点经常被忽略,导致升级后出现"部分 Pod 性能异常"的诡异现象。
6. 一些踩过坑之后才想明白的事
最后聊几个不成体系但很值钱的体会。
关于驱动版本,我现在坚持一个原则:集群内所有 GPU 节点的驱动版本必须完全一致。哪怕差一个小版本号都不行。原因是一次教训——两个批次的机器驱动差了 0.1 个版本,某个自定义算子在新驱动上编译出来的二进制在老驱动上跑会偶发段错误,排查了三天才定位到版本差异。现在我们的节点镜像统一构建,驱动版本写死在镜像里,扩容时不可能出现偏差。
关于资源申请,我建议在准入层面加一道校验,禁止申请 GPU 但没设limits的 Pod 上线,也禁止写requests和limits不一致的情况。这些错误在 YAML review 阶段很容易漏,等到线上才发现就是 Pending 事故。用 Kyverno 或者 OPA 写个简单策略,几分钟的事,长期收益很大。
关于时间切片的 replicas 值,别贪心。很多人一看能切就想切 8 份、16 份,实际上卡就那么点显存和算力,切得越碎,争抢越激烈,性能抖动越大,最后业务方会来找你投诉"为什么我的推理延迟从 20ms 变成了 200ms"。我的经验值是 4 份封顶,再多就得考虑换更大显存的卡或者上 MIG 了。
还有一个容易被忽略的运维细节:GPU 节点的重启策略。普通节点重启影响不大,GPU 节点重启后如果驱动模块加载顺序有问题,可能出现nvidia-smi报Unable to determine the device handle。这种情况通常是nvidia-persistenced服务没起来,或者systemd的模块加载顺序不对。我一般会在节点上配一个开机自检脚本,重启后自动跑一次nvidia-smi和一次最小 CUDA 测试,失败就自动打上gpu-unhealthy标签把节点从调度池里摘出去,避免有问题的节点接活。
关于成本,我还想补一句。GPU 纳管做得再漂亮,如果利用率上不去,钱就是白花。建议每隔一段时间拉一次节点的显存和算力利用曲线,把长期低于 30% 的负载找出来,要么合并、要么改成切片模式、要么直接下掉。这件事比研究怎么装插件重要得多,只不过后者更"技术",前者更"无聊",所以经常被跳过。
如果你的集群还没到需要切分和多租户的规模,那就老老实实用默认的整卡模型,把基础链路做扎实,别一上来就上 Operator 加 MIG 加时间切片的全家桶——复杂度是成本,而且很多时候是负收益。等你真的遇到资源碎片和利用率问题,再按上面的顺序一层层加,路会好走很多。