Kubernetes 调度器深度剖析:从默认调度到自定义扩展的实战路径
2026/6/26 2:09:24 网站建设 项目流程

Kubernetes 调度器深度剖析:从默认调度到自定义扩展的实战路径

一、默认调度器够用吗?生产环境中的调度困境

Kubernetes 默认调度器基于 predicates/priorities 模型工作:先过滤满足条件的节点,再按优先级排序选最优节点。这套机制对无状态 Web 服务完全够用,但在以下场景会暴露明显短板。

GPU 作业调度:默认调度器不感知 GPU 拓扑,一个需要 4 卡的训练任务可能被分散到不同 NUMA 节点,跨 NUMA 访问 GPU 导致性能下降 15%-30%。批任务与在线服务混部:离线任务可以容忍抢占,但默认调度器没有区分工作负载优先级的抢占策略,导致在线服务被低优先级批任务挤占资源。多租户场景:默认调度器只看资源请求量,不管实际使用量,一个租户声明了 8 核但只用 2 核,其他租户的 Pod 却因"资源不足"无法调度。

这些问题的本质是——默认调度器的决策维度太少,它只看 CPU/内存请求量,不看 GPU 拓扑、不看实际利用率、不看工作负载类型。要解决这些问题,需要深入理解调度器机制并做定制化扩展。

二、调度器工作机制:从调度周期到扩展点的全链路

Kubernetes 调度器的工作流程分为三个阶段:调度周期(Scheduling Cycle)、绑定周期(Binding Cycle)和调度失败后的重试。调度周期是同步的,一次只处理一个 Pod;绑定周期是异步的,可以并行。

sequenceDiagram participant Queue as 调度队列 participant Sched as 调度周期 participant Filter as Filter 扩展点 participant Score as Score 扩展点 participant Bind as 绑定周期 participant API as API Server participant Node as 目标节点 Queue->>Sched: 取出待调度 Pod Sched->>Filter: 执行所有 Filter 插件 Filter-->>Sched: 返回可行节点列表 Sched->>Score: 对可行节点打分排序 Score-->>Sched: 返回最优节点 Sched->>Sched: Reserve 预留资源 Sched->>Bind: 进入绑定周期(异步) Bind->>API: 发送绑定请求 API->>Node: Pod 运行在目标节点 Note over Sched: 如果绑定失败,执行 Unreserve 释放预留

调度框架(Scheduling Framework)在 v1.19 进入稳定期,它把调度流程拆解为多个扩展点:PreFilter、Filter、PostFilter、PreScore、Score、Reserve、Permit、Bind。每个扩展点都可以注册自定义插件,这是扩展调度行为的标准方式。

关键扩展点的作用说明:PreFilter 做前置校验(比如检查 Pod 是否声明了 GPU),Filter 过滤不满足条件的节点,Score 对可行节点打分排序,Reserve 在绑定前预留资源(防止并发调度导致超卖),Permit 可以暂停调度等待外部条件。

三、自定义调度插件:GPU 拓扑感知调度实战

3.1 调度插件框架搭建

package main import ( "context" "fmt" "math" "sort" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/kubernetes/pkg/scheduler/framework" ) const ( // PluginName 插件名称,注册到调度框架 PluginName = "GPUTopologyAware" ) // GPUTopologyAware GPU 拓扑感知调度插件 type GPUTopologyAware struct { handle framework.Handle } // New 初始化插件,实现 framework.Plugin 接口 func New(ctx context.Context, configuration runtime.Object, handle framework.Handle) (framework.Plugin, error) { return &GPUTopologyAware{ handle: handle, }, nil } // Name 返回插件名称 func (g *GPUTopologyAware) Name() string { return PluginName }

3.2 Filter 阶段:过滤 GPU 拓扑不满足的节点

// GPUTopologyInfo 节点 GPU 拓扑信息,从节点注解中读取 type GPUTopologyInfo struct { NodeName string `json:"nodeName"` GPUIDs []int `json:"gpuIds"` NUMANode map[int]int `json:"numaNode"` // GPU ID -> NUMA Node PCIeSwitch map[int]int `json:"pcieSwitch"` // GPU ID -> PCIe Switch TotalGPUs int `json:"totalGpus"` } // Filter 过滤不满足 GPU 拓扑要求的节点 // 核心逻辑:如果 Pod 请求多卡,要求所有 GPU 在同一 NUMA 节点 func (g *GPUTopologyAware) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status { // 获取 Pod 请求的 GPU 数量 gpuRequest := getGPURequest(pod) if gpuRequest == 0 { // 不需要 GPU 的 Pod,直接通过 return framework.NewStatus(framework.Success, "") } // 获取节点 GPU 拓扑信息 topoInfo := getGPUTopologyFromNode(nodeInfo.Node()) if topoInfo == nil { // 没有拓扑信息的 GPU 节点,降级为默认调度 return framework.NewStatus(framework.Success, "") } // 计算节点可用 GPU 数量 availableGPUs := getAvailableGPUs(nodeInfo) if availableGPUs < gpuRequest { return framework.NewStatus(framework.Unschedulable, fmt.Sprintf("节点 GPU 不足: 需要 %d, 可用 %d", gpuRequest, availableGPUs)) } // 多卡场景:检查同一 NUMA 节点是否有足够 GPU if gpuRequest > 1 { numaGPUCount := make(map[int]int) // NUMA Node -> GPU 数量 for _, gpuID := range topoInfo.GPUIDs { if isGPUAvailable(nodeInfo, gpuID) { numaID := topoInfo.NUMANode[gpuID] numaGPUCount[numaID]++ } } maxSameNUMA := 0 for _, count := range numaGPUCount { if count > maxSameNUMA { maxSameNUMA = count } } if maxSameNUMA < gpuRequest { return framework.NewStatus(framework.Unschedulable, fmt.Sprintf("GPU 拓扑不满足: 需要 %d 卡同 NUMA, 最大同 NUMA 仅 %d 卡", gpuRequest, maxSameNUMA)) } } return framework.NewStatus(framework.Success, "") } // getGPURequest 从 Pod 中提取 GPU 请求量 func getGPURequest(pod *v1.Pod) int64 { var total int64 for _, container := range pod.Spec.Containers { if limit, ok := container.Resources.Limits[v1.ResourceName("nvidia.com/gpu")]; ok { total += limit.Value() } } return total }

3.3 Score 阶段:优先选择 GPU 拓扑最优的节点

// Score 对节点打分,优先选择 GPU 拓扑紧凑的节点 // 评分逻辑:同 NUMA 的 GPU 越多,分数越高 func (g *GPUTopologyAware) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) { nodeInfo, err := g.handle.SnapshotSharedLister().NodeInfos().Get(nodeName) if err != nil { return 0, framework.NewStatus(framework.Error, fmt.Sprintf("获取节点信息失败: %v", err)) } gpuRequest := getGPURequest(pod) if gpuRequest <= 1 { // 单卡不需要拓扑感知,给中间分 return 50, framework.NewStatus(framework.Success, "") } topoInfo := getGPUTopologyFromNode(nodeInfo.Node()) if topoInfo == nil { return 50, framework.NewStatus(framework.Success, "") } // 计算每个 NUMA 节点的可用 GPU 数 numaGPUCount := make(map[int]int) for _, gpuID := range topoInfo.GPUIDs { if isGPUAvailable(nodeInfo, gpuID) { numaID := topoInfo.NUMANode[gpuID] numaGPUCount[numaID]++ } } // 找到能容纳请求的最大同 NUMA GPU 组 maxSameNUMA := 0 for _, count := range numaGPUCount { if count > maxSameNUMA { maxSameNUMA = count } } // 打分公式:同 NUMA GPU 数 / 请求 GPU 数,映射到 0-100 // 完全满足 = 100 分,完全不满足 = 0 分 ratio := float64(maxSameNUMA) / float64(gpuRequest) score := int64(math.Min(ratio*100, 100)) return score, framework.NewStatus(framework.Success, "") } // ScoreExtensions 返回 nil 表示不需要归一化(框架会自动处理) func (g *GPUTopologyAware) ScoreExtensions() framework.ScoreExtensions { return nil }

3.4 Reserve 阶段:防止并发调度导致 GPU 超卖

// gpuReservations GPU 预留记录,全局维护 var gpuReservations = struct { sync.RWMutex records map[string]map[int]string // nodeName -> gpuID -> podUID }{ records: make(map[string]map[int]string), } // Reserve 在绑定前预留 GPU 资源,防止并发调度超卖 func (g *GPUTopologyAware) Reserve(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) *framework.Status { gpuRequest := getGPURequest(pod) if gpuRequest == 0 { return framework.NewStatus(framework.Success, "") } nodeInfo, err := g.handle.SnapshotSharedLister().NodeInfos().Get(nodeName) if err != nil { return framework.NewStatus(framework.Error, "获取节点信息失败") } topoInfo := getGPUTopologyFromNode(nodeInfo.Node()) if topoInfo == nil { return framework.NewStatus(framework.Success, "") } // 找到同一 NUMA 节点上可用的 GPU numaAvailable := make(map[int][]int) // NUMA -> 可用 GPU ID 列表 for _, gpuID := range topoInfo.GPUIDs { if isGPUAvailable(nodeInfo, gpuID) && !isGPUReserved(nodeName, gpuID) { numaID := topoInfo.NUMANode[gpuID] numaAvailable[numaID] = append(numaAvailable[numaID], gpuID) } } // 选择 GPU 数量最多的 NUMA 节点 var selectedGPUs []int maxCount := 0 for numaID, gpus := range numaAvailable { if len(gpus) >= int(gpuRequest) && len(gpus) > maxCount { selectedGPUs = gpus[:gpuRequest] maxCount = len(gpus) } } if len(selectedGPUs) == 0 { return framework.NewStatus(framework.Unschedulable, "Reserve 阶段: 无法找到足够的同 NUMA GPU") } // 执行预留 gpuReservations.Lock() defer gpuReservations.Unlock() if gpuReservations.records[nodeName] == nil { gpuReservations.records[nodeName] = make(map[int]string) } for _, gpuID := range selectedGPUs { gpuReservations.records[nodeName][gpuID] = string(pod.UID) } return framework.NewStatus(framework.Success, "") } // Unreserve 绑定失败时释放预留的 GPU func (g *GPUTopologyAware) Unreserve(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) { gpuReservations.Lock() defer gpuReservations.Unlock() if nodeRecords, ok := gpuReservations.records[nodeName]; ok { for gpuID, podUID := range nodeRecords { if podUID == string(pod.UID) { delete(nodeRecords, gpuID) } } } } // isGPUReserved 检查 GPU 是否已被预留 func isGPUReserved(nodeName string, gpuID int) bool { gpuReservations.RLock() defer gpuReservations.RUnlock() if nodeRecords, ok := gpuReservations.records[nodeName]; ok { _, reserved := nodeRecords[gpuID] return reserved } return false }

3.5 插件注册与部署

# scheduler-config.yaml — 调度器配置 apiVersion: kubescheduler.config.k8s.io/v1 kind: KubeSchedulerConfiguration profiles: - schedulerName: gpu-topology-scheduler plugins: filter: enabled: - name: GPUTopologyAware score: enabled: - name: GPUTopologyAware weight: 5 reserve: enabled: - name: GPUTopologyAware pluginConfig: - name: GPUTopologyAware args: topologyAnnotation: "gpu-topology.nvidia.com/info" preferSameNUMA: true
# gpu-workload.yaml — 使用自定义调度器的 GPU 作业 apiVersion: v1 kind: Pod metadata: name: gpu-training-job annotations: scheduling.k8s.io/group-name: gpu-topology-scheduler spec: schedulerName: gpu-topology-scheduler containers: - name: training image: pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime resources: limits: nvidia.com/gpu: "4" memory: "32Gi" requests: cpu: "8" memory: "16Gi" command: ["python", "train.py"]

四、自定义调度的代价:复杂度、维护成本与适用边界

调度插件的开发与维护成本。自定义调度插件需要编译进 kube-scheduler 二进制或以扩展方式部署,两种方式都有维护负担。编译方式要求跟随 Kubernetes 版本升级重新编译,扩展方式(scheduler extender)有网络延迟开销,每个 Filter/Score 调用都是一次 HTTP 请求。在 v1.26+ 中,Scheduling Framework 是推荐方式,但它要求用 Go 开发且依赖 Kubernetes 内部包,API 稳定性不如外部接口。

Reserve 机制的状态管理问题。上面代码中的 gpuReservations 使用进程内 map 存储,调度器重启后预留信息丢失。生产环境需要将预留状态持久化到 etcd 或通过 Lease 对象管理。更严重的是,多副本调度器场景下,进程内状态无法共享,必须依赖外部存储实现分布式一致性。

GPU 拓扑信息的获取与维护。NVIDIA Device Plugin 不直接暴露 GPU 拓扑信息,需要额外部署 nvidia-topology-daemon 将拓扑数据写入节点注解。拓扑信息在 GPU 热插拔或驱动升级后可能变化,需要持续同步。这部分基础设施的维护成本容易被低估。

适用边界。以下场景不建议使用自定义调度:单卡推理服务(默认调度器足够);GPU 节点少于 3 个的小集群(拓扑感知收益有限);团队没有 Go 开发能力(维护调度插件的成本远超收益)。对于这些场景,用 nodeSelector/nodeAffinity 做简单拓扑约束,配合默认调度器,是更务实的选择。

禁用场景。已经使用 Volcano/YuniKorn 等批调度器的集群,不建议再叠加自定义调度插件,两套调度逻辑冲突的风险极高。多租户场景下如果租户间不需要 GPU 拓扑隔离,也不需要自定义调度——用 ResourceQuota 限制配额就够了。

五、总结

本文从生产环境中 GPU 作业调度的实际困境出发,深入剖析了 Kubernetes 调度器的工作机制,并基于 Scheduling Framework 实现了 GPU 拓扑感知调度插件。核心实现覆盖三个关键扩展点:Filter 阶段过滤 NUMA 拓扑不满足的节点,Score 阶段优先选择同 NUMA GPU 紧凑的节点,Reserve 阶段预留 GPU 防止并发超卖。同时明确了自定义调度的代价:开发维护成本、状态管理的复杂性、拓扑信息获取的额外基础设施,以及多副本调度器的一致性问题。调度器的职责是为工作负载找到最合适的节点,而不是把所有调度逻辑都塞进一个插件。在默认调度器够用的场景下,不要为了"技术先进"而引入自定义调度——基础设施的改动,每多一行代码就多一份运维负担。


质量评分

维度评估标准得分
直接性直接陈述事实还是绕圈宣告?
10 分:直截了当;1 分:充满铺垫
/10
节奏句子长度是否变化?
10 分:长短交错;1 分:机械重复
/10
信任度是否尊重读者智慧?
10 分:简洁明了;1 分:过度解释
/10
真实性听起来像真人说话吗?
10 分:自然流畅;1 分:机械生硬
/10
精炼度还有可删减的内容吗?
10 分:无冗余;1 分:大量废话
/10
总分/50

标准:

  • 45-50 分:优秀,已去除 AI 痕迹
  • 35-44 分:良好,仍有改进空间
  • 低于 35 分:需要重新修订

参考

本技能基于 Wikipedia:Signs of AI writing,由 WikiProject AI Cleanup 维护。那里记录的模式来自对维基百科上数千个 AI 生成文本实例的观察。

关键见解:"LLM 使用统计算法来猜测接下来应该是什么。结果倾向于适用于最广泛情况的统计上最可能的结果。"

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询