
咱们搞 Kubernetes 的人十有八九都被 GPU 调度折腾过。明明节点上nvidia-smi看得到 8 张 A100可用kubectl describe一看 Pod 还是 PendingCPU 内存都够就是 GPU 没配上。这个问题的根子就在于很多人只把 GPU 当成一个“特殊的更大号的资源”去配置却没有真正理解从节点上架到 Pod 最终拿到 GPU中间到底有哪些组件在协作、各自做了什么决策。这篇文章我不打算只讲“怎么装 nvidia-device-plugin”而是把这整条链路从头到尾捋一遍驱动和容器运行时怎么准备、设备插件怎么把物理 GPU 注册成 Kubernetes 认识的资源、调度器怎么决策、kubelet 怎么把 GPU 真的塞进容器。我会把每一步的原理和我在生产环境里踩过的坑串在一起适合正在搭 GPU 集群的运维也适合搞 AI 平台开发的工程师参考。1. 节点上架的前置条件硬件、驱动与容器运行时的三层耦合1.1 先确认你手里的卡是什么定位在动手配置之前得先搞清楚手上是什么类型的 GPU。数据中心卡A100、H100、V100、T4、L40S 这一档和企业级卡A30、A10 等走的是标准 NVIDIA 驱动生态产线场景基本围绕它们转。消费卡RTX 3090、4090在测试环境和内部小规模训练里也经常出现但驱动分支不同某些型号需要单独打补丁而且不支持 MIG。这一步决定了后面驱动的下载路径。NVIDIA 官方驱动按分支区分数据中心卡装-server变体常见命名类似550.54.15-server桌面卡装普通变体。搞反了不是不能用而是某些功能比如 vGPU 或者 MIG 开不了会受影响。我见过有人把消费卡驱动硬装到 A 系列卡上nvidia-smi能出信息但 CUDA 任务一跑就崩最后回滚驱动才恢复正常。还有一类被问得很多的“昇腾”它走的是另一套 CANN 生态和 Ascend device-plugin原理和 NVIDIA 的 Extended Resource 机制一样但设备插件、容器注入方式完全不同。本文所有步骤都以 NVIDIA 生态为例昇腾和 AMD ROCm 的配置逻辑可以类比迁移。1.2 驱动、CUDA 与容器运行时的版本匹配很多新手在“驱动版本怎么选”这个问题上纠结很久。其实这里有个非常实用的简化规则宿主机只装显卡驱动CUDA 版本交给镜像里的 CUDA Runtime驱动版本只需要大于等于你镜像里 CUDA 要求的最低驱动版本即可。比如你的训练镜像用的是pytorch/pytorch:2.1.0-cuda12.1-cudnn8那么宿主机驱动只要能满足 CUDA 12.1 的最低驱动要求大约是 530.x 以上就行。反过来如果你装了老驱动 470.x那 CUDA 12.x 的镜像基本跑不动容器里一执行nvidia-smi就报CUDA driver version is insufficient。判断驱动最低要求有个简单的办法在 NVIDIA 官方的 CUDA 兼容性表格里看某 CUDA 版本对应的 Minimum Driver Version。实际操作中我一般直接装当前最新的 stable 驱动分支比如 550 或 560避免频繁为了新 CUDA 镜像去升级节点驱动。测试环境里我会刻意保留一套“老驱动 老镜像”的组合专门验证历史任务兼容性。1.3 容器运行时改造nvidia-container-toolkit 是必选项驱动装好之后光在宿主机上nvidia-smi能出结果还不够容器运行时也要能感知 GPU。这一步靠的是 NVIDIA 官方的nvidia-container-toolkit它负责在容器启动时把 NVIDIA 驱动库、GPU 设备节点注入到容器里。这里有一个很容易被忽略的历史遗留问题老一点的教程会让装nvidia-docker2它依赖 Docker 的 custom runtime 机制。但现在生产环境大多数已经切到 containerd 了所以正确做法是装nvidia-container-toolkit之后手动改/etc/containerd/config.toml给 containerd 增加一个nvidiaruntime再把它设置为默认 runtime。以 Ubuntu 系为例大致的安装命令是这样的distribution$(. /etc/os-release;echo $ID$VERSION_ID) curl -s -L https://nvidia.github.io/libnvidia-container/gpgkey | sudo apt-key add - curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.list | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list sudo apt-get update sudo apt-get install -y nvidia-container-toolkit然后让 containerd 识别到新 runtimesudo nvidia-ctk runtime configure --runtimecontainerd sudo systemctl restart containerd这里的核心原理是nvidia-ctk runtime configure会在 containerd 配置里插入一段[plugins.io.containerd.grpc.v1.cri.containerd.runtimes.nvidia]告诉 CRI 存在一个名为 nvidia 的 runtime。如果你不想全局默认成 nvidia runtime就保留默认的 runc在 Pod 的 RuntimeClass 里指定nvidia。默认全局反而省事因为非 GPU Pod 即使经过 nvidia runtime如果没有设置 GPU 环境变量也只是环境里多了几个动态库路径不会真正挂载显卡设备影响可以忽略。1.4 节点标签给 GPU 节点一个“身份”节点上架之后在设备插件上报资源前我建议先手动打一套标签把 GPU 的型号、数量、显存大小记在节点上。这些标签后续写调度策略时非常有用。kubectl label node gpu-node-01 \ nvidia.com/gpu.productNVIDIA-A100-SXM4-40GB \ nvidia.com/gpu.count8 \ nvidia.com/gpu.memory40Gi \ acceleratornvidia同时如果是专用 GPU 节点要加 taint防止普通无 GPU 需求的 Pod 被调度上去kubectl taint nodes gpu-node-01 nvidia.com/gpu:NoSchedule这套“label taint”组合是后期用 nodeSelector、亲和性、容忍度管理 GPU 节点的基础。没打标签就裸奔上架等 Pod 量大了再回去补就只能一个个节点手工操作了。2. device-plugin 的本质把一块物理 GPU 翻译成一个可调度资源2.1 为什么 Kubernetes 原生不认识 GPUKubernetes 对资源调度有一个核心分界可压缩资源CPU和不可压缩资源内存、GPU。CPU 可以超卖因为时间片可以切分而 GPU 这类设备一旦分配出去就只能给一个容器独占不能切时间片除非走 MIG 或虚拟化。因此Kubernetes 把 GPU 归类为Extended Resource扩展资源。扩展资源有四个硬性约束理解这些约束能解释很多“诡异现象”数量必须是整数nvidia.com/gpu: 0.5是非法请求不支持 overcommit不能设置超过节点上报总量的请求不支持requests与limits分离配置调度时只做“数量”的判断不感知显存大小、不感知 GPU 型号、不感知拓扑这就是为什么默认情况下你没法告诉 Kubernetes“我要一张 40GB 显存的卡”——它不知道也没办法知道。这个问题的解法要么依赖设备插件把不同的 GPU 类型注册成不同的资源名要么靠外部调度器插件去感知。2.2 设备插件的 ListAndWatch 与 Allocate 两大 APIKubernetes 从 1.8 开始引入了 device plugin 框架本质上是一个运行在节点上的 gRPC 服务。kubelet 启动时会主动去发现/var/lib/kubelet/device-plugins/目录下的 Unix socket并通过这个 socket 跟设备插件通信。两个核心接口决定了整条链路ListAndWatch(Empty, stream Device)设备插件启动后通过这个接口把节点上的 GPU 设备 IDUUID列表推送给 kubelet。kubelet 拿到后会在节点状态里把nvidia.com/gpu资源数量更新为“可用 GPU 数量”。之后 kubelet 会持续 watch如果某张卡掉线插件会通过流推送一个新的列表kubelet 更新资源数量。这就是 GPU 故障后节点资源数自动降低的机制来源。Allocate(AllocateRequest, AllocateResponse)当 Pod 被调度到这个节点并且请求了nvidia.com/gpu时kubelet 在为 Pod 创建容器前会调用这个接口。插件返回什么kubelet 就用什么。NVIDIA 官方的nvidia-device-plugin在 Allocate 阶段会返回三样东西环境变量NVIDIA_VISIBLE_DEVICESGPU UUID设备节点映射把宿主机/dev/nvidia0、/dev/nvidiactl、/dev/nvidia-uvm等映射进容器cgroup 设备白名单让容器只允许访问指定的 GPU 设备2.3 部署 nvidia-device-plugin 的两种方式第一种是直接用 DaemonSet。这种方式最直观适合已有集群快速接入。apiVersion: apps/v1 kind: DaemonSet metadata: name: nvidia-device-plugin-daemonset namespace: kube-system spec: selector: matchLabels: name: nvidia-device-plugin-ds template: metadata: labels: name: nvidia-device-plugin-ds spec: tolerations: - operator: Exists containers: - name: nvidia-device-plugin-ctr image: nvcr.io/nvidia/k8s-device-plugin:v0.15.0 env: - name: NVIDIA_VISIBLE_DEVICES value: all volumeMounts: - name: device-plugin mountPath: /var/lib/kubelet/device-plugins - name: dev mountPath: /dev resources: limits: memory: 128Mi cpu: 100m volumes: - name: device-plugin hostPath: path: /var/lib/kubelet/device-plugins - name: dev hostPath: path: /dev第二种是走 NVIDIA GPU Operator 统一纳管它会用 Helm 一次性把驱动、device-plugin、DCGM exporter 全部编排好。GPU Operator 适合大型集群的标准化交付但对底层网络、操作系统版本的适配要求更高新环境我建议先用手动 DaemonSet 方式跑通再评估上不上 Operator。2.4 如何确认设备插件生效设备插件装完后看一眼节点资源就知道了kubectl describe node gpu-node-01 | grep -A 10 Capacity正常输出里应该能看到nvidia.com/gpu: 8这个数字出现才说明“物理设备”已经被翻译成了“调度资源”。如果这里显示 0 或者根本没有这一行先查插件日志kubectl logs -n kube-system -l namenvidia-device-plugin-ds最常见的日志错误有两类一类是failed to initialize NVML说明驱动没装好另一类是failed to list devices说明设备插件容器本身看不到 GPU 设备基本是没做/dev挂载或者容器运行时没走 nvidia runtime。3. 从调度器到 kubelet一次 Pod 拿到 GPU 的完整决策链3.1 调度器的 Filter 阶段只做“够不够”的判断当用户提交一个 GPU Pod调度器会经历Filter - Score - Bind三个阶段。在 Filter 阶段调度器拿到的是 kubelet 上报的节点资源快照Node.Status.Allocatable通过一个公式判断节点剩余资源是否满足 Pod 请求可分配 GPU 数量 Node.Status.Allocatable[nvidia.com/gpu] - 已分配 GPU 数量这里的“已分配”是从 API Server 的 Pod 记录里统计的调度器并不真正去看某张卡是否物理空闲。也就是说如果 kubelet 上报的 allocatable 是 8但当前节点上有 2 个 Pod 各申请了 1 张卡调度器就认为剩余 6 张。这里有个非常经典的坑如果你给 Pod 的资源是resources: limits: nvidia.com/gpu: 1而没有写requests扩展资源的规则会默认把requests补成等于limits。表面看没问题但如果你多个容器共用一个 Pod或者你手动设置了requests: 0就会导致调度器的“已分配统计”跟“实际资源占用”脱节轻则资源碎片化重则两个 Pod 被调度到同一张卡上启动时因为设备冲突直接失败。所以 GPU Pod 的 requests 和 limits 一定都要显式写。3.2 Score 阶段默认调度器不感知 GPU 型号Filter 过滤完所有可调度节点后Score 阶段会给候选节点打分。默认调度器的打分逻辑NodeResourcesFit主要看 CPU 和内存的碎片化程度对 GPU 只是“有没有”的等级判断不会因为是 A100 还是 T4 就区别对待。这就意味着不同型号 GPU 混布的集群里如果你不干预Pod 可能被均匀分散到 A100 和 T4 节点上——训练任务慢了你还不知道原因。要解决这个问题优先级最高的手段是 nodeSelector 或 NodeAffinity把特定任务绑定到特定 GPU 型号spec: nodeSelector: nvidia.com/gpu.product: NVIDIA-A100-SXM4-40GB如果想更精细地控制可以通过--feature-gates...或者启用调度插件但目前社区比较成熟的方案很少日常生产中 nodeSelector 已经能解决 90% 的需求。3.3 Bind 之后kubelet 的 Allocate 才是真正“给 GPU”的那一步Pod 被调度器选中绑定到节点之后kubelet 才开始动手。这一步的顺序很多人没注意过kubelet 从 API Server 看到 Pod 已经被 bind 到本节点kubelet 调用容器运行时的RunPodSandbox创建沙箱创建业务容器前kubelet 检查 Pod.Spec 里的资源请求发现有nvidia.com/gpukubelet 通过 device plugin 的 Unix socket调用该资源的Allocate接口设备插件返回NVIDIA_VISIBLE_DEVICESUUID等环境变量和设备映射kubelet 把这些返回值传给容器运行时容器运行时containerd启动 nvidia runtime根据环境变量和预置的钩子脚本完成 GPU 设备挂载容器进程启动看到的环境变量决定了它能访问哪张卡第 5 步到第 7 步是整个链条里最核心的“翻译”动作。NVIDIA_VISIBLE_DEVICES这个变量的值不是随便填的编号而是 GPU 的 UUID。这么做的好处是即使宿主机的设备编号/dev/nvidia0因为驱动加载顺序发生变化容器访问的 GPU 也不会出错。容器起来之后你可以进入容器验证kubectl exec -it pod-name -- nvidia-smi这里看到的 GPU 列表应该只包含被分配的卡而不是宿主机上的全部卡。如果你看到全部卡说明 Allocate 阶段返回了all多半是你给 device-plugin 容器本身设置了不恰当的环境变量或者 RuntimeClass 配置被绕过了。3.4 整条链路的决策事件表把这一整个过程压缩成一张事件表排查问题时会很有用阶段组件判断依据失败表现Filterkube-scheduler节点 Allocatable 剩余 GPU 数量Pod Pending提示节点上资源不足Scorekube-scheduler节点 CPU/内存碎片化分数分配到不期望的节点Bindkube-scheduler选定节点绑定关系Pod Pending绑定失败Allocatekubelet device-plugin资源名与 GPU UUID 映射容器创建失败sandbox 异常容器启动containerd nvidia runtimeNVIDIA_VISIBLE_DEVICES 变量容器内看不到 GPU报 CUDA 错误4. 实操踩坑共享、显存隔离、多容器 Pod 与 MIG4.1 只加 limits 不加 requests 的隐性风险前面提到了 requests 和 limits 不一致的问题我再展开一下。Kubernetes 的扩展资源有一个设计如果你在 Pod 里只写了limits.nvidia.com/gpukubelet 会把requests.nvidia.com/gpu视为相同值。但如果多个容器共用一个 Pod并且只有其中一个容器写了 GPU 限制另一个容器没写那这个 Pod 的 GPU 请求量计算就会变得很混乱。实际操作中我要求团队所有 GPU Workload 采用统一写法resources: requests: nvidia.com/gpu: 1 limits: nvidia.com/gpu: 1这样写还有一个附带好处便于用kubectl describe pod看资源占用也方便给调度器做成本核算。真实环境里那些“GPU 被偷偷占满”的告警一大半都是因为请求和限制不一致导致调度统计虚高或虚低。4.2 一张卡只能给一个 Pod 吗时间切片与 MIG 的选择这是 GPU 集群管理里最常被问的问题。Kubernetes 原生的 Extended Resource 机制是“整卡分配”一张 A100 要么给一个 Pod 独占要么空闲。这在中大型训练任务里没问题但推理服务的显存占用通常只有几个 GB整卡分配会导致巨大的浪费。社区常见的方案有三种时间切片、MIG、第三方 vGPU 方案。我做过一个对比方案原理显存隔离性能边界适用场景备注时间切片time-slicing多个 Pod 共享一张卡按时间片切换无显存共用受单卡算力上限约束多 Pod 互相争抢简单推理服务、低负载多任务配置成本最低MIG硬件级切分每实例有独立显存和计算单元强隔离每个实例算力按切片比例受限中高负载推理、多租户隔离仅 Ampere 及以后数据中心卡第三方 vGPU软件虚拟化显存隔离部分方案可做显存隔离性能损耗 5%~20%需要资源计费的平台额外授权成本时间切片的配置方式也简单NVIDIA 官方提供了一套配置模板通过修改 device-plugin 的配置文件把一张卡伪装成多张可重复调度的设备但要注意它并不能解决显存隔离问题。MIG 则是硬件层面把物理卡切成多个实例比如 A100 40GB 可以切 7 个1g.5gb的实例每个实例拥有独立的显存和 SM。MIG 模式下device-plugin 需要额外配置 MIG 策略资源名也会变成类似nvidia.com/mig-1g.5gb这样的命名调度逻辑跟整卡完全一样只是上报的资源名不同。4.3 多容器 Pod 里的环境变量泄漏问题一个 Pod 里既有 GPU 主容器又有 sidecar 容器比如日志收集、监控代理这是很常见的部署结构。问题在于kubelet 调用 Allocate 时拿到的环境变量是“按 Pod 级别”还是“按容器级别”注入的实际行为是device-plugin 的 Allocate 返回结果会应用到该 Pod 内的所有容器。也就是说如果 device-plugin 给 Pod 注入NVIDIA_VISIBLE_DEVICESGPU-xxxx那么 sidecar 容器启动时也会带上这个变量。对大多数应用来说多一个环境变量无伤大雅但如果 sidecar 里恰好装了某个版本的 CUDA 库或者它本身是个 Python 服务启动时发现有 GPU 环境变量就会尝试初始化 CUDA初始化失败直接崩溃。解决办法有两个要么给非 GPU 容器单独设置环境变量覆盖它env: - name: NVIDIA_VISIBLE_DEVICES value: void要么给 GPU 主容器单独指定 env 和 resources让 sidecar 容器不继承。我的习惯是前者一行配置就能挡住大部分问题。4.4 MIG 模式下的设备 ID 兼容性问题开启 MIG 后GPU 实例的设备节点路径和普通整卡不一样是类似/dev/nvidia-caps/nvidia-cap1这种。NVIDIA device-plugin 需要传入--mig-strategysingle或mixed来识别 MIG 设备。常见的坑是MIG 模式下NVIDIA_VISIBLE_DEVICES的值是MIG-uuid而不是GPU-uuid很多只做了整卡适配的镜像或工具会不识别这个 ID。在镜像层面尽量用较新的 CUDA 基础镜像在排障层面记得先去容器里看环境变量实际注入的 ID 格式。调试命令kubectl exec -it pod -- env | grep NVIDIA kubectl exec -it pod -- nvidia-smi -L如果nvidia-smi -L显示的实例和你预期不符多半是 MIG 策略配置或 device-plugin 的配置和实际 MIG 切分不一致。5. 调度策略调优与日常运维监控5.1 从“能调度”到“调得好”亲和性与反亲和性的实战组合GPU 集群日常很容易出现“一张卡空着另外一张卡挤了仨 Pod”的情况。默认调度器的打分策略对 GPU 节点没有紧凑或分散的偏好所以需要自己加策略。想让 GPU Pod 尽可能分散到不同节点降低单点故障的影响用 PodAntiAffinityaffinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: gpu-workload topologyKey: kubernetes.io/hostname想让多个相关 Pod 尽量聚到同一节点减少跨节点通信开销用 PodAffinity。实际工作中对训练任务我倾向让它们“按用户/项目”聚合避免一个用户的任务把整个集群的 GPU 打散其他人想用却找不到连续的空闲卡。5.2 GPU 监控DCGM 指标看什么、怎么看GPU 调度层只负责分配真正判断“这张卡是不是值得继续跑”需要监控。NVIDIA 官方提供了 DCGMData Center GPU Manager和配套的 dcgm-exporter可以直接接入 Prometheus 生态。部署 dcgm-exporter 同样是一个 DaemonSet镜像拉起来后在:9400/metrics暴露指标。我日常比较关注下面几个指标含义告警建议DCGM_FI_DEV_GPU_UTILGPU 计算利用率百分比长期低于 10% 需要排查业务是否存在等待DCGM_FI_DEV_FB_USED显存已用量接近卡片上限会出现 OOM 风险DCGM_FI_DEV_POWER_USAGE当前功耗功耗异常升高可能表示卡被恶意挖矿或高负载任务DCGM_FI_DEV_TEMPERATUREGPU 核心温度超过 85 度需要关注散热DCGM_FI_DEV_XID_ERRORSXID 错误码出现 31、43、79 等常见 XID 错误需要重点排查Grafana 的展示模板可以直接从社区下载但告警阈值一定要根据自己的卡型调整。A100 和 T4 的功耗、温度基线完全不同用同一套阈值只会被报警淹没。5.3 一个典型的 GPU 排障完整链路最后分享一个实际遇到过的问题可以作为排查思路的参考。现象某节点上有 4 张卡kubectl describe node显示nvidia.com/gpu: 4但提交一个gpu:1的 Pod 后一直 Pending描述信息提示节点资源不足。排查过程是这样的先看节点状态里的 Allocated resources确认是否真的有 Pod 已经占用了 GPU。结果是 0。查看节点上的 Pod 列表发现有几个 Pod 处于 Terminating 状态很久了。GPU 资源释放的判定标准是“Pod 从 API Server 删除”而不是“容器停止”。处于 Terminating 的 Pod 在调度器视角仍然占用资源导致实际空闲但资源不可分配。强制删除这些 Pod问题立刻恢复。这个案例里没有任何复杂的驱动问题纯粹是 Pod 回收流程阻塞导致的资源泄漏。之后我在集群里加了 CronJob定期扫描长期 Terminating 的 Pod 并告警这种问题就能在业务投诉前被处理掉。还有一个更隐蔽的问题运行 CUDA 任务的容器退出后显存没有完全释放导致 kubelet 的可用 GPU 数量和nvidia-smi看到的空闲 GPU 不一致。这个情况比较难自动判断最直接的办法是nvidia-smi --query-compute-appspid,used_memory --formatcsv找出残留进程或者直接重启节点。如果频繁出现建议排查是否有容器在退出前没有正确销毁 CUDA context尤其是用 fork 子进程的老程序。6. 最后补充一个实用小技巧GPU 集群上架并接入调度之后我建议大家先跑一个空壳测试不要直接上训练任务。用一个简单的 busybox 镜像加nvidia.com/gpu: 1资源请求进入容器后看三个信息env | grep NVIDIA、nvidia-smi -L、ls /dev/nvidia*。三个信息全部符合预期再让算法同学跑真实负载。这样做的原因是GPU 调度链路涉及 kubelet、device-plugin、container runtime、驱动、业务镜像五个层面任何一个环节不匹配都会在第 6 层——模型训练跑到一半——才暴露出来。用空壳容器做端到端验证五秒之内就能暴露整条链路的问题成本低得多。我见过太多“本地跑得好好的上集群就 Pending”的案例九成都能用这个方法提前揪出来。