调度器深入:打分、抢占与拓扑约束
调度器(kube-scheduler)是 Kubernetes 控制平面里最容易被低估的组件。它做的事情看起来简单:把没有 nodeName 的 Pod 绑定到某个节点。但"选哪个节点"这个问题背后,隐藏着资源感知、亲和性约束、拓扑分散、优先级抢占等多维度的决策过程。
从 Kubernetes 1.19 起,调度器的内部逻辑通过 Scheduling Framework 对外暴露为一组扩展点。原来散落在代码各处的调度逻辑被重新组织成插件,每个插件只负责一个扩展点。这让调度行为既可预测,又可扩展。
本文的核心问题是:Kubernetes 调度器用什么框架选节点,当资源不足时如何通过抢占让高优先级 Pod 运行?
调度框架全景图
1 | |
Filter 阶段:淘汰不可用节点
Filter 阶段的目标是从集群所有节点里找出"可行节点"(feasible nodes)——所有满足 Pod 约束的节点。只要有一个 Filter 插件对某节点返回失败,该节点就被从候选集里剔除。
主要 Filter 插件:
NodeResourcesFit
检查节点的可分配资源(allocatable)减去已分配资源(所有已调度 Pod 的 requests 之和)是否满足当前 Pod 的 requests。
注意:调度器用的是 requests,不是 limits。节点上实际运行的容器可以突破 requests,但调度决策基于 requests。这意味着如果所有 Pod 都突破 requests,节点可能 OOM,但调度器在调度时并不知道这件事。
NodeSelector 与 NodeAffinity
nodeSelector 是简单的标签匹配,要求节点带有指定标签键值对。nodeAffinity 表达力更强,支持 In、NotIn、Exists、DoesNotExist、Gt、Lt 等操作符,并区分硬约束(requiredDuringSchedulingIgnoredDuringExecution)和软约束(preferredDuringSchedulingIgnoredDuringExecution)。
硬约束的 nodeAffinity 在 Filter 阶段执行——不满足就淘汰节点。软约束在 Score 阶段执行——满足得分更高,但不满足也不淘汰。
TaintToleration
节点可以打 Taint(污点),Pod 必须有对应的 Toleration 才能调度到该节点。三种 Taint effect:
NoSchedule:新 Pod 不能调度到该节点(Filter 阶段执行)PreferNoSchedule:尽量不调度到该节点(Score 阶段降分)NoExecute:已运行的 Pod 也会被驱逐(不由调度器处理,由 Node controller 处理)
PodAffinity 与 PodAntiAffinity
PodAffinity 允许 Pod 指定与哪些 Pod 在同一拓扑域(如同一节点、同一可用区)运行。PodAntiAffinity 则是相反约束——与某些 Pod 不在同一拓扑域。
这两个插件的计算复杂度随集群规模上升:每个 Pod 调度都需要查询已运行的 Pod 的标签。大规模集群中过度使用 PodAffinity 是调度器性能问题的常见来源。
InterPodAffinity
处理 PodAffinity/PodAntiAffinity 的 Filter 阶段逻辑(硬约束部分)。如果一个 Pod 要求必须和带某标签的 Pod 在同一节点,但某节点上没有带该标签的 Pod,该节点被过滤掉。
Score 阶段:对可行节点打分
Filter 之后剩下的节点都是合法选择,Score 阶段决定优先选哪个。每个 Score 插件给每个节点打 0-100 分,加权求和后排名,分最高的节点胜出(如有相同分数,随机选一个)。
主要 Score 插件:
LeastAllocated
优先选择已分配资源最少的节点。具体计算:(cpu_free / cpu_total + mem_free / mem_total) / 2 * 100,值越大得分越高。
这是默认启用的主要打分插件,倾向于将 Pod 分散到不同节点,避免单节点过载。
MostAllocated
与 LeastAllocated 相反,优先选择已分配资源最多的节点。适用于想"装箱"以腾出空闲节点关机(云端省钱)的场景。与 LeastAllocated 互斥使用。
ImageLocality
给已经有容器镜像的节点加分。如果某个节点已经拉取过 Pod 所需的镜像,拉起容器更快。得分基于节点上已有的镜像总大小。
NodeAffinityPriority
对满足软约束 preferredDuringScheduling 的 nodeAffinity 节点加分。
InterPodAffinityPriority
对满足软约束 PodAffinity/PodAntiAffinity 的节点加分或减分。
PodTopologySpread:更精细的拓扑分散
PodTopologySpread(1.19 GA,1.24 成为默认插件)用来替代 PodAntiAffinity 实现更精细的拓扑分散控制。
核心参数:
1 | |
maxSkew:允许的最大偏差。如果最多的域有 3 个 Pod,最少的域有 1 个 Pod,skew 为 2。maxSkew: 1要求任意两个域的 Pod 数量差不超过 1。topologyKey:定义"域"的节点标签键。kubernetes.io/hostname意味着每个节点是一个域;topology.kubernetes.io/zone意味着每个可用区是一个域。whenUnsatisfiable:约束无法满足时的行为。DoNotSchedule是硬约束(在 Filter 阶段执行);ScheduleAnyway是软约束(在 Score 阶段,选 skew 增加最少的节点)。
与 PodAntiAffinity 对比:PodAntiAffinity 是"不能和某 Pod 在同一域",是全有或全无的约束;PodTopologySpread 是"Pod 在各域的分布不超过 maxSkew",更灵活。5 副本的 Deployment 跨 3 个可用区用 PodAntiAffinity 无法保证均匀分布,而 PodTopologySpread 可以。
PriorityClass 与抢占机制
PriorityClass
PriorityClass 是集群级别的对象,给 Pod 赋予数值优先级(0-1000000000,系统组件通常用 2000000000 以上):
1 | |
Pod 在 spec.priorityClassName 里引用这个 PriorityClass。调度器的优先级队列按这个值排序——高优先级 Pod 先调度。
抢占(Preemption)触发条件
当 Filter 阶段找不到任何可行节点(feasible nodes 为空),PostFilter 插件触发抢占逻辑:
- 遍历所有节点,模拟"如果驱逐该节点上某些低优先级 Pod,当前 Pod 能否调度到该节点"。
- 找到能腾出足够资源的节点后,选择一个"最优"候选节点(优先选需要驱逐 Pod 数量最少的节点;数量相同时,优先选被驱逐 Pod 中最高优先级最小的节点)。
- 向 API Server 发送驱逐请求(Eviction API),被驱逐 Pod 的所属 Deployment/ReplicaSet controller 负责重建它们。
- 在被驱逐 Pod 的 gracePeriod 内,当前 Pod 在那个节点上等待(
nominatedNodeName字段记录这个节点)。被驱逐 Pod 全部删除后,调度器重新尝试调度当前 Pod。
抢占只驱逐优先级低于当前 Pod 的 Pod,不驱逐相同或更高优先级的 Pod。
PodDisruptionBudget(PDB)与抢占
PDB 定义了允许同时不可用的 Pod 数量上限(maxUnavailable)或必须保持可用的下限(minAvailable)。抢占逻辑尊重 PDB——如果驱逐某个 Pod 会违反其 PDB,调度器不会驱逐它,而是寻找其他可驱逐的 Pod 组合。
可观测实验:观察抢占过程
准备环境
1 | |
创建两个 PriorityClass
1 | |
用低优先级 Pod 占满节点资源
先查节点可分配资源:
1 | |
创建低优先级 Pod,请求足够多的 CPU 让节点接近满载:
1 | |
根据节点实际资源,创建足够多的低优先级 Pod 让节点资源耗尽。
创建高优先级 Pod 触发抢占
1 | |
观察抢占事件
1 | |
预期看到类似:
1 | |
观察 nominatedNodeName
在 high-1 进入 Pending + 等待驱逐的短暂窗口期:
1 | |
这个字段显示调度器选定的目标节点,在 Pod 实际绑定之前存在。
观察调度器打分过程
调度器的详细日志(在 kind 控制平面容器里):
1 | |
或者通过调度器的 verbose 日志(需要启用 --v=5)看到每个节点的打分明细。
将实验结果映射到 K8s 对象
kubectl get events 里看到的 Preempting 事件,来源于调度器在 PostFilter 扩展点执行的 DefaultPreemption 插件。这个插件的执行结果是:向 API Server 发送 Eviction 对象,更新目标 Pod 的 status.nominatedNodeName。
被驱逐的 Pod 进入 Terminating 状态(deletionTimestamp 被设置),其 gracePeriodSeconds 计时开始。这段时间里,高优先级 Pod 在队列中等待,不断重新尝试调度,直到节点资源实际释放。
kubectl describe pod high-1 的 Events 会显示多次调度尝试和最终成功的时间戳,这反映了调度器的重试机制。
调度结果写入 pod.spec.nodeName 后,kubelet 通过 Informer 发现并开始 syncPod(详见06 kubelet 与容器运行时)。
模式提炼
Scheduling Framework 是"责任链 + 插件积分"调度模型。
责任链体现在 Filter 阶段:任意一个插件的拒绝导致节点被淘汰,整个链是逻辑与(AND)。
插件积分体现在 Score 阶段:多个插件独立给节点打分,加权求和,最终分数是向量的线性组合。不同场景可以通过调整权重改变调度偏好,而不需要修改代码。
抢占是对"当前无法满足"的特殊处理路径:当没有节点通过 Filter,触发 PostFilter 的补偿逻辑,通过驱逐低价值 Pod 为高价值 Pod 腾出空间。这是优先级反转问题的主动解决方案,不是被动等待资源释放。
工程迁移表
| K8s 机制 | 类比概念 | 差异点 |
|---|---|---|
| Filter 阶段(责任链) | Spring Security FilterChain / Servlet Filter | K8s Filter 是只读的,只决定是否淘汰节点;Servlet Filter 可以修改请求/响应 |
| Score 阶段(加权求和) | 负载均衡器权重轮询 / 推荐系统打分模型 | 调度器的分数是多插件加权聚合,负载均衡器通常是单维度权重 |
| PriorityClass + Preemption | 线程池任务队列优先级(Java PriorityBlockingQueue) | 线程池拒绝低优先级任务,K8s 调度器驱逐已运行的低优先级 Pod;K8s 提供了资源的主动回收,线程池没有 |
| LeastAllocated Score | Nginx upstream least_conn / 数据库连接池负载均衡 | LeastAllocated 基于资源 requests 的剩余量;least_conn 基于当前活跃连接数 |
| PostFilter(抢占)Cost Model | 数据库查询优化器选择执行计划 | 查询优化器基于统计信息选最低 cost 路径,调度抢占基于被驱逐 Pod 数量和优先级选最小代价节点 |
| PodTopologySpread | CDN 多 PoP 节点流量分发 / 数据库分片均衡 | PodTopologySpread 控制拓扑域间 Pod 数量偏差,数据库分片均衡控制数据量偏差 |
| Taint/Toleration | 数据库连接池黑名单 / 专用线程池(任务只给特定线程) | Taint 是节点拒绝调度的标记,Toleration 是 Pod 的豁免声明;方向是节点推 Pod,而不是 Pod 选节点 |
常见误解
误解一:调度器直接运行 Pod
调度器只做一件事:将 pod.spec.nodeName 从空设置为某个节点名称。运行 Pod(拉取镜像、创建容器、配置网络)是 kubelet 的职责,在调度完成之后触发。
调度器是无状态的控制平面组件,不维护任何节点上容器的运行时状态。它依赖 API Server 中 Pod 对象的 spec.requests 字段计算已用资源,而不是查询容器运行时的实际用量。
误解二:nodeSelector 和 nodeAffinity 是同一机制的不同写法
nodeSelector 是早期 API,只支持精确匹配(key=value),在 Filter 阶段作为硬约束执行。nodeAffinity 是后来引入的更丰富的 API,支持集合操作符,且区分硬约束(required,Filter 阶段)和软约束(preferred,Score 阶段)。
两者可以同时使用,但语义不同:nodeSelector 只要一项不匹配就淘汰节点,nodeAffinity.preferred 不满足时节点仍然是候选,只是分数较低。混用时两者同时生效,是逻辑与关系。
误解三:被抢占驱逐的 Pod 会立刻消失,高优先级 Pod 立刻调度
抢占触发后,低优先级 Pod 进入 Terminating 状态,但会等待 terminationGracePeriodSeconds(默认 30 秒)。在这段时间里,高优先级 Pod 处于 Pending 状态,status.nominatedNodeName 记录目标节点,但 spec.nodeName 还是空的——还没真正调度成功。
只有当目标节点上足够多的被驱逐 Pod 实际退出、资源真正释放后,调度器重新对高优先级 Pod 运行 Filter + Score,确认资源满足,才完成绑定。如果在这段等待时间里有其他更高优先级的 Pod 出现,之前的抢占计划可能被重新评估。
练习
-
查看当前集群的调度器配置(
kubectl -n kube-system get pod -l component=kube-scheduler -o yaml),找到--config参数指定的 KubeSchedulerConfiguration 文件,查看哪些 Score 插件被启用,默认权重是多少。 -
创建三个 Deployment(各 4 副本)在一个有 3 个 worker 节点的集群中,分别配置
maxSkew: 1的PodTopologySpread(按节点),观察 Pod 分布是否均匀;然后将其中一个节点 cordoned(kubectl cordon),观察 Deployment 的 Pod 分布如何变化,以及新调度的 Pod 是否违反约束。 -
模拟优先级抢占:先创建足够多的低优先级 Pod 占满一个节点,然后创建一个高优先级 Pod,用
kubectl get events -w全程观察;在被驱逐 Pod 的 gracePeriod 期间,用kubectl get pod high-xxx -o jsonpath='{.status.nominatedNodeName}'确认目标节点已被提名但尚未绑定。 -
编写一个 KubeSchedulerConfiguration,将
LeastAllocated插件权重改为 0,MostAllocated权重改为 1,部署到 kind 集群,观察 Pod 调度是否从"分散优先"变为"装箱优先"(多个 Pod 集中调度到同一节点)。
系列导航
- 00 核心概念导读
- 01 API Server 与声明式 API
- 02 etcd 与持久化
- 03 控制器模式与 Informer 机制
- 04 Pod 生命周期
- 05 调度器深入
- 06 kubelet 与容器运行时
- 07 网络模型与 CNI
- 08 Service 与 kube-proxy
- 09 Ingress 与 Gateway API
- 10 存储体系
- 11 配置与密钥管理
- 12 RBAC 与安全模型
- 13 资源管理与自动伸缩
- 14 CRD 与 Operator 模式
- 15 Helm 与应用打包
- 16 可观测性
- 17 生产集群运维
