一、问题背景
交易系统的核心 API 服务以 Deployment 形式跑在 Kubernetes 集群上,平时靠滚动更新(rolling update)发版,预期旧 Pod 在 30 秒优雅退出窗口内 shutdown、新 Pod 接管流量。某次例行发布(v2.3.1)触发滚动更新后,监控却开始报警:部分接口 5xx 比例陡增、响应超时。我们登陆集群一看,发现旧版本的 Pod 并没有按预期被清掉,而是大量堆积在 Terminating 状态,且 AGE 已经显示几十分钟,远超正常的 30 秒。节点资源被这些"死而不僵"的 Pod 占着,新 Pod 调度拥挤、部分直接 Pending,发布流水线也卡在"等待旧副本退出"。
二、故障现象
现场表现非常反直觉:
kubectl get pods显示约 20 个旧 Pod 状态为Terminating,AGE高达 40 分钟以上,普通kubectl delete pod xxx没有任何反应。kubectl describe pod能看到deletionTimestamp已经设置,但 Events 最后一条基本是空或者停留在 “Killing” 之前,说明 APIServer 已经标记删除,Pod 对象却迟迟没被真正移除。- 登上对应节点用
crictl ps/docker ps查看,这些"Terminating"的容器进程其实还活着,端口也仍在监听——它们是"僵尸",不是"已退出"。 - 新 Pod 大量
Pending:调度器认为节点资源已被占用(Terminating 的 Pod 依然计入requests),有油却加不进新车。 - 线上出现诡异 5xx:这些旧容器进程明明还活着、端口还监听,但流量已经进不来——因为 Endpoint 控制器早把它们的 IP 从 Service 的 Endpoints 里摘掉了。于是造成"进程活着、服务已下线"的真空窗口,请求被导向尚未就绪的新 Pod 或干脆无可用后端。
三、排查过程
第一步,先确认 Pod 是不是真的进了删除态。kubectl get pod <p> -o jsonpath='{.metadata.deletionTimestamp}' 有值、{.metadata.deletionGracePeriodSeconds} 也有值,说明对象确实处于"标记删除但等待完成"阶段——Terminating 不等于"正在删",而是"删不动"。
第二步,区分两类卡死:节点失联型 vs 节点健康型。先 kubectl get node 确认节点都是 Ready,节点上的 kubelet 正常工作,排除"kubelet 失联、apiserver 标记删除却没人在节点侧执行"的情况。既然节点健康,问题一定出在"节点侧删不掉"或"对象删除被门闩卡住"。
第三步,查 finalizer。kubectl get pod <p> -o jsonpath='{.metadata.finalizers}' 暴露了端倪:这批 Pod 上挂着团队自研控制器写入的自定义 finalizer(如 example.com/cleanup)。该 finalizer 约定由控制器在清理完外部资源后摘除,但当时控制器因上游依赖故障未能完成 reconcile,finalizer 永不清除——而 K8s 的删除语义规定:只要还有 finalizer 未清空,对象就永远停在 Terminating。这正是"删不动"的第一类根因。
第四步,查 preStop。即便没有 finalizer,仍有大量 Pod 卡着,于是对照 Pod spec 发现 lifecycle.preStop 里 exec 了一段脚本,脚本用 sleep 60 来"等待上游连接优雅排空"。但 Pod 的 terminationGracePeriodSeconds 只有 30 秒。这里有个常被忽略的时序:K8s 删除一个 Pod 时,先发 SIGTERM,然后执行 preStop,且 preStop 的耗时算在 grace period 之内,preStop 完成后才发 SIGKILL。也就是说,30 秒的窗口要先被 preStop 的 60 秒 sleep 吃掉,preStop 永远跑不完,SIGKILL 永远不会发出,容器进程就这么挂着——这是"删不动"的第二类根因,也是现场最致命的一条。
第五步,交叉验证流量真空。kubectl get endpoints <svc> 确认旧 Pod IP 已从 Endpoints 摘掉,解释了为何进程活着却 5xx:它们已被 Service 层逻辑"除名",新请求不会再来,而新 Pod 又因为节点资源被僵尸占着起不来,形成发布中断的死锁。
四、解决方案
止血不能无脑 kubectl delete --force --grace-period=0。强制删除会绕过优雅退出与 finalizer 流程,对有状态卷/有外部资源绑定的 Pod 可能留下孤儿资源,且节点侧容器可能清理不干净。正确做法是分情况处理:
- finalizer 残留、控制器已无法恢复:安全摘除 finalizer,让对象立即进入"可被真正删除"状态,kubelet 随即执行容器停止:
kubectl patch pod <p> -p '{"metadata":{"finalizers":[]}}' --type=merge对多个 Pod 可批量kubectl get pods --field-selector=status.phase=Running | grep Terminating后循环 patch。 - preStop 死等:对已处于删除态的 Pod 无法再改 grace period。稳妥做法是先同样 patch 摘除 finalizer 跳过 preStop 阶段强制退出,同时修复镜像里的 preStop 脚本(去掉长 sleep、改为只做信号转发/快速关闭监听),再重新滚动发版。
- 清理完僵尸后,确认
kubectl get pods无残留 Terminating,新 Pod 正常Running并重新加入 Endpoints,5xx 与 Pending 随即消失。
五、根因分析
本质是对 Pod 删除语义的理解偏差:设置了 deletionTimestamp 不等于立即删除,也不等于 30 秒后一定删除。一个 Pod 真正从集群消失,必须同时满足两个条件——(1)所有 finalizers 被清空;(2)节点侧 kubelet 停掉全部容器,而停止容器又要求 preStop 在 terminationGracePeriodSeconds 内完成、之后 SIGKILL 兜底。这两道"门闩"任一卡住,对象就永久停在 Terminating。其中 preStop 耗时计入 grace period 这一细节最易被忽视:很多人以为 SIGTERM 之后还有整整 30 秒,实际上这 30 秒要先被 preStop 占用。
六、预防措施
- preStop 只做轻量操作:转发 SIGTERM、关闭监听端口、flush 日志,绝不
sleep或等待外部依赖。如确有等待诉求,时长必须明显小于terminationGracePeriodSeconds,并预留 SIGKILL 缓冲(建议 grace ≥ preStop 预估耗时 + 5s)。 - finalizer 必须有控制器兜底:reconcile 逻辑要保证在异常/退出路径上也能清理 finalizer;给控制器加存活探针、健康监控与崩溃告警,避免"写 finalizer 的控制器自己先挂了"。
- 合理设置优雅退出窗口:默认 30s 不够长关闭的业务适当上调,但别无脑拉满——窗口越长,卡死时僵尸存活越久。
- 补齐监控:对持续
Terminating超过 1 分钟的 Pod 告警;节点 NotReady 告警;控制器 Pod 状态告警;发布流水线在滚动后检查 Terminating 残留再判定成功。 - 发布规范:滚动更新显式配置
maxUnavailable/maxSurge,避免一次性驱逐过多副本放大影响面。
七、总结
Terminating 不是"正在删除",而是"删除被卡住"。定位时先看两点:一是 finalizer 是否残留、控制器是否还活着;二是节点是否健康、preStop 是否超时吞噬了优雅窗口。止血优先用 patch 安全摘除 finalizer 让僵尸退场,根治则落在镜像里的 preStop 设计(轻量、不等待)与控制器对 finalizer 的兜底清理上。把删除语义吃透,发布才不会在"旧副本退不掉、新副本起不来"的死锁里翻车。