Kubernetes 存储排障:PV 泄漏与 Multi-Attach 修复
我维护一套自建的三节点 Kubernetes 集群,上面跑着几个内部服务和 CI 的构建缓存。今年初我给存储组件做了一次常规升级,本以为只是滚动重启,第二天早上却收到告警:一个 StatefulSet 的三个 Pod 全部卡在 ContainerCreating,两个 PVC 卡在 Terminating 超过十小时,而后端对象存储里还静静躺着三条没有任何人引用的孤儿卷。我实际排查了六个小时才收尾,最后发现根因既不是网络也不是磁盘,而是三件最容易被忽略的事:PV 回收策略的 Finalizer 行为、RWO 卷的跨节点独占锁,以及「先删 PV 再删 PVC」这个顺序错误。
先说明:本文包含 Amazon 联盟链接,你通过这些链接下单我会拿到少量佣金,而你支付的价格不变。文中涉及的生产域名、卷 ID 和节点名均已脱敏,命令在 Kubernetes 1.35 与 1.36 上实测通过。
⏳ 太长不看版(TL;DR)
- Terminating 不是故障,是保护机制在等条件满足;先去确认谁还占着它,不要上来就 patch 掉 Finalizer。
- 回收策略没被执行,多数是 CSI external-provisioner 版本低于 v5.0.1,Finalizer 压根没被加上。
- RWO 卷同一时刻只能挂在一个节点上;节点挂掉后残留的 VolumeAttachment 会让新 Pod 永远卡住。
- 用 Deployment 加 RWO 卷做 RollingUpdate,多副本场景几乎一定死锁,改成 Recreate 或 StatefulSet。
- 先删 PVC 再删 PV 是正确顺序;反过来会让后端卷泄漏,而且全程没有任何报错。
- 预防三件套:固定删除顺序、有状态负载用 StatefulSet、给 PV 与 VolumeAttachment 做告警。
前置准备:版本口径与一次可回滚的快照
我实际使用的环境是自建集群(kubeadm 部署)加 CSI 存储插件,版本口径截至发稿:
| 组件 | 版本 | 说明 |
|---|---|---|
| Kubernetes | 1.37(2026-08-26 发布) | 当前维护 1.37 / 1.36 / 1.35 三条分支;1.34 将于 2026-10-27 EOL |
| CSI external-provisioner | v5.0.1 及以上 | 低于此版本不会为新 PV 打上回收策略 Finalizer |
| kubectl | 与集群同小版本 | 排查命令全部基于原生 kubectl 与 jq |
| 存储 | 任意 CSI 驱动 | 需要 ReadWriteOnce / ReadWriteMany 语义 |
动任何存储对象之前,先把现场固定下来,下面几条命令是只读的,不会改变状态:
kubectl get pv,pvc -A
kubectl get volumeattachment -o wide
kubectl get pv -o yaml > /tmp/pv-snapshot.yaml
kubectl get pvc -A -o yaml > /tmp/pvc-snapshot.yaml
为什么「先删 PV 再删 PVC」会泄漏后端卷
这里涉及 KEP-2644,标题是「始终遵守 PersistentVolume 回收策略」。它的原始问题是:当一个 PV 与 PVC 处于 Bound 状态时,删除顺序会决定回收策略是否被执行。先删 PVC 再删 PV,回收流程正常;但如果先删 PV 再删 PVC,回收流程会因为 PV 对象已经从 API Server 消失而拿不到最新状态,最终不触发后端卷删除——也就是卷泄漏,而且全程没有任何报错,只有账单和存储配额在悄悄增长。
这个特性在 1.23 以 alpha 引入,需要通过 HonorPVReclaimPolicy 特性门控显式开启;1.31 转为 beta 并默认开启,1.33 达到 GA。实现方式是在 CSI 卷的 PV 上补一个 Finalizer:
finalizers:
- kubernetes.io/pv-protection
- external-provisioner.volume.kubernetes.io/finalizer
其中 external-provisioner.volume.kubernetes.io/finalizer 只在该 PV 对应的后端存储被真正删除后才会移除;而 kubernetes.io/pv-protection 则是在 PV 仍与 PVC 绑定时阻止删除。理解这两者的差别,是读懂后面所有 Terminating 现象的前提。
Step 1:三分钟定位到底谁卡住了
先不要猜,按顺序读状态。第一步看 PV 与 PVC 的 Phase,第二步看 Finalizer,第三步看有没有残留的 VolumeAttachment:
kubectl get pvc -A | grep -i terminating
kubectl get pv | grep -i terminating
kubectl get pvc my-data -n myapp -o jsonpath='{.metadata.finalizers}'
kubectl get volumeattachment -o custom-columns=NAME:.metadata.name,PV:.spec.source.persistentVolumeName,NODE:.spec.nodeName,ATTACHED:.status.attached
第三步最容易被跳过,但它往往是整条排查链的答案。只要有一条 VolumeAttachment 指向一个已经不存在的节点,那个卷就再也挂不上新节点,表现就是 Pod 一直 ContainerCreating。
💣 踩坑录:5 个真实报错与修法
报错一:PVC 一直 Terminating,删不掉
现象是执行 kubectl delete pvc 之后,状态长时间停在 Terminating,describe 出来是这样:
Name: my-data
Namespace: myapp
Status: Terminating
Finalizers: [kubernetes.io/pvc-protection]
原因:kubernetes.io/pvc-protection 这个 Finalizer 会在仍有 Pod 挂载该 PVC 时阻止删除。常见触发是一个卡在 Terminating 的 Pod,或一个跑在已失联节点上的 Pod。先用 jq 找出引用它的 Pod:
kubectl get pods -A -o json | jq -r '.items[]
| select(.spec.volumes[]?.persistentVolumeClaim.claimName=="my-data")
| .metadata.name'
如果 Pod 所在节点已经失联,加 --force --grace-period=0 删除;确认没有任何 Pod 引用之后,Finalizer 会自行清除。只有在确认卷已卸载、且明确知道后果时,才手工 patch:
kubectl patch pvc my-data -n myapp --type merge -p '{"metadata":{"finalizers":null}}'
报错二:PV Terminating 且后端卷泄漏
现象:PV 上带着 external-provisioner.volume.kubernetes.io/finalizer,长期 Terminating;同时对象存储里能看到一条无人引用的卷。
原因:如果 external-provisioner 低于 v5.0.1,集群不会为新 PV 打这个 Finalizer,于是先删 PV 再删 PVC 时后端卷不会被删。反过来,如果 Finalizer 在、但 CSI 驱动控制器不可用,删除请求也无法完成。先确认版本与驱动状态:
kubectl -n kube-system get deploy csi-provisioner \
-o jsonpath='{.spec.template.spec.containers[0].image}'
kubectl get pod -n kube-system | grep -i csi
修法:把 external-provisioner 升到 v5.0.1 或更高并重启驱动,在存储侧确认卷已删除后,Finalizer 会随之释放。确实需要强制清理时:
kubectl patch pv pv-my-data --type merge -p '{"metadata":{"finalizers":null}}'
报错三:Multi-Attach,卷挂不上新节点
现象:Pod 永远停在 ContainerCreating,describe pod 的 Events 里是:
Warning FailedAttachVolume attachdetach-controller
Multi-Attach error for volume "pvc-9f2a..." Volume is already exclusively attached to one node and can't be attached to another
原因:ReadWriteOnce 的语义是「同一时刻只允许一个节点读写挂载」,不是「一个 Pod」。如果旧节点崩溃或 Pod 没有干净终止,卷会一直标记为挂在旧节点上。找到残留的 VolumeAttachment 并删除即可:
kubectl get volumeattachment | grep pvc-9f2a
kubectl delete volumeattachment
如果这个 attachment 自己还带 Finalizer 删不掉,先 describe 看是哪个外部控制器在持有,再决定是否 patch。
报错四:滚动更新必然死锁
现象:Deployment 用 RollingUpdate 且挂 RWO 卷,kubectl rollout status 永远不结束,新旧两个 Pod 都在等对方。
原因:新 Pod 调度到节点 B,需要把卷从节点 A 摘下来再挂到 B;但旧 Pod 要等新 Pod Ready 才终止;而新 Pod 又因为卷还挂在 A 上无法 Ready——一个闭环。多副本下这几乎 100% 会死锁。修法是把单副本有状态负载的策略改成 Recreate,或直接改用 StatefulSet:
spec:
strategy:
type: Recreate
临时解卡可以先把副本数归零,等卷卸载后再拉起:
kubectl scale deployment my-app -n myapp --replicas=0
sleep 30
kubectl scale deployment my-app -n myapp --replicas=1
报错五:ProvisioningFailed,存储类找不到
现象:PVC 一直 Pending,describe pvc 里是:
Warning ProvisioningFailed persistentvolume-controller
storageclass.storage.k8s.io "fast-nvme-ssd" not found
原因:PVC 引用的 StorageClass 名称不存在,或跨可用区时卷的 zone 与节点 zone 不匹配(后者通常表现为 Unable to attach or mount volumes: timed out waiting for the condition,且没有 Multi-Attach 字样)。先列出可用存储类,再确认绑定模式:
kubectl get storageclass
kubectl get pv pvc-9f2a... -o jsonpath='{.spec.nodeAffinity}'
预防跨区问题,给 StorageClass 设置 volumeBindingMode: WaitForFirstConsumer,让调度器先把 Pod 定到节点,再就近创建卷。
🛡️ 加固:让这类故障不再发生
三条纪律。第一,固定删除顺序:永远先删 PVC 再删 PV,并且在驱动升级完成前不要手工删 PV。第二,把有状态负载从 Deployment 迁到 StatefulSet,每个副本独占自己的 PVC,从设计上消灭 Multi-Attach。第三,把监控补齐,对 PV 阶段与 VolumeAttachment 数量做告警,比等用户来报障便宜得多。这三条里最容易被当成小事、却最值钱的是第三条:我这次是从监控告警才发现的,如果只靠人肉巡检,三条泄漏卷可能要在账单里躺上整整一个季度才会被发现,而那时候你早已记不清它们对应哪个已删的命名空间。
自建 Kubernetes 实验室的桌面装备
我的测试集群跑在三块单板机上,桌面这几个小件对我来说是刚需,均含 Amazon 联盟链接,价格受内存现货波动影响,请以页面为准:
👉 在 Amazon 查看 Raspberry Pi 5 8GB,做 kubelet 与 etcd 的实验节点很合适,发布价 $80 起。
👉 在 Amazon 查看 Amazon Basics Cat 6 网线,节点间走 1GbE 也够用。
👉 在 Amazon 查看 Amazon Basics microSDXC 128GB,刷系统镜像必备。
总结与相关阅读
存储排障的核心不是背命令,而是理解两件事:Finalizer 代表还有一步没完成,RWO 代表同一时刻只能挂一个节点。读懂这两条,Terminating 与 Multi-Attach 就都不再神秘。这也是我在这次事故里最大的收获:Kubernetes 的存储层并不神秘,它只是把「谁在占用资源、占用到什么时候」判断得非常严格,一旦上层的操作顺序与它的假设冲突,它就会用 Terminating 这种看似卡死的方式,安静地等你把顺序摆正。反过来,只要顺着 Finalizer 和 VolumeAttachment 这两条线索往下走,绝大多数「卷挂不上、删不掉」的问题都能在十分钟内定位,而不是靠反复重启或直接改底层存储去赌运气。
如果你也在折腾自托管基础设施,可以接着看这几篇:GitHub Actions 供应链踩坑实录、2026 程序员创客开发板横评、WordPress 7.1 MCP Adapter 实战。
👉 立即参与 MiniMax Token Plan:AI 编程加速,企业用户专享优惠
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):