1. 先搞清楚一张 GPU 卡是怎么“进”集群的:节点上架的完整链路
很多人第一次在 Kubernetes 里跑 GPU 任务时,都会有一个想当然的假设:把显卡插进 PCIe 槽,装好驱动,节点上自然就会出现nvidia.com/gpu,Pod 里自然就能nvidia-smi。这套直觉在单机环境下成立,在 K8s 集群里却差了整整三层。一段真实经历:我接手过一个集群,运维新上了一批 A100 节点,硬件装好、驱动装好、nvidia-smi在宿主机上能看到 8 张卡,但kubectl describe node里nvidia.com/gpu一直是 0,调度器永远提示Insufficient nvidia.com/gpu。问题最后定位在容器运行时没有接入 NVIDIA 的 prestart hook——GPU 物理上在机器里,但 Kubernetes 和容器运行时根本“不知道”怎么把它交给容器。
所以这条链路的完整顺序是:物理设备 → 内核驱动 → NVIDIA Container Toolkit → 容器运行时 → Device Plugin → Kubelet Device Manager → kube-scheduler → Pod。这篇文章就从这条链路往下拆,把每一层为什么存在、做什么事、出问题怎么查,一次讲清楚。
1.1 驱动层:宿主机 nvidia-smi 能出卡只是起点
GPU 节点上架,第一件事当然是装驱动。但这里有一个很容易被忽略的分工:宿主机驱动解决的是“操作系统能访问 GPU”,和“容器能访问 GPU”是两码事。驱动装好后,你应该确认这些内核模块都加载了:nvidia、nvidia_uvm、nvidia_modeset、nvidia_drm。其中nvidia_uvm尤其关键,它负责 Unified Virtual Memory,Device Plugin 做健康检查和显存分配时都依赖它。如果只有nvidia加载而nvidia_uvm没加载,宿主机nvidia-smi还能看到卡,但容器里 CUDA 初始化会直接失败。
另一个常见坑是驱动版本和容器内 CUDA 版本的关系。很多人以为容器里跑 CUDA 12,宿主机就必须装 CUDA 12 的驱动,其实不是。容器镜像里的 CUDA Toolkit 是用户态库,宿主机驱动是内核态和用户态驱动,两者通过驱动暴露的接口通信。只要宿主机驱动版本 ≥ 容器内 CUDA 要求的最低驱动版本,就能跑。比如 CUDA 12.4 要求驱动 ≥ 550.54.14,你装 550 或更高版本都行。反过来就不行:驱动版本低了,容器内应用启动时会报CUDA driver version is insufficient for CUDA runtime version,这个错误几乎不用怀疑,就是驱动太老。
如果你用 Deb/RPM 或 runfile 装驱动,建议直接选发行版仓库里带 DKMS 的包,或者自行保证内核升级后驱动要重新编译。GPU 集群里因为内核安全升级导致驱动模块丢失、节点直接失去 GPU 能力的事,我见过太多次了。
驱动装完,用nvidia-smi -L确认所有卡都识别正常,然后用nvidia-smi -q -d ECC看显存 ECC 状态。如果 ECC 错误数激增,这张卡要么返修要么隔离,不要直接丢进集群。
1.2 NVIDIA Container Toolkit:容器运行时认不认识 GPU,由它说了算
宿主机驱动搞定了,下面这一步是整个上架过程中最容易出错、也最不被理解的:NVIDIA Container Toolkit(旧名 nvidia-container-runtime / nvidia-docker2)。它的本质不是一个完整的容器运行时,而是一个 OCI Runtime 的 prestart hook,在容器创建前截获请求,检测容器是否请求 GPU,如果需要,就把宿主机上的 GPU 设备节点、驱动库、二进制工具链(比如nvidia-smi)注入到容器的 runtime spec 里,同时设置好环境变量。
为什么没有它就不行?因为容器默认只有一份最小化的设备白名单,/dev/nvidia* 这些设备不会被自动暴露进容器。CUDA 程序在容器里找不到/dev/nvidia0、/dev/nvidiactl、/dev/nvidia-uvm,自然认为自己跑在一台没有 GPU 的机器上。
以 containerd 为例,你需要修改/etc/containerd/config.toml,给 CRI 增加一个nvidiaruntime,然后重启 containerd:
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia] privileged_without_host_devices = false runtime_engine = "" runtime_root = "" runtime_type = "io.containerd.runc.v2" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia.options] BinaryName = "/usr/bin/nvidia-container-runtime"这里有一个容易翻车的点:如果你只改了 containerd 配置,但 Kubelet 创建 Pod 时用的 runtimeClass 仍然是默认的runc,GPU 设备还是进不了容器。较新的 NVIDIA Container Toolkit 支持把 nvidia 设为默认 runtime,或者你为 GPU Pod 显式指定runtimeClassName: nvidia。两套模式各有取舍:全节点默认 nvidia runtime,简单直接,但相当于所有容器都走了 hook;用 runtimeClass 隔离,更干净,但需要业务方 yaml 里写好。自己内部集群我建议直接默认 runtime 设成 nvidia,少一层扯皮。
验证这一步是否配置正确,最快的方法是先不要上 Device Plugin,直接手动跑一个测试容器:
ctr image pull docker.io/library/ubuntu:22.04 ctr run --rm --runtime io.containerd.runc.v2 --gpus 0 docker.io/library/ubuntu:22.04 test-nvidia nvidia-smi如果容器里能看到卡,说明 runtime hook 已经通了,再往后走 Device Plugin。
1.3 标签、污点与“可调度”的边界
GPU 节点上架的最后一步,是让集群知道这张卡有什么用、给谁用。我通常会做三件事:
- 给节点打标签:
gpu.vendor=nvidia、gpu.model=A100-SXM4-40GB、gpu.memory=40Gi。标签不是为了装饰,而是给调度器和业务方一个明确的路由依据。没有标签,业务方想只调度到 A100 节点,就得靠nodeName硬编码,非常难维护。 - 给节点打污点:
dedicated=gpu:NoSchedule,同时给 GPU 工作负载加对应 toleration。这一步能把普通 CPU 业务挡在 GPU 节点外面。GPU 节点通常很贵,CPU 和内存配置也比较高,如果不打污点,一些无状态业务很可能被调度上去,把节点 CPU 挤爆,影响 GPU Pod 的通信线程。 - 确认 kubelet 启动参数里没有
--register-with-taints和自定义--node-labels冲突。很多发行版用 systemd 管理 kubelet,改配置的姿势不对,kubelet 一重启标签就丢了。
做到这一步,节点只是“准备好了”,Kubernetes 还不会报告它有 GPU 资源。下一节就是整个 GPU 调度体系里最关键的一块:Device Plugin 和 Extended Resource。
2. Device Plugin 与 Extended Resource:调度器凭什么“看见” GPU
有一个基本事实要先说清楚:kube-scheduler 本身对 GPU 一无所知。它既不认识 NVIDIA,也不认识 CUDA,更不理解“显存”“算力”“SM 利用率”这些概念。Kubernetes 之所以能调度 GPU,靠的是 Extended Resource 机制加 Device Plugin 插件,二者配合才让 GPU 变成一个调度器能处理、kubelet 能分配的“资源”。
2.1 为什么 K8s 把 GPU 当成一个“黑盒整数”
在 K8s 里,nvidia.com/gpu属于 Extended Resource。Extended Resource 有一系列硬性限制,其中一个关键限制是:只能申报整数数量,不能像 CPU 那样写500m,也不能写0.5。所以从调度器视角看,一张 A100 和一张 T4 没有区别,都只是“1”这个数字。它不会关心你的 Pod 需要 40GB 显存还是 16GB 显存,也不在乎这张卡能不能满足你的算力需求。
这个设计的妥协是有历史原因的。Extended Resource 的目标是提供一种通用扩展机制,让各种硬件设备都能被调度器纳入计算,但又要保持调度器足够简单和高效。真正理解显存、拓扑、MIG 这些复杂属性的,只有对应硬件厂商或者运维团队的扩展组件。所以你会看到,官方 Device Plugin 只负责数量维度,如果你想要“按显存调度”,就得自己去写调度插件或者用第三方解决方案。
这也引出第一个运维经验:不要试图用 Extended Resource 管理显存。如果你把显存建模成nvidia.com/gpu-mem=23000,会遇到两个问题:一是 Extended Resource 不允许小数,显存单位只能以整数 MiB 上报;二是调度器不知道自己分配出去的 GPU 卡到底是哪一张,很容易出现“调度到 40GB 显存的卡,但实际分配到的卡只剩 20GB 可用”的状态。后续的 NVIDIA 官方方案在 MIG 场景用nvidia.com/mig-<profile>这样的独立资源名,本质上仍然是数量匹配,而不是显存大小匹配。
2.2 Device Plugin 的三件事:注册、上报、分配
Device Plugin 是 kubelet 的一个插件机制,通常以 DaemonSet 方式部署到每个 GPU 节点。它是运行在宿主机上的一个二进制,通过一套 gRPC 接口和 kubelet 通信。NVIDIA 官方实现叫 k8s-device-plugin ,社区也有 AMD、Intel 等其他实现。它的核心生命周期可以概括为三步:
- 注册:插件启动后,通过 Unix socket
/var/lib/kubelet/device-plugins/kubelet.sock向 kubelet 注册自己,声明资源名是nvidia.com/gpu。kubelet 会为这个资源名建立独立的设备管理器表。 - 上报与健康检查:插件通过
ListAndWatch接口持续向 kubelet 上报当前节点上的设备列表,比如 8 个 GPU 的 UUID。如果某个 GPU 出现 Xid 错误、驱动异常或者无法访问,插件会标记这个设备为Unhealthy,kubelet 就不会再把它分配给新 Pod。设备变成 Unhealthy 后,已运行在该设备上的容器不会被强制杀掉,但新的调度会避开它。 - 分配:当调度器把 Pod 调度到某个节点,且这个 Pod 请求了
nvidia.com/gpu,kubelet 会在创建容器前调用插件的Allocate接口,传入请求的设备 ID。插件返回一组信息:需要注入的环境变量(典型是CUDA_VISIBLE_DEVICES)、需要挂载的设备文件、需要做的 mount、需要附加的 annotation 等。kubelet 把这些信息交给 CRI 运行时,最终形成容器可感知的 GPU 环境。
所以 Device Plugin 的本质是“翻译官”:把底层硬件的复杂性翻译成 kubelet 能执行的分配指令。这里有个经常被误解的地方:Device Plugin 不做调度决策,它只负责本节点的设备分配。真正决定 Pod 落在哪个节点的是调度器,而调度器依赖的是插件上报的数量。
2.3 节点上 GPU 时有时无的常见原因
节点上nvidia.com/gpu不可用或者数量不对,排障时先别急着怀疑调度器,按这个顺序查:
- 检查 Device Plugin Pod 是否正常运行:
kubectl logs -n kube-system -l name=nvidia-device-plugin。如果插件一直 CrashLoopBackOff,大概率是宿主机驱动和插件版本不匹配,或者 socket 路径不对。 - 看插件日志里设备上报的数量。一个健康节点应该类似
Found 8 GPU devices。 - 看
kubectl describe node里Allocatable和Capacity的nvidia.com/gpu。如果 Capacity 有但 Allocatable 是 0,说明设备被标记为 Unhealthy 了,查健康检查日志和 Xid。 - 如果状态一直不刷新,重启 kubelet 通常能解决。Device Plugin 注册是一次性的,kubelet 和插件之间断连后,插件一般会重试注册,但有时重试逻辑不够健壮,重启 kubelet 是最快的兜底方案。
- 还要检查节点上是否已经有其他 Pod 占用了这些 GPU 资源:
kubectl get pods --all-namespaces -o wide看看有没有悬空的 Pod 占据了资源。这一点经常被忽视——旧 Pod 卡在 Terminating,资源没释放,新 Pod 自然调度不进来。
注册机制还有个小坑:设备插件删除后重新部署,如果新 Pod 的 socket 文件还残留,插件可能注册失败。所以在更新 Device Plugin 时,最好用 DaemonSet 滚动更新方式,让插件走完整的启动、注册流程。
3. 从调度到绑卡:Pod 请求 GPU 后到底发生了什么
前面说了调度器只知道“整数”,这一节我们详细走一遍,一个 GPU Pod 从提交到真正拿到卡,系统内部做了哪些决策。这个过程中有两类角色分工,很容易混淆:调度器负责决定“去哪台节点”,kubelet 负责决定“用哪张卡”。
3.1 过滤与打分:调度器对 GPU 只做数量匹配
一个 GPU Pod 的 yaml 通常长这样:
apiVersion: v1 kind: Pod metadata: name: gpu-pod spec: containers: - name: pytorch image: pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime resources: limits: nvidia.com/gpu: 1注意这里我只写了limits没写requests。K8s 的语义是如果只写 limits,requests 默认等于 limits。这点在 GPU 场景很重要,因为像 CPU/内存,你可以只写 requests 不写 limits,但在 GPU 上,必须让请求和限制一致,否则调度器无法保证资源不超卖。
调度器处理这个 Pod 时,会先执行过滤。NodeResourcesFit插件会检查候选节点的nvidia.com/gpu可分配数量是否满足请求。这里有一个关键的内部逻辑:调度器计算的是“已调度但可能尚未创建的 Pod 的请求总量”,而不是“已经实际分配的 GPU 数量”。所以它是基于声明式资源模型做判断,不看真实负载。这带来一个常见问题:节点上的 GPU 明明有 Pod 在跑,但显存、算力都很空闲,调度器却认为资源满了——因为它统计的是请求量,不是使用量。反过来,如果 GPU 已经被某个进程吃满显存,但 K8s 层面没有任何 Pod 请求该资源,调度器还是会把它当成空闲资源。后面我们会聊到这是超卖/共享方案要解决的根源。
过滤通过后进入打分环节。默认策略对 CPU 和内存是LeastAllocated,倾向于把负载打散。但对 GPU 来说,这种打散策略并不总是好事。你可能希望 GPU 任务尽量堆满一张卡再开下一张,降低碎片;也可能希望分散到不同节点,避免单点故障影响面扩大。官方 Device Plugin 不提供任何打分策略,所以如果你对 GPU 调度有拓扑、共享、显存维度的需求,要么自己写调度插件,要么上 Volcano、Kueue、scheduler-plugins 这类扩展。Volcano 的 binpack 插件、scheduler-plugins 的拓扑感知插件,都是针对这类场景的成熟实现。
3.2 kubelet 的设备管理器:节点侧才是真正的“发卡”环节
Pod 被调度到节点后,kubelet 开始创建工作负载。在创建容器之前,kubelet 的 Device Manager 会检查这个 Pod 请求了哪些 Device Plugin 资源,如果存在,就进入Allocate流程。这是整个链路中最容易出问题、也最值得深入理解的一环。
Device Manager 在 kubelet 内部维护了一张“已分配设备”的表。当同一个节点上多个 Pod 都请求 GPU 时,Device Manager 会按设备 ID 去重分配:Pod A 拿到GPU-123,Pod B 拿到GPU-456,不会出现同一张卡被两个 Pod 同时分配的情况(这是 K8s 默认语义,除非你用的是共享/超卖方案)。
分配完成后,Device Manager 调用对应 Device Plugin 的Allocate接口。插件返回的内容会包含:
- 环境变量:
CUDA_VISIBLE_DEVICES=GPU-123 - Mounts:比如需要把宿主机
/usr/local/nvidia/lib64/下的驱动库挂进容器 - Devices:比如
/dev/nvidia0:/dev/nvidia0 - Annotations:插件自定义信息
然后 kubelet 把这份附加信息传给容器运行时,运行时在创建容器时执行这些挂载和环境变量注入。这里有个细节:Allocate是 kubelet 在创建 Pod sandbox 或容器时调用的,所以 Device Plugin 必须事先已经注册,且设备健康。如果插件没注册成功,kubelet 会创建一个状态为Failed的沙箱,Pod 一直卡在ContainerCreating。
关于同一个 Pod 内多个容器请求 GPU 的场景,NVIDIA 官方插件的实现允许 Pod 内多个容器共享同一张卡。比如一个 Pod 里有两个容器,各请求 1 个 GPU,总共请求 2 个 GPU。这时Allocate调用会在容器级别分别传入设备。如果你只申请了 1 个 GPU,两个容器都请求nvidia.com/gpu: 1,插件会返回同一个设备给两个容器吗?答案取决于插件实现。官方插件在这种场景下会为每个容器分配独立的设备,所以实际上你申请 1 个 GPU、两个容器都要用,K8s 会认为资源不足,Pod 会被拒绝。正确做法是每个容器都显式请求 GPU 数量,总体请求等于容器请求之和。
3.3 CUDA_VISIBLE_DEVICES 与容器里“看到”的卡
队列里最经典的问题:为什么在容器里跑nvidia-smi,只看到一张卡?道理很简单:所有被分配的卡并不是全部物理暴露给容器,而是通过CUDA_VISIBLE_DEVICES环境变量做了一层“过滤”。
在宿主机上,你用nvidia-smi -L看到的是物理 GPU 的 UUID;在容器里,CUDA 库启动时会读取CUDA_VISIBLE_DEVICES,只让程序枚举到这个变量指定的设备。所以即使宿主机有 8 张卡,容器里nvidia-smi也只会显示被塞进这个变量的那一张或那几张。
这里有个不容忽视的运维细节:CUDA_VISIBLE_DEVICES的值不是 GPU 索引,而是 UUID。为什么用 UUID?因为索引会随驱动加载顺序、设备顺序变化,而 UUID 是硬件级的、稳定的。如果插件按索引分配,那么宿主机重启后设备顺序一变,容器里看到的卡可能就换了。用 UUID,每次重启后分配依然稳定。
如果你遇到“容器里看不到 GPU”这类问题,第一步就是手动进容器,用env | grep CUDA看看CUDA_VISIBLE_DEVICES是否存在。如果存在,说明 Device Plugin 分配逻辑正常;如果不存在,说明容器根本没走 Device Plugin 的分配流程,优先怀疑容器运行时没配好,或者 Pod 的 resources 里没写 GPU 请求。
还有一类场景,容器里能看到多个 GPU,但程序只用到其中一个,这通常是应用代码没有感知到设备数量,写死了cuda:0。分布式训练里这类问题很常见,后面在排障部分专门说。
4. 显存、算力与共享:GPU 资源管理的进阶玩法
默认情况下,K8s 只能把一个 GPU 整卡分配一个 Pod,不能把一个 GPU 分配给多个 Pod。这在显存小、任务多、碎片化严重的推理场景里非常浪费。A100 80GB 跑一个小模型,显存可能只用 10GB,但一个 Pod 独占之后,其他 Pod 都挤不进来。所以真实生产环境里,GPU 资源管理一定绕不开“共享”和“切分”两件事。这一节聊聊我实际用过的方案:MIG、时间切片、第三方共享组件,以及多卡任务的拓扑优化。
4.1 MIG:把一整张卡拆成多个互相隔离的实例
MIG(Multi-Instance GPU)是 NVIDIA 在 A100、H100、A30 等数据中心 GPU 上提供的硬件级切分能力。它能将一张物理 GPU 切分成最多 7 个实例,每个实例有独立的显存、独立的内存控制器、独立的 SM 分区,实例之间在硬件层面隔离。这意味着一个 MIG 实例上跑的任务显存爆掉,原则上不会影响同一物理卡上的其他实例(也有边界情况,后面会提到)。
MIG 开启后,K8s 的资源模型会发生根本变化。物理 GPU 不再上报nvidia.com/gpu: 1,而是上报一组 MIG profile,比如:
nvidia.com/mig-1g.10gb: "2" # 这张卡可以创建 2 个 1g.10gb 实例 nvidia.com/mig-2g.20gb: "1" # 还可以创建 1 个 2g.20gb 实例Device Plugin 会把这些 profile 作为独立的 Extended Resource 上报。调度器依然只做整数匹配,但这里的“整数”对应的是 MIG 实例数,不再是物理卡数。Pod 申请nvidia.com/mig-1g.10gb: 1,调度器确保节点上该 profile 还有可用实例,kubelet 在Allocate阶段会告诉容器运行时使用哪个 MIG UUID。
MIG 的好处是隔离性和稳定性,特别适合多租户场景。但它有几个硬约束:
- 不是所有卡都支持 MIG。T4、V100、A40 等经典卡不支持。
- MIG 开启时,物理 GPU 上的
nvidia.com/gpu资源不会上报,所以需要明确选择使用哪种资源模型。 - MIG 实例的显存是固定的,比如
1g.10gb只能用 10GB,如果你想跑一个需要 12GB 的模型,它就是用不了。资源粒度偏粗,容易造成内部碎片。 - 驱动、CUDA 版本、container toolkit 版本都需要匹配 MIG 支持。
我个人的建议是:如果集群里主要是推理业务、租户隔离要求高、卡是 A100/H100 这类支持 MIG 的新卡,优先上 MIG;如果跑大模型训练,一张卡 80GB 经常都不够用,再切 MIG 反而浪费,直接用整卡更合理。
4.2 时间切片与显存共享:超卖和隔离的权衡
MIG 是硬件切分,另一条路线是软件层面的共享。官方比较轻量的做法是 GPU Time Slicing,本质上是利用 CUDA 的时间片调度,让多个上下文共享同一物理 GPU。NVIDIA 官方 device plugin 提供了 time-slicing 配置,可以在一张物理卡上虚拟出多个“虚拟 GPU”资源。配置之后,节点上报的nvidia.com/gpu数量可以超过物理卡数量,比如 8 张物理卡、每张虚拟成 4 个,节点就有 32 个nvidia.com/gpu。
时间切片的好处是部署简单,适合把小模型批量打包到一张大卡上。但它的隔离性很差:
- 显存不隔离。两个 Pod 共享同一张卡,各自申请 16GB 显存,实际物理显存可能只有 24GB。如果两个 Pod 同时把显存吃满,会触发显存 OOM,甚至产生 Xid 错误导致整卡掉卡。
- 算力不隔离。时间片调度依赖 CUDA 层,如果一个 Pod 里跑满算力,另一个 Pod 的延迟会显著上升。
- 故障影响面大。一个 Pod 的异常 CUDA 上下文可能影响同卡上的其他 Pod。
所以时间切片只适合“空闲资源利用”和“低敏感推理”,不适合生产环境的租户隔离。
如果你需要同时隔离显存和算力,可以有更重的方案。腾讯 qGPU、阿里 cGPU 这类基于内核和 Device Plugin 的共享方案,能在单卡上创建多个虚拟 GPU,每个虚拟 GPU 有独立的显存上限和算力权重。它们通过劫持 CUDA 的显存申请和 kernel 下发来实现隔离。这类方案的引入成本高,通常还需要专门的驱动模块和研发维护,适合中大型平台团队。
另一种很实用的轻量方案是给同一张卡上的 Pod 分别设置 MIG 或者使用 MPS(Multi-Process Service)限制算力。MPS 是 NVIDIA 提供的进程级共享机制,可以在普通卡上把多个 CUDA 上下文合并提交,配合显存限制能实现一定程度的隔离。但 MPS 的配置和管理也不简单,而且和容器调度结合不如 MIG 那么优雅。
我的一段实战经验:在一个 10 多张 A100 的推理集群里,我用 MIG + 时间切片组合。核心业务用 MIG 保证隔离,非核心的批量离线推理用时间切片跑,把空闲算力利用起来。前提是运维侧有清晰的业务分级和严格的资源 Request 规范。
4.3 多卡任务与拓扑感知:为什么 8 卡任务会“莫名”变慢
GPU 调度还有一个比共享更隐蔽的问题:多卡任务的设备拓扑。以一个常见的 8 卡节点为例,物理上这 8 张卡通过 NVLink 或 NVSwitch、PCIe Switch、甚至 NUMA 节点相连。有的卡之间走高速 NVLink,有的卡之间要走 PCIe,通信带宽和延迟完全不同。
默认调度器不关心这个问题。当你的 Pod 请求 8 个 GPU 时,Device Manager 会从节点上任意挑 8 张可用的卡。如果节点上已经有其他 Pod 占用了部分卡,你的 8 张卡可能分布在不同的 PCIe Switch 下面,跨 NUMA 访问,集合通信性能会有一段断崖式下降。最典型的场景是分布式训练,NCCL 的 allreduce 性能直接决定训练吞吐。我遇到过训练速度慢 30%,排查到最后发现是 4 张卡在 NUMA node 0、4 张在 NUMA node 1,通信大量走 QPI/UPI。
要解决这个问题,有两条路:
- 开启 kubelet 的 Topology Manager 和 CPU Manager,让 GPU、CPU、内存三者尽量在同一 NUMA 节点上对齐。这要求 Pod 在 resources 里显式请求 CPU,并且节点 CPU 策略配置为静态。
- 部署拓扑感知调度插件或调度器。比如 scheduler-plugins 的
NodeResourceFit扩展、NVIDIA 官方的 GPU topology 感知方案,可以在调度阶段就根据 GPU 之间的 NVLink 拓扑计算最优设备组合,并为 Pod 打上最合适的节点标签或模拟资源。
如果你的集群规模不大,业务也比较单一,可以先用节点选择器粗粒度控制:把 8 卡训练任务固定调度到“整节点空闲”的节点上,同时用反亲和避免和其他 GPU Pod 混部。这种方式牺牲了调度密度,但换来了稳定性,在中小集群里是最划算的选择。
4.4 资源测算:怎么判断一张卡到底够不够
很多业务方提交 GPU 申请时,对“需要几张卡”完全没有概念,只管写 4 卡、8 卡。平台侧如果也不做校验,就会出现一堆 Pod 申请了 8 卡但实际只用了 1 卡的情况。所以我要单独提一下 GPU 资源测算。
推理场景的显存需求主要由几个部分组成:模型权重、KV Cache、激活值(activation)、CUDA context。以大模型推理为例,7B 参数 FP16 权重大约 14GB,如果开启 vLLM 这类优化,KV cache 会动态分配,通常还需要预留 20%-40% 的额外空间。你可以用 vLLM 启动时的--max-model-len、batch size 去估算峰值显存,也可以直接看nvidia-smi或 DCGM 指标来反向校准。我的习惯是先按最乐观情况给一个初值,再拿真实请求压低,至少观察一周的显存峰值,再做资源规格模板。
训练场景的显存需求更复杂,但有一个粗略的公式:
- 模型参数权重:假设参数量为 P,FP16 训练时权重占用
2P字节 - 梯度:同样
2P字节 - 优化器状态:Adam 优化器通常需要
8P到12P字节(主副本、动量项、方差项,取决于实现) - 激活值:取决于 batch size、序列长度、层数,这部分经常比权重还大
所以一个 7B 模型,用 Adam + 混合精度训练,光模型状态就需要大概(2 + 2 + 12) * 7GB ≈ 112GB。这意味着单张 80GB 的 A100 根本放不下,通常需要 ZeRO 等并行策略,或者调低 batch size、开启激活重计算。很多训练任务一直 OOM,不是代码问题,是资源测算在第一步就错了。
平台侧可以把常见任务的资源需求做成模板,比如“chat 模型 7B 推理:1×A100 40G / 2×A100 80G”,减少业务方反复试错。
5. 排障实录:GPU 相关问题的定位链路
这一节不讲理论,全部是我在 GPU 集群运维中真实踩过、解决过的问题。我按现象分类,并给出一条可以复现的排查链路,方便你下次直接套用。
5.1 Pod Pending:先看调度器怎么说
GPU Pod 最常见的问题就是一直 Pending。第一步永远是:
kubectl describe pod <pod-name>看 Events 里的调度消息。常见的几种:
0/8 nodes available: 4 Insufficient nvidia.com/gpu, 2 node(s) didn't match node selector, 2 node(s) had taint ...:这种信息非常直白,对应三种原因。Insufficient 说明 GPU 资源不足;node selector 不匹配通常是你用了节点选择器但打的标签不对;taint 相关说明 Pod 没有加 toleration。- 如果 Events 里只有
FailedScheduling,没有其他细节,可能是调度器本身异常或者 webhook 拦截。进 scheduler 日志看一下。 - 还有一种隐蔽情况:一个节点上 Capacity 显示有 GPU,但 Allocatable 是 0。这通常是因为节点上的 GPU 设备被标记为 Unhealthy,或者 Device Plugin 上报设备数为 0。这时候去查 Device Plugin 的日志。
资源请求写错也会导致 Pending。比如业务方在 yaml 里写了nvidia.com/gpu,但集群里根本没有安装 NVIDIA Device Plugin,调度器当然找不到任何节点满足条件。这类问题的排查顺序是:先确认集群里确实有该资源,再看资源数量是不是够,再看 Pod 的调度约束,最后才考虑调度器配置。
5.2 ContainerCreating 与 Runtime Hook 失败
如果 Pod 已经调度到节点,但一直卡在ContainerCreating,说明 kubelet 在创建容器阶段出了问题。先看事件:
kubectl describe pod <pod-name> -n <namespace>比较常见的是:
failed to run Kubelet container ... nvidia-container-cli: detection error: ...这种是宿主机 NVIDIA Container Toolkit 或驱动问题。“detection error”一般翻译过来是“找不到 GPU 设备或者驱动库”,在容器运行时 hook 被调用、但设备没暴露时就会出现。优先检查宿主机nvidia-smi是否正常,再检查容器运行时配置。
另一个高频报错是:
Error: failed to get device ... failed to add device ... /dev/nvidia-uvm does not exist这基本确认是nvidia_uvm内核模块没有加载。直接:
modprobe nvidia-uvm然后重建 Pod。如果重启后模块又消失,你需要把模块加载写成持久化配置,比如/etc/modules-load.d/nvidia.conf。
还有一类 ContainerCreating 与 runtime 无关,是 Pod 内部镜像启动时 CUDA 初始化失败。比如镜像里带的是 CUDA 12.1,宿主驱动只支持到 CUDA 11.8。启动日志会出现CUDA driver version is insufficient。这时候要么换一个与驱动匹配的 CUDA 镜像,要么升级宿主机驱动。强烈推荐把“宿主机驱动版本管理”做成节点池维度,比如 gpu-driver-535、gpu-driver-550 两种节点池,不同业务选择不同池子,避免升级驱动时全集群一起遭殃。
5.3 显存 OOM、Xid 错误与掉卡
显存 OOM 在 GPU 集群里属于“日常暴力”级别的故障。最直观的现象是容器内日志报CUDA out of memory,但 K8s 层面可能没有任何异常事件,因为 K8s 根本不感知显存用量。如果显存 OOM 只导致进程退出,可能只影响这一个 Pod;但如果 OOM 发生在共享卡上、或者直接触发了驱动级的 Xid 错误,风险就大了。
Xid 错误是 NVIDIA 驱动报告的错误码,常见的有:
- Xid 79/80:GPU 显存或者内部错误,经常由显存超频、供电不稳、硬件故障导致。
- Xid 33:通常是驱动和 GPU 固件之间发生异常,处理不当会演变成整卡“掉卡”。
- Xid 45:一般是预取错误,多和 PCIe 链路有关,比如卡没有插稳。
排查掉卡的方法:
dmesg | grep -i nvidia nvidia-smi # 如果卡已经掉,这里看不到对应卡掉卡后,节点上的 Device Plugin 健康检查会立刻把这个设备标记为 Unhealthy,K8s 侧体现为Allocatable数量减少。运维处理要果断:先隔离该节点,然后尝试nvidia-smi --gpu-reset,如果还在掉,就报修。不要抱侥幸心理把坏卡留在节点上,它会不断吞噬排障精力。
对于显存 OOM,另一个常见原因是显存碎片。哪怕总量看起来够,但碎片导致连续显存不足。这类问题只能靠减小 batch size、重启进程、或者换用 vLLM 等对显存管理更友好的推理框架。运维侧无法根治。
5.4 一套可复用的 GPU 排障命令组合
我把常用的排查命令总结成一张速查表,遇到 GPU 相关问题时按顺序执行:
| 现象 | 排查命令 | 关键信息 |
|---|---|---|
| Pod Pending | kubectl describe pod <pod> | Events 里的 FailedScheduling |
| 节点 GPU 数量不对 | kubectl describe node <node> | Capacity / Allocatable / Unhealthy 数量 |
| Device Plugin 异常 | kubectl logs -n kube-system ds/nvidia-device-plugin | 设备数量、健康检查结果 |
| 容器内看不到 GPU | crictl exec <ctr> nvidia-smi,env | grep CUDA | CUDA_VISIBLE_DEVICES 是否存在 |
| 运行时 hook 失败 | journalctl -u kubelet | nvidia-container-cli 相关错误 |
| 驱动/内核模块异常 | lsmod | grep nvidia,dmesg | grep -i nvidia | nvidia_uvm 是否加载、Xid |
| 硬件故障 | nvidia-smi -a,nvidia-smi -q -d ECC | ECC 错误计数、温度、掉卡 |
有一个容易忽略的点:kubectl logs只能看到容器 stdout/stderr,但很多 CRI 错误发生在容器创建前。所以排查 ContainerCreating 时,一定不要只看 Pod 事件,要看 kubelet 日志:
journalctl -u kubelet --since "10 minutes ago" | grep <pod-name>这条命令能看到 CRI 调用失败的具体原因,比 describe pod 有用得多。
6. 长期维护:GPU 集群的监控、升级和一些容易忽略的细节
GPU 集群和普通 CPU 集群的运维节奏差别很大。GPU 是高价值、高故障率、高能耗设备,如果不做监控,故障基本要靠业务方来报。这一节谈谈我长期维护 GPU 集群的经验。
6.1 DCGM 监控与告警指标
NVIDIA 官方的 DCGM(Data Center GPU Manager)Exporter 是目前主流的 GPU 监控方案。它通过 Prometheus 暴露一组 GPU 相关的指标,再由 Grafana 展示。我建议告警至少覆盖以下指标:
DCGM_FI_DEV_GPU_UTIL:SM 利用率。不要只看这一个指标判断 GPU 忙不忙,它衡量的是计算单元占用,显存搬数据但没计算时利用率可能很低。DCGM_FI_DEV_FB_USED/DCGM_FI_DEV_FB_FREE:显存使用率。这个是所有 GPU 告警里最该盯的,显存打满离 OOM 只差一步。DCGM_FI_DEV_TEMP:温度。A100 这类卡长期超过 85°C 就有风险,H100 甚至需要更保守。DCGM_FI_DEV_XID_ERRORS:Xid 错误计数。这个指标突然升高,基本就是硬件问题的前兆。DCGM_FI_DEV_MEM_CLOCK/DCGM_FI_DEV_SM_CLOCK:频率。如果手动降频或热节流,这里能看出来。- NVLink 流量和带宽:
DCGM_FI_PROF_PCIE_TX_BYTES、DCGM_FI_PROF_NVLINK_TX_BYTES,多卡任务性能异常时重点查这里。
告警阈值要按卡类型和业务形态调整。比如 T4 的散热能力和 A100 完全不同,不能设同一个温度阈值。GPU 利用率也不是越高越好,长期 100% 利用率且伴随温度高企,可能导致寿命缩短或可靠性下降。
在共享/超卖场景下,监控的另一个难点是“这张卡上有哪个 Pod”。建议在添加 DCGM 指标时,把 Pod 的 namespace、name、GPU UUID 关联上,否则出现显存突然打满时,你根本不知道是哪个租户干的。
6.2 驱动和容器运行时的升级,怎么不做成大事故
GPU 驱动升级是最容易引发大面积故障的操作。因为一旦驱动版本变化,所有节点上的 Device Plugin、容器运行时 hook、甚至已运行 Pod 的 CUDA 上下文都可能受影响。我建议的流程是:
- 先在测试节点上验证:升级驱动、重启节点、确认
nvidia-smi正常、跑一个真实业务 Pod,观察日志和监控。 - 正式升级时,先
kubectl drain <node> --ignore-daemonsets --delete-emptydir-data,把节点排空。非 GPU 业务先迁走。 - 卸载旧驱动,安装新驱动,建议同时重启节点,确保内核模块干净加载。
- 重启 kubelet、containerd,确认 Device Plugin 已重新注册。
- 用
kubectl describe node确认nvidia.com/gpu数量恢复。 kubectl uncordon <node>恢复调度。- 逐个节点滚动,不要并行操作太多节点。
容器运行时(containerd 版本)升级相对低风险,但也要注意 NVIDIA Container Toolkit 和 containerd 的兼容性矩阵。containerd 大版本升级可能改变 CRI 插件的配置结构,导致config.toml里的 nvidia runtime 配置不生效。升级后一定要先用测试容器验证 GPU hook,再批量推广。
6.3 几个容易被忽略的运维细节
最后分享几个我长期维护 GPU 集群后总结出来的细节,每个都踩过坑:
GPU Pod 的 CPU 资源不要卡得太死。CUDA 运行时、NCCL 通信、数据加载都会占用大量 CPU 线程。Linux 下 CUDA 默认会为每个 GPU 创建独立的 polling 线程,如果 Pod 的 CPU limit 只有 1 核或者不到 2 核,可能出现 GPU 利用率上不去、训练/推理吞吐不如预期。给 GPU Pod 预留至少 4 核是比较稳妥的做法。
系统内存要和显存成比例预留。大模型训练时,pinned memory、CUDA context、数据预处理 buffer 都在系统内存里。如果节点只有 128GB 内存却插 8 张 80GB 的卡,业务一上来就会 OOM。常规划分是每张 GPU 至少搭配 16GB 到 32GB 系统内存,A100/H100 集群建议更高。
HugePages 和 IPC Lock 限制。NCCL 在分布式训练时会使用大量共享内存和锁,如果limits.memory过小或者/dev/shm空间不足,NCCL 初始化会失败。有时候报错是 “unable to allocate memory for receive buffers”,其实不是显存问题,是容器内共享内存不够。
资源配额别忘了 Extended Resource。ResourceQuota 是支持扩展资源的,如果你在多租户集群里只限制了 CPU/内存、没限制nvidia.com/gpu,业务方可能在一个 namespace 里把全集群的 GPU 申请完。务必加上配额:
apiVersion: v1 kind: ResourceQuota metadata: name: gpu-quota spec: hard: nvidia.com/gpu: "4"不要把所有 GPU 节点打成一个池子。如果业务同时有 A100 和 T4,建议按卡型拆分节点池并打上明确的标签。否则一个训练任务调度到 T4 上,性能直接不可用,业务方还以为是代码问题。把“不匹配”在调度阶段就挡掉,比事后优化代码省事得多。
最后再说一个我自己的习惯。我会在每个 GPU 节点上固定放一个 minimal 的 DeepSpeed / vLLM 测试 Pod 模板,节点上架验收、驱动升级、故障切换后,都先跑一遍这个模板,确认从调度到容器内nvidia-smi、再到真实 CUDA 矩阵运算都正常,再放业务进来。这个操作成本很低,但能把大概率会在凌晨爆发的问题,提前到下午茶时间解决。