1. 为什么需要自建GPU资源池化
很多人一开始接触GPU集群,第一反应就是“容器里挂GPU嘛,nvidia-docker插上不就行了”。真到了生产环境,你会发现这套想法在早期小团队里还行,一旦规模铺到几百张卡、几十个项目组同时跑训练和推理,事情完全不是那么回事。
我在某大型互联网公司的AI中台团队做过一版GPU资源池化改造,当时面临的核心问题非常典型:各项目组的训练任务抢着独占整张卡,但A100跑个小模型时显存利用率经常只有20%到30%;推理服务为了高可用还会在每张卡上冗余部署好几份,高峰期一过大量算力直接睡大觉。与此同时,新来的项目申请GPU走流程要好几周,因为卡被“名义上分配”出去了,实际没人用也拿不回来。说白了就是资源卡在物理边界里,谁也分不出去、调不动、借不走。
云原生GPU资源池化要做的,就是把“一张物理GPU卡”这种粗粒度分配方式,拆成“算力池”这种可以按需切分、弹性伸缩、跨节点调度的细粒度资源视图。落地的形式就是容器化部署:K8s集群作为统一调度面,GPU通过设备插件和自定义调度策略接入集群,暴露成可分配、可限额、可监控的池化资源。整个项目涉及三大块:驱动虚拟化层怎么做、K8s侧的资源模型怎么扩展、调度与配额策略怎么设计。
这篇文章不聊PPT上的架构图,直接把我们在生产环境里真正落地的一套方案、踩过的坑、调过的参数都梳理出来。适合正在做GPU集群管理、想提高卡利用率的基础设施工程师,也适合被老板要求做“算力中台”但又不知道从哪里下手的团队参考。
2. 资源池化的核心设计思路与关键决策
2.1 资源切分的三种主流路径怎么选
要做GPU资源池化,第一步绕不开的就是“怎么把一张物理卡切成多个逻辑卡”。这里我对比了三种主流路径,各有各的适用场景。
第一种是NVIDIA官方提供的MIG(Multi-Instance GPU)方案。A100和H系列支持把一张物理卡按显存和计算核心切成多个实例,每个实例拥有独立的显存带宽、L2缓存和计算分区,硬件隔离性最好。比如A100 80G可以切成7个1g.10gb的实例,或者3个混合大小实例。优点是无需额外开发,官方驱动直接支持;缺点是只有特定型号支持,显存切分的粒度和数量规则固定,灵活性天然受限。例如我想把一个GPU切出2GB给一个小模型,MIG根本做不到。
第二种是vGPU虚拟化,也就是在驱动层做显存和算力的逻辑划分。用户态进程看到的是一张“虚拟GPU”,实际物理卡由驱动统一调度。这种方式切分粒度高,可以做到1/8、1/16这样细的显存配额,而且不限制必须用什么型号的卡。缺点是每个容器里跑的是经过虚拟化层接管的CUDA环境,部分GPU特性(比如某些NVLink通信、GPU Direct)兼容性需要验证,多用户相互干扰的场景还需要额外的算力限制策略。
第三种是纯软件层的“显存隔离+调度穿透”,本质上就是用NVIDIA的MPS(Multi-Process Service)或者自研的代理层来限制进程访问显存的范围。我们最终采用的是“vGPU逻辑切分为主、MPS算力限制为辅”的组合方案,原因很简单:生产环境里既有大模型训练(吃显存和算力都很猛),又有大量小规模推理服务(只需要一小段显存但要求快速启停),单靠任何一种方案都有明显的盲区。
2.2 资源抽象与上层的配额模型
物理层的切分解决“能切成什么”,上层的资源模型解决“切成的东西怎么被认知和分配”。在K8s原生的资源模型里,GPU通常通过nvidia.com/gpu这个扩展资源暴露,但它只能表示“整卡数量”,无法表达显存大小、算力份额、卡型号差异这些信息。面对异构卡池(我们有A100、H800、还有少量RTX 3090兜底),只传一个整数根本没法用。
我们的做法是自定义了一组CRD,核心是GPU池对象和租户配额对象。GPU池对象描述一组同型号物理卡,包含卡型号、总显存、切分粒度、可分配模式;租户配额对象则描述某个部门或项目组可以申请的资源上限,包括总显存配额、最大卡数、优先级、借用权限。底层K8s继续用扩展资源管理物理设备,上层业务在申请时指定的是“显存大小和型号偏好”,由自研调度器完成从虚拟请求到物理资源的映射。
比如一个搜索团队想跑个线上推理服务,申请12GB显存,调度器会扫描当前所有节点的剩余显存,找一个碎片足够放下12GB的物理卡,把这张卡上的一段显存区间标记给这个容器。容器看到的是一整张逻辑卡,实际上它在物理卡上和别的容器共享。这个过程对业务方透明,他们不需要知道自己在哪个节点上和谁共用一张卡。
这套抽象模式解决了几个关键痛点:异构卡被统一建模,业务不用关心底层具体型号;显存按需分配,不再是整卡的粗粒度交付;配额可量化、可审计,谁占了多少资源一目了然。
3. 容器化部署全流程:从驱动到调度器的落地细节
3.1 GPU节点的初始化与底层环境准备
容器化部署的第一步是把GPU节点干净地接入K8s集群。这一步看起来轻巧,实际上最容易埋坑。我们需要统一节点镜像中的驱动版本、CUDA运行时、容器运行时配置。
节点初始化执行的关键操作包括:
- 安装和宿主机内核匹配的NVIDIA驱动,这里强调一下,我们在生产中锁定驱动版本号为535系列,并统一所有节点的CUDA版本为12.2,避免不同业务镜像依赖不同CUDA版本导致的兼容性地狱。
- 配置NVIDIA Container Toolkit,让容器运行时能识别GPU设备。需要修改
/etc/docker/daemon.json或者containerd的配置文件,把nvidia-container-runtime挂为默认运行时。 - 加载所需的NVIDIA内核模块,比如
nvidia_uvm,并且在系统层面配置NVIDIA_REQUIRE_CUDA环境变量。
容器运行时的配置里有一个极其容易忽略的参数:默认的运行时参数。如果你用containerd,需要在配置里加:
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia] privileged_without_host = false runtime_engine = "" runtime_root = "" runtime_type = "io.containerd.runc.v2" runtime_path = "/usr/bin/nvidia-container-runtime"不加这段,K8s调度GPU资源的时候会遇到Failed to create container sandbox或者unknown runtime nvidia之类的报错,排查起来非常烦人。后来我们干脆用Ansible写了一整个节点初始化角色,从驱动到运行时到节点标签一气呵成,再没手工操作过。
3.2 Device Plugin部署:资源上报的细节与版本坑
底层环境就绪后,需要部署NVIDIA官方Device Plugin让K8s感知GPU资源。一般直接用DaemonSet方式部署,它会自动在每个GPU节点上启动,并把节点上的GPU数量作为扩展资源上报给Kubelet。
这里有几个关键参数需要根据我们自己的切分模式来定制:
--resource-name必须和后面的调度策略一致,我们直接沿用nvidia.com/gpu,方便社区生态兼容;--mig-strategy如果用的是MIG模式需要设置为mixed或single,但我们走的是vGPU逻辑切分,所以这个参数不生效,用的是默认;--fail-on-init-error建议设为true,否则驱动加载异常的节点会被标记为可用但实际上调度不了任务,害得任务无限Pending。
Device Plugin在节点上的资源上报逻辑是:每个GPU对应一个设备ID,kubelet把这些设备以扩展资源的形式注册到API Server。我们部署完成后验证时发现一个典型问题:每个节点上报的GPU数量是物理卡数,但我们希望K8s看到的其实是“可切分的显存数”。Device Plugin做不到这一点,它的单位就是“张”。
所以我们在架构中加了一层:Device Plugin负责发现物理设备,真正的细粒度资源上报由我们自研的虚拟GPU Agent来做。这个Agent运行在每个节点上,通过访问nvidia-smi获取每张卡的显存总量和已分配显存,把自己节点的总资源换算成“可分配显存池”,上报为一个自定义扩展资源,比如ai.gpu-mem.gb。调度器真正参考的是这个细粒度资源数量,当容器请求12GB时,它分配的是12个单位的ai.gpu-mem.gb,而Device Plugin保证物理GPU能被可靠挂载。
3.3 调度器的扩展:从请求到物理卡的映射
K8s默认调度器只知道节点上有几张卡,不知道显存碎片在哪里,更不知道怎么把一个容器塞到一张卡的特定显存区间里。应对方案是写了一个Scheduler Extender,挂载到默认调度器的filter和bind阶段旁边。
Filter阶段的逻辑比较简单:遍历所有节点,根据每个节点当前的显存池余量、卡型号偏好、优先级,筛出能容纳请求的候选节点。实际操作中还要检查一个关键条件——目标节点上是否存在“同一张物理卡内剩余显存 ≥ 请求显存”。这个条件决定了容器能否在单卡内完全放置,还是需要跨卡占多个GPUID。我们的策略是优先单卡放置,因为跨卡要消耗NVLink或PCIe带宽,而且会引入额外的故障域。
我举个例子说明映射过程。假设节点A物理上有4张A100(每张80GB显存),其中两张各自已经占用了30GB,剩下50GB;另外两张完全空闲。这时一个请求60GB的任务来了,过滤逻辑会发现:四张卡里没有任何一张的剩余显存 ≥ 60GB,节点A被直接刷掉。调度器不会选择在这个节点上分配两个30GB的区间凑出60GB,因为那样容器需要同时挂载两张物理卡,通信开销和故障概率都不划算。
Bind阶段做的事情是把调度决策写回K8s的Pod对象上。我们在Pod上追加一组Annotation,标明这个Pod被分配的具体卡型号、物理卡ID、显存起始偏移和大小。业务容器启动时,我们有一个瘦身版的CUDA拦截层会读取这些Annotation,在进程内建立显存空间的映射关系,确保业务进程只能操作自己分配到的显存区间。
3.4 自定义CRD控制器:配额与分池的逻辑闭环
调度器解决的是“一次请求怎么分配”,配额和归还的管理需要一个控制器来持续运作。我们定义了一个叫GpuLease的CRD,每个租户的每次申请创建一条Lease记录,字段包括租户标识、资源规格、起始时间、到期时间、优先级、状态。
控制器维护的核心循环是这样的:
- 当业务方提交一个Pod,调度器查一下该租户当前的Lease总量是否超过配额。超过就直接挂起,排队等待。
- Pod跑起来后,控制器根据调度器写入的Annotation,更新对应Lease的已用字段。
- Pod结束或删除时,控制器收到事件,释放显存区间,并把资源归还节点池。
- 到期未续租的Lease,控制器自动回收,对应的Pod触发驱逐迁移。
实际做的时候发现控制器要想稳定,必须处理大量边界场景。例如节点宕机会导致该节点上的所有Lease失联,但这些Lease在K8s里的状态还可能是Running。我们在控制器里加了一个NodeHealth监听器,节点失联超过3分钟就触发Lease的强制回收,并由调度器在其他节点上进行重调度。
配额借用功能是另一个业务价值点。有的项目组季度末突然要跑一批评测,配额不够,但又不想走冗长的审批流程。我们的系统支持配额借用:低优先级的租户可以借出自己的闲置配额给高优先级租户,借出方保留随时回收的权利,借用方在回收事件到达时需要优雅退出当前Pod。这个机制在内部落地的效果出乎意料地好,资源利用率提升了大约17个百分点,而且因为回收事件可以强制触发,低优先级租户的“借出去收不回来”的顾虑基本被消除了。
4. 实操过程与关键环节实现
4.1 GPU节点的池化注册流程(一步步照着做)
下面我把我们团队在新增一个GPU节点时执行的完整注册流程写出来,照着这个走基本一次就能通过。
第一步,节点初始化。部署操作系统后,安装内核头文件和编译工具链,因为NVIDIA驱动需要针对当前内核版本编译模块。执行uname -r确认内核版本,然后安装对应版本的kernel-devel包。
第二步,安装驱动。建议直接用NVIDIA官方runfile安装,不要用发行版自带的驱动包,后者经常出现版本被锁死、升级内核后驱动失效的问题。安装时加上--no-opengl-files避免和桌面环境冲突,--silent模式方便脚本化。装完后运行nvidia-smi确认驱动识别,这一步最常见的问题是“No devices were found”,八成是内核模块没加载成功,排查dmesg | grep nvidia。
第三步,安装NVIDIA Container Toolkit。官方提供了apt源,直接安装nvidia-container-toolkit,然后根据你用的容器运行时执行相应的配置命令。我们用的containerd,执行:
sudo nvidia-ctk runtime configure --runtime=containerd sudo systemctl restart containerd这一步做完,可以用ctr run --rm --runc-binary nvidia-container-runtime测试一个CUDA容器是否能正常启动。测试镜像用nvidia/cuda:12.2.0-base-ubuntu22.04,跑nvidia-smi看是否能识别GPU。
第四步,部署Device Plugin。K8s官方提供的DaemonSet YAML可以直接用,但注意把镜像版本对齐你集群的K8s版本。我们集群是1.26,用的Device Plugin版本是0.14.0。部署之后检查每个GPU节点的kubectl describe node,看到nvidia.com/gpu资源以及对应的可分配数量,说明插件上报正常。
第五步,部署我们的虚拟GPU Agent。它启动时会扫描节点上的所有物理GPU,初始化显存池状态,并通过节点标签上报可用的显存池总量。比如一张A100 80G的卡,Agent上报的ai.gpu-mem.gb是72(预留了约10%给系统使用和碎片整理)。这一步完成之后,节点就正式成为资源池的一部分,可以接收业务请求了。
4.2 容器请求GPU资源的方式与调度生效过程
业务方使用新平台很简单,他只需要在Deployment的resources里有如下声明:
resources: limits: ai.gpu-mem.gb: 12 nvidia.com/gpu: 1调度器看到这个请求后,会先检查租户配额剩余量,然后按Filter逻辑找目标节点,再给Pod写上我们自定义的Annotation。这里有一个非常关键的设计点——我们在调度阶段就会把显存映射关系算好,而不是等容器启动之后再去探测。调度器在Bind前会读取节点Agent上报的显存分配位图,从里面挑一个合适的显存区间,把区间元数据写进Annotation。
容器运行时启动时,我们的CUDA拦截层会拦截cuInit和显存分配调用,把业务进程的内存分配引导到Annotation指定的显存区间内。对于业务方来说,他以为自己在用一块独立的12GB GPU,实际上是和别的容器共享一张物理卡。这种透明替换的好处是业务代码完全不用改,坏处是一旦有问题(比如某个显存越界)排查起来非常考验底层功底。
调度生效的验证方式,我们一般看三个信号:Pod被调度到正确节点;容器内执行nvidia-smi看到的是被拦截后的显存视图;宿主机的nvidia-smi能看到多个进程在共享一张卡的显存。
4.3 配额管理的配置示例与权限设计
配额管理的操作界面我们做在内部运维平台上,但底层CRD配置是可以直接写YAML的。举个例子,假设某项目组申请了一个月2TB总显存时长、最多同时占用400GB显存的配额,对应的CRD这样写:
apiVersion: ai.example.io/v1 kind: GpuQuota metadata: name: search-nlp-quota namespace: ai-platform spec: tenant: project-search-nlp maxConcurrentMemGB: 400 maxTotalGBHours: 2000 maxConcurrentPods: 120 priority: production borrowPolicy: canBorrow: true maxBorrowGB: 100这段配置定义的是租户的“资源上限”。调度器做配额校验的时候,会读取当前租户所有活跃Lease的总和,和这里配置的上限做比较。如果申请超量,Pod会进入Pending状态,并且不会触发默认的K8s重调度,而是由我们的队列控制器统一管理,按优先级和申请时间排队。
权限设计上,我们把“谁能申请多少资源”和“谁能批准配额调整”分成两级。项目组的技术负责人只能查看自己的配额和申请Lease,没有权限修改配额大小;配额调整走平台审批流。这套权限模型的好处是资源治理有据可查,避免“谁和平台关系好谁就能多拿卡”的暗箱操作。
4.4 监控体系与资源可视化的落地情况
GPU资源池化之后,监控体系和物理GPU时代完全不同。以前看每张卡的利用率就行,现在要看的是显存池的碎片率、租户的真实使用率、单卡多容器的相互干扰度。
我们基于Prometheus做了一套四维监控:
第一维是物理层,采集每张卡的GPU利用率、显存总量、温度、功耗。第二维是逻辑层,采集每个Lease对应的容器实际用掉的显存和算力,这个最能反映资源是否被浪费。第三维是配额层,看每个租户的配额使用率、排队时长、借用情况。第四维是调度层,记录每次调度决策的耗时、失败原因、各节点的碎片情况。
实际操作中有一个指标特别有价值:显存碎片率。我们定义成“物理卡剩余不可分配的显存大小占这张卡总显存的比例”。碎片率超过30%的卡需要管理员介入进行Lending回收或迁移腾挪。调度器在做Filter时会倾向于选择碎片率更低的节点,减少资源碎片长期积压。
可视化页面我们用Grafana搭了两个大屏:一个给运维看,展示节点健康度、每张卡的分配和实际使用对比;一个给业务方看,展示自己项目的配额用量、任务排队时长和资源趋势。业务方能直观看到自己占了资源但没用满的数据,内部协调资源时就不再靠扯皮了。
5. 常见问题与排查技巧实录
5.1 容器启动报CUDA Error: Unknown Error
这个是我们上线初期遇到最多的一个问题。容器启动后进程一调用CUDA就报CUDA Error: Unknown Error,但宿主机上nvidia-smi是完全正常的。
排查过程很有代表性。先看容器内的nvidia-smi是否能执行,发现能执行且能看到GPU信息,但调用CUDA API就崩。怀疑是CUDA版本问题,换了好几个基础镜像还是不行。最后用dmesg看内核日志,发现是nvidia-uvm模块没有加载,而CUDA运行时依赖这个模块做统一内存管理。
解决办法是确保nvidia-uvm模块在节点启动时加载。我们在节点初始化脚本里加入:
echo "nvidia-uvm" >> /etc/modules-load.d/nvidia.conf modprobe nvidia-uvm并且检查了/dev/nvidia-uvm设备节点是否存在。如果不存在,需要手动创建,或者重启一次容器运行时服务让它自动创建。这个问题产生的根源是部分节点镜像用云厂商自带的驱动包,这些包在初始化时并不会自动加载uvm模块。
5.2 调度器把Pod调度到节点后容器一直ContainerCreating
这种现象多半和虚拟GPU Agent的上报状态不一致有关。比如调度器通过Filter阶段选出了节点A,但容器尝试启动时,节点A的显存池刚刚被另一个高优任务抢占,实际可用显存低于调度器当时看到的。
我们设计了双保险来避免这个问题:调度器在Bind之前会再次调用节点Agent的“原子预留”接口,如果预留失败就直接换节点,不留脏数据;Agent每30秒向调度器同步最新的显存位图,在极端情况下即使有短暂不一致,Agent也会在收到启动请求时做二次校验,发现显存不足时返回明确的错误码,Pod立即重调度而不是卡死。
另外检查一下Device Plugin和虚拟GPU Agent的版本是否对齐也很重要。有一次我们升级了Agent,但Device Plugin还停在旧版本,导致设备分配和显存分配的ID错位,容器起来看到的GPU设备和调度器记录的设备不一致,直接导致探活失败。
5.3 显存碎片严重,新任务调度失败率居高不下
运行了大概两个月后,我们发现有部分节点的显存碎片率飙升到40%以上。原因是小容器的生命周期短,频繁启停导致物理卡上的显存区间被切成无数小块,而新来的大请求在单卡上找不到连续的显存空间。
解决思路分两层。调度层面的策略是优先使用“剩余容量足够但碎片率低”的节点,让大请求落到相对完整的卡上,小请求才用碎片卡。运维层面我们加了一个定期整理的机制:当某张卡的碎片率超过阈值时,将该卡上的所有Lease标记为“可迁移”,调度器在后续滚动更新时把Lease迁到其他节点,空出来的卡重置显存池。
执行一次整理操作需要谨慎,不能直接kill业务容器。我们是通过把节点标记为unschedulable,停止新调度,然后按照Lease的优先级从低到高逐批迁移。迁移之前会通知业务方预留缓冲时间,对无法优雅退出的任务采用强制驱逐但保留数据快照的策略。这套流程上线后,碎片率基本稳定在10%以内。
5.4 多租户互相干扰,GPU算力被抢占导致推理超时
显存隔离做得再好,算力抢占有时候更致命。一个线上的A100节点上一半跑训练任务,一半跑推理服务,训练任务把GPU的SM都占满了,推理服务的P99延迟从30ms飙到400ms,线上告警直接被打爆。
解决方案是在vGPU逻辑切分的基础上叠加了MPS的算力控制。每个容器在启动时通过环境变量指定算力份额,比如CUDA_MPS_PIPE_DIRECTORY=/tmp/mps-<uuid>和CUDA_MPS_ACTIVE_THREAD_PERCENTAGE=30。MPS会根据这个百分比限制进程的并发计算线程数,粗粒度地控制算力占用。效果还算理想,推理服务的P99延迟能回到80ms以内,训练任务的吞吐因此损失了大约10%,但这是合理的代价。
需要注意,MPS方案对多进程并行计算的限制比较明显,如果业务方用PyTorch的DataLoader多进程模式,ACTIVE_THREAD_PERCENTAGE的语义会变得复杂。我们在生产上建议训练任务不用MPS限制,只对推理任务启用,并且MPS限制配置从节点级别的ConfigMap读取,运维可以动态调整。
5.5 节点宕机后的Lease悬挂与恢复流程
节点物理宕机是不可避免的。默认情况下,K8s会等tolerationSeconds结束后把Pod标记为Failed,但我们的GpuLease资源记录的显存分配并不会自动释放,导致这部分显存在资源池里凭空消失。
我们在控制器里增加了一个节点失联处理逻辑:节点心跳超过90秒,触发Lease的故障标记;超过180秒,如果节点仍未恢复,直接将Lease状态置为Lost,释放对应的显存区间,允许同等规格的新Lease在其他节点创建。同时,控制器会尝试寻找该节点上有持久化存储的任务的副本数据,确保重调度后的任务能恢复到最近一次checkpoint。这套设计的取舍点在于:对于没有做分布式存储的临时训练任务,节点宕机直接导致任务失败,这是物理故障的客观代价,无法完全避免,只能靠任务方自己的容错机制来兜底。
6. 一些心里话与实践建议
整套GPU资源池化架构从设计到落地,前后花了大概四个多月,中间推翻过一次方案。最初我们想直接依赖社区的开源虚拟化方案,省事,但后来发现它没法满足多租户配额和显存区间映射的需求,后期改造的成本远远大于自研Agent的成本。这个决策给我的教训是:方案选型不能只看能完成多少功能,更要看你想做的资源治理模型是否和底层实现匹配。
另外一点很重要的是,做资源池化不仅是技术项目,更是组织协调问题。GPU资源从独占变成共享之后,必然有人受益有人觉得被动了奶酪。我们当时花了大力气做资源可视化,让每个业务方都能看到自己的使用曲线、排队情况和别人的借用情况,配合成本账单的数据,业务方自己就会优化资源申请策略。数据透明是推动资源治理最有效的杠杆。
如果你们团队打算做类似的事情,我建议从一个小规模试点开始,先拿几台卡、几个非核心业务把流程跑通,验证调度策略和Agent的稳定性,再逐步扩大到全量节点。一上来就全量改造,风险太大,出了问题连回滚的机会都没有。
这套架构后续我还在持续演进,下一步计划是引入GPU拓扑感知调度,把显存区间分配和NVLink拓扑结合起来,减少多卡任务跨交换机通信的开销。技术方向是清晰的,剩余的就是时间问题。希望这篇沉淀能帮正在做类似方向的同行少走几个坑。