理解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 一样,在不同的生命周期钩子(扩展点)上挂载你的自定义插件:
几个核心的扩展点流水线
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 语境下,业务通常被分为两大类:
在线业务(Online / Latency-Sensitive, 简称 LS)
对延迟极其敏感,属于“给用户看的业务”。比如电商的下单接口、视频点播、网页搜索。
流量像潮汐一样波动(白天高、晚上低),但只要响应慢了 10 毫秒就会亏钱。
离线业务(Offline / Best-Effort 或 Batch, 简称 BE)
对延迟不敏感,属于“后台算数据的业务”。比如大模型训练、大数据分析(Hadoop/Spark 任务)、视频转码、一些数据的同步和更新。
中途卡顿几秒钟、甚至被中断重启都完全没关系。
混部调度算法的机制
如果只是简单地把这两类 Pod 调度到同一台机器上,离线业务一旦疯狂跑计算,就会把 CPU、内存带宽、三级缓存(L3 Cache)全部占满,导致在线业务“卡顿”( noisy neighbor / 喧闹邻居效应)。因此,混部调度算法的核心,就是在保证在线业务(LS)绝对安全的前提下,尽可能多地把离线任务(BE)塞进去。 它的核心算法逻辑通常分为两个层面:
宏观层面:动态资源超卖与预测算法(在 Master 节点算)
K8s 原生的资源管理是静态的(你申请了 2 核,就永远占着 2 核)。混部调度器则会引入动态超卖(Resource Reclamation):
时间序列预测:调度器会监控所有节点上在线业务的实际使用量,通过机器学习算法(如 LSTM、时间序列预测)或者滑动窗口算法,预测出接下来一段时间内,在线业务“占而不用”的空闲资源空间。
衍生虚拟资源:调度器把这部分空闲资源“虚构”出来,定义为一种低优先级的自定义资源(例如
kubernetes.io/batch-cpu),专门用来调度离线任务。
微观层面:单机资源隔离与降级算法(在 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进行排序。
一、等待调度阶段
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)”。
QueueSort
对调度队列(scheduling queue)内的 pod 进行排序,决定先调度哪些 pods。
二、调度阶段(scheduling cycle)
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名称列表。调度器在后面的阶段就只会去遍历这几个节点,剩下的几千个节点直接忽略。PostFilter:Filter之后没有 node 剩下,补救阶段如果 Filter 阶段之后,所有 nodes 都被筛掉了,一个都没剩,才会执行这个阶段;否则不会执行这个阶段的 plugins。 按 plugin 顺序依次执行,任何一个插件将 node 标记为Schedulable就算成功,不再执行剩下的 PostFilter plugins。
PreScore:给 node 打分的,以最终选出一个最合适的 nodeScore:针对每个 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) ─► 读数据,打分NormalizeScore:对不同的plugin给出的分数做标准化,像api中转站给不同的模型厂商设定一个平台倍率一样,统一定价。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。
Permit:允许/拒绝/等待进入 binding cycle根据选举结果,可以阻止或延迟将一个 pod binding 到 candidate node ,有三种返回。
type PermitPlugin interface { Permit(ctx , state *CycleState, p *v1.Pod, nodeName string) (*Status, time.Duration) }approve:所有 Permit plugins 都 appove 之后,这个 pod 就进入下面的 binding 阶段;
deny:任何一个 Permit plugin deny 之后,就无法进入 binding 阶段。这会触发
Reserveplugins 的Unreserve()方法;wait(with a timeout):如果有 Permit plugin 返回 “wait”,这个 pod 就会进入一个 internal “waiting” Pods list;
三、绑定阶段(binding cycle)
PreBind:Bind之前的预处理,例如到 node 上去挂载 volume任何一个 PreBind plugin 失败,都会导致 pod 被 reject,进入到
reserveplugins 的Unreserve()方法;我们在上一步的
Reserve阶段,只是在调度器的内存里把资源占住了。但是,有些资源不仅需要调度器记账,还需要去物理世界里真正做配置。 比如检查磁盘能否挂载,IP 池是否满了。Bind:将 pod 关联到 nodetype BindPlugin interface { Bind(ctx , state *CycleState, p *v1.Pod, nodeName string) *Status }所有 plugin 按配置顺序依次执行,每个 plugin 可以选择是否要处理一个给定的 pod,如果选择处理,后面剩下的 plugins 会跳过。也就是最多只有一个 bind plugin 会执行
PostBind:informational,可选,执行清理操作作为 binding cycle 的最后一个阶段,一般是用来清理一些相关资源
type PostBindPlugin interface { PostBind(ctx , state *CycleState, p *v1.Pod, nodeName string) }