GPU 节点亲和性:推理和训练不能放在同一批节点上

📅 2026/7/14 12:44:37
GPU 节点亲和性:推理和训练不能放在同一批节点上
GPU 节点亲和性推理和训练不能放在同一批节点上一、同一批节点上训练任务和推理服务互相拖垮对方在搭建 AI 平台初期运维团队通常倾向于把所有 GPU 节点归入同一个节点池让 Kubernetes 调度器自行分配。这种简单方案很快暴露出问题训练任务和推理服务共用同一批节点时两类负载的资源使用模式截然不同却共享同一块 GPU 的显存和 PCIe 带宽。训练任务的特点是显存占用固定且持久SM 利用率接近 100%显存带宽持续打满。推理服务则相反显存占用一部分用于模型常驻SM 利用率波动大对延迟敏感。当一个训练 Job 和一个推理 Pod 同时调度到同一节点上时推理请求会出现不可预测的尾延迟——这不是代码的问题是物理资源的争抢。更隐蔽的问题是 GPU 显存碎片化。训练任务释放 GPU 后可能留下多个小块可用显存下一个需要大块连续显存的训练任务将调度失败。基础设施不需要漂亮话混部不是降低成本的捷径它可能是在制造生产事故。二、NodeSelector、Node Affinity 与 Taint/Toleration 的调度链路Kubernetes 的调度器通过过滤Filter和打分Score两个阶段决定 Pod 落在哪个节点上。对于 GPU 工作负载需要利用标签体系将节点按用途分组再通过调度策略将不同工作负载绑定到对应的节点池。graph TD Scheduler[Kubernetes Scheduler] -- Filter[过滤阶段] subgraph 节点池标签体系 TrainNode[训练节点br/gpu-type: trainingbr/workload: batch] InferNode[推理节点br/gpu-type: inferencebr/workload: serving] MixedNode[混部节点br/gpu-type: mixedbr/workload: both] end Filter -- NodeSelector{NodeSelector /br/Node Affinity} Filter -- TaintFilter{Taint 检查} NodeSelector -- TrainNode NodeSelector -- InferNode TaintFilter -- TrainTaint[训练节点 Taintbr/workloadtraining:NoSchedule] TaintFilter -- InferTaint[推理节点 Taintbr/workloadinference:NoSchedule] TrainTaint -- TrainPod[训练 Podbr/toleration: workloadtraining] InferTaint -- InferPod[推理 Podbr/toleration: workloadinference] subgraph 调度结果 Result1[训练 Job → 训练节点池 ✓] Result2[推理服务 → 推理节点池 ✓] Result3[无 Toleration Pod → Pending ✗] end TrainNode -- Result1 InferNode -- Result2 TrainTaint -- Result3 InferTaint -- Result3 style TrainNode fill:#ffcdd2,color:#333 style InferNode fill:#c8e6c9,color:#333 style MixedNode fill:#fff9c4,color:#333NodeSelector 是最简单的匹配方式但缺少灵活性。Node Affinity 支持requiredDuringSchedulingIgnoredDuringExecution硬亲和和preferredDuringSchedulingIgnoredDuringExecution软亲和两种模式。对于训练和推理的隔离诉求硬亲和是必要的——一次不必要的调度可能造成数小时的训练作业失败。Taint/Toleration 提供了反向排他性。当节点被打上workloadtraining:NoSchedule的污点时只有带对应容忍的 Pod 才能调度上来。这比 Node Affinity 更强硬因为它主动拒绝不符合条件的 Pod而 Node Affinity 只是引导调度器的选择。三、生产级调度配置训练与推理的物理隔离以下是一套在 GPU 集群中实施训练/推理隔离的完整配置包含节点标签管理、Taint 策略和 Pod 的调度声明。// node_pool_manager.go — GPU 节点池管理器 // 职责根据节点 GPU 型号、用途标签将节点划入不同的节点池 // 注意标签操作是幂等的重复应用相同标签不会触发额外的 API 更新 package main import ( context fmt os time metav1 k8s.io/apimachinery/pkg/apis/meta/v1 k8s.io/apimachinery/pkg/types k8s.io/client-go/kubernetes k8s.io/client-go/rest ) // NodePoolType 定义节点池类型 type NodePoolType string const ( PoolTypeTraining NodePoolType training PoolTypeInference NodePoolType inference PoolTypeMixed NodePoolType mixed ) // GPUNodeLabeler 管理 GPU 节点的标签和 Taint type GPUNodeLabeler struct { client kubernetes.Interface dryRun bool retryMax int retryWait time.Duration } // NewGPUNodeLabeler 初始化标签管理器 func NewGPUNodeLabeler(dryRun bool) (*GPUNodeLabeler, error) { config, err : rest.InClusterConfig() if err ! nil { // 非集群内运行尝试使用 kubeconfig kubeconfig : os.Getenv(KUBECONFIG) if kubeconfig { kubeconfig os.ExpandEnv($HOME/.kube/config) } config, err rest.InClusterConfig() if err ! nil { // 最终降级到默认配置 config, err clientcmd.BuildConfigFromFlags(, kubeconfig) if err ! nil { return nil, fmt.Errorf(无法加载 Kubernetes 配置: %w, err) } } } clientset, err : kubernetes.NewForConfig(config) if err ! nil { return nil, fmt.Errorf(创建 Kubernetes 客户端失败: %w, err) } return GPUNodeLabeler{ client: clientset, dryRun: dryRun, retryMax: 3, retryWait: 2 * time.Second, }, nil } // AssignNodePool 将节点分配到指定的节点池 // 操作包括添加工作负载标签、打 Taint、移除冲突标签 func (l *GPUNodeLabeler) AssignNodePool(ctx context.Context, nodeName string, pool NodePoolType) error { // 标签定义gpu-pool 用于 Node Affinity 匹配 // workload-type 用于监控面板分组 labels : map[string]string{ gpu-pool: string(pool), workload-type: string(pool), } if pool PoolTypeTraining { labels[batch-scheduling] enabled } // Taint 定义不同类型使用不同 key // Effect: NoSchedule — 拒绝无 Toleration 的 Pod taints : []map[string]string{ { key: fmt.Sprintf(workload-%s, pool), value: true, effect: NoSchedule, }, } if l.dryRun { fmt.Printf([DRY-RUN] 将节点 %s 分配至 %s 节点池, 标签: %v, Taints: %v\n, nodeName, pool, labels, taints) return nil } // 获取 Node 对象 var lastErr error for retry : 0; retry l.retryMax; retry { node, err : l.client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}) if err ! nil { lastErr fmt.Errorf(获取节点 %s 失败: %w (重试 %d/%d), nodeName, err, retry1, l.retryMax) time.Sleep(l.retryWait) continue } // 合并标签 updated : node.DeepCopy() if updated.Labels nil { updated.Labels make(map[string]string) } for k, v : range labels { updated.Labels[k] v } // 合并 Taint避免重复 taintMap : make(map[string]bool) for _, t : range updated.Spec.Taints { taintMap[t.Key] true } for _, t : range taints { if !taintMap[t[key]] { updated.Spec.Taints append(updated.Spec.Taints, corev1.Taint{ Key: t[key], Value: t[value], Effect: corev1.TaintEffect(t[effect]), }) } } // Patch 更新 patchBytes, err : strategicpatch.CreateTwoWayMergePatch( convertNodeToBytes(node), convertNodeToBytes(updated), corev1.Node{}, ) if err ! nil { return fmt.Errorf(生成 Patch 失败: %w, err) } _, err l.client.CoreV1().Nodes().Patch(ctx, nodeName, types.StrategicMergePatchType, patchBytes, metav1.PatchOptions{}) if err ! nil { lastErr fmt.Errorf(Patch 节点 %s 失败: %w (重试 %d/%d), nodeName, err, retry1, l.retryMax) time.Sleep(l.retryWait) continue } fmt.Printf(节点 %s 已分配至 %s 节点池\n, nodeName, pool) return nil } return lastErr }对应的 Kubernetes 资源 YAML 配置如下。训练 Job 通过 Node Affinity 和 Toleration 确保只调度到训练节点池# training-job.yaml — 训练 Job 的调度声明 apiVersion: batch/v1 kind: Job metadata: name: llama2-finetune spec: template: spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: gpu-pool operator: In values: - training - key: nvidia.com/gpu.product operator: In values: - NVIDIA-A100-SXM4-80GB tolerations: - key: workload-training operator: Exists effect: NoSchedule containers: - name: trainer image: pytorch/pytorch:2.1.0-cuda12.1 resources: limits: nvidia.com/gpu: 4推理服务的配置类似但 Node Affinity 指向gpu-pool: inference# inference-deploy.yaml — 推理 Deployment 的调度声明 apiVersion: apps/v1 kind: Deployment metadata: name: llama2-inference spec: replicas: 3 template: spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: gpu-pool operator: In values: - inference tolerations: - key: workload-inference operator: Exists effect: NoSchedule containers: - name: vllm image: vllm/vllm-openai:latest resources: limits: nvidia.com/gpu: 1四、物理隔离的成本和运维复杂度将 GPU 节点按工作负载类型物理隔离后资源利用率会下降。训练节点池在空闲时段 GPU 处于闲置状态推理节点池在非高峰期同样如此。以 20 个 A100 节点的集群为例如果按 5:5:10 分为训练 : 推理 : 预留预留 10 台给突发任务实际平均利用率可能只有 40%。降低利用率的对冲策略是引入弹性扩容。训练节点池在低负载时通过 Cluster Autoscaler 缩容推理节点池在流量波谷时通过 HPA 降低副本数。但这要求训练和推理节点的机器规格一致否则缩容后无法互相承接溢出负载。Gang Scheduling 的支持问题。当训练 Job 需要多节点同时调度如 PyTorch DDP 的 4 节点训练而训练节点池中只有 2 个可用节点时调度器无法立即满足需求。需要引入 Volcano 或 Yunikorn 等批调度框架支持队列等待和最小可用资源预留。禁用的反模式不要在推理和训练节点之间共享 GPU 型号但用 PriorityClass 做优先级抢占。PriorityClass Preemption 在高负载下会导致推理服务被频繁驱逐服务稳定性不可接受。优先级的差异无法替代物理隔离。五、总结GPU 节点按工作负载做物理隔离是保障服务稳定性的基础架构决策。核心落地原则标签体系必须标准化gpu-pool、workload-type、nvidia.com/gpu.product三层标签提供从节点池到 GPU 型号的完整路由信息。Taint/Toleration 用于强制执行Node Affinity 用于引导调度。两者不是二选一而是互补。弹性缩容对冲利用率下降训练节点池在空闲时自动缩容推理节点池按流量扩缩。引入批调度框架处理多节点训练场景Volcano 的 Gang Scheduling 确保训练 Job 的原子性调度。