理解k8s源码之scheduler调度框架设计

1Background

K8s 调度器演进史中最核心的设计——从“铁板一块”到“插拔式插件”的架构变革

、 为什么要有schedulerName(多调度器共存机制)

在早期的 K8s 中,整个集群只能有一个调度策略,但实际的工业界场景:

  • 普通微服务:关注的是 CPU/内存利用率、高可用打散(默认调度器default-scheduler就能搞定)。

  • 大数据/AI 训练任务(如 Spark、PyTorch):需要Gang Scheduling(批量调度)——要么 8 个 GPU 节点同时起来,要么一个都不起,否则会发生死锁。

  • 高频交易/低延迟任务:需要极速调度,甚至需要感知底层的物理拓扑结构(NUMA 架构)。

为了满足这种“各家自扫门前雪”的需求,K8s 引入了schedulerName字段。

、 什么是调度框架(Scheduling Framework)

在旧版本里,如果想改调度逻辑,必须硬编码去改官方kube-scheduler的源码,然后重新编译。而现在的调度框架,把一个 Pod 从“被发现”到“绑定到节点”的完整生命周期,拆分成了多个扩展点(Extension Points)

一个 Pod 的调度主要分为两个大阶段:Scheduling Cycle(选择节点)Binding Cycle(绑定节点)。在这期间,可以像写 Servlet Filter 或者 Spring Interceptor 一样,在不同的生命周期钩子(扩展点)上挂载你的自定义插件:

  1. 几个核心的扩展点流水线

  • QueueSort(队列排序):决定调度队列里哪个 Pod 优先被调度。对于大模型训练的优先级调度,通常在这里做文章。
  • PreFilter(预过滤):在开始筛选节点前,对 Pod 的信息进行预处理或检查。
  • Filter(过滤):相当于以前的 Predicates。检查节点资源够不够、端口有没有冲突。做资源分配算法的研究,通常在这里写逻辑。
  • PostFilter(后过滤):如果 Filter 完发现没有一个节点满足条件(抢占发生在这里),可以用这个钩子来做抢占(Preemption)或触发集群自动扩容。
  • PreScore(预打分)Score(打分):相当于以前的 Priorities。给所有通过过滤的节点打分(0-100分)。亲和性、反亲和性、装箱算法(Binpack)、或者基于机器学习预测负载,核心逻辑全写在 Score 插件里。
  • Reserve(预留):高分节点选出来后,先在内存里把这个节点的资源扣掉(锁定),防止并发调度时别的 Pod 把资源抢走。
  • Permit(准许):可以阻断或延迟 Pod 的绑定。Gang Scheduling(批调度)的核心!在这里让 Pod 等一等,直到同一批次的所有 Pod 都通过了评审,再一起放行。
  • PreBind / Bind / PostBind(绑定阶段):真正把 Pod 和节点的映射关系写入 K8s APIServer。

、 什么是混部调度

要明白它混合部署的是什么。在 K8s 语境下,业务通常被分为两大类:

  1. 在线业务(Online / Latency-Sensitive, 简称 LS)

  • 对延迟极其敏感,属于“给用户看的业务”。比如电商的下单接口、视频点播、网页搜索。

  • 流量像潮汐一样波动(白天高、晚上低),但只要响应慢了 10 毫秒就会亏钱。

  1. 离线业务(Offline / Best-Effort 或 Batch, 简称 BE)

  • 对延迟不敏感,属于“后台算数据的业务”。比如大模型训练、大数据分析(Hadoop/Spark 任务)、视频转码、一些数据的同步和更新。

  • 中途卡顿几秒钟、甚至被中断重启都完全没关系。

混部调度算法的机制


如果只是简单地把这两类 Pod 调度到同一台机器上,离线业务一旦疯狂跑计算,就会把 CPU、内存带宽、三级缓存(L3 Cache)全部占满,导致在线业务“卡顿”( noisy neighbor / 喧闹邻居效应)。因此,混部调度算法的核心,就是在保证在线业务(LS)绝对安全的前提下,尽可能多地把离线任务(BE)塞进去。 它的核心算法逻辑通常分为两个层面:

  1. 宏观层面:动态资源超卖与预测算法(在 Master 节点算)

K8s 原生的资源管理是静态的(你申请了 2 核,就永远占着 2 核)。混部调度器则会引入动态超卖(Resource Reclamation)

  • 时间序列预测:调度器会监控所有节点上在线业务的实际使用量,通过机器学习算法(如 LSTM、时间序列预测)或者滑动窗口算法,预测出接下来一段时间内,在线业务“占而不用”的空闲资源空间。

  • 衍生虚拟资源:调度器把这部分空闲资源“虚构”出来,定义为一种低优先级的自定义资源(例如kubernetes.io/batch-cpu),专门用来调度离线任务。

  1. 微观层面:单机资源隔离与降级算法(在 Worker 节点控)

当在线和离线任务真的在一台机器上打起来时,单机层面的混部算法必须进行秒级甚至毫秒级的裁决:

  • CPU 调度隔离:利用 Linux 内核的Group Identity(阿里开源贡献)或内核混部特性,确保在线业务的线程永远能绝对优先抢到 CPU 时间片。

  • 内存与缓存压制(RDT技术):使用 Intel RDT 技术,限制离线业务能使用的 L3 缓存大小和内存带宽,防止它“偷”走在线业务的缓存。

  • 驱逐与自杀机制(Eviction):当在线业务流量突然暴涨,单机检测到 CPU 严重争抢时,单机混部算法会立刻、毫不留情地杀掉(Kill/Eviction)正在运行的离线业务,把资源瞬间全部还给在线业务。

2Pod Schedular Process

上文提到一个完整的pod调度过程可以分为三个阶段,PreEnqueue等待调度阶段,这一步没过就不会进入调度队列,scheduling cycle进行决策,为 pod 选择一个 node ,binding cycle落实以上选择,落库。这个过程包括一些失败回滚和异常处理的情况,加起来成为一个scheduling context。另外在进入上下文之前,可以将待调度的pod放入队列,自定义算法对地调度队列中的pods进行排序。

、等待调度阶段


  1. PreEnqueue

    由于每个 Pod 在调度时可能需要满足多种条件,例如必须存在持久化卷、符合 Pod Pod 反亲和规则或容忍节点污点等,因此该机制需要能够将调度操作推迟,直到集群满足所有成功调度所需的条件,天然需要等待队列:

    activeQ *heap.Heap//活跃队列 podBackoffQ *heap.Heap//退避/冷却队列 unschedulableQ *UnschedulablePodsMap//不可调度队列
    • active(activeQ):提供即时调度的Pods。

      调度器在寻找可以调度的 Pod 时,会主动且唯一盯着这个队列看。这个堆(Heap)结构的顶端(Head)永远是优先级(Priority)最高的那个 Pod。

    • backoff(podBackoffQ):等待特定条件发生的pod。

      这是一个按照“退避过期时间(Backoff Expiry)”排序的堆。当 Pod 冷却时间结束(完成了 backoff),它们会先于activeQ被弹出并挪走。

      如果一个 Pod 刚才尝试调度但失败了(例如因为网络抖动、临时没有合适节点等),它不会立刻被扔回 activeQ 重新排队。如果立刻重试,在资源没释放的情况下它大概率还会失败,这会疯狂消耗调度器的 CPU。 所以,调度器会计算一个“冷却时间”(比如先等 1 秒,再失败等 2 秒,指数递增),把它放进 podBackoffQ。这个堆的顶部永远是最早结束冷却的 Pod。时间一到,它就会被放回activeQ重新排队。

    • 不可调度(unschedulableQ):等待特定条件发生的pod。

      存放那些已经尝试过调度,但被明确判定为“当前不可调度”的 Pod。它是一个自定义的 Map 结构。如果一个 Pod 调度失败,且原因短时间内无法解决(例如:Pod 要求 64 核 CPU,而集群里最大的机器只有 32 核;或者 Pod 要求特定的存储卷,但集群里没有),那它就会被扔进 unschedulableQ。 为什么它是个 Map 而不是堆? 因为这些 Pod 什么时候能重新调度,不取决于时间(Backoff),而是取决于集群环境什么时候发生变化。例如:当集群新加入了一个 64 核的节点,或者某个大 Pod 被删除了释放了资源,Kubernetes 会通过事件(Event)触发,直接去这个 Map 里把相关的 Pod 捞出来,重新放回activeQ

    协同工作流
    [ 新创建的 Pod ] │ ▼ ┌──────────────┐ │ activeQ │ ◄────────────────────────┐ └──────┬───────┘ │ │ (调度器取走尝试调度) │ ▼ │ / 调度是否成功?\ │ < > │ \______┬_______/ │ │ │ ├─► [成功] ──► 绑定节点 (Bind) │ (环境改变/强制更新) │ │ ├─► [临时失败] ──► 进入 podBackoffQ ─┤ (冷却时间到) │ │ └─► [必然失败] ──► 进入 unschedulableQ ┘
    Moving Request(移动请求)

    unschedulableQ(不可调度队列)里的 Pod 就像在“死水池”里,它们不会自己动。只有当集群发生某些特定事件(Cluster Events)时,调度器才会触发一个Moving Request,把相关的 Pod 从不可调度队列“捞”出来,丢回activeQ(活跃队列)或podBackoffQ(退避队列)重新排队。

    这些事件包括但不限于:

    • 新增或更新了Nodes(有了新机器,之前因为没资源失败的 Pod 可以被挂载)。

    • 删除了某些Pods(释放了资源)。

    • 更新了PV/PVC/StorageClass(存储就绪了)。

    moveRequestCycle机制

    这个过程存在一些死锁的情况,moveRequestCycle机制可以解决一些并发的问题。

    Kubernetes 引入了schedulingCycle(调度周期计数器,每调度一个 Pod 就 +1)和moveRequestCycle(移动请求周期)。

    • 每次调度器发出Moving Request时,都会把当前的调度周期记录在moveRequestCycle变量中。

    • 当一个 Pod 调度失败准备进unschedulableQ时,调度器会检查:

      在我给这个 Pod 调度的期间,是不是发生过 Moving Request?(即:检查当前 Pod 的schedulingCycle是否等于moveRequestCycle

    • 如果等于:说明在调度期间,集群环境已经变化为满足!这时候 Pod不进不可调度队列,而是走“快捷通道”直接进入podBackoffQ(退避队列)。这样它就能很快被放回活跃队列,重新用最新的集群数据再试一次。

      示例

      • 当一个 pod 被调度时,不可调度队列中会有匹配的 pod 亲和力可以安排。如果仅要求匹配亲和力 在调度条件下,发布移动请求即可允许这些舱体 他们终于被安排好了。

      • 一个 pod 正在被过滤插件处理,这些插件已经没有剩余的节点可供调度。 与此同时,异步移动请求作为新节点事件的响应发出。 把舱体移到退车队列下方,可以更快被移动 进入活跃队列,检查新节点是否符合调度资格。

    监控指标
    ​ 1.pending_pods(静态:当前有多少 Pod)

    ​ 这是一个仪表盘(Gauge)类型的指标,记录当前这一时刻,各个子队列中分别有多少个处于等待 状态(Pending)的 Pod。

    ​ 它会根据队列名称(queue="active"/"backoff"/"unschedulable")进行标签分类。

    ​ 2.queue_incoming_pods_total(动态:进去了多少次 Pod)

    ​ 这是一个计数器(Counter)类型的指标,它只增不减,记录了从调度器启动到现在,Pod 被塞进某个队列的总次数,它不仅记录次数,还会附带记录“触发这次入队的操作或事件(Event)”。

  2. QueueSort

    对调度队列(scheduling queue)内的 pod 进行排序,决定先调度哪些 pods。

、调度阶段(scheduling cycle)


  1. PreFilter:预筛选,pod 预处理和检查,不符合预期就提前结束调度

    type PreFilterPlugin interface { Plugin PreFilter(ctx context.Context, state *CycleState, p *v1.Pod) (*PreFilterResult, *Status) PreFilterExtensions() PreFilterExtensions }

    调度器要在后面为 Pod 筛选成百上千个节点(Filter 阶段)。为了提高效率,PreFilter 插件通常会在这里提前把 Pod 相关的状态算好,并存进 CycleState 里。 如果插件能以 ​ 的极快速度直接判定“只有某些特定节点才配被考虑”(比如 Pod 明确指定了只运行在带有某个标签的特定节点上),它可以直接在PreFilterResult里返回一个节点node名称列表。调度器在后面的阶段就只会去遍历这几个节点,剩下的几千个节点直接忽略。

  2. PostFilterFilter之后没有 node 剩下,补救阶段

    如果 Filter 阶段之后,所有 nodes 都被筛掉了,一个都没剩,才会执行这个阶段;否则不会执行这个阶段的 plugins。 按 plugin 顺序依次执行,任何一个插件将 node 标记为Schedulable就算成功,不再执行剩下的 PostFilter plugins。

  3. PreScore:给 node 打分的,以最终选出一个最合适的 node

  4. Score:针对每个 node 依次调用 scoring plugin,得到一个分数。

    Kubernetes 调度器采用了“先总、后分”的流水线设计:

    【步骤 1:单线程串行】 PreScorePlugin.PreScore() ──► 算出全局公共数据,存入 CycleState (仅 1 次) │ ▼ 【步骤 2:多线程并发】 ┌──► Go-routine 1 ──► Score(Node-1) ──► 读数据,打分 ├──► Go-routine 2 ──► Score(Node-2) ──► 读数据,打分 CycleState ────┼──► Go-routine 3 ──► Score(Node-3) ──► 读数据,打分 (只读不写) ├──► ... └──► Go-routine N ──► Score(Node-1000) ─► 读数据,打分
  5. NormalizeScore:对不同的plugin给出的分数做标准化,像api中转站给不同的模型厂商设定一个平台倍率一样,统一定价。

  6. Reserve占座:Informational的(不会影响调度决策),维护 plugin 状态信息

    type ReservePlugin interface { Reserve(ctx , state *CycleState, p *v1.Pod, nodeName string) *Status Unreserve(ctx , state *CycleState, p *v1.Pod, nodeName string) }

    这里有两个方法 ,维护了 runtime state (aka “stateful plugins”) 的插件,可以通过这两个方法 接收 scheduler 传来的信息 。

    • Reserve: 用来避免 scheduler 等待bind操作结束期间,因 race condition 导致的错误。 只有当所有Reserveplugins 都成功后(原子性保证),才会进入下一阶段,否则 scheduling cycle 就中止了。

    • Unreserve:兜底策略。调度失败,这个阶段回滚时执行。Unreserve()必须幂等,且不能 fail。

  7. Permit允许/拒绝/等待进入 binding cycle

    根据选举结果,可以阻止或延迟将一个 pod binding 到 candidate node ,有三种返回。

    type PermitPlugin interface { Permit(ctx , state *CycleState, p *v1.Pod, nodeName string) (*Status, time.Duration) }
    1. approve:所有 Permit plugins 都 appove 之后,这个 pod 就进入下面的 binding 阶段;

    2. deny:任何一个 Permit plugin deny 之后,就无法进入 binding 阶段。这会触发Reserveplugins 的Unreserve()方法;

    3. wait(with a timeout):如果有 Permit plugin 返回 “wait”,这个 pod 就会进入一个 internal “waiting” Pods list;

、绑定阶段(binding cycle)


  1. PreBindBind之前的预处理,例如到 node 上去挂载 volume

    任何一个 PreBind plugin 失败,都会导致 pod 被 reject,进入到reserveplugins 的Unreserve()方法;

    我们在上一步的Reserve阶段,只是在调度器的内存里把资源占住了。但是,有些资源不仅需要调度器记账,还需要去物理世界里真正做配置。 比如检查磁盘能否挂载,IP 池是否满了。

  2. Bind:将 pod 关联到 node

    type BindPlugin interface { Bind(ctx , state *CycleState, p *v1.Pod, nodeName string) *Status }

    所有 plugin 按配置顺序依次执行,每个 plugin 可以选择是否要处理一个给定的 pod,如果选择处理,后面剩下的 plugins 会跳过。也就是最多只有一个 bind plugin 会执行

  3. PostBind:informational,可选,执行清理操作

    作为 binding cycle 的最后一个阶段,一般是用来清理一些相关资源

    type PostBindPlugin interface { PostBind(ctx , state *CycleState, p *v1.Pod, nodeName string) }