一次 Pod 调度的端到端路径
Pod 创建、scheduler 决策、API Server 持久化、kubelet 执行 是异步衔接的四段职责。Scheduler 只完成选点与绑定,不负责创建容器。
用户/控制器创建 Pod
↓
API Server 写入 etcd(Pod.spec.nodeName 为空)
↓
Scheduler 监听到未调度 Pod
↓
调度队列 Scheduling Queue
↓
PreFilter
↓
Filter:筛选可用 Node
↓
PostFilter:必要时抢占
↓
Score:给 Node 打分
↓
NormalizeScore / Reserve
↓
Permit:可选等待/拒绝
↓
PreBind
↓
Bind:写入 Pod.spec.nodeName
↓
API Server 持久化绑定结果
↓
目标 Node 上的 kubelet 监听到该 Pod
↓
kubelet 拉镜像、创建容器、启动 Pod
1. Pod 创建与待调度状态
用户或控制器创建 Pod,例如 kubectl apply -f pod.yaml。如果 Pod 没有指定 spec.nodeName,它就是一个待调度 Pod。
spec:
nodeName: ""
API Server 会把 Pod 对象写入 etcd。此时调度还没有发生,集群里只是多了一个“期望被运行、但还没选 Node”的对象。
2. Scheduler 发现 Pod 并进入队列
kube-scheduler 通过 informer / watch 机制监听 API Server,重点关注 Pod.spec.nodeName 为空的 Pod。它们会进入 scheduler 内部的调度队列。
队列 作用 核心含义
activeQ当前可以尝试调度的 Pod 现在试试
backoffQ之前失败,等待退避时间结束的 Pod 过会儿再试
unschedulableQ当前没有可行节点,等待集群状态变化的 Pod 等条件变了再试
调度器会不断从 activeQ 中取出一个 Pod,开始一次 Scheduling Cycle 。
补充:Pod 不一定立刻进入 ActiveQ
新版 Kubernetes 里,Pod 进入正常调度队列前还可能被 SchedulingGates 或 PreEnqueue 拦住。这个点经常用来区分“只会背 Filter/Score”和“理解现代 scheduler”的候选人。
机制 发生位置 解决什么问题 面试口径
spec.schedulingGatesPod 入队前 外部控制器还没准备好前,不让 Pod 进入调度队列 有 gate 的 Pod 不会进入正常调度循环,避免无效 Filter/Score
PreEnqueueQueue 前的扩展点 插件可以在入队前判断 Pod 是否值得进入 ActiveQ 它比 PreFilter 更早,目标是减少无效入队
QueueingHint 调度失败后重新入队 判断某个集群事件是否真的可能让 Pod 变得可调度 它解决 UnschedulableQ 的惊群唤醒问题
收束句:不是所有 Pod 都马上进 ActiveQ;入队前有 gates,失败后有 QueueingHint,目的都是减少无效调度周期。
3. Scheduling Cycle:选择 Node
一次调度主要分成两个大阶段:Scheduling Cycle 负责选择 Node,Binding Cycle 负责把结果写回 API Server。 Scheduling Cycle 的目标是为当前 Pod 找到一个最合适的 Node。
阶段 做什么 面试抓手
PreFilter 提前计算后续过滤会用的信息,例如资源请求、PVC、亲和性、拓扑约束、端口需求 能算一次的,不要在每个 Node 上重复算
Filter 遍历候选 Node,判断每个 Node 能不能运行这个 Pod 回答“能不能放”
PostFilter Filter 没有可行节点时执行,典型动作是抢占 失败后的补救,不是常规打分
PreScore / Score 给可行节点打分,选出最优 Node 回答“放哪里最好”
NormalizeScore 把插件分数归一化到统一范围 不同插件分数才能加权汇总
Reserve 在调度器内部先预留资源 防止并发调度重复占用同一资源
Permit 可选地允许、拒绝或等待 Gang Scheduling 常用
4. Filter:筛选可用 Node
Filter 会得到一批可行节点,例如 feasibleNodes = [node-a, node-c, node-f]。如果为空,就说明当前 Pod 暂时无法调度。
过滤条件 例子 失败后常见现象
资源是否足够 Node 剩余 CPU / Memory / GPU 是否满足 Pod requests Insufficient cpu、Insufficient memory、Insufficient nvidia.com/gpu
NodeSelector / NodeAffinity Pod 要求 disk=ssd 或必须是 A100 节点 节点很多但标签不匹配
Taints / Tolerations Node 有 dedicated=gpu:NoSchedule,Pod 没有 toleration 被 TaintToleration 插件过滤
PodAffinity / AntiAffinity 必须靠近某类 Pod,或不能和同服务副本同节点 拓扑域或已有 Pod 分布不满足
Volume 约束 PV 是否能挂载到该 Node,volume zone 是否匹配 PVC / VolumeBinding 相关 FailedScheduling
HostPort 冲突 Pod 使用 hostPort: 8080 目标 Node 已有 Pod 占用相同端口
5. PostFilter:调度失败后的抢占
如果 Filter 阶段没有任何 Node 可用,会进入 PostFilter。最典型的动作是 Preemption :当前高优先级 Pod 调度不上时,尝试驱逐某些低优先级 Pod 腾出资源。
找到一些候选 Node
↓
模拟删除低优先级 Pod
↓
判断当前 Pod 是否可以放上去
↓
选出最合适的抢占目标
↓
设置 nominatedNodeName
注意:抢占不是立刻完成绑定,而是先让低优先级 Pod 进入删除流程;目标资源真正释放后,Pod 才有机会重新调度成功。
6. Score:给可行节点打分
如果 Filter 后存在多个可行 Node,调度器会进入 Score 阶段。每个打分插件会给 Node 一个分数,通常归一化到 0 ~ 100,最终加权求和。
finalScore(node) =
pluginA_score * weightA +
pluginB_score * weightB +
pluginC_score * weightC
打分维度 作用
资源分布策略 LeastAllocated 倾向空闲节点,MostAllocated 倾向装箱,RequestedToCapacityRatio 支持自定义利用率曲线
镜像本地性 Node 上已有镜像时得分更高,减少镜像拉取时间
亲和性偏好 preferredDuringSchedulingIgnoredDuringExecution 这类软约束影响打分,不决定能不能调度
拓扑分布 尽量让副本分散到不同 Node / Zone / Region,减少单点风险
如果多个 Node 同分,调度器会做一定的随机化或稳定选择,避免热点集中。
7. Reserve、Permit、PreBind 与 Bind
阶段 作用 失败处理
Reserve 选出目标 Node 后,在 scheduler 内部先为这个 Pod 预留资源 后续失败时执行 Unreserve 释放预留状态
Permit 可选阶段,可以允许绑定、拒绝绑定或等待一段时间 等待超时或拒绝时触发回滚
PreBind 绑定前处理,例如 volume binding、外部插件最终校验、自定义资源准备 失败则不会进入 Bind
Bind 向 API Server 发起绑定请求,把 Pod 更新为 spec.nodeName = selected-node 失败后进入调度失败处理
PostBind 绑定成功后的通知型动作,例如记录事件、异步上报 通常不影响 Pod 已经绑定的事实
关键点:Reserve / Assume 解决 scheduler 本地并发一致性,Bind 解决 API Server 中的持久化状态。
多个 Reserve 插件按配置顺序执行;某个 Reserve 失败后,后续 Reserve 不再执行,已经执行过的插件按反向顺序调用 Unreserve。Unreserve 必须幂等且不能返回错误,因为 Permit 拒绝/超时、PreBind 失败、Bind 失败都可能触发回滚。Permit 返回 Wait 时,Pod 进入 waiting Pods 集合,binding cycle 等待批准;超时会转为拒绝并触发 Unreserve,这使它适合表达 Gang 成员“先占位、凑齐后一起放行”的语义。
8. kubelet 发现并启动 Pod
API Server 接收到绑定请求后,会更新 Pod 对象并写入 etcd。此时 Pod 对象变成:
spec:
nodeName: node-a
目标 Node 上的 kubelet 会监听 spec.nodeName == 当前节点名 的 Pod。发现新 Pod 后,它进入 SyncPod 流程:
获取 PodSpec
↓
创建 Pod sandbox
↓
调用 CNI 配置网络
↓
挂载 volume
↓
拉取镜像
↓
通过 CRI 调用 container runtime
↓
创建容器
↓
启动容器
↓
上报 Pod 状态
如果使用 containerd,路径大致是 kubelet → CRI → containerd → runc / kata / gVisor。
职责边界:Scheduler 不负责起容器
组件 职责
kube-scheduler 决定 Pod 放到哪个 Node
API Server 保存 Pod 对象和绑定结果
etcd 持久化集群状态
kubelet 在目标 Node 上真正创建和运行 Pod
container runtime 创建容器进程
CNI 配置 Pod 网络
CSI / volume plugin 挂载存储
Scheduler 只负责“选机器”,不负责“起容器”;容器真正启动是在 kubelet 侧完成的。
源码口径的简化路径
ScheduleOne
↓
NextPod
↓
SchedulingCycle
↓
PreFilter
↓
Filter
↓
PostFilter if needed
↓
Score
↓
SelectHost
↓
Reserve
↓
Permit
↓
BindingCycle
↓
PreBind
↓
Bind
↓
PostBind
最核心的是:Filter 判断能不能放,Score 判断放哪里最好,Bind 把结果写回 API Server。
记忆版:Watch Pod → Queue → Filter → Score → Bind → Kubelet Run。
调度队列总览图
这张图要抓住一个核心:队列系统决定“下一个被尝试调度的是谁”,Filter/Score 才决定“它放到哪里”。 因此队列策略会直接影响等待时间、公平性、吞吐和重试风暴。
kube-scheduler 队列流转
activeQ / backoffQ / unschedulablePods / move request
New / Updated Pod
未绑定 Pod 进入调度器
带 priority / affinity / PVC
ActiveQ
当前可以立即尝试调度
内部按 QueueSort 排序
priority、timestamp、plugin 共同影响顺序
Scheduling Cycle
PreFilter / Filter / Score
用 snapshot 判断目标节点
成功后进入 assume / bind
Bind
写 API Server
Pod 获得 nodeName
BackoffQ
已被唤醒,但退避尚未结束
避免 CPU tight loop
到期后回到 ActiveQ
UnschedulableQ
当前没有任何可行节点
等待事件提示重新入队
不是按时间轮询为主
Move request
相关事件决定进入 ActiveQ 或 BackoffQ
队列职责
ActiveQ 控制机会分配;BackoffQ 控制失败重试节奏;UnschedulableQ 控制事件驱动唤醒;Move request 控制无效重试比例。
一个 Pod 在调度队列里的流转过程
下面用最直观的文本流程图展示 Pod 从创建到绑定(或失败重试)的完整路径:
新 Pod 创建
↓
进入 ActiveQ
↓
调度器从 ActiveQ 取出 Pod
↓
尝试调度(Filter → Score → Assume)
↓
├── 成功 ──→ 进入绑定流程(Bind → PostBind)
│
└── 失败 ──→ 放入 UnschedulablePods :记录失败插件,等待相关状态变化
↓ Node/Pod/PVC/ResourceClaim 等事件触发 QueueingHint / Move request
↓
├── 退避已结束 ──→ ActiveQ
└── 仍在退避 ───→ BackoffQ → 到期后进入 ActiveQ
这是最常见路径。若 Pod 正在一次调度尝试中时已经发生了可能使它恢复的 move request,失败处理可以直接把它放入 BackoffQ,避免错过该事件;退避到期后再进入 ActiveQ。
核心记忆:ActiveQ 是"现在试试",BackoffQ 是"过会儿再试",UnschedulableQ 是"等条件变了再试"。调度器的吞吐和延迟很大程度上取决于这三个队列之间的流转策略。
三个队列分别解决什么问题
调度器用三个队列管理不同状态的 Pod,而不是把所有 Pod 放在一个队列里轮询。理解这三个队列的进入条件、退出条件、排序策略和设计意图 ,是面试中区分“会用 K8s”和“理解调度器”的关键。
维度 ActiveQ BackoffQ UnschedulableQ
核心区别 现在试试 过会儿再试 等条件变了再试
进入条件 新 Pod 通过 PreEnqueue、BackoffQ 到期,或 Move request 时退避已结束 不可调度 Pod 被相关事件唤醒,但当前退避期限尚未结束 PreEnqueue 拒绝,或一次调度失败后等待可能改变结论的事件
退出条件 被调度器取出尝试调度 退避时间到期后移回 ActiveQ 集群事件(Node/Pod/PVC 变化)触发 Move request
排序策略 QueueSort 插件:默认按 priority 降序 + 入队时间 按退避到期时间排序(FIFO) 不排序,等待事件驱动唤醒
核心问题 谁先获得调度机会?队头阻塞、饥饿、公平性 失败后多久重试?退避过短浪费 CPU,过长增加延迟 什么时候唤醒?事件提示不精准会导致无效重试风暴
AI 场景 小推理任务、交互式 Notebook 能否插队 GPU 大作业资源不够时避免频繁扫描节点 等待 GPU 释放、RDMA 节点加入、PVC 绑定、gang 资源凑齐
机制影响 Filter/Score 只能处理已出队的 Pod;队列排序决定谁先获得机会 把失败重试从忙等变成有节奏的再尝试 保存暂不满足条件的 Pod;只让相关事件触发唤醒
ActiveQ 管“谁先上”,BackoffQ 管“别太急”,UnschedulableQ 管“等时机”。三个队列的流转策略直接影响调度器的吞吐、延迟和公平性。
Move request:为什么事件提示很关键
Move request 可以理解为“某个集群事件可能让一批不可调度 Pod 重新有机会”。调度器结合上次失败插件、事件类型和 QueueingHint 判断是否唤醒;命中后,如果该 Pod 的退避已经结束就进入 ActiveQ,否则先进入 BackoffQ。除此之外,UnschedulablePods 中停留过久的 Pod 还会被周期性 flush,避免因漏事件而永久沉睡。
事件 可能唤醒哪些 Pod 为什么 无效唤醒风险
Node 新增或 Node label 变化 nodeSelector、nodeAffinity、拓扑约束失败的 Pod 节点集合或标签变了,Filter 结果可能改变 如果所有 Pod 都唤醒,会造成全量重试
Pod 删除或完成 资源不足、端口冲突、反亲和失败的 Pod CPU/GPU/内存/端口/拓扑位置被释放 只释放 CPU 却唤醒 GPU 不足的 Pod,收益很低
PVC 绑定完成 之前因 volume binding 失败的 Pod 存储条件满足后才可能通过 Filter 和存储无关的 Pod 不应被大量唤醒
ResourceSlice / ResourceClaim 变化 DRA 设备匹配失败的 Pod 设备库存、属性或 claim 状态变化 设备事件过粗会导致大量 GPU Pod 重试
PodGroup / quota 变化 Gang、队列配额、批任务准入失败的 Pod 组资源或配额条件变化 准入条件未变化时重试只会消耗调度周期
队列性能优化不是“多重试几次”,而是“在正确事件发生后,只唤醒可能变得可调度的 Pod”。
调度问题定位:区分 Pod 属性、调度阶段和调度机制
在分析 Kubernetes Scheduler 时,需要区分三类概念,这三类概念不能混在一起 :
Pod 属性: Pod 自身携带的信息,例如优先级、资源请求、节点选择约束等。
调度阶段 / 扩展点: Scheduler Framework 中处理 Pod 的流程位置,例如 QueueSort、Filter、Score、Reserve、Permit。
调度机制 / 策略: 由多个阶段共同完成的行为,例如抢占、退避重试、Gang 调度、回填调度等。
例如,priority 是 Pod 的属性,它会影响队列排序和抢占,但它本身不是调度阶段。Preemption 是调度失败后的抢占机制,通常发生在没有可行节点之后,和 PostFilter 等流程有关,但它也不是普通的节点打分阶段。QueueSort、Filter、Score、Reserve、Permit 才是 Scheduler Framework 中更明确的扩展点。
三类概念对照表
类型 示例 说明
Pod 属性 priority、resource requests、nodeSelector、affinity、tolerations、preemptionPolicy描述 Pod 自身需求或调度约束,不是调度阶段
调度阶段 / 扩展点 QueueSort、PreFilter、Filter、PostFilter、Score、Reserve、Permit、Bind、Unreserve Scheduler Framework 中的处理流程,可以开发插件扩展
调度机制 / 策略 Preemption、Backoff、UnschedulableQ 重新入队、Gang Scheduling、Backfill、Quota 管理 通常横跨多个阶段,不一定对应单一扩展点
常见问题应该从哪里定位
问题 本质 主要涉及的调度阶段 相关 Pod 属性 / 机制 说明
高优先级 Pod 长时间没被调度 Pod 没有及时获得调度机会,或资源被低优任务占住 QueueSort、PostFilter Pod 属性: priority机制: Preemptionpriority 影响队列排序和抢占;如果 Pod 未出队,先看 QueueSort;如果出队后无节点可放,再看抢占
Pod 反复扫描大量节点但失败 失败 Pod 被无效重新入队 SchedulingQueue、PreFilter、Filter 机制: Backoff、UnschedulableQ、事件提示应优化重新入队条件,避免无关事件唤醒无关 Pod
短作业被大作业队头阻塞 出队顺序不合理 QueueSort 机制: Backfill、多队列小作业没机会出队时,Score 不会生效
GPU 拓扑放置不合理 节点或设备组合选择不好 Filter、Score、Reserve Pod 属性: resource requests、nodeAffinity、GPU topologyPod 已进入调度周期,问题是放到哪里和怎么预留设备
Gang 任务部分 Pod 占住资源但整体无法启动 缺少整组准入与失败回滚 Reserve、Permit、Unreserve 机制: Gang Scheduling、PodGroup需要整组 Pod 要么一起放行,要么一起回滚
高优任务无节点可放,但低优任务占着资源 资源不足,需要让低优任务让路 PostFilter Pod 属性: priority、preemptionPolicy机制: Preemption没有可行节点时,Score 没意义,需要抢占制造可行节点
面试表达技巧:先区分"这是 Pod 属性问题、调度阶段问题还是调度机制问题",再定位到具体扩展点或策略。不要把 priority 说成"调度阶段",也不要把 Preemption 说成"打分的一部分"。
Scheduler Cache 与 Assume 机制
scheduler 不会每调度一个 Pod 都从 API Server 重新拉全量 Node 和 Pod。它维护本地 cache,并在调度周期开始时生成 snapshot。选中节点后,scheduler 会先在本地 cache 中 assume 该 Pod 已经占用资源,然后异步绑定。
机制 解决什么问题 风险
NodeInfo 缓存节点资源、Pod、镜像、本地状态 cache 与 API Server 存在短暂不一致
Snapshot 给一个调度周期提供稳定视图 不是强一致,只是调度器本地视角
Assumed Pod 绑定完成前先占住资源,避免过度分配 Bind 失败后必须过期或回滚
Nominated Pod 抢占时记录候选节点 被抢占 Pod 退出前,高优先级 Pod 仍可能等待
Cache 与 API Server 暂时不一致时如何收敛
Scheduler Cache 是基于 Informer 事件维护的低延迟视图,不是跨 API Server、节点和外部 scheduler 的强一致事务。默认 scheduler 依靠单 Leader、Cache 顺序更新、Assume 提前记账和绑定结果回流,把常见竞态限制在可恢复范围内。
时刻 内存与持久状态 恢复机制
启动或切主 新进程没有旧 Cache 与 Assume 状态 先等待 Informer Cache 同步;已 Bind Pod 从 API 对象重建,未 Bind Pod 重新进入队列
选中 Node、Bind 尚未完成 API 中仍未绑定,但 Cache 已计入 Assumed Pod 后续 scheduling cycle 先看到资源被占,避免同一 scheduler 过度分配
Bind 成功 API Server 保存 spec.nodeName Informer 的 Add/Update 事件确认绑定,并把 assumed 状态转为普通已绑定状态
Bind 失败或 assumed Pod 超时 API 中没有成功绑定 Forget assumed Pod、执行 Unreserve,并把 Pod 交回失败处理与队列重试
Node/Pod/PVC 在调度中发生变化 当前 Snapshot 可能稍旧 相关 API 写入使用 resourceVersion/绑定语义保护;Informer 事件更新 Cache,失败 Pod 重试时基于新 Snapshot 重算
这种设计保证的是在标准单活 scheduler 模型下最终收敛,不是任意并发写入下的全局串行化。如果多个独立 scheduler 或外部组件同时分配同一批 Node 资源,它们必须共享 Reservation/Claim 协议或划分互斥资源池,不能只依赖各自 Cache。
调度失败状态:不是所有 Pending 都一样
面试官问 Pod 为什么 Pending 时,不要只答“资源不够”。scheduler 内部会区分失败类型,这决定了后续是等待事件、退避重试、记录错误,还是进入抢占。
状态 含义 后续处理 例子
Unschedulable当前没有满足条件的节点,但未来集群状态变化可能解决 进入失败处理,等待 QueueingHint / Move request 唤醒 资源暂时不足、PodAntiAffinity 暂时不满足
UnschedulableAndUnresolvable普通事件很难让它变可调度,通常是硬约束本身不可能满足 减少无效重试,等待更强的配置变化 nodeSelector 指向不存在的标签、硬约束写错
Error插件或内部执行异常,不是业务资源约束 记录 error,按失败路径处理并暴露事件/日志 插件读取 cache 失败、外部 extender 返回错误
FitError没有 feasible node 时聚合出的调度失败结果 转成 FailedScheduling 事件,里面包含各插件失败原因 0/100 nodes are available
排查口径:Pending 先看 FailedScheduling 事件,再看是哪个 Plugin 产生了哪类 Status;不要把配置错误、资源不足和插件异常混成一类。
Assume / Reserve / Bind:三个“占用”不是一回事
这张表是面试里解释调度一致性的关键。scheduler 的本地状态、插件状态和 API Server 持久化状态是三层不同状态。
阶段 写哪里 解决什么问题 失败如何恢复
Assume scheduler cache Bind 还没完成前,先让后续调度周期看到资源已被占用,避免过度分配 Bind 失败或超时后 Forget assumed Pod
Reserve 插件自己的内存账本或状态 预留插件特有资源,例如 GPU 拓扑、MIG slot、PodGroup 名额 后续失败时调用 Unreserve
Bind API Server / etcd 把最终结果持久化为 spec.nodeName 或 Binding 对象 失败后走调度失败路径,已 Reserve 的状态要回滚
PostBind 通常是事件、日志或外部通知 绑定成功后的通知,不再改变放置决策 一般不影响 Pod 已经绑定的事实
Assume 是 scheduler 本地先占位,Reserve 是插件状态先占位,Bind 是把结果写进 API Server。
Plugin 扩展点与调度研究问题的映射
研究问题 适合扩展点 说明
短作业优先 / SLA 排序 QueueSort 改变 Pod 出队顺序,影响全局等待时间
Gang Scheduling PreFilter + Permit + Reserve 先识别 PodGroup,再在 Permit 阶段等待同组 Pod 凑齐
拓扑感知放置 PreFilter + Filter + Score 基于 NUMA、NVLink、机架、RDMA 等约束过滤和打分
多资源公平 QueueSort + Score + PostFilter 排序决定谁先获得机会,抢占决定如何回收资源
代价基抢占 PostFilter 调度失败后选择 victim,考虑 checkpoint、运行时长和释放资源量
DRA 设备匹配 PreFilter + Filter + Reserve 基于 ResourceClaim 和 ResourceSlice 做设备级匹配与预留
Preemption 深入
抢占不是简单地“杀掉低优先级 Pod 后马上运行高优先级 Pod”。scheduler 会先寻找通过移除低优先级 Pod 后可满足高优先级 Pod 的节点,选择 victim 后设置 nominatedNodeName,等待被抢占 Pod 优雅退出。期间如果集群状态变化,调度结果仍可能改变。
PDB: PodDisruptionBudget 会影响 victim 选择,减少对高可用服务的破坏。
Graceful termination: 被抢占 Pod 有终止宽限期,高优先级 Pod 不能立刻拿到资源。
不可抢占约束: nodeSelector 不匹配、PVC 约束不满足、硬亲和性不满足,抢占也解决不了。
训练任务代价: AI 训练抢占要考虑 checkpoint 新鲜度、已运行时间、重启成本和 gang 语义。
抢占四问:面试官最常追
问题 回答抓手
抢占是不是直接杀 Pod? 不是。scheduler 选择 victim 后,低优 Pod 进入优雅删除流程;高优 Pod 通常先记录 nominatedNodeName,等待资源真正释放。
为什么抢占后高优 Pod 还 Pending? victim 有 termination grace period;PDB 可能限制驱逐;同时集群状态可能变化,原 nominated node 未必最终可用。
preemptionPolicy: Never 是什么?这个 Pod 可以有高 priority 参与排序,但不会主动抢占别人,适合高优但不想破坏其他任务的工作负载。
什么问题抢占也解决不了? 硬约束不匹配,例如 nodeAffinity 写错、PVC zone 不匹配、GPU 型号不存在、Taint 不容忍、端口冲突不可通过删除低优 Pod 解决。
Gang Scheduling → 见"任务调度理论"页面
Gang Scheduling 的理论基础(partial allocation、PodGroup/minAvailable、Backfill、弹性训练)和 K8s 实现细节(Coscheduling Plugin / Volcano / Kueue、Framework 扩展点落点、边界情况)已统一归并到 "任务调度理论" → "批调度、Gang 与 Backfill" 标签页。
快速索引:Gang 概念与 partial allocation → 任务调度理论 / 批调度、Gang 与 Backfill;Framework 扩展点落点 → 同上;Coscheduling / Volcano / Kueue 对比 → 同上;边界情况与坑 → 同上。
Q: 为什么说调度算法不能脱离 scheduler cache 和 binding cycle 讨论?
因为算法给出的只是“应该放哪里”,而 Kubernetes 还要解决并发绑定、缓存一致性、资源临时预留、失败回滚和 API Server 写入延迟。一个理论上最优的策略,如果不能处理 assume、reserve、unreserve、permit timeout 和抢占等待,在真实 kube-scheduler 中就不可落地。
Q: Scheduler Extender、Scheduler Plugin、多个 scheduler 怎么选?
新能力优先用 Scheduling Framework Plugin,因为它能接入完整生命周期和 scheduler cache;Extender 更像外部 HTTP 过滤/打分,延迟和一致性控制较弱;多个 scheduler 适合业务强隔离,但要避免不同 scheduler 同时竞争同一批资源造成策略冲突。
K8s 整体架构定位
设计理念六维矩阵
六维设计目标
维度 含义 对应机制 典型权衡
可扩展性 支持业务定制调度逻辑,而不是改 scheduler 源码 Scheduling Framework Plugin、Extender、Multiple Scheduler、DRA 性能(in-tree)vs 灵活性(out-of-tree HTTP)
效率优先 大集群下保证调度延迟可控 percentageOfNodesToScore、Filter 并行、Snapshot、Cache 调度质量 vs 调度速度
声明式 API 用户描述"想要什么",不是"怎么做" Pod.spec.affinity / tolerations / topologySpreadConstraints 表达力 vs 复杂度
公平性 避免大作业饿死小作业、避免单租户耗尽资源 QueueSort、PriorityClass、Preemption、ResourceQuota、Kueue 公平 vs 吞吐
高可用(HA) scheduler 自身故障不影响新 Pod 调度 Leader Election、多副本、--leader-elect-resource-name 故障切换时间 vs 一致性
用户可配置性 不同业务用不同调度策略 KubeSchedulerConfiguration、多 Profile、pluginConfig 配置灵活 vs 运维复杂度
面试用法:被问"K8s scheduler 的设计哲学是什么"先报六维,再用具体 Plugin 举例。
经典插件一:NodeAffinity(Required vs Preferred)
Node Affinity / Anti-Affinity 与 Pod Affinity / Anti-Affinity
这里有三组概念容易混:Node Affinity、Node Anti-Affinity、Pod Affinity / Pod Anti-Affinity 。核心区别:
Node Affinity / Anti-Affinity:Pod 和节点之间的关系。Pod Affinity / Anti-Affinity:Pod 和 Pod 之间的关系。
Node Affinity:Pod 对节点有偏好
Node Affinity 解决的是:这个 Pod 应该去什么样的机器上? 它是比 nodeSelector 更强大的节点选择机制,支持软约束(preferred)和硬约束(required),以及基于节点标签的复杂表达式。
类型 行为 典型场景
requiredDuringSchedulingIgnoredDuringExecution 硬约束,Pod 必须调度到满足条件的节点,否则 Pending 必须是 A100 节点、必须在北京机房
preferredDuringSchedulingIgnoredDuringExecution 软约束,优先调度到满足条件的节点,但不强制 最好在 SSD 节点、最好在北京机房(但上海也可以)
IgnoredDuringExecution 的含义
调度时会检查这个规则;Pod 已经运行后,如果节点标签变化了,Kubernetes 默认不会因为这个规则再把 Pod 驱逐掉。这是设计选择:避免运行时驱逐造成服务中断。如果需要运行时驱逐,用 Taint 的 NoExecute 效果。
Node Anti-Affinity
Kubernetes 里严格说没有一个和 nodeAffinity 同级的字段叫 nodeAntiAffinity,但可以通过 nodeAffinity 里的 NotIn、DoesNotExist 等表达"不要去某些节点"。例如:不要调度到 V100 节点、不要调度到 spot 节点。
NodeAffinity 在两个扩展点上的不同行为
NodeAffinity 同时挂在 Filter (处理 Required)和 Score (处理 Preferred)两个扩展点上。这是"硬约束 vs 软偏好"在调度框架里的标准落地方式。
类型 字段 挂在哪个扩展点 不满足时的行为
Required requiredDuringSchedulingIgnoredDuringExecutionPreFilter + Filter 节点直接被过滤掉,Pod Pending
Preferred preferredDuringSchedulingIgnoredDuringExecutionPreScore + Score 节点得分降低,但仍可能被选中
Required 在 Filter 阶段的判断逻辑
// 简化版,pkg/scheduler/framework/plugins/nodeaffinity/node_affinity.go
func (pl *NodeAffinity) Filter(ctx context.Context, state *framework.CycleState,
pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
node := nodeInfo.Node()
affinity := pod.Spec.Affinity
// 没有 Required 约束 → 直接通过
if affinity == nil || affinity.NodeAffinity == nil ||
affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil {
return nil
}
// 把 Required 转成 NodeSelector,对 Node 求值
selector, err := nodeaffinity.NewNodeSelector(
affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution)
if err != nil {
return framework.NewStatus(framework.Error, err.Error())
}
if !selector.Match(node) {
return framework.NewStatus(framework.UnschedulableAndUnresolvable,
"node(s) didn't match Pod's node affinity/selector")
}
return nil
}
Preferred 在 Score 阶段的打分公式
Preferred 项每条带 weight(1-100)。一个节点的 NodeAffinity 得分等于它满足的 preferredTerm 的 weight 之和 ,再归一化到 0-100。
$$\text{NodeAffinityScore}(n) = \sum_{t \in \text{preferred terms}} w_t \cdot \mathbb{1}[\text{node } n \text{ matches term } t]$$
归一化(在 NormalizeScore 阶段):
$$\text{Score}_{norm}(n) = \frac{\text{NodeAffinityScore}(n)}{\max_n \text{NodeAffinityScore}(n)} \cdot \text{MaxNodeScore}$$
其中 MaxNodeScore = 100。
Pod Affinity / Anti-Affinity:Pod 之间的关系
Pod Affinity 解决的是:这个 Pod 希望和哪些已有 Pod 放近一点? 判断对象不是节点标签,而是已有 Pod 的标签 。Pod Anti-Affinity 则相反:不希望和某些 Pod 放得太近。
类型 判断对象 典型场景
Pod Affinity 已有 Pod 的标签 训练任务靠近数据缓存 Pod(降低延迟);Worker 靠近 Parameter Server
Pod Anti-Affinity 已有 Pod 的标签 同服务副本不要在同一节点(高可用);两个大 GPU 任务不要在同一台机器(避免资源竞争)
topologyKey 是什么?
Pod Affinity / Anti-Affinity 中 topologyKey 表示"靠近"或"远离"是按什么范围来定义的:kubernetes.io/hostname 表示同一节点,topology.kubernetes.io/zone 表示同一可用区,rack 表示同一机架。
性能影响
Pod Affinity/Anti-Affinity 需要在调度时扫描大量 Pod,大规模集群中可能显著增加调度延迟。建议限制 topologyKey 的粒度,避免在超大集群中使用跨节点的 Pod Anti-Affinity。
Node Affinity 和 Pod Affinity 的区别(面试核心)
类型 判断对象 例子
Node Affinity 节点的标签 我要去 A100 节点
Node Anti-Affinity 节点的标签 我不要去 spot 节点
Pod Affinity 已有 Pod 的标签 我要靠近 redis Pod
Pod Anti-Affinity 已有 Pod 的标签 我不要和同服务副本在同一节点
Node Affinity 看节点标签,Pod Affinity 看已有 Pod 标签。 硬约束(required)主要在 Filter 阶段起作用,不满足就直接过滤掉节点;软偏好(preferred)主要在 Score 阶段起作用,满足偏好的节点得分更高。
经典插件二:TaintToleration(三种 Effect 的语义差异)
三种 Effect 的语义、扩展点和触发对象
Effect 语义 挂在哪个扩展点 对正在运行的 Pod 的影响 典型场景
NoSchedule新 Pod 不容忍则不能调度到此节点 Filter 不影响(已运行的 Pod 留在节点上) GPU 节点专用、节点池隔离
PreferNoSchedule新 Pod 不容忍则尽量不调度,但不强制 Score(不是 Filter) 不影响 软隔离,例如"成本高的 spot 节点尽量后用"
NoExecute新 Pod 不容忍则不能调度;运行中的 Pod 不容忍则被驱逐 Filter + 由 controller-manager 中的 TaintEvictionController 执行驱逐 会驱逐 (可通过 tolerationSeconds 延迟)节点不健康、维护前抢占清场
关键区分:NoSchedule 在 Filter,PreferNoSchedule 在 Score —— 所以 PreferNoSchedule 不会让节点出现在 FailedScheduling 事件里。NoExecute 是唯一一个事后驱逐 的 effect。
容忍判断逻辑
Toleration 通过 operator 决定匹配方式:
Equal(默认):要求 key、value、effect 都相等。
Exists:只要 key 存在即可(value 字段必须为空),常用于"容忍所有 NoSchedule"。
"通配容忍"模式:
# 容忍任意 NoSchedule taint
- operator: Exists
effect: NoSchedule
# 容忍所有 effect 的所有 taint(很危险,仅 system pod 使用)
- operator: Exists
经典插件三:NodeResourcesFit(Filter + 三种打分策略)
Filter 阶段:装得下吗
NodeResourcesFit 在 Filter 阶段判断节点可用资源是否能装下 Pod 的 requests。逻辑很直接:对每个资源类型(CPU、Memory、扩展资源),检查 node.Allocatable - sum(running pods.requests) ≥ pod.requests。
Score 阶段:三种打分策略
NodeResourcesFit 在 Score 阶段支持三种策略,通过 scoringStrategy.type 配置。下面给出每种策略的打分公式。
1. LeastAllocated(默认):剩余资源越多分越高
$$\text{Score}_{Least}(n) = \frac{\sum_i (\text{Allocatable}_i - \text{Requested}_i) \cdot w_i / \text{Allocatable}_i}{\sum_i w_i} \cdot \text{MaxNodeScore}$$
其中:
$i$ 遍历每种资源(CPU、Memory、扩展资源等)
$w_i$ 是该资源的权重(在 resources 中配置)
$\text{MaxNodeScore} = 100$
语义: 把负载分散到资源最空闲的节点,适合通用场景。
2. MostAllocated:剩余资源越少分越高(装箱)
$$\text{Score}_{Most}(n) = \frac{\sum_i \text{Requested}_i \cdot w_i / \text{Allocatable}_i}{\sum_i w_i} \cdot \text{MaxNodeScore}$$
语义: 把 Pod 集中到已经"快装满"的节点,腾出空节点用于大任务。适合 GPU 训练等需要整机资源的场景,避免碎片化。
3. RequestedToCapacityRatio:曲线打分
支持自定义"利用率 → 分数"的折线映射:
$$\text{Score}_{RTC}(n) = \frac{\sum_i \text{piecewise}(\text{Requested}_i / \text{Allocatable}_i) \cdot w_i}{\sum_i w_i}$$
其中 piecewise 由用户配置的 shape: [{utilization, score}, ...] 折线决定。例如:
scoringStrategy:
type: RequestedToCapacityRatio
resources:
- name: nvidia.com/gpu
weight: 5
requestedToCapacityRatio:
shape:
- utilization: 0
score: 0
- utilization: 100
score: 10
语义: 表达"装到 80% 最优、再装会降速"这类非线性偏好。常用于"同时考虑装箱和性能拐点"的场景。
三种策略的选型
策略 典型场景 风险
LeastAllocated 通用业务、CPU/Memory 资源均匀打散 大 GPU 任务可能找不到整机资源
MostAllocated GPU 训练集群、希望先装满旧节点再用新节点 单节点故障影响多个 Pod
RequestedToCapacityRatio 有明确性能拐点的场景,例如 GPU 利用率 ≥ 80% 后性能下降 配置复杂,需要持续 tune shape 曲线
Scheduler Extender(HTTP 扩展)
Extender 是什么、和 Plugin 的关系
Extender 是 K8s 早期的扩展机制,核心是一个独立 HTTP 服务,scheduler 在 Filter / Prioritize / Bind 等阶段通过 HTTP 调用它。
维度 Extender Scheduling Framework Plugin
部署形态 独立 HTTP 服务 编译进 scheduler 二进制
调用开销 HTTP 网络调用(ms 级) 函数调用(μs 级)
访问 scheduler cache 不能 可以
支持的扩展点 Filter、Prioritize、Preempt、Bind 全部 12 个扩展点
语言 任意(HTTP 服务) 仅 Go
定位 历史兼容、跨语言简单扩展 新功能首选
Extender 的 HTTP 通信路径
scheduler 主流程(schedulingCycle)
|
|--- Filter 阶段(in-tree filters 跑完)
| |
| v
| POST {extenderURL}/filter
| Body: {Pod, Nodes, NodeNameToInfo}
| Response: {Nodes, FailedNodes, Error}
|
|--- Score 阶段(in-tree scores 跑完)
| |
| v
| POST {extenderURL}/prioritize
| Body: {Pod, Nodes}
| Response: [{Host, Score}, ...]
|
|--- Bind 阶段(如果 extender 配置了 bindVerb)
| |
| v
| POST {extenderURL}/bind
| Body: {PodName, PodNamespace, PodUID, Node}
| Response: {Error}
KubeSchedulerConfiguration 中配置 Extender
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
extenders:
- urlPrefix: "http://gpu-extender.kube-system.svc:8080"
filterVerb: "filter"
prioritizeVerb: "prioritize"
weight: 5
enableHTTPS: false
nodeCacheCapable: true # extender 自己缓存 NodeInfo,scheduler 只传 NodeName
managedResources: # 只对包含这些资源的 Pod 调用 extender
- name: "example.com/foo"
ignoredByScheduler: true
httpTimeout: 1s # 超时强制返回,避免阻塞 schedulingCycle
ignorable: false # extender 故障时是否允许调度继续
面试要点:Extender 现在主要见于历史遗留系统。新需求一律推荐 Framework Plugin。如果一定要用 Extender,关键参数是 httpTimeout 和 ignorable,否则 Extender 故障会拖死整个 scheduler。
抢占(Preemption)的设计哲学
抢占体现的设计哲学
声明式: 用户通过 PriorityClass 表达"重要程度",不需要写抢占代码。
公平性: 抢占只能"高优抢低优",避免同优先级互抢;PDB 限制驱逐范围。
异步退出: 设置 nominatedNodeName 后等待 victim graceful shutdown,而不是立刻杀掉,保证服务连续性。
可扩展性: 抢占决策在 PostFilter 扩展点,自定义 Plugin 可以替换默认抢占逻辑(例如考虑 GPU checkpoint 新鲜度)。
调度框架全景图
下面这张是 kubernetes/enhancements/keps/sig-scheduling/624-scheduling-framework 设计文档给出的官方流程图。
官方资料:Scheduling Framework · Scheduler Configuration
QueueSort:全局队列只能有一套顺序
Less(p1, p2) 决定谁先获得调度机会
QueueSort 不选择节点,而是比较 ActiveQ 中两个 Pod 的先后顺序。默认 PrioritySort 先比较 Priority,优先级相同时再比较入队时间。自定义实现可以加入租户公平性、deadline 或预测运行时间,但必须保留确定的 tie-breaker,并满足传递性;否则优先队列可能出现不稳定顺序。
规则 原因
同一时刻只能启用一个 QueueSort Plugin 一个优先队列只能依赖一套比较关系维护堆序
同一 kube-scheduler 的所有 Profile 必须使用相同插件和相同参数 多个 Profile 共享同一个 pending Pods queue,而不是各自维护 ActiveQ
比较器必须有稳定的最终 tie-breaker 避免两个 Pod 在多次比较中前后关系漂移,并降低饥饿风险
排序状态必须能低成本读取 Less 位于队列热路径,外部 RPC 会直接放大入队和出队延迟
PreFilter vs Filter:为什么必须拆开
核心差异表
维度 PreFilter Filter
阶段目标 数据预处理 + 全局状态检查 节点级过滤 ,逐节点检查条件
数据流 写入共享数据到 CycleState 从 CycleState 读取数据并过滤节点
执行顺序 所有 PreFilter 插件按配置顺序执行 候选 Node 可以并行评估;同一 Node 内的 Filter 插件按配置顺序执行
终止能力 可以提前终止整个调度周期(如 Pod 不合法、PodGroup 不齐) 仅排除当前节点,不影响其它节点判断
调用次数 每个调度周期调用一次 每个候选节点调用一次(节点数 × 插件数)
典型工作 解析 Pod annotation、查 PodGroup 状态、构建拓扑索引、计算资源需求 检查节点资源、Taint、Affinity、Volume、自定义约束
设计哲学:能在 PreFilter 算一次的事,绝不在 Filter 里对每个节点重复算 。这是 Filter 阶段并行化的前提。
为什么要这样切:一个具体例子
假设你写一个「Pod 必须放在和它的 PodGroup 其他成员同 zone 的节点上 」插件:
查 PodGroup 当前已绑定到哪些 zone:这是一次集群级查询,所有节点都用同一个结果 。如果放在 Filter 里,N 个节点会查 N 次,性能爆炸。
正确做法:PreFilter 里查一次写入 CycleState["targetZones"] = [...];Filter 里只做 node.zone in targetZones 这种 O(1) 判断。
这同时解释了为什么不同 Node 的 Filter 计算可以并行:每个 goroutine 只读本轮准备好的 CycleState 和当前 NodeInfo。插件若在 Filter 中修改共享状态,必须自行保证并发安全。
Filter 的短路与失败语义
对一个候选 Node,scheduler 按配置顺序调用 Filter 插件。只要某个插件把该 Node 判为 infeasible,后续 Filter 插件就不再为这个 Node 执行;其他 Node 的评估不受影响,并可继续并行。
返回状态 含义 后续影响
Success当前插件允许该 Node 继续执行该 Node 的下一个 Filter 插件
Unschedulable当前条件下不可行,但状态变化后可能恢复 该 Node 短路;失败插件进入 Diagnosis,后续事件可通过 QueueingHint 唤醒 Pod
UnschedulableAndUnresolvable当前约束很难由普通集群事件解决 该 Node 短路,并减少无意义的抢占或重试
Error插件执行或依赖发生内部错误 不是普通“不满足约束”,本次调度按错误路径失败并重试
短路意味着 FailedScheduling 事件不保证列出每个 Node 上所有潜在失败原因;它记录的是实际执行到的诊断结果。调整 Filter 插件顺序既影响性能,也可能影响首先暴露给用户的失败原因。
PreScore vs Score:同样的设计套路
核心差异表
维度 PreScore Score
阶段目标 全局数据准备,避免重复计算 节点级打分,按策略生成优先级
数据粒度 集群级 / 候选节点列表级 单节点级
执行频率 每个调度周期一次 每个候选节点一次
输出影响 不直接参与最终决策,只准备中间数据 直接影响节点排名(0–100)
典型例子:PodTopologySpread 在 PreScore 里统计每个拓扑域当前已有多少 Pod;Score 里只做「这个节点所在域是不是欠的最多」的查表打分。
NormalizeScore:被忽略的第三段
Score 出来的原始分可能不在 [0, MaxNodeScore] 区间内。NormalizeScore 是同一个插件的最后机会 对自己所有节点的分数做一次归一化(线性缩放、对数压缩等),保证不同插件的分数能加权合并。
每个 Score 插件先完成自己的节点打分和可选归一化,Framework 再校验分数范围并乘以该插件在 KubeSchedulerConfiguration 中配置的 weight,最后对同一 Node 求和:
FinalScore(node) = Σ Normalize(pluginScore(node)) × pluginWeight
某个 Score 插件返回错误时,本次调度周期按错误处理,而不是忽略它后继续用不完整的总分选 Node。并列最高分节点由 scheduler 再做选择,不能假定总会固定命中同一个节点。
Plugin 与 Hook 的多对多结构
K8s scheduler 框架的优雅之处:一个插件可以挂多个 Hook,一个 Hook 可以挂多个插件,一个 Hook 内可以注册多种策略 。下面三段代码是 kube-scheduler 源码里的真实写法。
1. 一个插件挂多个 Hook(NodeAffinity)
// pkg/scheduler/framework/plugins/nodeaffinity/node_affinity.go
var _ framework.PreFilterPlugin = &NodeAffinity{}
var _ framework.FilterPlugin = &NodeAffinity{}
var _ framework.PreScorePlugin = &NodeAffinity{}
var _ framework.ScorePlugin = &NodeAffinity{}
var _ framework.EnqueueExtensions = &NodeAffinity{}
这五行 var _ = ... 是什么写法
这是 Go 里一个常见的编译期接口实现校验 技巧:
var _ framework.FilterPlugin = &NodeAffinity{} 这一行不引入任何运行时变量(_ 是空标识符),但会强制编译器检查 *NodeAffinity 是否实现了 framework.FilterPlugin 接口的所有方法。
少写一个方法 → 编译失败 ,不会等到运行时才报错。
面试可以答:"这是一种零运行时开销的接口契约校验,K8s、etcd、Docker 等大型 Go 项目都在用。"
2. 一个 Hook 挂多个插件(ScorePlugin)
// 这些都在不同的 plugin 目录里,全部实现了 ScorePlugin
var _ framework.ScorePlugin = &NodeAffinity{} // nodeaffinity
var _ framework.ScorePlugin = &Fit{} // noderesources(默认 LeastAllocated)
var _ framework.ScorePlugin = &BalancedAllocation{} // noderesources(CPU/Mem 均衡)
var _ framework.ScorePlugin = &TaintToleration{} // tainttoleration
var _ framework.ScorePlugin = &PodTopologySpread{} // podtopologyspread
调度器最终给某个节点的总分 = Σ (插件分 × 插件权重)。权重在 KubeSchedulerConfiguration 里配置,不重新编译就能改 。
3. 一个插件在一个 Hook 里挂多种策略(NodeResourcesFit)
// pkg/scheduler/framework/plugins/noderesources/resource_allocation.go
var nodeResourceStrategyTypeMap = map[config.ScoringStrategyType]scorer{
config.LeastAllocated: func(args *config.NodeResourcesFitArgs) *resourceAllocationScorer {
return &resourceAllocationScorer{
Name: string(config.LeastAllocated),
scorer: leastResourceScorer(args.ScoringStrategy.Resources),
resources: args.ScoringStrategy.Resources,
}
},
config.MostAllocated: func(args *config.NodeResourcesFitArgs) *resourceAllocationScorer {
return &resourceAllocationScorer{
Name: string(config.MostAllocated),
scorer: mostResourceScorer(args.ScoringStrategy.Resources),
resources: args.ScoringStrategy.Resources,
}
},
config.RequestedToCapacityRatio: func(args *config.NodeResourcesFitArgs) *resourceAllocationScorer {
return &resourceAllocationScorer{
Name: string(config.RequestedToCapacityRatio),
scorer: requestedToCapacityRatioScorer(args.ScoringStrategy.Resources, args.ScoringStrategy.RequestedToCapacityRatio.Shape),
resources: args.ScoringStrategy.Resources,
}
},
}
三种策略对应三种目标:LeastAllocated 倾向分散、MostAllocated 倾向 bin packing、RequestedToCapacityRatio 使用自定义利用率—分数曲线 。
kube-scheduler 源码目录职责
framework/interface.go 定义扩展点契约,runtime/framework.go 负责插件注册、配置与调用,schedule_one.go 把这些 Hook 串入单 Pod 调度和绑定主循环。
kubernetes/pkg/scheduler/
├── apis/ # KubeSchedulerConfiguration 结构、参数校验
├── framework/ # 调度框架核心
│ ├── interface.go # 所有扩展点接口定义(必读起点)
│ ├── cycle_state.go # CycleState 线程安全状态读写
│ ├── types.go # NodeInfo、PodInfo、QueuedPodInfo
│ ├── events.go # 调度事件记录
│ ├── extender.go # 外部 Extender 的 HTTP 通信
│ ├── listers.go # 本地 Node/Pod cache 查询
│ ├── parallelize/ # 并行 Filter 工具(默认 16 协程)
│ ├── preemption/ # 抢占公共逻辑(PostFilter 复用)
│ ├── plugins/ # 内置插件(nodeaffinity、noderesources、…)
│ ├── runtime/ # 插件注册、配置加载、依赖解析
│ └── autoscaler_contract/ # 与 Cluster Autoscaler 交互的协议
├── backend/ # SchedulingQueue、Scheduler Cache 实现
├── profile/ # 多 Profile 支持(一台 scheduler 跑多套配置)
├── schedule_one.go # 单 Pod 调度主循环(schedulingCycle / bindingCycle)
└── scheduler.go # Scheduler 结构体、Run() 入口
核心数据载体:
// pkg/scheduler/scheduler.go
type Scheduler struct {
Cache internalcache.Cache // 实时 Node / Pod 状态,pod 中心设计
SchedulingQueue internalqueue.SchedulingQueue // 待调度 Pod 队列
// ...
}
// pkg/scheduler/schedule_one.go 主流程
// Scheduler.schedulingCycle()
// ├── Scheduler.schedulePod()
// │ ├── findNodesThatFitPod()
// │ │ ├── Framework.RunPreFilterPlugins()
// │ │ └── findNodesThatPassFilters()
// │ │ └── Framework.RunFilterPluginsWithNominatedPods()
// │ └── prioritizeNodes()
// │ ├── Framework.RunPreScorePlugins()
// │ └── Framework.RunScorePlugins()
// ├── Framework.RunReservePluginsReserve()
// └── Framework.RunPermitPlugins()
// Scheduler.bindingCycle()
// ├── Framework.WaitOnPermit()
// ├── Framework.RunPreBind()
// └── Scheduler.bind() → Framework.RunBindPlugins() → Framework.RunPostBindPlugins()
自定义 Scheduler Plugin 实战
自定义调度逻辑可以运行在 kube-scheduler 进程内、进程外 Extender,或独立 scheduler 中。三种方式能接入的生命周期、状态视图和故障边界不同。
三种实现自定义调度逻辑的方式
方式 原理 优点 缺点 适用场景
Scheduling Framework Plugin (进程内) 实现 Framework 扩展点接口,并编译进自定义 kube-scheduler 二进制 性能最好,直接使用 FrameworkHandle、Lister 和 NodeInfo;可以接入完整生命周期(QueueSort 到 PostBind) 不是运行时动态加载;需要维护自定义镜像,并跟随 Kubernetes 版本适配内部接口 性能敏感的调度逻辑(GPU 拓扑、NUMA、Gang);需要访问 scheduler 状态或参与 Reserve/Permit
Scheduler Extender (Out-of-tree HTTP) 独立 HTTP 服务,scheduler 通过 HTTP 调用 Filter / Prioritize / Bind 等接口 独立部署,不侵入 scheduler 代码;可以用任意语言开发 HTTP 调用延迟高(ms 级);无法访问 scheduler cache;只能参与 Filter / Score / Bind 等有限阶段 简单过滤逻辑(如特殊 label 过滤);非性能敏感的定制需求;多语言团队
Multiple Scheduler (独立 Scheduler) 部署另一个完整的 scheduler 实例,Pod 通过 schedulerName 指定 完全独立,策略隔离;可以用不同版本的 scheduler 不同 scheduler 之间不共享 cache,可能产生资源竞争;运维复杂(需要维护两套 scheduler) 业务强隔离(GPU 任务 vs CPU 任务);需要完全不同的调度策略
三种方式的本质差异是调度逻辑运行位置和可参与的生命周期。需要 Cache、CycleState、Reserve/Permit 时使用进程内 Plugin;只做有限的远端过滤/打分且能接受网络故障时才考虑 Extender;策略与运维边界都必须隔离时再运行独立 Scheduler。
Scheduling Framework Plugin 开发详解
Scheduler Framework 定义了从 Pod 入队到绑定的完整生命周期,每个阶段都是一个扩展点(Extension Point) 。开发自定义插件就是实现一个或多个扩展点接口。
Framework 扩展点全景
扩展点 类型 触发时机 典型用途
QueueSort 排序 Pod 进入 ActiveQ 时 自定义出队顺序(如短作业优先、SLA 排序)
PreFilter 过滤 Filter 之前,预处理 Pod 信息 计算 Pod 的调度约束、检查 PodGroup 完整性
Filter 过滤 对每个候选节点判断是否可用 GPU 拓扑匹配、NUMA 亲和、自定义资源检查
PostFilter 过滤 Filter 后无可用节点时 Preemption 抢占逻辑(选择 victim)
PreScore 打分 Score 之前,预处理打分数据 预计算节点统计信息
Score 打分 对每个候选节点打分 基于实时负载打分、拓扑分散打分
NormalizeScore 打分 Score 之后,归一化分数 将分数映射到统一范围
Reserve 预留 选中节点后,Bind 之前 预留 GPU 设备、标记资源已占用
Permit 许可 Reserve 之后,等待条件满足 Gang Scheduling 等待同组 Pod 凑齐
PreBind 绑定 Bind 之前,执行必须先于绑定完成的准备 例如 VolumeBinding 完成 PVC 绑定;不负责 kubelet 侧 CNI 配网
Bind 绑定 将 Pod 绑定到节点 自定义绑定逻辑(极少需要)
PostBind 绑定 Bind 之后,通知型操作 记录调度事件、通知外部系统
Unreserve 回滚 Reserve 之后失败时 释放预留的 GPU 设备、清理临时状态
开发、注册与启用
固定版本: 插件会编译进 scheduler 进程,并依赖 Kubernetes Scheduler Framework 接口;插件依赖、构建源码和目标集群版本必须配套。
实现扩展点接口: 根据需求选择实现 FilterPlugin、ScorePlugin、ReservePlugin 等接口。每个接口有固定的方法签名。
实现工厂函数: 构造函数接收 runtime 配置和 framework.Handle,解析参数并创建插件实例;Name() 返回配置中引用的稳定名称。
注册插件: 自定义 scheduler 的 main() 调用 app.NewSchedulerCommand(app.WithPlugin(name, factory)),把工厂加入 Registry。
编译部署: 编译自定义 scheduler 二进制和镜像;不能只把一个 .so 或配置文件挂进官方镜像就完成动态加载。
配置启用: 在 KubeSchedulerConfiguration 的 profiles[].plugins 中启用插件,必要时在 pluginConfig 中传入参数。
package main
import (
"os"
"k8s.io/component-base/cli"
"k8s.io/kubernetes/cmd/kube-scheduler/app"
"example.com/scheduler/pkg/myplugin"
)
func main() {
cmd := app.NewSchedulerCommand(
app.WithPlugin(myplugin.Name, myplugin.New),
)
os.Exit(cli.Run(cmd))
}
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: gpu-scheduler
plugins:
filter:
enabled:
- name: MyGPUPlugin
score:
enabled:
- name: MyGPUPlugin
weight: 5
pluginConfig:
- name: MyGPUPlugin
args:
maxMetricAgeSeconds: 15
Pod 只有在 spec.schedulerName: gpu-scheduler 时才会选择这个 Profile。部署后还应检查启动日志中的插件 Registry/Profile、配置版本、RBAC 和 leader election,而不是只确认进程存活。
关键接口签名(面试要能写出)
以下是最常用的三个接口签名,面试中如果被问到"写过什么插件",至少能写出 Filter 和 Score 的签名:
接口 方法签名 返回值含义
FilterPlugin Filter(ctx, state, pod, nodeInfo) *StatusSuccess 表示节点可用;Unschedulable 表示不可用
ScorePlugin Score(ctx, state, pod, nodeName) (int64, *Status)返回 0-100 的分数,分数越高越优先
ReservePlugin Reserve(ctx, state, pod, nodeName) *StatusSuccess 表示预留成功;失败会触发 Unreserve
注意:CycleState 是单次调度周期内的临时状态存储,可以在 PreFilter 中写入数据,在 Filter/Score/Reserve 中读取,避免重复计算。
典型示例:GPU 拓扑感知 Filter + Score 插件
这是 AI Infra 面试中最常见的自定义插件场景。下面给出完整的实现思路和关键代码骨架。
场景描述
集群中有多种 GPU 拓扑的节点(如 NVLink 互联的 8 卡节点、PCIe 互联的 4 卡节点)。训练任务需要 4 张 NVLink 互联的 GPU,不能分配到 PCIe 节点上,也不能分配到 NVLink 域不够 4 卡的节点上。
实现思路
PreFilter: 从 Pod annotation 中解析 GPU 拓扑需求(如 gpu-topology: nvlink-4),写入 CycleState。
Filter: 从 Node label 中读取 GPU 拓扑信息(如 nvidia.com/gpu-topology: nvlink-8),判断是否满足 Pod 需求。不满足则返回 Unschedulable。
Score: 对满足条件的节点,根据 NVLink 域剩余 GPU 数量打分:刚好满足需求(如剩余 4 卡域)给高分,碎片化严重的给低分。
Reserve: 在插件账本中记录所选 Node 的逻辑 GPU 拓扑名额,防止 Bind 完成前被后续调度周期重复使用。普通 nvidia.com/gpu 的具体 device ID 仍由节点侧 Device Plugin / kubelet Allocate 路径决定。
Filter 核心代码骨架
func (p *GPUTopologyPlugin) Filter(
ctx context.Context,
state *framework.CycleState,
pod *v1.Pod,
nodeInfo *framework.NodeInfo,
) *framework.Status {
// 1. 从 CycleState 读取 PreFilter 阶段解析的 GPU 需求
data, err := state.Read(stateKeyGPURequirement)
if err != nil {
return framework.NewStatus(framework.Error, err.Error())
}
requirement := data.(*GPURequirement) // topology=nvlink, count=4
// 2. 从 Node label 读取 GPU 拓扑信息
node := nodeInfo.Node()
topoLabel, ok := node.Labels["nvidia.com/gpu-topology"]
if !ok {
return framework.NewStatus(framework.Unschedulable, "node has no GPU topology label")
}
// 3. 判断拓扑是否匹配
if topoLabel != requirement.Topology {
return framework.NewStatus(framework.Unschedulable,
fmt.Sprintf("GPU topology mismatch: need %s, got %s",
requirement.Topology, topoLabel))
}
// 4. 检查可用 GPU 数量(从 nodeInfo 或 annotation 获取)
availableGPUs := getAvailableGPUs(node, nodeInfo)
if availableGPUs < requirement.Count {
return framework.NewStatus(framework.Unschedulable,
fmt.Sprintf("insufficient GPUs: need %d, available %d",
requirement.Count, availableGPUs))
}
return framework.NewStatus(framework.Success)
}
Score 核心代码骨架
func (p *GPUTopologyPlugin) Score(
ctx context.Context,
state *framework.CycleState,
pod *v1.Pod,
nodeName string,
) (int64, *framework.Status) {
// 从 CycleState 读取 GPU 需求
data, _ := state.Read(stateKeyGPURequirement)
requirement := data.(*GPURequirement)
// 获取该节点上剩余 GPU 的拓扑分布
node := getNodeByName(nodeName)
freeGPUDomains := getFreeNVLinkDomains(node)
// 打分策略:刚好满足需求的域越多,分数越高
// 避免把 Pod 放到"只剩最后一个 4 卡域"的节点上
matchingDomains := 0
for _, domain := range freeGPUDomains {
if domain.FreeGPUs >= requirement.Count {
matchingDomains++
}
}
// 分数范围 0-100
score := int64(matchingDomains * 25)
if score > 100 {
score = 100
}
return score, framework.NewStatus(framework.Success)
}
KubeSchedulerConfiguration 配置示例
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: gpu-scheduler
plugins:
preFilter:
enabled:
- name: GPUTopology
filter:
enabled:
- name: GPUTopology
score:
enabled:
- name: GPUTopology
weight: 10 # 权重越高,拓扑因素越重要
reserve:
enabled:
- name: GPUTopology
pluginConfig:
- name: GPUTopology
args:
topologyTypes:
- nvlink
- pcie
defaultCount: 1
面试表达结构:先说明"有三种实现方式,我选择 Framework Plugin 因为性能最好、能力最全" → 再讲"我实现了 PreFilter + Filter + Score + Reserve 四个扩展点" → 最后给出 Filter/Score 的核心逻辑和配置。如果能写出接口签名和关键代码骨架,会大幅加分。
案例:AIJob 驱动的预测调度插件
面试官常追问:"如果让你做一个预测调度器,既预测任务运行时间,又预测多个任务共置时的干扰程度,你怎么落到 Kubernetes Scheduler Framework 里?" 推荐统一回答成 AIJob CRD + AIJob Operator + Scheduler Plugin 三层架构:AIJob 表达深度学习任务,AIJob Operator 管生命周期和预测子控制器,scheduler plugin 只读辅助 CRD。
层次 组件 职责 边界
任务表达层 AIJob CRD表达模型、batch size、replica、GPU 需求、checkpoint、共置容忍度 不直接做节点选择
节点采集层 DCGM Exporter / Node GPU Collector 采集 SM、HBM、PCIe/NVLink、显存、进程级 GPU memory、训练 step time 只采集和暴露指标,不做调度决策
任务控制面 AIJob Operator 创建 PodGroup / Pods,维护任务状态,并通过预测子控制器写 PredictionResult 与 NodeGpuProfile 不在 scheduler 进程内运行模型
调度热路径 Predictive Scheduler Plugin 通过 Informer 本地缓存 CRD,在 QueueSort / Filter / Score / Reserve 中查表决策 Filter / Score 绝不发 RPC,不拉 Prometheus
核心边界:AIJob Operator 负责"任务生命周期 + 预测状态生产";scheduler plugin 负责"读取预测状态并做放置决策"。
① 控制面数据流:统一走 CRD
这条链路避免了大对象写入 Pod annotation,也让预测结果有独立生命周期、状态、GC 和权限控制。
步骤 动作 产物
1. 提交任务 用户提交 AIJob,声明模型、GPU、replica、checkpoint、共置容忍度 AIJob 对象
2. 采集 节点侧 DCGM Exporter 暴露 GPU counter,训练框架暴露 throughput / step time Prometheus / TSDB 中的历史样本
3. 建模 AIJob Operator 的预测子控制器周期训练 runtime 模型和共置 retention 矩阵 模型文件、特征版本、job-signature 聚类
4. 写 CRD AIJob Operator 为 AIJob 写 PredictionResult,为节点写 NodeGpuProfile 结构化预测状态
5. 本地缓存 Scheduler Plugin 在初始化时建立 Informer jobUID → PredictionResult、nodeName → NodeGpuProfile
6. 调度决策 QueueSort / PreFilter / Filter / Score / Reserve 只查本地 map 微秒级读路径
② CRD 设计:AIJob、PredictionResult 与 NodeGpuProfile
apiVersion: scheduling.predictor.io/v1
kind: AIJob
metadata:
name: resnet50-train
namespace: train
spec:
framework: pytorch
replicas:
workers: 8
resources:
gpu:
count: 8
type: A100
workload:
model: resnet50
batchSize: 256
precision: fp16
scheduling:
queue: research
minAvailable: 8
allowColocation: true
minRetention: 0.90
checkpoint:
enabled: true
intervalSeconds: 600
status:
phase: Pending
predictionRef:
name: pred-resnet50-train
apiVersion: scheduling.predictor.io/v1
kind: PredictionResult
metadata:
name: pred-resnet50-train
namespace: train
spec:
jobRef:
kind: AIJob
name: resnet50-train
uid: "aijob-uid-1234"
status:
jobSignature: "resnet50-bs256-fp16"
predictedRuntimeSeconds: 3600
confidence: 0.86
minRetention: 0.90
interferenceProfile:
bert-large:
retention: 0.92
slowdown: 1.08
gpt2-medium:
retention: 0.78
slowdown: 1.28
apiVersion: scheduling.predictor.io/v1
kind: NodeGpuProfile
metadata:
name: gpu-node-42
status:
nodeName: gpu-node-42
gpuUtilization: 0.62
hbmBandwidthUtilization: 0.48
colocatedJobSignatures:
- bert-large
- gpt2-medium
avgRetentionIfAdd: 0.84
updatedAt: "2026-06-15T15:00:00Z"
对象 谁写 谁读 生命周期
AIJob用户 / 平台 AIJob Operator、Scheduler Plugin 训练任务生命周期
PredictionResultAIJob Operator 的预测子控制器 Scheduler Plugin 跟随 AIJob 创建和删除,可 ownerReference 绑定 AIJob
NodeGpuProfileAIJob Operator / Node collector controller Scheduler Plugin 跟随 Node,周期更新状态
③ PredictionResult 的生命周期与消费路径
PredictionResult 不是用户手写的主资源,而是 AIJob Operator 为调度器准备的辅助状态。它的核心作用是把“深度学习任务画像”转换成 scheduler plugin 能低延迟读取的结构化字段。
阶段 什么时候发生 谁做 结果怎么被感知
创建 AIJob 创建后,Operator 第一次 Reconcile,解析 spec 中的模型、batch size、GPU、replica、checkpoint、共置容忍度 AIJob Operator 的预测子控制器 创建 PredictionResult,并把 AIJob.status.predictionRef 指过去
初始预测 AIJob 还没运行时,基于历史任务、模型画像和资源请求估计 runtime / retention 预测子控制器 更新 PredictionResult.status,scheduler plugin 的 Informer 收到 update
调度消费 Pod 进入调度队列并执行 QueueSort / PreFilter / Filter / Score Scheduler Plugin 从本地 cache 按 jobUID 读取,不访问 API Server,不调模型
运行中校准 Pod 绑定后,训练框架上报 step time,DCGM 上报 GPU counters AIJob Operator / metric collector 异步修正 PredictionResult.status.confidence、runtime 或 retention
完成回收 AIJob Succeeded / Failed / Deleted AIJob Operator 把真实 runtime / throughput 写入训练样本;通过 ownerReference GC PredictionResult
用户怎么使用它
用户通常不直接创建 PredictionResult,只提交 AIJob。如果要排查,可以通过 kubectl get/describe predictionresult 看预测运行时间、置信度、共置风险和更新时间。它更像 PVC 的绑定状态:用户关心结果,但不手写细节。
调度器怎么使用它
Scheduler Plugin 在初始化时建立 Informer,把 PredictionResult 放进本地索引,例如 jobUID → PredictionResult。调度时从 Pod ownerReference / label 找到所属 AIJob,再查本地 cache。这样 QueueSort / Filter / Score 都是内存读取,不会阻塞调度周期。
func (r *AIJobReconciler) reconcilePrediction(ctx context.Context, job *aiv1.AIJob) error {
// 1. 从 AIJob spec 提取任务画像:模型、batch size、GPU、replica、checkpoint。
features := buildFeatures(job.Spec)
// 2. 调用预测模块;这是控制面异步逻辑,不在 scheduler 热路径。
pred := r.predictor.Predict(ctx, features)
// 3. Upsert PredictionResult,并通过 ownerReference 绑定 AIJob 生命周期。
result := buildPredictionResult(job, pred)
if err := controllerutil.SetControllerReference(job, result, r.Scheme); err != nil {
return err
}
return r.Client.Status().Update(ctx, result)
}
PredictionResult 在 AIJob 创建后的 Reconcile 中产生,运行中异步校准;用户用它排查预测状态,scheduler plugin 用它做本地查表决策。
外部指标与预测服务不能成为调度热路径依赖
Scheduling Cycle 对 Pod 串行推进。Filter/Score 虽然能并行处理 Node,但任何同步 DCGM 查询、Prometheus 查询或预测 RPC 都会把网络尾延迟、限流和故障传播到整个调度主循环;如果在每个 Node 的 Filter/Score 中调用一次,还会把请求量放大为“Pod 数 × Node 数”。
风险 控制方式 降级语义
RPC 慢、超时或服务不可用 Operator/collector 异步计算并写 CRD;Plugin 只读 Informer 本地缓存 按配置选择保守拒绝、回退默认分或进入可重试 Error,不能临时随机决定
指标陈旧或抖动 状态携带 observedAt、模型版本、confidence 和 TTL;使用滑动窗口与安全余量 超过最大年龄后不把旧值当实时事实,低置信度禁止高风险共置
缓存尚未同步 启动时等待 Informer HasSynced;缺失数据使用明确的 Status reason 避免把“未同步”误判为“节点满足条件”
插件锁竞争或计算过重 不可变快照、读多写少索引、PreFilter/PreScore 预计算、限制候选节点 超预算时回退简单策略,并通过指标暴露降级次数
模型判断错误 运行期用 DCGM/step time 对账,设置 SLO 阈值和解除共置动作 调度决策可重算,但已绑定 Pod 不会被 Score 自动迁移,需要 Controller 执行后续处置
若确实必须远程调用,应使用严格的 context deadline、连接池、熔断、并发上限和结果缓存,并把调用次数控制在每个 scheduling cycle 一次,而不是每个候选 Node 一次。即使如此,它仍会降低可用性,异步物化状态通常更稳妥。
④ 各扩展点职责与 Go 骨架
下面代码只展示关键路径。真实实现中还需要错误处理、metrics、并发保护、feature gate 和配置化权重。
QueueSort:预测运行时间只做第三排序键
func (pl *PredictivePlugin) Less(p1, p2 *framework.QueuedPodInfo) bool {
// 1. PriorityClass 仍然是第一优先级,避免预测策略破坏 K8s 语义。
if *p1.Pod.Spec.Priority != *p2.Pod.Spec.Priority {
return *p1.Pod.Spec.Priority > *p2.Pod.Spec.Priority
}
// 2. 租户公平性第二优先级,QAD 越低表示越需要补偿资源。
qad1 := pl.fairness.QAD(p1.Pod.Namespace)
qad2 := pl.fairness.QAD(p2.Pod.Namespace)
if qad1 != qad2 {
return qad1 < qad2
}
// 3. 运行时间预测来自 AIJob 对应的 PredictionResult,本地 cache 读取。
rt1 := pl.predStore.RuntimeSeconds(jobUID(p1.Pod))
rt2 := pl.predStore.RuntimeSeconds(jobUID(p2.Pod))
if rt1 != rt2 {
return rt1 < rt2
}
// 4. 最后用入队时间打破平局,避免不稳定排序。
return p1.Timestamp.Before(p2.Timestamp)
}
PreFilter:把 CRD 预测值写入 CycleState
func (pl *PredictivePlugin) PreFilter(
ctx context.Context,
state *framework.CycleState,
pod *v1.Pod,
) (*framework.PreFilterResult, *framework.Status) {
pred := pl.predStore.GetByJobUID(jobUID(pod))
if pred == nil {
// 冷启动兜底:AIJob 还没有 PredictionResult 时走保守策略。
pred = conservativePrediction(pod)
}
// CycleState 只在本次 scheduling cycle 内有效,避免后续阶段重复查 CRD cache。
state.Write(stateKeyPrediction, &PodPredictionState{
RuntimeSeconds: pred.RuntimeSeconds,
JobSignature: pred.JobSignature,
MinRetention: pred.MinRetention,
InterferenceProfile: pred.InterferenceProfile,
})
return nil, framework.NewStatus(framework.Success)
}
Filter:共置干扰超过阈值就拒绝节点
func (pl *PredictivePlugin) Filter(
ctx context.Context,
state *framework.CycleState,
pod *v1.Pod,
nodeInfo *framework.NodeInfo,
) *framework.Status {
pred := readPredictionState(state)
nodeName := nodeInfo.Node().Name
nodeProfile := pl.nodeProfileStore.Get(nodeName)
// 节点画像缺失时走保守策略:Guaranteed 任务拒绝共置,BestEffort 可降级打分。
if nodeProfile == nil && isGuaranteed(pod) {
return framework.NewStatus(framework.Unschedulable, "missing NodeGpuProfile")
}
retention := minPredictedRetention(pred, nodeProfile.ColocatedJobSignatures)
if retention < pred.MinRetention {
return framework.NewStatus(
framework.Unschedulable,
fmt.Sprintf("predicted retention %.2f below threshold %.2f", retention, pred.MinRetention),
)
}
return framework.NewStatus(framework.Success)
}
Score:在可行节点里选择更低干扰、更好装箱的节点
func (pl *PredictivePlugin) Score(
ctx context.Context,
state *framework.CycleState,
pod *v1.Pod,
nodeName string,
) (int64, *framework.Status) {
pred := readPredictionState(state)
nodeProfile := pl.nodeProfileStore.Get(nodeName)
// interferenceScore 越高表示共置越安全。
retention := minPredictedRetention(pred, nodeProfile.ColocatedJobSignatures)
interferenceScore := int64(retention * 100)
// MostAllocated 风格:优先填补已有 GPU 利用率较高但仍安全的节点。
binPackScore := int64(nodeProfile.GPUUtilization * 100)
topologyScore := pl.topology.Score(pod, nodeName)
score := interferenceScore*5 + binPackScore*2 + topologyScore*3
return normalize(score), framework.NewStatus(framework.Success)
}
Reserve / Unreserve:维护插件自己的共置账本
func (pl *PredictivePlugin) Reserve(
ctx context.Context,
state *framework.CycleState,
pod *v1.Pod,
nodeName string,
) *framework.Status {
pred := readPredictionState(state)
// scheduler cache 只知道整数资源;共置 signature 账本由插件自己维护。
pl.ledger.Add(nodeName, pod.UID, pred.JobSignature)
return framework.NewStatus(framework.Success)
}
func (pl *PredictivePlugin) Unreserve(
ctx context.Context,
state *framework.CycleState,
pod *v1.Pod,
nodeName string,
) {
// Bind / Permit / PreBind 失败时必须回滚,避免后续 Pod 看到假的共置状态。
pl.ledger.Remove(nodeName, pod.UID)
}
⑤ 干扰信号怎么形成闭环
阶段 输入 输出 为什么不放在 scheduler 内
单跑画像 任务单独运行时的 throughput、step time、GPU counters job-signature 的 baseline 需要历史窗口和聚合计算
共置画像 两个 job-signature 共置时的 throughput 变化 retention / slowdown 矩阵 需要离线统计和异常值清洗
在线更新 Pod 绑定后的真实 runtime、实际 retention、驱逐事件 更新 PredictionResult / 训练样本 异步闭环,不能阻塞调度
调度使用 本地 Informer cache 中的 CRD 状态 QueueSort / Filter / Score 决策 热路径只查内存,保证 P99
面试要点:干扰不是 scheduler 实时测的,而是 Operator 用历史和在线反馈维护 CRD;scheduler 看到的是已经算好的结构化状态。
⑥ 设计边界与故障处理
设计问题 处理机制
为什么用 AIJob,而不是只有 PredictionResult? AIJob 表达训练任务语义:模型、batch size、replica、GPU、checkpoint、minAvailable、共置容忍度。PredictionResult 只是 AIJob 的调度辅助状态。
为什么不用 Pod annotation? 预测结果是结构化状态,可能包含 runtime、confidence、干扰矩阵、版本和更新时间;CRD 可独立 watch、GC、鉴权和演进,不污染 Pod 对象。
为什么不在 Filter 里直接 gRPC 调模型? Filter 是节点级并行热路径,节点数越多 RPC 越多;scheduler P99 必须稳定,所以只读 Informer 本地 cache。
预测不准怎么办? 用 confidence 和安全 margin;低置信度走保守策略;PostBind 后回收真实 runtime 和 retention;SLO 破坏时驱逐低优共置伙伴。
冷启动没有 PredictionResult 怎么办? Guaranteed 任务保守拒绝高风险共置;BestEffort 可用 namespace / AIJob 类型历史中位数和默认 retention;同时 AIJob Operator 尽快补齐 CRD。
怎么证明有效? 看调度延迟、JCT、waiting time、GPU 利用率、SLO violation、实际 retention;做 ablation:去掉 runtime 排序、去掉 interference Filter、去掉 interference Score。
预测运行时间作用于“先调谁”,共置干扰预测作用于“能不能放和放哪里”;预测值由 Operator 异步写 CRD,scheduler plugin 通过 Informer 本地缓存读取。
QueueingHint 操作的三个队列
三队列职责
队列 数据结构 语义 出队条件
ActiveQ 堆(priority + timestamp) 等待立即调度的 Pod scheduler 主循环 Pop()
BackoffQ 堆(backoff 到期时间) 已经具备重试理由,但退避期限尚未结束的 Pod backoff 时间到 → 自动迁移到 ActiveQ
UnschedulableQ map[uid]Pod 调度失败、需要事件唤醒的 Pod QueueingHint 命中、定时刷盘(默认 5 分钟)
UnschedulableQ 主要等待“集群事件 + QueueingHint”精确唤醒,同时保留周期 flush 兜底;被唤醒时再根据退避是否结束进入 ActiveQ 或 BackoffQ。
QueueingHint:从惊群到精确唤醒
官方资料:Scheduling Framework / QueueingHint · Kubernetes 1.32 QueueingHint
没有 QueueingHint 时的"惊群"问题
K8s 1.28 之前的逻辑很粗糙:只要集群中发生了某种类型的事件(比如新 Node 加入、Pod 删除),scheduler 会把 UnschedulableQ 中所有相关 Plugin 的 Pod 一股脑搬回 ActiveQ。
误唤醒: 新加入一台 GPU=A100 的节点,原本因为「内存不足」失败的 Pod 也会被唤醒。
反复扫描: 这些 Pod 出队后会重新跑 PreFilter/Filter,绝大多数仍然失败、再回到 UnschedulableQ,浪费 CPU 和锁。
调度延迟放大: 5000 节点 + 10000 Pending Pod 的集群,惊群一次可能让 scheduler 卡 1-2 秒。
QueueingHint 的设计
QueueingHint 让每个 Plugin 通过实现 EnqueueExtensions 接口告诉 scheduler:
我关心哪些事件类型: 例如 NodeAffinity 关心 Node 增删和 Node Label 更新;NodeResourcesFit 关心 Node 增删和 Node 资源量更新;TaintToleration 关心 Node Taint 变化。
事件发生时,能不能让这个 Pod 重新有机会: 返回 QueueingHint 的三种值:
返回值 含义 scheduler 行为
Queue这个事件可能让 Pod 重新可调度 把 Pod 从 UnschedulableQ 移到 BackoffQ(或 ActiveQ)
QueueSkip这个事件和 Pod 失败原因无关 Pod 留在 UnschedulableQ
EnqueueExtensions 接口示例
// NodeAffinity 插件实现 EnqueueExtensions
func (pl *NodeAffinity) EventsToRegister(_ context.Context) ([]framework.ClusterEventWithHint, error) {
return []framework.ClusterEventWithHint{
{
Event: framework.ClusterEvent{
Resource: framework.Node,
ActionType: framework.Add | framework.UpdateNodeLabel,
},
QueueingHintFn: pl.isSchedulableAfterNodeChange,
},
}, nil
}
// QueueingHint 函数:判断这次 Node 变化是否可能让 Pod 变可调度
func (pl *NodeAffinity) isSchedulableAfterNodeChange(
logger klog.Logger,
pod *v1.Pod,
oldObj, newObj interface{},
) (framework.QueueingHint, error) {
_, newNode, err := schedutil.As[*v1.Node](oldObj, newObj)
if err != nil {
return framework.Queue, err
}
// 只有当新节点的 label 满足 Pod 的 NodeAffinity 时才唤醒
affinity, _ := nodeaffinity.NewLazyErrorNodeSelector(pod.Spec.Affinity.NodeAffinity)
if affinity.Match(newNode) {
return framework.Queue, nil
}
return framework.QueueSkip, nil
}
面试要点:QueueingHint 把"是否唤醒"的判断下沉到具体 Plugin ,因为只有 Plugin 自己知道"我之前为什么失败、这次事件能不能让我成功"。
Move Request:UnschedulableQ → ActiveQ 的触发链路
Move Request 来源
Move Request (也叫 cluster event)是 scheduler 内部抽象,统一表达"集群中发生了某种可能影响 Pending Pod 的变化"。下面是触发 Move Request 的全部来源:
事件来源 触发场景 Resource ActionType
Node 增删 新节点加入 / 节点下线 Node Add / Delete
Node 状态变化 Allocatable 变化、Taint 增删、Label 变更、Condition 变化 Node UpdateNodeAllocatable / UpdateNodeTaint / UpdateNodeLabel / UpdateNodeCondition
Pod 删除 已运行 Pod 被删除(释放资源) Pod Delete
Pod 更新 Pod label 变化(影响 Pod Affinity) Pod UpdatePodLabel
PVC / StorageClass 增删 VolumeBinding 插件关心 PersistentVolumeClaim / StorageClass Add / Update
CSINode / CSIDriver 变化 VolumeZone、NodeVolumeLimits 关心 CSINode / CSIDriver Add / Update
Scheduler 自身周期事件 UnschedulableQ flush(默认 5 分钟) — 定时器触发,无差别移动所有 Pod
Move Request 的处理流程
事件接收: EventHandler 监听 informer,收到对象变化。
构造 ClusterEvent: 把 informer event 翻译成 {Resource, ActionType} 二元组。
遍历 UnschedulableQ: 对每个 Pending Pod,找到所有曾经失败的 Plugin。
调用 QueueingHintFn: 对每个 Plugin 调用其注册的 hint 函数,传入 oldObj / newObj。
决策: 只要有一个 Plugin 返回 Queue,就唤醒 Pod;退避已结束时进入 ActiveQ,否则进入 BackoffQ。全部返回 QueueSkip 时留在 UnschedulableQ。
关键 trick:Plugin 返回 QueueSkip 不代表 Pod 永远不再被尝试 —— 5 分钟的 flush 定时器仍然会兜底,避免 hint 函数有 bug 时 Pod 永远卡死。
Q: 1.28 之前 K8s 用什么机制把 Pod 从 UnschedulableQ 唤醒?为什么要换成 QueueingHint?
1.28 之前: 每个 Plugin 通过 EventsToRegister() 注册关心的 ClusterEvent 类型,scheduler 一旦收到匹配类型的事件,就把 UnschedulableQ 里所有"失败 Plugin 包含这个 Plugin"的 Pod 一次性全搬走。
问题: 事件粒度太粗。例如 NodeAffinity 注册了 Node.UpdateLabel,但任何一次 Node 标签变化都会唤醒所有因 NodeAffinity 失败的 Pod,而绝大多数 Pod 关心的标签和这次变化的标签根本不是同一个。
1.28 引入 QueueingHint: 在原来的"事件类型匹配"基础上,加一层 Plugin 级别的精确判断函数,只有 Plugin 自己确认"这次事件可能让我成功"才搬移。
1.32: QueueingHint 以 Beta 状态默认开启;1.34: 功能进入 Stable。版本演进不改变核心语义:插件结合具体对象变化判断这次事件是否值得触发重试。
Q: 一个 Pod 因为 NodeResourcesFit + NodeAffinity 同时失败进了 UnschedulableQ。新加入了一个 Node,但 label 不满足这个 Pod 的 NodeAffinity。这个 Pod 会被唤醒吗?
会被唤醒。 新 Node 加入触发 Node.Add 事件,scheduler 会对这个 Pod 涉及的所有失败 Plugin 调用 hint:
NodeResourcesFit.hint: 新节点资源充足 → 返回 Queue。
NodeAffinity.hint: 新节点 label 不匹配 → 返回 QueueSkip。
只要有一个 Plugin 返回 Queue,就搬移。 原因是 scheduler 没法证明"NodeResourcesFit 满足但 NodeAffinity 不满足 = 一定调度不了",必须重新跑一遍 Filter 才知道。这是设计上的"宁可错放,不可漏放"。
工具一:kube-scheduler-simulator
是什么、为什么需要
kube-scheduler-simulator (sig-scheduling 官方维护,github.com/kubernetes-sigs/kube-scheduler-simulator )是一个本地 scheduler + Web UI,能在不动生产集群的前提下:
导入快照: 把生产集群的 Node / Pod / PVC / PriorityClass 等对象一键导入。
重放调度: 用同一份 KubeSchedulerConfiguration 跑一遍调度,看每个 Pod 在哪些 Plugin 失败、得分如何。
Mock Plugin: 支持注入 mock 插件,可以预设某个 Plugin 在某个节点上的返回值,用来构造极端场景。
新插件验证: 开发自定义 Plugin 时,先在 simulator 上跑通,再部署到 staging。
典型使用流程
# 1. 启动 simulator(容器化或 docker compose)
docker compose up -d
# 2. 从生产集群导出快照
kubectl get nodes,pods,pvc,sc,priorityclass -A -o yaml > snapshot.yaml
# 3. 通过 simulator UI 或 API 导入
curl -X POST http://localhost:1212/api/v1/import \
-H "Content-Type: application/yaml" \
--data-binary @snapshot.yaml
# 4. 创建一个测试 Pod,观察调度结果
# UI 会展示:哪些节点被 Filter 过滤,每个节点 Score 是多少
面试可以加分的点:你做过什么调度问题排查 → "我用 kube-scheduler-simulator 把生产快照拉下来,本地复现了 Pending"。
工具二:Diagnosis / FitError 数据结构
findNodesThatFitPod 返回的诊断数据
当一个 Pod 调度失败,findNodesThatFitPod() 会返回 framework.Diagnosis,描述"为什么没找到合适节点"。这是 kubectl describe pod 里 FailedScheduling 事件背后的数据源。
Diagnosis / FitError 字段拆解
字段 类型 含义
NodeToStatusmap[string]*Status每个节点最终的失败状态(Unschedulable / UnschedulableAndUnresolvable / Error)和拦下它的 Plugin 名
UnschedulablePluginssets.Set[string]本次调度中哪些 Plugin 至少在某个节点上返回了 Unschedulable —— 用于 QueueingHint 决定哪些 Plugin 关心后续事件
PendingPluginssets.Set[string]返回 Pending 状态的 Plugin(暂时无法判断、等待外部信号)
PreFilterMsgstringPreFilter 阶段直接拒绝时的消息(terminates the entire cycle)
PostFilterMsgstringPostFilter(抢占)阶段的诊断消息
看一个真实的 FailedScheduling 事件
$ kubectl describe pod my-gpu-pod
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 10s default-scheduler 0/100 nodes are available:
3 node(s) had untolerated taint {node.kubernetes.io/not-ready: },
5 node(s) didn't match Pod's node affinity/selector,
90 Insufficient nvidia.com/gpu,
2 node(s) didn't match pod anti-affinity rules.
preemption: 0/100 nodes are available:
3 Preemption is not helpful for scheduling,
97 No preemption victims found for incoming pod.
这条消息直接来自 NodeToStatus 的聚合 + PostFilterMsg。每一行就是一个 Plugin 在多少个节点上返回 Unschedulable。
面试拆解技巧:看到 FailedScheduling 先按 Plugin 分类 :资源类(NodeResourcesFit)/ 节点选择类(NodeAffinity / NodeSelector)/ 隔离类(TaintToleration)/ 拓扑类(PodAffinity / PodTopologySpread)/ 设备类(VolumeBinding)。每类对应一组排查动作。
工具三:Prometheus Metrics 与 SLO
scheduler 核心 metrics 全景
Metric 稳定性/类型 含义 诊断用途
scheduler_pending_podsStable Gauge,按 queue 分类 ActiveQ / BackoffQ / Unschedulable / Gated 中的 Pod 数 区分处理能力不足、退避重试、硬约束失败和主动 gate
scheduler_scheduling_attempt_duration_secondsStable Histogram,按 profile/result 分类 一次调度尝试耗时,包含调度算法与 binding 观察调度器单次处理 P50/P95/P99
scheduler_schedule_attempts_totalStable Counter,按 profile/result 分类 scheduled / unschedulable / error 尝试次数 计算吞吐、成功率并区分业务不可调度与内部错误
scheduler_framework_extension_point_duration_secondsStable Histogram 某个 extension point 内全部插件的总耗时 先定位慢在 Filter、Score、Permit 还是 Bind
scheduler_plugin_execution_duration_secondsAlpha Histogram 单个 plugin / extension_point 的执行耗时 在版本允许时定位具体慢插件;升级时关注指标兼容性
scheduler_pod_scheduling_attemptsStable Histogram 一个成功调度的 Pod 经历多少次尝试 识别反复失败、Backoff 或无效重试
scheduler_queue_incoming_pods_totalStable Counter,按 event/queue 分类 各种事件向各队列加入了多少 Pod 定位哪个事件源在制造队列惊群
scheduler_unschedulable_podsAlpha Gauge,按 plugin/profile 分类 当前被各插件判定不可调度的 Pod 数 定位共同失败插件;同一 Pod 可能计入多个插件
scheduler_pod_scheduled_after_flush_totalAlpha Counter 因超时从 UnschedulablePods flush 后才调度成功的 Pod 数 持续增长提示 QueueingHint 或事件注册可能漏唤醒
固定阈值不能跨集群照搬。在线服务、批任务和稀缺 GPU 队列的正常等待时间差异很大,应以目标集群基线、业务启动 SLO、profile 和 PriorityClass 分层告警。
吞吐、延迟与放置质量
调度器评估必须同时观察三类结果
维度 代表指标 只能说明什么
吞吐 单位时间 scheduled attempts、稳定可处理的 Pod/s、队列积压增长率 调度器能否跟上工作负载到达速度;不能证明节点放置合理
调度延迟 Pod 从可调度到成功 Bind 的 P50/P95/P99、单次 scheduling attempt 延迟、重试次数 用户等待和控制面尾延迟;必须区分排队等待、不可调度等待和单次算法耗时
放置质量 资源利用率、CPU/内存/GPU 碎片、拓扑本地性、跨机通信量、SLO violation、JCT、公平性与抢占代价 策略对业务和集群目标是否有效;不能只用 scheduler 自身延迟替代
可复现的评估方法
固定输入: 保存 Node、Pod、PVC、PriorityClass、队列和自定义 CRD 快照,使用相同到达序列比较基线与新策略。
先验证硬正确性: 任何候选策略都不能违反 requests、taint、affinity、存储拓扑和设备约束;可行性不是用平均收益交换的指标。
分层测性能: 分别测 scheduler 微基准、离线重放/模拟器,以及 staging 的端到端 Pod 启动;同时报告吞吐与 P99。
测业务目标: GPU 调度至少报告等待时间、JCT、GPU 利用率、碎片、拓扑命中率、SLO violation、抢占次数和 checkpoint 损失。
做消融与压力场景: 去掉 QueueSort、干扰 Score 或节点采样分别比较;覆盖资源充足、资源紧张、大量不可调度 Pod、预测服务降级和 Leader 切换。
percentageOfNodesToScore 的收益也要按这套方法评估:降低采样比例可能改善调度延迟,却同时恶化装箱、拓扑选择或 GPU 碎片,不能只看 scheduler CPU 降低。
核心 PromQL 查询示例
# 1. 调度成功率(5 分钟窗口)
sum(rate(scheduler_schedule_attempts_total{result="scheduled"}[5m]))
/ sum(rate(scheduler_schedule_attempts_total[5m]))
# 2. P99 调度延迟
histogram_quantile(0.99,
sum(rate(scheduler_scheduling_attempt_duration_seconds_bucket[5m])) by (le, profile, result))
# 3. 找出"卡得最久"的 Plugin
topk(5,
histogram_quantile(0.99,
sum(rate(scheduler_plugin_execution_duration_seconds_bucket[5m])) by (le, plugin)))
# 4. UnschedulableQ 增长趋势
sum(scheduler_pending_pods{queue="unschedulable"})
# 5. 哪个 Plugin 拦下了最多 Pod
topk(5, sum(scheduler_unschedulable_pods) by (plugin))
三件套联动:一次 Pod Pending 排查路径
排查 SOP
第一步:单 Pod 现场。 kubectl describe pod <name> 看 FailedScheduling 事件,按 Plugin 分类拆解。
第二步:确认共性。 看 scheduler_unschedulable_pods{plugin=...},判断是单个 Pod 配置问题还是多个 Pod 同时被某 Plugin 拦下。
第三步:本地复现。 如果是共性问题,用 simulator 拉快照本地重放,验证假设。
第四步:长期趋势。 看 scheduler_scheduling_attempt_duration_seconds P99,先用 scheduler_framework_extension_point_duration_seconds 定位阶段,再在可用版本中用 plugin 指标定位具体插件。
第五步:修正反馈。 修配置 / 加节点 / 调 Plugin 顺序,再用 simulator 验证一次。
面试加分项:能给出具体的 metric 名和阈值,比"我会看监控"具体得多。
Q: scheduler_pending_pods 持续增长,应该怎么排查?
1. 先按 queue label 拆:
queue="active" 增长 → scheduler 处理速度跟不上入队速度,看调度延迟和 plugin 性能。
queue="backoff" 增长 → 大量 Pod 调度失败正在 backoff,看 schedule_attempts_total{result="unschedulable"}。
queue="unschedulable" 增长 → 集群资源真的不够,或 QueueingHint 没正确唤醒,看 unschedulable_pods 按 plugin 分布。
2. 配合 plugin 维度: topk(5, sum(scheduler_unschedulable_pods) by (plugin)) 直接定位"哪个 Plugin 在拦人"。
3. 看入队源: scheduler_queue_incoming_pods_total 看是不是某个事件源(NodeAdded / PodDeleted)在制造惊群。
Q: P99 调度延迟从 50ms 突然涨到 800ms,怎么定位?
第一招:按扩展点拆。 先看稳定指标 scheduler_framework_extension_point_duration_seconds 的 Filter / Score / PreFilter / Bind P99,确定延迟卡在哪个阶段。
第二招:按 plugin 拆。 如果当前版本暴露 alpha 指标 scheduler_plugin_execution_duration_seconds,再按 plugin/extension_point topk 找最慢插件。常见嫌疑:自定义插件没有缓存、亲和性规则扫描大量 Pod、Filter/Score 发外部 RPC、Extender HTTP 超时或锁竞争。
第三招:和事件相关性。 看延迟跳升时间点和发布、节点变更、流量峰值是否对应。
来源
内容整理自 Lark 文档《万字长文详解 Kubernetes 调度器:kube-scheduler 实现》,按运行时函数链路提炼启动、缓存、调度与绑定机制。
源码阅读主线
启动链路
01
main
cmd/kube-scheduler/scheduler.go 创建 cobra command
02
NewSchedulerCommand
构造 Options、flags、配置文件入口
03
runCommand
校验配置并调用 Setup
04
Setup
创建 CompletedConfig、Framework profile、scheduler 实例
05
Run
启动 informer/cache、Leader Election、最终调用 sched.Run(ctx)
面试里不需要背所有 flags,但要知道:scheduler 是通过 KubeSchedulerConfiguration、profiles、pluginConfig、extenders、parallelism、percentageOfNodesToScore 等配置组装出 Framework 和调度器实例的。
核心运行循环
Lark 文档里强调的主入口是:
func (sched *Scheduler) Run(ctx context.Context) {
sched.SchedulingQueue.Run(logger)
go wait.UntilWithContext(ctx, sched.scheduleOne, 0)
<-ctx.Done()
sched.SchedulingQueue.Close()
}
这段代码说明三件事:
scheduler 不是被动 RPC 服务,而是一个持续消费调度队列的控制循环。 SchedulingQueue 负责存储和唤醒待调度 Pod。scheduleOne 是单个 Pod 调度的主流程。
scheduleOne 的函数链路
01
scheduleOne
从 SchedulingQueue 取一个 Pod
02
schedulingCycle
串行运行,为 Pod 选择一个节点
03
schedulePod
找可行节点、打分、选最高分节点
04
assume
在 scheduler cache 里先假定 Pod 占用资源
05
bindingCycle
并发执行 WaitOnPermit / PreBind / Bind / PostBind
06
failureHandler
失败时回队列、记录 FailedScheduling、触发抢占或退避
关键点:Scheduling Cycle 串行,Binding Cycle 可以和下一个 Pod 的 Scheduling Cycle 并发。 这也是为什么 Reserve/Unreserve 和 Assume 很重要:绑定还没写 API Server 前,scheduler 本地 cache 必须先看到资源已被占用,避免后续 Pod 过度分配。
schedulePod 的三段式
Lark 文档中源码链路可以压缩成:
Diagnosis / FitError 为什么重要
调度失败时,findNodesThatFitPod 会把每个节点为什么不可行写到 Diagnosis.NodeToStatusMap 中。FitError 最终会变成 FailedScheduling 事件的一部分。
01
Filter 失败
每个 Node 记录失败 plugin 和原因
02
Diagnosis
聚合 NodeToStatusMap、UnschedulablePlugins、PreFilterMsg
03
FitError
没有 feasible node 时返回
04
Event
用户通过 kubectl describe pod 看到 FailedScheduling
05
QueueingHint
后续事件是否应该唤醒这个 Pod,依赖失败 plugin 的判断
这解释了为什么排查 Pending 不能只说“资源不足”:真实事件通常是多个 plugin 的聚合结果,例如 NodeResourcesFit、NodeAffinity、TaintToleration、VolumeBinding、PodTopologySpread。
与本站现有章节的关系