Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 八): —— 从入口到调度的全链路源码剖析(k8s v1.36.1)

Kubernetes 源码【左扬精讲】—— kube-scheduler(调度专题 · 八): —— 从入口到调度的全链路源码剖析(k8s v1.36.1)

Pod 是 k8s 最基本的调度单位。当用户提交一个 Pod(或 Deployment、StatefulSet 等工作负载)时,kube-scheduler 负责把这个 Pod 绑到最适合的节点上——这个"适合"由资源、拓扑、亲和性、污点等数十个策略共同决定。理解 scheduler 的内部架构,是做大规模集群调优自定义调度策略故障诊断的必经之路。

本文以 k8s v1.36.1 源码为蓝本,从宏观架构到微观实现,把 scheduler 的启动入口调度队列Scheduling Framework(12 个扩展点)、6 大内置插件Profile 配置缓存机制Bind 流程全部讲透。

读完本文,你应该能回答:scheduler 从收到 Pod 到完成 Bind 经过了哪些步骤?Scheduling Framework 的 12 个扩展点各负责什么?6 大内置插件是如何协作的?Profile 和 Plugin 是什么关系?CycleState 在扩展点之间怎么传值?

Kubernetes Scheduler Scheduling Framework Go 源码 Plugin Go k8s v1.36.1

学习重点提示建议先通读全文,再重点回顾标注内容

重点掌握(必须)

  • 调度循环的两阶段分离schedulingCycle(Filter + Score + Reserve,同步)+ runBindingCycle(Permit + PreBind + Bind,异步 goroutine)
  • Scheduling Framework 12 个扩展点QueueSort → PreEnqueue → PreFilter → Filter → PostFilter → PreScore → Score → Reserve → Permit → PreBind → Bind → PostBind
  • Scheduler 核心数据结构NodeInfopkg/scheduler/framework/types.go:166)、QueuedPodInfopkg/scheduler/framework/types.go:553)、CycleState
  • FrameworkImpl 的插件切片frameworkImplpkg/scheduler/framework/runtime/framework.go:58)对每个扩展点持有 []Plugin 切片,按配置顺序执行

次重点(了解即可)

  • 多 Profile 机制:schedulerName 路由到不同 Framework 实例,支持"一套二进制、多套调度策略"
  • SchedulingQueue 三队列:activeQ / backoffQ / unschedulableQ,以及 backoff 退避机制
  • NodeInfo Snapshot:调度器缓存 node/pod 状态,避免每次都打 apiserver
  • Extender 扩展:HTTP RPC 扩展,允许调度器调用外部服务做二次过滤/打分

文章目录

一、宏观架构:从 Pod 创建到 Bind 的全链路

kube-scheduler 的工作听起来简单:把待调度的 Pod 绑到合适的节点。但实际实现是一条精心设计的数据流,涉及队列、缓存、插件链、并行化、异步回调等多个子系统的协作。先看一张总图建立全局感:

                    ┌──────────────────────────────────────────────────────────┐
                    │              kube-apiserver (etcd)                       │
                    │  Pod 创建 ──► pod informer ──► 调度队列 (PriorityQueue)  │
                    └──────────────────────────────────────────────────────────┘
                                              │
                    ┌─────────────────────────┘
                    ▼
              ┌─────────────────────────────────────────────────────────────┐
              │   ScheduleOne()  [主循环,从队列 Pop 一个 Pod]                │
              └─────────────────────────────────────────────────────────────┘
                    │
                    ▼
   ┌──────────────────────────────────────────────────────────────────┐
   │              schedulingCycle (同步)                               │
   │  ┌──────────┐  ┌──────────┐  ┌────────┐  ┌───────┐  ┌────────┐  │
   │  │QueueSort │─►│PreEnqueue│─►│PreFilter│─►│Filter │─►│PostFilter│ │
   │  │(排序)    │  │(门控)    │  │(预计算) │  │(筛选) │  │(抢占)  │   │
   │  └──────────┘  └──────────┘  └──────────┘  └──────┘  └────────┘   │
   │                                                      ↓               │
   │  ┌──────────┐  ┌────────┐  ┌────────┐  ┌──────────────────────────┐  │
   │  │PreScore  │─►│ Score  │─►│Reserve │  │  调度成功 ──► 进入 Bind   │  │
   │  │(预评分)  │  │(评分) │  │(预留)  │  └──────────────────────────┘  │
   │  └──────────┘  └────────┘  └────────┘                               │
   └──────────────────────────────────────────────────────────────────┘
                    │
                    ▼  (goroutine)
   ┌──────────────────────────────────────────────────────────────────┐
   │              runBindingCycle (异步)                               │
   │  ┌──────────┐  ┌────────┐  ┌────────┐  ┌──────────────────────┐   │
   │  │ Permit   │─►│PreBind │─►│ Bind   │─►│PostBind (清理/指标) │   │
   │  │(等待批准) │  │(卷绑定)│  │(写API) │  └──────────────────────┘   │
   │  └──────────┘  └────────┘  └────────┘                              │
   └──────────────────────────────────────────────────────────────────┘
                    │
                    ▼
              ┌──────────────────┐
              │  kubelet watch   │
              │  Pod.Spec.NodeName │
              │  启动容器         │
              └──────────────────┘

整个调度流程分为两大部分

  • schedulingCycle(调度周期):同步执行,找到最优节点
  • runBindingCycle(绑定周期):异步 goroutine 执行,把结果写回 apiserver

两者通过 assumedPodInfo(假设已调度的 Pod 副本)传递——调度周期先在内存中"假设" Pod 已在某节点,等 Bind 真正成功后才更新缓存。这种乐观并发设计让调度和绑定解耦、互不阻塞。

设计精髓

scheduler 的核心设计哲学是"插件化 + 两阶段分离":所有调度策略(资源、亲和性、拓扑、污点等)都是插件,通过 12 个扩展点串联;调度(找节点)和绑定(写结果)分离,前者同步快速,后者异步可重试。这套架构让调度器在保证正确性的前提下实现高吞吐——大集群每秒调度数万 Pod 全靠这套设计的并行化能力。

二、目录结构:pkg/scheduler/ 全览

scheduler 的核心代码在 pkg/scheduler/,cmd 层的入口在 cmd/kube-scheduler/

cmd/kube-scheduler/
├── scheduler.go              # main() 入口
└── app/
    └── server.go            # Run() —— 启动 scheduler server

pkg/scheduler/
├── schedule_one.go          # ScheduleOne() —— 主调度循环
├── scheduler.go            # Scheduler 结构体 —— 核心编排者
├── eventhandlers.go        # Pod/Node/Service 等 Informer 事件回调
├── extender.go             # Extender(HTTP 外部调度扩展)
├── schedule_one_test.go
│
├── framework/
│   ├── runtime/
│   │   └── framework.go    # FrameworkImpl —— 插件运行时
│   ├── types.go            # NodeInfo、QueuedPodInfo、CycleState 等核心类型
│   └── plugins/
│       ├── registry.go     # NewInTreeRegistry —— 14 个内置插件注册表
│       ├── queuesort/      # PrioritySort(QueueSort 插件)
│       ├── defaultbinder/  # DefaultBinder(Bind 插件)
│       ├── defaultpreemption/  # DefaultPreemption(PostFilter 插件)
│       ├── noderesources/  # NodeResourcesFit(Filter+Score 插件)
│       ├── nodeaffinity/   # NodeAffinity(Filter 插件)
│       ├── tainttoleration/  # TaintToleration(Filter 插件)
│       ├── interpodaffinity/  # InterPodAffinity(Filter+PreScore+Score 插件)
│       ├── podtopologyspread/ # PodTopologySpread(Filter+PreScore+Score 插件)
│       ├── volumebinding/  # VolumeBinding(PreFilter+Filter+Reserve+PreBind 插件)
│       ├── schedulinggates/  # SchedulingGates(PreEnqueue 插件)
│       └── ...             # 还有 DynamicResources、GangScheduling 等
│
├── backend/
│   ├── cache/              # NodeInfo 缓存(Snapshot)
│   ├── queue/              # PriorityQueue(activeQ / backoffQ / unschedulableQ)
│   └── api_dispatcher/     # 异步 API 调用(SchedulerAsyncAPICalls 特性门控)
│
├── profile/
│   └── profile.go          # 多 Profile 管理(Map[schedulerName]Framework)
│
└── util/
    └── ...                 # 工具函数

小贴士v1.36.1 架构演进

相比旧版架构,v1.36.1 中最大的变化是引入了 pkg/scheduler/backend/ 子包,把调度队列(PriorityQueue)和缓存(Cache)从原来的 internal/ 迁移到 backend/,职责更清晰。另外,pkg/scheduler/framework/ 里的类型定义(NodeInfoQueuedPodInfo)仍然保留,是调度器的核心数据模型

三、入口:cmd/kube-scheduler 怎么启动

scheduler 是一个标准 k8s component,入口极其简洁:

// cmd/kube-scheduler/scheduler.go (行 29-33, k8s v1.36.1)
func main() {
    command := app.NewSchedulerCommand()
    code := cli.Run(command)
    os.Exit(code)
}

真正的启动逻辑在 cmd/kube-scheduler/app/server.goRun() 函数(行 424~482),核心流程:

// cmd/kube-scheduler/app/server.go (行 ~424-~482, k8s v1.36.1)
// 1. 解析配置(--config 或 kubeconfig 文件)
cc, schedConfig, err := setup()

// 2. 创建 informer factory(监听 Pod/Node/PV/PVC 等资源)
informerFactory := informers.NewSharedInformerFactory(cc.Client, 0)

// 3. 初始化 scheduler 缓存(node + pod 快照)
cache := internalcache.New(cc.PodInformer)

// 4. 构建 Framework 插件配置(从 KubeSchedulerProfile 读取)
registry := frameworkplugins.NewInTreeRegistry()
profiles, err := profile.NewMap(ctx, cfgs, registry, recorderFact, ...)

// 5. 初始化调度队列
schedulingQueue := internalqueue.NewSchedulingQueue(
    profiles[defaultProfileName].QueueSortFunc(), informerFactory, ...)

// 6. 构造 Scheduler 核心结构体
sched, err := scheduler.New(
    client, informerFactory, cache, schedulingQueue, profiles, ...)

// 7. 注册 informer 事件处理(Pod Add/Update/Delete 回调)
informerFactory.Start(sched.SchedulingQueue.AddEventHandler(ctx, sched.Cache))

// 8. 启动 scheduler runloop
sched.Run(ctx)

这里的关键是:配置驱动——scheduler 的所有行为(用哪些插件、插件的参数、开启哪些特性门控)全部由 KubeSchedulerProfile 配置对象决定,代码层面零硬编码。

四、核心结构体:Scheduler 怎么串联所有组件

pkg/scheduler/scheduler.goScheduler 结构体是整个调度器的心脏

// pkg/scheduler/scheduler.go (行 68-130, k8s v1.36.1)
type Scheduler struct {
    // 节点+Pod 状态缓存(调度时不打 apiserver,靠这个快照)
    Cache internalcache.Cache

    // 外部扩展器列表(HTTP RPC)
    Extenders []fwk.Extender

    // 从队列取 Pod 的函数(POP,不阻塞 channel)
    NextPod func(logger klog.Logger) (*framework.QueuedPodInfo, error)

    // 调度失败时的回调
    FailureHandler FailureHandlerFn

    // 实际调度算法(内部调用 schedulingAlgorithm)
    SchedulePod func(ctx context.Context, fwk framework.Framework,
        state fwk.CycleState, podInfo *framework.QueuedPodInfo) (ScheduleResult, error)

    // 关闭信号
    StopEverything  Framework
    Profiles profile.Map

    // NodeInfo 快照(内部 cache 快照)
    nodeInfoSnapshot *internalcache.Snapshot

    // 异步 API 调用分发器(v1.36.1 新增,SchedulerAsyncAPICalls 门控)
    APIDispatcher *apidispatcher.APIDispatcher

    // PodGroup 支持(genericWorkloadEnabled 时启用)
    genericWorkloadEnabled bool
    // ...
}

所有组件通过 Scheduler 串联:

  • Cache → 为 Filter/Score 提供 node/pod 状态快照
  • SchedulingQueue → 管理待调度的 Pod 生命周期(active / backoff / unschedulable)
  • Profiles → 按 schedulerName 路由到对应 Framework 实例
  • Extenders → 外部调度服务(可选的 HTTP 扩展)

五、调度循环:ScheduleOne 的两阶段设计

pkg/scheduler/schedule_one.goScheduleOne(行 67)是调度循环的入口。它每次从队列取一个 Pod(或 PodGroup),然后分两阶段处理:

5.1 schedulingCycle(调度周期,同步)

从行 174 开始的 schedulingCycle 是同步的,找到节点后立即返回:

// pkg/scheduler/schedule_one.go (行 174-198, k8s v1.36.1)
func (sched *Scheduler) schedulingCycle(
    ctx context.Context,
    state fwk.CycleState,
    schedFramework framework.Framework,
    podInfo *framework.QueuedPodInfo,
    start time.Time,
    podsToActivate *framework.PodsToActivate,
) (ScheduleResult, *framework.QueuedPodInfo, *fwk.Status) {
    // 第 1 步:更新 NodeInfo 快照(从 cache 取最新状态)
    if err := sched.Cache.UpdateSnapshot(klog.FromContext(ctx), sched.nodeInfoSnapshot); err != nil {
        return ScheduleResult{nominatingInfo: clearNominatedNode},
            podInfo, fwk.AsStatus(err)
    }

    // 第 2 步:跑调度算法(Filter + Score + Reserve)
    scheduleResult, status := sched.schedulingAlgorithm(ctx, state, schedFramework, podInfo, start)
    if !status.IsSuccess() {
        return scheduleResult, podInfo, status
    }

    // 第 3 步:准备 Bind(Reserve 阶段已预占资源,这里做假设)
    assumedPodInfo, status := sched.prepareForBindingCycle(ctx, state, schedFramework,
        podInfo, podsToActivate, scheduleResult)
    if !status.IsSuccess() {
        return ScheduleResult{nominatingInfo: clearNominatedNode},
            assumedPodInfo, status
    }

    return scheduleResult, assumedPodInfo, nil
}

schedulingAlgorithm(由 sched.SchedulePod 指向)是实际执行 Filter + Score 的地方,它遍历所有节点,按扩展点顺序调用插件链,最终返回一个 ScheduleResult(含节点名和资源匹配信息)。

5.2 runBindingCycle(绑定周期,异步 goroutine)

调度成功后,立即起一个 goroutine 执行绑定:

// pkg/scheduler/schedule_one.go (行 147, k8s v1.36.1)
// 绑定是异步的,不阻塞调度循环
go sched.runBindingCycle(ctx, state, fwk, scheduleResult, assumedPodInfo, start, podsToActivate)

runBindingCycle(行 150)内部调 bindingCycle,依次跑 Permit → PreBind → Bind → PostBind。如果 Bind 失败,则调用 handleBindingCycleError——这是Bind 重试的入口。

小贴士两阶段分离的好处

调度(找节点)是 CPU 密集型(要跑数十个 Filter 插件、遍历所有节点),绑定(写 API)是 IO 密集型(调 apiserver)。如果绑定是同步的,每次绑定都会阻塞下一个 Pod 的调度。异步 goroutine 绑定让调度循环始终保持高吞吐:调度器在等 apiserver 响应时,已经在处理下一个 Pod 了。

5.3 scheduleOnePod 全流程(逐行解读)

// pkg/scheduler/schedule_one.go (行 98-148, k8s v1.36.1)
func (sched *Scheduler) scheduleOnePod(ctx context.Context, podInfo *framework.QueuedPodInfo) {
    logger := klog.FromContext(ctx)
    pod := podInfo.Pod
    logger = klog.LoggerWithValues(logger, "pod", klog.KObj(pod))
    ctx = klog.NewContext(ctx, logger)

    // ① 按 schedulerName 找到对应的 Framework(Profile 路由)
    fwk, err := sched.frameworkForPod(pod)

    // ② 检查是否需要跳过(SchedulingGates 未清除等)
    if sched.skipPodSchedule(ctx, fwk, pod) { return }

    // ③ 创建 CycleState(贯穿整个调度周期的上下文载体)
    state := framework.NewCycleState()

    // ④ 同步调度:找最优节点
    scheduleResult, assumedPodInfo, status := sched.schedulingCycle(ctx, state, fwk, podInfo, start, podsToActivate)
    if !status.IsSuccess() {
        // 调度失败:调用 FailureHandler(Pod 进 unschedulableQ / backoffQ)
        sched.FailureHandler(ctx, fwk, assumedPodInfo, status, scheduleResult.nominatingInfo, start)
        return
    }

    // ⑤ 异步绑定:goroutine 写 Binding 对象到 apiserver
    go sched.runBindingCycle(ctx, state, fwk, scheduleResult, assumedPodInfo, start, podsToActivate)
}

六、Scheduling Framework:12 个扩展点全解

Scheduling Framework(k8s v1.19 引入)是 scheduler 插件化的核心机制。它定义了 12 个扩展点,插件实现对应接口后注册到 Framework,调度器按顺序调用。这套机制替代了旧版 AlgorithmProvider,让自定义调度策略从"改源码"变成"写插件"。

扩展点接口名核心方法职责代表性插件
QueueSort QueueSortPlugin Less(p1, p2) 决定 activeQ 里 Pod 的调度顺序(默认按 priority → timestamp) PrioritySort
PreEnqueue PreEnqueuePlugin PreEnqueue(ctx, pod) Pod 入 activeQ 前的门控(SchedulingGates 清除等) SchedulingGates
PreFilter PreFilterPlugin PreFilter(ctx, state, pod) 预计算并修剪搜索空间(返回 PreFilterResult{NodeNames} 可直接跳过节点) NodeResourcesFit
Filter FilterPlugin Filter(ctx, state, pod, node) 核心筛选:检查节点是否满足 Pod 的硬约束(资源/Taint/拓扑等) NodeResourcesFit
PostFilter PostFilterPlugin PostFilter(ctx, state, pod, m) 所有 Filter 都失败后调用——做抢占(Preemption) DefaultPreemption
PreScore PreScorePlugin PreScore(ctx, state, pod, nodes) 在评分前预计算共享数据(避免重复计算,如 InterPodAffinity 预聚合) InterPodAffinity
Score ScorePlugin Score(ctx, state, pod, node) 对通过 Filter 的节点打分(0-100 或更大),并行计算后加权求和 NodeResourcesFit
Reserve ReservePlugin Reserve(ctx, state, pod, node) 在假设(Assume)阶段预留资源(主要用于 VolumeBinding 预留 PV) VolumeBinding
Permit PermitPlugin Permit(ctx, state, pod, node) 绑定前的最后门控(可等待外部信号,默认直接返回 Allow n/a(默认)
PreBind PreBindPlugin PreBind(ctx, state, pod, node) Bind 前的前置工作(VolumeBinding 把 PVC 绑定到 PV) VolumeBinding
Bind BindPlugin Bind(ctx, state, pod, node) 创建 Binding 对象到 apiserver,写入 Pod.Spec.NodeName DefaultBinder
PostBind PostBindPlugin PostBind(ctx, state, pod, node) Bind 成功后清理(如释放临时资源、记录指标) n/a(默认)

小贴士扩展点的串行与并行

Filter、Score、PostFilter 等插件在多个节点上并行执行(由 parallelize.Parallelizer 控制 goroutine 池大小)。QueueSort、Reserve、Permit、Bind 等扩展点在单一 Pod上串行执行。PreFilter、PreScore 在 Filter/Score 之前对所有节点做预计算。理解这一点对性能调优很重要:Filter 插件的耗时直接影响调度延迟。

七、FrameworkImpl:插件是怎么组织起来的

pkg/scheduler/framework/runtime/framework.goframeworkImpl(行 58)是插件的运行时容器。它按扩展点分组存储所有插件实例:

// pkg/scheduler/framework/runtime/framework.go (行 58-112, k8s v1.36.1)
type frameworkImpl struct {
    // === 插件注册表 ===
    registry   Registry
    pluginsMap map[string]fwk.Plugin  // name -> Plugin 实例

    // === 各扩展点的插件切片(按配置顺序) ===
    preEnqueuePlugins         []fwk.PreEnqueuePlugin    // PreEnqueue
    enqueueExtensions         []fwk.EnqueueExtensions    // 事件注册(哪些事件触发重调度)
    queueSortPlugins          []fwk.QueueSortPlugin     // QueueSort(只能有 1 个)
    preFilterPlugins          []fwk.PreFilterPlugin     // PreFilter
    filterPlugins             []fwk.FilterPlugin        // Filter
    postFilterPlugins         []fwk.PostFilterPlugin    // PostFilter
    preScorePlugins           []fwk.PreScorePlugin      // PreScore
    scorePlugins              []fwk.ScorePlugin         // Score
    reservePlugins            []fwk.ReservePlugin       // Reserve
    preBindPlugins            []fwk.PreBindPlugin       // PreBind
    bindPlugins               []fwk.BindPlugin          // Bind(可多个,串行尝试)
    postBindPlugins           []fwk.PostBindPlugin      // PostBind
    permitPlugins             []fwk.PermitPlugin        // Permit
    batchablePlugins          []fwk.SignPlugin          // 批量签名( OpportunisticBatching)
    podGroupPostFilterPlugins []framework.PodGroupPostFilterPlugin

    // === 共享资源 ===
    snapshotSharedLister  fwk.SharedLister      // NodeInfo 快照
    clientSet             clientset.Interface    // apiserver 客户端
    informerFactory       informers.SharedInformerFactory
    eventRecorder         events.EventRecorderLogger
    parallelizer          fwk.Parallelizer        // goroutine 池

    // === 调度辅助 ===
    podNominator fwk.PodNominator   // nominatedNodeName 管理
    apiCacher    fwk.APICacher       // 异步 API 缓存
    batch        *OpportunisticBatch  // 机会批量调度

    profileName               string
    percentageOfNodesToScore *int32
}

Framework 初始化时,按 KubeSchedulerProfile 配置加载插件:

// pkg/scheduler/framework/runtime/framework.go (行 ~117-142, k8s v1.36.1)
// extensionPoint 封装了"配置引用"和"插件切片指针"的配对
type extensionPoint struct {
    plugins *config.PluginSet   // 配置中的插件列表
    slicePtr interface{}         // 指向 frameworkImpl 的插件切片字段
}

func (f *frameworkImpl) getExtensionPoints(plugins *config.Plugins) []extensionPoint {
    return []extensionPoint{
        {&plugins.PreFilter, &f.preFilterPlugins},
        {&plugins.Filter, &f.filterPlugins},
        {&plugins.PostFilter, &f.postFilterPlugins},
        {&plugins.Reserve, &f.reservePlugins},
        {&plugins.PreScore, &f.preScorePlugins},
        {&plugins.Score, &f.scorePlugins},
        {&plugins.PreBind, &f.preBindPlugins},
        {&plugins.Bind, &f.bindPlugins},
        {&plugins.PostBind, &f.postBindPlugins},
        {&plugins.Permit, &f.permitPlugins},
        {&plugins.PreEnqueue, &f.preEnqueuePlugins},
        {&plugins.QueueSort, &f.queueSortPlugins},
        // ... 调度相关扩展点
    }
}

7.1 CycleState:扩展点之间的上下文载体

CycleState 是一个 map[string]StateData,每个插件可以在里面读写自己的状态,供后续扩展点使用:

// CycleState 用法示例(各扩展点之间通过 Key-Value 传值)
// PreFilter 阶段写入:
state.Write("NodeResourcesFit_prefilter", &preFilterState{...})

// Filter 阶段读取:
data, _ := state.Read("NodeResourcesFit_prefilter")

// Score 阶段读取:
data2, _ := state.Read("InterPodAffinity_prescore")

这种设计让插件可以在 PreFilter 做预计算,在 Filter 直接复用,避免重复扫描节点数据。

八、调度队列:PriorityQueue 三队列机制

pkg/scheduler/backend/queue/scheduling_queue.goPriorityQueue 管理 Pod 的生命周期,内部维护三个队列:

队列类型含义出队时机
activeQ heap.Heap(堆) 准备调度的 Pod(已通过 PreEnqueue) 调度成功后出队;失败后进 backoffQ 或 unschedulableQ
backoffQ heap.Heap 退避中的 Pod(调度失败但有重试价值) backoff 过期后弹回 activeQ
unschedulablePods map[types.UID]*QueuedPodInfo 明确不可调度的 Pod(UnschedulableAndUnresolvable) 被相关 ClusterEvent 触发后移回 backoffQ

8.1 Pod 的队列旅程

Pod 创建 ──► PreEnqueue 检查(SchedulingGates 等)
                │
                │ 通过
                ▼
         activeQ ──► ScheduleOne Pop ──► 调度成功 ──► 移出队列
                │                         │
                │ 调度失败                 │ Bind 失败
                ▼                         ▼
         backoffQ ──► backoff 过期 ──► activeQ(重新调度)
                │
                │ Filter 返回 UnschedulableAndUnresolvable
                ▼
         unschedulableQ ──► 相关事件触发 ──► backoffQ

8.2 QueueingHint:精细化重调度触发

v1.36.1 支持 QueueingHint 机制(QueueingHintMapPerProfile):每个插件注册"什么事件可能导致这个 Pod 可调度",队列只在这个事件发生时才会尝试重新调度该 Pod。这比旧版的"任意事件都触发"要精确得多,大幅减少无效的队列抖动。

九、调度缓存:NodeInfo Snapshot

pkg/scheduler/backend/cache/cache.goCacheSnapshot 是调度器的状态快照。调度器不在 Filter/Score 阶段直接读 apiserver,而是靠缓存:

// pkg/scheduler/backend/cache/cache.go
// 每次 schedulingCycle 开始时更新快照
if err := sched.Cache.UpdateSnapshot(logger, sched.nodeInfoSnapshot); err != nil {
    return err
}

NodeInfopkg/scheduler/framework/types.go:166)是节点的聚合视图

// pkg/scheduler/framework/types.go (行 166-210, k8s v1.36.1)
type NodeInfo struct {
    node     *v1.Node                    // Node 对象
    Pods     []fwk.PodInfo              // 节点上所有 Pod
    PodsWithAffinity        []fwk.PodInfo  // 有 PodAffinity 的 Pod
    PodsWithRequiredAntiAffinity []fwk.PodInfo  // 有 RequiredAntiAffinity 的 Pod
    UsedPorts fwk.HostPortInfo           // 已占用端口

    // 资源统计(调度用)
    Requested        *Resource  // 所有 Pod 请求的资源
    NonZeroRequested *Resource  // 非零请求资源
    Allocatable      *Resource  // 节点可分配资源

    // 镜像状态
    ImageStates map[string]*fwk.ImageStateSummary

    // PVC 引用计数
    PVCRefCounts map[string]int

    // 特性集
    DeclaredFeatures ndf.FeatureSet
}

关键点:RequestedNonZeroRequested 是调度器的"资源账本"。Filter 插件(NodeResourcesFit)通过比较 Allocatable - Requested 判断节点是否有足够资源。

设计精髓

调度缓存采用了"假设(Assume)机制":调度器在 Reserve 阶段把 Pod 加入 NodeInfo(Requested += podRequest),这样即使 Bind 还没完成,后续 Pod 也不会被调度到同一节点。如果 Bind 失败(handleBindingCycleError),会回滚这个假设(removePod)。这是一套乐观并发的"先占后验"策略。

十、6 大内置插件:源码级精讲

通过 pkg/scheduler/framework/plugins/registry.go 注册的 14 个内置插件中,6 大核心插件最为常用:

10.1 NodeResourcesFit —— 资源能不能装下

最基础的 Filter 插件,检查 CPU / Memory / GPU / Ephemeral-Storage 是否满足:

// pkg/scheduler/framework/plugins/noderesources/fit.go (行 ~615, k8s v1.36.1)
// Filter 方法签名
func (f *Fit) Filter(ctx context.Context, state *fwk.CycleState,
    pod *v1.Pod, nodeInfo *fwk.NodeInfo) *fwk.Status {

    // 比较:Pod 请求 vs (Allocatable - 已用)
    if f.skipFiltering() {
        return nil
    }
    pods := append(nodeInfo.PodsWithRequiredAntiAffinity, nodeInfo.PodsWithAffinity...)
    if insufficientResources := f.insufficientPod(pod, nodeInfo, pods...); len(insufficientResources) > 0 {
        return f.newInsufficientStatus(pod, insufficientResources...)
    }
    return nil
}

它实现了 5 个扩展点接口:PreFilter(预计算 Pod 请求)、Filter(资源比较)、PreScoreScore(资源打分)、SignPlugin(Opportunistic Batching 签名)。

10.2 NodeAffinity —— 节点亲和性

检查 Pod 的 spec.affinity.nodeAffinity 是否匹配节点标签:

// pkg/scheduler/framework/plugins/nodeaffinity/node_affinity.go (行 ~140, k8s v1.36.1)
func (pl *NodeAffinity) Filter(ctx context.Context, state *fwk.CycleState,
    pod *v1.Pod, nodeInfo *fwk.NodeInfo) *fwk.Status {
    if pl.addedNodeSelector != nil {
        // 合并 spec.nodeSelector + 调度配置文件里的 addedNodeSelector
        nodeInfo.Node().Labels = mergeLabels(nodeInfo.Node().Labels, pl.addedNodeSelector)
    }

    if !pl.nodeSelector.Match(nodeInfo.Node()) {
        return fwk.NewStatus(fwk.Unschedulable, "node(s) didn't match Node affinity")
    }
    return nil
}

10.3 TaintToleration —— 污点容忍

检查 Pod 的 spec.tolerations 是否覆盖节点的 taints

// pkg/scheduler/framework/plugins/tainttoleration/taint_toleration.go (行 ~119, k8s v1.36.1)
func (pl *TaintToleration) Filter(ctx context.Context, state *fwk.CycleState,
    pod *v1.Pod, nodeInfo *fwk.NodeInfo) *fwk.Status {
    taint, isUntolerated := v1helper.FindMatchingUntoleratedTaint(
        nodeInfo.Node().Spec.Taints, pod.Spec.Tolerations, pl.enableTaintTolerationComparisonOperators)
    if !isUntolerated {
        return nil  // 所有污点都被容忍,Filter 通过
    }
    return fwk.NewStatus(fwk.UnschedulableAndUnresolvable,
        fmt.Sprintf("node(s) had untolerated taint(s): %v", taint))
}

注意

TaintToleration 返回 UnschedulableAndUnresolvable(而非 Unschedulable)——这意味着 PostFilter 阶段的抢占也不会尝试在这个节点上驱逐 Pod 来腾位置,因为污点冲突是"不可调和"的。

10.4 InterPodAffinity —— Pod 亲和性与反亲和性

最复杂的 Filter 插件之一,处理 pod.spec.affinity.podAffinitypodAntiAffinity

// pkg/scheduler/framework/plugins/interpodaffinity/plugin.go (行 ~93-103, k8s v1.36.1)
// 订阅的事件:Pod Add/Delete/Update + Node Update
func (pl *InterPodAffinity) EventsToRegister() []fwk.ClusterEventWithHint {
    return []fwk.ClusterEventWithHint{
        {Event: fwk.ClusterEvent{Resource: fwk.Pod, ActionType: fwk.Add | fwk.Update | fwk.Delete}},
        {Event: fwk.ClusterEvent{Resource: fwk.Node, ActionType: fwk.Update}},
    }
}

// Filter 逻辑:遍历节点上所有 Pod,检查亲和性/反亲和性条件
func (pl *InterPodAffinity) Filter(ctx context.Context, state *fwk.CycleState,
    pod *v1.Pod, nodeInfo *fwk.NodeInfo) *fwk.Status {
    // 硬性 requiredDuringSchedulingIgnoredDuringExecution 条件
    // 对节点上每个 Pod 检查 topologyKey 匹配
    // ...
    return nil
}

10.5 PodTopologySpread —— 拓扑分布约束

v1.25 后默认开启,将 Pod 均匀分布到拓扑域:

// pkg/scheduler/framework/plugins/podtopologyspread/plugin.go (行 46-57, k8s v1.36.1)
// v1.36.1 默认约束(当用户没显式配置时)
var systemDefaultConstraints = []v1.TopologySpreadConstraint{
    {
        TopologyKey:       "kubernetes.io/hostname",
        MaxSkew:           3,
        WhenUnsatisfiable: v1.DoNotSchedule,  // 硬约束:不可调度则整体 Pending
    },
    {
        TopologyKey:       "topology.kubernetes.io/zone",
        MaxSkew:           5,
        WhenUnsatisfiable: v1.ScheduleAnyway,  // 软约束:允许不满足但会降低 score
    },
}

小贴士PodTopologySpread 的默认值陷阱

v1.25 引入 MinDomainsInPodTopologyspread 特性门控后,默认约束在某些场景下会导致"莫名被均匀打散"——特别是 Deployment 只有 1-2 个副本时,MaxSkew=3DoNotSchedule 约束会让 Pod 调度失败。生产环境中,如果不需要系统级拓扑分布,最好显式在 Pod spec 里配置 topologySpreadConstraints: [] 来覆盖默认值。

10.6 VolumeBinding —— 卷绑定

处理 PVC/PV 绑定,是少数跨越多个扩展点的插件:

// pkg/scheduler/framework/plugins/volumebinding/volume_binding.go
// 实现的扩展点:PreFilter + Filter + Reserve + PreBind + PreScore + Score
// PreFilter:收集 Pod 所有 PVCs
// Filter:检查节点上是否有匹配 PV(对于 immediate 模式)
// Reserve:假设绑定 PVC(AssumePodVolumes)
// PreBind:真正绑定 PVC 到 PV(BindPodVolumes)

VolumeBinding 有两种模式:

  • WaitForFirstConsumer(推荐):卷绑定延迟到实际调度决策时
  • immediate:调度前就绑定,容易导致 Pod 无法调度

十一、Profile 配置:多调度器共存

一个 kube-scheduler 进程可以运行多套调度策略,通过 schedulerName 字段路由:

// pkg/scheduler/profile/profile.go (行 46-65, k8s v1.36.1)
// Profile Map:schedulerName -> Framework
type Map map[string]framework.Framework

func NewMap(ctx context.Context, cfgs []config.KubeSchedulerProfile,
    r frameworkruntime.Registry, recorderFact RecorderFactory,
    opts ...frameworkruntime.Option) (Map, error) {
    m := make(Map)
    for _, cfg := range cfgs {
        p, err := newProfile(ctx, cfg, r, recorderFact, opts...)
        m[cfg.SchedulerName] = p
    }
    return m, nil
}

每套 Profile 有独立的:插件配置、QueueSort、百分比配置(percentageOfNodesToScore)。Pod 通过 spec.schedulerName 字段选择调度器:

# 使用默认调度器
spec:
  schedulerName: default-scheduler  # 默认值

# 使用自定义调度器(profile 名 = schedulerName)
spec:
  schedulerName: my-custom-scheduler

十二、Bind 流程:Pod 怎么写回 apiserver

当 schedulingCycle 返回成功后,runBindingCycle 开始执行绑定。核心是 pkg/scheduler/framework/plugins/defaultbinder/default_binder.goBind 方法:

// pkg/scheduler/framework/plugins/defaultbinder/default_binder.go (行 ~51-75, k8s v1.36.1)
func (b DefaultBinder) Bind(ctx context.Context, state fwk.CycleState,
    p *v1.Pod, nodeName string) *fwk.Status {

    // 构造 Binding 对象
    binding := &v1.Binding{
        ObjectMeta: metav1.ObjectMeta{
            Namespace: p.Namespace,
            Name:      p.Name,
            UID:       p.UID,
        },
        Target: v1.ObjectReference{
            Kind: "Node",
            Name: nodeName,
        },
    }

    // 调 apiserver 创建 Binding(这是真正的"写"操作)
    err := b.handle.ClientSet().CoreV1().Pods(binding.Namespace).
        Bind(ctx, binding, metav1.CreateOptions{})

    if err != nil {
        return fwk.AsStatus(err)
    }

    b.handle.EventRecorder().Eventf(p, nil, v1.EventTypeNormal,
        "Scheduled", "Successfully assigned %s/%s to %s",
        p.Namespace, p.Name, nodeName)

    return nil
}

Binding 对象的本质是告诉 apiserver:"Pod ns/name 应该跑在 Node nodeName 上"。apiserver 写入 etcd 后,kubelet 通过 informer watch 到 Pod 的 .spec.nodeName 变化,开始启动容器。

注意

Bind 是幂等的:如果已经 Bind 成功(scheduler 重启前已完成),再次 Bind 会收到 AlreadyExists 错误(Pod 已有 nodeName),Framework 会优雅处理。但Reserve 阶段的 Assume 不是幂等的——它直接修改内存中的 NodeInfo,如果 Bind 失败需要显式回滚(forgetPod)。

十三、核心数据结构:NodeInfo、QueuedPodInfo、CycleState

13.1 QueuedPodInfo —— 队列中的 Pod 封装

// pkg/scheduler/framework/types.go (行 553-611, k8s v1.36.1)
type QueuedPodInfo struct {
    *PodInfo                    // 包含 *v1.Pod 对象

    // 调度历史与状态
    Timestamp             time.Time  // 进入队列的时间
    Attempts              int        // 调度尝试次数
    BackoffExpiration     time.Time  // backoff 过期时间
    UnschedulableCount    int        // 连续失败次数

    // 插件级失败追踪
    UnschedulablePlugins sets.Set[string]  // 哪些插件返回了 Unschedulable
    PendingPlugins       sets.Set[string]  // 哪些插件返回了 Pending

    // 调度门控
    GatingPlugin      string                  // 哪个插件 gate 住了这个 Pod
    GatingPluginEvents []fwk.ClusterEvent     // 对应的事件

    // Pod 签名(Opportunistic Batching)
    PodSignature fwk.PodSignature
}

13.2 Resource —— 资源计量单位

// pkg/scheduler/framework/types.go (行 ~200-~230, k8s v1.36.1)
type Resource struct {
    CPU              int64
    Memory           int64
    EphemeralStorage int64

    // Extended resources (GPU, FPGA, RDMA, etc.)
    AllowedPodNumber     int64
    ScalarResources      map[v1.ResourceName]int64
}

13.3 Framework Status Code

// pkg/scheduler/framework/runtime/framework.go (行 ~42-~101, k8s v1.36.1)
// 插件返回的状态码
const (
    Success Code = iota          // 成功,继续下一个插件
    Error                       // 内部错误,整个调度失败
    Unschedulable               // 不适合,等待重调度(触发 PostFilter/抢占)
    UnschedulableAndUnresolvable  // 不可调度且抢占也无法解决(不触发 PostFilter)
    Wait                        // Permit 阶段等待外部批准
    Skip                        // 跳过此插件(不影响调度结果)
    Pending                     // 停止,等待外部信号
)

十四、FAQ:常见疑问(15 问)

Q1. 为什么 Filter 失败后不直接报错,而要等所有节点都跑完?

Filter 插件在所有节点上并行执行(goroutine pool),所以不是"等所有节点",而是"等所有 goroutine 完成"。但 Framework 确实会等全部 Filter 跑完才进入 Score——因为 Score 需要一个"通过 Filter 的节点列表"作为输入。这个设计保证了"先筛后打"的正确性,但也意味着:如果 Filter 很慢,Score 就要等很久。

Q2. Score 打分和 Filter 筛选是什么关系?

Filter 是硬约束(must),Score 是软偏好(should)。Filter 把不满足的节点剔除,Score 对剩余节点打分排序。两者都在 Filter 通过的节点上执行,Filter 通过 100 个节点 → Score 给这 100 个节点分别打分 → 取最高分节点。

Q3. 多个 Score 插件的分数怎么合并?

每个 Score 插件返回 0-100(或更大)的原始分数。Framework 先做 NormalizeScore(可选,标准化到 [0, MaxNodeScore]),然后乘以 weight 并求和:

TotalScore(node) = Σ(score_i(node) × weight_i) / Σ(weight_i)

所有插件的分数被归一化到同一量纲后加权求和,percentageOfNodesToScore 控制只对前 N% 的节点评分(默认 50%,最少评 100 个节点)。

Q4. Reserve 和 Assume 是什么关系?

Reserve 是扩展点接口,Assume 是缓存操作。VolumeBinding 在 Reserve 扩展点里调 AssumePodVolumes:把 PVC 状态假设为"已绑定"(写入缓存),等 PreBind 真正执行绑定。如果 PreBind 失败,缓存中的假设被回滚。Reserve 不是 apiserver 写入,只是内存中的预占。

Q5. 调度失败后 Pod 会一直卡在 unschedulableQ 吗?

不会。调度失败后,Pod 会根据失败原因进入 backoffQunschedulableQ

  • 可恢复失败(资源不足)→ backoffQ,指数退避后重试
  • 不可恢复失败(污点冲突)→ unschedulableQ,等相关 ClusterEvent 触发才重试

Q6. Extender 和 In-Tree 插件谁先执行?

Filter 阶段:Extender 在所有 In-Tree Filter 之后执行(callExtendersschedule_one.go),只有通过 In-Tree Filter 的节点才会发给 Extender 做二次检查。Score 阶段:Extender 独立打分,与 In-Tree Score 分数分别计算后合并

Q7. percentageOfNodesToScore 怎么影响调度精度?

scheduler 会对通过 Filter 的节点只打前 percentageOfNodesToScore 的分(默认 50%,最少 100 个)。这意味着:对于 1000 节点的集群,最多评 500 个节点的分;对于 50 节点集群,评全部 50 个节点。这个参数是精度与性能的折衷——增大比例提高调度质量但增加 CPU 开销。

Q8. Pod 的 priority 怎么决定调度顺序?

PrioritySort(QueueSort 插件)实现 Less(p1, p2):priority 高的排前面;priority 相同则 timestamp 早的排前面(p1.Timestamp.Before(p2.Timestamp))。这是 activeQ 里 heap 的排序依据。

Q9. SchedulingGates 是什么?

SchedulingGatespkg/scheduler/framework/plugins/schedulinggates/scheduling_gates.go)是 PreEnqueue 插件,允许用户在 Pod spec 里加 spec.schedulingGates 字段来"门控"调度时机:

spec:
  schedulingGates:
    - name: "custom-gate-1"
  # 调度器在 PreEnqueue 阶段会检查 gates 是否清空,
  # 只要有任意 gate 未清除,Pod 就不能进入 activeQ

这是一种"延迟调度"机制:可以在创建 Pod 后等待某些条件(如 CRD 资源就绪)再清除 gate 触发调度。

Q10. Opportunistic Batching 是什么?

Opportunistic Batching(v1.36.1 实验性)允许调度器在 Reserve 阶段发现"节点上还有额外空间"时,把多个 Pod 批量调度到同一节点。它依赖 SignPlugin 接口(SignPod / ValidatePodGroup)来评估"这些 Pod 能否批量到同一节点"。这对于批量作业(如 Spark、Ray)有显著吞吐提升。

Q11. 为什么 Filter 插件的顺序很重要?

Filter 按配置顺序串行执行(一个节点一个节点过)。开销大的插件(如 InterPodAffinity、VolumeBinding)放在前面,可以尽早排除不可能的节点,减少后续插件的计算量。但更常见的是把开销小的插件放前面(快速剪枝),把开销大的放后面。官方默认顺序是经过 benchmarks 优化的。

Q12. 调度器怎么发现新 Pod 和节点变化?

通过 informer factory 注册的 SharedInformer

  • podInformer.AddEventHandlerpkg/scheduler/eventhandlers.go)注册 Add/Update/Delete 回调
  • nodeInformer.AddEventHandler 注册节点变更回调,更新 NodeInfo 缓存
  • 回调里调 SchedulingQueue.Enqueue / cache.Update

Q13. 调度器能同时调度多少个 Pod?

调度循环(scheduleOnePod)是单线程串行的——一次只处理一个 Pod 的 schedulingCycle。但多个 Pod 的 runBindingCycle(goroutine)是并行的。如果要提高并发调度吞吐,需要配置 scheduler.conf 里的 parallelizer(goroutine 池大小,默认等于 CPU 核数)。

Q14. 调度器的 etcd 压力如何?

调度器不直接读 etcd,靠 informer 维护的 store(本地缓存)。Bind 操作写 Binding 对象(一个 API 对象),每个 Pod 只写一次。相比 controller-manager 的 list/watch,scheduler 对 apiserver 的压力是O(P)(P = Pod 数)的只读 + O(1) 的写。

Q15. 调度器和 kubelet 的协调机制是什么?

两者不直接通信,通过 apiserver 间接协调:

  1. scheduler Bind 写 Pod.Spec.NodeName → apiserver 持久化
  2. kubelet informer watch 到 Pod.Spec.NodeName == 本节点 → kubelet 开始拉镜像、启动容器
  3. 容器启动后 kubelet 更新 Pod.Status → apiserver 持久化

这种"apiserver 当总线"的松耦合设计是 k8s 的核心哲学:所有组件都只和 apiserver 通信,彼此之间互不感知。

设计精髓

kube-scheduler 的架构完美体现了 k8s 的设计哲学:声明式 + 控制循环 + 松耦合。调度器声明"这个 Pod 应该在这个节点",apiserver 存储声明,kubelet 观察声明并执行。没有任何组件"调用"另一个组件——全部通过 etcd-backed API 完成。这套架构让 k8s 可以水平扩展到数千节点,也让调度策略的插件化变得自然。


本文参考与源码链接:
  • schedule_one.go · ScheduleOne 主循环
  • scheduler.go · Scheduler 核心结构体
  • framework.go · FrameworkImpl 插件运行时
  • types.go · NodeInfo、QueuedPodInfo、Resource 核心类型
  • registry.go · NewInTreeRegistry 14 个内置插件
  • noderesources/fit.go · NodeResourcesFit
  • nodeaffinity/node_affinity.go · NodeAffinity
  • tainttoleration/taint_toleration.go · TaintToleration
  • interpodaffinity/plugin.go · InterPodAffinity
  • podtopologyspread/plugin.go · PodTopologySpread
  • volumebinding/volume_binding.go · VolumeBinding
  • defaultbinder/default_binder.go · DefaultBinder
  • profile/profile.go · Profile Map 多调度器
  • scheduling_queue.go · PriorityQueue 三队列
  • app/server.go · 启动入口

posted @ 2026-06-21 16:22  左扬  阅读(7)  评论(0)    收藏  举报