先给结论
你观察到的两个现象不是同一件事,拆开看就清楚了:
- PDB 只约束「经 API Server 的驱逐路径」(NodeController 调 evict 接口、
kubectl drain),它不约束云厂商节点侧直接 kill 容器的路径。Spot 回收时你看到的 30s 被截断,大概率不是 K8s 把 grace period 砍了,而是云厂商节点侧的实例回收流程在「K8s 驱逐路径」之外又走了一遍 SIGTERM/SIGKILL,而阿里云 ECS 节点侧的默认容器优雅退出窗口就是 30s 量级(systemd 的DefaultTimeoutStopSec默认 90s,但 ECS 抢占回收的--stop-timeout通常更短,且 containerd 侧stop_timeout默认 10s)。 - PDB 在 K8s 驱逐路径上是真的会拦住的:NodeController 调
POST /apis/policy/v1/namespaces/{ns}/pods/{pod}/eviction,API Server 的 PodDisruptionBudget admission 会校验,若disruptionsAllowed=0则返回 429 +retryAfter,NodeController 退避重试——它不会绕过 PDB 强杀。你看到的「副本数掉到 8」更可能是:PDB 放行了一部分驱逐(disruptionsAllowed≥1),被驱逐 Pod 走 30s 节点侧路径被秒杀,K8s 侧status.phase=Running的 Pod 实际已不可用,Deployment 控制器看到「Ready 数 < minAvailable」又补 Pod,瞬时形成 8 副本窗口。
链路图(谁说了算)
Spot 回收事件
├─ 路径 A:云厂商节点侧(阿里云 ECS 抢占回收)
│ IMDS 5min 通知 → 节点侧 shutdown 流程
│ → 容器 SIGTERM → 等待 stop_timeout(通常 10~30s)→ SIGKILL
│ ★ 此路径不经过 API Server,PDB 完全无效
│
└─ 路径 B:K8s 集群侧
节点 NotReady(kubelet 心跳断)
→ NodeController 检测到 node 被 cordon
→ 调 evict API 驱逐 Pod
→ API Server PDB admission 校验
├─ disruptionsAllowed ≥ 1 → 驱逐成功,grace period 走 Pod.spec.terminationGracePeriodSeconds
└─ disruptionsAllowed = 0 → 429 拒绝,NodeController 退避重试
★ PDB 生效边界:仅在此路径,且只决定「能不能驱逐」,不延长 grace period
关键区分:PDB 拦的是「能不能驱逐」,不是「驱逐后等多久」
terminationGracePeriodSeconds=90是 Pod 级字段,在 evict 请求被接受后生效,kubelet 负责执行;- PDB 的
maxUnavailable控制的是「同时可以驱逐几个」,不能把 90s 拉长,也不能阻止单个被放行的 Pod 走 90s; - 你主动
kubectl drain时 90s 完整走完,是因为 drain 走的是路径 B(evict API),PDB 放行后 kubelet 正常执行 grace period; - Spot 回收时 30s 被截断,是因为路径 A(云厂商节点侧)先于路径 B 执行了 kill——两条路径并发竞争,节点侧赢了。
验证方法(可复现)
-
模拟 Spot 回收(路径 A):
# 在测试节点上直接模拟云厂商行为 # 先 cordon,再强制删除节点(跳过 K8s 驱逐) kubectl cordon <node> # 在节点上执行(模拟云厂商 shutdown) crictl stopp <pod-id> --timeout 30 # 观察 Pod 从 SIGTERM 到 kill 的实际耗时如果 30s 内被 kill,说明节点侧路径确实绕开了 K8s。
-
抓 PDB 判定结果(路径 B):
# API Server 侧开 PDB 审计日志 # 在 audit-policy 里加: # level: RequestResponse # resources: pods/eviction # 过滤 evict 请求的 response,看 429 的 retryAfter 值 # 或直接看 NodeController 日志 kubectl logs -n kube-system kube-controller-manager-<node> \ | grep -E "eviction|disruption" # 关键日志: # "Evicting pod" / "Pod eviction failed: ..." # "Disruption budget ... not satisfied" -
确认哪条路径先执行:
# 在 Pod 里加 preStop hook,打时间戳 lifecycle: preStop: exec: command: ["/bin/sh", "-c", "date +%s%N > /tmp/prestop_ts; sleep 90"] # 对比 /tmp/prestop_ts 与 kubelet 日志里 SIGTERM 时间 # 如果 preStop 没跑完就被 kill,说明路径 A 先执行
兜底方案(成本可量化)
| 方案 | 成本 | 效果 | 推荐度 | |------|------|------|--------| | 关键服务钉在按量池(nodeSelector / taint) | 多付 ~40% 算力成本(按量 vs Spot 价差) | 完全避开路径 A,PDB 100% 生效 | ★★★ 核心服务首选 | | Karpenter 做二次调度(Spot 池 + 按量池混合) | Karpenter 运维成本 + 按量池预留成本 | Spot 被抢后 Karpenter 自动补按量节点,drain 窗口可控 | ★★★ 非核心服务 | | DaemonSet 抢占守卫(监听节点 NotReady,主动 cordon + 延长 drain) | 开发成本 ~2 人天,运维成本低 | 把路径 A 的 30s 窗口拉长到 90s+,但不能延长 grace period 本身 | ★★ 辅助手段 | | preStop hook 主动延长 | 开发成本低 | 只能拉长「应用层 drain」,不能阻止节点侧 SIGKILL | ★ 治标 |
我的推荐:核心交易链路(你那 12 副本 Java API)直接钉在按量池,用 nodeSelector: instance-type: on-demand 或 taint/toleration 隔离。Spot 池留给非核心批处理、CI/CD、开发测试。成本上,12 副本 × 16C64G 按量 vs Spot 价差约 40%,但换来的是 PDB 100% 生效 + grace period 不被截断,这个钱比 oncall 复盘 4 次 Spot 事件的成本低得多。
一句话总结:PDB 在 K8s 驱逐路径(evict API)上 100% 生效,但云厂商节点侧的 Spot 回收路径(直接 kill 容器)会绕开 PDB;你看到的 30s 截断不是 K8s 干的,是云厂商节点侧先赢了。核心服务钉按量池是最稳的兜底。