PDB 配成 100%:集群维护卡在节点排空

季度维护逐节点排空升级,第三个节点 drain 卡死不动。根因是 order-api 的 PDB 配成 minAvailable 100%,自愿中断额度为零,eviction 永远被拒。改为 maxUnavailable 1 后恢复,并补全 PDB 审计。

问题背景

公司生产 K8s 集群每季度有一次维护窗口:凌晨逐节点 cordon + drain,排空后升级内核和 kubelet,再 uncordon 放回。整个流程演练过多次,前几个节点一向顺畅,所以维护夜的值班安排很简单——一个人盯着 drain 命令往下滚,出问题再叫人。

这次维护共 6 个节点,前两个节点十几分钟排完。第三个节点(worker-03)的 drain 跑起来之后,屏幕上的输出停在一行报错上反复刷新,十几分钟没有推进。窗口总共 4 小时,已经用掉近 1 小时,如果这个节点排不掉,后面 3 个节点和升级本身全部要延期,而下一个可用的维护窗口在两个月后。

故障现象

  • kubectl drain 挂起,反复打印同一类报错:Cannot evict pod as it would violate the pod's disruption budget,涉及 order-api 的 2 个 Pod。
  • 节点本身完全正常:状态 Ready、kubelet 健康、上面其他 30 多个 Pod 陆续被正常驱逐,唯独这 2 个不动。
  • kubectl get pdb -A 显示 order-api 的 ALLOWED DISRUPTIONS 列是 0——不只是这一刻为 0,盯着看几分钟一直是 0。
  • 换一台节点复现:只要哪个节点上跑着 order-api 的 Pod,那个节点就排不掉——问题跟着 Pod 走,与节点无关。
  • 窗口剩余时间不断缩水,业务高峰时段不允许带业务中断做升级。

排查过程

第一步,确认 drain 的报错语义。 报错里写得很直白:eviction 会违反 Pod 的中断预算(disruption budget)。kubectl drain 默认不是直接删 Pod,而是走 Eviction API——这套 API 会先检查 PodDisruptionBudget(PDB),允许才放行。所以节点排不掉不是网络、不是 kubelet、不是权限,而是有人给这组 Pod 设了"不允许中断"的规则。

第二步,看清 PDB 的具体配置。 kubectl describe pdb order-api 输出:minAvailable: 100%,Current Healthy: 2,Desired Healthy: 2,Disruptions Allowed: 0。翻译成预算逻辑:可用性下限是"两个都要在",现实是"两个都在"——没有余量,任何一次自愿中断(eviction)都被拒绝。这个 PDB 的额度是恒定的 0,不管排空等到什么时候都不会变成 1。

第三步,追溯这个配置从哪来。 查变更记录,这个 PDB 是两年前一次线上误删事故后加上的"保险":当时 order-api 重启带来过 40 秒抖动,团队顺手配了个 PDB"防止 Pod 被意外杀掉"。配置人按直觉把 minAvailable 填了 100%——“任何时候都别少一个”,本意是保护,实际语义却是永久禁止一切自愿中断。因为平时没人排空这个节点上的 Pod,两年里它从未被触发过。

第四步,把同类地雷一次摸清。 借这次机会全集群扫了一遍 PDB:除了 order-api,还有 3 个单副本 Deployment 配着 minAvailable: 1(等价于"唯一副本永不中断"),以及 2 个 maxUnavailable: 0 的写法——本质都是零额度。这次它们没添乱,只是因为当晚没跑在要维护的节点上,下次就会轮到。

第五步,评估出路。 三条路:其一,kubectl drain --disable-eviction 绕过 PDB 直接删 Pod——等于拆掉保险再让节点排空,虽然 order-api 有 2 副本、删掉一个服务无感,但这个开关会同时忽略所有 PDB,属于"用大炮打蚊子",不采用;其二,临时调大副本数——把 2 副本扩到 3,minAvailable: 100% 仍是零额度,无效;其三,修正 PDB 本身,让它回到"可用性预算"的语义。评估服务当前处于流量低谷、2 副本结构允许任何时刻少 1 个,选第三条。

解决方案

  1. 当场修复:把 order-api 的 PDB 从 minAvailable: 100% 改为 maxUnavailable: 1——含义是"允许同时中断 1 个,且至多 1 个",2 副本时中断额度恒为 1。patch 落下后 drain 立刻推进,该节点在窗口内完成升级。
  2. 全量清理:维护结束后按排查第四步的清单修复全部零额度 PDB:单副本负载不配阻塞式 PDB(或将 minAvailable 降为 0),多副本负载统一用 maxUnavailable: 1 或 minAvailable: N-1 的写法。
  3. 预检固化:维护窗口开始前增加自动预检脚本——扫描全集群 PDB,输出所有 ALLOWED DISRUPTIONS = 0 的条目;再对"待维护节点上的 Pod ↔ PDB"做一次对账,存在零额度就直接在窗口开始前卡住流程,而不是排到一半才发现。
  4. 演练回归:维护演练脚本加入 PDB 检查项;PDB 变更纳入常规变更评审,禁止再出现"凭直觉填 100%“的配置。

根因分析

直接根因是 order-api 的 PDB 配置为 minAvailable: 100%,中断预算恒为零,节点排空的 eviction 请求全部被拒绝,drain 永久等待。深层根因是把 PDB 理解成了"保护锁"而非"可用性预算”:PDB 的语义从来不是"不许中断",而是"在保住最低可用数的前提下,允许有限度的中断";当最低可用数等于总数时,预算恒为零,保护机制就变成了维护死锁。再叠加缺少 PDB 的定期审计,两年前的临时"保险"无人复核,地雷一直埋着,直到维护窗口踩上。

预防措施

  • 制定 PDB 配置规范:任何 PDB 必须留出至少 1 个中断额度(maxUnavailable ≥ 1 或 minAvailable ≤ 副本数 - 1);单副本负载如确需保护,先扩副本再加 PDB;
  • 维护窗口前自动预检"零额度 PDB"与"待维护节点上的 Pod ↔ PDB 对账",未通过不允许进入窗口;
  • 季度 PDB 审计纳入例行巡检,与容量巡检同表执行;
  • 值班手册补充"排空被 PDB 卡住"的决策顺序:修正 PDB > 扩副本冗余 > 最后才评估 --disable-eviction(并明确其会忽略全部 PDB 的代价)。

总结

这次事故里 PDB 工作得完全正确——它忠实执行了"零中断"的配置,只是配置本身把保护写成了死锁。运维工具里带"预算"“额度"字样的机制(PDB、资源配额、限流),共性是上限不等于永远可用:填成"满"或"零"都会让机制退化。凡是两年前以"保险"名义加上的配置,都值得拿"现在还有效吗、会不会挡住正常运维"两个问题重新过一遍——保护机制的第一课,是让保护与日常运转能共存。

延伸阅读

使用 Hugo 构建
主题 Stack 由 Jimmy 设计