
K8s 调度器扩展自定义优先级与亲和性策略深度解析一、默认调度器的能力天花板K8s 默认调度器足够应付 80% 的场景。但当你的 Pod 对 GPU 型号敏感、需要跨可用区严格反亲和、或要求基于实时负载动态调度时默认的nodeSelectoraffinity组合就力不从心了。典型痛点集群里有 A100 和 V100 两种 GPU 节点默认调度器只能按 label 匹配无法感知哪个节点的 GPU 显存碎片率更低、当前温度更优。你要的是优先 A100但 A100 负载 80% 时 fallback 到 V100——这种带有优先级的降级链。还有更棘手的多个 Deployment 之间的跨 Pod 反亲和。默认的podAntiAffinity基于 label selector但如果你希望同一个拓扑域内最多 2 个同类 Pod直接用requiredDuringScheduling会导致集群碎片化用preferredDuringScheduling权重又不够精确。flowchart TD A[Pod 创建事件] -- B[Scheduler 监听] B -- C{默认调度阶段} C --|Filter| D[节点过滤] C --|Score| E[节点打分] D -- F[Scheduler Extender] E -- F F -- G[自定义 Filter 插件] F -- H[自定义 Score 插件] G -- I{通过?} I --|否| J[节点剔除] I --|是| H H -- K[最终排序] K -- L[Bind Pod 到 Node]二、Scheduler Extender 与 Scheduling Framework 的选型两种扩展方式Scheduler ExtenderHTTP Webhook 模式调度器在 Filter/Score 阶段通过 HTTP 调用外部服务。优点是解耦任意语言实现。缺点是每次调度都要额外 HTTP 调用延迟高Webhook 不可用时调度整体阻塞。Scheduling Framework插件模式Go 语言写的编译时插件和调度器同进程运行。延迟几乎为零能访问调度器的内部状态。缺点是只能用 Go且调度器升级时需要重新编译。生产环境建议Filter 阶段用 Framework 插件高吞吐低延迟Score 阶段用 Extender业务逻辑灵活变更。三、自定义 Scoring 插件实现package main import ( context fmt math 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/feature k8s.io/kubernetes/pkg/scheduler/framework/plugins/names ) const PluginName GPUWearLevel // GPUWearLevel 基于 GPU 利用率漂移节点打分。 // 设计决策不使用实时指标Prometheus 查询有延迟抖动 // 而是读取 Node 上的 annotation由外部 DaemonSet 定期写入。 // 这样调度器内部无 IO保持确定性。 type GPUWearLevel struct { handle framework.Handle mu sync.RWMutex } var _ framework.ScorePlugin GPUWearLevel{} func (p *GPUWearLevel) Name() string { return PluginName } // Score 为节点打分。 // 分数 (1 - GPU利用率) * 100确保低负载节点获高分。 // 没有 GPU annotation 的节点给 0 分——不参与 GPU 工作负载调度。 func (p *GPUWearLevel) Score( ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string, ) (int64, *framework.Status) { nodeInfo, err : p.handle.SnapshotSharedLister().NodeInfos().Get(nodeName) if err ! nil { return 0, framework.NewStatus(framework.Error, fmt.Sprintf(node not found: %s, nodeName)) } node : nodeInfo.Node() if node nil { return 0, framework.NewStatus(framework.Error, node object is nil) } // 读取外部 DaemonSet 写入的 annotation gpuUtilStr, ok : node.Annotations[gpu-scheduler.example.com/utilization] if !ok { return 0, framework.NewStatus(framework.Success, no gpu annotation, skip) } var gpuUtil float64 if _, err : fmt.Sscanf(gpuUtilStr, %f, gpuUtil); err ! nil { return 0, framework.NewStatus(framework.Error, fmt.Sprintf(invalid gpu annotation: %v, err)) } // 利用率越低的节点分越高上限 100 score : int64((1.0 - gpuUtil) * 100) if score 0 { score 0 } return score, framework.NewStatus(framework.Success, ) } // ScoreExtensions 返回空——NormalizeScore 不做归一化保持原始分。 func (p *GPUWearLevel) ScoreExtensions() framework.ScoreExtensions { return nil }四、边界分析与架构权衡自定义调度的隐性成本调试复杂度Pod 为什么没调度到预期节点默认调度器有 Events。自定义插件需要自己实现诊断日志。升级风险K8s 版本升级时Framework 插件的 API 可能 breaking change。需要每次升级时重新编译。annotation 滞后Node annotation 更新有延迟取决于 DaemonSet 的采集间隔可能导致过时的调度决策。采集间隔取 5-10s太短则 etcd 写压力大。禁用场景集群规模 10 节点自定义调度纯属过度设计对延迟要求 99.9% 可用性的场景HTTP Extender 的单点风险太高业务调度逻辑频繁变更的场景改一次插件就要重启调度器五、结语自定义调度不是炫技。只有当你明确知道默认调度器哪里不够用时才动手。优先用 Scheduling Framework 做 Filter/Sort用 Node annotation 传指标——把复杂计算放在调度器外面保持核心链路轻量。记住一件事调度决策越快越好100ms 的延迟放大到千级 Pod 就是 100 秒。