故事的开始:Pod Pending,但节点明明有资源
周五下午,一个告警弹了出来:某 Deployment 扩容后,新 Pod 一直卡在 Pending。
$ kubectl describe pod my-app-7f8d9c6b4-xk2nz
...
Events:
Warning FailedScheduling default-scheduler 0/5 nodes are available:
5 Insufficient cpu. preemption: 0/5 nodes are available:
5 No preemption victims found for incoming pod.
我赶紧看了一下节点资源:
$ kubectl top nodes
NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%
node-1 1200m 30% 4096Mi 50%
node-2 800m 20% 3200Mi 40%
node-3 600m 15% 2800Mi 35%
node-4 900m 22% 3600Mi 45%
node-5 1100m 27% 4200Mi 52%
CPU 使用率最高才 30%,为什么调度器说 Insufficient cpu?
答案藏在调度器的核心设计里:调度器看的是 requests(声明量),不是实际使用量。
$ kubectl describe node node-1
...
Allocated resources:
Resource Requests Limits
-------- -------- ------
cpu 3800m (95%) 8000m (200%)
memory 6Gi (75%) 12Gi (150%)
虽然实际只用了 30% CPU,但节点上所有 Pod 的 requests.cpu 加起来已经占了 95%。调度器按 requests 计算可分配资源,自然认为"没地方放了"。
这个排查过程,引出了一个更大的问题:调度器到底是怎么工作的?
调度的本质
Kubernetes 调度器做的事情非常简单,用一句话概括:
输入:一个
spec.nodeName为空的 Pending Pod;输出:给它的spec.nodeName写上一个合适的 Node 名称。
就这么简单。但"合适"二字的背后,是一套精密的插件化框架。
调度器的核心循环:
- 从调度队列中取出一个 Pending Pod
- 遍历所有 Node,过滤掉不满足条件的(Filter)
- 对剩余 Node 打分排序(Score)
- 选出最高分的 Node,执行绑定(Bind)
听起来很简单?但实际上每一步都可以通过插件扩展,这就是 Scheduling Framework。
Scheduling Framework(1.19+ GA)
从 Kubernetes 1.19 开始,调度器内部全面重构为 Scheduling Framework,所有调度逻辑都以插件(Plugin)的形式挂载到一系列扩展点(Extension Point)上。
完整扩展点链
graph TD
A[Sort] --> B[PreFilter]
B --> C[Filter]
C --> D[PostFilter]
D --> E[PreScore]
E --> F[Score]
F --> G[NormalizeScore]
G --> H[Reserve]
H --> I[Permit]
I --> J[PreBind]
J --> K[Bind]
K --> L[PostBind]
style A fill:#e1f5fe
style B fill:#e1f5fe
style C fill:#ffebee
style D fill:#ffebee
style E fill:#e8f5e9
style F fill:#e8f5e9
style G fill:#e8f5e9
style H fill:#fff3e0
style I fill:#fff3e0
style J fill:#f3e5f5
style K fill:#f3e5f5
style L fill:#f3e5f5
整个流程分为两个阶段:
| 阶段 | 包含的扩展点 | 特点 |
|---|---|---|
| Scheduling Cycle(调度周期) | Sort → PreFilter → Filter → PostFilter → PreScore → Score → NormalizeScore → Reserve → Permit | 串行执行,同一时刻只调度一个 Pod |
| Binding Cycle(绑定周期) | PreBind → Bind → PostBind | 并行执行,多个 Pod 可以同时绑定 |
为什么这样设计?因为调度决策需要全局视角(必须串行),而绑定只是写 apiserver(可以并行提高吞吐)。
各扩展点详解
1. Sort — 决定谁先调度
Sort 插件决定调度队列中 Pod 的排序。默认实现是 QueueSort,按 Pod 优先级(PriorityClass)从高到低排列,优先级相同则按创建时间排。
举个例子,假设以下 3 个 Pod 几乎同时进入调度队列:
# Pod A — 普通业务 Pod,无 PriorityClass(默认优先级 0),创建时间 10:00:03
apiVersion: v1
kind: Pod
metadata:
name: web-server
creationTimestamp: "2026-01-01T10:00:03Z"
spec:
containers:
- name: nginx
image: nginx
# Pod B — 关键服务,优先级 1000,创建时间 10:00:01
apiVersion: v1
kind: Pod
metadata:
name: payment-service
creationTimestamp: "2026-01-01T10:00:01Z"
spec:
priorityClassName: high-priority # priority value: 1000
containers:
- name: payment
image: payment:v2
# Pod C — 关键服务,优先级 1000,创建时间 10:00:02
apiVersion: v1
kind: Pod
metadata:
name: order-service
creationTimestamp: "2026-01-01T10:00:02Z"
spec:
priorityClassName: high-priority # priority value: 1000
containers:
- name: order
image: order:v3
Sort 之后的调度顺序:
| 顺序 | Pod | 优先级 | 创建时间 | 原因 |
|---|---|---|---|---|
| 1 | payment-service | 1000 | 10:00:01 | 优先级最高,且创建时间最早 |
| 2 | order-service | 1000 | 10:00:02 | 优先级相同,但创建时间晚于 payment |
| 3 | web-server | 0 | 10:00:03 | 优先级最低,排最后 |
优先级高的 Pod 先被调度器处理。优先级相同时,先创建的排前面(FIFO)。
2. PreFilter — 预处理与快速失败
PreFilter 在正式过滤之前运行,用于:
- 预计算一些后续 Filter 需要的数据(避免对每个 Node 重复计算)
- 快速判断 Pod 是否根本不可调度(提前失败)
快速失败的例子: 假设集群中所有 Node 最大可分配内存是 64Gi,但某个 Pod 请求了 128Gi:
apiVersion: v1
kind: Pod
metadata:
name: greedy-pod
spec:
containers:
- name: app
image: myapp
resources:
requests:
memory: "128Gi" # 集群中没有任何 Node 有这么多内存
NodeResourcesFit 的 PreFilter 阶段会汇总集群中所有 Node 的最大可分配资源,发现没有任何 Node 能满足 128Gi 的请求。这时它返回 UnschedulableAndUnresolvable,调度器跳过对所有 Node 的逐个 Filter(因为一定全部失败),直接标记这次调度失败。
Pod 会怎样? Pod 保持 Pending 状态,被放回调度队列。调度器不会一直重试——Pod 会在队列中等待,直到集群状态发生变化(比如新增了一个大内存 Node、或者其他 Pod 释放了资源),调度器才会重新尝试调度它。kubectl describe pod 会看到类似这样的 Event:
Events:
Type Reason Message
---- ------ -------
Warning FailedScheduling 0/5 nodes are available: 5 Insufficient memory.
这就是 PreFilter 的价值:不用对 5 个 Node 逐个检查,在 PreFilter 阶段就知道不可能,省掉 5 次无意义的 Filter 计算。
内置 PreFilter 插件:
NodeResourcesFit:预计算 Pod 的资源请求,检查是否有 Node 可能满足NodePorts:检查 Pod 是否需要 hostPortInterPodAffinity:预处理 Pod 亲和/反亲和规则PodTopologySpread:预计算拓扑分布约束VolumeBinding:检查 PVC 状态(如 PVC 引用了不存在的 StorageClass,直接失败)
3. Filter — 硬性过滤(最关键的一步)
与 PreFilter(只看一次 Pod,做全局判断)不同,Filter 是对每个 Node 逐个执行的:调度器遍历所有候选 Node,对每个 Node 依次跑所有 Filter 插件,任何一个插件返回失败,该 Node 就被淘汰。集群有 1000 个 Node,就会执行 1000 轮 Filter 检查。
全部内置 Filter 插件:
| 插件 | 作用 |
|---|---|
NodeResourcesFit | 检查 Node 剩余资源(Allocatable - 已分配 requests)是否满足 Pod requests |
NodeName | 如果 Pod 指定了 spec.nodeName,只留该 Node |
NodePorts | 检查 Pod 所需的 hostPort 在 Node 上是否被占用 |
NodeAffinity | 评估 nodeAffinity 规则(requiredDuringSchedulingIgnoredDuringExecution) |
NodeUnschedulable | 过滤掉 spec.unschedulable: true 的 Node(cordon 的 Node) |
TaintToleration | 检查 Node 上的 Taint,Pod 是否有对应的 Toleration |
InterPodAffinity | 评估 Pod 间亲和/反亲和的 required 规则 |
PodTopologySpread | 检查 Pod 拓扑分布约束 whenUnsatisfiable: DoNotSchedule |
VolumeBinding | 检查 PVC/PV 绑定状态,以及 Node 是否满足存储拓扑约束 |
VolumeRestrictions | 检查 Volume 挂载限制(如 ReadWriteOnce 不能多节点) |
VolumeZone | 检查 PV 的 zone 标签是否与 Node 匹配 |
EBSLimits / GCEPDLimits / AzureDiskLimits | 检查 Node 上挂载的云盘数量是否超限 |
CSIMaxVolumeLimitChecker | 检查 CSI 卷数量限制 |
这就解释了开头的问题:
NodeResourcesFit计算的是 requests 之和,不是实际使用量。即使kubectl top显示 CPU 使用率只有 30%,只要 requests 之和超过了 Node 的 Allocatable,调度器就会拒绝。
4. PostFilter — 抢占的入口
当 Filter 阶段没有任何 Node 通过时,PostFilter 被触发。默认的 PostFilter 插件是 DefaultPreemption,它负责执行抢占逻辑(后面详细讲)。
5. PreScore — 打分前的准备
PreScore 在打分之前运行,用于预计算 Score 插件需要的数据。例如 InterPodAffinity 在 PreScore 阶段预处理拓扑匹配信息。
6. Score — 给候选 Node 打分
Filter 阶段筛完后,可能还剩多个 Node,Score 阶段给它们排名次。每个 Score 插件对每个 Node 打 0-100 分,最终加权求和。
全部内置 Score 插件:
| 插件 | 作用 | 默认权重 |
|---|---|---|
NodeResourcesBalancedAllocation | 倾向 CPU/Memory 使用比例均衡的 Node | 1 |
NodeResourcesFit (LeastAllocated) | 倾向资源空闲多的 Node(默认策略) | 1 |
InterPodAffinity | Pod 亲和/反亲和的 preferred 规则打分 | 1 |
NodeAffinity | nodeAffinity 的 preferredDuringSchedulingIgnoredDuringExecution 打分 | 1 |
TaintToleration | 倾向 Taint 更少的 Node | 1 |
PodTopologySpread | 拓扑分布约束的 whenUnsatisfiable: ScheduleAnyway 打分 | 2 |
ImageLocality | 倾向已经拉取过 Pod 镜像的 Node(减少拉取时间) | 1 |
NodeResourcesFit的 Score 策略有三种模式:
- LeastAllocated(默认):倾向空闲节点,适合大多数场景
- MostAllocated:倾向装满节点,适合节省成本(减少节点数)
- RequestedToCapacityRatio:自定义打分曲线
7. NormalizeScore — 归一化
将各 Score 插件的原始分数归一化到 [0, 100] 范围内,确保不同插件之间可以公平加权。
8. Reserve — 预留资源
Score 选完最优 Node 后,Pod 并不会立即绑定——Bind 是异步的(在 Binding Cycle 中并行执行)。但调度器不会等 Bind 完成,而是立即开始调度下一个 Pod。这就产生了一个问题:
假设 Node-A 还剩 2Gi 内存,Pod-X(请求 2Gi)刚被调度到 Node-A 但还没 Bind 完成。此时 Pod-Y(也请求 2Gi)进入调度,如果调度器不知道 Pod-X 已经"占了" Node-A 的 2Gi,就会把 Pod-Y 也调度到 Node-A——结果超卖了。
Reserve 就是解决这个问题的。 它在 Bind 完成之前,先在调度器的内存缓存中标记资源已被占用,这样后续调度其他 Pod 时能看到这些预留,避免重复分配。这种"先占位、失败再回滚"的策略本质上和乐观锁是同一个思路——假设成功是大概率事件,不阻塞等待,出问题再补偿。
具体来说,内置的 Reserve 插件会做这些事:
VolumeBinding:将选定的 PV/PVC 绑定关系标记为"已预留",防止其他 Pod 抢占同一个 PVNodeResourcesFit(通过调度器缓存):将 Pod 的 requests 计入 Node 的已分配资源
如果后续的 Permit 或 Bind 阶段失败,会触发 Unreserve 回滚——把预留的资源释放回去。
9. Permit — 最后的拦截
Permit 是调度周期的最后一关,可以:
- Approve:允许通过
- Deny:拒绝,Pod 回到队列
- Wait:挂起等待(用于实现 Gang Scheduling 等需要多个 Pod 同时到位的场景)
10. PreBind → Bind → PostBind
进入 Binding Cycle(并行执行):
- PreBind:绑定前的准备工作(如
VolumeBinding在这里真正执行 PV/PVC 绑定) - Bind:调用 apiserver 将
spec.nodeName写入 Pod 对象(默认插件DefaultBinder) - PostBind:绑定完成后的清理工作(纯通知性质,不影响结果)
调度策略与优先级
PriorityClass
PriorityClass 决定了 Pod 在调度队列中的优先级,以及能否触发抢占。
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000000
globalDefault: false
preemptionPolicy: PreemptLowerPriority # 或 Never
description: "用于关键业务 Pod"
---
apiVersion: v1
kind: Pod
metadata:
name: critical-app
spec:
priorityClassName: high-priority
containers:
- name: app
image: my-app:latest
resources:
requests:
cpu: "2"
memory: "4Gi"
系统内置的 PriorityClass:
| 名称 | 值 | 用途 |
|---|---|---|
system-cluster-critical | 2000000000 | 集群关键组件(如 kube-dns) |
system-node-critical | 2000001000 | 节点关键组件(如 kube-proxy) |
抢占机制(Preemption)
当高优先级 Pod 无法调度时,调度器会尝试驱逐低优先级 Pod 来腾出资源。
抢占流程(5 步):
- 触发:Filter 阶段所有 Node 都被过滤掉,进入 PostFilter → DefaultPreemption
- 筛选候选 Node:在每个 Node 上模拟驱逐低优先级 Pod,看是否能腾出足够资源
- 选择最佳 Node:选择需要驱逐最少 Pod、影响最小的 Node(优先选择不影响 PDB 的)
- 驱逐 Victim Pod:给被选中的低优先级 Pod 发送删除请求(优雅终止)
- 重新调度:高优先级 Pod 回到调度队列等待下一轮调度(不是立即绑定,因为被驱逐的 Pod 需要时间终止)
注意:抢占不是立即完成的。高优先级 Pod 设置
nominatedNodeName后,还要等 victim Pod 优雅退出,然后下一轮调度才真正绑定。
抢占逻辑可以自定义吗? 可以。抢占是通过 PostFilter 扩展点实现的,DefaultPreemption 只是默认插件。你可以写自己的 PostFilter 插件来替换它,完全控制抢占策略——比如选择驱逐哪些 Pod、优先保护哪些 Node。
但默认的 DefaultPreemption 已经内置了"最小影响"策略,选择 Node 时的优先级是:
- 不违反 PDB(PodDisruptionBudget)的 Node 优先
- 驱逐 Pod 数量最少的 Node 优先
- 被驱逐 Pod 中最高优先级最低的 Node 优先(尽量驱逐低优先级的)
- 以上都相同时,驱逐 Pod 的 requests 总和最小的 Node 优先
也就是说,默认策略已经是"影响最小"的方案。如果你想写一个"影响最大"的抢占方案(比如测试或特殊场景),技术上完全可以——写一个 PostFilter 插件,反转上面的选择逻辑即可。但生产环境不会这么做。
Q&A
Q1: 怎么保证 Pod 不被调度到资源不足的 Node?
核心插件:NodeResourcesFit
调度器看的是 requests,不是 limits,更不是实际使用量。计算公式:
Node 可分配 = Allocatable - 已调度的所有 Pod 的 requests 之和
如果 Node 可分配 < 新 Pod 的 requests → 过滤掉
这意味着:
- 如果不设 requests,调度器认为 Pod 不需要资源(
requests = 0),可以调度到任何 Node - 如果 requests 设得太大,会浪费资源;设得太小,会导致超卖(overcommit)——这个术语来自航空业(200 座的飞机卖 210 张票),在 K8s 中指 Node 上所有 Pod 的 requests 之和超过了 Node 实际可提供的资源。平时看起来没事(因为 Pod 实际使用量通常低于 requests),但一旦所有 Pod 同时用满,就会内存不足触发 OOM Kill
kubectl top显示的是实际使用量(来自 metrics-server),和调度器无关
最佳实践: requests 应该设置为 Pod 正常运行时的峰值需求——观察监控数据,取能覆盖绝大多数时段(如 99% 的时间都低于这个值)的用量。设为平均值太小,容易超卖;设为历史最大值又太大,浪费资源。limits 设置为极端情况下的硬上限。
Q2: Node Affinity vs Node Selector?
| 维度 | nodeSelector | nodeAffinity |
|---|---|---|
| 表达力 | 只支持精确匹配 | 支持 In, NotIn, Exists, DoesNotExist, Gt, Lt |
| 软/硬约束 | 只有硬约束 | required(硬)+ preferred(软,带权重) |
| 使用场景 | 简单场景:指定 GPU 节点 | 复杂场景:倾向某些 zone 但不强制 |
| 调度器阶段 | Filter | Filter(required)+ Score(preferred) |
# nodeSelector 简单用法
spec:
nodeSelector:
gpu: "true"
# nodeAffinity 复杂用法
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: ["us-east-1a", "us-east-1b"]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: node-type
operator: In
values: ["high-memory"]
Q3: Taint 和 Toleration 的使用场景?
Taint 和 Toleration 是"反向亲和"机制:Node 说"我有毒(Taint)",Pod 说"我能容忍(Toleration)"。
三种 Effect:
| Effect | 含义 |
|---|---|
NoSchedule | 没有对应 Toleration 的 Pod 不会被调度到该 Node |
PreferNoSchedule | 尽量不调度,但不是硬约束 |
NoExecute | 不调度 + 已在运行的 Pod 也会被驱逐 |
典型场景:
# 1. 专用节点:GPU 节点只给 ML 任务用
kubectl taint nodes gpu-node-1 dedicated=ml:NoSchedule
# 2. 节点维护:排空节点
kubectl taint nodes node-1 maintenance=true:NoExecute
# 3. 系统内置 Taint
# - node.kubernetes.io/not-ready (NoExecute)
# - node.kubernetes.io/unreachable (NoExecute)
# - node.kubernetes.io/memory-pressure (NoSchedule)
# - node.kubernetes.io/disk-pressure (NoSchedule)
# - node.kubernetes.io/pid-pressure (NoSchedule)
# - node.kubernetes.io/unschedulable (NoSchedule) — cordon 时添加
Pod 侧的 Toleration:
spec:
tolerations:
- key: "dedicated"
operator: "Equal"
value: "ml"
effect: "NoSchedule"
# 容忍所有 Taint(DaemonSet 常用)
- operator: "Exists"
Q4: PodTopologySpread 是什么?怎么用?
PodTopologySpread 控制 Pod 在拓扑域(zone、node、rack 等)之间的分布均匀性。
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: my-app
关键参数:
maxSkew:允许的最大偏差。maxSkew=1表示各拓扑域的 Pod 数量最多相差 1topologyKey:Node 标签键,定义拓扑域(zone、hostname等)whenUnsatisfiable:DoNotSchedule:硬约束(在 Filter 阶段检查)ScheduleAnyway:软约束(在 Score 阶段打分)
labelSelector:哪些 Pod 参与计算
Q5: 如何自定义调度策略?
三种方式,从轻到重:
方式一:Scheduler Configuration(推荐)
通过 KubeSchedulerConfiguration 调整内置插件的启停和参数。配置结构分两部分:
plugins:按扩展点控制插件的启停。每个扩展点(preFilter、filter、score等)下面列出要启用或禁用的插件pluginConfig:配置插件的参数。一个插件可能同时注册在多个扩展点上(比如NodeResourcesFit同时实现了 PreFilter、Filter 和 Score),参数在这里统一配置
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
plugins:
# ---- 扩展点:Filter ----
filter:
disabled:
- name: NodeUnschedulable # 禁用某个 Filter 插件
enabled:
- name: MyCustomFilter # 在 Filter 扩展点加入自定义插件
# ---- 扩展点:Score ----
# 一个扩展点可以挂多个插件,按 weight 加权求和
score:
disabled:
- name: NodeResourcesBalancedAllocation # 禁用均衡分配打分
enabled:
- name: NodeResourcesFit # 内置插件,调整权重
weight: 2
- name: ImageLocality # 内置插件,倾向已有镜像的 Node
weight: 1
- name: MyCustomScore # 自定义打分插件也加在这里
weight: 3
# ---- 扩展点:PostFilter(抢占)----
postFilter:
enabled:
- name: DefaultPreemption # 默认抢占插件,也可以替换成自定义的
# 插件参数配置(跨扩展点统一配置)
pluginConfig:
- name: NodeResourcesFit
args:
scoringStrategy:
type: MostAllocated # 改为装箱策略(尽量填满节点)
可以看到:扩展点就是前面流程图里的那些阶段(Sort、PreFilter、Filter、Score、Reserve 等),每个扩展点下可以挂多个插件。同一个插件可以出现在多个扩展点中——比如 NodeResourcesFit 在 PreFilter 阶段做快速失败检查,在 Filter 阶段逐 Node 检查资源,在 Score 阶段给 Node 打分,但它的参数(如 MostAllocated)在 pluginConfig 中只配一次。
方式二:自定义 Scheduler Plugin(Go 开发)
实现 framework.Plugin 接口,编译进调度器二进制:
type MyPlugin struct{}
func (p *MyPlugin) Name() string { return "MyPlugin" }
func (p *MyPlugin) Filter(ctx context.Context, state *framework.CycleState,
pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
// custom logic
return framework.NewStatus(framework.Success, "")
}
方式三:Webhook Extender(不推荐,将废弃)
通过 HTTP Webhook 外挂扩展逻辑,延迟高、可靠性差。
方式四:运行多个调度器
部署独立的调度器实例,Pod 通过 spec.schedulerName 指定使用哪个:
spec:
schedulerName: my-custom-scheduler
实战场景
场景一:Pod Pending 但节点有资源(开头的故事)
现象: kubectl top nodes 显示 CPU 使用率才 30%,但 Pod 因 Insufficient cpu 无法调度。
根因: 调度器看 requests,不看实际使用量。其他 Pod 的 requests.cpu 之和已经占满了节点的 Allocatable。
排查路径:
# 1. 看 Pod 事件
kubectl describe pod <pod-name>
# 2. 看 Node 的已分配资源(重点看 Requests 列)
kubectl describe node <node-name> | grep -A 5 "Allocated resources"
# 3. 看所有 Node 的资源分配情况
kubectl get nodes -o custom-columns=\
NAME:.metadata.name,\
CPU_REQ:.status.allocatable.cpu,\
CPU_ALLOC:.status.capacity.cpu
解决方案:
- 审查现有 Pod 的 requests 是否设置过大(常见问题:copy-paste 导致 requests = limits)
- 使用 VPA(Vertical Pod Autoscaler)自动调整 requests
- 增加节点
场景二:三个节点负载严重不均
现象: 三个 Node,一个负载 80%,另外两个只有 10%。
根因: 默认的 LeastAllocated 策略是按 requests 计算的,如果 Pod 的 requests 设得很小(或为 0),调度器认为每个 Node 都很空闲,调度变得接近随机。早期部署的大 Pod 占据了某个 Node 的大量 requests,后续小 requests 的 Pod 被"均匀"分到各 Node,但实际资源消耗差距很大。
解决方案:
- 正确设置 requests:这是根本解决方案。requests 应反映真实资源需求
- 使用 Descheduler:定期重新平衡已运行的 Pod(调度器只管新 Pod,不管已运行的)
- 考虑
MostAllocated策略:先装满一个 Node 再用下一个(适合节省成本的场景)
# Descheduler 配置示例
apiVersion: descheduler/v1alpha2
kind: DeschedulerPolicy
profiles:
- name: rebalance
pluginConfig:
- name: LowNodeUtilization
args:
thresholds:
cpu: 20
memory: 20
targetThresholds:
cpu: 50
memory: 50
场景三:高优先级 Pod 抢占导致线上故障
现象: 运维为某个批处理任务设置了高 PriorityClass,结果触发抢占,驱逐了线上服务的 Pod,导致短暂故障。
根因: 抢占不区分业务重要性,只看 PriorityClass 的数值。如果批处理任务的优先级高于线上服务,它就可以抢占线上服务。
解决方案:
合理规划 PriorityClass 体系:
system-node-critical: 2000001000 (系统组件) system-cluster-critical: 2000000000 (集群组件) production-critical: 1000000 (线上关键服务) production-standard: 500000 (线上普通服务) batch-high: 100000 (批处理高优先级) batch-low: 10000 (批处理低优先级)使用 PDB(PodDisruptionBudget)保护关键服务:
apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: my-app-pdb spec: minAvailable: 2 # 或 maxUnavailable: 1 selector: matchLabels: app: my-app设置
preemptionPolicy: Never:让高优先级 Pod 排队等待而不是抢占apiVersion: scheduling.k8s.io/v1 kind: PriorityClass metadata: name: batch-high-no-preempt value: 100000 preemptionPolicy: Never # 排队但不抢占
场景四:TopologySpreadConstraints 死锁
现象: 集群有 3 个 zone(A/B/C),Deployment 设置了 maxSkew=1、whenUnsatisfiable=DoNotSchedule。Zone C 故障后,扩容的 Pod 全部 Pending。
分析:
假设故障前每个 zone 有 3 个 Pod(共 9 个)。Zone C 故障后,zone C 的 3 个 Pod 被标记为 NotReady 但仍然计入拓扑分布计算:
Zone A: 3 pods
Zone B: 3 pods
Zone C: 3 pods (NotReady)
此时扩容新 Pod,maxSkew=1 要求新 Pod 必须调度到数量最少的 zone(或者各 zone 差距不超过 1)。三个 zone 都是 3 个 Pod,新 Pod 调度到 A 或 B 后 skew 变成 1(可以)。但如果 Zone C 的 Node 被设置为不可调度(cordon / Taint),那 C 的 Pod 数量不会增加,后续持续扩容就会导致:
Zone A: 5 pods
Zone B: 5 pods
Zone C: 3 pods (全部 NotReady,无法调度新 Pod)
继续扩容时,skew = 5 - 3 = 2 > maxSkew(1),新 Pod 只能去 Zone C,但 Zone C 不可调度 → 死锁。
解决方案:
- 使用
whenUnsatisfiable: ScheduleAnyway(软约束代替硬约束) - 设置
nodeAffinityPolicy: Honor+nodeTaintsPolicy: Honor(1.26+ GA),让调度器在计算 skew 时忽略不可调度节点上的 Pod:topologySpreadConstraints: - maxSkew: 1 topologyKey: topology.kubernetes.io/zone whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: my-app nodeAffinityPolicy: Honor nodeTaintsPolicy: Honor - 增大
maxSkew:比如设为 2 或 3,容忍更大的偏差 - 配合
minDomains(1.30+ GA):指定至少考虑多少个拓扑域,避免故障域被全部排除后无处可调度
关键结论
- Pod Pending 时先看
kubectl describe node的 Allocated resources 而不是kubectl top——调度器只认 requests,不看实际使用量,这两个数字可能差几倍。 - 设 requests 时把它当成"你向集群预定的资源",而不是"应用实际用多少"。requests 设为 0 等于告诉调度器"我不需要资源",后果是超卖和被优先驱逐。
- 调度器只管新 Pod 放哪,不管已经跑着的 Pod 分布是否合理。发现负载不均时,去找 Descheduler,别指望调度器自己修。
- PriorityClass 的数字大小直接决定了谁能抢占谁——上线前必须和团队对齐优先级体系,否则批处理任务可能把线上服务挤掉。
TopologySpreadConstraints配了硬约束(DoNotSchedule)又遇到 zone 故障时,很容易死锁。除非你有充分理由,否则优先用软约束(ScheduleAnyway)。
总结
回到开头的故事:Pod Pending 不是因为"没资源",而是因为"没有可调度资源"。理解这个区别,就理解了调度器的核心设计哲学:
- 调度器是声明式的:它基于 requests 做决策,不关心实际使用量
- 调度器是插件化的:Scheduling Framework 的 12 个扩展点覆盖了所有定制需求
- 调度器是保守的:宁可让 Pod Pending,也不冒超卖的风险
- 调度器是一次性的:它只管新 Pod 的调度,不管已运行 Pod 的再平衡(那是 Descheduler 的事)
掌握这些原理,下次再遇到 Pod Pending,你就知道该从哪里查了。