尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

K8s混部实战:Kubeflow tf-operator调度TensorFlow离线训练

K8s混部实战:Kubeflow tf-operator调度TensorFlow离线训练 先说个项目背景。最近部门里在线推理服务和离线训练任务挤在同一批K8s节点上机器白天被在线服务吃满晚上流量降下来CPU和GPU大片闲置。为了把夜间算力用起来我把一批TensorFlow离线训练任务通过kubeflow的tf-operator调度进集群同时接了prometheus和grafana把资源水位拉成可视化面板。整套方案跑下来晚间算力利用率从不到30%拉到了70%以上在线服务的P99延迟基本没受影响。这篇文章就把整个部署过程中的思路、踩坑和可复现步骤完整写出来给同样在做离线混部或打算上Kubeflow的朋友做个参考。1. 场景梳理与方案选型1.1 离线任务混部到底解决什么问题先把“混部”这个词讲清楚。传统K8s集群里在线业务和离线任务通常是分开集群部署的中间用物理隔离来保障互不干扰。但代价很直接在线集群晚高峰之后大量资源闲置离线集群又经常因为资源不够而要排队。混部就是让离线任务去填充在线集群的空闲资源在“不挤占在线服务质量”的前提下把整集群利用率抬起来。机器学习训练任务是比较典型的离线负载它可以容忍延迟、可以被打断重试、对资源的需求是短时突发型。这些特性天然适合混部。而TensorFlow这种分布式训练任务在K8s上的编排除了最原始的Deployment手动管理之外更顺手的方案是Kubeflow的tf-operator。它把PS、Worker、Chief这些角色抽象成TFJob CRD一个YAML就能拉起一套分布式训练任务省掉大量人工管理Pod集合的负担。1.2 为什么选kubeflow的tf-operator而不是裸写Deployment在第一版方案里我其实先用过裸K8s资源来编排分布式训练三个Deployment分别跑PS、Chief、Worker再用Service做互相发现。能跑但问题很多。最典型的是故障恢复和角色管理一个Worker挂了Deployment只会把Pod拉起来但分布式训练集群的状态可能已经不一致了需要人工介入去协调。还有一个细节是TF_CONFIG环境变量用裸Deployment要每个角色手工拼JSON写到Pod的env里非常容易错。tf-operator解决的就是这些事。它本身就是Kubeflow的核心训练组件之一通过一个叫TFJob的自定义资源来描述TensorFlow分布式训练任务。你只需要在YAML里声明Master、Worker、PS各自几个副本、什么镜像、什么启动命令tf-operator的controller会自动完成三件事一是按角色创建Pod二是自动生成并注入每个Role对应的TF_CONFIG环境变量这是TensorFlow分布式训练的任务发现机制必须要有三是根据restartPolicy自动处理Pod失败重建。第一次体验完这几个能力之后我就没有再考虑裸写Deployment的方案了。另外选tf-operator还有一个实际考量它和Prometheus的集成很自然。tf-operator的controller本身会暴露/metrics端点包括当前训练任务数量、Pod状态变化等指标这些可以直接被Prometheus抓走做训练任务的调度监控。而底层的GPU利用率、CPU内存水位则由节点级的exporter提供。两套数据合起来正好覆盖“任务级”和“资源级”两个维度的观测。1.3 整体架构与组件职责整套系统的组件分成三层第一层是K8s集群本身负责Pod调度、资源分配和服务发现。这一层需要提前做好节点标签和资源预留的规划尤其是要区分哪些节点可以跑离线任务、在线服务核心节点要不要打污点这些直接影响混部的隔离效果。第二层是Kubeflow训练组件核心就是tf-operator。它负责监听TFJob的创建、更新和删除并驱动底层的Pod、Service对齐到应有的状态。我们在2024年之后的版本里直接用kubeflow/tf-operator的独立部署方式不需要装一整套Kubeflow平台只把training-operator装好就够用。第三层是监控体系。Prometheus负责采集三类指标kubelet内置的cadvisor容器指标、node-exporter的节点指标、tf-operator的controller指标。Grafana做可视化把节点水位、训练任务状态、GPU利用率拼成一张大屏。混部场景下监控不是可选项没有监控我根本不敢开混部。2. 环境准备与基础组件部署2.1 Kubernetes集群的版本与节点规划先交代一下我实际用的环境版本这非常重要因为不同版本的K8s对CRD和调度器的支持差异很大照着别的教程抄作业时版本不一致会踩很多隐性坑。我这边K8s集群版本是1.28节点配置分成三类在线服务节点4台16C64G每台带1张A10 GPU这批节点给在线推理服务用打了标签roleonline并且加了污点onlinetrue:NoSchedule防止离线任务误调度上来。混部节点6台32C128G每台带2张A10 GPU这批节点是离线训练的主战场标签rolemixed不设置污点允许在线和离线Pod都调度上来。监控节点2台8C32G不承载业务负载只跑Prometheus和Grafana。在做节点规划的时候有一个细节值得单独说混部节点上不需要把在线和离线任务用nodeSelector硬隔离因为混部的意义就是共享。真正的隔离要靠两件事一是资源配额ResourceQuota和LimitRange二是优先级PriorityClass。在线Pod属于高优先级离线训练任务属于低优先级当节点资源紧张时K8s调度器会优先保证高优先级Pod的调度请求必要时驱逐低优先级Pod来腾资源。这是K8s原生能力不用额外装组件。2.2 部署tf-operatortraining-operator部署tf-operator的方式在Kubeflow官方文档里有两条路一条是装整套Kubeflow一条是单独部署training-operator。混部场景下我强烈建议单独部署原因很直接装整套Kubeflow会带进来notebook-controller、katib、pipeline等一大批组件对离线训练来说大部分用不上白占资源不说还增加了排障时的问题域。单独部署的操作非常简单。从GitHub上拉取manifest仓库切到和你的K8s版本匹配的release分支然后直接applygit clone https://github.com/kubeflow/training-operator.git cd training-operator git checkout v1.7.0 make deploymake deploy做的事情本质上是两件一是创建CRDCustomResourceDefinition把TFJob、PyTorchJob这些资源类型注册进K8s二是创建training-operator的Deployment跑起controller。部署完之后验证一下kubectl get crd | grep kubeflow.org kubectl -n kubeflow get pods | grep training-operator正常情况下能看到类似tfjobs.kubeflow.org这样的CRD以及一个名为training-operator的Pod处于Running状态。这里有个细节要注意不同版本的training-operator使用的命名空间可能不一样有些版本是kubeflow有些是training-operator。我用的v1.7.0版本默认装到kubeflow命名空间下如果找不到就检查一下部署清单里的Namespace定义。2.3 镜像仓库配置与镜像拉取因为是在离线环境里集群不能直接访问外网镜像仓库所有基础镜像都要提前推到内网Harbor。这一步虽然机械但踩坑率很高。我这边涉及的镜像主要有三个tensorflow/tensorflow:2.11.0-gpuHmm实际上这个是CPU版我后来换了nvidia/cuda:11.8-cudnn8-runtime-ubuntu20.04做底再加TensorFlow 2.11的pip包打自定义镜像。quay.io/prometheus/node-exporter:v1.6.0grafana/grafana:10.2.3自定义训练镜像的Dockerfile大致是FROM nvidia/cuda:11.8-cudnn8-runtime-ubuntu20.04 RUN apt-get update apt-get install -y python3 python3-pip RUN pip3 install tensorflow2.11.0 COPY train.py /opt/model/train.py WORKDIR /opt/model ENTRYPOINT [python3, train.py]推镜像之前先把docker login到内网Harbor然后docker tag和docker push。之后在K8s节点上我统一通过配置containerd的registry mirror和Harbor的证书来确保节点能拉到镜像这一步如果没做好后面创建TFJob时会卡在ImagePullBackOff排查起来很费时间。还有一个跟镜像相关的点tf-operator要求Pod内必须有且仅有一个容器叫tensorflow命名是强制的吗严格来说是约定俗成的如果你容器名不叫这个官方示例会不匹配。我的做法是镜像里的容器名就叫tensorflowYAML里通过name字段显式声明避免踩到某些内置逻辑的坑。实际上tf-operator本身不强制容器名但为了日志和后续监控指标对齐统一叫tensorflow最省心。2.4 部署Prometheus与Grafana监控组件的部署我用了kube-prometheus-stack这个Helm chart它一个包就把Prometheus、Alertmanager、Grafana、node-exporter、kube-state-metrics全部装好还有默认的告警规则省掉很多手工配置。helm repo add prometheus-community https://prometheus-community.github.io/helm-charts helm repo update helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \ --namespace monitoring \ --create-namespace \ --version 55.5.1 \ -f kube-prometheus-stack-values.yamlvalues文件里我需要改的关键项有三个第一grafana.adminPassword默认是prom-operator生成的随机密码我在values里固定掉否则后面登录麻烦。 第二prometheus.prometheusSpec.storageSpec给Prometheus挂一块持久化存储我用的是nfs-client的StorageClass不然一重启数据全丢。 第三nodeExporter.hostNetwork我开着hostNetwork让node-exporter直接用主机网络监听方便后续防火墙白名单配置。部署完等几分钟检查一下kubectl -n monitoring get pods正常情况下应该有一堆Running状态的Pod。到这里基础组件都齐了下一步就可以正式跑起来一个TensorFlow训练任务了。3. 通过TFJob拉起TensorFlow分布式训练3.1 TFJob的YAML结构与关键字段解析Kubeflow的TFJob结构不复杂但有几个字段的语义必须搞清楚不然生产环境要出事。核心结构是spec.tfReplicaSpecs下面按角色分Master、Worker、PS三个子配置每个子配置里有replicas、restartPolicy和template三个核心字段。我实际用的一个分布式训练TFJob长这样apiVersion: kubeflow.org/v1 kind: TFJob metadata: name: tf-mnist-dist namespace: training spec: tfReplicaSpecs: Master: replicas: 1 restartPolicy: OnFailure template: spec: containers: - name: tensorflow image: harbor.example.com/ml/tf-mnist:2.11.0 command: - python3 - /opt/model/train.py resources: requests: cpu: 2 memory: 4Gi limits: cpu: 4 memory: 8Gi Worker: replicas: 3 restartPolicy: OnFailure template: spec: containers: - name: tensorflow image: harbor.example.com/ml/tf-mnist:2.11.0 command: - python3 - /opt/model/train.py resources: requests: cpu: 2 memory: 4Gi limits: cpu: 4 memory: 8Gi先说Master。在TensorFlow分布式训练里Master也叫Chief负责协调调度它要做的事情比较多但在tf-operator里Master的配置就是普通Pod不需要额外暴露什么端口。tf-operator会自动在每个角色的Pod里注入一组环境变量其中最关键的是TF_CONFIG这个变量描述的是当前Pod的角色、集群中所有节点的地址列表。例如Master角色的Pod里TF_CONFIG大致长这样{ cluster: { master: [tf-mnist-dist-master-0.tf-mnist-dist.training.svc:2222], worker: [ tf-mnist-dist-worker-0.tf-mnist-dist.training.svc:2222, tf-mnist-dist-worker-1.tf-mnist-dist.training.svc:2222, tf-mnist-dist-worker-2.tf-mnist-dist.training.svc:2222 ] }, task: { type: master, index: 0 } }这些Service地址的域名格式是{job-name}-{role}-{index}.{job-name}.{namespace}.svc。这个自动注入机制是tf-operator的核心价值也是为什么我不愿意手工写Deployment的根本原因。再说restartPolicy。TFJob支持三个值Always、OnFailure、Never。在离线训练混部场景下我的建议是OnFailure。如果设成AlwaysPod退出码是0正常结束也会被重启训练任务完成后控制器还会拉起新的Pod非常浪费资源。OnFailure则只在Pod失败退出时重建正常结束后TFJob状态会变成Succeeded。Worker失败时OnFailure策略也会让该Worker被重建并且配合K8s的backoff机制避免无限重启。3.2 混部场景下的资源Reservation与调度细节混部场景下TFJob的资源声明不能随手填填法直接决定了调度器把训练Pod放哪里。我这里给在线服务设定了一个硬性规则在线Pod的limits必须等于requests因为在线服务对延迟敏感如果request小于limitK8s在节点资源紧张时可能把CPU配额压缩到request值服务性能就会劣化。而离线训练任务我故意把requests调低、limits调高比如一个训练Pod可以request 2C4G、limit 4C8G。这样做的意图是调度器按request值来安排Pod落点让训练Pod尽可能多地挤进同一台机器等到真正运行时如果机器有空闲CPU训练Pod可以飙到limit上限把空闲资源用满而在线服务因为用的是高优先级且requestlimit不会被抢走份额。但这里有一个很关键的坑CPU是可压缩资源限流只影响性能不会导致Pod被杀。而内存是不可压缩资源一旦超过limit会被OOMKilled。所以训练任务的内存request不能压太低我给训练Pod的内存request就按实际使用量评估一般比预估峰值高20%左右来写避免训练跑到一半全被OOM杀掉。另外调度器层面还需要配合PriorityClass来做抢占式调度。我建了两个PriorityClassapiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: name: online-high value: 1000000 globalDefault: false description: 在线服务高优先级 apiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: name: offline-batch value: 100000 globalDefault: false description: 离线训练低优先级在线服务Pod的priorityClassName设为online-high训练Pod设为offline-batch。当混部节点上没有足够资源给在线Pod时K8s调度器会优先尝试把低优先级的训练Pod驱逐或抢占掉腾出空间给在线Pod。这个机制保证了混部场景下在线服务的稳定性。这里还有一个细节要提醒PriorityClass数值不能乱设要留够差距。如果在线和离线的优先级只差100那么多个离线Pod的总优先级可能压过在线Pod的优先级抢占逻辑就会出问题。我的经验是至少差一个数量级。3.3 创建训练任务并观察状态用命令行创建TFJob并观察状态kubectl apply -f tf-mnist-dist.yaml kubectl get tfjob tf-mnist-dist -n training刚创建完状态一般是Created然后变成Running。查看Pod列表kubectl get pods -n training -l training.kubeflow.org/job-nametf-mnist-dist这里要注意tf-operator给Pod打的标签键是training.kubeflow.org/job-name不是app或者job-name按照这个标签过滤就能看到所有归属于这个TFJob的Pod。正常情况下你会看到类似tf-mnist-dist-master-0、tf-mnist-dist-worker-0/1/2四个Pod都处于Running状态。如果有Pod处于Pending大概率是资源不足或镜像拉取问题往下看监控面板能很快定位。训练过程中查看日志kubectl logs -f tf-mnist-dist-worker-0 -n training -c tensorflow由于tf-operator自动注入了TF_CONFIG环境变量训练代码里只要做一件事在代码开头调用tf.distribute.experimental.MultiWorkerMirroredStrategyTensorFlow会自动从TF_CONFIG里读取集群拓扑信息完成角色发现不需要手动拼地址import json import os import tensorflow as tf strategy tf.distribute.experimental.MultiWorkerMirroredStrategy()这个策略会自动读取环境变量里的集群信息和当前任务信息完成worker之间的通信协商。如果你的训练代码里没有用任何分布式策略只是普通单机训练那么即使你用TFJob起了多个Worker它们也只是各自为战并没有真正组成分布式训练。这一点是我的一个重要提醒TFJob只负责拉起和管理角色Pod一个训练任务是不是真正的分布式训练取决于代码里有没有配置对应的分布式策略。3.4 PS模式的配置示例除了上文的Worker-only模式TensorFlow还有一种经典的角色模型PSParameter Server Worker。PS角色负责维护模型参数Worker负责计算梯度并异步推送。这种模式在超大模型或异构带宽场景下仍然有用。PS模式的TFJob配置也很直接在tfReplicaSpecs里加一个PS条目即可spec: tfReplicaSpecs: Master: replicas: 1 ... Worker: replicas: 2 ... PS: replicas: 2 restartPolicy: OnFailure template: spec: containers: - name: tensorflow image: harbor.example.com/ml/tf-mnist:2.11.0 command: - python3 - /opt/model/train.py resources: requests: cpu: 1 memory: 2Gi limits: cpu: 2 memory: 4GiPS角色不需要GPU资源但需要相对稳定的网络和内存。混部时我会把PS和Worker的亲和性做一下区分比如给PS加nodeSelector指定到CPU充足的节点避免和大量GPU Worker挤在一起互相抢内存。4. 监控体系搭建与Grafana可视化4.1 Prometheus采集路径与方法如果前面用kube-prometheus-stack部署成功那Prometheus实际上已经自动配置好了三套服务发现节点监控通过node-exporter暴露/metrics采集CPU、内存、磁盘、网络等节点级指标。容器监控通过kubelet内置的cadvisor路径/metrics/cadvisor采集每个容器的CPU、内存、网络、磁盘IO指标。这个是观察训练Pod资源使用情况的核心数据源。Kubernetes对象监控通过kube-state-metrics暴露的指标采集Deployment、Pod、Service等资源对象的状态信息比如Pod数量、重启次数等。你可以在Grafana的Explore页面直接用PromQL验证采集是否正常。几个我常用的验证指标查询节点CPU使用率100 - (avg by (instance) (rate(node_cpu_seconds_total{modeidle}[5m])) * 100)查询容器内存使用量container_memory_working_set_bytes{namespacetraining, containertensorflow}查询容器CPU使用量sum by (pod) (rate(container_cpu_usage_seconds_total{namespacetraining, containertensorflow}[5m]))关于container过滤条件我补充一句cadvisor指标里container字段的值取决于Pod的容器名如果之前TFJob里的容器名是tensorflow那过滤条件就要写containertensorflow。如果你不写这个过滤条件查询会混入Pause容器和容器运行时组件的指标导致数值翻倍。这是我实际遇到过的一个坑一开始没过滤时看到CPU使用量接近200%以为是数据采错了后来才发现是没过滤容器名。4.2 GPU监控指标与DCGM ExporterTensorFlow训练任务混部场景里GPU利用率是最核心的指标。光靠cadvisor纳管不了GPU需要额外部署NVIDIA的DCGM Exporter。DCGMData Center GPU Manager是NVIDIA官方的GPU监控工具Exporter会把GPU利用率、显存使用、温度、功耗等指标暴露成Prometheus格式。这些指标采集的核心是DCGM_FI_DEV_GPU_UTIL和DCGM_FI_DEV_FB_USED显存使用量等。部署DCGM Exporter我用的是官方Helm charthelm repo add gpu-helm-charts https://nvidia.github.io/dcgm-exporter/helm-charts helm install dcgm-exporter gpu-helm-charts/dcgm-exporter \ --namespace monitoring \ --version 3.3.0部署完成后在Prometheus采集到的核心指标DCGM_FI_DEV_GPU_UTILGPU利用率百分比范围0-100DCGM_FI_DEV_FB_USED显存使用量MiBDCGM_FI_DEV_POWER_USAGEGPU功耗瓦DCGM_FI_DEV_DEC_UTIL解码器利用率有了这些指标就可以在Grafana画一张GPU监控面板按节点或按Pod维度来看GPU水位。关于按Pod维度看GPU需要说明目前DCGM Exporter默认是按设备维度曝指标的想看到某个Pod用了哪块GPU需要结合nvidia-smi的Pod映射信息或者部署NVIDIA K8s Device Plugin的附属组件。一般我按节点维度看GPU利用率就够用了——混部场景下我们需要知道的是“哪台机器的GPU闲着”而不是“哪个Pod在用”后者训练任务自己能看到。4.3 Grafana面板导入与重点图表配置Grafana装好之后默认是没有数据的需要先添加数据源。在Grafana界面里点到Configuration → Data Sources → Add data source选PrometheusURL填http://kube-prometheus-stack-prometheus.monitoring.svc:9090这是在集群内部访问Prometheus服务的方式保存并测试显示成功即可。接下来是面板导入。Grafana支持从dashboard id直接导入官方社区面板对于K8s容器监控和节点监控我常用这几个面板面板名称面板ID用途Node Exporter Full1860节点级CPU/内存/磁盘/网络全面监控Kubernetes cluster monitoring315集群资源总览Pod状态、容器重启Kubernetes Views15756资源对象状态命名空间维度导入过程Dashboards → Import填入面板ID选择Prometheus数据源点Import即可。对于混合部场景我需要重点看两个自定义图表。第一个是“混部节点CPU分配率”用来评估节点资源有没有被充分利用PromQLsum(kube_pod_container_resource_requests_cpu_cores) by (node) / sum(kube_node_status_allocatable_cpu_cores) by (node)这个指标的含义是节点上所有Pod的CPU Request总和占节点可分配CPU的比例。由于混部场景下低优先级Pod的request可能填得很低这个比例通常不能反映真实使用情况所以要再看一个“节点CPU实际使用率”做对照100 - (avg by (instance) (rate(node_cpu_seconds_total{modeidle}[5m])) * 100)这两个CPU指标放在同一张图里就能直观地看到“调度水位”和“真实使用水位”的差距。如果调度水位低但实际使用高说明资源配额设置偏保守可以适当调大Requests。第二个是“训练任务GPU利用率热力”用DCGM指标画成按节点排列的热力图能一眼看出哪些GPU利用率高、哪些在空闲对于混部调度策略调整很有帮助。我用的是Grafana的State timeline图按GPU设备分组展示DCGM_FI_DEV_GPU_UTIL值颜色从绿到红映射0到100%。4.4 告警规则配置监控不只是看面板更重要的是告警。混部场景下我最关心的两个告警是第一节点资源水位过高。当混部节点CPU真实使用率持续超过85%超过5分钟说明可能需要减少离线任务调度或者在线服务有突发流量。我配置的PrometheusRule规则片段apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: name: mixed-node-alerts namespace: monitoring spec: groups: - name: mixed-node.rules rules: - alert: NodeCPUHigh expr: | (100 - (avg by (instance) (rate(node_cpu_seconds_total{modeidle}[5m])) * 100) 85) for: 5m labels: severity: warning annotations: summary: 节点CPU使用率过高 description: {{ $labels.instance }} CPU使用率超过85%持续5分钟第二训练任务卡住或失败。tf-operator暴露的指标里有TFJob的状态变化配合kube-state-metrics的Pod状态可以做到训练Pod长时间Pending或不断重启时触发告警- alert: TrainingPodPendingLong expr: | (kube_pod_status_phase{phasePending} 0) for: 30m labels: severity: warning annotations: summary: 训练Pod长时间Pending description: Pod {{ $labels.pod }} 处于Pending状态超过30分钟之所以把Pending告警时间设到30分钟是因为低优先级Pod在混部节点上可能因为在线服务抢占而短暂Pending。如果5分钟就告警半夜会收到一堆骚扰消息。5. 在线与离线混部的关键配合策略5.1 在线服务的Pod规格保护混部期间最怕的事情是训练任务把在线服务的资源挤爆。虽然前面用了PriorityClass但实际运行中还是有几个细节会影响隔离效果。第一个细节是CPU绑核。在线服务如果是高QPS的CPU密集型应用建议让在线Pod的CPU管理策略走static静态绑核这样K8s会给Pod分配一整颗物理核核心之间的干扰会小很多。混部节点上的kubelet需要配置--cpu-manager-policystatic并且在线Pod的CPU request必须是整数。我在在线服务的Deployment里把CPU request改成8让K8s给它绑8颗物理核训练任务只能使用剩下的CPU。实测下来在线服务的P99延迟从2.1ms变成了2.3ms基本无感。第二个细节是内存预留。对于内存来说即使有PriorityClassOOM杀进程还是会发生。我的做法是在混部节点上给在线服务设置内存limit时留足余量并且在节点层面预留一部分内存给系统本身。通过kubelet的--system-reserved参数预留比如systemReserved: cpu: 500m memory: 4Gi这4G内存不会被业务Pod抢走留给内核、日志采集等系统组件。如果这一步不做训练任务吃满内存时可能会导致系统OOM把整台机器搞挂后果非常严重。第三个细节是网络带宽和连接数。混部场景训练任务会大量拉取镜像、做分布式通信在线服务对网络延迟敏感。我这边用NetworkPolicy限制离线训练Pod只能访问训练命名空间内的Service和镜像仓库地址减少它对在线服务网络路径的干扰。5.2 离线训练任务的弹性调度与驱逐策略混部场景下离线任务被驱逐是常态所以训练代码必须支持断点续跑。所谓断点续跑就是定期保存checkpoint重启之后从最近一次保存的checkpoint恢复训练而不是从头开始。TFJob本身不会帮你做checkpoint它只管Pod生命周期。我用的是TensorFlow的tf.train.CheckpointManager来做checkpoint保存。最简单的做法是每个Worker定期保存自己的checkpoint到共享存储我用的是NFScheckpoint_dir /mnt/checkpoints/tf-mnist-dist ckpt tf.train.Checkpoint(modelmodel, optimizeroptimizer) manager tf.train.CheckpointManager( ckpt, checkpoint_dir, max_to_keep3 ) # 每隔1000步保存一次 if step % 1000 0: manager.save()当Pod被驱逐后重启tf-operator会按照restartPolicy重建同一个Pod代码会在启动时检查checkpoint目录是否存在latest tf.train.latest_checkpoint(checkpoint_dir) if latest: ckpt.restore(latest)这个改造做完之后即使训练任务在混部期间被在线服务抢占驱逐好几次也能自己恢复训练不需要人工干预。这算是混部任务能稳定运行的前提条件。5.3 混部效果验证与优化过程整个方案上线后的验证流程我的做法是分三步走第一步先跑一个小的测试任务。用MNIST一个极小模型起1个Master加2个Worker观察任务能否正常调度、训练日志是否正常输出、监控面板能否采集到数据。这一步主要是验证环境不做性能评估。第二步跑一个稍大的真实任务。用一个真实的推荐模型带2个PS加4个Worker持续训练约30分钟同时观察在线服务延迟指标有没有明显波动。这里我会盯三个指标在线服务的P99延迟、CPU steal、整机CPU使用率。如果P99延迟抖动超过10%立即排查训练任务的资源使用。第三步是在夜间流量低谷期放开混部。把混部任务数量从2个增加到6个、10个同时持续跑监控直到确认训练任务和在线服务能稳定共存。稳了一周之后我把混部节点的平均利用率从30%左右拉到了70%以上。这中间还踩了一个关联性的坑训练任务刚放上去的时候在线服务的延迟曲线出现周期性的小毛刺查了半天发现是镜像拉取的时候占满了磁盘IO。训练Pod每次启动要拉镜像镜像比较大同时在线服务在写日志磁盘IO带宽不够互相抢导致在线服务磁盘写入延迟升高。解决办法是把训练镜像在混部节点上提前预热提前ctr images pull把镜像拉到本地后来干脆写了个DaemonSet定期把常用训练镜像拉一遍。这个坑提醒我混部不只是CPU内存的调度问题IO和网络的隔离也一样重要。6. 常见问题与排查技巧实录6.1 TFJob创建后无反应症状执行kubectl apply -f tfjob.yaml之后没有报错但kubectl get tfjob查不到或者查到了但Pod一直没创建。排查思路第一步检查CRD是否注册成功kubectl get crd | grep tfjobs如果没有这个CRD说明training-operator的make deploy没有成功回去检查operator的Pod日志。第二步检查是不是控制器没有监听。有些版本training-operator需要有集群管理员权限去Watch所有命名空间下的TFJob。如果部署的时候RBAC配置不对控制器日志里会刷resource does not exist或forbidden之类的内容。kubectl -n kubeflow logs deployment/training-operator --tail100第三步检查命名空间。TFJob本身是命名空间级别的资源如果你的TFJob YAML里metadata.namespace写了一个不存在的命名空间创建会直接报错。6.2 GPU资源调度失败症状创建TFJob后GPU Worker的Pod一直Pendingkubectl describe pod显示节点不满足GPU资源请求。这个问题的核心在于GPU资源在K8s里是以Extended Resource形式存在的。需要先确认节点上有没有注册GPU资源kubectl describe node node-name | grep nvidia正常情况下能看到类似nvidia.com/gpu: 2的Capacity。如果看不到说明NVIDIA Device Plugin没有安装这个插件负责把GPU资源上报到Kubelet。另一个原因是TFJob的容器资源里没有声明nvidia.com/gpu。如果YAML里只写了CPU和内存调度器当然不会给GPU。我见过很多次这种问题加一行resources: limits: nvidia.com/gpu: 1训练容器就能正常申请到GPU了。这里要注意nvidia.com/gpu只能写limits不能只写requestsK8s在Extended Resource上的语义就是requests值会被对齐到limit。6.3 Pending问题低优先级任务无法抢占高优先级任务占用症状混部节点的资源明明已经被训练Pod占了很多但节点上的在线服务Pod启动时却调度不上去一直Pending。排查后发现原因K8s的PriorityClass抢占机制有一个关键的默认行为——抢占发生时调度器只会尝试驱逐低优先级的Pod但如果低优先级Pod正在执行关键初始化或者有PodDisruptionBudget保护就无法被驱逐。我这边在线服务当时已经被打上PDB要求至少2个副本在线驱逐Pod时需要满足PDB约束。而调度器的抢占过程可能会因为PDB限制而无法驱逐任何低优先级Pod导致在线Pod一直Pending。解决方案是给离线训练任务明确加上PodDisruptionBudget或者干脆不加PDB让它们可以被自由驱逐。还有一点是低优先级Pod要设置terminationGracePeriodSeconds尽量短比如30秒这样被抢占时能快速退出不会拖很久。6.4 Prometheus抓不到cadvisor指标症状Grafana面板里容器的CPU内存指标全是N/Anode-exporter的指标正常但container_*开头的一类指标一个都没有。排查过程先手动在集群里curl一下kubelet的metrics接口curl -k https://node-ip:10250/metrics/cadvisor | head -20如果返回403 Forbidden说明Prometheus使用的ServiceAccount没有足够的RBAC权限去访问kubelet。kube-prometheus-stack默认会给Prometheus创建一个ClusterRole但如果你自定义了values且RBAC被覆盖过就可能缺权限。解法是给Prometheus绑定一个允许访问/metrics/cadvisor的ClusterRoleapiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: prometheus-kubelet-cadvisor rules: - nonResourceURLs: - /metrics/cadvisor verbs: - get还有一种情况是K8s集群开启了Webhook鉴权需要在kubelet配置里把authentication.webhook.enabled打开并确保Prometheus用正确的Token去请求。kube-prometheus-stack会在ConfigMap的prometheus.yml里生成bearer_token_file配置默认指向Prometheus的ServiceAccount Token通常没问题。6.5 Grafana面板导入后数据为空症状Grafana面板导入成功但图形上全是空白看不到任何数据。首先确认面板里的数据源选对了没有导入面板的页面里每张面板都会有一个数据源选择下拉框要选成你配置的Prometheus数据源。然后确认面板里的PromQL是不是适用于你这个K8s版本。比如有些面板用的是老版本的指标名kube_node_status_allocatable_cpu_cores而新版本kube-state-metrics已经改名成kube_node_status_allocatable_cpu_cores或kube_node_status_allocatable_cpu_cores带上了注解。如果指标名对不上直接在Explore页面里试跑一下这个PromQL看能不能返回数据。如果是自定义面板那要从PromQL本身开始查。老规矩先跑一个最简单的时间序列up{jobnode-exporter}如果连这个都没有数据说明Prometheus根本没有采集到node-exporter。进一步检查Prometheus的Targets页面看node-exporter的Endpoint是不是Up状态。6.6 训练任务被OOMKilled后一直重启症状Worker Pod的状态在CrashLoopBackOffkubectl logs里面能看到原始的OOMKilled错误或者kubectl describe pod里写的Exit Code: 137。这不是tf-operator的问题是纯资源声明问题。排查方法是去Prometheus看这个Pod在OOM之前的内存实际使用量峰值用max_over_time(container_memory_working_set_bytes{namespacetraining, podtf-mnist-dist-worker-0}[10m])如果实测内存峰值已经接近你所设置的limit那就把内存limit上调。注意一个关键细节container_memory_working_set_bytes是容器当前占用的工作集内存差不多等于你在docker stats里看到的数值比container_memory_usage_bytes更接近OOM判定的基准。如果这个值快贴到limit了OOM就只是时间问题。另外TensorFlow的分布式训练框架本身有一个内存问题MultiWorkerMirroredStrategy在NCCL通信时会额外分配一些缓冲内存这部分内存是在container_memory_working_set_bytes里看不出来的因为它是共享内存或GPU内存所以给TensorFlow训练容器设内存limit时我通常会在代码里估算的峰值之上再加20%-30%宁多勿少。7. 写在最后混部不是终点整套方案从架设tf-operator到Grafana监控面板跑通前前后后花了一周左右。真正让系统稳定下来靠的不是某一项技术而是把调度策略、资源声明、监控告警和断点续跑这几块拧成一股绳。其中我觉得最容易被忽视的其实是checkpoint续跑机制——混部场景下训练Pod被驱逐不是异常而是常态只有训练代码能优雅地接受这种“业务中断”离线任务才算真正融入了混部体系。最后分享一个小技巧tf-operator的TFJob从状态Succeeded到所有Pod回收之间有一个时间窗口这个窗口内训练的最终日志和checkpoint还在Pod文件系统里如果任务完成时没有及时把结果推到共享存储最后会被回收掉。后来我的做法是在训练代码的收尾阶段主动把模型文件写到NFS挂载目录再加一个kubectl wait --forconditioncomplete tfjob/tf-mnist-dist --timeout1h的CI步骤确保每个任务真正收尾干净才敢放开混部的批量任务量。希望这篇记录对正打算做K8s混部或上Kubeflow训练平台的朋友有帮助。
返回列表