调度器(kube-scheduler)是 Kubernetes 控制平面里最容易被低估的组件。它做的事情看起来简单:把没有 nodeName 的 Pod 绑定到某个节点。但"选哪个节点"这个问题背后,隐藏着资源感知、亲和性约束、拓扑分散、优先级抢占等多维度的决策过程。

从 Kubernetes 1.19 起,调度器的内部逻辑通过 Scheduling Framework 对外暴露为一组扩展点。原来散落在代码各处的调度逻辑被重新组织成插件,每个插件只负责一个扩展点。这让调度行为既可预测,又可扩展。

本文的核心问题是:Kubernetes 调度器用什么框架选节点,当资源不足时如何通过抢占让高优先级 Pod 运行?

调度框架全景图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Pod pending (no nodeName)


Scheduler(from ActiveQ by priority)
┌──────────────────────────────────────────────────────┐
PreFilter — 预计算共享状态(如 Pod affinity 拓扑键) │
│ Filter — 过滤不满足条件的节点 │─► feasible nodes
│ │
│ PostFilter — 如果 feasible=0,触发抢占逻辑 │
│ │
│ PreScore — 为打分准备数据 │
Score — 对 feasible nodes 打分(0-100) │─► ranked nodes
NormalizeScore — 归一化各插件分数 │
│ │
│ Reserve — 乐观锁定,预留资源(可回滚) │
│ Permit — gang scheduling 等待 │
│ PreBind — 绑定前置操作 │
Bind — 更新 Pod.spec.nodeName │
│ PostBind — 绑定后通知 │
└──────────────────────────────────────────────────────┘


Pod.Spec.NodeName = "node-X"(写入 etcd)


kubelet on node-X 开始 syncPod

Filter 阶段:淘汰不可用节点

Filter 阶段的目标是从集群所有节点里找出"可行节点"(feasible nodes)——所有满足 Pod 约束的节点。只要有一个 Filter 插件对某节点返回失败,该节点就被从候选集里剔除。

主要 Filter 插件:

NodeResourcesFit

检查节点的可分配资源(allocatable)减去已分配资源(所有已调度 Pod 的 requests 之和)是否满足当前 Pod 的 requests

注意:调度器用的是 requests,不是 limits。节点上实际运行的容器可以突破 requests,但调度决策基于 requests。这意味着如果所有 Pod 都突破 requests,节点可能 OOM,但调度器在调度时并不知道这件事。

NodeSelector 与 NodeAffinity

nodeSelector 是简单的标签匹配,要求节点带有指定标签键值对。nodeAffinity 表达力更强,支持 InNotInExistsDoesNotExistGtLt 等操作符,并区分硬约束(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

对满足软约束 preferredDuringSchedulingnodeAffinity 节点加分。

InterPodAffinityPriority

对满足软约束 PodAffinity/PodAntiAffinity 的节点加分或减分。

PodTopologySpread:更精细的拓扑分散

PodTopologySpread(1.19 GA,1.24 成为默认插件)用来替代 PodAntiAffinity 实现更精细的拓扑分散控制。

核心参数:

1
2
3
4
5
6
7
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: my-app
  • 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
2
3
4
5
6
7
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000
globalDefault: false
description: "High priority workloads"

Pod 在 spec.priorityClassName 里引用这个 PriorityClass。调度器的优先级队列按这个值排序——高优先级 Pod 先调度。

抢占(Preemption)触发条件

当 Filter 阶段找不到任何可行节点(feasible nodes 为空),PostFilter 插件触发抢占逻辑:

  1. 遍历所有节点,模拟"如果驱逐该节点上某些低优先级 Pod,当前 Pod 能否调度到该节点"。
  2. 找到能腾出足够资源的节点后,选择一个"最优"候选节点(优先选需要驱逐 Pod 数量最少的节点;数量相同时,优先选被驱逐 Pod 中最高优先级最小的节点)。
  3. 向 API Server 发送驱逐请求(Eviction API),被驱逐 Pod 的所属 Deployment/ReplicaSet controller 负责重建它们。
  4. 在被驱逐 Pod 的 gracePeriod 内,当前 Pod 在那个节点上等待(nominatedNodeName 字段记录这个节点)。被驱逐 Pod 全部删除后,调度器重新尝试调度当前 Pod。

抢占只驱逐优先级低于当前 Pod 的 Pod,不驱逐相同或更高优先级的 Pod。

PodDisruptionBudget(PDB)与抢占

PDB 定义了允许同时不可用的 Pod 数量上限(maxUnavailable)或必须保持可用的下限(minAvailable)。抢占逻辑尊重 PDB——如果驱逐某个 Pod 会违反其 PDB,调度器不会驱逐它,而是寻找其他可驱逐的 Pod 组合。

可观测实验:观察抢占过程

准备环境

1
kind create cluster --name scheduler-demo

创建两个 PriorityClass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cat <<'EOF' | kubectl apply -f -
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: low-priority
value: 100
globalDefault: false
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000
globalDefault: false
EOF

用低优先级 Pod 占满节点资源

先查节点可分配资源:

1
kubectl describe node kind-control-plane | grep -A5 "Allocatable:"

创建低优先级 Pod,请求足够多的 CPU 让节点接近满载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: low-1
spec:
priorityClassName: low-priority
containers:
- name: stress
image: busybox:1.35
command: ["sh", "-c", "while true; do sleep 3600; done"]
resources:
requests:
cpu: "500m"
memory: "256Mi"
EOF

根据节点实际资源,创建足够多的低优先级 Pod 让节点资源耗尽。

创建高优先级 Pod 触发抢占

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: high-1
spec:
priorityClassName: high-priority
containers:
- name: app
image: busybox:1.35
command: ["sh", "-c", "while true; do sleep 3600; done"]
resources:
requests:
cpu: "600m"
memory: "256Mi"
EOF

观察抢占事件

1
kubectl get events --sort-by='.lastTimestamp' | grep -E "Preempt|Evict|Scheduled"

预期看到类似:

1
2
3
2s    Normal   Preempting          pod/high-1    Preempted pod default/low-1 on node kind-control-plane
1s Normal Killing pod/low-1 Stopping container stress
0s Normal Scheduled pod/high-1 Successfully assigned default/high-1 to kind-control-plane

观察 nominatedNodeName

high-1 进入 Pending + 等待驱逐的短暂窗口期:

1
kubectl get pod high-1 -o jsonpath='{.status.nominatedNodeName}'

这个字段显示调度器选定的目标节点,在 Pod 实际绑定之前存在。

观察调度器打分过程

调度器的详细日志(在 kind 控制平面容器里):

1
docker exec kind-control-plane cat /var/log/kube-scheduler.log 2>/dev/null | grep -i "score" | tail -20

或者通过调度器的 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 出现,之前的抢占计划可能被重新评估。

练习

  1. 查看当前集群的调度器配置(kubectl -n kube-system get pod -l component=kube-scheduler -o yaml),找到 --config 参数指定的 KubeSchedulerConfiguration 文件,查看哪些 Score 插件被启用,默认权重是多少。

  2. 创建三个 Deployment(各 4 副本)在一个有 3 个 worker 节点的集群中,分别配置 maxSkew: 1PodTopologySpread(按节点),观察 Pod 分布是否均匀;然后将其中一个节点 cordoned(kubectl cordon),观察 Deployment 的 Pod 分布如何变化,以及新调度的 Pod 是否违反约束。

  3. 模拟优先级抢占:先创建足够多的低优先级 Pod 占满一个节点,然后创建一个高优先级 Pod,用 kubectl get events -w 全程观察;在被驱逐 Pod 的 gracePeriod 期间,用 kubectl get pod high-xxx -o jsonpath='{.status.nominatedNodeName}' 确认目标节点已被提名但尚未绑定。

  4. 编写一个 KubeSchedulerConfiguration,将 LeastAllocated 插件权重改为 0,MostAllocated 权重改为 1,部署到 kind 集群,观察 Pod 调度是否从"分散优先"变为"装箱优先"(多个 Pod 集中调度到同一节点)。

系列导航

参考资料