Kubernetes 调度器深度剖析:从默认调度到自定义扩展的实战路径
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 是否声明了 GPUFilter 过滤不满足条件的节点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 使用统计算法来猜测接下来应该是什么。结果倾向于适用于最广泛情况的统计上最可能的结果。