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

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

一、默认调度器不知道你的 GPU 在干什么

Kubernetes 默认调度器对 GPU 的理解停留在"有没有卡、几块卡"的层面。Node 上有nvidia.com/gpu: 8,Pod 请求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)) } // 检查3:NUMA 亲和性 — 多卡请求需要同一 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 状态的感知精度。