一、痛点开头:一个测试环境引发的半小时煎熬
上周五下午,我想清理一套测试集群里一个没用的 namespace,顺手敲下命令:
kubectl delete ns demo-ns
屏幕上回车之后,我转身去倒杯咖啡,回来一看终端还停在那儿。再等二十分钟,还是没动静。于是我又敲了一遍:
kubectl get ns demo-ns
输出让我眉头一皱:
NAME STATUS AGE
demo-ns Terminating 37m
删了快半小时,它还是 Terminating。更要命的是,这个 namespace 里原本跑着几个 CronJob 控制器,namespace 卡住之后,新版本的控制器因为同名冲突无法重建,连带影响了另外一条数据同步链路。一个不起眼的清理动作,把测试环境的可用性搅成了一锅粥。
我相信不止我一个人踩过这个坑:kubectl delete ns xxx 之后,namespace 永远停在 Terminating,怎么等都不消失。这时候千万别急着拍脑袋加 --force,下面把根因、排查、解法、以及我亲手制造的二次事故,一次性讲透。
二、一句话点透原理:卡住的真相
Kubernetes 里几乎所有资源删除都要走 finalizer(终结器) 流程。当你执行 kubectl delete ns xxx 时,apiserver 并不会立刻把对象抹掉,而是先给 namespace 打上删除时间戳(deletionTimestamp),然后逐个等待 spec.finalizers 列表里的终结器被清空。只有当所有 finalizer 都被对应的控制器回调清理掉,apiserver 才会真正删除这个 namespace。
对于 namespace 来说,正常状态下它的 spec.finalizers 里默认只有一项:kubernetes。这个内置终结器由 apiserver 自己负责清理,正常情况下几秒内就完事。可一旦 namespace 下的某些资源(比如某个 CRD 定义的自定义资源实例、或者某个聚合 APIService 背后的 controller)带着自己的 finalizer 无法被清理,apiserver 就永远收不齐“所有 finalizer 已释放”的信号,namespace 自然永远 Terminating。
最常见的两个元凶:
- 失效的聚合 APIService(aggregated API):某次部署了一个扩展 API 服务(比如
v1alpha1.foo.example.com),后来这个服务的 Pod 被删了、Service 也失效了,但 APIService 对象还注册在集群里。namespace 删除时,apiserver 会去回调这个早已失联的扩展 API 清理资源,自然永远等不到回应。
- CRD 自定义资源实例带 finalizer:某个 CRD 的控制器已经下线,但它创建的资源实例上还挂着 finalizer,删 namespace 时这些实例清不掉,同样把 namespace 拖死。
这里有个容易混淆的细节:你执行 kubectl delete ns xxx 时,命令往往很快就返回了“namespace deleted”,但 kubectl get ns 里它却还在。这是因为 apiserver 收到删除请求后,只负责写入 deletionTimestamp 并等待 finalizer 释放,删除动作本身已经完成、但对象并未真正从 etcd 移除。换句话说,带有 deletionTimestamp 且还有 finalizer 的对象,在 etcd 里依然以 Terminating 状态存在,apiserver 只是不再把它暴露给普通 list/watch,直到 finalizer 清空才做物理删除。理解这一点,就不会被“命令明明成功了却删不掉”的假象困惑。
一句话总结:namespace 卡 Terminating,十有八九是 spec.finalizers 里残留了某个“永远不被清理”的终结器,而背后通常是失效的 APIService 或 CRD 控制器。
三、实战步骤:定位、查证、安全强制删除
1. 确认卡在哪个 finalizer
先把 namespace 的完整 JSON 拉出来看:
kubectl get ns demo-ns -o json > demo-ns.json
cat demo-ns.json | jq '.spec.finalizers'
正常情况下你会看到:
[
"kubernetes"
]
如果这个列表里多出了别的字符串(比如 foregroundDeletion 之外的自定义项),或者虽然有 kubernetes 但命名空间里的资源迟迟清不掉,那就说明有资源在拖后腿。再看看状态里的删除条件:
kubectl get ns demo-ns -o json | jq '.status.conditions'
如果 status.phase 是 Terminating 且 deletionTimestamp 不为空,基本可以断定是 finalizer 阻塞。
2. 查是哪个 APIService 或 CRD 在阻塞
先列一下集群里所有 APIService,重点看状态不健康的(不是 True 的):
kubectl get apiservice | grep -v "True"
对于聚合 API(SERVICE 列不是 Local 的那几个),如果它的 AVAILABLE 是 False,极可能就是元凶。例如:
kubectl get apiservice v1alpha1.foo.example.com -o yaml
重点看 status.conditions 里的 Type: Available 是否 status: False,以及 spec.service 指向的 Service 是否还存在。
如果是 CRD 问题,查一下命名空间里还剩哪些非内置资源:
kubectl api-resources --verbs=list --namespaced -o name \
| xargs -n 1 kubectl get -n demo-ns 2>/dev/null
重点找那些带 metadata.finalizers 的自定义资源实例,用下面的命令逐个命名空间扫一遍:
kubectl get crd -o name |
while read crd; do
echo "== $crd =="
kubectl get "$crd" -n demo-ns -o json 2>/dev/null \
| jq -r '.items[]? | select(.metadata.finalizers!=null) | .metadata.name'
done
3. 安全地移除 finalizer 强制删除
确认 namespace 确实不再承载重要业务后,最稳妥的方式是直接调用 apiserver 的 finalize 接口,把 spec.finalizers 置空。推荐用 kubectl proxy 起一个本地代理,避免手搓证书:
kubectl proxy --port=8001 &
然后准备一个 JSON patch 文件 ns-finalize.json:
{
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {
"name": "demo-ns"
},
"spec": {
"finalizers": []
}
}
接着用 PUT 请求打到 finalize 子资源(注意是 PUT 不是 DELETE,路径带 /finalize):
curl -k -H "Content-Type: application/json" \
-X PUT \
--data-binary @ns-finalize.json \
http://127.0.0.1:8001/api/v1/namespaces/demo-ns/finalize
返回 200 且 namespace 从 kubectl get ns 列表里消失,就说明强制删除成功。
如果你的 kubectl 版本较新,也可以直接 patch(但效果等价于上面,前提是能绕过 RBAC 对 finalize 的限制):
kubectl patch ns demo-ns \
--type=json \
-p='[{"op":"replace","path":"/spec/finalizers","value":[]}]'
注意:直接编辑 spec.finalizers 字段(比如 kubectl edit)是无效的,因为 finalizer 受 apiserver 保护,只能通过 /finalize 子资源提交。
补充一个排查小技巧:如果不确定到底是谁在拖,可以看命名空间的 events 和 controller 日志。虽然 namespace 本身 event 不多,但可以用下面的命令确认是否还有资源残留没清完:
kubectl get all -n demo-ns
kubectl get events -n demo-ns --sort-by=.lastTimestamp
若 kubectl get all 仍列出 Pod、PVC 等资源,说明是命名空间内的资源删除被各自 finalizer 卡住,而不是命名空间级别的 kubernetes 终结器问题——这种情况要先去清那些资源实例,而不是急着 finalize 命名空间。
另外,如果是 APIService 失联,apiserver 自身日志里通常会有类似 failed calling webhook 或 service unavailable 的报错,配合 kubectl get apiservice 的 False 状态,基本能锁定元凶。
四、生产环境注意:别无脑强制删
强制清空 finalizer 等于告诉 apiserver“别等了,直接删”,但那些原本该由控制器清理的资源(PV、外部负载均衡器、云盘)并不会被回收,会留下孤儿资源。所以生产环境务必遵循下面三件事:
-
先清理根源,再动 namespace。如果是失效的 APIService 在阻塞,先把它删掉或恢复后端服务:
kubectl delete apiservice v1alpha1.foo.example.com
如果是 CRD 控制器下线导致实例清不掉,先手动删除那些自定义资源实例,或临时恢复控制器让它回收 finalizer,再删 namespace。
-
强制删除只能是最后手段。只有确认 namespace 内已无有状态、无外部依赖的资源时,才走 /finalize。删除前用 kubectl get all -n demo-ns 和上面的 CRD 扫描脚本兜底确认一遍。如果命名空间里还挂着 PVC,强制 finalize 后这些 PVC 对应的后端存储卷不会自动回收,需要到存储控制台手动清理,否则会持续产生费用。
-
多集群命名空间治理。在数十个集群、数百个 namespace 的环境里,建议把命名空间生命周期纳入 GitOps 管控:删除前强制跑一遍 finalizer 检查脚本,并把 APIService/CRD 的注册与命名空间解耦,避免“扩展 API 失联就拖死一堆命名空间”。对长期 Terminating 的命名空间做监控告警,别等它影响业务才被发现。
五、踩坑实录:我亲手制造的二次事故
事故一:用 proxy 改 JSON,把正常 finalizer 也误删了
有次我急着删一个测试 namespace,图省事直接用 kubectl proxy 改 JSON。当时 namespace 的 spec.finalizers 里其实有两项:
[
"kubernetes",
"demo.example.com/cleanup"
]
我本意只想删掉那个自定义的 demo.example.com/cleanup,结果手快把整个列表替换成了空数组 [],连内置的 kubernetes 一起干掉了。PUT 之后 namespace 确实瞬间消失,但问题来了:那个自定义清理器原本负责在删除前把命名空间里的一个外部对象存储桶排空,finalizer 被我提前抹掉,清理逻辑压根没触发,桶里约 5MB 的临时数据成了孤儿,直到一周后存储成本异常才被发现。这次教训让我记住:强制 finalize 时,JSON 里至少保留 kubernetes,只移除阻塞的那个自定义 finalizer,千万不要一把梭全清空。
事故二:用了 --force --grace-period=0 却仍卡住
另一个经典困惑:明明已经执行了
kubectl delete ns demo-ns --force --grace-period=0
namespace 还是 Terminating。很多人以为 --force 能“强杀”,其实它只影响 Pod 等非命名空间作用域资源的优雅退出时间,对 namespace 自身的 finalizer 流程没有任何作用。namespace 级删除是否完成,只取决于 spec.finalizers 是否被清空,和 grace-period 无关。所以我后来再遇到卡住,第一步永远是看 spec.finalizers,而不是反复敲 --force。
六、总结 + Namespace 卡 Terminating 排错 Checklist
核心结论:namespace 卡 Terminating 不是 apiserver 的 bug,而是 finalizer 机制在“等一个永远等不到的回调”。排查顺序永远是:看 finalizer → 定位阻塞的 APIService/CRD → 先清根源 → 最后才考虑强制 finalize。
下面这份清单建议直接收藏,下次遇到照着走:
- 确认状态:
kubectl get ns <ns>,看 STATUS 是否 Terminating、AGE 是否异常偏大。
- 看 finalizer:
kubectl get ns <ns> -o json | jq '.spec.finalizers',找出非 kubernetes 的残留项。
- 查 APIService:
kubectl get apiservice | grep -v True,定位 AVAILABLE=False 的聚合 API。
- 查 CRD 实例:用上面的 CRD 扫描脚本,找出命名空间内带 finalizer 的自定义资源实例。
- 先清根源:删除失效的 APIService,或恢复/手动清理阻塞的 CRD 控制器,让 finalizer 自然释放。
- 兜底强制删:确认无孤儿风险后,用
kubectl proxy + PUT /finalize 提交只清空阻塞 finalizer 的 JSON(保留 kubernetes)。
- 不要滥用
--force:记住它对 namespace 的 finalizer 无效,别被它迷惑。
- 善后核查:删除后用
kubectl get all -n <ns> 和存储/负载均衡控制台确认没有残留孤儿资源。
排错没有银弹,但套路可以复用——把 finalizer 这个机制吃透,namespace 再也不会卡你半小时。类似的 Kubernetes 排错经验,也欢迎到 云栈社区 持续交流。
相关命令速查
# 起本地代理
kubectl proxy --port=8001 &
# 强制 finalize(保留 kubernetes 时改成只删自定义项)
curl -k -X PUT -H "Content-Type: application/json" \
--data-binary @ns-finalize.json \
http://127.0.0.1:8001/api/v1/namespaces/demo-ns/finalize
# 查不健康的 APIService
kubectl get apiservice | grep -v True