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

理解k8s源码之scheduler调度框架设计
1BackgroundK8s 调度器演进史中最核心的设计——从“铁板一块”到“插拔式插件”的架构变革一、 为什么要有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//不可调度队列activeactiveQ提供即时调度的Pods。调度器在寻找可以调度的 Pod 时会主动且唯一盯着这个队列看。这个堆Heap结构的顶端Head永远是优先级Priority最高的那个 Pod。backoffpodBackoffQ等待特定条件发生的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。​ 它会根据队列名称queueactive/backoff/unschedulable进行标签分类。​ 2.queue_incoming_pods_total动态进去了多少次 Pod​ 这是一个计数器Counter类型的指标它只增不减记录了从调度器启动到现在Pod 被塞进某个队列的总次数它不仅记录次数还会附带记录“触发这次入队的操作或事件Event”。QueueSort对调度队列scheduling queue内的 pod 进行排序决定先调度哪些 pods。二、调度阶段scheduling cyclePreFilter预筛选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名称列表。调度器在后面的阶段就只会去遍历这几个节点剩下的几千个节点直接忽略。PostFilterFilter之后没有 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 cyclePreBindBind之前的预处理例如到 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 会执行PostBindinformational可选执行清理操作作为 binding cycle 的最后一个阶段一般是用来清理一些相关资源type PostBindPlugin interface { PostBind(ctx , state *CycleState, p *v1.Pod, nodeName string) }