Kubernetes 调度器深度解析:从 Filter 到 Score 的调度决策全过程
"Pod 为什么调度到了这个节点?"——当你的推理服务 Pod 被调度到一个 GPU 显存不足的节点上导致 OOM,或者延迟敏感的服务和批处理任务被部署到同一个节点上相互干扰时,理解调度器的完整决策流程就变得至关重要。本文将 K8s 调度器的 Filter → Score → Reserve 三阶段逐层拆解,并演示自定义调度插件的开发方法。
一、调度框架的扩展点体系
Kubernetes 调度器(kube-scheduler)从 1.19 版本开始正式采用调度框架(Scheduling Framework)架构。它将调度过程抽象为多个扩展点(Extension Points),每个扩展点可以注册多个插件(Plugin)。
flowchart TB subgraph SchedulingCycle["调度周期 (Scheduling Cycle)"] S[Sort<br/>队列排序] --> PF[PreFilter<br/>预处理/前置过滤] PF --> F[Filter<br/>过滤不可用节点] F --> PostF[PostFilter<br/>无可用节点时的补救] PostF --> PreS[PreScore<br/>评分前预处理] PreS --> Sc[Score<br/>对剩余节点打分] Sc --> NormS[NormalizeScore<br/>分数归一化] NormS --> Res[Reserve<br/>预留资源] Res --> Perm[Permit<br/>许可/等待/拒绝] end subgraph BindingCycle["绑定周期 (Binding Cycle)"] Perm --> PreB[PreBind<br/>绑定前准备] PreB --> Bind[Bind<br/>绑定 Pod 到节点] Bind --> PostB[PostBind<br/>绑定后通知] end PostB --> Done[调度完成] Res -.->|"调度失败<br/>(Reserve 后出错)"| UR[Unreserve<br/>回滚资源预留] style SchedulingCycle fill:#e1f5fe style BindingCycle fill:#fff3e0 style UR fill:#ffcdd2每个扩展点的职责:
| 扩展点 | 阶段 | 核心职责 | 典型插件 |
|---|---|---|---|
| Sort | 队列排序 | 决定 Pod 的出队顺序 | PrioritySort |
| PreFilter | 前置过滤 | 预处理 Pod 信息,计算调度需要的状态 | NodeResourcesFit |
| Filter | 节点过滤 | 排除不满足条件的节点 | NodeUnschedulable, NodePorts, VolumeRestrictions |
| PostFilter | 后置过滤 | 当无节点通过 Filter 时的补救(抢占) | DefaultPreemption |
| PreScore | 评分前处理 | 为 Score 阶段准备共享数据 | InterPodAffinity |
| Score | 节点评分 | 对每个候选节点打分 | NodeResourcesBalancedAllocation, ImageLocality |
| NormalizeScore | 分数归一化 | 将各插件分数统一到 [0, 100] | (框架内置) |
| Reserve | 资源预留 | 原子性地预留节点资源 | VolumeBinding |
| Permit | 许可控制 | 允许/拒绝/等待(如 Gang Scheduling) | (自定义场景) |
| PreBind | 绑定前 | 绑定前最后的准备工作 | VolumeBinding |
| Bind | 绑定 | 将 Pod 绑定到 Node | DefaultBinder |
| PostBind | 绑定后 | 绑定后的通知/清理 | (事件通知) |
二、Filter 阶段:多级过滤漏斗
Filter 阶段是一个漏斗模型——候选节点集从全部节点开始,逐级过滤缩小范围。默认调度器启用了 13 个 Filter 插件,每个插件检查一个维度的条件:
flowchart LR All[全部节点<br/>5000 Nodes] --> F1["NodeUnschedulable<br/>排除不可调度节点"] F1 -->|"4800 left"| F2["NodePorts<br/>检查端口冲突"] F2 -->|"4780 left"| F3["NodeResourcesFit<br/>CPU/内存是否满足"] F3 -->|"2300 left"| F4["NodeAffinity<br/>节点亲和性匹配"] F4 -->|"1200 left"| F5["TaintToleration<br/>污点容忍检查"] F5 -->|"800 left"| F6["VolumeLimits<br/>卷数量限制"] F6 -->|"750 left"| F7["其他 Filter 插件"] F7 -->|"700 left"| Score[进入 Score 阶段]Filter 插件的执行是并行的——每个节点可以独立判断是否满足条件。但 kube-scheduler 默认以percentageOfNodesToScore(默认 0%,即根据集群大小自适应)来控制 Filter 阶段扫描的节点比例,在 5000 节点集群中可能只扫描 5-10% 的节点。这在大集群中显著降低了调度延迟,但可能导致次优的调度结果。
GPU 资源的 Filter 挑战:
标准 Kubernetes 对 GPU 的支持通过 Device Plugin 机制实现。但在 AI 推理场景下,Filter 需要更细粒度的判断——不只是"是否有 GPU",还包括显存、GPU 型号、拓扑亲和性等。通常需要自定义 Filter 插件:
package gpuscheduler import ( "context" "fmt" 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" ) // GPUFilterPlugin 自定义 Filter 插件 // 功能:不仅检查 GPU 是否可用,还检查显存是否满足 Pod 需求 type GPUFilterPlugin struct { handle framework.Handle } var _ framework.FilterPlugin = &GPUFilterPlugin{} var _ framework.PreFilterPlugin = &GPUFilterPlugin{} const ( PluginName = "GPUFilter" // Pod 通过 annotation 声明 GPU 需求 GPUAnnotation = "gpu-scheduler/gpu-count" GPUModelAnnotation = "gpu-scheduler/gpu-model" GPUMemAnnotation = "gpu-scheduler/gpu-memory-gb" ) // Name 返回插件名称(在调度器配置中引用) func (g *GPUFilterPlugin) Name() string { return PluginName } // PreFilter 在 Filter 之前执行,预处理 Pod 的 GPU 需求 // 这里将 annotation 中的 GPU 需求写入 CycleState,避免后续重复解析 func (g *GPUFilterPlugin) PreFilter( ctx context.Context, state *framework.CycleState, pod *v1.Pod, ) (*framework.PreFilterResult, *framework.Status) { // 如果 Pod 没有 GPU 需求,跳过所有 GPU 相关的 Filter if pod.Annotations[GPUAnnotation] == "" { return nil, framework.NewStatus(framework.Skip, "no GPU requirement") } // 将 GPU 需求写入 CycleState(线程安全的临时存储) gpuRequirement := &GPURequirement{ Count: parseIntOrZero(pod.Annotations[GPUAnnotation]), Model: pod.Annotations[GPUModelAnnotation], MemoryGB: parseIntOrZero(pod.Annotations[GPUMemAnnotation]), } state.Write(framework.StateKey(PluginName), gpuRequirement) return nil, framework.NewStatus(framework.Success) } // Filter 检查节点是否满足 Pod 的 GPU 需求 // 这个函数会被调度器并行调用(每个候选节点一次) func (g *GPUFilterPlugin) Filter( ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo, ) *framework.Status { // 从 CycleState 中读取之前预处理的 GPU 需求 data, err := state.Read(framework.StateKey(PluginName)) if err != nil { // 如果没有 GPU 需求(PreFilter 返回了 Skip),通过 Filter return framework.NewStatus(framework.Success) } requirement := data.(*GPURequirement) node := nodeInfo.Node() // 1. 检查 GPU 数量 allocatableGPUs := node.Status.Allocatable["nvidia.com/gpu"] if allocatableGPUs.Value() < int64(requirement.Count) { return framework.NewStatus( framework.Unschedulable, fmt.Sprintf("节点 %s 的 GPU 不足: 需要 %d, 可用 %d", node.Name, requirement.Count, allocatableGPUs.Value()), ) } // 2. 检查 GPU 显存(通过节点 label 存储,由 Device Plugin 上报) gpuMemoryGB := node.Labels[GPUMemAnnotation] if parseIntOrZero(gpuMemoryGB) < requirement.MemoryGB { return framework.NewStatus( framework.Unschedulable, fmt.Sprintf("节点 %s 的 GPU 显存不足: 需要 %dGB, 可用 %sGB", node.Name, requirement.MemoryGB, gpuMemoryGB), ) } // 3. 检查 GPU 型号(如果指定) if requirement.Model != "" { nodeGPUModel := node.Labels[GPUModelAnnotation] if nodeGPUModel != requirement.Model { return framework.NewStatus( framework.UnschedulableAndUnresolvable, fmt.Sprintf("节点 %s 的 GPU 型号不匹配: 需要 %s, 实际 %s", node.Name, requirement.Model, nodeGPUModel), ) } } // 4. 检查 GPU 拓扑亲和性(NVLink 连接) // 如果 Pod 需要多个 GPU 且要求 NVLink 互联, // 需要确认所有 GPU 在同一 NVLink 域内(简化实现) if requirement.Count > 1 && pod.Annotations["gpu-scheduler/nvlink-required"] == "true" { if !g.checkNVLinkAffinity(node, requirement.Count) { return framework.NewStatus( framework.Unschedulable, fmt.Sprintf("节点 %s 无法提供 %d 个 NVLink 互联的 GPU", node.Name, requirement.Count), ) } } return framework.NewStatus(framework.Success) } // checkNVLinkAffinity 检查节点上是否有足够的 NVLink 互联 GPU func (g *GPUFilterPlugin) checkNVLinkAffinity( node *v1.Node, requiredCount int, ) bool { // 实际实现需要读取 NVLink 拓扑信息 // 这里简化:通过节点 label 确认 NVLink 域内的 GPU 数量 maxInDomain := parseIntOrZero(node.Labels["gpu-scheduler/max-nvlink-group-size"]) return maxInDomain >= requiredCount } type GPURequirement struct { Count int Model string MemoryGB int } func parseIntOrZero(s string) int { var result int fmt.Sscanf(s, "%d", &result) return result }三、Score 阶段:多维度加权评分
Filter 之后剩余的候选节点进入 Score 阶段。每个 Score 插件为节点打分(通常 0-100),然后按配置的权重加权求和。
默认启用的 Score 插件及其权重(Kubernetes 1.29):
| 插件 | 权重 | 评分逻辑 |
|---|---|---|
| NodeResourcesFit | 1 | LeastAllocated(资源利用率最低得分最高)或 MostAllocated(资源利用率最高得分最高) |
| NodeResourcesBalancedAllocation | 1 | CPU 和内存使用比例的差异越小得分越高 |
| ImageLocality | 1 | 节点上已存在 Pod 所需镜像 → 高分(避免镜像拉取) |
| InterPodAffinity | 2 | 满足 Pod 间亲和性/反亲和性规则 |
| NodeAffinity | 2 | 满足节点亲和性偏好 |
| TaintToleration | 3 | 能容忍越多污点的节点得分越高 |
加权总分的计算公式:
NodeScore = Σ (PluginScore_i × Weight_i)对于 AI 推理场景,建议自定义 Score 插件来优化 GPU 节点的分配:
// GPUScorePlugin 自定义 Score 插件 // 评分策略: // 1. GPU 型号匹配度(100 → 型号精确匹配) // 2. GPU 显存余量(余量越多分数越高,为突发请求预留) // 3. GPU 温度(温度越低分数越高,避免热节流) type GPUScorePlugin struct { handle framework.Handle } var _ framework.ScorePlugin = &GPUScorePlugin{} func (g *GPUScorePlugin) Name() string { return "GPUScore" } func (g *GPUScorePlugin) 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, err.Error()) } node := nodeInfo.Node() var totalScore int64 = 0 // 1. GPU 型号匹配度(权重 40%) requiredModel := pod.Annotations[GPUModelAnnotation] if requiredModel != "" { nodeModel := node.Labels[GPUModelAnnotation] if nodeModel == requiredModel { totalScore += 40 } } else { totalScore += 40 // 无型号要求,所有节点都得满分 } // 2. GPU 显存余量评分(权重 35%) // 余量 = (总显存 - 已用显存) / 总显存 totalMemory := parseIntOrZero(node.Labels["gpu-scheduler/total-memory-gb"]) usedMemory := parseIntOrZero(node.Annotations["gpu-scheduler/used-memory-gb"]) if totalMemory > 0 { freeRatio := float64(totalMemory-usedMemory) / float64(totalMemory) // 余量 > 60% 得满分 35;余量 < 10% 得 0 分 totalScore += int64(freeRatio / 0.6 * 35) } // 3. 当前推理请求队列长度(权重 25%) // 队列越短分数越高,实现负载均衡 queueLength := parseIntOrZero(node.Annotations["gpu-scheduler/current-queue"]) // 队列长度 > 10 得 0 分;队列 = 0 得满分 25 if queueLength < 10 { totalScore += int64((10 - queueLength) * 25 / 10) } return totalScore, framework.NewStatus(framework.Success) } // ScoreExtensions 返回 NormalizeScore 插件 func (g *GPUScorePlugin) ScoreExtensions() framework.ScoreExtensions { return g } // NormalizeScore 不做额外归一化(默认已映射到 [0, 100]) func (g *GPUScorePlugin) NormalizeScore( ctx context.Context, state *framework.CycleState, pod *v1.Pod, scores framework.NodeScoreList, ) *framework.Status { return nil }四、自定义调度插件的部署方式
自定义插件有两种部署模式:
1. 内置方式(In-Tree):将插件编译进 kube-scheduler 二进制文件。适合组织内部的标准插件。需要维护自己的 kube-scheduler 镜像。
2. 外置方式(Out-of-Tree):通过调度器框架(Scheduler Framework)实现独立的调度器,与默认调度器共存。适合实验性或定制化需求。
多调度器共存的典型场景:
# 场景:默认调度器处理普通 Pod,GPU 调度器处理 AI 推理 Pod --- # GPU 调度器的 Deployment apiVersion: apps/v1 kind: Deployment metadata: name: gpu-scheduler namespace: kube-system spec: replicas: 2 # 高可用:2 个副本,使用 Leader Election selector: matchLabels: component: gpu-scheduler template: metadata: labels: component: gpu-scheduler spec: serviceAccountName: gpu-scheduler containers: - name: gpu-scheduler image: registry.example.com/gpu-scheduler:v1.2.0 command: - /usr/local/bin/kube-scheduler - --config=/etc/kubernetes/gpu-scheduler-config.yaml - --leader-elect=true - --leader-elect-resource-name=gpu-scheduler - --port=10260 # 与默认调度器 10259 区分 volumeMounts: - name: scheduler-config mountPath: /etc/kubernetes volumes: - name: scheduler-config configMap: name: gpu-scheduler-config --- # GPU 调度器配置 apiVersion: v1 kind: ConfigMap metadata: name: gpu-scheduler-config namespace: kube-system data: gpu-scheduler-config.yaml: | apiVersion: kubescheduler.config.k8s.io/v1 kind: KubeSchedulerConfiguration leaderElection: leaderElect: true resourceName: gpu-scheduler profiles: - schedulerName: gpu-scheduler # 调度器名称 plugins: filter: enabled: - name: GPUFilter score: enabled: - name: GPUScore weight: 10 # 权重最高 - name: NodeResourcesFit weight: 1 pluginConfig: - name: GPUFilter args: minGpuMemoryGB: 8 --- # 使用 GPU 调度器的 Pod 示例 apiVersion: v1 kind: Pod metadata: name: llm-inference-pod annotations: gpu-scheduler/gpu-count: "2" gpu-scheduler/gpu-model: "A100" gpu-scheduler/gpu-memory-gb: "40" gpu-scheduler/nvlink-required: "true" spec: schedulerName: gpu-scheduler # 指定使用 GPU 调度器 containers: - name: llm-server image: vllm/vllm-openai:latest resources: limits: nvidia.com/gpu: 2调度延迟优化策略:
- percentageOfNodesToScore:大集群(> 1000 节点)建议设为 5-10%,牺牲调度最优性换取延迟
- Pod Topology Spread:对 Filter 阶段影响较大,复杂约束会增加计算时间
- 禁用不必要的插件:每个插件都会增加 Filter/Score 各阶段的耗时。审查所有启用的插件,关闭不需要的
- NodeCache 刷新频率:默认 100ms(
nodeCacheRefreshInterval),对于动态变化的 GPU 指标,可在节点端做聚合上报来减少调度器的刷新压力
五、总结
K8s 调度器的 Filter → Score → Reserve 三阶段架构提供了高度可扩展的调度决策框架。三个关键实践:
Filter 是漏斗,Score 是排序。Filter 的目标是快速排除不满足硬性条件的节点(GPU 数量、内存、亲和性);Score 的目标是对剩余节点做精细化排序。理解这个分工是设计自定义插件的基础——不要在 Filter 中做"打分"的工作,也不要在 Score 中做"硬性排除"的工作。
自定义插件应该关注领域特定的需求。K8s 原生调度器不理解"GPU 显存"、"NVLink 拓扑"、"推理请求队列"等 AI 推理领域的概念。通过自定义 Filter + Score 插件,可以将这些领域知识注入调度决策,避免推理 Pod 被调度到不合适的 GPU 节点。
多调度器共存是平滑迁移的最佳路径。通过
schedulerName字段,AI 推理 Pod 使用 GPU 调度器,普通 Pod 继续使用默认调度器。两者独立运行、互不干扰。逐步将流量从默认调度器迁移到专用调度器,可以降低风险。