K8s GPU调度实战:从驱动安装到Device Plugin部署全指南
1. 环境准备NVIDIA驱动安装与服务器基础检查做K8s GPU调度这件事第一步不是急着看K8s配置而是先把宿主机这块GPU驱动搞定。我见过太多人一上来就在K8s里折腾device plugin结果Pod调度过去起不来一查日志发现是宿主机驱动压根没装好绕了一大圈又回来重做系统环境。所以这一章节先把GPU驱动这块讲透踩过的坑也一并交代清楚。1.1 装驱动之前必须先确认的硬件和系统信息NVIDIA显卡型号五花八门对应的驱动版本策略差别很大。先执行命令确认硬件lspci | grep -i nvidia用Tesla系列举例P100、P40、M40这些老卡和A100、H100这类新卡对驱动版本和CUDA版本的要求完全不同。P100用CUDA 11.x就很好A100/H100上新业务基本得CUDA 12.x起步。系统信息这块也别漏了uname -a cat /etc/os-release实测中最常见的问题是内核版本太新或太旧导致驱动编译模块加载失败。Ubuntu 20.04和22.04这两代系统配合内核5.4、5.15、5.19这些版本都比较稳。另外如果机器是刚装的系统先把基础编译工具链补全不然后面安装驱动报gcc缺失能把人气死apt-get update apt-get install -y build-essential gcc make linux-headers-$(uname -r)1.2 驱动安装runfile安装方式最可控NVIDIA驱动安装主流有几种方式apt仓库安装、runfile手动安装、以及CUDA toolkit捆绑安装。实际生产环境中我更推荐runfile方式理由有三一是版本完全自己掌握不会被apt仓库源里的默认版本绑架二是可以在安装时直接指定参数比如跳过nouveau冲突检查、不装多余的图形组件三是后续卸载和退版本也方便一条命令清干净。到NVIDIA官网下载对应型号的驱动runfile注意下载后的文件权限要先改一下chmod x NVIDIA-Linux-x86_64-550.90.07.run ./NVIDIA-Linux-x86_64-550.90.07.run --silent --no-opengl-files --no-nouveau-check这里两个参数拆开解释一下--no-opengl-files是避免把OpenGL相关文件装进去服务器场景不需要图形界面渲染这个选项可以有效规避和桌面环境打架的问题--no-nouveau-check是跳过对新驱动模块检测的提示如果你已经确认nouveau内核模块被禁用了这个参数能让安装流程更顺畅不会中途卡住等交互。装完验证驱动是否正常工作执行nvidia-smi看到类似下面这张表就说明驱动已经跑起来了----------------------------------------------------------------------------- | NVIDIA-SMI 550.90.07 Driver Version: 550.90.07 CUDA Version: 12.4 | |--------------------------------------------------------------------------- | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | | Tesla V100-SXM2-16GB On | 00000000:20:01.0 Off | Off | ---------------------------------------------------------------------------到这里有些同学容易掉以轻心觉得nvidia-smi能查显存状态就万事大吉了。其实漏了一步Persistence Mode持久化模式。默认情况下GPU是随进程使用动态切换状态的不做持久化会导致GPU频繁切换电源状态在K8s这种需要频繁起停Pod的场景下会明显增加调度时的时延和资源冲突概率。建议果断打开nvidia-smi -pm 11.3 容器运行时配置让Docker和Containerd识别GPU驱动装好了接下来是让容器运行时能够往容器里注入GPU设备。这里有两套东西要讲清楚一个是nvidia-container-toolkit一个是nvidia-container-runtime。前者是后者的基础库主要负责做GPU设备的发现和注入逻辑后者是实际的OCI runtime钩子。以containerd为例现在K8s默认CRI基本都是containerd了先装toolkitcurl -s -L https://nvidia.github.io/libnvidia-container/gpgkey | \ apt-key add - curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \ tee /etc/apt/sources.list.d/nvidia-container-toolkit.list apt-get update apt-get install -y nvidia-container-toolkit装完后要改containerd配置让它在创建容器时调用NVIDIA的runtime hooknvidia-ctk runtime configure --runtimecontainerd这个命令会自动修改/etc/containerd/config.toml在其中的runc和nvidia之间建立关联。改完配置记得重启containerdsystemctl restart containerd验证runtime是否注册成功执行ctr run --rm --runtime nvidia.io/nvidia --gpus 1 docker.io/library/ubuntu:20.04 nvidia-test nvidia-smi能正常输出GPU信息就说明容器运行时这条链路已经打通了。2. 理解K8s的GPU资源管理机制从Device Plugin到Extended Resource驱动和容器运行时都搞定后终于可以进入K8s层面了。但这里如果不把原理讲透后面排查问题容易像无头苍蝇。K8s默认情况下对GPU是一无所知的它只知道CPU和内存这两种标准资源。要让K8s认识GPU靠的是一整套伪装和上报机制。2.1 Extended Resource是什么为什么GPU要走这条路K8s里的资源分为两类标准资源CPU、内存和扩展资源Extended Resource。GPU、FPGA、NPU这类异构计算设备都属于后者。扩展资源的语法特点是域名前缀资源名比如nvidia.com/gpu。和CPU、内存最大的区别在于CPU/内存是可压缩资源CPU可以超卖回收内存可以limit而Extended Resource是不可压缩资源一旦分配给某个Pod在Pod释放前其它Pod不可能使用这块GPU。所以K8s对待GPU的态度是要么全给要么不给不存在共享或者超分。K8s节点通过API Server对外暴露资源总量有一种机制叫设备插件Device Plugin正是专门用来上报扩展资源的。每个节点的kubelet会启动一个device plugin的gRPC服务向kubelet汇报我这个节点上有几块GPU、型号是什么kubelet再把数据上报给API Server。调度器在调度Pod时看到nvidia.com/gpu这个资源名就会筛选出有该资源的节点来绑定。2.2 Device Plugin的完整工作流程设备插件的工作流程拆开来看大致是以下几步Plugin启动时向kubelet注册自己的Unix Socket比如/var/lib/kubelet/device-plugins/nvidia.sock。kubelet通过gRPC接口向Plugin查询设备列表拿到设备ID和对应的扩展资源名称。设备信息被写入节点的status.allocatable。调度器在调度Pod时根据Pod声明的nvidia.com/gpu资源量选出有对应资源的节点。Pod被调度到节点后kubelet调起容器运行时时告诉device plugin这个Pod需要一块GPU。Device Plugin返回具体的设备路径如/dev/nvidia0容器运行时通过绑定挂载的方式把设备装进容器。正是因为走的是上报-调度-绑定这条链路所以宿主机驱动情况、设备插件状态、kubelet版本这三者任何一环出问题GPU都到不了容器里。2.3 为什么不能直接用nvidia-container-runtime而不装device plugin这是很多新手最容易搞混的概念。nvidia-container-runtime承担的是容器创建时注入设备的角色但它不会告诉K8s我有GPU可以用。如果你只配了runtime没装device plugin那么Pod的配置文件里即使写了GPU资源请求调度器也只能把Pod扔到某个节点上不管Pod起来后容器里完全是空的GPU设备根本不会出现。反过来装了device plugin但runtime没配置调度器能把Pod调度过来但在创建容器时注入不了GPU设备Pod会一直CrashLoopBackOff。所以这两个组件缺一不可是一前一后的配合关系。3. 核心实操在K8s集群中安装NVIDIA Device Plugin原理讲清楚了下面直接进入部署环节。NVIDIA官方提供了现成的device plugin部署方式有两种DaemonSet方式推荐和Helm方式。我给你拆解最稳的DaemonSet方式。3.1 准备DaemonSet资源清单NVIDIA官方推荐在集群每个GPU节点上跑一个Device Plugin Pod所以最合理的资源类型就是DaemonSet。Ingress到https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.15.0/nvidia-device-plugin.yml可以拿到官方YAML模板。实际生产环境我建议自己维护一份方便后续定制参数。核心资源清单大概是这样的apiVersion: apps/v1 kind: DaemonSet metadata: name: nvidia-device-plugin-daemonset namespace: kube-system spec: selector: matchLabels: name: nvidia-device-plugin-ds updateStrategy: type: RollingUpdate template: metadata: labels: name: nvidia-device-plugin-ds spec: priorityClassName: system-node-critical tolerations: - operator: Exists effect: NoSchedule affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: nvidia.com/gpu.present operator: Exists containers: - image: nvcr.io/nvidia/k8s-device-plugin:v0.15.0 name: nvidia-device-plugin args: - --fail-on-init-errorfalse env: - name: NVIDIA_VISIBLE_DEVICES value: all securityContext: allowPrivilegeEscalation: false capabilities: drop: [ALL] volumeMounts: - name: device-plugin mountPath: /var/lib/kubelet/device-plugins - name: nvidia-driver mountPath: /usr/local/nvidia volumes: - name: device-plugin hostPath: path: /var/lib/kubelet/device-plugins - name: nvidia-driver hostPath: path: /usr/local/nvidia这里有三个细节值得展开讲。--fail-on-init-errorfalse这个参数非常关键。它的作用是在设备插件初始化失败时不让容器直接退出重启而是保持存活状态。为什么要这样因为如果设备插件一旦起不来就反复重启会持续消耗节点资源而且日志容易刷屏覆盖掉真正的原因。保持存活后你可以随时用kubectl logs去看它的输出排查问题方便多了。第二个是nvidia.com/gpu.present这个节点亲和性。官方镜像默认会检测节点的PCI设备如果节点上没有NVIDIA GPUPod会启动失败。加上这个亲和性后只有带有nvidia.com/gpu.present标签的节点才会调度上去。这要求你在每个GPU节点上手动打标签kubectl label nodes gpu-node-name nvidia.com/gpu.presenttrue第三个是NVIDIA_VISIBLE_DEVICESall这个环境变量。它告诉容器把宿主机上所有GPU都暴露出来。如果你只想暴露部分GPU可以改成具体的设备序号比如NVIDIA_VISIBLE_DEVICES0,1。3.2 部署并验证设备插件正常工作保存YAML后执行部署命令kubectl apply -f nvidia-device-plugin.yml等待Pod进入Running状态kubectl -n kube-system get pods -l namenvidia-device-plugin-ds看Pod状态一直是Running且未出现重启基本就成功了。这时候可以验证节点资源是否已经上报kubectl describe node gpu-node-name | grep -A 5 Allocatable正常情况下你会看到类似这样的内容Allocatable: cpu: 64 memory: 251640756Ki nvidia.com/gpu: 4注意这里的nvidia.com/gpu: 4说明这个节点上有4块GPU能被K8s调度使用。到这一步K8s侧的GPU资源接入就算完成了。3.3 配置GPU资源的上限和显存分配策略在生产环境里有一个需求很常见GPU资源不是无限可用的怎么限制Pod最大能申请多少块GPU这里有个K8s原生机制可以配合使用就是ResourceQuota和LimitRange。比如开发团队需要限制每个命名空间最多只能用2块GPUapiVersion: v1 kind: ResourceQuota metadata: name: gpu-quota namespace: ml-team spec: hard: nvidia.com/gpu: 2还有一个重要问题GPU显存怎么做限制K8s的nvidia.com/gpu只能按块来申请无法按显存MB来细粒度分配。如果业务方需要按照显存来切分GPU目前比较成熟的做法是搭配**NVIDIA MIGMulti-Instance GPU**技术。MIG可以把A100、H100这类大卡切分成多个独立实例每个实例拥有自己独立的显存和计算单元。切分后每个实例会映射为一个独立的设备K8s里的nvidia.com/gpu数量会相应增加。MIG的配置方式是在宿主机上执行nvidia-smi mig -cgi 1g.10gb -C-cgi指定实例规格1g.10gb表示1个GPC切片加10GB显存-C是将该配置持久化。配置完后重启kubelet节点上的GPU数量会按照切分结果上报。4. 部署GPU工作负载从测试Pod到真实业务容器资源准备好了接下来怎么用这是大家最关心的问题。这一章我会给出一套从简单到复杂的实战用例确保你能完整跑通GPU工作负载的部署流程。4.1 第一个GPU Pod跑nvidia-smi验证最基础的测试直接用一个轻量镜像跑一个nvidia-smi命令apiVersion: v1 kind: Pod metadata: name: gpu-test spec: containers: - name: cuda-test image: nvidia/cuda:12.4.0-base-ubuntu22.04 command: [nvidia-smi] resources: limits: nvidia.com/gpu: 1这里有一个新手特别容易踩的坑limits里写了GPU但requests没写。K8s的约定是如果只写limitsrequests默认等于limits所以这样写没问题。但如果你分开写比如requests不写GPU、limits写1块GPUPod会创建失败因为K8s不允许请求资源数小于限制资源数。创建这个Pod后看日志kubectl logs gpu-test如果输出的是nvidia-smi的正常信息说明整个链路已经打通。这一步验证的是调度器正确选择了GPU节点 → device plugin返回了设备 → 容器运行时成功注入了GPU设备。4.2 PyTorch训练任务部署案例接下来是一段真实的AI训练场景。假设团队在用PyTorch做模型训练我们需要把训练任务容器化并调度到GPU节点上。镜像Dockerfile大致长这样FROM pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime WORKDIR /workspace COPY train.py . ENTRYPOINT [python, train.py]部署到K8s的Deployment YAML最关键的部分是资源声明apiVersion: apps/v1 kind: Deployment metadata: name: resnet50-train spec: replicas: 1 selector: matchLabels: app: resnet50-train template: metadata: labels: app: resnet50-train spec: containers: - name: trainer image: registry.example.com/pytorch-train:latest resources: limits: nvidia.com/gpu: 1 requests: nvidia.com/gpu: 1 env: - name: NVIDIA_VISIBLE_DEVICES value: 0说一下NVIDIA_VISIBLE_DEVICES0这个环境变量的意义。它用于指定容器可见的具体GPU序号这在单机多卡场景下非常有用。比如节点上有8块GPU你希望让这个Pod只用第0块其它卡留给别的任务就通过这个变量来限定。注意这里的0对应的是容器内设备编号不是宿主机上的绝对序号K8s的device plugin会自动把容器编号映射到宿主机实际设备上。还有一个经验要分享如果你在代码里直接访问cuda:0这种设备索引尤其要注意NVIDIA_VISIBLE_DEVICES的设置。因为如果设置了NVIDIA_VISIBLE_DEVICES2容器里的cuda:0对应的其实是宿主机上的第3块卡代码层面的设备编号必须和容器环境变量对齐否则容易出现明明看到卡在用但训练速度慢得离谱这种奇怪问题。4.3 GPU应用的显存和算力监控方法部署完训练任务后监控是日常运维的重头戏。K8s官方自带的kubectl top只能看到CPU和内存使用看不到GPU的利用率。生产环境要监控GPU主流方案是**DCGMData Center GPU Manager**配合Prometheus。部署DCGM Exporterhelm repo add nvidia https://nvidia.github.io/dcgm-exporter/helm helm repo update helm install dcgm-exporter nvidia/dcgm-exporterDCGM Exporter启动后会暴露一个metrics接口默认端口9400。它能够采集的指标非常丰富我平时最常用的几个指标名含义运维用途DCGM_FI_DEV_GPU_UTILGPU计算核心利用率判断算力是否跑满DCGM_FI_DEV_MEM_COPY_UTIL显存拷贝引擎利用率判断数据搬运是否成为瓶颈DCGM_FI_DEV_GPU_TEMPGPU温度防止过热宕机DCGM_FI_DEV_POWER_USAGE实时功耗检查供电是否充足DCGM_FI_DEV_FB_USED显存已用量排查显存溢出类OOM问题在实际运营中有一个经验值得记录如果GPU利用率长期接近100%但模型迭代速度提升不明显问题大概率不在GPU算力上而在数据加载链路。这时候要看CPU利用率和数据读取IO情况往往CPU已经成了瓶颈。我遇到过一次线上训练任务GPU利用率97%但整体吞吐量只有预期的60%排查半天发现是数据读取走的NFS网络延迟严重拖慢了数据管线。换成本地NVMe盘后吞吐量直接翻倍。这个经验教训是GPU调试不只是看GPU本身要结合CPU、内存、磁盘、网络一起看才能定位真正的瓶颈。5. 实战排错从驱动到调度再到运行时的典型问题最后一章是真正的干货。这些年在GPU集群运维上踩过的坑整理成一份速查手册遇到问题直接对照排查。5.1 宿主机层面nvidia-smi报错与驱动失联最常见的驱动级报错就是NVIDIA-SMI has failed because it couldnt communicate with the NVIDIA driver.这个报错出现的原因绝大多数是驱动模块和当前运行的内核版本不匹配。触发场景通常是系统自动更新了内核但模块没跟着重新编译或者手动升级内核后忘了重装驱动。排查步骤确认当前加载的模块状态lsmod | grep nvidia如果没有输出说明nvidia模块没有正常加载尝试手动加载modprobe nvidia如果提示内核模块不存在或版本不对直接重装驱动是解决速度最快的路子。建议重装前先彻底清理旧驱动nvidia-uninstall再重新执行runfile安装流程。注意重装后要同步核对nvidia-smi输出里的Driver Version和CUDA Version是否满足你的Container Runtime要求。5.2 设备插件层面节点资源不显示GPU如果kubectl describe node的输出里没有nvidia.com/gpu问题大概率出在设备插件上。按照下面三层排查第一步确认device plugin Pod是否正常运行kubectl -n kube-system get pods | grep nvidia-device-plugin第二步查看Pod日志以及对应事件kubectl -n kube-system logs pod-name kubectl -n kube-system describe pod pod-name常见日志错误和原因对照关系参考下表日志内容根因解决方向Failed to initialize NVML驱动未正确安装或NVML库缺失重新安装驱动确认libnvidia-ml.so存在No devices found节点上没有检测到NVIDIA GPU检查PCI设备识别情况lspciUnable to connect to kubeletSocket路径不对或kubelet重启未重新注册确认device plugin的Volume Mount路径与kubelet device-plugins目录一致timed out waiting for kubelet to startPlugin启动太早kubelet还没就绪增加--fail-on-init-errorfalse参数让Pod等待重试第三步检查kubelet到device plugin的socket链路。有时候Pod显示Running但节点资源就是没上报大概率是socket连接异常。手动检查设备插件目录ls /var/lib/kubelet/device-plugins/正常情况下应该能看到nvidia.sock文件。如果文件存在但节点资源没更新可以重启kubelet强制重新拉取systemctl restart kubelet5.3 调度层面Pod一直PendingPod处于Pending状态说明调度器还没有找到合适的节点。查看事件kubectl describe pod pod-name | grep -A 5 Events常见的Pending原因就三类第一类是节点GPU资源已被占满。查看节点资源分配情况kubectl describe node gpu-node-name | grep -A 5 Allocated resources第二类是节点活跃CPU/内存不满足Pod的requests。GPU节点通常都是重负载机器有可能GPU空闲但CPU或内存超售。这时候要么降Pod的CPU请求量要么扩容节点资源。第三类是节点亲和性或污点没处理。如果你的GPU节点打了nvidia.com/gpu.presenttrue标签而Pod没有对应的nodeSelector或者节点上有NoSchedule污点而Pod没有对应容忍调度器就会把Pod挂着不动。解决方法是给Pod加上对应的nodeSelector或tolerations。5.4 运行层面容器内看不到GPU或报CUDA错误Pod成功运行但程序报以下错误是另一个高频问题CUDA error: no kernel image is available for execution on the device这个报错的意思是CUDA版本和GPU架构不匹配。比如你用的CUDA镜像版本太老不支持新出的Ampere架构或Hopper架构或者反向新CUDA版本已经放弃了对老架构的兼容。解决办法是检查镜像基础层的CUDA版本和实际GPU架构的算力Capability对齐。另一个高频现象是容器里执行nvidia-smi提示找不到命令。这种情况不是设备没注入而是镜像里压根没有这个工具。官方CUDA镜像默认含nvidia-smi但如果你用的是一个精简过的基础镜像就需要手动安装或直接换镜像docker run --rm nvidia/cuda:12.4.0-runtime-ubuntu22.04 nvidia-smi如果你必须使用自定义精简镜像可以在Dockerfile里加上RUN apt-get update apt-get install -y nvidia-utils-550版本号要和宿主机驱动配套别乱装否则又会出现NVML库版本不匹配的问题。5.5 容器内报显存不足或OOM训练类任务经常遇到显存溢出报错。和CPU内存OOM不同的是GPU显存耗尽不会直接杀掉容器而是进程崩溃退出。排查思路是先看当前实际显存占用nvidia-smi看哪块卡被占满了以及是哪个进程占的。结合DCGM监控指标里的DCGM_FI_DEV_FB_USED时间序列基本能定位是哪个Pod在什么阶段把显存吃满了。显存OOM的常见原因主要有batch size设置过大、模型本身参数量太大、多卡并行方式没配对。常规的调优手段是降低batch size、用梯度累积替代大batch、或者开启混合精度训练。以PyTorch为例开启AMP混合精度可以大幅降低显存占用scaler torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): output model(input)混合精度配合好的情况下主流模型能节省30%到50%的显存训练速度有时还能提升。这在GPU资源受限的环境里是性价比最高的优化手段。6. 多卡调度与共享场景的进阶思考文章到这里K8s中GPU使用的主线已经完整走通了。最后补充一些面向大规模生产环境的进阶思考。多卡训练场景下K8s原生调度器对GPU亲和性的支持能力是有限的。默认情况下它只能保证Pod申请到指定数量的GPU但不能保证这些GPU在同一节点上、或者满足NVLink互联条件。对需要多卡通信的分布式训练任务推荐引入NVIDIA的GPU Operator或第三方调度器扩展组件。GPU Operator是一个集成度非常高的方案它把驱动生命周期管理、device plugin、DCGM exporter、MIG配置等统一包装成了一系列K8s原生控制器。使用GPU Operator后节点接入GPU集群就像装了一个普通应用一样简单不需要手动在每个节点上执行驱动安装脚本。对于大规模集群来说这个方案能显著降低运维成本。另外关于GPU资源共享目前比较主流的方案是时间片共享和MIG切分。时间片共享允许多个Pod共用一张物理GPU但显存不隔离一个Pod把显存耗尽会影响同设备上的其它Pod。MIG则是硬件层面隔离安全性更高但只支持较新的数据中心GPU型号。具体选哪种取决于你的业务对隔离强度的要求。如果是跑推理服务这种长尾低负载场景时间片共享效率更高如果是多个团队共用一集群跑训练MIG切分能更好地避责和隔离。回到K8s本身GPU资源接入这件事看似只是装一个驱动和一个插件实际牵涉到的链路很长宿主机驱动、容器运行时、设备插件、调度器、监控体系、应用侧资源请求每一环都紧密相关。按照这篇文章的步骤一步步操作先确保单节点驱动和容器运行时可用再部署device plugin接入K8s最后再推业务容器整体会顺畅很多。