两个"卡住":在 kind 上把 topologySpreadConstraints 和 PodDisruptionBudget 撞到边界
调度器拒绝放置 Pod 和 PDB 拒绝驱逐 Pod,是两条不同链路上的两个"卡住",报错文本、重试行为和恢复条件都不一样。我在 Windows 11 + Docker Desktop 上用 kind 起了 1 控制面 + 2 工作节点的集群,把两条链路都撞到边界跑了一遍:拓扑约束这一侧,6 副本在 2 个 zone 上以 3/3 落位只用 224 毫秒,把 minDomains 设成 3 之后立刻变成 2/6 就绪、4 个 Pod 长期 Pending;PDB 这一侧,minAvailable: 4 让 6 副本只允许 2 次中断,第 3 次驱逐被 apiserver 以 HTTP 429 拒绝,kubectl drain 在 60.7 秒里重试 12 次全部失败后超时退出。更值得记录的是两者叠加:drain 先 cordon 节点,替换 Pod 因 zone 不可用而 Pending,PDB 又把可用副本压在 4,drain 既等不到驱逐成功也等不到替换就绪。
1. 环境与集群
宿主机 Windows 11 + Docker Desktop 29.4.3(WSL2 后端,内核 6.6.114.1-microsoft-standard-WSL2),32 vCPU / 64 GiB 内存,Docker 引擎分配 32 vCPU / 31.2 GiB。集群用 kind v0.27.0(windows/amd64,go1.23.6)起,节点镜像 kindest/node:v1.32.2,Kubernetes v1.32.2,containerd 2.0.2,节点系统 Debian 12。
$ kind create cluster --config .kind-14.yaml --wait 180s
✓ Preparing nodes 📦 📦 📦
✓ Ready after 10s 💚
从执行到 Ready after 10s 共 165.8 秒,绝大部分花在拉取 1.49 GB 节点镜像上。配置里给两个 worker 打 zone 标签,控制面不打:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: kind14
nodes:
- role: control-plane
- role: worker
labels: {topology.kubernetes.io/zone: zone-a}
- role: worker
labels: {topology.kubernetes.io/zone: zone-b}
结果 3 节点、2 个拓扑域:kind14-control-plane 无 zone 标签且自带 node-role.kubernetes.io/control-plane:NoSchedule 污点,既不参与分布计算也不接受业务 Pod;kind14-worker 是 zone-a,kind14-worker2 是 zone-b。后面所有 0/3 nodes are available 里的 1 node(s) had untolerated taint 说的就是控制面。
没有用 extraPortMappings 做宿主端口映射:Windows 保留端口段会让 bind 失败,而实验只需 apiserver 访问。
2. 正常路径:6 副本在 2 个 zone 上均分
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels: {app: spread-demo}
maxSkew: 1 的语义是:候选域里匹配 Pod 的数量,减去所有合格域中的全局最小值,差值不超过 1。zone-a、zone-b 两个合格域,6 副本的均衡解是 3/3,任何时刻偏差都不超过 1,约束始终可满足。实测落位 3/3(zone-a 3 个、zone-b 3 个)。
把约束拿掉做对照,各跑 3 轮,计时口径是"从 kubectl apply 到 6 个 Pod 全部拿到 spec.nodeName":
| 轮次 | 无约束 | maxSkew=1 / zone |
|---|---|---|
| 1 | 232 ms | 224 ms |
| 2 | 196 ms | 208 ms |
| 3 | 210 ms | 234 ms |
| 中位数 | 210 ms | 224 ms |
差异 14 毫秒,落在轮次间波动范围内。这个规模上拓扑约束的调度开销可以忽略——它在 Filter 阶段对候选节点做一次计数比较,不是全量打分。要注意 whenUnsatisfiable: DoNotSchedule 只在调度阶段生效,对已经在跑的 Pod 没有约束力:约束被破坏(比如 cordon 之后)不会触发迁移,文档建议用 Descheduler 做再平衡。
3. 第一次"卡住":让约束真的不可满足
网上流传一个判据:副本数 > 节点数 × maxSkew 就会 Pending。按这个说法把副本数从 6 拉到 12(2 zone、maxSkew=1,12 > 2 × 1)应该卡住,实测没有:12 个 Pod 全部 Running,6/6 均分。
原因在 maxSkew 的定义里:它约束的是"某个域的数量减全局最小值",而全局最小值随匹配 Pod 总数浮动。只要域数 ≥ 2,任何副本数都能找到偏差 ≤ 1 的均衡解,这个约束本身永远可满足,副本再多也只是需要更多容量,不会因为 skew 而 Pending。
真正能让它不可满足的,是把全局最小值钉死。minDomains 就是干这个的:合格域数量小于 minDomains 时,全局最小值按 0 计算,每个域最多只能放 0 + maxSkew 个。集群只有 2 个 zone,把 minDomains 设成 3:
topologySpreadConstraints:
- maxSkew: 1
minDomains: 3
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels: {app: spread-stuck}
6 副本,每个域上限变成 1,总容量 2。实测 12 秒后 Deployment 报 2/6,2 个 Running、4 个 Pending。Pending Pod 的 kubectl describe 事件原文:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 20s default-scheduler 0/3 nodes are available: 1 node(s) had
untolerated taint {node-role.kubernetes.io/control-plane: }, 2 node(s) didn't match pod
topology spread constraints. preemption: 0/3 nodes are available: 1 Preemption is not
helpful for scheduling, 2 No preemption victims found for incoming pod.
这段信息量比看上去大:
2 node(s) didn't match pod topology spread constraints—— 两个 worker 都"因为分布约束"被排除,不是 CPU、内存或亲和性。这句是拓扑约束独有的指纹,看到它不必再查资源。1 node(s) had untolerated taint—— 控制面因污点被排除,与约束无关。后半段preemption: ... No preemption victims found说明调度器试过抢占,没有可抢占的牺牲者,抢占也救不了约束不满足。
Pod condition 只有一条 PodScheduled=False reason=Unschedulable。4 个 Pending Pod 各产生 1 条 FailedScheduling 事件,合计 4 条,没有刷屏:调度失败后进入退避重试,间隔逐次拉长。
由此可反推一条判据:whenUnsatisfiable: DoNotSchedule 配 maxSkew: 1 时,可调度上限是"合格域数 × (全局最小值 + maxSkew)";合格域少于 minDomains 时全局最小值取 0,上限退化成"域数 × maxSkew"。2 × 1 = 2,正好对上实测的 2/6。这也是那个流行判据唯一成立的场景。上限公式只在 DoNotSchedule 下成立,换成 ScheduleAnyway 就是软约束,Pod 不会被卡住。
4. 第二次"卡住":PDB 拒绝驱逐
PDB 走另一条链路:不是调度器不放行,而是 apiserver 的 eviction 子资源在准入阶段拒绝。被测工作负载 6 副本、同样带 zone 分布约束,PDB 用 minAvailable: 4:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: pdb-demo, namespace: tsdemo}
spec:
minAvailable: 4
selector:
matchLabels: {app: pdb-demo}
容器带 12 秒才就绪的 readinessProbe(sleep 12 && touch /tmp/ready)。真实应用启动本来就要十几秒,而 PDB 的"可用副本"只认 Ready,正是这个延迟决定了驱逐能否连续放行。6/6 就绪后 disruptionsAllowed=2 currentHealthy=6 desiredHealthy=4 expectedPods=6,6 - 4 = 2,账对得上。
先直接用 Eviction API 打,绕开 drain 的重试逻辑,对 zone-a 上的 3 个 Pod 依次发起,间隔 800 毫秒:
----- attempt 1 : pdb-demo-55494d7d9b-6clp7 ----- RESULT: ACCEPTED
----- attempt 2 : pdb-demo-55494d7d9b-vcvpn ----- RESULT: ACCEPTED
----- attempt 3 : pdb-demo-55494d7d9b-z54ls ----- RESULT: REJECTED
Error from server (TooManyRequests): Cannot evict pod as it would violate the pod's disruption budget.
2 接受、1 拒绝。状态码是 429 而不是 403,语义是"现在不行,稍后可能行",这正是 drain 会一直重试的原因。此时 disruptionsAllowed=0 currentHealthy=4。
再看 drain。节点被 cordon 后一次性提交 3 个驱逐请求:
$ kubectl drain kind14-worker --ignore-daemonsets --delete-emptydir-data --timeout=60s
node/kind14-worker cordoned
evicting pod tsdemo/pdb-demo-55494d7d9b-8mtns
error when evicting pods/"pdb-demo-55494d7d9b-8mtns" -n "tsdemo" (will retry after 5s):
Cannot evict pod as it would violate the pod's disruption budget.
pod/pdb-demo-55494d7d9b-8f7wh evicted
pod/pdb-demo-55494d7d9b-bl9mj evicted
...(同一条拒绝重复至第 12 次)
There are pending pods in node "kind14-worker" when an error occurred:
... global timeout reached: 1m0s
error: unable to drain node "kind14-worker" due to error: ... global timeout reached: 1m0s,
continuing command...
There are pending nodes to be drained:
kind14-worker
退出码 1,耗时 60.7 秒。驱逐成功 2 次、被拒 12 次,每 5 秒重试一次,60 秒里 12 次全部撞在同一堵墙上。两次独立复跑:第 1 轮 61.3 秒、重试被拒 11 次,第 2 轮 60.7 秒、12 次,两轮都是 2 次驱逐成功。--timeout 到点后 drain 并不放弃,而是打印 continuing command... 并把节点留在"待排空"名单里。这一点容易误读成"命令卡死了",实际是 drain 在等一个它自己无法创造的条件。
5. 两个"卡住"叠加:drain 永远等不到
上一节结束时集群是 4 个就绪、2 个 Pending、disruptionsAllowed=0 currentHealthy=4。那 2 个 Pending 的正是被驱逐 Pod 的替换者,它们卡在第三个原因上:
0/3 nodes are available: 1 node(s) didn't match pod topology spread constraints, 1 node(s)
had untolerated taint {node-role.kubernetes.io/control-plane: }, 1 node(s) were
unschedulable. preemption: 0/3 nodes are available: 1 No preemption victims found for
incoming pod, 2 Preemption is not helpful for scheduling.
三段判词对应三个节点,一个都不剩:
1 node(s) were unschedulable—— kind14-worker 已被 drain cordon,不再接受新 Pod。1 node(s) didn't match pod topology spread constraints—— kind14-worker2 上已堆了 3 个就绪加 2 个待定,继续放会破坏 maxSkew=1。- 控制面照旧因污点出局。
这就是叠加效应的闭环:cordon 让 zone-a 退出候选,替换 Pod 只能去 zone-b,而 zone-b 已满足不了分布约束,于是替换 Pod Pending;替换 Pod 不 Ready,可用副本停在 4,正好等于 minAvailable,于是 disruptionsAllowed=0;驱逐被拒,第 3 个 Pod 走不掉,drain 重试到超时。三个数字互相咬住:4(可用)= 4(minAvailable)→ 0(允许中断)。
6. 放宽之后恢复
把 PDB 放宽到 minAvailable: 1,允许中断从 0 跳到 3(disruptionsAllowed=3 currentHealthy=4)。重跑 drain:
$ kubectl drain kind14-worker --ignore-daemonsets --delete-emptydir-data --timeout=90s
node/kind14-worker already cordoned
evicting pod tsdemo/pdb-demo-55494d7d9b-8mtns
pod/pdb-demo-55494d7d9b-8mtns evicted
node/kind14-worker drained
退出码 0,6.2 秒完成(另一轮 7.1 秒)。同一个节点、同一批 Pod,唯一变量是 PDB 阈值,结果从"60 秒超时失败"变成"6 秒成功"。
节点仍然 cordon 着,所以 3 个替换 Pod 还在 Pending,uncordon 之后才能落回 zone-a:22.2 秒(另一轮 25 秒)恢复到 3/3。延迟主要来自那 12 秒 readinessProbe 加调度和容器启动。这也说明 drain 完成不等于服务恢复,PDB 保护的是"可用副本不低于阈值",不是"副本立刻补齐"。
7. 关键数字汇总
| 指标 | 数值 |
|---|---|
| 集群创建耗时(kind v0.27.0) | 165.8 s |
| 节点镜像 | kindest/node:v1.32.2(1.49 GB) |
| 节点 / 拓扑域 | 3 节点 / 2 个 zone |
| 6 副本无约束调度(中位) | 210 ms(232 / 196 / 210) |
| 6 副本 maxSkew=1 调度(中位) | 224 ms(224 / 208 / 234) |
| 12 副本 maxSkew=1 结果 | 6/6 全部 Running,0 Pending |
| minDomains=3 + 6 副本结果 | 2 Running / 4 Pending,Deployment 2/6 |
| PDB minAvailable=4 允许中断 | 2(6 - 4) |
| Eviction API 3 次尝试 | 2 接受 / 1 拒绝(429) |
| drain 被 PDB 卡住 | 60.7 s 超时,退出码 1,12 次重试全部被拒 |
| 放宽 PDB 后 drain | 6.2 s,退出码 0 |
| uncordon 后恢复 3/3 | 22.2 s |
小结
拓扑分布约束和 PDB 都会让操作"看起来停住",但停在不同层:前者是 kube-scheduler 的 Filter 阶段返回不可调度,Pod 停在 Pending,现象是 PodScheduled=False;后者是 apiserver 的 eviction 准入返回 429,Pod 还在正常跑,现象是 disruptionsAllowed=0。诊断时先看事件来源,default-scheduler 和 apiserver 的报错前缀完全不同,能立刻分流。
那个流行的"副本数 > 节点数 × maxSkew 就会 Pending"判据,在默认参数下不成立。实测 12 个副本配 2 个 zone、maxSkew=1,仍然 6/6 均分。maxSkew 约束的是"域内数量减全局最小值",全局最小值随副本数浮动,只要域数 ≥ 2 就总能找到均衡解。真正把上限钉死的是 minDomains:合格域不够时全局最小值按 0 算,可调度上限退化成"域数 × maxSkew",这才是 6 副本只能起 2 个的原因。
PDB 这一侧最该记住的是叠加效应。单独看,minAvailable: 4 配 6 副本允许 2 次中断,账很清楚;但 drain 先 cordon 掉一个 zone,替换 Pod 因为分布约束无法落位,可用副本就永久停在 4,disruptionsAllowed 归零,驱逐被拒,drain 重试到超时。60 秒里 12 次重试全部失败,放宽阈值后 6 秒就走完,变量只有一个。排障顺序因此是反的:先确认替换 Pod 有没有地方去,再看 PDB 阈值。
数据来源与检索时间
官方文档检索时间 2026-11-10。所有实测数字来自 2026-11-10 在本机 kind 集群上的运行。
| 编号 | 来源 | URL | 用于 |
|---|---|---|---|
| [1] | Kubernetes 文档《Pod Topology Spread Constraints》 | https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ | maxSkew 语义、minDomains 与全局最小值取 0 的规则、缺失 topologyKey 的节点被跳过 |
| [2] | Kubernetes 文档《Disruptions》 | https://kubernetes.io/docs/concepts/workloads/pods/disruptions/ | 自愿/非自愿中断划分、PDB 只约束走 Eviction API 的操作、drain 的周期性重试 |
| [3] | kind 文档《Quick Start》 | https://kind.sigs.k8s.io/docs/user/quick-start/ | Windows 安装、多节点配置、通过 nodes[].labels 注入 zone 标签 |
核验说明
- 第 2 节的调度耗时是 3 轮独立运行的中位数,每轮都先删干净 Deployment 并等到 Pod 数为 0 再重新 apply;计时口径为"apply 到 6 个 Pod 全部拿到
spec.nodeName",不含容器启动。 - 第 3 节的 12 副本测试与 minDomains 测试是两组独立实验,前者无
minDomains,后者加minDomains: 3,其余字段相同。 - 第 4 节的 drain 场景做了 2 次完整复跑,重试被拒次数分别为 11 和 12,差异来自 5 秒重试间隔与 60 秒全局超时之间的取整。事件文本从
kubectl describe与kubectl get events -o json原样摘录,只做折行处理。 - 第 4 节工作负载带 12 秒 readinessProbe,是为了让"可用副本"在驱逐后有一段时间真的低于阈值;若换成秒级就绪的 pause 容器,PDB 还没来得及收紧,下一次驱逐就可能被放行,那组数据不可重复。
- 未做的事:没有测
ScheduleAnyway下 skew 的实际分布,也没有验证 Descheduler 的再平衡效果,这两项只有文档依据。收尾已执行kind delete cluster --name kind14,删除下载的 kind 二进制与 1.49 GB 的 kindest/node 镜像,宿主机上其余容器未受影响。