Kubernetes 系列 · 第五篇

kube-scheduler:Filter 和 Score 到底在比什么

第一篇提到 scheduler 在内存里跑完 Filter 和 Score 两个阶段就把结果写回 apiserver,但没展开这两个阶段具体在算什么。这一篇把黑箱打开:Filter 是一道道"能不能"的判断题,任何一条不满足就直接淘汰;Score 是给活下来的节点打分,分数从哪来、为什么同样的 Pod 在不同节点上打分会不一样,这篇用一个最常见的打分插件(NodeResourcesFitLeastAllocated 策略)讲清楚。

跟上一篇 kube-proxy 一样的做法:本机没有真实多节点集群可以拿来调度,直接从 kube-scheduler v1.37.0 真实源码的官方单元测试里取出它自己断言过的真实打分结果(不是编的例子),用 Python 把打分公式原样实现一遍,逐数字核对。

3 组真实打分,逐数字复现
直接取自 kube-scheduler 官方测试用例的真实期望分数
4 处真实源码
Filter 的资源不足判断、Score 的打分公式,逐行核对

两个阶段,两种问题

Filter

能不能——是非题

逐个节点跑一遍所有 Filter 插件,任何一个插件说"不行"就直接淘汰这个节点,不再往下看。资源不够、端口冲突、亲和性不满足,都在这一步过滤掉。

Score

哪个更好——打分题

只有通过全部 Filter 的节点才会进入这一步。每个 Score 插件给每个候选节点打一个 0-100 的分,加权汇总,分数最高的节点(或按权重随机选一个高分节点)胜出。

真实源码:Filter——资源不够,直接淘汰

NodeResourcesFit 插件的 Filter 部分,判断逻辑就是一行比较。

pkg/scheduler/framework/plugins/noderesources/fit.go · kubernetes @ v1.37.0, L733 if podRequest.MilliCPU > 0 && deltaMilliCPU > (nodeInfo.GetAllocatable().GetMilliCPU()-nodeInfo.GetRequested().GetMilliCPU()) { insufficientResources = append(insufficientResources, InsufficientResource{ ResourceName: v1.ResourceCPU, Reason: "Insufficient cpu", ... }) }
pkg/scheduler/framework/plugins/noderesources/fit.go · kubernetes @ v1.37.0, L654 func (f *Fit) Filter(ctx context.Context, cycleState fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) *fwk.Status { ... insufficientResources := fitsRequest(s, nodeInfo, ...) if len(insufficientResources) != 0 { // 把所有不满足的原因都收集起来,一次性返回,不是发现第一个就退出 return fwk.NewStatus(fwk.Unschedulable, failureReasons...) } return nil }

判断是"Pod 请求的 CPU 是否超过节点剩余可分配的 CPU"(allocatable - requested),内存、临时存储走的是同一个模式。只要有一项不满足,这个节点就被打上 Unschedulable 状态,不再进入 Score 阶段——这就是为什么"资源不够的节点"永远不会因为其他维度分数高就被选中:它压根没有资格参与打分。

真实源码:Score——打分公式只有三行

pkg/scheduler/framework/plugins/noderesources/least_allocated.go · kubernetes @ v1.37.0, L49 // The unused capacity is calculated on a scale of 0-MaxNodeScore // 0 being the lowest priority and `MaxNodeScore` being the highest. // The more unused resources the higher the score is. func leastRequestedScore(requested, capacity int64) int64 { if capacity == 0 { return 0 } if requested > capacity { return 0 } return ((capacity - requested) * fwk.MaxNodeScore) / capacity }
pkg/scheduler/framework/plugins/noderesources/least_allocated.go · kubernetes @ v1.37.0, L24 // leastResourceScorer favors nodes with fewer requested resources. func leastResourceScorer(resources []config.ResourceSpec) func([]int64, []int64, []int64) int64 { return func(requested, _, allocable []int64) int64 { var nodeScore, weightSum int64 for i := range requested { weight := resources[i].Weight resourceScore := leastRequestedScore(requested[i], allocable[i]) nodeScore += resourceScore * weight weightSum += weight } return nodeScore / weightSum } }

核心就是剩余容量的百分比,不是剩余容量的绝对值:(容量-已用)*100/容量,每种资源(CPU、内存……)各算一个百分比分数,按权重(默认 CPU 和内存权重都是 1)取加权平均,就是这个节点在这一个插件下的最终分数。fwk.MaxNodeScore 就是 100——每个 Score 插件的输出都被统一映射到 0-100 这个区间,方便跟其他打分插件加权汇总。

真实实验(用 Python 复现):同一个 Pod,不同节点打分为什么不一样

直接取 kube-scheduler 官方测试文件里的真实节点配置和真实期望分数,用 Python 实现上面两段公式,看算不算得出一样的结果。

真实实测
$ python3 -c "
def least_requested_score(requested, capacity):
    if capacity == 0 or requested > capacity: return 0
    return ((capacity - requested) * 100) // capacity

# 真实测试用例:Pod 请求 cpu=3000, memory=5000(两个容器的请求求和)
# node1: capacity cpu=4000, memory=10000(小节点)
# node2: capacity cpu=6000, memory=10000(大节点,只有 CPU 容量不同)
n1 = (least_requested_score(3000, 4000) + least_requested_score(5000, 10000)) // 2
n2 = (least_requested_score(3000, 6000) + least_requested_score(5000, 10000)) // 2
print('node1:', n1, ' node2:', n2)
"
node1: 37 node2: 50 ← 跟 kube-scheduler 官方测试用例的真实期望分数逐位一致

同一个 Pod、同样的请求量,node2 只是把 CPU 容量从 4000 加到 6000(内存容量没变),分数就从 37 涨到 50——因为打的是比例分不是绝对值:node1 的 CPU 用掉了 75%(3000/4000),node2 只用掉了 50%(3000/6000)。这也是这个策略叫 LeastAllocated(偏爱占用比例更低的节点)的原因:大节点天然更容易在这个维度上得高分,倾向把负载摊薄,而不是把小节点先塞满。

真实实验(续):已经在跑的 Pod,也会拉低分数

再取一组真实测试用例:这次新 Pod 本身不请求任何资源,但两个节点上都已经有 Pod 在跑。

节点capacity已有 Pod 用量CPU 分内存分最终分
node1cpu:10000 mem:20000cpu:6000 mem:04010070
node2cpu:10000 mem:20000cpu:6000 mem:5000407557

两个节点容量完全一样,CPU 占用也一样(都是 6000),差别只在内存——node2 上已有的 Pod 多占了 5000 内存,内存分从 100 掉到 75,最终分从 70 掉到 57。requested 这个输入不是"这个新 Pod 要多少",是"这个节点已经承诺出去的总量,加上这个新 Pod 要的量"——已经在跑的 Pod 一直在拉低这个节点后续的打分,这正是负载均衡效果的来源。

交互演示:Filter 淘汰,Score 排名

把上面全部真实数据和真实公式按发生顺序串成一条演示。

调度决策实录未开始
点击"下一步"或"播放"开始。

判断逻辑由 Python 脚本按真实源码逐行转写:Filter 的判断直接照抄 fitsRequest 的"请求超过剩余可分配量就拒绝"规则;Score 的公式跟 leastRequestedScore/leastResourceScorer 一致,3 组真实测试用例的分数全部逐位核对通过,另有一套用浮点除法而不是整数除法重新实现的独立版本交叉核对过。

参考与说明

  • 本文源码引用(Fit.FilterfitsRequest 的资源不足判断、leastRequestedScoreleastResourceScorerMaxScore 常量)均取自 kubernetes/kubernetes 仓库 v1.37.0 标签,直接从 GitHub 拉取源文件核对过函数签名和关键逻辑。
  • 本文引用的 3 组真实打分数据,均逐数字取自同一份源码仓库里 pkg/scheduler/framework/plugins/noderesources/least_allocated_test.goTestLeastAllocatedScoringStrategy 测试用例——这是 kube-scheduler 项目自己对"给定这样的节点和 Pod,LeastAllocated 策略必须打出这样的分数"的正式断言,不是本文编造的示例。本机没有真实的多节点集群可以拿来实际调度,因此只验证了"打分算法"这一层,没有真实跑过 kube-scheduler 二进制。
  • 演示数据的自检:3 组真实分数逐位核对通过,另有一套用浮点除法(而不是源码里的整数除法)重新实现的独立打分函数交叉核对过,结果一致;Filter 阶段的"资源不足即拒绝"逻辑在"刚好够用"和"明显不够"两种场景下都验证过。
  • 没有涉及:除 LeastAllocated 之外的其他 Score 插件(BalancedAllocationPodTopologySpread 等)、多个 Score 插件之间怎么加权汇总成最终排名、抢占(preemption)机制、调度队列(SchedulingQueue)的优先级与退避策略。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电