Skip to main content

14. K8S调度原理

调度器的输入输出​

前面一路讲了很多——Pod 写好了、网络和存储就绪、安全限制过了。现在还缺一步:决定这个 Pod 去哪台机器。

调度器(kube-scheduler)的角色一句话就说清楚:

输入:一个 nodeName 为空的 Pod 输出:这个 Pod 的 nodeName 被写成了某个 Node 的名字

接口定义在 k8s.io/kubernetes/pkg/scheduler:

// 调度器只需要做一件事:把 Pod 绑到 Node
func (sched *Scheduler) scheduleOne(ctx context.Context) {
pod := sched.NextPod() // 从队列里拿一个待调度的 Pod
nodeName := sched.schedule(pod) // 筛选 + 打分
sched.bind(pod, nodeName) // 把绑定结果写回 API Server
}

NextPod() 是从 Scheduler 内部的 SchedulingQueue 拿的,这个队列 Watch 了所有未调度的 Pod。调度是异步的——Pod 创建后不会立刻绑定,而是排队等调度器处理。

预选(Filter):哪些节点能跑​

第一步叫 Filter——从所有 Node 中筛掉不满足条件的。不能拍的节点直接出局。

调度器启动时注册了一堆 Plugin,每个 Plugin 可以在 Filter 阶段投票。核心的 Filter 有:

// k8s.io/kubernetes/pkg/scheduler/framework/plugins/
// Plugin: NodeResourcesFit
func (f *Fit) Filter(ctx context.Context, state *CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
// 1. 检查 Node 剩下多少 CPU/内存
// 2. Pod 的 request 是否超出剩余量
// 3. 超出 → 直接返回 Unschedulable
}

// Plugin: NodePorts
func (pl *NodePorts) Filter(ctx context.Context, cycleState *CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
// Pod 需要映射的 NodePort 是否已被占
}

// Plugin: NodeAffinity / NodeSelector
func (pl *NodeAffinity) Filter(..., pod *v1.Pod, nodeInfo *NodeInfo) *Status {
// Pod 的 nodeSelector / nodeAffinity 要求是否匹配这个 Node 的标签
}

// Plugin: TaintToleration
func (pl *TaintToleration) Filter(..., pod *v1.Pod, nodeInfo *NodeInfo) *Status {
// Node 身上的 taint(这个节点在维护、这个节点是 GPU 专用)Pod 到底能不能忍
}

Scheduler 遍历所有 Node,用每个 Plugin 跑一遍 Filter。一个 Node 通不过任意一个 Filter,直接淘汰。

优选(Score):剩下的谁最好​

Filter 之后剩下一组候选 Node。接下来 Score——给每个候选 Node 打分,分最高的当选。

// Plugin: NodeResourcesBalancedAllocation
func (*NodeResourcesBalancedAllocation) Score(ctx, state, pod, nodeInfo) (int64, *Status) {
// 给 CPU 和内存使用率做打分
// 两项使用率差距小(均衡)→ 分数高
// 一项 90% 一项 10% → 分数低(不均衡)
}

// Plugin: ImageLocality
func (*ImageLocality) Score(ctx, state, pod, nodeInfo) (int64, *Status) {
// Node 上已经 Pull 过这个镜像 → 分数高(容器起得快)
}

// Plugin: NodeAffinity (Preferred)
// 不是硬要求(非 Filter)而是偏好,分高

一个真实案例:3 个 Node,Pod 要 500m CPU:

  1. Node-1:CPU 用了 60%、内存 50% → 资源平衡,分高
  2. Node-2:CPU 用了 90%、内存 10% → 不均衡,分低
  3. Node-3:满足但是有 taint Pod 没有 toleration → Filter 阶段直接被淘汰

结果:Node-1 最高分,Pod 调度到 Node-1。

调度框架:所有步骤都是 Plugin​

Scheduler 整个流程由 Scheduling Framework 组织。每个阶段——PreFilter、Filter、PostFilter、PreScore、Score、Reserve、Permit、PreBind、Bind、PostBind——都是 Plugin 插入的地方:

Pod 进入调度队列
│
├─ PreFilter(预处理,计算 Pod 的亲和规则等)
├─ Filter (筛掉不行的 Node)
├─ PostFilter (没有 Node 通过 Filter → 是否抢占别的 Pod)
├─ PreScore (打分前的准备)
├─ Score (给候选 Node 打分)
├─ Reserve (预留资源,假设这个 Pod 会被安排到某个 Node)
├─ Permit (是否允许绑定的最后检查——可能需要等别的操作完成)
├─ PreBind (绑定前操作,比如创建 Volume)
├─ Bind (真正写 API Server)
└─ PostBind (绑定成功后的通知)

K8S 1.24 后默认用这套框架,所有内置调度逻辑都是 Plugin。这意味着你可以关掉默认的、用自己的 Plugin 替代任何一个阶段。调度器的可扩展性在这里达到了顶点。

调度策略实例:podAffinity 和 podAntiAffinity​

这两个是在 PreFilter + Filter 阶段真正起作用的:

spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: redis
topologyKey: kubernetes.io/hostname # 跟 Redis Pod 跑在同一台机器上

requiredDuringScheduling:调度时严格执行(硬性要求)。 preferredDuringScheduling:优先考虑(给分数加权)。 IgnoredDuringExecution:Pod 已经在跑之后,这个条件可能不再成立——不会因不满足而驱逐它。

Scheduler 在 PreFilter 阶段解析这个 affinity 规则,到了 Filter 阶段检查每个候选 Node 是否满足。podAntiAffinity 就是反过来——"别跟 X 跑在同一台机器上"。

topologySpreadConstraints:让 Pod 均匀分布在可用区​

podAntiAffinity 能保证"每个 Node 最多一个 Pod",但有个问题——如果是 5 个 Pod、3 个可用区、每区 2 个 Node——podAntiAffinity 只能保证"同一 Node 不重复",不能保证 Pod 跨 zone 均匀分布。你可能得到 zone-a 3 个 Pod、zone-b 2 个、zone-c 0 个——这仍然是合法的 podAntiAffinity 结果。

K8S 1.19 引入的 topologySpreadConstraints 专门解决"按拓扑均匀分布"的问题:

spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone # 按可用区维度
whenUnsatisfiable: DoNotSchedule # 不满足就不调
labelSelector:
matchLabels:
app: nginx

这条规则的含义:同一 zone 内 app=nginx 的 Pod 数量,最高 zone 和最低 zone 的差值不能超过 1(maxSkew: 1)。

假设 5 个 Pod、3 个 zone,每个 zone 有足够 Node。均匀分布结果:zone-a: 2、zone-b: 2、zone-c: 1(差值 = 2-1 = 1,满足)。podAntiAffinity 给不出这个结果——因为它只管"同一 Node 不重复",不管跨 zone 的宏观分布。

跟 podAntiAffinity 的核心区别:

维度podAntiAffinitytopologySpreadConstraints
粒度每个 Pod 对:Pod-A 不能跟 Pod-B 同一 Node整个组的最高和最低差:所有 app=nginx Pod 的分布
顶层域只能控制一对 Pod控制一组 Pod 跨拓扑域的均匀性
适用场景"这个数据库主从不能在一个 Node""Nginx Pod 必须在三个 AZ 平均分"

新补充:minDomains(K8S 1.24 加入,1.27 GA)——主动确保拓扑域数量:

spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
minDomains: 3 # ← 保证至少有 3 个 zone 都有 Pod
labelSelector:
matchLabels:
app: nginx

minDomains 告诉 Scheduler:即使 maxSkew 公式算出来可以不往某个 zone 放 Pod,也必须确保至少 3 个 zone 都有 Pod。如果集群只有 2 个 zone,且 whenUnsatisfiable: DoNotSchedule——Pod 会一直 Pending。

这就是你想要的"每个 zone 必须有副本"——minDomains 搭配 maxSkew: 1,既均匀又有保证。

真实用法:生产应用三件一起配——Service 做入口,HPA 做伸缩,topologySpreadConstraints 做均匀分布。HPA 把 Pod 从 3 扩到 9 之后,Scheduler 会根据 spread constraint 把这 9 个 Pod 均匀分到 3 个 zone,每个 zone 3 个——不需要你手动管。

思考题:如果 whenUnsatisfiable: DoNotSchedule + 集群只有 2 个 zone + minDomains: 3,会发生什么?ScheduleAnyway 又是什么行为?什么场景下你宁愿不完美分布也要先把 Pod 跑起来?

思考题:如果集群的资源满了——一个高优先级的 Pod 想调度,但 Node 上全是低优先级的 Pod 占着资源。Scheduler 能不能把低优先级的 Pod 踢走?这个"踢"的过程是怎么执行的——直接 SIGKILL 还是优雅关闭?

思考题:分布式训练需要 4 个 GPU Pod 同时跑——如果只有 3 个 GPU Node 空着,这 4 个 Pod 调度了 3 个、1 个 Pending,训练没法开始。有没有办法保证"要么全部调度成功,要么一个都别调度"?

K3S 的调度器差异​

K3S 没有独立的 kube-scheduler 二进制。Scheduler 内置在 k3s server 里。代码上它是直接 import 了 K8S 的 Scheduler:

// k3s/pkg/cluster/https.go
import "k8s.io/kubernetes/cmd/kube-scheduler/app"

func Run(ctx context.Context, cfg *config.Control) error {
cmd := app.NewSchedulerCommand()
return cmd.ExecuteContext(ctx)
}

逻辑和行为完全一样——同样的 Scheduling Framework、同样的 Plugin 机制。区别只是 K3S 省了一个进程。


解答​

minDomains: 3 但集群只有 2 个 zone——Pod 会 Pending 吗?

取决于 whenUnsatisfiable:

  • DoNotSchedule → 是的。Scheduler 在 Filter 阶段发现"域数量不足 3",直接判 Unschedulable。Pod 一直 Pending,直到你加第三个 zone 或改掉配置。
  • ScheduleAnyway → 不卡死。Scheduler 在 Score 阶段会给"把 Pod 往 zone 少的域放"更高的分数,尽量让分布靠近 maxSkew=1,但不满足也不拦。最坏情况:所有 Pod 都挤在两个 zone 里,差大于 maxSkew,但至少 Pod 能跑。

什么时候选 ScheduleAnyway? 当"Pod 必须跑起来"比"完美分布"更重要时——例如你是在一个有 2 个 zone 的开发环境测试,线上是 3 个 zone——同样的 YAML 不能因为开发环境少 zone 就拒绝调度。

2. 优先级抢占——节点满了谁被踢走?​

Scheduler 不只是找空位——当集群资源紧张时,它会抢占。流程:

高优先级 Pod 进入调度队列
→ Filter 阶段:所有 Node 都满了
→ PostFilter 阶段:暴力——找到那些"如果你踢走一个低优先级 Pod,这个 Node 就有空间了"的 Node
→ 被踢的 Pod 优雅关闭 → 高优先级 Pod 调度到腾出来的位置

PriorityClass 定义:

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000000 # 数值越大优先级越高
globalDefault: false
preemptionPolicy: PreemptLowerPriority # 允许抢占低优先级

Pod 引用:

spec:
priorityClassName: high-priority

抢占不是 kubectl delete,不走 Finalizer。被踢的 Pod 收到 SIGTERM,进入 terminationGracePeriodSeconds(默认 30 秒)的优雅退出。如果你设了 gracePeriod=0,它就 SIGKILL。

Scheduler 怎么选"牺牲者"的? 在 kube-scheduler 源码里,PostFilter 阶段有一个 DefaultPreemption Plugin,它的逻辑:

  1. 找到所有可能被抢占的 Node(这些 Node 上的低优先级 Pod 总 request 大于高优先级 Pod 的 request)
  2. 对每个候选 Node,选择被踢 Pod 的组合——优先踢最少 Pod 的方案、优先踢最低优先级的 Pod
  3. 将选中的 Pod 标记为 Preempting,更新 etcd
  4. kubelet 收到 Pod 的 DeletionTimestamp → 优雅退出
  5. 高优先级 Pod 调度到腾出来的位置

3. Gang Scheduling——要么全调度,要么全不调度​

分布式训练(PyTorch DDP、TensorFlow)需要 N 个 Worker Pod 同时启动——缺一个都无法开始训练。默认 Scheduler 是逐个调度 Pod 的,可能出现"3 个 Worker 在跑、1 个 Pending"的僵局:3 个 Pod 占着 GPU,训练没法开始,GPU 全浪费了。

SchedulerGating Plugin + Volcano 是目前的方案。Volcano 的 PodGroup 实现 gang scheduling:

apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
name: training-job
spec:
minMember: 4 # 至少要 4 个 Pod 都到位
queue: gpu-queue
minResources:
nvidia.com/gpu: 4 # 一共要 4 个 GPU

Scheduler 看到这些 Pod 属于同一个 PodGroup → 等所有 4 个 Pod 都满足调度条件后才一起分配 Node。如果资源不够 4 个——一个都不调,Pod 集体 Pending,不会浪费 GPU"部分启动"。

K8S 上游在 1.26 引入了 PodSchedulingContext(alpha)作为 gang scheduling 的标准化尝试,但目前生产还是靠 Volcano / Coscheduling Plugin。

Descheduler:主动重平衡​

Scheduler 只管"Pod 该去哪",不管"Pod 已经在跑了但位置不好"。时间长了你可能发现——加了 Node 之后新 Pod 全往新 Node 上走,旧 Node 慢慢空出来但老 Pod 从不迁移。Descheduler 反其道而行——定期驱逐"位置不好的 Pod",让它们重新调度:

apiVersion: descheduler/v1alpha2
kind: DeschedulerPolicy
profiles:
- name: Default
pluginConfig:
- name: LowNodeUtilization
args:
thresholds:
cpu: 30
memory: 30
targetThresholds:
cpu: 50
memory: 50
- name: RemovePodsViolatingTopologySpread
- name: RemovePodsViolatingInterPodAntiAffinity
策略做什么
LowNodeUtilization利用率低的 Node 收 Pod,移给高利用率 Node
RemoveDuplicates同一 RS 的 Pod 跑在同一 Node 上—打散
RemovePodsViolatingTopologySpread驱逐违反 spread constraint 的 Pod

跟抢占的区别:抢占是"高优 Pod 没位置,被迫踢低优"—紧急被动。Descheduler 是"位置不好,主动踢"—主动优化,有资源才做。

小结​

  • Scheduler 输入待调度的 Pod,经过 Filter(筛)→ Score(打分)→ Bind(写回 nodeName)
  • 调度框架让整个流程 Plugin 化——你可以换掉任何一个环节
  • 内置的 Filter/Score 涵盖资源、亲和、污点容忍、端口等多种策略
  • K3S 的 Scheduler 跟 K8S 同一套代码,只是内嵌在单进程里

下一章,Pod 已经在跑了,怎么不重启就更新配置——原地升级。