Kube-Scheduler 扩展:自定义 GPU 调度插件实现业务感知

📅 发布时间:2026/7/17 15:02:45
Kube-Scheduler 扩展:自定义 GPU 调度插件实现业务感知 Kube-Scheduler 扩展自定义 GPU 调度插件实现业务感知一、默认调度器不知道你的 GPU 在干什么Kubernetes 默认调度器对 GPU 的理解停留在有没有卡、几块卡的层面。Node 上有nvidia.com/gpu: 8Pod 请求nvidia.com/gpu: 1调度器找到一个满足资源需求的节点就算完成。但在实际业务中有 GPU和能用 GPU之间有巨大的鸿沟。同一个节点上的 8 张 A100可能其中 3 张正在跑模型训练持续高负载2 张在跑在线推理延迟敏感剩下 3 张空闲。默认调度器只知道有 3 张可用但不知道这 3 张是 NVLink 拓扑最优的、还是已经产生了大量 GPU 内存碎片的。真实的痛点包括跨 NUMA 节点分配 GPU 导致 PCIe 带宽瓶颈推理和训练混合部署时的显存碎片化某张卡温度过高需要主动避让同一业务的多张卡需要 NVLink 直连以降低通信开销。这些问题默认调度器一个也解决不了。二、Kube-Scheduler 插件框架与 GPU 拓扑感知调度Kubernetes 1.19 引入的调度框架Scheduling Framework允许在调度的多个阶段插入自定义逻辑。GPU 调度插件主要在 Filter、Score 和 Reserve 三个阶段发挥作用。Filter 阶段不只是检查 GPU 数量。插件从 Prometheus 拉取实时指标GPU 利用率、显存使用率、温度过滤掉超温或显存碎片过高的节点。同时检查 NUMA 亲和性——如果 Pod 请求 4 张 GPU只保留 4 张卡在同一 NUMA 节点上的候选。Score 阶段不是简单的空闲越多分越高。评分策略包含多个维度显存连续性优先选碎片少的、NVLink 直连跳数优先选拓扑最近的、负载均衡优先选已有业务少的、同业务亲和同一模型的 Pod 尽量调度到同一节点利用 NVLink。Reserve 阶段预留具体的 GPU 索引如GPU-0,GPU-1,GPU-3,GPU-4而不是简单标记已占用 4 张。这避免了两个 Pod 同时调度时争抢同一张卡的问题。三、GPU 感知调度插件实现以下是基于调度框架的 GPU Filter Score 插件核心代码package gpuscheduler import ( context fmt sync time v1 k8s.io/api/core/v1 k8s.io/apimachinery/pkg/runtime k8s.io/kubernetes/pkg/scheduler/framework k8s.io/kubernetes/pkg/scheduler/framework/plugins/names ) const ( Name GPUTopology gpuKey nvidia.com/gpu // 避免过热的温度阈值 tempThreshold 80.0 // 显存碎片阈值剩余显存 / 总显存 fragThreshold 0.3 ) // GPUTopologyPlugin 拓扑感知的 GPU 调度插件 type GPUTopologyPlugin struct { handle framework.Handle gpuState *GPUStateCache cacheTTL time.Duration mu sync.RWMutex } type GPUStateCache struct { mu sync.RWMutex nodes map[string]*NodeGPUInfo lastSync time.Time } type NodeGPUInfo struct { TotalGPUs int FreeGPUs []int // 空闲 GPU 索引列表 UsedGPUs map[int]string // GPU索引 - Pod名称 Temperature map[int]float64 MemoryFree map[int]uint64 // 剩余显存MiB MemoryTotal map[int]uint64 NVLinkTopo [][]int // NVLink 连通矩阵 NUMA map[int]int // GPU索引 - NUMA节点 PodCount int } var _ framework.FilterPlugin GPUTopologyPlugin{} var _ framework.ScorePlugin GPUTopologyPlugin{} var _ framework.ReservePlugin GPUTopologyPlugin{} // Name 返回插件名称 func (p *GPUTopologyPlugin) Name() string { return Name } // Filter 过滤不满足 GPU 调度条件的节点 func (p *GPUTopologyPlugin) Filter( ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo, ) *framework.Status { gpuRequested : getGPURequest(pod) if gpuRequested 0 { return nil // 非 GPU Pod放行 } node : nodeInfo.Node() if node nil { return framework.NewStatus(framework.Unschedulable, node not found) } nodeName : node.Name info, err : p.getNodeGPUInfo(nodeName) if err ! nil { return framework.NewStatus(framework.UnschedulableAndUnresolvable, fmt.Sprintf(failed to get GPU info for node %s: %v, nodeName, err)) } // 检查1温度阈值 — 有高温卡则排除当前节点 for gpuIdx, temp : range info.Temperature { if temp tempThreshold { return framework.NewStatus(framework.Unschedulable, fmt.Sprintf(GPU-%d on node %s temperature %.1f exceeds threshold %.1f, gpuIdx, nodeName, temp, tempThreshold)) } } // 检查2显存碎片 — 剩余显存过低的不算可用 freeCount : 0 for _, gpuIdx : range info.FreeGPUs { memFree : info.MemoryFree[gpuIdx] memTotal : info.MemoryTotal[gpuIdx] if memTotal 0 float64(memFree)/float64(memTotal) fragThreshold { continue // 显存碎片严重不算可用 } freeCount } if freeCount gpuRequested { return framework.NewStatus(framework.Unschedulable, fmt.Sprintf(node %s has %d usable GPUs (excluding fragmented), need %d, nodeName, freeCount, gpuRequested)) } // 检查3NUMA 亲和性 — 多卡请求需要同一 NUMA 节点 if gpuRequested 1 { if !hasNUMAAffinity(info, gpuRequested) { return framework.NewStatus(framework.Unschedulable, fmt.Sprintf(node %s cannot allocate %d GPUs in same NUMA node, nodeName, gpuRequested)) } } return nil } // Score 对通过 Filter 的节点打分 func (p *GPUTopologyPlugin) Score( ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string, ) (int64, *framework.Status) { gpuRequested : getGPURequest(pod) if gpuRequested 0 { return 0, nil } info, err : p.getNodeGPUInfo(nodeName) if err ! nil { return 0, framework.NewStatus(framework.Error, err.Error()) } score : int64(0) // 维度1显存连续性可用 GPU 的显存剩余率均值 var totalFragScore float64 count : 0 for _, gpuIdx : range info.FreeGPUs { if info.MemoryTotal[gpuIdx] 0 { totalFragScore float64(info.MemoryFree[gpuIdx]) / float64(info.MemoryTotal[gpuIdx]) count } } if count 0 { score int64((totalFragScore / float64(count)) * 40) // 40 分权重 } // 维度2业务亲和性 — 同 Deployment 的 Pod 已在节点上运行时加分 if deploymentName, ok : pod.Labels[app]; ok { for _, usedBy : range info.UsedGPUs { if usedBy deploymentName { score 30 // 30 分权重 break } } } // 维度3负载均衡 — 当前 Pod 数越少分越高 maxPodFraction : 50.0 currentPods : float64(info.PodCount) if cap : float64(info.TotalGPUs); cap 0 { score int64((1.0 - currentPods/cap) * maxPodFraction) } return score, nil } // ScoreExtensions 返回评分归一化范围 func (p *GPUTopologyPlugin) ScoreExtensions() framework.ScoreExtensions { return p } func (p *GPUTopologyPlugin) NormalizeScore( _ context.Context, _ *framework.CycleState, _ *v1.Pod, scores framework.NodeScoreList, ) *framework.Status { // 不做归一化分数差异本身就反映了节点优劣 return nil } // Reserve 预留具体 GPU 索引 func (p *GPUTopologyPlugin) Reserve( ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string, ) *framework.Status { gpuRequested : getGPURequest(pod) if gpuRequested 0 { return nil } info, err : p.getNodeGPUInfo(nodeName) if err ! nil { return framework.NewStatus(framework.Error, err.Error()) } // 按显存剩余量和 NVLink 跳数选择最优 GPU 组合 selected, err : selectBestGPUs(info, gpuRequested) if err ! nil { return framework.NewStatus(framework.Unschedulable, err.Error()) } // 写入 CycleState 供后续 Bind 阶段使用 state.Write(reserved-gpus, ReservedGPU{ NodeName: nodeName, GPUs: selected, }) return nil } // Unreserve 调度失败时释放预留 func (p *GPUTopologyPlugin) Unreserve( ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string, ) { // 清理 Reserved 状态 state.Delete(reserved-gpus) } type ReservedGPU struct { NodeName string GPUs []int } // selectBestGPUs 从可用 GPU 中选择 NVLink 拓扑最优的组合 func selectBestGPUs(info *NodeGPUInfo, n int) ([]int, error) { if len(info.FreeGPUs) n { return nil, fmt.Errorf(only %d free GPUs, need %d, len(info.FreeGPUs), n) } // 贪心策略按显存剩余量排序取前 n 个 // 生产环境应结合 NVLink 连通矩阵做最小跳数选择 candidates : make([]int, len(info.FreeGPUs)) copy(candidates, info.FreeGPUs) // 按剩余显存降序排列 for i : 0; i len(candidates); i { for j : i 1; j len(candidates); j { if info.MemoryFree[candidates[i]] info.MemoryFree[candidates[j]] { candidates[i], candidates[j] candidates[j], candidates[i] } } } return candidates[:n], nil } func hasNUMAAffinity(info *NodeGPUInfo, required int) bool { numaCounts : make(map[int]int) for _, gpuIdx : range info.FreeGPUs { numa : info.NUMA[gpuIdx] memFree : info.MemoryFree[gpuIdx] memTotal : info.MemoryTotal[gpuIdx] if memTotal 0 float64(memFree)/float64(memTotal) fragThreshold { numaCounts[numa] } } for _, count : range numaCounts { if count required { return true } } return false } func getGPURequest(pod *v1.Pod) int { total : 0 for _, container : range pod.Spec.Containers { if qty, ok : container.Resources.Requests[gpuKey]; ok { total int(qty.Value()) } } return total } func (p *GPUTopologyPlugin) getNodeGPUInfo(nodeName string) (*NodeGPUInfo, error) { p.gpuState.mu.RLock() info, ok : p.gpuState.nodes[nodeName] p.gpuState.mu.RUnlock() if !ok || time.Since(p.gpuState.lastSync) p.cacheTTL { // 触发刷新 return p.refreshNodeGPUInfo(nodeName) } return info, nil } func (p *GPUTopologyPlugin) refreshNodeGPUInfo(nodeName string) (*NodeGPUInfo, error) { // 实际实现应从 DCGM Exporter / Prometheus 拉取数据 // 此处省略 Prometheus 查询和 NVLink 拓扑解析逻辑 return nil, fmt.Errorf(refresh not implemented) }调度器配置中的插件注册apiVersion: kubescheduler.config.k8s.io/v1 kind: KubeSchedulerConfiguration profiles: - schedulerName: gpu-topology-scheduler plugins: filter: enabled: - name: GPUTopology score: enabled: - name: GPUTopology reserve: enabled: - name: GPUTopology四、调度插件的边界与取舍Filter 开销每个 GPU 节点的 Filter 调用都需要查询外部数据源Prometheus/DCGM这会增加调度延迟。必须设置缓存 TTL建议 5-10 秒避免每次调度都发起网络请求。缓存的代价是可能基于过时数据做出决策需要在准确性和性能之间权衡。Score 策略复杂度评分维度越多越容易产生非预期的交互效应。比如负载均衡少 Pod 高分和业务亲和性同业务 Pod 高分在某些场景下会矛盾。建议针对业务特点固定 3-4 个评分维度过多维度反而降低可解释性。GPU 碎片化Reserve 阶段预留了具体 GPU 索引后如果有 Pod 被抢占或失败需要可靠的清理机制更新 GPU 状态。否则会出现幽灵占用——GPU 实际空闲但状态标记为已用。五、总结Kube-Scheduler 扩展框架为 GPU 调度提供了灵活的定制空间。Filter 阶段基于实时指标过滤不合规节点Score 阶段综合显存连续性、业务亲和性和负载均衡打分Reserve 阶段预留具体 GPU 索引避免竞态。实现过程中最大的挑战不是代码本身而是 GPU 状态数据的准确性和实时性——调度决策的质量取决于你对集群 GPU 状态的感知精度。