KernelRanch
返回文章列表

两个"卡住":在 kind 上把 topologySpreadConstraints 和 PodDisruptionBudget 撞到边界

2026-11-10 #运维

调度器拒绝放置 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
1232 ms224 ms
2196 ms208 ms
3210 ms234 ms
中位数210 ms224 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 后 drain6.2 s,退出码 0
uncordon 后恢复 3/322.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 镜像,宿主机上其余容器未受影响。