两次节点升级,两种相反的结局

同一个维护窗口,两个团队各升一批节点。

A 团队的 kubectl drain 跑了四十分钟毫无进展,终端里只有同一段话在循环:

$ kubectl drain node-12 --ignore-daemonsets --delete-emptydir-data
node/node-12 cordoned
error when evicting pods/"checkout-66f7c8d94-x2bpm" -n prod (will retry after 5s):
Cannot evict pod as it would violate the pod's disruption budget.
The disruption budget checkout needs 3 healthy pods and currently has 3 healthy pods.

B 团队正相反:三个节点升级得异常顺利,驱逐一路畅通——直到监控里 checkout 服务错误率冲到 100%。事后复盘,该服务三个副本因为历史调度结果全挤在这三个节点上,而它的 PDB 把 app: checkout 拼成了 app: checkouts,一个 Pod 都没选中。

两个事故方向完全相反:一个被预算卡死,一个被预算缺位打穿。根因是同一个——把 PDB 当成了"可用性开关",而它只是一份"计划内维护的预算表"。

主动中断和被动中断,预算只管前者

Kubernetes 把 Pod 中断分成两类,PDB 的适用范围严格限于第一类:

  • 主动中断(voluntary):kubectl drain、节点缩容时 autoscaler 的驱逐、Descheduler 搬迁、Karpenter 整顿节点。这些动作都要经过 Eviction API,PDB 在那里有一票否决权。
  • 被动中断(involuntary):断电、内核 panic、网卡故障、宿主机失联。没有任何组件会"来问一句预算",PDB 一个字节也管不了。

所以先把预期摆正:PDB 保护的是"计划内维护不把服务打穿",不是任何意义上的 SLA。防被动中断靠的是副本在节点和可用区上的分布,那是后文拓扑打散要解决的问题,预算帮不上忙。

eviction 不是 delete:drain 的完整路径

很多事故源于一个误解:drain 只是"换个姿势删 Pod"。差别在中间那道准入:

  1. drain 先 cordon:给节点打上 node.kubernetes.io/unschedulable 污点,新 Pod 不再调度上来;
  2. 对每个受管 Pod,drain 调用的是 Eviction API(POST /api/v1/namespaces/<ns>/pods/<pod>/eviction),不是 DELETE;
  3. API server 检查所有 selector 命中该 Pod 的 PDB:预算不够就返回 429 和 retryAfterSeconds,drain 进入退避重试——A 团队看到的那段循环就是它;
  4. 预算允许,eviction 才转化为一次 DELETE,并按 Pod 自己的 terminationGracePeriodSeconds 走优雅停机(这一环四月的文章聊过,不展开)。

这里有个比死锁更重要的反直觉事实:只有 Eviction API 尊重 PDB。kubectl delete pod、Deployment 滚动更新、HPA 缩容、StatefulSet 按序下线,全都绕开这道准入。所以"没看到 PDB 报错"不代表"操作安全",它经常只代表"这条路压根不查预算"。

minAvailable 还是 maxUnavailable:二选一再填数

PDB 只允许 minAvailable 和 maxUnavailable 二选一:

  • minAvailable:任何时刻至少保住几个健康 Pod,表达的是容量下限;
  • maxUnavailable:任何时刻最多允许几个不可用,表达的是动作上限;
  • 两者都接受整数或带引号的百分比字符串("50%")。

百分比的基数是控制器期望的副本数(对 Deployment 就是 replicas),不是当下活着的 Pod 数;minAvailable 向上取整、maxUnavailable 向下取整。3 副本配 maxUnavailable: "25%" 时,25% × 3 = 0.75,向下取整是 0——预算直接锁死,这是取整规则咬人最狠的一次。

# 在线服务:容量有硬下限,用 minAvailable
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: checkout
  namespace: prod
spec:
  minAvailable: 2          # 3 副本里至少 2 个健康,一次 drain 最多动 1 个
  selector:
    matchLabels:
      app: checkout
---
# 离线 worker:对"动几个"不敏感,用 maxUnavailable
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: report-worker
  namespace: prod
spec:
  maxUnavailable: 1        # 副本日后从 4 扩到 10,这条预算不用跟着改
  selector:
    matchLabels:
      app: report-worker

挑选的经验法则:在线服务用 minAvailable,它直接对应"最少剩多少才能扛住流量";worker、消费者这类少一个不影响正确性的用 maxUnavailable,副本数变化时语义自动适配。

预算有没有余量,不要靠脑算,看状态:

$ kubectl get pdb -n prod -o wide
NAME            MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
checkout        2               N/A                1                     45d
report-worker   N/A             1                  1                     45d

ALLOWED DISRUPTIONS 对应 status.disruptionsAllowed,是 0 时任何走 eviction 的驱逐都会被拒。节点维护前该检查的就是这一列,而不是等 drain 卡在半路才发现。

单副本与 0%:把预算写成死锁

最经典的配置事故长这样:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: legacy-cron-api
  namespace: prod
spec:
  maxUnavailable: 0       # "0%" 完全等价:1 副本 × 0 = 0
  selector:
    matchLabels:
      app: legacy-cron-api   # 该服务恰好是单副本

disruptionsAllowed 永远是 0,任何 drain 到这个 Pod 都会无限重试。出路的优先级:

  1. 把服务变多副本,配合下面的拓扑打散。单副本服务谈"零中断维护"本身就是伪命题,预算只是把矛盾暴露出来;
  2. 接受明确的维护窗口:临时删掉这条 PDB,升完恢复。看起来丑,但这是一个显式的、可审计的决定;
  3. 把这类 Pod 挪到不参与自动节点升级的节点池,用另一套维护节奏。

比死锁更阴的是 selector 选不中任何 Pod——PDB 不报错,只是拦不住任何驱逐,保护静默失效(B 团队事故的另一半)。上线 PDB 后必须核对一次 status:

$ kubectl get pdb legacy-cron-api -o jsonpath='{.status}{"\n"}'
{"currentHealthy":0,"desiredHealthy":0,"disruptionsAllowed":0,"expectedPods":0}
# expectedPods 是 0:selector 什么都没选中,这条 PDB 目前是摆设

不健康的 Pod,要不要绕过预算

另一个别扭的场景:某 Pod 已经 CrashLoopBackOff 二十分钟,节点等着升级,默认语义下这个坏 Pod 一样占用预算——预算满时你连它都清不走,drain 被一个僵尸卡住。unhealthyPodEvictionPolicy(1.26 起稳定)就是补这个洞的:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: checkout
  namespace: prod
spec:
  minAvailable: 2
  # Ready=False 的 Pod 不再计入预算,随时可被驱逐
  unhealthyPodEvictionPolicy: AlwaysAllow
  selector:
    matchLabels:
      app: checkout
  • IfHealthyBudget(默认):不健康 Pod 与健康 Pod 同样计入预算,预算没余量就都不能动,行为保守;
  • AlwaysAllow:不健康的 Pod 绕过预算检查,直接可驱逐。

选哪个的判断标准很朴素:如果服务 readiness 掉了就等于已经不接流量,那让它占着预算毫无意义,AlwaysAllow 更符合直觉;如果 not-ready 的 Pod 还在承担收尾工作(比如 draining 长连接),保守的默认值更安全。

让预算有意义的另外两块:拓扑打散与 surge

PDB 说"一次最多动一个",但它不知道这一个占容量的百分之几。三个副本挤在同一个节点上时,maxUnavailable: 1 允许的那次驱逐就是全灭。要让预算有意义,得保证单节点故障最多带走一个副本:

# Deployment 片段:打散、surge、PDB 三者的分工
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # 发布期间的容量由 surge 兜住
      maxUnavailable: 0
  template:
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway   # 节点级用软约束,给 surge 留余地
          labelSelector:
            matchLabels: { app: checkout }
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule    # 可用区级用硬约束,真正防被动中断
          labelSelector:
            matchLabels: { app: checkout }

三件工具各管一段,互相替代不了:

  • topologySpreadConstraints(zone 维度)防被动中断——zone 断电时 PDB 无能为力,只有分布能救;
  • RollingUpdate maxSurge 保发布期容量——发布走 DELETE 不走 eviction,PDB 管不到它;
  • PDB 管 drain、autoscaler 驱逐这类计划内操作的节奏。

一个组合上的真坑:节点级 spread 用 DoNotSchedule 硬约束时,滚动更新 surge 出来的新 Pod 可能因违反约束而 Pending,发布卡住。节点级 ScheduleAnyway、可用区级 DoNotSchedule 是比较稳的组合。

–force –grace-period=0:一条命令拆三层保险

drain 卡住后,搜索引擎置顶的答案通常是这么一条:

kubectl drain node-12 --ignore-daemonsets --delete-emptydir-data \
  --force --disable-eviction --grace-period=0

能用,但先看清楚它拆掉了什么:

  • --disable-eviction:不走 Eviction API,直接对 Pod 发 DELETE——PDB 从此不在环路里。你失去了唯一一道"多个管理员并发 drain、autoscaler 同时回收时别打穿服务"的防线;
  • --grace-period=0:跳过优雅停机,等效 SIGKILL,在途请求、本地缓冲、未刷盘的日志全部丢失;
  • --force:连不被任何控制器管理的裸 Pod 也照删——它们删了没人重建,这一删是永久的。

三个 flag 各自有正当的排查用途,但组合成"日常升级命令",本质是拿生产换省事。正确顺序永远是修预算(改 PDB、加副本、清掉坏 Pod),而不是拆保险丝。

两个补充。其一,两个管理员同时 drain 不同节点时,两次驱逐的预算检查存在时间差,实际驱逐可能略微超出预算——PDB 是尽力而为的护栏,不是强一致配额器。其二,drain 默认无限重试,别指望它自己超时退出,卡住就是真的卡住。

把 drain 当演习跑,而不是等事故来考

PDB 是少数能低成本演习的稳定性配置,成本只是 staging 一个节点:

# 1. 维护前全集群盘点:预算余量为 0 的提前处理
kubectl get pdb -A -o wide | awk 'NR==1 || $5==0'

# 2. staging 挑一个承载目标服务的节点,完整走一遍 drain
kubectl drain node-stg-03 --ignore-daemonsets --delete-emptydir-data

# 3. 另一个终端盯两件事:预算被消耗的过程 + 重调度落点
kubectl get pdb -n prod checkout -w
kubectl get pods -n prod -o wide -w | grep checkout

通过标准不是"drain 成功了",而是三件事同时成立:drain 在预期时间内收敛;服务错误率没有抬头;被驱逐的 Pod 落到了不同节点。第三件最容易漏——如果重调度又挤回同一个节点,说明拓扑约束没生效,下次被动中断照样全灭。

常见坑点

1. 把 PDB 当高可用方案

断电和内核崩溃不经过 Eviction API。预算之上的副本分布才是防被动中断的那一层。

2. selector 选不中任何 Pod,静默失效

不报错、不拦截、不留事件,expectedPods: 0 几乎是唯一线索。上线 PDB 后核对一次 status 是必做动作。

3. 忘了只有 eviction 查预算

kubectl delete、滚动更新、HPA 缩容都绕开 PDB。“没被 PDB 拦住"不等于安全,可能只是这条路根本不设卡。

4. 百分比取整和副本数漂移

3 副本 × 25% 向下取整成 0 直接死锁;minAvailable: "100%" 在 HPA 把副本从 4 扩到 10 时,绝对语义跟着变。小规模服务用绝对数更可控。

总结

PDB 只回答一个问题:计划内维护时,一次最多能动几个 Pod。

把它放进正确的位置:

  • 走 Eviction API 的操作(drain、autoscaler 驱逐)受预算约束;
  • 被动中断靠拓扑打散兜底,发布容量靠 maxSurge 兜底,都不是 PDB 的职责;
  • 单副本服务要么加副本,要么显式接受维护窗口,别用 0 预算或 –force 自欺。

预算的价值不在 apply 的那一刻,而在下一次节点升级、下一次集群缩容时——drain 是体面收敛,还是把你的服务变成下一个维护窗口事故。


PDB 是给计划内维护立的规矩;断电从来不读你的 YAML。