

























在 k8s 集群里,kube-scheduler 负责把每个待调度的 Pod 安排到合适的 Node 上。这件事听上去简单:不就是给 Pod 找个 Node 嘛。但真要拆开看,Node 上 CPU 够不够、内存够不够、端口冲突不冲突、Pod 之间的亲和性满不满足、节点是不是处于 NoSchedule 污点……这些规则加起来有几十种,每种还可能跟"集群状态"和"其他 Pod"有关。
早期的 scheduler 用的是"一长串 Predicate + Priority 函数"的方案,所有逻辑写死在一个调度器里,想加一条新规则就得改源码、重新编译。这显然不友好——k8s 这种生态规模,不可能每个新需求都让核心团队去改主仓。于是从 v1.15 开始,调度器就演变成了我们今天要聊的主角:Scheduling Framework。它把调度流程拆成一组"扩展点(Extension Point)",每个扩展点都是一个 Go interface,你只要实现这个 interface 并注册到 SchedulerConfiguration 里,调度器就会在合适的时机调用你的代码。
这篇文章的目标只有一个:把 v1.36.1 里 Scheduling Framework 的核心扩展点——PreFilter(预过滤:统一预处理 Pod 与节点数据、校验配置,失败直接终止调度) / Filter(过滤:逐节点校验资源、亲和性等条件剔除不合规节点) / Score(打分:对过滤后的合格节点按策略计算优先级分值) / Reserve(预留:临时锁定节点资源防止被其他 Pod 抢占) / Permit(许可:外部插件审批放行 Pod 调度) / PreBind(预绑定:执行绑定前前置校验与准备工作) / Bind(绑定:完成 Pod 与目标节点的持久化绑定操作) / PostBind(绑定:执行绑定完成后的清理、日志、通知等收尾逻辑)——按调用顺序一个一个拆开讲。我们会追到 staging/src/k8s.io/kube-scheduler/framework/interface.go 这份核心接口文件,再配上 pkg/scheduler/framework/runtime/framework.go 的运行时实现,看看一个 Pod 从入队到绑定到底经过了哪些"关卡"。学完之后你不仅能看懂别人的自定义调度器源码,还能自己写一个插件塞进集群。
🔓 学习重点提示 — 建议先通读全文,再重点回顾标注内容
★ 重点掌握(必须)
• 扩展点分类:调度周期(Scheduling Cycle)和绑定周期(Binding Cycle)的区别,以及每个扩展点落在哪个周期
• CycleState 数据流:PreFilter 通过 Write 写入,Filter/Score 通过 Read 消费,Reserve 再覆盖写入的协作模式
• 关键状态码:UnschedulableAndUnresolvable 与 Unschedulable 的差异(前者跳过 preemption,后者会进抢占)
• 并发与并行:Filter 默认并行评估每个 Node,Score 跨 Node 维度并行,PreBind 内部还支持 AllowParallel 分组
☆ 次重点(了解即可)
• PostFilter、Permit、PreBindPreFlight 这些"边角扩展点"的用途和触发时机
• PreFilterExtensions(AddPod/RemovePod)用于增量模拟,不改原始数据
• SignPlugin 与批处理优化(KEP-5598)的关系
📋 文章目录
Scheduling Framework 是一套定义在 k8s 调度器内部的"插件化执行流程"。我们可以把它想象成机场的登机口流程:每个登机口(扩展点)都会做一件事,有人负责验票(Filter)、有人负责排优先级(Score)、有人负责锁座位(Reserve)、有人负责把人请上飞机(Bind)。你需要做的,就是在合适的位置放一个"自己人",让 ta 帮你做点定制判断。
在 v1.36.1 里,这套框架把一次 Pod 调度拆成 Scheduling Cycle(调度周期) 和 Binding Cycle(绑定周期) 两个阶段,中间用 Assume 机制做衔接。前者负责"挑出最合适的 Node",后者负责"把 Pod 真正绑上去"。每个阶段内部都有一组扩展点,扩展点之间的数据通过一个叫 CycleState 的内存对象传递——这就是"插件之间怎么共享数据"的答案。
核心扩展点一共 8 个,按时间顺序是:PreFilter → Filter → PostFilter → PreScore → Score → Reserve → Permit → PreBind → Bind → PostBind。每个扩展点都是一个 Go interface(staging/src/k8s.io/kube-scheduler/framework/interface.go),名字、签名和职责都被严格规定。插件作者只需要实现其中一两个,剩下的交给框架帮你串起来。
| 扩展点 | 所属周期 | 核心职责(一句话) |
|---|---|---|
| PreFilter | 调度周期 | "全局预筛":对所有 Node 做一次粗过滤,把 Pod 不可能跑得上的 Node 直接淘汰 |
| Filter | 调度周期 | "细筛":对每个候选 Node 逐个判断这个 Pod 能不能跑上去 |
| PostFilter | 调度周期 | "破局补救":Filter 全失败后尝试抢占(preemption)腾出资源 |
| PreScore | 调度周期 | "打分前准备":对已经通过 Filter 的 Node 列表做一次"信息预热" |
| Score | 调度周期 | "打分":给每个 Node 打一个 0~100 的分数,分高的优先选 |
| Reserve | 调度周期 | "占座":在 scheduler cache 里把资源"占住",避免其他 Pod 也盯上同一个 Node |
| Permit | 调度/绑定间 | "放行/暂停":可以决定这个 Pod 等一会儿再绑(典型场景:等待外部审批) |
| PreBind | 绑定周期 | "绑定前收尾":比如 VolumeBinding 会在这里真正去绑定 PV |
| Bind | 绑定周期 | "最终落槌":真正向 apiserver 发 Binding 请求 |
| PostBind | 绑定周期 | "绑后清理":常用于异步清理资源(已绑就绑了,失败不影响 Pod) |
这 8 个扩展点不是"全必选"的——你可以只实现其中一两个。比如 InterPodAffinity 只实现 PreFilter + Filter + PreScore + AddPod/RemovePod,而 DefaultBinder 只实现 Bind。这就是插件化带来的灵活性。
要理解扩展点机制为什么存在,我们得先回到"老调度器"的样子。v1.14 之前(也就是 v1.15 引入 Scheduling Framework 之前),kube-scheduler 内部是一个长长的 algorithm 包,文件叫 pkg/scheduler/core/generic_scheduler.go,里面有一坨叫 findHostsThatFit 和 prioritizeHosts 的函数,所有的"过滤逻辑"和"打分逻辑"都通过一个 []FitPredicate 和 []PriorityConfig 切片注册——这其实就是"插件"的雏形,但耦合度太高:
Scheduling Framework 怎么解决这些痛点?它把"调度流程"标准化成 8 个扩展点,每个扩展点背后都是一组 interface;然后把"插件"和"框架"解耦——插件只关心自己的逻辑,框架负责按顺序调用插件、收集结果、处理错误、并行优化、Metrics 上报。
| 对比项 | 老调度器(v1.14 之前) | Scheduling Framework(v1.15 起,v1.36.1 已成熟) |
|---|---|---|
| 扩展形式 | 修改 core 包,编译二进制 | 实现 interface,配置 SchedulerConfiguration 后热加载 |
| 数据共享 | 通过全局缓存 + PredicateInfo 互传 | 通过 CycleState.Read/Write 显式读写 |
| 并行度 | 手写 goroutine 协调 | Filter/Score 默认走 Parallelizer 并行评估 |
| 错误语义 | bool return + error string | 标准化的 *Status + 7 个 Code(Success/Error/Unschedulable 等) |
| 抢占 | 内嵌在 algorithm 里 | 独立的 PostFilter 扩展点 |
| 批处理 | 不支持 | 通过 SignPlugin 启用 opportunistic batching(KEP-5598) |
💡 注意
框架不是万能的——它适合"在调度决策点插入逻辑",但不适合做"调度结果的下游处理"。比如"Pod 跑起来后自动注入 sidecar"这种需求,框架帮不了你,得用 admission webhook 或 operator。
这一节我们用一个最小可运行的例子把流程跑通:写一个"禁止 Pod 调度到带 team=legacy 标签的 Node 上"的 Filter 插件。完整步骤有 4 步:定义插件类型、实现接口、注册到 SchedulerConfiguration、配置 kube-scheduler 启动参数。
下面这段是插件的 Go 实现骨架(k8s v1.36.1)。完整的 import、错误处理、metrics 上报被简化掉,但关键点都齐了:实现 FilterPlugin 接口的 Filter 方法,返回 *fwk.Status。
// staging/src/k8s.io/sample-scheduler-plugin/pkg/legacyfilter/legacyfilter.go (k8s v1.36.1)
Go
package legacyfilter
import (
"context"
v1 "k8s.io/api/core/v1"
fwk "k8s.io/kube-scheduler/framework"
)
const Name = "LegacyFilter"
type LegacyFilter struct{}
func New(_ context.Context, _ runtime.Object, _ fwk.Handle) (fwk.Plugin, error) {
return &LegacyFilter{}, nil
}
// Name 必须实现 Plugin 父接口
func (l *LegacyFilter) Name() string { return Name }
// Filter 实现 FilterPlugin 接口
func (l *LegacyFilter) Filter(_ context.Context, _ fwk.CycleState, pod *v1.Pod, node fwk.NodeInfo) *fwk.Status {
if node.Node().Labels["team"] == "legacy" {
return fwk.NewStatus(fwk.Unschedulable, "node is in legacy team, reject")
}
return nil // nil 等价于 Success
}
解读:① Name() 是所有插件的"身份证",框架靠它来打日志和配 SchedulerConfiguration;② Filter 收到一个 NodeInfo(聚合了 Node 自身和已运行 Pod 的快照),我们可以读它的 label 字段;③ 返回 fwk.NewStatus(fwk.Unschedulable, ...) 表示"这个 Node 不能用",框架会把它从候选列表里剔除。
插件写好后,要让 kube-scheduler 加载它,需要准备一个 out-of-tree 入口程序(也可以直接写一个内置插件 PR 进 k8s 主仓)。最简模式是编译一个独立的 my-scheduler 二进制,引用官方 kube-scheduler 的 framework + 我们的插件包。
// staging/src/k8s.io/sample-scheduler-plugin/cmd/my-scheduler/main.go (k8s v1.36.1)
Go
func main() {
command := app.NewSchedulerCommand(
app.WithPlugin(legacyfilter.Name, legacyfilter.New),
)
if err := command.Execute(); err != nil {
os.Exit(1)
}
}
然后准备一份 SchedulerConfiguration 告诉 kube-scheduler 在哪个 profile 里启用它:
# scheduler-config.yaml (k8s v1.36.1)
YAML
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
plugins:
filter:
enabled:
- name: LegacyFilter
最后启动 kube-scheduler,指定配置和认证信息。验证清单:① 启动日志里能看到 Registered plugin: LegacyFilter;② 创建一个测试 Pod,带 nodeSelector: {team: legacy} 时 Pod 卡在 Pending;③ kubectl describe pod 的 Events 段会看到 "node is in legacy team, reject"。
🌟 实用技巧
开发阶段建议把 --v=4 开到 4 或更高,scheduler 会在每个扩展点打印耗时,方便定位慢插件。
本节是文章的核心。我们会按"调度周期 → 绑定周期"的顺序,把 6 个最常用的扩展点(PreFilter、Filter、PreScore、Score、Reserve、Bind)从接口定义 → 运行时调度逻辑 → 真实插件实现三层逐个拆开。先给出一张全局时序图帮大家建立整体认知:
┌─────────────────────────── Scheduling Cycle ───────────────────────────┐
│ Pod 入队 → NewCycleState → RunPreFilterPlugins → RunFilterPlugins │
│ → (失败)RunPostFilterPlugins │
│ → (成功)RunPreScorePlugins → RunScorePlugins → RunReservePlugins │
│ → (Permit)WaitOnPermit │
└────────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────── Binding Cycle ─────────────────────────────┐
│ RunPreBindPreFlights → WaitOnPermit → RunPreBindPlugins → RunBindPlugins
│ → RunPostBindPlugins │
└────────────────────────────────────────────────────────────────────────┘
↓
失败:Unreserve + 回到 activeQ
图解:上半分支是调度周期(同步执行,挑出 SuggestedHost),下半分支是绑定周期(可异步执行,真正落盘)。中间 Permit 是"放行闸"——默认空实现直接放行;自定义插件可以在这里让 Pod 等一会儿。失败兜底逻辑是 Unreserve(Reserve 插件配套的回滚方法)。
PreFilter 出现在调度周期的最开头——它收到的是"所有 Node 的列表",但只需要返回一个 PreFilterResult 告诉框架"这批 Node 里哪些可以保留"。这是 O(N) 级别的初筛,能在 Filter 阶段前就把一批明显不适合的 Node 砍掉。
接口定义(来自 staging/src/k8s.io/kube-scheduler/framework/interface.go):
// staging/src/k8s.io/kube-scheduler/framework/interface.go (k8s v1.36.1)
Go
// PreFilterPlugin 在调度周期开始时调用
type PreFilterPlugin interface {
Plugin
PreFilter(ctx context.Context, state CycleState, p *v1.Pod, nodes []NodeInfo) (*PreFilterResult, *Status)
PreFilterExtensions() PreFilterExtensions // 可选,返回 AddPod/RemovePod
}
type PreFilterResult struct {
NodeNames sets.Set[string] // 保留的 Node 集合;nil 表示"全部保留"
}
// PreFilterExtensions 让插件支持增量模拟
type PreFilterExtensions interface {
AddPod(ctx context.Context, state CycleState, podToSchedule *v1.Pod, podInfoToAdd PodInfo, nodeInfo NodeInfo) *Status
RemovePod(ctx context.Context, state CycleState, podToSchedule *v1.Pod, podInfoToRemove PodInfo, nodeInfo NodeInfo) *Status
}
逐行解读:CycleState 是插件间共享数据的"内存邮箱"——state.Write(key, val) 写,state.Read(key) 读,key 用 fwk.StateKey 类型避免冲突。PreFilterResult 里 NodeNames 是 nil 时表示"我没意见,全保留";返回非 nil 时框架会把这个集合与后续 PreFilter 插件的结果做 Intersection。
运行时调度逻辑(来自 pkg/scheduler/framework/runtime/framework.go,第 922 行起):
// pkg/scheduler/framework/runtime/framework.go (k8s v1.36.1)
Go
func (f *frameworkImpl) RunPreFilterPlugins(ctx context.Context, state fwk.CycleState, pod *v1.Pod) (_ *fwk.PreFilterResult, status *fwk.Status, _ sets.Set[string]) {
startTime := time.Now()
skipPlugins := sets.New[string]()
defer func() {
state.SetSkipFilterPlugins(skipPlugins) // ← 关键:把 Skip 的插件记下来
metrics.FrameworkExtensionPointDuration.WithLabelValues(metrics.PreFilter, status.Code().String(), f.profileName).Observe(metrics.SinceInSeconds(startTime))
}()
nodes, err := f.SnapshotSharedLister().NodeInfos().List()
...
var result *fwk.PreFilterResult
pluginsWithNodes := sets.New[string]()
for _, pl := range f.preFilterPlugins {
r, s := f.runPreFilterPlugin(ctx, pl, state, pod, nodes)
if s.IsSkip() {
skipPlugins.Insert(pl.Name()) // ← Skip 的插件连同它的 Filter 一起被跳过
continue
}
if !s.IsSuccess() { /* 错误处理 */ }
if !r.AllNodes() {
pluginsWithNodes.Insert(pl.Name())
}
result = result.Merge(r) // ← 多个 PreFilter 的结果取交集
}
return result, returnStatus, pluginsWithNodes
}
解读:这个循环有 3 个关键点——① 用 s.IsSkip() 跳过的插件,会被记到 skipFilterPlugins 里,框架后面调用 Filter 阶段时直接跳过它们;② 多个 PreFilter 的 PreFilterResult 通过 Merge 取交集(参考 interface.go 的 Merge 方法);③ 如果最终交集为空,会返回 UnschedulableAndUnresolvable 状态——这意味着所有 Node 都被这个插件干掉了,框架连 PostFilter(preemption)都不会跑。
真实案例(pkg/scheduler/framework/plugins/interpodaffinity/filtering.go 第 274 行的 PreFilter):
// pkg/scheduler/framework/plugins/interpodaffinity/filtering.go (k8s v1.36.1)
Go
// PreFilter invoked at the prefilter extension point.
func (pl *InterPodAffinity) PreFilter(ctx context.Context, cycleState fwk.CycleState, pod *v1.Pod, allNodes []fwk.NodeInfo) (*fwk.PreFilterResult, *fwk.Status) {
var nodesWithRequiredAntiAffinityPods []fwk.NodeInfo
var err error
if nodesWithRequiredAntiAffinityPods, err = pl.sharedLister.NodeInfos().HavePodsWithRequiredAntiAffinityList(); err != nil {
return nil, fwk.AsStatus(fmt.Errorf("failed to list NodeInfos with pods with affinity: %w", err))
}
s := &preFilterState{}
if s.podInfo, err = framework.NewPodInfo(pod); err != nil {
return nil, fwk.NewStatus(fwk.UnschedulableAndUnresolvable, fmt.Sprintf("parsing pod: %+v", err))
}
// 解析 namespaceSelector,把 RequiredAffinityTerms 合并到具体 namespace
for i := range s.podInfo.GetRequiredAffinityTerms() {
if err := pl.mergeAffinityTermNamespacesIfNotEmpty(s.podInfo.GetRequiredAffinityTerms()[i]); err != nil {
return nil, fwk.AsStatus(err)
}
}
...
s.existingAntiAffinityCounts = pl.getExistingAntiAffinityCounts(ctx, pod, s.namespaceLabels, nodesWithRequiredAntiAffinityPods)
s.affinityCounts, s.antiAffinityCounts = pl.getIncomingAffinityAntiAffinityCounts(ctx, s.podInfo, allNodes)
// 如果这个 Pod 没有任何 affinity 规则,直接 Skip——后续 Filter 也会被跳过
if len(s.existingAntiAffinityCounts) == 0 && len(s.podInfo.GetRequiredAffinityTerms()) == 0 && len(s.podInfo.GetRequiredAntiAffinityTerms()) == 0 {
return nil, fwk.NewStatus(fwk.Skip)
}
cycleState.Write(preFilterStateKey, s) // ← 关键:把预计算结果塞进 CycleState
return nil, nil
}
解读:InterPodAffinity 的 PreFilter 不返回 PreFilterResult(留 nil),它真正做的是"把亲和性/反亲和性数据预计算好,写进 CycleState"。这样后面 Filter 阶段每个 Node 评估时,直接从 CycleState 读结果,避免对每个 Node 重复扫描整个集群的 Pod——这就是 PreFilter 阶段最常见的模式:"把昂贵的 O(N) 计算做一次,结果共享给 Filter"。
📄 官方引用 — 引用自 k8s 官方源码注释
当 PreFilter 返回 Skip 状态时,"returned PreFilterResult and other fields in status are just ignored, and coupled Filter plugin/PreFilterExtensions() will be skipped in this scheduling cycle."(interface.go 第 521 行)——也就是说 PreFilter 的 Skip 是"成对跳过"机制。
Filter 拿到的是"单个 NodeInfo + Pod",要回答"这个 Pod 能不能跑在这个 Node 上"。Filter 是 v1.36.1 调度器里调用次数最多、并行度最高的一环。
接口定义:
// staging/src/k8s.io/kube-scheduler/framework/interface.go (k8s v1.36.1)
Go
// FilterPlugin 接收一个 NodeInfo,返回这个 Node 是否能跑这个 Pod
type FilterPlugin interface {
Plugin
Filter(ctx context.Context, state CycleState, pod *v1.Pod, nodeInfo NodeInfo) *Status
}
运行时调度逻辑(pkg/scheduler/framework/runtime/framework.go 第 1093 行):
// pkg/scheduler/framework/runtime/framework.go (k8s v1.36.1)
Go
// RunFilterPlugins runs the set of configured Filter plugins for pod on the given node.
func (f *frameworkImpl) RunFilterPlugins(
ctx context.Context,
state fwk.CycleState,
pod *v1.Pod,
nodeInfo fwk.NodeInfo,
) (status *fwk.Status) {
...
for _, pl := range f.filterPlugins {
if state.GetSkipFilterPlugins().Has(pl.Name()) {
continue // ← PreFilter Skip 的插件,这里直接跳过
}
...
status = f.runFilterPlugin(ctx, pl, state, pod, nodeInfo)
if !status.IsSuccess() {
return status
}
}
return nil
}
解读:注意 state.GetSkipFilterPlugins() 这一行——它就是 PreFilter 阶段记录的"该被跳过的插件集合"。如果 PreFilter 返回 Skip,Filter 阶段这个插件就被静默跳过,整个调度周期对每个 Node 都少算一项。并行逻辑不在 RunFilterPlugins 内,而在调用者 pkg/scheduler/schedule_one.go 的 findNodesThatPassFilters 里——它会用 f.Parallelizer().Until(...) 把"对每个 Node 调用 RunFilterPlugins"并行化。
真实案例(InterPodAffinity 的 Filter):
// pkg/scheduler/framework/plugins/interpodaffinity/filtering.go (k8s v1.36.1)
Go
// Filter invoked at the filter extension point.
// It checks if a pod can be scheduled on the specified node with pod affinity/anti-affinity configuration.
func (pl *InterPodAffinity) Filter(ctx context.Context, cycleState fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) *fwk.Status {
state, err := getPreFilterState(cycleState) // ← 关键:从 CycleState 读 PreFilter 写的数据
if err != nil {
return fwk.AsStatus(err)
}
if !satisfyPodAffinity(state, nodeInfo) {
return fwk.NewStatus(fwk.UnschedulableAndUnresolvable, ErrReasonAffinityRulesNotMatch)
}
if !satisfyPodAntiAffinity(state, nodeInfo) {
return fwk.NewStatus(fwk.Unschedulable, ErrReasonAntiAffinityRulesNotMatch)
}
if !satisfyExistingPodsAntiAffinity(state, nodeInfo) {
return fwk.NewStatus(fwk.Unschedulable, ErrReasonExistingAntiAffinityRulesNotMatch)
}
return nil
}
// getPreFilterState 从 CycleState 反序列化 PreFilter 阶段写入的对象
func getPreFilterState(cycleState fwk.CycleState) (*preFilterState, error) {
c, err := cycleState.Read(preFilterStateKey)
if err != nil {
return nil, fmt.Errorf("error reading %q from cycleState: %w", preFilterStateKey, err)
}
s, ok := c.(*preFilterState)
if !ok {
return nil, fmt.Errorf("%+v convert to interpodaffinity.state error", c)
}
return s, nil
}
解读:这里我们看到 PreFilter/Filter 的"标准数据流":① PreFilter 里 cycleState.Write(preFilterStateKey, s) 把结构体写进去;② Filter 里 cycleState.Read(preFilterStateKey) 读出来。注意 getPreFilterState 的类型断言 c.(*preFilterState)——插件作者必须保证写入的对象实现了 StateData 接口(也就是 Clone() 方法)。
💡 注意
UnschedulableAndUnresolvable vs Unschedulable:前者告诉框架"别想通过 preemption 解决",后者表示"可以试试 PostFilter 抢占"。InterPodAffinity 这里分别用对了:required 亲和性不满足→不可解析;preferred/preferred 反亲和性不满足→可被 preemption 解决。
Filter 通过后,进入打分阶段。这里有两个扩展点:PreScore 是"打分前的预热"(可选),Score 才是真正给每个 Node 打 0~100 分的函数。
接口定义:
// staging/src/k8s.io/kube-scheduler/framework/interface.go (k8s v1.36.1)
Go
type PreScorePlugin interface {
Plugin
PreScore(ctx context.Context, state CycleState, pod *v1.Pod, nodes []NodeInfo) *Status
}
type ScorePlugin interface {
Plugin
Score(ctx context.Context, state CycleState, p *v1.Pod, nodeInfo NodeInfo) (int64, *Status)
ScoreExtensions() ScoreExtensions
}
// Score 分数的范围:0 ~ 100
const (
MaxScore int64 = 100
MinScore int64 = 0
)
运行时 Score 调度(pkg/scheduler/framework/runtime/framework.go 第 1339 行起):
// pkg/scheduler/framework/runtime/framework.go (k8s v1.36.1)
Go
func (f *frameworkImpl) RunScorePlugins(ctx context.Context, state fwk.CycleState, pod *v1.Pod, nodes []fwk.NodeInfo) (ns []fwk.NodePluginScores, status *fwk.Status) {
...
allNodePluginScores := make([]fwk.NodePluginScores, len(nodes))
numPlugins := len(f.scorePlugins)
plugins := make([]fwk.ScorePlugin, 0, numPlugins)
pluginToNodeScores := make(map[string]fwk.NodeScoreList, numPlugins)
for _, pl := range f.scorePlugins {
if state.GetSkipScorePlugins().Has(pl.Name()) {
continue // ← PreScore 返回 Skip 的插件,这里直接跳过
}
plugins = append(plugins, pl)
pluginToNodeScores[pl.Name()] = make(fwk.NodeScoreList, len(nodes))
}
...
// 关键:跨 Node 维度并行,每个 Node 上再串行调用每个 Score 插件
f.Parallelizer().Until(ctx, len(nodes), func(index int) {
nodeInfo := nodes[index]
...
for _, pl := range plugins {
s, status := f.runScorePlugin(ctx, pl, state, pod, nodeInfo)
...
pluginToNodeScores[pl.Name()][index] = fwk.NodeScore{Name: nodeName, Score: s}
}
}, metrics.Score)
...
}
解读:Score 阶段的并行粒度是"按 Node 拆"——框架为每个 Node 起一个 goroutine,goroutine 内部再串行调用每个 Score 插件。这种"外层并行、内层串行"的设计平衡了并行度和锁开销。Score 之后会做 Normalize(把所有分数线性映射到 0~100),再用 Plugin Weight 做加权求和,得到每个 Node 的 TotalScore。
真实 Score 案例(VolumeBinding):
// pkg/scheduler/framework/plugins/volumebinding/volume_binding.go (k8s v1.36.1)
Go
// Score invoked at the score extension point.
func (pl *VolumeBinding) Score(ctx context.Context, cs fwk.CycleState, pod *v1.Pod, nodeInfo fwk.NodeInfo) (int64, *fwk.Status) {
if pl.scorer == nil {
return 0, nil
}
state, err := getStateData(cs) // ← 读 PreFilter 写入的 stateData
if err != nil {
return 0, fwk.AsStatus(err)
}
nodeName := nodeInfo.Node().Name
podVolumes, ok := state.podVolumesByNode[nodeName]
if !ok {
return 0, nil // 该 Node 没有匹配的卷信息
}
// 把静态绑定 / 动态供给的卷按 StorageClass 聚合,算出"剩余容量得分"
classResources := make(classResourceMap)
if len(podVolumes.StaticBindings) != 0 || !pl.fts.EnableStorageCapacityScoring {
for _, staticBinding := range podVolumes.StaticBindings {
class := staticBinding.StorageClassName()
...
classResources[class].Requested += storageResource.Requested
classResources[class].Capacity += storageResource.Capacity
}
} else {
for _, provision := range podVolumes.DynamicProvisions {
...
classResources[class].Capacity = provision.NodeCapacity.Capacity.Value()
requestQty := provision.PVC.Spec.Resources.Requests[v1.ResourceName(v1.ResourceStorage)]
classResources[class].Requested += requestQty.Value()
}
}
return pl.scorer(classResources), nil // 把聚合结果喂给具体的打分函数
}
解读:VolumeBinding 的 Score 完美演示了 CycleState 的"接力赛":PreFilter 阶段算出"这个 Pod 在每个 Node 上需要绑哪些卷"塞进 state.podVolumesByNode;Score 阶段读出来按 StorageClass 聚合,算"剩余空间够不够"的分数。如果你写一个自己的 Score 插件,强烈建议把"读 CycleState"封装成一个 helper(比如这里的 getStateData),避免每个扩展点都写一遍类型断言。
Score 完成后,调度器已经选出了 SuggestedHost。但此时 Pod 还没真正绑定 apiserver——如果直接进入 Bind 阶段,这段"还没绑"的时间里另一个 Pod 可能也被调度到同一个 Node 上。所以中间有一个 Reserve 阶段,让插件"在 scheduler 自己的缓存里"先把这个资源占住。
接口定义:
// staging/src/k8s.io/kube-scheduler/framework/interface.go (k8s v1.36.1)
Go
// ReservePlugin 用于更新插件自身状态
// 老调度器里叫 "assume"(假设这个 Pod 已经被绑定)
type ReservePlugin interface {
Plugin
Reserve(ctx context.Context, state CycleState, p *v1.Pod, nodeName string) *Status
// Unreserve 是 Reserve 的回滚;必须是幂等的
Unreserve(ctx context.Context, state CycleState, p *v1.Pod, nodeName string)
}
运行时调度(pkg/scheduler/framework/runtime/framework.go 第 1859 行):
// pkg/scheduler/framework/runtime/framework.go (k8s v1.36.1)
Go
// RunReservePluginsReserve runs the Reserve method in the set of configured
// reserve plugins. If any of these plugins returns an error, it does not
// continue running the remaining ones and returns the error.
func (f *frameworkImpl) RunReservePluginsReserve(ctx context.Context, state fwk.CycleState, pod *v1.Pod, nodeName string) (status *fwk.Status) {
...
for _, pl := range f.reservePlugins {
status = f.runReservePluginReserve(ctx, pl, state, pod, nodeName)
if !status.IsSuccess() {
if status.IsRejected() {
...
return status
}
err := status.AsError()
logger.Error(err, "Plugin failed", "plugin", pl.Name(), "pod", klog.KObj(pod))
return fwk.AsStatus(fmt.Errorf("running Reserve plugin %q: %w", pl.Name(), err))
}
}
return nil
}
解读:注意 Reserve 阶段的失败行为——如果任意一个 Reserve 插件失败,循环立即返回,不会调用后续的 Reserve 插件。返回失败后,框架会反向调用所有"已成功执行 Reserve 的插件"的 Unreserve 做回滚——这正是为什么 Unreserve 必须是幂等的。
真实案例(VolumeBinding 的 Reserve):
// pkg/scheduler/framework/plugins/volumebinding/volume_binding.go (k8s v1.36.1)
Go
// Reserve reserves volumes of pod and saves binding status in cycle state.
func (pl *VolumeBinding) Reserve(ctx context.Context, cs fwk.CycleState, pod *v1.Pod, nodeName string) *fwk.Status {
state, err := getStateData(cs)
if err != nil {
return fwk.AsStatus(err)
}
// we don't need to hold the lock as only one node will be reserved for the given pod
podVolumes, ok := state.podVolumesByNode[nodeName]
if ok {
// ← 关键:调用 AssumePodVolumes,把卷"假设绑定"到这个 Node
allBound, err := pl.Binder.AssumePodVolumes(klog.FromContext(ctx), pod, nodeName, podVolumes)
if err != nil {
return fwk.AsStatus(err)
}
state.allBound = allBound
} else {
// may not exist if the pod does not reference any PVC
state.allBound = true
}
return nil
}
解读:VolumeBinding 的 Reserve 干了两件事——① 拿到 PreFilter/Score 阶段塞进 state.podVolumesByNode 的卷信息;② 调用 pl.Binder.AssumePodVolumes 把卷"假设绑定"——这是 VolumeBinding 内部的 AssumeCache 在内存里标记"这个 PV 已经被这个 Pod 占了",避免另一个 Pod 也认为这个 PV 可用。
过了 Reserve 后,调度器开始进入绑定周期。Permit 是"放行闸",可以选实现;PreBind 是"绑定前最后准备"(典型应用:VolumeBinding 真正调用 PV 控制器做绑定);Bind 是"最终落槌";PostBind 是"绑定后清理"。
接口定义:
// staging/src/k8s.io/kube-scheduler/framework/interface.go (k8s v1.36.1)
Go
type PermitPlugin interface {
Plugin
// 返回 (*Status, time.Duration):要么 Success 放行,要么 Wait 一段时间,要么直接拒
Permit(ctx context.Context, state CycleState, p *v1.Pod, nodeName string) (*Status, time.Duration)
}
type PreBindPlugin interface {
Plugin
// PreBindPreFlight 决定这个插件是否要真正跑 PreBind
PreBindPreFlight(ctx context.Context, state CycleState, p *v1.Pod, nodeName string) (*PreBindPreFlightResult, *Status)
PreBind(ctx context.Context, state CycleState, p *v1.Pod, nodeName string) *Status
}
// BindPlugin 的 Bind 只能返回 Skip 或非 Skip;第一个返回非 Skip 的会"独占"绑定动作
type BindPlugin interface {
Plugin
Bind(ctx context.Context, state CycleState, p *v1.Pod, nodeName string) *Status
}
type PostBindPlugin interface {
Plugin
// 失败不影响 Pod 状态,常用于清理
PostBind(ctx context.Context, state CycleState, p *v1.Pod, nodeName string)
}
运行时 Bind 调度(pkg/scheduler/framework/runtime/framework.go 第 1775 行):
// pkg/scheduler/framework/runtime/framework.go (k8s v1.36.1)
Go
func (f *frameworkImpl) RunBindPlugins(ctx context.Context, state fwk.CycleState, pod *v1.Pod, nodeName string) (status *fwk.Status) {
...
if len(f.bindPlugins) == 0 {
return fwk.NewStatus(fwk.Skip, "") // ← 没有 Bind 插件时,框架走默认路径
}
for _, pl := range f.bindPlugins {
...
status = f.runBindPlugin(ctx, pl, state, pod, nodeName)
if status.IsSkip() {
continue // ← 这个插件不想绑定,让下一个试
}
if !status.IsSuccess() { /* 错误处理 */ }
return status // ← 第一个非 Skip 的插件接管,后续不再调用
}
return status
}
解读:Bind 阶段的设计是"先到先得 + 唯一胜出"——多个 Bind 插件按顺序调用,第一个返回非 Skip 状态的插件接管整个绑定动作,后续插件直接被跳过。如果所有 Bind 插件都返回 Skip(默认行为),框架会走自己的"兜底"逻辑:调用 apiserver 的 Binding sub-resource 接口。
默认 Bind 插件(pkg/scheduler/framework/plugins/defaultbinder/default_binder.go):
// pkg/scheduler/framework/plugins/defaultbinder/default_binder.go (k8s v1.36.1)
Go
type DefaultBinder struct {
ClientSet clientset.Interface
}
func NewDefaultBinder(c clientset.Interface) framework.Plugin { return &DefaultBinder{c} }
func (b DefaultBinder) Name() string { return Name }
// Bind 通过 apicaller 异步向 apiserver 发 Binding 请求
func (b DefaultBinder) Bind(ctx context.Context, _ fwk.CycleState, p *v1.Pod, nodeName string) *fwk.Status {
binding := &v1.Binding{
ObjectMeta: metav1.ObjectMeta{Namespace: p.Namespace, Name: p.Name, UID: p.UID},
Target: v1.ObjectReference{Kind: "Node", Name: nodeName},
}
return fwk.AsStatus(b.ClientSet.CoreV1().Pods(p.Namespace).Bind(ctx, binding, metav1.CreateOptions{}))
}
解读:DefaultBinder 是最简实现——构造一个 v1.Binding 对象(包含目标 Pod 的 namespace/name/UID 和目标 Node),然后调 apiserver 的 /api/v1/namespaces/{ns}/pods/{name}/binding sub-resource。返回值用 fwk.AsStatus(err) 包装——如果 err 非空就是 Error 状态,否则是 nil(Success)。
HTTPExtender(pkg/scheduler/extender.go 第 362 行)的 Bind 演示了"把绑定委托给外部服务"的写法:
// pkg/scheduler/extender.go (k8s v1.36.1)
Go
// Bind delegates the action of binding a pod to a node to the extender.
func (h *HTTPExtender) Bind(binding *v1.Binding) error {
var result extenderv1.ExtenderBindingResult
if !h.IsBinder() {
return fmt.Errorf("unexpected empty bindVerb in extender")
}
req := &extenderv1.ExtenderBindingArgs{
PodName: binding.Name,
PodNamespace: binding.Namespace,
PodUID: binding.UID,
Node: binding.Target.Name,
}
if err := h.send(h.bindVerb, req, &result); err != nil {
return err
}
if result.Error != "" {
return errors.New(result.Error)
}
return nil
}
解读:注意 Extender 不实现 Framework 的 BindPlugin interface——它是"在 k8s 调度器之外的另一种扩展形式",通过 HTTP 协议和外部服务通信。这就是为什么 v1.36.1 的 SchedulerConfiguration 有两个并列配置:extenders 和 profiles[].plugins。前者适合"已有外部服务、想最小改动接入",后者适合"想写 Go 代码深度定制"。
最后我们把 CycleState 单独拎出来讲一遍——它是整个扩展点机制能跑起来的"血液系统"。源码位置:staging/src/k8s.io/kube-scheduler/framework/cycle_state.go
// staging/src/k8s.io/kube-scheduler/framework/cycle_state.go (k8s v1.36.1)
Go
// StateData 是 CycleState 存的对象的接口约束
type StateData interface {
Clone() StateData // 浅拷贝即可
}
// StateKey 是 CycleState 的 key 类型
type StateKey string
// CycleState 是一次调度周期内的"插件共享数据总线"
type CycleState interface {
ShouldRecordPluginMetrics() bool
GetSkipFilterPlugins() sets.Set[string] // PreFilter Skip → Filter 跳过
SetSkipFilterPlugins(plugins sets.Set[string])
GetSkipScorePlugins() sets.Set[string] // PreScore Skip → Score 跳过
SetSkipScorePlugins(plugins sets.Set[string])
GetSkipPreBindPlugins() sets.Set[string] // PreBindPreFlight Skip → PreBind 跳过
SetSkipPreBindPlugins(plugins sets.Set[string])
GetParallelPreBindPlugins() sets.Set[string]
SetParallelPreBindPlugins(plugins sets.Set[string])
ShouldSkipAllPostFilterPlugins() bool
Read(key StateKey) (StateData, error)
Write(key StateKey, val StateData)
Delete(key StateKey)
Clone() CycleState
// GenericWorkload feature gate 启用后才会有值
IsPodGroupSchedulingCycle() bool
GetPodGroupSchedulingCycle() PodGroupCycleState
SetPodGroupSchedulingCycle(PodGroupCycleState)
}
解读:CycleState 接口里分两类方法——① 框架专用的 metadata 方法(GetSkipFilterPlugins、GetParallelPreBindPlugins 等),这些是框架内部用来协调多阶段行为的,插件作者一般不用碰;② 插件用的 Read/Write/Delete,加 Clone。其中 Clone 是关键——preemption 阶段会 Clone 出一份新状态,避免和原状态打架。
我们把扩展点和 CycleState 的协作画成一张图:
PreFilter ──[Write key1: data1]──┐
│
Filter ──[Read key1]───────────┤──→ nodeInfo 评估
PreScore ──[Read key1 + Write key2: data2]──┐
│
Score ──[Read key2]────────────────────────┤
Reserve ──[Read key2 + 标记 Assume]────────┤
Permit ──[可选 Wait]──────────────────────┤
PreBind ──[读 + 真正执行副作用]─────────────┤
Bind ──[发 Binding 请求到 apiserver]────┘
PostBind ──[清理 / 日志]─────────────────────
图解:左列是阶段,中列是 CycleState 上的操作(Write/Read),右列是副作用。"读"几乎贯穿所有阶段——这就是为什么 PreFilter 阶段的"全局预计算"是性能优化的关键:后面所有阶段都能从 CycleState 直接拿结果,不用再扫集群。
这一节把生产环境最常踩的 6 个坑列出来,每个都附"现象 → 根因 → 排查 → 修复"四步走。这些坑不是脑补的——前 3 个是从 SIG-Scheduling 邮件列表和 GitHub Issues 总结的"经典坑",后 3 个是 v1.36.x 升级时新引入的细节。
现象:kubectl describe pod 看到 0/1 nodes are available: 1 plugin returned Skip; remaining plugins did not return Success.,scheduler 日志反复打印 error reading xxx from cycleState: not found。
根因:PreFilter 返回 Skip 会让框架跳过这个插件的 Filter,但如果 PreFilter 写了数据,Filter 即使不被调,CycleState 里也残留了脏数据。当另一个不 Skip 的插件也用同名 key 写数据时,可能引发类型混乱。
排查:用 kubectl logs -n kube-system kube-scheduler-xxx -v=8 | grep "SkipFilterPlugins" 看具体被跳过的插件。
修复:插件要遵循"要么 PreFilter+Filter 同时实现,要么都不实现"的成对原则。如果 PreFilter 里发现"这个 Pod 不需要我处理",应该 return nil, fwk.NewStatus(fwk.Skip) 且不写 CycleState。参考 interpodaffinity 第 304-308 行的写法。
现象:preemption 阶段偶发 panic:runtime error: invalid memory address or nil pointer dereference,或在并发场景下两个 Pod 看到"被对方污染的"数据。
根因:CycleState 在 preemption 阶段会 Clone() 出多份并行副本。如果你的 StateData 里有 slice/map 字段但没深拷贝,副本会共享底层数据,preemption 模拟 RemovePod 时改了一个 Pod 的 state,另一个 Pod 的 Filter 读到"被串改的" state。
排查:检查你的 StateData 类型有没有实现 Clone() StateData。注意 cycle_state.go 第 32 行的注释:"For performance reasons, clone should make shallow copies for members (e.g., slices or maps) that are not impacted by PreFilter's optional AddPod/RemovePod methods."——意思是被 AddPod/RemovePod 改的字段必须深拷贝。
修复:Clone 写法参考 interpodaffinity 的 preFilterState:
// pkg/scheduler/framework/plugins/interpodaffinity/filtering.go (k8s v1.36.1 简化版)
Go
// Clone implements StateData interface.
func (s *preFilterState) Clone() fwk.StateData {
if s == nil {
return nil
}
// 浅拷贝外壳
clone := &preFilterState{
podInfo: s.podInfo,
namespaceLabels: s.namespaceLabels,
existingAntiAffinityCounts: s.existingAntiAffinityCounts, // map,可以共享
}
// 被 AddPod/RemovePod 改的字段必须深拷贝
clone.affinityCounts = s.affinityCounts.deepCopy()
clone.antiAffinityCounts = s.antiAffinityCounts.deepCopy()
return clone
}
现象:scheduler.log 出现 Score plugin xxx: failed with: not found,但偶发且与节点数量相关。
根因:如果你的插件同时实现了 PreScore 和 Score,PreScore 返回 Skip 会让 Score 阶段被跳过;但 state.GetSkipScorePlugins() 是在 RunScorePlugins 入口处统一检查的——这点 4.3 节的代码已经体现了,但老插件代码里常有人手动循环调 Score 时忘了查。
排查:在 Score 入口先 if state.GetSkipScorePlugins().Has(pl.Name()) { return 0, nil } 兜底。
预防:写插件时把"读 CycleState"封装在 getStateData 函数里,函数内部检查 Skip 并返回默认值(return &stateData{}, nil)。
现象:生产环境所有 Pod 都跑不通自定义 Bind 路径,但日志显示 DefaultBinder 调到了 apiserver。
根因:Bind 插件有个特殊契约——返回 Skip 表示"我不管,下一个"。如果你的 Bind 实现里 没在不该管时返回 Skip,就会拦截所有 Pod。
修复:参考 4.5 节 RunBindPlugins 的实现,第一个返回非 Skip 的插件会"独占"绑定动作。在自定义 Bind 插件里:
// 自定义 Bind 插件伪代码 (k8s v1.36.1)
Go
func (p *MyBinder) Bind(ctx context.Context, _ fwk.CycleState, pod *v1.Pod, nodeName string) *fwk.Status {
// 关键:先判断"这个 Pod 我管不管"
if !p.shouldHandle(pod) {
return fwk.NewStatus(fwk.Skip, "") // ← 不管就让位
}
// ... 真正处理绑定
return nil
}
现象:Pod 调度失败重试后,scheduler 内部 AssumeCache 里残留旧 Pod 的状态,下一轮调度 Filter 阶段报"卷被占用"。
根因:interface.go 第 644 行的注释明确说:"The Unreserve method implementation must be idempotent and may be called by the scheduler even if the corresponding Reserve method for the same plugin was not called."——Unreserve 可能被调用多次,且可能没调过 Reserve 就直接调 Unreserve。
修复:Unreserve 实现先 if state, err := getStateData(cs); state == nil || err != nil { return } 兜底,函数内所有清理操作都要"先检查再删"。
🚀 版本更新 — k8s v1.36.1 引入 / 变更
v1.36 起,OpportunisticBatching feature gate 进一步收紧,要求 Scoring/Prescoring/Filtering/PreFiltering 的插件必须实现 SignPlugin,否则会"静默关闭批处理"——日志里看不到明显错误,但调度延迟可能上升。
修复:v1.36 升级时务必检查所有自定义插件是否实现 SignPod(ctx, pod) ([]SignFragment, *Status)。如果你的插件过滤逻辑是"看 pod spec 的某个字段",就把这个字段序列化到 SignFragment.Value 里。
⛔ 生产环境禁止操作 — 严禁在生产环境执行以下命令
kubectl delete pods -n kube-system -l component=kube-scheduler
后果:杀掉所有 scheduler pod 不会丢数据,但会触发 leader election 切换;切换过程中如果有大量 Pod 处于 PreFilter 阶段,可能出现 30~60 秒的"调度停滞",期间所有新 Pod 都 Pending。
这一节用 20 组问答把读者最常见的疑问一次性解决。Question 用红色加粗,Answer 用绿色加粗,Question / Answer 用全称不用缩写。
Question 1: PreFilter 和 Filter 的区别到底是什么?什么场景下应该用哪个?
Answer:PreFilter 一次性处理所有 Node("全局视角"),Filter 处理单个 Node("局部视角")。PreFilter 的设计意图有两个:① 快速缩小候选集(如 NodeSelector 已经在 API Server 过滤过,PreFilter 可以再砍一波);② 做昂贵的预计算并写入 CycleState,让后续 Filter 直接读结果。判断标准:如果你的判断逻辑是"这个 Pod 整体的某些属性"(如 PVC、Toleration、NamespaceSelector),用 PreFilter;如果是"Pod 和这个 Node 之间的关系"(如 Node 上的资源够不够),用 Filter。
Question 2: CycleState 在 PreFilter 写的数据,为什么不能直接在 Filter 里"读+改"然后让 Score 看到?
Answer:理论上可以读+改,但实际上 强烈不建议。原因有二:① Filter 会被并行调用(每个 Node 一个 goroutine),共享 state 会引发 data race;② preemption 阶段会 Clone CycleState 出来模拟 RemovePod,如果你 Filter 改了 state 的某个 map,切到 preemption 模拟时会拿到不一致的数据。规范做法是 Filter 只 Read 不 Write,需要追加新数据用一个新的 StateKey 写入。
Question 3: PreFilter 返回 Skip 和返回 nil 在源码里是怎么区分处理的?
Answer:看 pkg/scheduler/framework/runtime/framework.go 第 948 行 if s.IsSkip() 判断:返回 Skip 时插件名被加入 skipFilterPlugins 集合,框架后续会跳过这个插件的 Filter;返回 nil(Success)则按正常流程合并 PreFilterResult。换句话说,Skip 是"我决定这次调度不需要我",Success 是"我处理完了,结果在 result 里"。
Question 4: UnschedulableAndUnresolvable 和 Unschedulable 在源码里到底差在哪?调度器对它们的行为有区别吗?
Answer:有区别,差在是否进 PostFilter(preemption)。在 pkg/scheduler/schedule_one.go 第 288 行 schedulingAlgorithm 里,如果 SchedulePod 失败,会尝试 RunPostFilterPlugins;但 pkg/scheduler/framework/runtime/framework.go 第 954 行 PreFilter 失败时返回 UnschedulableAndUnresolvable 会直接终止 PreFilter 循环不再继续。简单说:Unschedulable = 试试抢占;UnschedulableAndUnresolvable = 别试了,Pod 进 unschedulablePods 等事件触发。
Question 5: 我的自定义插件在 Filter 阶段返回了 fwk.AsStatus(err),err 里带了具体信息,但 kubectl describe pod 里看到的还是 "0/1 nodes are available" 的通用信息,详细信息去哪了?
Answer:这通常是 FitError 的诊断信息被吞了。Pod 调度失败时,框架会构造 framework.FitError,里面有个 Diagnosis 结构记录每个 Node 的失败原因;这些原因会被收集进 Pod 的 Events。检查 status.reasons 是否非空——只有 status.code + status.reasons 都有值,Events 里才会显示你写的错误信息。生产排错时打开 scheduler -v=4 能直接看到。
Question 6: v1.36.1 里 PreScore 看起来像"可选",什么场景下必须实现它?
Answer:PreScore 适合 "Filter 通过后、Score 之前需要做一次共享的预计算"。典型场景:PodTopologySpread 要在所有可行 Node 上算"Pod 分布"——这个计算如果放在 Score 内部每个 Node 都会算一遍;放 PreFilter 又太早(不知道哪些 Node 通过了 Filter)。PreScore 是中间地带:调用时机是"已知通过 Filter 的 Node 列表",且在 Score 之前,每个 Node 的 Score 可以直接读 PreScore 写的结果。
Question 7: Reserve 阶段真的会把"资源占住"吗?会不会和 scheduler cache 的 Assume 机制冲突?
Answer:Reserve 和 Assume 是 不同层面 的事。Assume 是 scheduler 主流程里把 Pod 信息加到 cache(sched.assume 在 pkg/scheduler/schedule_one.go 第 326 行),让其他 Pod 的 Filter 看到这个 Pod 已经"假设占用"了 Node 资源;Reserve 是给插件自己留的"占座"机会——典型应用是 VolumeBinding 内部 AssumePodVolumes,在 AssumeCache 里把 PV 标记为"已占"。两者不冲突,Reserve 是 Assume 之后做的更细粒度的资源预留。
Question 8: 调度器是怎么决定哪些 Pod 走 Permit、哪些不走的?我能强制让所有 Pod 都过 Permit 吗?
Answer:Permit 走不走取决于 你是否注册了 PermitPlugin。在 SchedulerConfiguration 里:
# scheduler-config-permit.yaml (k8s v1.36.1)
YAML
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
plugins:
permit:
enabled:
- name: MyPermitPlugin
没注册 Permit 插件时,框架直接放行,Pod 立即进入 Bind 阶段。注册了之后,Permit 返回 Success 放行;返回 Wait 加上超时时间则 Pod 进 waitingPods 队列等 permit。v1.36 内置只有 WaitOnPermit 这个辅助插件可以用,自己写 Permit 插件通常是等外部审批。
Question 9: Bind 插件返回 Skip 的设计意图是什么?为什么不让所有 Bind 插件都被调用?
Answer:"先到先得 + 唯一胜出"是为了 保证绑定动作只发生一次。如果多个 Bind 插件都执行真正的绑定,Pod 会被多个路径同时设置 spec.nodeName,可能产生并发写冲突。返回 Skip 的语义是"我判断这个 Pod 不该我绑,让下一个来",框架会按注册顺序继续尝试;如果所有 Bind 插件都返回 Skip,最终由框架默认调 apiserver 的 /binding sub-resource 完成绑定。
Question 10: 怎么调试 CycleState 里到底存了什么?有没有类似 Redis 那种"看一眼"的方法?
Answer:CycleState 没有外部可视化 API(它就是内存里的一个 map),但调试技巧有 3 个:① 启动 scheduler 时加 --v=6,会打印每个插件的执行耗时;② 在自定义插件的 Read/Write 处加 klog.V(4).InfoS("xxx", "key", key, "val", val),让日志显示;③ 用 delve 在 cs.Read / cs.Write 上打断点,配合 unit test 看运行时值。
Question 11: v1.36.1 的 PreBindPreFlight 是个什么新东西?为什么 PreBind 之前要先 PreBindPreFlight?
Answer:PreBindPreFlight 是 v1.30 之后引入的"轻量级 PreBind 决策"——它 不执行实际副作用(如不创建资源),只决定"这个 PreBind 插件要不要跑真正的 PreBind"。两个价值:① 减少不必要的资源操作——比如 VolumeBinding 看到 Pod 没用 PVC 就直接 Skip PreBind;② 让 PreBind 支持并行分组——返回 PreBindPreFlightResult{AllowParallel: true} 的插件会被分到同一个并行组,框架同时跑这些插件的 PreBind;返回 false 的插件独立串行执行。
Question 12: 调度一个 Pod 的过程中,scheduler 会不会自动重试 Reserve 失败的情况?
Answer:不会自动重试 Reserve 本身,但会 回滚到 Unreserve 并让 Pod 重新进队列。看 pkg/scheduler/schedule_one.go 第 337 行 assumeAndReserve,Reserve 失败时调 unreserveAndForget,然后 Pod 进 backoffQ 等 backoff 时间到再重试。所以 Unreserve 必须是幂等的——它可能被调用多次,包括 Reserve 没成功时也调。
Question 13: v1.36.1 的 NodeInfo 包含哪些字段?为什么 Filter 阶段看到的是 NodeInfo 而不是直接拿 Node?
Answer:NodeInfo 是 "Node + 节点上 Pod 列表的聚合快照"。源码看 pkg/scheduler/framework/types.go 第 166-214 行,字段包括:
用 NodeInfo 而不是直接拿 Node 的关键原因:① Filter 需要看"假设占用"的 Pod(如 Reserve 阶段标记的 AssumePod),Node 对象本身没有这些信息;② 避免每个插件都去 informers cache 查 Pod 列表。
Question 14: 生产环境如何观察某个插件的执行耗时?Metrics 暴露了哪些标签?
Answer:框架通过 metrics.FrameworkExtensionPointDuration 暴露每个扩展点(PreFilter/Filter/Score/Reserve/Bind 等)的耗时,标签是 {extension_point, status, profile}。如果启用 pluginMetricsSamplePercent(默认很小),还会暴露 scheduler_plugin_execution_duration,标签包含 {plugin, extension_point, status}。PromQL 例子:
$ PromQL 排查慢插件
# 找最近 5 分钟 Filter 阶段最慢的插件
topk(5, histogram_quantile(0.99, sum by (plugin, le) (
rate(scheduler_plugin_execution_duration_bucket{extension_point="Filter"}[5m])
)))
Question 15: PostFilter 和 default preemption 是什么关系?PostFilter 失败后会发生什么?
Answer:PostFilter 是抽象的扩展点;DefaultPreemption 是这个扩展点上的内置插件实现。看 pkg/scheduler/framework/plugins/defaultpreemption/default_preemption.go。PreFilter/Filter 全失败时,框架调 RunPostFilterPlugins,如果失败结果是 Unschedulable 会触发 preemption 流程(选 victim Pod,删掉让出资源)。PostFilter 自己也可能直接返回 Success(preemption 成功抢到资源)或者 Unschedulable(preemption 也救不了,Pod 标记为 unschedulable)。
Question 16: v1.36.1 里 CycleState 多了 PodGroupCycleState 字段,这是 v1.36 新加的什么特性?
Answer:PodGroupCycleState 是 v1.36 GA 的 GenericWorkload 特性的一部分。受 feature gate GenericWorkload 控制,开启后可以调度一组互相依赖的 Pod(类似 JobGroup / PodGroup CRD 的工作负载)。调度时先跑一个 PodGroup 级别的 CycleState,把所有相关 Pod 的约束聚合起来一次性选 Node,再下钻到每个 Pod 的 CycleState。这就是 IsPodGroupSchedulingCycle() 和 GetPodGroupSchedulingCycle() 这两个方法的用途。
Question 17: 在生产环境写了一个自定义 Filter 插件,但发现 Pod 调度延迟从 1ms 涨到 50ms,怎么定位是我的插件慢还是其他原因?
Answer:用上面提到的 scheduler_plugin_execution_duration PromQL 找慢插件。但要先打开 pluginMetricsSamplePercent(默认很小,例如 1%),生产环境可以临时调到 100% 排查完再调回。另外一个偏方:在自定义插件的入口和出口加 klog.V(4).InfoS("plugin timing", "duration", time.Since(start)),让 scheduler -v=4 日志能直接 grep 到。
Question 18: SignPlugin 是什么?v1.36.1 的批处理优化(OpportunisticBatching)到底是怎么工作的?
Answer:SignPlugin 让插件为每个 Pod 生成 "调度签名"——一个 JSON 可序列化的对象。签名相同的 Pod 调度结果可以复用,省去重算 Filter+Score 的开销。具体流程:scheduler 同时拿到多个待调度 Pod 时,调用每个 Pod 的 SignPod 拿签名;如果多个 Pod 签名一致(比如同一 Service 的多份 Deployment 副本),它们共享一份 Filter+Score 结果。关键约束:KEP-5598 要求所有 Scoring/Prescoring/Filtering/PreFiltering 插件都实现 SignPlugin,否则整个 batch 优化被关闭。
Question 19: PostBind 阶段失败会怎么样?它和 PreBind 失败的兜底机制一样吗?
Answer:不一样,PostBind 的失败不影响 Pod 状态。看 pkg/scheduler/framework/runtime/framework.go 第 1824 行 RunPostBindPlugins:它跑完不检查 status,也不返回 status——因为 Pod 已经绑上了,PostBind 失败最多就是清理没做,不会让 Pod 重新进 backoffQ。PreBind 失败则会让 Pod 重新进 backoffQ 重试整个调度。这两个阶段的"错误容忍度"差很多。
Question 20: v1.36.1 的 framework 还能和 v1.32 之前的旧式 Scheduler Algorithm 同时使用吗?老代码怎么迁移?
Answer:v1.36.1 的 kube-scheduler 已经 完全弃用了老算法路径——v1.15 引入 Framework 时还保留了 fallback,v1.17 之后老路径只通过 --feature-gates=SchedulerQueueingHints=false 这类 flag 才能勉强用,到 v1.22 彻底移除。迁移步骤:① 把 algorithmprovider 配置文件转成 KubeSchedulerConfiguration;② 把每个 Predicate 写成 FilterPlugin、每个 Priority 写成 ScorePlugin;③ 共享数据通过 CycleState 传;④ 用 --config 参数启动 scheduler。k8s 官方文档 https://kubernetes.io/docs/reference/scheduling/config/ 有详细 migration 指南(注意:v1.36 时路径以最新文档为准)。
到这里 20 个问题就答完了。回过头看,Scheduling Framework 的设计哲学其实很清晰:把"调度决策"拆成可观测、可扩展、可并行的若干阶段,每个阶段都有自己的状态码语义和数据共享机制(CycleState)。掌握这套机制后,你不仅能看懂别人写的调度器代码,还能自己写插件解决特定场景的调度问题——比如"只在带 GPU 卡的 Node 上跑 ML Pod"、"按机房机架做拓扑感知"、"等审批通过才绑定"等。
相关阅读:
• k8s 官方文档:调度和驱逐
• kube-scheduler framework/interface.go(核心接口定义)
• schedule_one.go(调度周期串联实现)
k8s 调度框架专题【左扬精讲】—— Scheduling Framework 扩展点逐个源码拆解 · 基于 k8s v1.36.1 源码解析
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。