8张卡撑起30个开发环境这个比例第一眼看上去有点像在做资源魔术。我们去年在电科云内部搭AI研发平台时就因为这个数字被团队内部挑战过好几轮国产GPU本来就紧张怎么还敢把一张卡拆给四五个环境用先说结论能撑起来前提是搞明白开发环境的真实GPU占用画像再引入一套靠谱的GPU虚拟化调度组件。我们最终选择的是HAMi这套方案让30个容器化开发环境VS Code Remote、Jupyter Notebook、模型调试任务等稳定跑在8张国产加速卡上。整件事不是简单的“一张卡切成四份”涉及硬件适配、设备注册、调度策略、显存隔离、算力限制一整套链路。如果你正在做内部AI平台、K8s上的GPU资源池或者被老板问“为什么30个人只有8张卡还能干活”这类问题这篇文章应该能给你一个完整的参考答案。文章里所有内容来自我们实际环境整理涉及版本相关的参数我会特别说明不同版本之间差异不小直接照搬配置前一定要先核对。1. 先算清楚账为什么30个开发环境只需要8张卡1.1 开发环境和训练环境的GPU画像完全不同我们一开始也想当然按“人均一块GPU”规划但实际观察团队工作流后发现这个假设根本不成立。算法工程师一天里的状态大概是这样的早上改模型结构改完跑一个step验证一下显存占用16GB以下持续几分钟然后去改数据加载、看loss曲线、调超参这段时间GPU基本空转只有到下午要正式微调或评估时才会连续占用一块卡几十分钟甚至几小时。如果把工作流拆开看不同活动的GPU需求差异非常大开发活动GPU主力程度典型显存占用持续时长能否共享写代码/改配置几乎不用0长完全能单步调试/小batch验证低2-16GB分钟级能数据预处理与可视化低间歇性中能模型转换/量化/编译低到中时高时低中部分能正式训练/大规模推理高基本吃满长不能切太碎结论很清楚30个环境即使同时在线同一时刻真的在“吃”GPU的往往只有三分之一不到而且就算在吃也不是所有算力都用满。按这个画像8张64GB的卡共512GB显存每个开发环境给12-16GB理论上就能覆盖30个环境。1.2 算账的具体数值我们来把数字摆到台面上算一遍。总共512GB显存30个环境如果平均每个分配16GB那就占掉480GB剩下的32GB留给系统开销和缓冲。这个数字看起来紧但配合算力配额再看就合理了每个环境给20%左右的算力配额30个环境的“总需求”虽然超过8张卡总算力的600%但因为实际使用时高度错峰调度器可以在某张卡空闲时让其他环境临时用满。这里要特别提醒一句不能把账算到极限。我见过不少团队一上来就追求高密度结果一个环境出问题把整张卡拖死连累所有共享用户。我们内部定的标准是虚拟化池的显存超卖率不超过1.2倍算力超卖率不超过2倍极端并发时宁可让低优先级环境排队也不能把整卡打爆。1.3 满足可落地的三个硬条件光算账不够要真正让“8卡承载30环境”落地必须满足三个硬条件条件一显存强隔离。开发环境都是人在操作经常出现显存泄漏、一次性申请超大显存的情况。一个环境把显存打爆不能影响同卡其他环境。条件二细粒度切分与动态回收。环境随时创建和销毁显存要能按MB级粒度划分空闲环境退出后资源要能立即归还否则碎片化很快让整个资源池瘫痪。条件三算力不能无限制。否则一个跑10个并行DataLoader的环境能把整张卡的AI Core拖垮旁边几个环境全部跟着遭殃。这三个条件直接把“物理独享、纯时间片、硬件MIG”这些方案筛掉了HAMi恰好就是冲着这三个条件去的。2. 为什么选 HAMi不是唯一解但是最合适的解2.1 先排掉四个常见方案物理独享就不用多说了8张卡只能给8个环境30个环境就是3倍缺口哪怕靠排队也严重影响开发效率。这个方案在任何资源紧张的场景下都不现实。官方时间片方案也有明显问题。NVIDIA官方device plugin这类方案主要解决的是“算力复用”显存还是不隔离的。一个容器里cudaMalloc 30GB整卡显存被打满同卡其他环境立刻OOM。放到有研发团队的环境里出现一次就会被骂死。而且国产卡这边官方方案的适配程度参差不齐很多甚至没有官方插件可用。硬件切分MIG、vNPU这类隔离性确实好但问题同样不少。很多国产卡不支持硬件切分或者需要通过专门的配置工具才能做另外硬件切分的档位往往是固定的比如只能切成1g、2g、4g和开发环境那种“又要5GB又要15GB”的灵活需求完全对不上。在K8s上动态管理这些硬件实例更是别扭。还有一种是纯应用层共享比如在torch里通过环境变量控制几个进程各自用卡这种方式断代严重无法按环境精细化分配K8s调度器也感知不到资源变化只适合单机调试不适合平台化。2.2 HAMi 到底是个什么东西如果要用一句话讲清楚HAMi是一个面向K8s的异构AI计算设备虚拟化中间件它把驱动之上的Runtime调用管起来让“一张卡按显存、按算力切成很多份”变成K8s的原生能力。组件结构上HAMi主要管三块device plugin负责设备发现与注册scheduler extender负责在调度阶段做资源打分与选卡runtime钩子负责容器启动后的显存和算力隔离。三块配合起来对上层训练框架几乎透明。最让我看重的是它对多厂商卡的支持。HAMi不只是支持NVIDIA还支持昇腾、寒武纪等一系列国产加速卡这对我们这种已经切换到国产GPU平台的团队来说太重要了。不用为每种卡单独维护一套虚拟化方案。2.3 方案对比为什么落定在 HAMi方案隔离粒度是否依赖硬件显存隔离算力控制K8s融合度多厂商支持物理独享整卡否自然隔离无一般差官方共享插件进程级否弱弱一般差硬件切分硬件实例是强强差差HAMi显存/算力否强中原生融合好综合看HAMi胜在软件层虚拟化、无硬件依赖、切分粒度细而且天然长在K8s调度器上。对我们这种“资源池统一管理、环境生命周期短、开发环境为主”的云平台来说它是最贴合的选择。2.4 不回避HAMi的局限性选择HAMi不等于它能解决所有问题。它的显存隔离本质上是“记账式”的不是硬件强隔离如果业务代码绕过Runtime API直接操作设备隔离就可能失效。算力限制的颗粒度也取决于不同厂商的Runtime实现不一定能像硬件MIG那样做到严格配额。所以我们内部定了一条原则HAMi只承载开发调试和轻量推理正式训练预留整卡池绝不进虚拟化池。这个认知在项目初期就得统一不然后面很容易出事故。3. 硬件归一与驱动适配国产GPU接入K8s的第一道坎3.1 设备层准备不是插上卡就能用国产加速卡和普通显卡的接入方式很不一样不是插上就能被K8s调度。我们拿到的这批卡需要先做一套完整的设备层准备装驱动、刷固件、装Runtime库。以昇腾卡为例驱动和固件版本必须和AI芯片型号严格匹配同时还要装CANN或者NNRT Runtime库上层训练框架才能正确调用加速能力。这里有个最常见的坑只装驱动没装Runtime或者CANN版本和PyTorch适配不一致导致容器里初始化设备直接报错。我们最初的排错时间有三分之一都花在这些“看起来不是问题”的问题上。我们的解决办法是把Runtime固定成一个base镜像所有开发环境的镜像都从这个base构建。这样不管研发团队怎么折腾上层依赖底层驱动接口是不变的出问题也只在base镜像层面解决不会每次都在几十个环境里各自排查一遍。3.2 K8s里如何看到卡设备层就绪之后需要让K8s感知到这些加速卡。这一步依赖设备插件Device Plugin插件通过标准接口把节点上的卡上报给kubeletkubelet再把它注册为节点可调度资源。执行完这一步kubectl describe node就能看到类似昇腾资源、nvidia.com/gpu这样的资源项了。注意设备插件的资源名由厂商或HAMi决定不同版本之间可能不一样我们曾经因为升级后资源名变了导致一整批存量Pod无法被调度。这块一定要在文档里记录清楚。同时要对节点打上标签把卡型、显存容量、驱动版本都标出来比如gpu.modelatlas-910、gpu.mem64G。调度时通过nodeSelector和亲和性配置保证任务不会跑到“卡型不支持”的节点上去。3.3 设备注册与HAMi的接入HAMi部署完成后会接管整个设备管理流程。设备插件通过ListAndWatch上报设备调度器对每张卡维护一个“剩余显存/剩余算力”的账本有新的Pod申请时在这个账本上做扣减和恢复。这个环节最需要注意的一点是设备插件所在节点的驱动版本必须和节点实际安装的版本一致。如果集群节点间驱动版本不统一设备插件的上报数据可能错乱导致调度器以为某张卡有100GB显存实际只有64GB。我们最终把集群所有节点的驱动和Runtime收敛到了同一版本这个问题才彻底消失。4. 显存切分与算力隔离的实现逻辑4.1 两层协作模型对一个使用HAMi虚拟化的Pod来说从用户提交到容器运行调度过程分两步。第一步kube-scheduler在过滤节点时触发scheduler extenderHAMi的调度扩展组件会根据显存剩余、算力剩余、节点标签亲和性对所有候选设备打分排序第二步Pod被绑到节点后kubelet调用device plugin的Allocate接口把选好的GPU实例转换成容器可见的环境变量和设备挂载点。这两层合起来决定了“任务去哪个节点、用哪张卡的哪一块”。理解了这个模型后面排查问题就顺了凡是调度位置不对先查scheduler环节凡是启动失败、资源对不上再查device plugin环节。4.2 显存隔离记账式加API拦截容器内应用调用显存分配API时底层真实发生的事情很多人不清楚。以昇腾卡为例应用调用类似rtMemAlloc的接口这个调用会先经过HAMi注入的shim库。shim库维护着一张“虚拟显存账本”如果本次分配在这个环境的配额之内正常放行如果超过配额直接返回OOM错误而不是真的让物理显存被打爆。这种方式的本质是记账式隔离不是硬件物理隔离。好处是分配粒度可以做到很细灵活不需要硬件支持坏处是如果业务代码绕过Runtime API比如自己直接映射显存地址空间隔离就有可能失效。好在开发环境这个场景下绝大多数应用都规规矩矩走Runtime API。可以打个比方这就像给每个房间装了独立电表正常情况下各用各的额度谁也影响不了谁但如果有人私拉乱接电线电表就形同虚设。4.3 算力隔离限制而不是独占算力隔离的思路和显存隔离不太一样。它的目标不是给每个环境精确预留多少算力而是防止某个环境把整张卡的算力打满从而拖垮同卡其他用户。具体实现上不同厂商差异很大。有的通过时间片轮转有的通过任务队列配额有的通过在Runtime层限制算子并发。国产卡在算力隔离上普遍没有NVIDIA那么成熟我们实测下来的感受是能做到限制峰值但不太能做到非常精细的优先级控制。对开发环境这种场景够用但如果未来有严格的SLA隔离需求还是得靠整卡池来承担。4.4 为什么上层框架感觉不到差异对训练框架来说它看到的是环境变量里写着“可用设备0”设备文件也存在调用Runtime API也正常所以它以为自己在用一张完整的卡。实际上每次显存分配、每个算子提交底层都经过了一层“翻译”。这也是HAMi这类虚拟化方案的核心价值之一对上层应用透明不使用户感知到底层资源是切碎了的。研发团队的代码完全不用改只用在自己的K8s模板里声明需要多少显存和算力就行。5. 部署与配置实操5.1 安装流程安装HAMi之前建议先把环境检查清单过一遍K8s版本是否满足要求、节点驱动和Runtime是否就绪、kubelet的DevicePlugins特性是否开启。我们当时跳过了其中一步后面排查浪费了不少时间。安装本身不复杂用Helm或直接用官方YAML都可以。大致做的事就是部署device-plugin、scheduler extender、相关CRD和webhook。以Helm方式为例helm repo add hami https://project-hami.github.io/HAMi/ helm install hami hami/hami --namespace kube-system --version your-version这里提醒一下具体版本号一定要以官方文档为准。我们中途升级过一次HAMi版本配置项的语法有明显变化旧参数直接失效了需要在升级前仔细阅读Release Notes。安装完成后验证也简单kubectl get pods -n kube-system | grep hami确认三个组件都在Running状态然后看节点资源里有没有新增的GPU相关可调度资源。5.2 核心配置项每个集群规模不同配置参数不能完全照搬官方默认值。我们根据自己的环境调整了几项关键配置配置项作用我们用的值最小显存分配粒度决定显存切分的最小单元避免过小碎片256MB单卡最大虚拟设备数量防止单卡被切得过碎影响运维8个超卖开关与倍率允许超卖的幅度开发环境可容忍小超卖1.2倍显存2倍算力调度策略同节点优先、同卡优先、负载均衡等负载均衡节点排除标签把训练整卡池节点排除在虚拟化池外gpu.pooltraining这几个参数不是一次调对的。最开始我们用默认配置结果单卡被切成十几个小碎片单个环境的显存大小根本不够用后来才改成限制单卡最大虚拟设备数量。这个优化对稳定性帮助很大。5.3 开发环境接入示例研发团队那边不用手写这些YAML但我们平台模板的底层就是下面这个逻辑apiVersion: v1 kind: Pod metadata: name: dev-gpu-01 labels: app: dev-environment spec: containers: - name: dev image: registry.internal/base/pytorch:2.1-cann resources: requests: nvidia.com/gpu-mem: 16Gi nvidia.com/gpu-cpumem: 512Mi nvidia.com/gpu-coremem: 20 limits: nvidia.com/gpu-mem: 16Gi nvidia.com/gpu-cpumem: 512Mi nvidia.com/gpu-coremem: 20资源字段名在不同vendor、不同HAMi版本里会有差异上面示例只是我们环境里实际使用的写法。最稳妥的做法是用kubectl describe node看一下节点上真实暴露的资源名再照着写。我们给研发团队封装了三个档位小环境8GB显存、10%算力普通环境16GB显存、20%算力大环境32GB显存、50%算力。用户只需要在平台界面上选档位后台自动生成对应的Pod。5.4 运维侧处理“环境创建失败”开发环境创建失败最常用的排查顺序是先看Pod事件确认是不是资源不足再看scheduler extender日志确认调度器有没有正确计算显存剩余接着看device plugin日志确认分配是否成功最后看容器本身的日志确认镜像、Runtime相关依赖是否正常。这条链路看起来简单但实际排障中90%的问题都能在这个顺序里找到答案。不要一上来就盯着容器日志看很多时候问题根本到不了容器启动那一步。6. 压力测试与踩坑记录6.1 我建议的四轮验证HAMi部署完不能直接放生产我建议至少要过四轮验证。第一轮单容器显存上限验证故意在容器里申请超过配额的显存预期返回OOM同时同一节点其他容器不受影响。第二轮同卡多容器并发验证在一个节点上拉起4个不同配额的环境同时申请显存验证互相不挤占。第三轮批量调度压测一次性提交30个开发环境Pod观察调度器排队情况和最终分配的均匀度。第四轮真实负载验证一个环境跑LoRA微调另外几个环境同时反复创建销毁观察新环境的创建速度和整体稳定性。我们当时第四轮差点翻车。LoRA微调跑起来后新建环境明显变慢后来发现是调度器的状态同步有延迟并发创建时同一批Pod会争抢同一张卡的剩余配额。升级版本并加了对账机制后解决。6.2 坑一容器退出后显存不释放这是上线后遇到的第一个严重问题。开发环境删除后节点上“剩余显存”没有回升环境利用率肉眼可见地往下掉。排查链路是这样的先看Pod删除事件有没有正常触发回收逻辑再看device plugin的日志发现它上报的剩余显存没有更新最后确认是容器删除事件没有正确传递到设备插件导致资源账本没有归还。解决方式是升级到修复版本同时给平台加了一个定时核对任务每小时拿设备插件上报数据和节点真实状态对账对不上就触发重新上报。这事的教训是虚拟化组件最怕“状态不同步”运维侧一定要有对账兜底机制不能完全依赖组件自身的状态管理。6.3 坑二同一份镜像在不同节点上表现不一样另一个印象深刻的问题是同一个开发环境镜像在节点A创建正常在节点B却反复CreateContainerError。一开始怀疑节点资源不足看事件不是又怀疑device plugin分配失败也不是最后翻到容器启动阶段发现是HAMi注入的runtime钩子库路径和节点B上安装的Runtime库版本对不上导致容器启动失败。根因还是节点间版本不一致节点A是新装的CANN节点B还是旧版本钩子库和旧版本不兼容。解决方式很简单也很粗暴把所有节点的驱动和Runtime统一到同一版本之后这类问题基本绝迹。6.4 坑三LD_PRELOAD钩子被业务环境覆盖这个坑比较隐蔽。HAMi在做Runtime拦截时很多时候依赖类似LD_PRELOAD的环境变量注入机制。但有的深度学习框架镜像里已经自定义了LD_PRELOAD把自己依赖的库挂在前面导致HAMi的钩子失效容器里的应用直接看到“完整卡”。排查方法是进入容器打印环境变量看注入是否成功再对比正常环境和异常环境的差异。解决方式是调整HAMi的注入逻辑或者改基础镜像规范要求业务镜像保留平台入口。这里特别提醒喜欢自定义环境的研发团队很容易和这种钩子机制产生摩擦平台规范里必须写清楚。6.5 国产卡特有的坑最后说一个国产卡特有的坑npu-smi info这类系统工具显示的是物理整卡信息容器里看到的信息不代表自己实际分到的资源。研发团队如果习惯用系统工具看资源占用很容易误以为自己拿到了整卡实际上只是切出来的一块。我们后来统一要求大家通过平台监控面板看资源使用禁止依赖节点上的系统工具。另外国产卡的CANN/PyTorch适配版本非常繁杂不同版本组合出来的行为差异很大。我们内部直接锁死了一套版本组合谁的镜像版本特殊就单独开整卡池不进虚拟化池。这个规则一开始有人觉得武断但后来是它保住了整个平台的稳定性。7. 最后提醒虚拟化池与整卡池要分开规划7.1 不要把所有负载都塞进虚拟化池这是我在整个项目里最想强调的一点。开发调试、小批量验证、推理测试适合放进HAMi虚拟化池长时训练、大模型微调、有严格性能要求的生产推理必须走整卡池。虚拟化的本质是提高碎片负载的调度效率它不能替代物理资源。我们通过节点标签把两类池子彻底隔离开虚拟化池的Pod可以通过nodeSelector落在指定节点组整卡池的任务则直通物理卡。后面扩容或者变更配置时互不干扰运维心态会好很多。7.2 配额治理与回收开发环境数量如果不治理30个很快会变50个、80个资源池再能切也扛不住。我们设置了空闲回收规则超过一定天数没有活跃使用的环境自动释放对应GPU配额回收给新环境用。对于研发团队来说环境是有状态的回收前会给足提醒和导出入口避免误删。资源是动态的治理规则也要动态调整。我们每个月看一次资源使用报表根据活跃度、平均显存占用、排队时长三个指标决定下个月是提高配额还是收紧超卖。7.3 监控体系虚拟化后节点级指标基本没有参考价值必须按容器维度看监控每个环境的显存使用量、GPU利用率、调度排队时长。HAMi本身就提供了监控接口接到Prometheus后我们把集群、节点、Pod、容器几个维度全部打通做成研发团队的自助看板。看板还有一个额外好处研发可以直观看到自己申请的资源到底用了多少。很多人发现自己常年申请16GB但实际只用4GB之后会主动降低配额资源效率就这么一点点抠出来的。7.4 版本兼容矩阵驱动、固件、CANN/NNRT Runtime、HAMi版本、基础镜像这五个维度任何一项变更都必须先测再推。我们内部维护了一张兼容矩阵表每次升级前对照矩阵逐项确认。这张表看起来简单但救了我们很多次。如果要让我总结这段经历最值钱的不是8张卡跑30个环境这个结果而是想清楚了一个问题资源虚拟化不是万能的它解决的是碎片化负载集中调度的问题而不是物理资源从无到有的问题。开发环境这种低占用的碎片负载用HAMi这类组件切分后非常划算但真正需要整卡的重负载依然要老老实实留出独立资源池。先把负载分类再决定用什么工具这个顺序比选型本身更重要。