排查过程中经常能遇到一种让人血压飙升的场景:业务侧跑来报障,说 StatefulSet 扩容卡住了,Pod 一直处于 Pending 状态。为了“快速恢复”,他们熟练地加上 --force --grace-period=0 强删了 Pod 和 PVC,结果不仅新 Pod 没起来,旧的 PV 全变成了 Terminating 僵尸态,底层云盘疯狂计费,CSI Provisioner 的队列被彻底塞爆。
先抛出结论:在多可用区(Multi-AZ)集群中,StorageClass 绝对不能使用默认的 volumeBindingMode: Immediate。 必须显式声明为 WaitForFirstConsumer。否则,CSI Provisioner 会在 PVC 创建瞬间盲目在一个随机可用区创建底层存储卷,一旦 K8s 调度器受限于节点资源或 Pod 反亲和性(Anti-Affinity),将 Pod 强行调度到另一个可用区,就会触发经典的 volume node affinity conflict 死锁。而无脑的强删操作,只会引发 Finalizer 锁死,导致控制面雪崩。
案发现场:一次愚蠢的“调度冲突”与强删风暴
某次核心中间件集群扩容,运维同学反馈新加的两个 Pod 挂死在 Pending 状态。
随手敲下 kubectl describe pod,看到了 K8s 存储排查中最眼熟的报错:
Warning FailedScheduling 3m2s default-scheduler 0/50 nodes are available: 20 node(s) didn't match pod anti-affinity rules, 30 node(s) had volume node affinity conflict.
这个报错的信息量极大。集群一共 50 个节点,其中 20 个节点因为业务配置了强反亲和性(requiredDuringSchedulingIgnoredDuringExecution)被过滤,剩下 30 个节点全部报 volume node affinity conflict。
去查一眼 PVC 和 PV 的状态,发现 PVC 已经是 Bound 状态了:
$ kubectl get pvc data-kafka-3
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
data-kafka-3 Bound pvc-8f9a2b3c-1234-5678-90ab-cdef12345678 500Gi RWO ssd-sc 15m
这就是典型的“盘建好了,但 Pod 过不去”。
此时,业务研发为了自救,执行了经典的毁灭三连:
kubectl delete pod kafka-3 --force
kubectl delete pvc data-kafka-3 --force
kubectl delete pv pvc-8f9a2b3c... --force
结果灾难发生了:PVC 和 PV 全部卡在 Terminating。CSI Controller 疯狂刷错,external-provisioner 的 Goroutine 数量飙升,API Server 持续收到无用的 Update 请求,整个存储控制面陷入瘫痪。
核心原理解析:为什么盘和计算节点会劈腿?
很多半吊子对 Kubernetes 存储生命周期的认知还停留在“建 PVC -> 绑 PV -> 挂载到 Pod”的线性思维上。在 CSI(Container Storage Interface)架构下,多可用区集群的存储拓扑感知(Topology Awareness)是一件极其严谨的事。
1. Immediate 模式的致命缺陷
查看当时的 StorageClass 配置:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ssd-sc
provisioner: ebs.csi.aws.com
parameters:
type: gp3
# 致命缺失:没有定义 volumeBindingMode,默认使用了 Immediate
在 Immediate 模式下,当 StatefulSet 创建出 PVC 时,CSI external-provisioner 会立刻调用云厂商 API 创建一块 EBS 盘。由于此时它不知道最终 Pod 会被调度到哪个节点,它只能随机(或根据默认规则)选择一个可用区(假设选了 Zone A)。
盘建好后,生成的 PV 对象里会被硬性打上 nodeAffinity:
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: topology.ebs.csi.aws.com/zone
operator: In
values:
- ap-southeast-1a # 盘被锁死在了 Zone A
2. 调度器被两头堵死
接下来 kube-scheduler 开始为 Pod 寻找节点。
-
Pod 自身带有反亲和性,恰好 Zone A 的节点都已经部署了同一个 StatefulSet 的其他 Pod,Zone A 全部被过滤。
-
调度器试图把 Pod 塞进 Zone B 的节点,但在评估存储卷时,发现 PV 的
nodeAffinity是 Zone A。 -
最终结果:计算资源要求去 Zone B,存储资源锁死在 Zone A。死锁形成,Pod 永久
Pending。
3. 强删引发的 Finalizer 僵尸机制
K8s 极度推崇“防御性编程”,为了防止数据丢失,设计了 Finalizer 机制。
-
当你删除正在被 Pod(哪怕是 Pending 但已绑定的 Pod)引用的 PVC 时,
kubernetes.io/pvc-protectionFinalizer 会拦截删除操作。 -
当你强制干掉 PV 时,
kubernetes.io/pv-protection会死死拦住。 -
更要命的是,底层云盘的 Delete 请求依赖 CSI 正常通信。当人为
kubectl patch暴力清除 Finalizer 时,K8s 里的对象没了,但云厂商那边的物理云盘变成了孤儿资源(Leaked Volume),默默消耗着高昂的云预算。
破局与自救:如何体面地收拾残局?
不要一上来就改 etcd 或者无脑 patch finalizer,按顺序执行以下操作:
第一步:揪出卡死的资源并妥善释放
如果 PVC/PV 已经处于 Terminating,必须先确认底层云盘是否已经删除。如果没删,手动去云控制台删盘。确认盘没用后,再通过 Patch 清理 K8s 对象:
# 清理 PVC Finalizer
kubectl patch pvc data-kafka-3 -p '{"metadata":{"finalizers":null}}'
# 清理 PV Finalizer
kubectl patch pv pvc-8f9a2b3c-1234-5678-90ab-cdef12345678 -p '{"metadata":{"finalizers":null}}'
第二步:检查是否有残留的 VolumeAttachment
有时候 PV 删了,但 CSI 挂载记录还在,会导致同名节点后续挂载一直报错 VolumeInUse:
kubectl get volumeattachment | grep pvc-8f9a2b3c
# 如果有,同样 patch 清掉
kubectl patch volumeattachment <name> -p '{"metadata":{"finalizers":null}}'
第三步:重建 StorageClass(核心防御)
StorageClass 的 volumeBindingMode 是不可变字段(Immutable),只能建新的。
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ssd-sc-topology
provisioner: ebs.csi.aws.com
parameters:
type: gp3
volumeBindingMode: WaitForFirstConsumer # 绝对核心
allowedTopologies: # 可选:显式限制允许创建存储的可用区
- matchLabelExpressions:
- key: topology.ebs.csi.aws.com/zone
values:
- ap-southeast-1a
- ap-southeast-1b
原理揭秘:改为 WaitForFirstConsumer 后,PVC 创建时 CSI 不会立即建盘,PVC 会处于 Pending 状态。kube-scheduler 会将 Pod 调度到合适的节点(例如 Zone B),然后将选定的节点拓扑信息传递给 CSI Provisioner,CSI 再拿着 “Zone B” 的确切坐标去调用云 API 建盘。实现了“计算在哪,存储就建在哪”的精准协同。
排查清单:K8S 存储异常速查表
-
查调度模式冲突:检查
StorageClass是否为Immediate且集群为多可用区。只要符合这两条,立刻改成WaitForFirstConsumer。 -
查 PV 拓扑亲和性:
kubectl get pv,查看-o yaml nodeAffinity中声明的 Zone,是否与 Pod 最终想要调度的 Node 所在的 Zone 完全一致。 -
查挂载残留对象:排查
kubectl get volumeattachments列表中是否有长时间Attached: true但实际 Pod 已经销毁的僵尸记录。 -
查 CSI 控制平面:抓取
external-provisioner和external-attacher容器的日志,搜索Failed to attach volume或rate exceeded关键字,确认是否因 API 限流导致状态不一致。
存储无小事。在基础设施即代码的今天,任何一行缺乏底层逻辑支撑的 YAML,都有可能在深夜掀起一场毁灭性的雪崩。敬畏数据,敬畏拓扑。