结论先行:
PodDisruptionBudget(PDB)只约束「自愿驱逐」这条路径(kubectl drain、集群升级、cordon、云厂商维护事件),对「非自愿驱逐」(节点失联、内核 panic、硬件故障、Spot 实例回收)完全无效。我们 6 副本的服务在一次节点 drain 中被压到 2 副本,根因不是「少配了 PDB」,而是调度层面副本根本没跨 AZ 打散 + drain 的 eviction 超时和 preStop 没对齐——三件事凑在一起才出事。这篇文章把 PDB 的机制边界、和topologySpreadConstraints的分工、以及 drain 超时链路一次讲清,给一份能直接抄的可用性三件套。
一、现象与背景:一次「计划内维护」打出来的 14 分钟 P99 事故
先交代规模和数字。我们的订单网关:Kubernetes 1.28.9 + containerd 1.7.13,12 节点(8C32G,3 个可用区 cn-hangzhou-a / -b / -c,每 AZ 4 台),服务副本数 6(requests: cpu=1, memory=2Gi),白天稳态 QPS 约 2400,P99 基线 85ms。
上周四 20:30,一台 -b 区节点要做内核补丁升级,运维执行了标准的 kubectl cordon + kubectl drain。预期影响:该节点上的 2 个副本被驱逐,剩余 4 副本继续服务,P99 允许短暂上浮到 150ms 以内。
实际结果:
- 副本数从 6 掉到 2,而不是 4——因为 6 个副本里有 4 个本来就落在被 drain 的这台节点和同 AZ 的另外两台节点上(调度策略只写了
podAntiAffinity,没有跨 AZ 打散约束); - 2 副本扛 2400 QPS,P99 从 85ms 飙到 3.8s,持续 14 分钟,直到剩余 2 个副本的 Pod 在另外两个 AZ 调度起来并 ready;
- 更糟的是 drain 过程中卡了 3 分钟不动,运维在 Slack 里问「为什么 drain 卡住」,最后用
--force强推才完成——这一步本身又是另一个坑(见第四节)。
复盘后我们确认三件事:调度没打散、没配 PDB、eviction 超时链路没对齐。三者任何一个单独存在,事故都不会到 14 分钟、P99 3.8s 这个量级。
二、原理:PDB 到底挡什么、不挡什么,以及 drain 为什么会卡
2.1 PDB 的作用域:只对「自愿驱逐」生效
K8s 的 Pod 终止分两条路径:
| 路径 | 触发场景 | PDB 是否拦截 |
|---|---|---|
| 自愿驱逐(voluntary) | kubectl drain、集群升级(kubeadm/k0s/云厂商控制面维护)、cordon 后手动驱逐、云厂商计划内维护事件 | ✅ 是 |
| 非自愿驱逐(involuntary) | 节点 NotReady、内核 panic、硬件故障、Spot 实例回收、kubelet 资源驱逐(内存/磁盘) | ❌ 否 |
PDB 的工作机制是:自愿驱逐的发起方(drain 控制器、cluster-autoscaler、云厂商 agent)在执行驱逐前调用 eviction API(/pods/eviction),这个 API 会先检查 PDB,如果驱逐后存活副本数低于 minAvailable,就返回 429(Too Many Requests),驱逐被推迟而不是被拒绝——发起方会重试,直到有足够副本可用或超时。
非自愿驱逐走的是另一条路:kubelet 检测到节点异常后直接走 pod eviction 流程,不经过 PDB 检查,直接删 Pod。所以「配了 PDB 节点失联就没事」是错误的安全感——节点失联时 PDB 一个字节都帮不上忙。
2.2 drain 为什么会卡住:PDB 和 eviction 超时的相互作用
kubectl drain 的驱逐流程是:
- 给节点加
NoScheduletaint(cordon 已做); - 遍历节点上的 Pod,逐个调用 eviction API;
- eviction API 检查 PDB:如果违反,返回 429,drain 会等待重试,默认超时是
--pod-eviction-timeout(默认 5m0s); - 超时后,drain 默认放弃该 Pod(不加
--force的话),报错unable to delete Pod,但继续处理下一个 Pod。
我们的事故里,drain 卡 3 分钟的直接原因是:PDB 没配,但 podAntiAffinity 让 4 个副本都挤在同 AZ,drain 这台节点上的 2 个副本被驱逐后,新副本要调度到另外两个 AZ——而那两个 AZ 的节点因为 requests 总和已经接近节点容量(allocated 92%),新 Pod Pending 了 2 分钟才找到可调度节点。drain 本身没卡,是新副本调度慢让「可用副本数」迟迟回不到 4,P99 才一直压着。
2.3 为什么 PDB 不能替代跨 AZ 打散
PDB 的语义是「驱逐时保证至少 N 个副本活着」,它是被动防线——驱逐发生时才生效,不改变副本的初始分布。如果 6 个副本本来就 4 个在 -b、2 个在 -a、0 个在 -c,那 -b 整区故障(交换机抖动、机房断电)时,PDB 一个都救不了你——因为被驱逐的不是 1 个 Pod,是 4 个,PDB 的 minAvailable=4 在这种场景下直接失效(4 个同时被驱逐,存活 2 个,低于 minAvailable,但 PDB 只能推迟「逐个」驱逐,不能阻止「整批」失联)。
真正的防线是主动打散:让副本从一开始就均匀分布在多个 AZ,这样任何单 AZ 故障最多打掉 1/N 的副本。这正是 topologySpreadConstraints 的价值——它约束的是调度时的分布,PDB 约束的是驱逐时的底线,两者是互补关系,不是替代关系。
三、具体做法:可用性三件套(调度打散 + PDB 底线 + drain 超时对齐)
3.1 第一件:topologySpreadConstraints 跨 AZ 打散
# Deployment spec.template.spec
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway # 关键:容量不足时不阻塞,降级为「尽量」
labelSelector:
matchLabels:
app: order-gateway
maxSkew: 1 的语义是「任意两个 AZ 间的副本数差最多 1」——6 副本 3 AZ 的结果是 2/2/2。whenUnsatisfiable: ScheduleAnyway 是关键:如果某个 AZ 容量不足,不要让整批 Pod 全部 Pending(这是 DoNotSchedule 的坑),而是降级为软约束,先保证服务能起。
3.2 第二件:PDB 兜底
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: order-gateway-pdb
namespace: production
spec:
minAvailable: 4 # 6 副本,允许最多 2 个同时不可用
# 或者用比例:
# maxUnavailable: 1
selector:
matchLabels:
app: order-gateway # 必须是 Pod 的 label,不是 Deployment 的 label
配置原则:minAvailable 取「能扛住峰值流量的最小副本数」。我们的网关 2400 QPS,单副本极限约 800 QPS(压测数据),所以 3 副本是底线,留 1 副本余量 → minAvailable: 4。
3.3 第三件:drain 超时链路对齐
drain 的 --pod-eviction-timeout 必须 ≥ preStop + 优雅关闭时间,否则会出现「drain 提前 SIGKILL,in-flight 请求被切断」:
# 标准 drain 命令(运维 runbook 里固定)
kubectl drain <node> \
--ignore-daemonsets \
--delete-emptydir-data \
--pod-eviction-timeout=120s \ # ≥ preStop(30s) + 优雅关闭(60s) + 余量(30s)
--force # 最后兜底,但会绕过 PDB——见第四节
同时确认 Deployment 的 terminationGracePeriodSeconds 和 preStop 时长匹配:
# Deployment spec.template.spec
terminationGracePeriodSeconds: 90 # 总窗口
containers:
- name: order-gateway
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 30"] # 等 iptables 规则下发 + Envoy 摘流
# 应用层优雅关闭预算:90 - 30 = 60s
关键判据:--pod-eviction-timeout ≥ terminationGracePeriodSeconds。如果 eviction timeout 比 grace period 短,kubelet 会在应用还没完成优雅关闭时就 SIGKILL,P99 尖刺不可避免。
3.4 监控:把「可用副本数」做成一等指标
# 可用副本数(Ready 的 Pod 数)
kube_pod_status_ready{condition="true", namespace="production", pod=~"order-gateway.*"} and on(pod) kube_pod_labels{namespace="production", pod=~"order-gateway.*"}
# 告警:可用副本数 < minAvailable 持续 2 分钟
# 告警:drain 进行中且可用副本数 < minAvailable(PDB 正在阻塞)
四、踩过的坑与反直觉之处
坑 1:--force 会绕过 PDB。我们在 drain 卡住时加了 --force,结果 PDB 的 minAvailable=4 保护被完全跳过——--force 的语义是「即使违反 PDB 也驱逐」。这是运维 runbook 里必须标注的:--force 是最后手段,用之前必须确认「当前可用副本数 ≥ minAvailable」,否则就是在主动制造容量事故。
坑 2:PDB 的 selector 必须匹配 Pod 的 label,不是 Deployment 的 label。我们最初把 PDB 的 selector.matchLabels 写成了 Deployment metadata 的 label(app.kubernetes.io/name: order-gateway),而 Pod template 的 label 是 app: order-gateway——两者不一致,PDB 静默不生效(不报错,只是匹配不到任何 Pod)。验证方法:kubectl get pdb -o yaml 看 status.disruptedPods,如果驱逐时这个字段为空,说明 PDB 没匹配到 Pod。
坑 3:podAntiAffinity 的「打散」是假象。我们之前以为 podAntiAffinity 的 preferredDuringScheduling 权重 100 就够用了,实际它只保证「尽量别跟同 label 的 Pod 挤一个节点」,不保证跨 AZ 均匀——这正是苏亦舟-163 在另一篇文章里详细论证过的点:「反亲和的『打散』是按节点去重的,不是按可用区去重的」。这次 drain 事故里,6 副本 4 个落同 AZ,就是反亲和「打散了节点、没均衡可用区」的直接后果。
坑 4:minAvailable 设太高,drain 永远完不成。如果 6 副本设 minAvailable: 6,那 drain 任何一台有该服务 Pod 的节点都会被 PDB 阻塞,直到 --pod-eviction-timeout 超时——节点永远 drain 不完。minAvailable 的合理上限是「总副本数 - 单节点最大副本数」,我们的场景是 6 - 2 = 4,恰好也是流量底线。
坑 5:PDB 对 Job / CronJob 无效。PDB 只作用于「长期运行的 Pod」,Job 类型的 Pod 被驱逐后由 Job 控制器重建,不走 PDB 检查——所以批处理任务不要指望 PDB 保护,用 activeDeadlineSeconds + backoffLimit 控制重试。
五、结论与可复用清单
一句话结论:PDB 是「驱逐时的底线」,topologySpreadConstraints 是「调度时的分布」,drain 超时链路是「执行时的时序」——三者各管一段,缺任何一段都会在某个故障场景下露馅。PDB 不是万能的可用性保险,它只是一道「自愿驱逐」的闸门;节点失联、整区故障、Spot 回收这些「非自愿」场景,只有跨 AZ 打散 + 副本冗余才能兜住。
可复用清单(按优先级):
- 先查副本分布:
kubectl get pods -l app=<svc> -o wide --sort-by=.spec.nodeName,确认是否跨 AZ 均匀分布。如果 4 个副本挤在一个 AZ,优先加topologySpreadConstraints(maxSkew: 1,whenUnsatisfiable: ScheduleAnyway),这一步的收益远大于配 PDB。 - 再配 PDB:
minAvailable取「流量底线 + 1 副本余量」,selector必须匹配 Pod 的 label(不是 Deployment 的 label),用kubectl get pdb -o yaml的status.disruptedPods验证是否生效。 - 对齐 drain 超时:
--pod-eviction-timeout≥terminationGracePeriodSeconds,运维 runbook 里固定命令模板,禁止随手加--force(会绕过 PDB)。 - 监控可用副本数:把「Ready 副本数 < minAvailable」做成 P1 告警,把「drain 进行中且副本数不足」做成 P2 告警。
- 明确 PDB 的边界:团队内部对齐「PDB 不保护节点失联/整区故障/Spot 回收」,避免「配了 PDB 就安全了」的错误安全感——真正的可用性来自副本冗余 + 跨 AZ 打散 + 优雅下线三件套,PDB 只是其中「驱逐时」那一小段的保险。
落地成本:topologySpreadConstraints 4 行 YAML + PDB 8 行 YAML + drain runbook 加一条命令参数 + 2 条 Prometheus 告警,总计约 0.5 人天。对比这次 14 分钟 P99 3.8s 的业务影响(按 2400 QPS × 14min × 错误率 12% 估算,约 3.4 万笔请求受影响),这笔账怎么算都划算。