1. 先搞明白Kubernetes 为什么默认管不了 GPU在做 GPU 调度之前先得说清楚一个扎心的事实Kubernetes 原生的资源调度模型里根本没有GPU这个资源类型。你装好一个 k8s 集群kubectl get nodes看每个节点的可分配资源会发现只有cpu、memory、ephemeral-storage以及一堆cpu-manager之类的内置项。GPU 不在其中。这不是 Kubernetes 偷懒而是它的设计哲学决定的CPU 和内存的管理逻辑可以抽象成整体——反正 CPU 是共享的、内存是分页的任务之间天然可以互相挤占但 GPU 不行。GPU 是离散设备一个物理 GPU 就摆在 PCIe 或 NVLink 上驱动一旦绑定就只能被少数进程独占。你要么把整卡给一个任务要么做切分MIG、vGPUKubernetes 内核里根本没内置这些驱动逻辑所以它必须靠外部插件来接入。这就要引出整篇文章的核心概念大家常说的Kubernetes 调度 GPU其实包含了三层工作。第一层是资源接入Kubernetes 得先知道某个节点上有几张 GPU、每张卡的状态怎么样、哪些是可用的。这件事靠的是节点上的nvidia-device-plugin这类 DaemonSet 组件它实时把 GPU 数量汇报给 kubelet。第二层是资源请求用户写的 Pod YAML 里要声明nvidia.com/gpu: 1这属于 Kubernetes 的Extended Resource扩展资源。调度器在做节点筛选时会把每个节点的可分配 GPU 数量和你请求的数量比对不够就放不进候选名单。第三层是运行时注入光有资源数还不够Pod 起来之后进程要能真的访问到那块显卡。这靠的是容器运行时的配合——containerd或docker里配置好 NVIDIA 的 Container Runtime它会在容器创建时把 GPU 设备、驱动库、环境中需要的 CUDA 相关路径全部映射进去。很多人第一次上手 GPU 集群习惯性地先去折腾容器运行时、配置--gpus all结果发现 Pod 一直Pending——因为 kubelet 根本不知道节点上有 GPU自然就报Insufficient nvidia.com/gpu。所以理解这三层关系是整个 GPU 调度落地的前提。为了照顾不同基础的读者我先给一张整体架构的对应表后文再逐个展开层级组件角色作用典型组件资源接入Device Plugin向 kubelet 注册并上报 GPU 设备NVIDIA/k8s-device-plugin资源调度Kube-scheduler根据 Extended Resource 做节点筛选默认调度器 / 调度器扩展设备注入Container Runtime把 GPU 设备和驱动挂进容器nvidia-container-runtime底层支撑GPU 驱动让系统能识别和操作显卡NVIDIA Linux 驱动2. Device Plugin 不只是报个数那么简单讲到 GPU 管理Device Plugin是绕不开的核心机制。Kubernetes 从 1.8 开始引入了这个插件体系目的就是让厂商NVIDIA、Intel、AMD 等可以用自己的方式管理设备生命周期同时把它抽象成一种标准化的资源供调度器使用。一个 Device Plugin 本质上是一个 gRPC 服务运行在宿主机上通过 Unix Socket 和 kubelet 通信。它的生命周期分三步注册插件启动时向 kubelet 的/var/lib/kubelet/device-plugins/kubelet.sock发起注册请求说明自己叫什么名字比如nvidia.com/gpu、能提供多少个设备。上报kubelet 调用插件的ListAndWatch接口插件把当前所有可用设备列出来——包括每个设备的 ID、健康状态。之后有任何变化比如某张卡被插件自己标记为 Unhealthy通过同一个长连接推送。分配调度器决定把 Pod 调度到某个节点后kubelet 调用插件的Allocate接口传入需要的设备数量插件返回容器运行时要注入哪些环境变量、哪些宿主机路径要挂载、哪些设备节点要映射。这里最容易被忽略的是Allocate 的返回值。对于 NVIDIA Device Plugin它返回的内容大致有三类envs比如NVIDIA_VISIBLE_DEVICESGPU-uuid告诉 NVIDIA Container Runtime 要暴露哪张卡。mounts把宿主机的 CUDA 库路径、驱动路径挂进容器。多数发行版的做法是把宿主机的/usr/lib/x86_64-linux-gnu/libcuda.so等驱动库挂载进去。devices直接暴露/dev/nvidia0、/dev/nvidiactl这类设备文件。正因为它返回的是配置而不是直接去操作 GPU所以 Device Plugin 本身并不需要在容器进程里做任何库注入。真正干活的是容器运行时。读到这里你可能会问Docker 自带--gpus参数不是也能用吗为什么 K8s 里偏要搞一个 Device Plugin 出来核心原因是调度信息不互通。Docker 的--gpus是启动时我要用 GPU但集群管理者完全无法知道每个节点还剩多少资源调度器也不参与。而 Kubernetes 必须要在调度决策阶段就知道资源余量否则两个 Pod 都被分配到同一个节点GPU 显存一挤就爆。Device Plugin 把设备上报给了 kubeletkubelet 再把这些数字同步给调度器资源余量才是可信的。用一句话总结没有 Device PluginKubernetes 就是个睁眼瞎看得见 CPU 内存看不见 GPU。3. 从裸机到第一颗可调度 GPU驱动、Runtime 与 DevicePlugin 的部署顺序很多人部署 GPU 集群时习惯一上来就kubectl apply -f拉 Device Plugin然后发现nvidia-smi在容器里报错或 Pod 起不来。其实问题多半不在 Device Plugin 本身而在最底层的宿主驱动和容器运行时配置没跟上。我把整个链路按顺序拆开讲每一层都有验证手段照着做下来基本不会踩空。3.1 第一层宿主机 GPU 驱动这是最基础的一层。集群节点上必须能正常跑nvidia-smi否则后面全是空中楼阁。安装驱动的方法很多apt install nvidia-driver-550、.run文件安装、NVIDIA GPU Operator 自动安装都行。我这几年用得最顺的是Ubuntu 发行版自带的驱动包省去手动编译内核模块的麻烦升级内核后驱动也不会丢。CentOS/RHEL 系建议走dkms方式安装。安装完成后验证三件事nvidia-smi能列出显卡型号、驱动版本、CUDA 版本、显存容量第一层就算通过。这里有个新手经常踩的坑笔记本双显卡Intel 核显 NVIDIA 独显环境装完驱动后nvidia-smi在你自己的图形界面可能正常但 SSH 到机器上却提示couldnt communicate with the NVIDIA driver。这多数跟 Optimus 切换有关需要在nvidia-smi前确保当前 X 会话用的是 NVIDIA 显卡。如果是在服务器上遇到类似报错优先查内核模块是否加载lsmod | grep nvidia3.2 第二层容器运行时接入 NVIDIA 设备驱动装好之后宿主机自己能看见 GPU但容器里的进程还看不见。这一步要装nvidia-container-toolkit并且配置容器运行时去调用它。以现在主流的 containerd 为例安装 toolkit 后执行sudo nvidia-ctk runtime configure --runtimecontainerd这个命令会在 containerd 的配置文件里加入一个nvidiaruntime 的 handler。之后重启 containerdsudo systemctl restart containerd验证方法很直接用 containerd 手动跑一个临时容器sudo ctr run --rm --runtime io.containerd.runc.v2 --gpus 0 docker.io/library/cuda:12.1.0-base-ubuntu22.04 cuda-test nvidia-smi如果这个命令能正常输出显卡信息说明容器运行时链路已经通了。这里我必须多说一句很多 k8s GPU 问题排查了半天最后发现是这步没做。因为 Pod 调度、Device Plugin 都正常资源数也分配了但容器起来后用nvidia-smi看不到卡。原因就是 containerd 没配置nvidiaruntime容器进程根本没有被 NVIDIA 的 runtime 包装。kubelet 和 Device Plugin 只负责分配资源真正注入设备和库的工作完全交给 runtime。注意 ctr 命令版本的差异新版本--gpus 0是 0 号卡索引也可以直接用--env NVIDIA_VISIBLE_DEVICES0。3.3 第三层部署 Device Pluginruntime 通了最后再部署 Device Plugin。NVIDIA 官方提供了一个标准 DaemonSetGitHub 仓库是NVIDIA/k8s-device-plugin。一般来说直接用kubectl create -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.15.0/nvidia-device-plugin.yml部署完后验证节点状态kubectl describe node node-name如果配置正确Allocatable和Capacity里会多出nvidia.com/gpu: 1或nvidia.com/gpu: 2这样的条目数量等于节点上可用的 GPU 数。这里有个容易忽略的细节Device Plugin 默认只上报健康状态为正常的 GPU。如果节点上有张卡因为驱动问题比如 ECC 错误、Xid 错误导致掉卡被 NVIDIA 驱动标记为不健康Device Plugin 不会把它列入可分配资源。这不是 bug反而是保证集群稳定性的机制。所以看到可分配 GPU 比物理卡少时先跑nvidia-smi -q看每张卡有没有Healthy之外的异常状态。4. 调度器眼中的 GPUExtended Resource 的脾气和限制GPU 接入集群后接下来就是调度逻辑。调度器的处理方式和 CPU、内存完全不同有几个关键点必须记住。4.1 扩展资源不支持 request / limit 分离内建资源的 Pod 可以只写requests让 limit 默认等于 request或者显式写成不同值实现请求小、限制大的弹性。但Extended Resource 的 requests 必须严格等于 limits。我贴一个规范写法apiVersion: v1 kind: Pod metadata: name: gpu-pod spec: containers: - name: cuda-container image: nvidia/cuda:12.1.0-base-ubuntu22.04 command: [nvidia-smi] resources: limits: nvidia.com/gpu: 1 requests: nvidia.com/gpu: 1如果你只写limits不写requestsAPI Server 会帮你把 requests 自动复制一份成相同值也能执行但如果 requests 和 limits 数值不一样API Server 直接拒绝创建报错信息类似invalid resource request: nvidia.com/gpu: request 2, limit 1。背后的逻辑是扩展资源的分配必须预占不能像内存那样动态伸缩你请求 2 张卡就占 2 张使用过程完全独占。4.2 调度器只看数量不知道状态默认 kube-scheduler 在做 GPU 筛选时只做一个简单比较节点上可分配的 GPU 数是否大于等于 Pod 请求的 GPU 数。它不关心你这 1 张卡到底在哪个 NUMA 节点上、是不是接近满显存、是数据中心卡还是游戏卡。这种数量即一切的模型在单机单卡场景没问题但到了多卡服务器上有些现象就出来了两个 Pod 各请求 1 张 GPU调度器把它们的容器调度到同一台 8 卡机器上却可能让 Pod A 用了 0 号卡、Pod B 用了 1 号卡既不看亲和性也不绑定 PCIe Switch某张卡已经跑着占显存的大模型但 kubelet 上报它还是健康且空闲显存占用是运行时状态不是健康状态调度器照样把新 Pod 塞过去。默认调度器的设计理念是把这些复杂策略交给上层扩展去做。如果你的业务对 GPU 拓扑敏感比如大模型训练需多卡 NVLink 互联就需要引入额外的调度器扩展或者用 NVIDIA 的Multi-Instance GPU (MIG)模式配合专用插件。对个人测试环境理解数量独占的模型就足够用了。4.3 整卡独占是默认想要切分得另想办法默认的 GPU 调度单位是整卡。你请求nvidia.com/gpu: 1哪怕容器里只跑个打满 30% 显存的小推理任务整卡也被你占住。这对多租户共享集群是非常浪费的。常见的切分方案有几种我列个对比表方案切分方式隔离性适用场景备注NVIDIA MIG硬件级切分强隔离显存和算力硬切A100/H100 等数据卡每个 MIG 实例可独立上报给 K8sTime Slicing时间片切分弱隔离显存不隔离推理服务多路复用Device Plugin 里启用配置MPS 控制进程优先级控制算力隔离显存仍共享部分显存安全的场景需要人工配置vGPU / HAMi调度器级虚拟化显存和算力软隔离多租户共享、离线训练需要部署额外控制器其中 Time Slicing 在部分老一点的文档里叫GPU sharing是通过 NVIDIA Container Runtime 的NVIDIA_GPU_MIG_CONFIG或 Device Plugin 的配置文件实现的。显存不隔离这一点要再三强调时间片切分只把 GPU 的计算单元轮流分给多个容器如果两个容器一起申请大显存还是会 OOM 或 CUDAout of memory。HAMi以前叫 Hami后来改了名是国产开源的一个比较活跃的方案它通过拦截 CUDA 调用、在调度器侧管理显存分配实现比较细粒度的 GPU 虚拟化。它需要部署 mutating webhook 和调度器扩展并且要替换/扩展原来的 Device Plugin操作复杂度比上面的方案高不少不属于装上就能用的范畴。如果你的需求简单先老老实实用整卡 MIG 的组合就足够了。5. 第一个 GPU Pod从 YAML 到 nvidia-smi 的完整过程前几章把机制讲透了现在上手实操。我以一个真正能在你电脑或测试集群里跑通的流程为例手把手带你从 YAML 写起到看到显存信息。5.1 准备一个可用的镜像不建议用nvidia/cuda最新 tag体积太大拉取浪费时间。测试用途我用nvidia/cuda:12.4.1-base-ubuntu22.04不带 cudnn、不带 toolkit体积大概 150MB 左右。如果你要在容器里跑深度学习训练那得选nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04这种带开发工具链的因为你要pip install torch torchvision需要编译相关的头文件。5.2 写一份合理的 Pod YAML第一份 YAML 我建议保守一点先跑nvidia-smi把链路验证 OK 再做复杂的apiVersion: v1 kind: Pod metadata: name: gpu-pod spec: restartPolicy: OnFailure containers: - name: cuda-container image: nvidia/cuda:12.4.1-base-ubuntu22.04 command: [sh, -c, nvidia-smi sleep 3600] resources: limits: nvidia.com/gpu: 1 requests: nvidia.com/gpu: 1 nodeSelector: kubernetes.io/hostname: gpu-node-01为什么要加sleep 3600因为如果只用nvidia-smi作为唯一命令容器执行完就退出Pod 直接Completed你想进容器看后续就来不及了。加个 sleep 挂住方便kubectl exec进去排查。nodeSelector不一定要加加的话只针对你确认有 GPU 的那个节点。不加的话调度器自己找找到哪个有 GPU 就放哪逻辑上也 OK。创建后观察状态kubectl apply -f gpu-pod.yaml kubectl get pod gpu-pod -o wide kubectl logs gpu-pod如果节点资源足够Pod 会进入Running日志里能看到类似下面这样的输出--------------------------------------------------------------------------------------- | NVIDIA-SMI 550.54.15 Driver Version: 550.54.15 CUDA Version: 12.4 | |--------------------------------------------------------------------------- | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. | | 0 NVIDIA GeForce RTX 4060 ... | N/A | 46% 41W / ... | 123MiB / 8188MiB | 0% Default | ---------------------------------------------------------------------------5.3 从调度到运行的完整因果链Pod 创建后你打开kubectl describe pod gpu-pod事件里会看到这么几步不同版本显示的文字可能略有差异Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 34s default-scheduler Successfully assigned default/gpu-pod to gpu-node-01 Normal Pulling 33s kubelet Pulling image nvidia/cuda:... Normal Pulled 20s kubelet Successfully pulled image Normal Created 19s kubelet Created container cuda-container Normal Started 18s kubelet Started container cuda-container这条链路上每个环节可以对应我前面讲的各层Scheduled由调度器完成它看到的是 kubelet 上报的nvidia.com/gpu总量。Pulled、Created之后containerd 启动容器时调用的是nvidiaruntime这一步决定 GPU 是否能进容器。Started后容器里执行nvidia-smi输出正常链路就全部验证通过了。如果卡在Scheduled之前的Pending要么是节点上没有可用的nvidia.com/gpu要么是你请求的量超了。此时kubectl describe node看下各节点资源最直观。6. 排错GPU Pod 常见问题与完整排查链路到了这一步理论、部署、第一个 Pod 都齐了。但实际生产里你肯定还会遇到乱七八糟的问题这一章我把最经典的几个场景拿出来讲清排查思路而不是只给结论。6.1 Pod 一直 Pending现象kubectl get pod输出永远是Pendingkubectl describe pod里最常见的一句话是0/1 nodes are available: 1 Insufficient nvidia.com/gpu.排查链路按这个顺序走先kubectl get node -o json | jq .items[].status.allocatable看节点到底有没有nvidia.com/gpu字段。没有的话跳到第 3 步有的话看数量是否够请求。节点有 GPU 但还是 Insufficient检查是不是Allocatable被其他 Pod 占满。kubectl describe node的输出里能看到Allocated resources一栏里面有每个 Pod 占用 GPU 的情况。节点完全没有nvidia.com/gpu先查 Device Plugin Pod 状态kubectl get pods -n kube-system -o wide | grep -i nvidia kubectl logs -n kube-system device-plugin-podDevice Plugin 正常但节点没上报大概率是 kubelet 和插件握手失败。手动看下 kubelet 日志journalctl -u kubelet -n 100 | grep -i device常见错误是/var/lib/kubelet/device-plugins/kubelet.sock权限不对、或者插件部署的hostPath卷类型不对导致 socket 文件写不到宿主机。检查 DaemonSet 的volumeMounts配置。还有一种隐蔽场景节点上有多张 GPU但其中一张因为温度/驱动问题被标记 Unhealthy。kubectl describe node的Capacity和Allocatable数字会不一致少掉的正是坏掉的那张卡。6.2 容器启动失败报 NVIDIA 相关错误现象Pod 里kubectl logs出现docker: Error response from daemon: Unknown runtime specified nvidia.或者是nvidia-container-cli: could not select device: no such file or directory这通常不是 Device Plugin 的问题而是 containerd 配置没生效。常见原因nvidia-ctk runtime configure改的是哪个 runtim 的配置文件你不清楚导致 containerd 启动时没加载containerd 版本和 toolkit 版本不匹配旧版 containerd 不认识新的 runtime handler。排查方法先确认 containerd 配置里有nvidia运行时cat /etc/containerd/config.toml | grep -A 10 runtime_type看到runtime_type io.containerd.runc.v2且中间有nvidia配置即可。再测试ctr run的验证方法详见第 3.2 节如果 ctr 能跑起来k8s 里应该也能ctr 都起不来就是 runtime 配置的问题。6.3 容器起来了但 CUDA 初始化失败现象kubectl logs里报CUDA error: no kernel image is available for execution on the device这是镜像里 CUDA 版本和宿主机驱动版本不匹配导致的。CUDA 运行时向前兼容但向后不兼容——你的宿主驱动是 550容器镜像 if 用的是 CUDA 12.4一般没问题但如果宿主驱动是 470镜像里 CUDA 是 12.4 那就不行。因为 CUDA 版本越高要求的最低 driver 版本也越高。解决办法很简单拉一个和宿主驱动匹配的 CUDA 镜像或者升级宿主驱动。一般数据中心卡的升级驱动比较简单游戏卡要注意新版驱动对老卡的支持可以先查 NVIDIA 官方驱动支持矩阵。6.4 显存突然 OOM 或者 GPU 掉卡GPU 不像 CPU 那么好伺候在高负载长时间运行后出现 Xid 报错、显存 ECC 纠错、驱动崩溃都是常见现象。典型报错xid 79: GPU has fallen off the bus这类问题要分两类一类是硬件故障比如电源供电不稳、PCIe 金手指氧化、散热不良导致温度过高另一类是驱动 bug常见于频繁升级驱动、或者 Docker 容器里大量并发 CUDA context 创建销毁导致驱动崩溃。排查建议先dmesg -T | grep -i nvidia看内核日志里面有硬件层面的错误码。再nvidia-smi -q -d TEMPERATURE,POWER看卡的功耗和温度。如果是温度过高加风扇转速、清理灰尘。定期记录 Xid 错误代码79这类错误通常意味着硬件层面的连接断开了可能要重新插拔显卡或者检查供电。如果是软件层面的问题考虑关闭自动更新驱动、固定驱动版本并在 Pod 里限制 CUDA context 数量。说实话GPU 掉卡在云端多租户环境里出现过不少次因为风道、供电等因素叠加比 CPU 故障更常见。遇到这种问题先别急着在 K8s 层面折腾底层卡稳了上层调度才是可靠的。7. 进阶GPU 节点实践的一些个人经验最后聊几个不起眼但很影响体验的细节。第一Pod 声明nvidia.com/gpu但容器里又用了--gpus all这类 Docker 参数会造成资源冲突。K8s 环境里请不要在 Docker 层面手动指定 GPU一切交给 runtime plugin否则可能出现 Pod 里看到的 GPU 编号错乱。NVIDIA_VISIBLE_DEVICES环境变量会告诉你这个容器被分配了哪张卡在容器里执行echo $NVIDIA_VISIBLE_DEVICES如果显示的是 uuid那说明 Device Plugin 正确注入了。第二建议给 Device Plugin 的 DaemonSet 加上节点亲和性只调度到有 GPU 的节点。官方 yaml 默认是全节点调度如果集群里混着非 GPU 节点插件在那些节点上虽然不会报错但会白白占资源、反复打印找不到 GPU 的日志。加上 nodeSelector 或 affinity 能规避这些噪音。第三即便只是测试环境也建议用 NVIDIA GPU Operator 而不是手动逐层部署。GPU Operator 会帮你编排驱动、toolkit、Device Plugin、监控 exporter全部是 Helm 方式管理升级回滚都方便。我第一次搭 GPU 集群时也是手动一步步来后来发现 GPU Operator 在大型集群里的效率高得多。当然手动部署一遍能让你把所有依赖关系都吃透所以我建议两条路都走一遍第一次手动部署验证理解第二次用 Operator 进入生产。第四如果计划跑大模型训练记得给 Pod 加足够的/dev/shm。PyTorch DataLoader 的多进程模式会用到共享内存默认大小只有 64MB日志里会出现Unable to mmap之类的报错。在 Pod 里加emptyDir挂到/dev/shm并设置 sizeLimit或者直接在容器里设置环境变量NCCL_SHM_DISABLE1绕过。这一点在 GPU 集群里几乎必踩写出来帮后来人避坑。搞定这些基础之后你手里的集群就已经具备生产级别的 GPU 调度能力了。下一步可以做的是把 GPU 监控接进 Prometheus用 DCGM Exporter 看每张卡的利用率、显存占用量、温度再往后接 HPA根据 GPU 利用率或者自定义指标做弹性伸缩。希望这篇实战笔记能帮你少走几步弯路在 k8s GPU 这条路上一遍踩通。
