两次节点升级,两种相反的结局
同一个维护窗口,两个团队各升一批节点。
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"。差别在中间那道准入:
- drain 先 cordon:给节点打上
node.kubernetes.io/unschedulable污点,新 Pod 不再调度上来; - 对每个受管 Pod,drain 调用的是 Eviction API(
POST /api/v1/namespaces/<ns>/pods/<pod>/eviction),不是 DELETE; - API server 检查所有 selector 命中该 Pod 的 PDB:预算不够就返回 429 和 retryAfterSeconds,drain 进入退避重试——A 团队看到的那段循环就是它;
- 预算允许,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 都会无限重试。出路的优先级:
- 把服务变多副本,配合下面的拓扑打散。单副本服务谈"零中断维护"本身就是伪命题,预算只是把矛盾暴露出来;
- 接受明确的维护窗口:临时删掉这条 PDB,升完恢复。看起来丑,但这是一个显式的、可审计的决定;
- 把这类 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。