K8s混部实战:用TFJob与Prometheus提升AI集群资源利用率 集群里的GPU明明有不少空闲算法团队却在群里喊“任务排队等了快两个小时”这是我聊过的多个做AI平台化团队里反复出现的一幕。原因不复杂在线推理服务按峰值预留资源可凌晨和白天非高峰时段CPU、内存、GPU大量闲置离线训练任务呢只能挤在固定几台机器上两边资源互相摸不着。后面我们上一套方案核心就俩字——混部让在线服务和离线TensorFlow训练任务共用同一个k8s集群训练任务通过kubeflow的tf-operator变成k8s里的原生对象再用prometheus和grafana把节点水位的家底盘清楚。这篇文章就是这套环境的完整落地记录从tf-operator部署、镜像准备、提交TFJob到混部资源策略、Prometheus监控、Grafana看板最后补上我实际踩过的几个坑。适合已经会基本k8s操作、正准备搭AI平台或训练基础设施的读者参考。1. 混部这条路解决的是资源浪费的“时间差”1.1 在线服务波峰波谷背后的资源浪费任何在线业务都有明显的潮汐效应。早上十点流量起来晚上十二点之后直线下跌。为了扛住峰值在线服务必须预留足够的副本和资源但预留出来的这部分在半夜就是纯纯的浪费。很多团队的做法是给在线服务设置比实际需求高一倍的request把“可能要爆发”的那部分空间预占下来结果就是一台8核32G的节点实际CPU利用率可能只有15%。离线训练任务恰恰相反它对延迟不敏感但对吞吐量和资源量贪婪。模型训练、数据清洗、批量推理这类任务晚跑十分钟问题不大但要保证一定能跑完而且越多资源越好。把这类任务放进在线集群用在线服务剩下来的时间差填进去就是“混部”的核心思路。但混部不是嘴上说说难点在于在线服务对延迟极其敏感离线训练任务一旦抢资源可能导致在线服务P99飙到几百毫秒用户直接投诉。所以在动手之前一定得想清楚下面这几件事在线服务的request是否设置准确、离线任务是否能接受被抢占、监控是否能第一时间暴露资源倾斜。1.2 TFJob把训练任务变成k8s原生对象如果只是把TensorFlow训练任务当成普通Pod塞进集群运维起来会非常痛苦。分布式训练需要PS、Worker、Chief这些角色每个角色要多少个副本谁先启动谁后启动任务失败要不要重启没人帮你管全得自己写脚本。这就是kubeflow的tf-operator存在的意义。tf-operator向k8s注册了一个叫TFJob的CRD把TensorFlow训练任务抽象成一种资源对象。你只需要在YAML里声明PS几个、Worker几个、用什么镜像、给多少资源tf-operator就会自动创建对应Pod、注入分布式训练所需的网络环境变量、协调各角色的启动顺序、处理失败重启。它在k8s生态里的位置相当于把“TensorFlow分布式训练”这块拼图补上了。另外要明确一点这里说的“离线项目”指的是离线训练/批处理任务和在线推理服务是两个对立面。TFJob天然适合承载这类离线任务因为它是短生命周期、有明确的结束状态Succeeded/Failed不会像在线Deployment那样跑起来就赖着不走。1.3 混部不是简单共用一个集群三个前提我见过不少团队把离线训练直接丢进业务集群结果一周后就出事。混部的前提条件至少有三个。第一是所有在线服务必须设置合理的request。Kubernetes默认调度器是按request来算节点的剩余空间的在线服务如果不设置request调度器认为它可以吃掉节点全部资源离线任务就会一直Pending反过来在线服务虽然设置了request但节点实际运行中如果吃爆内存kubelet会通过OOM Killer干掉Pod通常受害的是优先级低的离线任务但风险更大的是连坐在线服务。所以在线服务的request不是拍脑袋填的要看着监控数据填。第二是离线训练任务必须接受“可用即可用不可用就排队”的定位。它不能去抢占在线服务的物理资源它只能去“捡漏”——用完在线服务没用到的那部分节点资源。所以离线训练Pod的QoS等级、优先级都要比在线服务低。第三是监控必须到位。混部的效果好不好节点水位是不是接近打满但又没打满在线服务延迟有没有恶化这些不能靠猜必须有一整套指标和看板。这也是这篇文章后半部分重点讲的Prometheusgrafana体系的由来。2. 搭建tf-operator环境安装、验证与镜像准备2.1 安装tf-operator只部署控制器不装全家桶kubeflow是一个很大的平台包含Jupyter notebook、Katib超参搜索、Pipeline等一堆组件。如果你只是想把TensorFlow训练任务跑起来完全没必要装全家桶直接部署tf-operator这个组件就够。我建议用官方仓库的standalone overlay部署干净、可控、升级方便git clone -b v1.8.0 https://github.com/kubeflow/tf-operator.git cd tf-operator kubectl apply -k manifests/overlays/standalone如果本机kubectl版本较老不支持apply -k也可以先生成清单再应用kustomize build manifests/overlays/standalone tf-operator.yaml kubectl apply -f tf-operator.yaml这套清单默认会创建一个kubeflow的namespace并在里面启动一个名为tf-operator的Deployment。它会创建TFJob这个CRD同时创建对应的ClusterRole和ClusterRoleBinding让控制器能查看和操作集群里的Pod、Service、ConfigMap等资源。整个安装过程理论上你只需要关心控制器Pod能不能起来别的都不需要碰。2.2 验证CRD和控制器就绪安装完第一件事验证控制器有没有活kubectl get pods -n kubeflow -l nametf-operator kubectl get crd | grep tfjobs正常情况下你会看到tf-operator这个Pod处于Running状态CRD列表里会出现tfjobs.kubeflow.org。这时候就已经具备提交TFJob的能力了。这里有个小经验如果有多个k8s集群或者用rancher、openshift这类平台记得确认当前kubectl的context是不是你要操作的集群。我曾经在浏览器里看到operator Pod还在Running但kubectl联的是另一个集群排查半天才发现是context切错了纯属浪费时间。验证完控制器可以顺手查一下CRD的当前版本kubectl get crd tfjobs.kubeflow.org -o jsonpath{.spec.versions[].name}v1是官方主推的API版本如果你在网上搜到老教程可能拿到的是v1beta2YAML里apiVersion写得不对提交时会被控制器丢弃或直接报错。写TFJob清单时apiVersion建议统一用kubeflow.org/v1。2.3 准备带指标暴露的训练镜像训练任务本身不复杂但我建议你在一开始就把监控埋进去避免后面再改镜像重新推一遍。我给一个很简单的训练脚本它模拟了一个训练循环同时用prometheus_client暴露loss和accuracy指标。import time from prometheus_client import start_http_server, Gauge loss_gauge Gauge(train_loss, current training loss) accuracy_gauge Gauge(train_accuracy, current training accuracy) start_http_server(9100) steps 200 for step in range(1, steps 1): loss 0.9 ** step accuracy 1 - 0.9 ** step loss_gauge.set(loss) accuracy_gauge.set(accuracy) print(fstep {step}/{steps}, loss{loss:.6f}, accuracy{accuracy:.6f}, flushTrue) time.sleep(2) print(training finished, flushTrue)注意9100这个端口我后面会用它作为指标端口。Dockerfile也一并放出来FROM tensorflow/tensorflow:2.16.1 RUN pip install prometheus-client WORKDIR /app COPY train.py /app/train.py CMD [python, /app/train.py]构建并推送到你的镜像仓库docker build -t harbor.example.com/ml/train-demo:v1 . docker push harbor.example.com/ml/train-demo:v1这里解释一下为什么选tensorflow官方2.x镜像做base它内置了TensorFlow和Python环境不需要额外处理CUDA、cuDNN这些依赖在CPU节点上跑起来零障碍。如果你的训练任务要上GPU后续可以换成带GPU Tag的镜像或者用NVIDIA的TensorFlow容器镜像。3. 第一个TFJob从提交到Succeeded3.1 一份最小TFJob清单拆开看每个字段先给一份能直接提交的TFJob清单所有字段都控制在最简apiVersion: kubeflow.org/v1 kind: TFJob metadata: name: tfjob-train-demo spec: runPolicy: cleanPodPolicy: All ttlSecondsAfterFinished: 600 tfReplicaSpecs: Worker: replicas: 1 restartPolicy: OnFailure template: metadata: labels: app: train-demo spec: containers: - name: tensorflow image: harbor.example.com/ml/train-demo:v1 ports: - name: metrics containerPort: 9100 resources: requests: cpu: 1 memory: 2Gi limits: cpu: 4 memory: 8Gi priorityClassName: offline-training字段拆开看runPolicy.cleanPodPolicy任务结束后Pod怎么处理。All表示任务结束直接删PodRunning表示只保留处于Running状态的PodNone表示全部保留。日常用All最省心不然训练完一堆Succeeded的Pod占着资源位看着烦还影响调度。runPolicy.ttlSecondsAfterFinished任务结束后多久自动删除TFJob对象。设个600秒训练完Controller会自动把TFJob和关联Pod清干净。tfReplicaSpecs.Worker这是核心表示这个训练任务里的Worker角色。replicas设为1先跑通单节点后面要分布式再扩。containers[0].name容器名字必须是tensorflow。这不是我拍脑袋要求的tf-operator在识别训练容器时和这个名字有约定起别的名字容器可能会一直起不来。ports[0].name端口名设为metrics后面PodMonitor通过这个端口名抓指标。priorityClassName先放这里后面讲混部资源策略时详细说。3.2 提交任务并观察状态流转提交非常简单kubectl apply -f tfjob-train-demo.yaml然后查看TFJob状态kubectl get tfjob tfjob-train-demo第一次你会觉得很无聊因为affect上就只有一行NAMESTATEAGE这些。想看细节用describekubectl describe tfjob tfjob-train-demo正常情况下你会看到这样的状态流转Pod被创建处于Pending状态。调度器正在找一个能满足request1核2Gi的节点。Pod调度成功进入ContainerCreating。镜像开始拉取。Pod进入Running。训练脚本启动9100端口开始输出指标。训练结束Pod状态变成Succeeded。因为cleanPodPolicy设置的是AllPod随后被删除TFJob在ttlSecondsAfterFinished过后也被清掉。整个过程如果顺利十分钟之内你能看到Succeeded。如果你的镜像仓库是私有的记得提前在namespace里创建imagePullSecret或者在Pod模板里用imagePullSecrets字段引用不然镜像永远拉不下来。3.3 分布式训练时TF_CONFIG是怎么生效的单Worker跑通后很多人会好奇如果把replicas改成2代码要不要改答案是如果你的训练代码用的是TensorFlow原生的分布式策略那基本不用改。tf-operator会为每个副本计算并注入TF_CONFIG环境变量里面有当前角色的cluster信息{ cluster: { worker: [tfjob-train-demo-worker-0:2222, tfjob-train-demo-worker-1:2222] }, task: { type: worker, index: 1 } }你的训练脚本里只需要调用import tensorflow as tf tf.distribute.experimental.MultiWorkerMirroredStrategy()框架就会自动根据TF_CONFIG构造集群。这也是tf-operator最值钱的地方把分布式训练的网络通信、角色发现、地址注入这些脏活都干完了你不需要自己维护一套etcd或者redis来做节点发现。但GC不对走到这里我们已经能用。如果分布式训练不出效果通常问题出在训练代码没有用MultiWorkerMirroredStrategy或者脚本里手写了集群IP导致和TF_CONFIG冲突。建议第一次做分布式扩Worker时先把单Worker跑通再改成2个Worker日志里出现“Hello from worker”之类的输出再往前推进。4. 让训练任务真正“混”进在线集群调度与资源策略4.1 PriorityClass训练任务必须“认怂”在线集群里有高优先级的线上服务混部时训练任务如果和线上服务平级调度器在节点资源不足时就会两个都调度不上去或者情况更糟——节点内存被打爆kubelet开始按优先级驱逐最后线上服务被误杀。所以离线训练任务必须有一个较低的PriorityClass。先创建一个apiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: name: offline-training value: 100 globalDefault: false description: 离线训练任务优先级低于在线服务在线服务如果按常规设置默认优先级0那离线训练的优先级反而比它低这不对。更合理的做法是给在线服务单独设一个高优先级PriorityClass比如300离线训练设100两者差距拉开。这样节点资源紧张触发抢占时调度器会优先把低优先级的离线Pod抢占掉把资源腾给在线服务。不过有个隐含风险如果离线训练被频繁抢占任务长时间无法完成。解决办法是配合checkpoint机制训练脚本每隔一段时间保存模型权重被抢占后重启能从最近检查点恢复。混部不是让任务“能跑就行”而是要让任务在反复被抢占的情况下最终能跑完这个一定要提前设计。4.2 request/limit的配比训练任务适合BurstableKubernetes里Pod按资源声明分成三类QoSGuaranteedrequest等于limit、Burstablerequest小于limit、BestEffort不设置request和limit。混部场景对这三类的态度非常明确类型资源声明调度保证离线程建议Guaranteedrequestlimit资源和声明完全一致节点上不容易被驱逐不推荐用于混部的离线任务资源浪费Burstablerequest limit保证request部分有机会用到limit部分推荐既能保证训练基础资源又能吃在线服务剩余资源BestEffort无request/limit无任何保证节点任何波动都可能被杀不推荐虽然最大程度利用空闲资源但训练中断概率太高离线训练任务我给的建议是request设一个“保底值”保证训练能启动不会饿死limit设一个“上限值”但别超过节点的可分配资源。比如一台节点16核32G在线服务预留了8核16G离线训练的request可以写1核2Gilimit写8核16G。这样训练Pod在调度时只需要找到一个节点的“剩余request”能满足1核2Gi就行而运行过程中它可以趁在线服务不在峰值时吃到更多CPU。但注意CPU和内存两个资源性质完全不同。CPU是可压缩资源limit限制的是CPU时间片超过limit只是变慢内存是不可压缩资源一旦超过limit直接OOM Kill没有商量空间。所以内存的limit务必保守最好根据训练实际用量观察几天监控数据再定不要拍脑袋给一个很大的值。4.3 GPU节点与标签调度训练任务最常见的诉求是GPU。混部场景下GPU很特殊它没有CPU那种“压缩资源时间片”的概念一块卡就是一块卡分不了。所以GPU节点的混部策略通常是把GPU节点单独划出来或者把GPU节点上的CPU内存给在线服务用但GPU卡只给离线训练。做法是给GPU节点打标签kubectl label node gpu-01 acceleratornvidia-tesla-t4TFJob的Pod模板里加上nodeSelectorspec: nodeSelector: accelerator: nvidia-tesla-t4同时确认节点上装了NVIDIA device plugin可以用这个命令验证kubectl get nodes -l acceleratornvidia-tesla-t4 -o custom-columnsNAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu如果显示nvidia.com/gpu1这类值说明device plugin工作正常TFJob里就能声明nvidia.com/gpu: 1。我见过很多GPU任务一直Pending原因根本不是模型和代码而是device plugin没装或者版本和驱动不匹配这种问题排查起来让人头秃。4.4 从监控数据反推资源配比混部资源配比不是一次就能定死的必须靠监控数据迭代。比如你给训练任务request配了1核2Gilimit配了8核16G运行一周后看grafana发现训练Pod的CPU使用率长期在5%-10%之间那就说明给少了节点上其实还有大量空闲CPU没被利用反过来如果长期在80%以上说明limit反而成了瓶颈任务在排队等CPU可以适当把limit往上调。内存也是同样的逻辑重点看container_memory_working_set_bytes和limit的差距。如果working set长期顶着limit说明训练任务要么内存泄漏要么数据批次开太大需要处理否则被OOM Kill是迟早的事。混部做得好不好指标就一个节点的平均资源利用率相比混部前是否显著提升同时在线服务SLA是否保持稳定。这两条曲线可以在grafana上放同一个面板里对比着看数据会决定你的资源配比应该往哪个方向调整。5. Prometheus侧监控体系怎么搭5.1 快速铺一套kube-prometheus-stack监控体系不建议自己零散搭直接用kube-prometheus-stack全家桶最省事。它自带prometheus、grafana、alertmanager、node-exporter、kube-state-metrics全部预配置好安装完就能用helm repo add prometheus-community https://prometheus-community.github.io/helm-charts helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \ --namespace monitoring --create-namespace这套东西装完会有一个Prometheus实例、一个Grafana还有一系列CRD比如ServiceMonitor和PodMonitor。PodMonitor就是用来抓Pod级别指标的后面我们要抓训练任务的自定义指标全靠它。装完以后用kubectl get pods -n monitoring确认每个组件都Running。这里有个提示kube-prometheus-stack的版本和k8s版本有对应关系版本不对会出现Prometheus起不来或者Grafana拿不到数据的问题。官方README有兼容性矩阵装之前先对一眼。5.2 从自助发现到PodMonitorPrometheus要抓取目标本质上需要三个信息目标地址、抓取路径、抓取频率。在k8s里Pod是动态创建销毁的所以不能写死IP必须做服务发现。kube-prometheus-stack默认有一个kubernetes-pods的抓取任务它会扫描所有带prometheus.io/scrape: true注解的Pod。这是最简单的方式只要在TFJob的Pod模板里加注解metadata: annotations: prometheus.io/scrape: true prometheus.io/port: 9100但这种方式比较脆弱注解拼错一个字母就静默失效而且不方便控制抓取频率。我更推荐用PodMonitor显式声明要抓哪些PodapiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: name: train-demo-monitor namespace: monitoring spec: selector: matchLabels: app: train-demo podMetricsEndpoints: - port: metrics interval: 15s注意selector匹配的是Pod上的label所以我之前在TFJob里特意加了app: train-demo这个标签。PodMonitor一提交Prometheus会在几十秒内自动发现新的抓取目标你可以在Prometheus的Targets页面看到这个目标处于UP状态。5.3 离线任务的关键指标和PromQL监控指标分三层看节点层、Pod层、业务层。节点层主要看节点是否被打满这直接决定混部是否安全# 节点CPU使用率 100 - (avg by (instance) (rate(node_cpu_seconds_total{modeidle}[5m])) * 100) # 节点内存使用率 (1 - sum by (instance) (node_memory_MemAvailable_bytes) / sum by (instance) (node_memory_MemTotal_bytes)) * 100Pod层看训练任务实际吃了多少资源和request、limit之间的关系# 训练任务Pod的CPU使用量 sum(rate(container_cpu_usage_seconds_total{namespaceml}[5m])) by (pod) # 训练任务Pod的内存使用量 sum(container_memory_working_set_bytes{namespaceml}) by (pod)业务层就是训练脚本暴露出来的指标train_loss train_accuracy业务层指标能直接看出训练任务是不是卡住了。如果loss曲线长时间不动训练大概率有问题这时候需要人工介入。除了这几个基础查询混部场景我建议配置一条告警训练任务长时间没有产生新指标。用absent函数或者对指标做增量判断都行这是防止“任务看似在跑其实早已死掉”的最有效手段。6. Grafana看板混部效果的“仪表盘”6.1 用自带面板先看节点水位kube-prometheus-stack装好之后自带了几套还算能看的面板尤其是“Kubernetes / Compute Resources / Node”这个节点CPU、内存、网络、存储使用率一应俱全。打开Grafana左上角Search里搜“Node”就能找到。我建议先把自带的节点资源面板固定到常用文件夹这是混部的第一道防线。节点水位一旦超过80%就要开始警惕超过90%基本属于危险区随时可能触发驱逐或OOM。去看页面上的曲线时注意区分“申请量request”和“实际使用量”。Kubernetes调度只看request节点资源被打满的罪魁祸首往往是实际使用量远超request。混部场景里在线服务的request设置如果偏高节点虽然“名义上”满了实际却很闲如果偏低节点很忙但调度器还一个劲往里塞Pod。这两种都要通过面板数据识别出来。6.2 自建训练任务面板自带的节点面板解决不了训练任务的可观测性问题你需要自建一个Dashboard。新建Dashboard填上几个关键PanelPanel 1训练任务CPU使用率。Query:sum(rate(container_cpu_usage_seconds_total{pod~tfjob-.*}[5m])) by (pod)Panel 2训练任务内存使用率。Query:sum(container_memory_working_set_bytes{pod~tfjob-.*}) by (pod)Panel 3训练进度。Query:train_loss把这三个Panel放到一行就能在训练过程中随时确认“资源吃多少、训练有没有进展”。我习惯再叠加一条告警阈值线比如loss超过某个值就标红这样扫一眼面板就知道当前任务状态不用一条条日志去翻。6.3 混部场景下重点盯哪几条曲线混部场景重点盯四条曲线在线服务P99延迟或错误率。这是混部的“红线”一旦恶化说明离线任务资源策略要收紧。节点CPU和内存水位。水位稳定在70%-85%之间是比较理想的状态意味着在线服务和离线任务基本把资源吃透了又没冲到危险区。训练Pod的CPU使用量和request的对比。如果长期低于request说明request配大了可以缩。训练任务自己的loss/accuracy曲线。这条曲线是判断任务收益的如果一直没有下降趋势即使资源利用率很高跑出来的模型也没价值。所谓混部看板本质上就是把“在线稳定性”和“离线收益”放到同一个屏幕上。我见过不少团队只盯着节点利用率把节点干到95%结果在线服务延迟爆表也没人管等用户投诉来了才开始找原因。这个顺序一定不能搞反。7. 混部实战后复盘踩坑记录与效果验证7.1 坑一训练结束Pod不清理静默占资源第一次跑TFJob时我没设置cleanPodPolicy任务Succeeded之后Pod一直留在节点上。表面上看起来没什么但它占用的CPU和内存是真正被“预留”走的——不对准确说它作为已完成Pod对节点资源的实际占用已经没了但它的request仍然被kubelet记录在节点分配里。调度器会认为这台节点还欠着一笔资源后续新Pod调度不进来莫名其妙就“资源不够了”。排查这种问题最直观的办法是看节点状态kubectl describe node node-name | tail -20如果发现已分配的CPU和内存明显偏高但实际运行的Pod又没那么多八成是残留Pod在作祟。解决办法就是一开始把cleanPodPolicy设为All或者设ttlSecondsAfterFinished让它自动过期别等出了问题再手工清理。7.2 坑二内存limit拍脑袋在线服务被连坐我把训练任务的内存limit设成16G心想训练任务多吃点没关系。结果训练跑起来节点内存被打到临界值kubelet直接触发OOM Killer把节点上几个在线服务Pod杀了线上短暂不可用。这个教训很深刻内存是不可压缩资源limit设得越高Pod能触碰到的内存天花板就越高对节点的冲击越猛。后来我们把训练任务内存limit调下来同时给在线服务设置了比之前更精准的request和内存limit节点水位才稳定。内存配比一定要基于监控数据逐步调整别贪心。7.3 坑三GPU任务调度不上节点GPU任务提交后Pod一直Pendingkubectl describe Pod看到的是“0/1 nodes are available: 1 Insufficient nvidia.com/gpu”但节点上明明有GPU。最后发现两块问题一是NVIDIA device plugin的版本和驱动不匹配导致插件崩溃GPU根本没上报到kubelet二是Pod模板里的nodeSelector标签写成了gpu: nvidia但节点上的标签实际是acceleratornvidia-tesla-t4匹配不上。排查GPU问题的顺序建议是先看节点是否可分配GPU再看Pod的调度条件是否满足最后看device plugin日志。顺序不对容易绕远路。7.4 效果验证的一种朴素方案混部效果到底好不好我用一个很朴素的方式验证挑一台混部节点记录混部前一周的CPU平均利用率和内存平均利用率然后把一个在线Demo服务和两个训练任务同时调度上去再跑一周对比同一个节点的利用率曲线。我测下来的结果CPU利用率从15%左右提升到了50%上下内存利用率也明显改善在线Demo服务的响应延迟没有明显恶化。这就是混部达到的基本目标在不牺牲在线质量的前提下把闲置资源挖出来给离线任务用。如果你的混部效果和预期差距很大大概率是request配比、PriorityClass或者监控数据里的某个环节没做对回头逐项排查。这套方案的好处是验证成本低、结论直观也给后续更大范围的混部提供了基础数据支撑。最后再分享一个运维层面的小习惯我会在推任何TFJob之前先看一眼当前集群的可用request资源。用kubectl describe node把几个关键节点扫一遍心里有个数再提交任务能省掉不少“为什么Pending”的排查时间。训练任务上到生产后给TFJob的apply过程加一层干run校验把这份YAML和上一次成功的版本做diff避免手滑把坏配置推进集群。混部这套东西难得不是跑起来而是让它一直在安全的边界内跑得稳稳当当。