
k8s集群纳管GPU节点这件事很多人的第一印象是跟加一台普通工作节点差不多——装好容器运行时kubeadm join 一条命令下去节点状态变成 Ready就以为完事了。真正的坑全在 join 之后kubectl describe node里看不到nvidia.com/gpu这个资源Pod 里跑nvidia-smi报 command not found或者明明插了四张卡却只认出来一张。我自己第一次把一台八卡机接进集群时光是让nvidia.com/gpu从 0 变成 8 就折腾了大半天中间踩过的坑基本覆盖了驱动、容器运行时、设备插件这三层。这篇内容就把 k8s 集群纳管 GPU 节点的完整链路拆开讲为什么 GPU 节点不能照搬 CPU 节点的接入方式、版本怎么对齐、每一步具体敲什么命令、出问题按什么顺序排查。适合正在做异构算力池化、要往已有集群里加卡、或者刚开始接触 GPU 调度的同学从运维到算法工程都能对着抄。1. GPU 节点接入为什么要单独走一套流程1.1 一次节点 Ready 但显卡不可见的真实起点先把最容易误解的地方说清楚。CPU 和内存属于节点上天然可知的资源kubelet 启动时会去找系统的 cgroup 和/proc/meminfo自己算出来能分配多少然后上报给 apiserver。显卡完全不是这个逻辑。对 Linux 内核来说一块计算卡就是挂在 PCI 总线上的一个设备内核只负责把驱动加载起来、在/dev下生成设备节点文件它不会告诉你这张卡还有 20GB 显存可用。kubelet 本身也不认识显卡它没有任何内置逻辑去枚举 GPU。所以你会看到一个非常典型的现象节点状态是 Readykubectl describe node输出干干净净看不出任何异常但 Pod 里nvidia.com/gpu这个资源压根不存在。这时候节点是健康的只是没人告诉 Kubernetes我这台机器上有四块可以被容器使用的加速卡。补上这个信息缺口的角色叫设备插件Device Plugin。它是 Kubernetes 里一种专门为异构设备设计的扩展机制本质是一个跑在节点上的 gRPC 服务向 kubelet 的 Device Plugin Manager 注册自己然后持续汇报我能提供多少份某种资源。这个机制决定了 GPU 节点的纳管一定是两步走先让节点以普通身份加入集群再通过 DaemonSet 把设备插件滚动到所有带卡节点上资源才会出现。任何试图在 join 之前就把 GPU 资源准备好的思路都走不通。1.2 设备插件机制带来的三个连锁约束理解了上面那层很多看似奇怪的限制就顺理成章了。我在实际配置时总结出三条必须提前接受的约束。第一GPU 是扩展资源Extended Resource只能用整数计量。你能申请nvidia.com/gpu: 1或者2但不能写0.5。这和 CPU 的500m完全不是一个语义。原因是设备插件上报的是设备份数不是容量调度器分配时做的是整数扣减。想要小数级别的切分只能靠 MIG 或者时间片共享在设备侧造出更多虚拟份数让插件上报更大的整数。第二申请了就必须独占调度器不做超卖。一个 Pod 拿了nvidia.com/gpu: 1这一份在 Pod 生命周期内就被锁死其他 Pod 拿不到哪怕这个 Pod 只是启动时用一下卡、后面全程空闲。这跟 CPU 可以被多个容器共享时间片的设计完全不同也是 GPU 集群利用率普遍偏低的根源之一。第三容器里的 GPU 需要运行时配合才能看见。设备插件只负责分配和记账真正把/dev/nvidia0这类设备节点、驱动库文件挂进容器的是容器运行时。如果运行时没配好 NVIDIA 相关的 hook插件会正常上报资源、Pod 也能正常调度但容器起来后nvidia-smi直接报错。这类问题最迷惑人因为从集群视角看一切都是绿的。1.3 版本对齐是整条链路的隐性前提我在不同环境里复现过好几次诡异的失败最后追下去都是版本错配。GPU 这条链路涉及的组件比普通节点多得多宿主机内核、显卡驱动、CUDA 运行时库、容器运行时containerd 或 CRI-O、NVIDIA 容器工具链、设备插件镜像、以及业务镜像里自带的 CUDA 版本。它们之间存在严格的兼容关系。举个具体的例子。宿主机装的是较老版本驱动业务镜像里是基于较新 CUDA 版本编译的框架容器启动时会报CUDA driver version is insufficient for CUDA runtime version。这个报错信息还算友好更麻烦的是某些框架会直接崩在初始化阶段日志里只有一段看不懂的段错误。我的做法是在接入前先把这张对齐表填好写在运维文档里后续扩容或升级都对着查层级关键项检查方式对齐原则宿主机内核版本、驱动版本nvidia-smi、uname -r驱动版本需覆盖镜像内 CUDA 所需的最高版本运行时containerd/CRI-O 版本containerd --version需支持 runtime handler 配置工具链nvidia-container-toolkitnvidia-ctk --version与驱动大版本匹配优先选稳定版集群侧设备插件镜像版本kubectl get ds -n kube-system与驱动、MIG 需求匹配业务侧镜像内 CUDA 版本nvcc --version不高于驱动支持的上限2. 接入前的环境盘点与版本对齐2.1 驱动、CUDA 与容器运行时的版本矩阵驱动版本的选择有个简单的判断标准看业务镜像里用的深度学习框架是基于哪个 CUDA 版本编译的然后去查该 CUDA 版本要求的最低驱动版本往上取一个稳定的大版本。不要盲目追最新驱动新驱动在小版本迭代里偶尔会引入和某些框架不兼容的行为GPU 节点一旦批量出问题回滚成本很高。我一般会遵循宿主机驱动略高于业务需求的策略。比如业务镜像里最高用到某个 CUDA 12.x 版本宿主机驱动就装能覆盖它的那个分支的较新小版本。这样后面新业务上线时不用为了一个镜像去升级整个集群的驱动。这里有个细节值得单独提宿主机上不需要装完整的 CUDA Toolkit。很多人习惯性地把 CUDA 全家桶装到宿主机其实完全没必要容器里会用镜像自带的 CUDA 运行时。宿主机只需要驱动和nvidia-smi能正常工作就够了装多了反而容易和容器里的库版本打架。我见过因为宿主机/usr/local/cuda下的库被容器挂载进去导致框架加载了错误版本的动态库、跑起来精度异常的例子。容器运行时的版本也有下限要求。较老的 containerd 版本对 runtime handler 的支持不完整配置[plugins.io.containerd.grpc.v1.cri.containerd.runtimes.nvidia]时会报解析错误或者静默忽略。建议至少在 1.6 以上的版本配置结构才是稳定的io.containerd.grpc.v1.cri前缀。2.2 节点上的物理资源与拓扑确认驱动装好后第一件事不是急着 join而是把物理拓扑摸清楚。nvidia-smi -L会列出所有卡的 UUID 和型号nvidia-smi topo -m会给出卡间互联矩阵。这两条命令的输出建议截图存档后面排查只认出一张卡这类问题时是重要的对照基线。nvidia-smi topo -m的矩阵里如果看到 NVLink 字样说明卡间有高速互联做多卡训练时值得在调度层做拓扑感知尽量把同一个训练任务的多张卡调度到同一台机器、同一个 NVLink 域内。如果全是 PCIe 连接那跨卡通信开销会明显大一截这时候更要避免把一个任务的多张卡分散到不同节点。还有两点容易被忽略。一是 BIOS 里要确认 PCIe 的 Above 4G Decoding 和 Resizable BAR 已经打开否则系统可能只识别出部分显卡这种现象在八卡机上很常见。二是要检查 IOMMU 分组是否会打断 GPU 到 CPU 的直连路径某些虚拟化环境里分组不当会导致显存带宽严重下降表现是训练速度莫名其妙只有预期的一半。2.3 节点角色规划与标签体系设计在节点真正 join 之前把标签体系想清楚比事后补要省事得多。我给 GPU 节点至少打三类标签。第一类是硬件型号标签比如acceleratornvidia-a10、gpu.count8、gpu.memory24g。注意设备插件启动后会自动打上一批nvidia.com/gpu.product、nvidia.com/gpu.memory、nvidia.com/gpu.count开头的标签但那批标签是插件生成的你不应该依赖它们做业务侧的硬约束因为插件版本升级时标签格式可能变。自己的标签体系才是稳定的。第二类是资源池标签比如pooltraining和poolinference。训练任务往往要求整卡独占、多卡互联推理任务对卡数不敏感但对密度敏感两种负载混在同一批节点上会互相干扰。用池子标签把物理资源切开是通过配置做隔离的最简单手段。第三类是维护状态标签比如gpu.statedraining。这个后面讲节点维护时会用到提前把约定定好避免停机升级时手忙脚乱。标签打完之后记得同步到节点亲和性和污点的设计上具体的调度写法在第 4 节展开。3. 实操把 GPU 节点完整接进集群3.1 驱动安装与基线验证假设节点已经装好操作系统、内核版本确定。驱动安装这一步我倾向于用发行版仓库的包管理方式而不是官方 runfile原因是包管理能自动处理内核模块编译和 DKMS 注册后续内核升级时驱动会自动重建不会出现内核一升级卡就没了的情况。安装完成后先做三级验证从低到高依次是# 第一级驱动是否加载卡是否被识别 nvidia-smi # 第二级设备节点是否生成权限是否正确 ls -l /dev/nvidia* # 第三级驱动与内核模块的匹配状态 cat /proc/driver/nvidia/version lsmod | grep nvidia第一级正常会输出一张表格显示驱动版本、CUDA Version注意这是驱动支持的最高 CUDA 版本不是已安装的 CUDA 版本很多人在这里看错、每张卡的显存和温度。如果这里就报NVIDIA-SMI has failed because it couldnt communicate with the NVIDIA driver先去看dmesg | grep -i nvidia通常是内核模块编译失败或者 Secure Boot 拦截了未签名模块。第二级验证经常被跳过但它是后面容器能不能用卡的关键。/dev/nvidia0到/dev/nvidiaN应该都在/dev/nvidiactl和/dev/nvidia-uvm也要存在。如果nvidia-uvm缺失说明 UVM 模块没加载CUDA 应用会在初始化时直接失败。补的办法是modprobe nvidia-uvm并确认/etc/modules-load.d/下有对应配置保证开机加载。注意不要用chmod 777 /dev/nvidia*这种粗暴方式解决权限问题。正确的做法是把需要访问 GPU 的用户加入video和render组容器的设备访问权限由运行时统一处理宿主机上放开权限反而会留下隐患。3.2 让容器运行时认识 GPU这一步是整个流程里最容易出岔子的地方。装好nvidia-container-toolkit之后必须让它去改写容器运行时的配置而不是自己手写。# 让工具链自动写入 containerd 配置 nvidia-ctk runtime configure --runtimecontainerd --set-as-defaultfalse systemctl restart containerd--set-as-defaultfalse这个参数很关键。它的作用是只注册一个名为nvidia的 runtime handler而不把nvidia设成默认运行时。为什么不设默认因为集群里绝大多数 Pod 根本不需要 GPU如果所有容器都走 NVIDIA 的运行时会带来两个后果一是每个容器启动都要跑一遍 hook启动耗时增加二是普通容器也可能被挂入驱动库文件和环境里已有的库产生冲突。按需指定是更稳妥的做法。配置完成后/etc/containerd/config.toml里应该能看到类似这样的段落[plugins.io.containerd.grpc.v1.cri.containerd.runtimes.nvidia] runtime_type io.containerd.runc.v2 [plugins.io.containerd.grpc.v1.cri.containerd.runtimes.nvidia.options] BinaryName /usr/bin/nvidia-container-runtime验证配置有没有真正生效不要只看文件内容。用ctr手动跑一个容器是最直接的ctr run --rm \ --runtime io.containerd.runc.v2 \ --mount typebind,src/usr/bin/nvidia-smi,dst/usr/bin/nvidia-smi,optionsrbind:ro \ docker.io/library/ubuntu:22.04 gpu-test nvidia-smi如果这一步能输出显卡信息说明运行时链路是通的。如果报设备找不到问题就在 toolkit 的 hook 没生效去查/etc/nvidia-container-runtime/config.toml里的no-cgroups设置某些环境下需要把它设为false才能正确注入设备。3.3 加入集群并部署设备插件前置条件都过了这时候才轮到kubeadm join。这一步和普通节点没有区别唯一的建议是在 join 的时候就把节点标签通过--node-labels带上省得后面补kubeadm join control-plane-endpoint:6443 \ --token token \ --discovery-token-ca-cert-hash sha256:hash \ --node-labelsacceleratornvidia-a10,pooltraining,gpu.count8节点 Ready 之后确认 kubelet 的启动参数里没有把设备插件相关的开关关掉。有些加固过的环境会设置--feature-gatesDevicePluginsfalse这个参数在老版本里存在如果被显式关掉插件注册会直接失败。接下来部署设备插件。这里有两种路线手工写 DaemonSet或者用 GPU Operator 一整套装下来。我的建议是第一次接入、节点数量少的时候手工部署因为每一步都看得见出问题好定位节点规模上了几十台、还要管驱动和工具链的统一版本时再上 Operator 做全生命周期管理。手工部署的核心是一个 DaemonSet精简后的关键部分大概是这样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: accelerator: nvidia-a10 tolerations: - key: nvidia.com/gpu operator: Exists effect: NoSchedule 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-pluginsnodeSelector那行是必须加的否则插件会往所有节点上滚在没有驱动的节点上反复 CrashLoopBackOff污染整个集群的事件流。/var/lib/kubelet/device-plugins这个 hostPath 挂载也是硬要求插件就是通过这个目录下的 socket 文件向 kubelet 注册自己的。3.4 验证链路从节点资源到 Pod 内可见部署完等一两分钟按下面的顺序验证每一步都能定位到具体环节。# 1. 插件 Pod 是否 Running有没有重启 kubectl get pods -n kube-system -l namenvidia-device-plugin -o wide # 2. 节点是否上报了 GPU 资源 kubectl get node gpu-node -o jsonpath{.status.capacity} kubectl describe node gpu-node | grep -A5 Capacity # 3. 查看插件日志确认分配到了几张卡 kubectl logs -n kube-system plugin-pod | head -30第 2 步里如果看到nvidia.com/gpu: 8说明记账成功了。这时候再跑一个测试 Pod 做端到端验证apiVersion: v1 kind: Pod metadata: name: gpu-smoke-test spec: restartPolicy: Never containers: - name: cuda image: nvcr.io/nvidia/cuda:12.2.0-base-ubuntu22.04 command: [nvidia-smi] resources: limits: nvidia.com/gpu: 2跑完之后kubectl logs gpu-smoke-test应该能看到两张卡的详细信息。这里有个细节测试用的 Pod 一定要申请2 张以上。我踩过一次坑只申请 1 张时一切正常申请多卡时才暴露出来设备分配逻辑的问题——插件按 UUID 分配设备但某些老版本的容器运行时对NVIDIA_VISIBLE_DEVICES环境变量里用逗号分隔的多个 UUID 处理有 bug导致容器里只能看到第一张卡。多卡测试才能把这个坑提前暴露。4. 调度策略让 GPU 用在该用的地方4.1 资源声明的写法与不可压缩特性GPU 资源的声明有个硬性规则requests和limits必须相等且必须写在limits里。如果你只写requests: nvidia.com/gpu: 1而不写 limitsAPI Server 会直接拒绝创建。这条规则背后是扩展资源的语义——它没有上限的概念只有要么给、要么不给。resources: limits: nvidia.com/gpu: 1 memory: 16Gi cpu: 4 requests: memory: 16Gi cpu: 4注意 CPU 和内存可以只写 requests 不写 limits走 Burstable但 GPU 不行。这个差异经常让刚接触的人困惑本质原因还是那句话GPU 是整数独占的、不可压缩的。另一个容易忽略的点是 initContainer 里的 GPU 申请。如果 initContainer 申请了 GPU它的资源需求会和主容器取最大值而不是相加这和普通资源一样。但有个例外如果 initContainer 只是需要短暂访问一下卡比如做模型预热或者编译 CUDA 算子而你不想让它独占整个 Pod 生命周期的卡可以把它设计成不需要 GPU 的独立步骤或者用restartPolicy: OnFailure配合较低的资源占用。这个设计取舍在多阶段任务里很实用。4.2 用标签与污点切出独立的 GPU 资源池默认情况下任何 Pod 只要申请 GPU 就会被调度到有卡的节点但反过来不申请 GPU 的普通 Pod 也可能被调度到 GPU 节点上白白占掉那些昂贵的 CPU 和内存。这是浪费最常见的形式。做法是给 GPU 节点打上不可容忍的污点kubectl taint node gpu-node nvidia.com/gpupresent:NoSchedule然后所有需要使用 GPU 的 Pod 都加上对应的容忍tolerations: - key: nvidia.com/gpu operator: Exists effect: NoSchedule这行容忍几乎是 GPU 工作负载的标配我一般直接写进内部的任务模板里业务方不用记。这样做的效果是GPU 节点成了一个只有明确声明需要 GPU 的 Pod 才能进的封闭池子普通微服务、监控 Agent、日志采集这些 DaemonSet 如果没有对应容忍就不会占用 GPU 节点的资源。不过这里有个副作用要提醒加了这行污点之后kube-proxy、CNI 插件、监控这类系统级 DaemonSet 如果没配容忍就也不会在 GPU 节点上运行节点网络或监控可能出问题。所以污点的施加顺序应该是先确认所有必需的系统 DaemonSet 都已经配了operator: Exists的全局容忍再打污点否则会有一段时间的观测盲区。再往下细分型号用节点亲和性做选择affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: accelerator operator: In values: [nvidia-a10]训练任务和推理任务对卡型的要求差异很大用requiredDuringScheduling做硬约束能避免任务被调度到不合适的卡型上跑出诡异结果。如果某个任务只是最好有大显存卡没有也能凑合就用preferredDuringSchedulingIgnoredDuringExecution加权重让调度器有腾挪空间。4.3 共享方案的取舍时间片、MPS 与 MIG单卡独占的利用率问题在推理场景特别明显。一个轻量模型可能只用到几百 MB 显存和 10% 的算力却占着整张卡。三种共享方案各有适用边界选错了比不共享还糟。时间片共享Time-Slicing是通过设备插件配置把一张物理卡虚拟成 N 份上报。配置上在插件启动参数里挂一个 ConfigMap 即可apiVersion: v1 kind: ConfigMap metadata: name: time-slicing-config namespace: kube-system data: a10: |- version: v1 flags: migStrategy: none sharing: timeSlicing: resources: - name: nvidia.com/gpu replicas: 4四张卡配replicas: 4节点就会上报nvidia.com/gpu: 16。代价是这些虚拟卡之间没有显存隔离任何一个 Pod 显存超用都会把同卡上的其他 Pod 一起拖垮OOM 是整卡级别的而且并发执行时会互相抢算力延迟变得不可预测。它适合的场景是开发调试环境、低优先级的批处理、对延迟不敏感的推理。MPSMulti-Process Service在同一个 CUDA 上下文里让多个进程并发提交 kernel相比时间片能显著降低上下文切换开销算力利用更充分。但它同样没有显存硬隔离配置也更复杂需要启动一个常驻的 control daemon。我的经验是除非有明确的吞吐需求且能接受隔离性缺失否则不建议在生产环境大规模用。MIGMulti-Instance GPU是唯一提供硬件级隔离的方案。它把一张卡在物理层面切成若干独立实例每个实例有自己的显存、缓存和计算单元互相之间完全隔离一个实例 OOM 不会影响其他实例。但 MIG 有硬性前提只有部分数据中心卡型支持切分规格是固定的几档切完之后不能像时间片那样随意定义份数。# 查看当前 MIG 状态 nvidia-smi mig -lgip # 按指定 profile 创建实例 nvidia-smi mig -cgi 9,9,9 -C选型我一般这样判断生产推理、需要稳定 SLA、卡型支持 MIG——用 MIG开发测试、追求密度、能容忍互相干扰——用时间片多进程推理服务、需要极致吞吐、能接受复杂度——考虑 MPS。混合使用也可以比如训练池保持整卡独占不做任何切分推理池按 MIG 切分通过不同的节点池标签隔开。5. 常见故障排查实录5.1 节点资源显示为 0 的四类根因这是最高频的问题。kubectl describe node里没有nvidia.com/gpu或者显示为 0。排查顺序我固定为下面四步基本能覆盖九成情况。排查层检查命令典型现象处理方式驱动层nvidia-smi命令不存在或报通信失败重装驱动检查内核模块运行时层ctr run --runtime测试容器内看不到设备节点重跑nvidia-ctk runtime configure插件层kubectl logs plugin-pod日志报no devices found检查 nodeSelector 与设备节点注册层插件日志中的Registering报 socket 已存在删除残留 socket 后重启插件第一层的坑是驱动装好了但/dev/nvidia-uvm不存在。设备插件启动时会去枚举这些设备节点缺一个就认为卡不可用。modprobe nvidia-uvm之后重启插件即可。第三层的坑最常见的是nodeSelector写错。比如节点标签是acceleratornvidia-a10DaemonSet 里写成了acceleratora10插件 Pod 根本不会调度上去自然没有资源上报。这种情况下的表现是资源缺失但完全没有报错最容易被忽略。我的习惯是部署完 DaemonSet 立刻kubectl get pods -o wide看DESIRED和READY数量是否一致不一致就说明有节点没覆盖到。第四层的坑出现在节点重建或者插件反复重启之后。kubelet 侧的 socket 文件残留了旧注册信息新插件注册时冲突。清理方式是在节点上删掉/var/lib/kubelet/device-plugins/kubelet.sock之外的残留文件注意不要删kubelet.sock本身然后重启插件 Pod。5.2 Pod 长期 Pending 的排查顺序GPU Pod 卡在 Pending事件里通常只有一句0/6 nodes are available: 6 Insufficient nvidia.com/gpu。这句话信息量很少需要往下挖。先确认资源是真的没有还是被占了。kubectl describe node node看Allocated resources段能看到当前已分配的 GPU 数量。很多时候是历史任务没有正确清理——Pod 已经 Terminated 但资源还没释放尤其是节点异常NotReady之后 Pod 卡在 Unknown 状态调度器仍然把它算作占用。这种僵尸 Pod 要手动删掉。再确认污点容忍。如果节点上的污点是nvidia.com/gpupresent:NoSchedule而 Pod 的容忍写成了key: nvidia或者effect: NoExecute都匹配不上Pod 会被直接拒绝调度。# 看节点污点 kubectl get node gpu-node -o jsonpath{.spec.taints} # 看 Pod 的容忍与事件 kubectl describe pod pod | grep -A10 Events还有一类隐蔽情况是 Pod 的resources.limits里申请的卡数大于单节点的卡数。比如一个 Pod 申请nvidia.com/gpu: 16而单节点最多 8 张无论等多久都调度不上去。这种配置错误在分布式训练任务里偶尔出现尤其是在调整并行度的时候忘了同步改资源声明。5.3 容器内不可见与多卡缺失nvidia-smi在宿主机正常容器里报Failed to initialize NVML: Unknown Error。这个报错在容器场景里出现频率极高原因通常是容器的设备访问被 cgroup 限制了。新版本的 NVIDIA 容器工具链引入了 cgroup v2 的兼容处理如果宿主机的 cgroup 版本和 toolkit 的假设不一致设备注入就会漏掉。检查方式是在节点上看/sys/fs/cgroup的挂载情况判断 cgroup 版本然后对照 toolkit 文档确认配置里的no-cgroups参数该设成什么值。通常在 cgroup v2 环境下需要让运行时自己处理设备权限而非依赖 hook 预注入。多卡缺失是另一类。容器里跑nvidia-smi -L只列出一张卡但宿主机上是八张。可能原因有三一是 Pod 只申请了 1 张那插件本来就只注入 1 张这属于正常行为不是故障二是插件版本老多设备环境变量注入时用了旧的逗号分隔格式而运行时只解析了第一个三是 MIG 模式下实例数与预期不符插件上报的replicas按实例数算你申请 2 份拿到的是同一个物理卡上的两个实例nvidia-smi -L看起来就会有点奇怪。排查这类问题最有效的方式是进容器看环境变量kubectl exec -it pod -- env | grep -i nvidiaNVIDIA_VISIBLE_DEVICES应该列出一组 UUID而不是all或者空。如果它是空的说明设备根本没注入问题在运行时如果列了多个 UUID 但只看到一张卡问题在应用侧或者驱动对多设备的初始化逻辑。这个区分动作能把排查范围一下子缩小一半。6. 上线后的运维细节与监控6.1 GPU 指标的采集与告警基线节点资源是 8 不代表真的能用 8。显存碎片、残留进程、显卡温度过高触发降频这些在集群视角都看不见。必须单独采一层 GPU 指标。业界比较成熟的做法是用 DCGM 的 exporter 以 DaemonSet 形式部署在 GPU 节点上暴露显存占用、算力利用率、温度、功耗、ECC 错误等一整套指标再由集群的监控体系抓取。关键指标我一般设这几条告警显存使用率持续超过 90% 超过 10 分钟——大概率是泄漏或者模型加载异常GPU 温度超过阈值——检查机房散热或风扇故障出现 XID 错误——驱动或硬件层面的问题需要尽快介入节点上存在 GPU 进程但 Kubernetes 侧没有对应的 Pod——僵尸进程占着卡不放。最后这条是最有价值的。我遇到过好几次资源明明够但调度不上的情况追下去都是上一次任务异常退出后宿主机上残留了一个 GPU 进程nvidia-smi里能看到但它不属于任何活跃 Pod。这种进程不会自己消失只能手工kill。有了指标告警这类问题能在几分钟内被发现而不是等到业务方投诉。6.2 节点维护时的正确排水姿势给 GPU 节点做驱动升级或者硬件维护直接kubectl cordon加drain是危险的。原因是训练任务通常没有优雅退出的实现drain的超时时间一到就会强杀 Pod几天的训练进度直接丢失。我的做法是分三步。先打上gpu.statedraining标签配合调度器的亲和性让新的 GPU 任务不再往这台节点上调度然后通过任务侧的通知机制让业务方在合适的检查点主动保存和退出这一步通常要留出小时级的窗口最后确认节点上没有活跃的 GPU Pod 之后再执行drain。kubectl label node gpu-node gpu.statedraining --overwrite # 等待业务侧自行退出确认无活跃 GPU Pod kubectl get pods --all-namespaces --field-selector spec.nodeNamegpu-node -o wide kubectl drain gpu-node --ignore-daemonsets --delete-emptydir-data--ignore-daemonsets是必需的否则设备插件的 DaemonSet Pod 会让 drain 卡住。--delete-emptydir-data也要加很多推理服务用 emptyDir 做临时缓存不加这个参数 drain 会报错退出。维护完成后把标签改回来之前先跑一遍第 3.4 节的端到端验证确认nvidia.com/gpu数量和物理卡数一致再放流量进来。我有一次升级驱动后忘了验证节点恢复调度的第一分钟就有一批任务调上去失败日志全是 CUDA 初始化错误回滚才发现是新驱动的某个小版本和业务镜像里的库不兼容。6.3 几个容易被忽略的长期运维坑第一个是镜像体积和拉取时间。GPU 业务镜像动辄十几个 GB节点上的镜像缓存策略如果没配好Pod 启动要等好几分钟。建议在 GPU 节点上设置合理的imageGCThreshold同时给高频镜像做预热或者用本地镜像仓库做就近分发。第二个是nvidia.com/gpu资源的泄漏。Pod 被强杀时如果 kubelet 没有走完整的清理流程扩展资源的占用可能残留。定期做一次巡检把节点上报的 GPU 数和实际活跃 Pod 申请的 GPU 数做对账不一致就清理。第三个是驱动升级时的滚动范围。绝对不要全集群同时升级。按节点池分批先升一个池观察 24 小时确认没有 XID 错误、没有业务异常再推进下一个池。驱动回滚比想象中麻烦尤其是内核模块层面的回滚有时候需要重启机器才能生效。我自己在实际操作中的体会是GPU 节点的纳管难点从来不在那条kubeadm join命令上而在前面那三层——驱动、运行时、插件——的状态是不是真的对齐了。每次扩容我都会把 3.4 节那三步验证完整跑一遍看着nvidia.com/gpu的数字和物理卡数对上心里才踏实。省下这十分钟后面可能要花十个小时去排查一个莫名其妙的 OOM。