在跨可用区(Multi-AZ)部署有状态服务时,默认的 Immediate 存储绑定模式极易导致 Pod 调度死锁(存储卷在 AZ-A 被提前创建,而该区无可用计算资源,导致 Pod 卡在 Pending)。本文直接给出核心解法:生产环境的多可用区集群中,StorageClass 必须强制启用 volumeBindingMode: WaitForFirstConsumer 与 CSI 拓扑感知,这也是解决存储与计算资源错配的唯一正解。
排查某次线上 Elasticsearch 集群扩容故障时,发现新建的 Pod 持续处于 Pending 状态。
通过 kubectl describe pod 查看 Events,输出了非常典型的报错:
Warning FailedScheduling 3m22s default-scheduler 0/15 nodes are available: 3 node(s) had volume node affinity conflict, 12 node(s) didn't match Pod's node affinity/selector.
集群环境是 K8S v1.28.2,底层使用 AWS EBS CSI Driver (v1.30.0)。报错明确指出了 volume node affinity conflict(存储卷节点亲和性冲突)。
顺着线索排查 PVC 和 PV:
# kubectl get pvc data-es-cluster-3
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
data-es-cluster-3 Bound pvc-8f9a2c1b-4d3e-4f5a-9b1c-2a3b4c5d6e7f 500Gi RWO gp3-default 15m
# kubectl get pv pvc-8f9a2c1b-4d3e-4f5a-9b1c-2a3b4c5d6e7f -o yaml | grep -A 5 nodeAffinity
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: topology.ebs.csi.aws.com/zone
operator: In
values:
- ap-southeast-1a
问题已经水落石出:PVC 绑定到了一个位于 ap-southeast-1a 可用区的 PV,但此时 ap-southeast-1a 的所有 Node 资源(CPU/Memory)已经耗尽。kube-scheduler 试图把 Pod 调度到有充裕资源的 ap-southeast-1b 或 1c,却被 PV 的拓扑亲和性(NodeAffinity)硬生生卡住,引发调度死锁。
产生这种惨剧的根本原因,在于对应的 StorageClass 采用了 volumeBindingMode: Immediate。
危险的 Immediate 模式
在 Immediate 模式下,PVC 的生命周期独立于 Pod。流程如下:
-
StatefulSet 控制器创建 PVC。
-
CSI 的
external-provisioner监听到 PVC 创建,立刻调用后端存储接口(如 AWS API)创建底层卷(EBS)。由于此时没有 Pod 的调度上下文,CSI Driver 只能基于 StorageClass 配置或随机选择一个 AZ 创建卷。 -
卷创建完毕,K8S 生成 PV 并与 PVC 绑定。PV 被打上了所在 AZ 的亲和性标签。
-
kube-scheduler 开始调度 Pod,发现 PVC 已经死死绑定在了 AZ-A,只能强行往 AZ-A 调度。如果 AZ-A 计算资源不足,调度彻底失败。
为什么 WaitForFirstConsumer 能终结跨可用区调度死锁?
要打破这个僵局,必须把“先建卷再调度”改为“先计算调度,再按节点建卷”。这正是 WaitForFirstConsumer 机制存在的原因。
修改 StorageClass 配置:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: gp3-topology-aware
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer # 核心配置
allowedTopologies:
- matchLabelExpressions:
- key: topology.ebs.csi.aws.com/zone
values:
- ap-southeast-1a
- ap-southeast-1b
- ap-southeast-1c
parameters:
type: gp3
fsType: ext4
当模式切换为 WaitForFirstConsumer 时,底层的控制流发生了彻底的翻转:
-
PVC 处于 Pending:StatefulSet 创建 PVC 后,
external-provisioner会忽略这个 PVC,不发起任何创建卷的动作,PVC 保持 Pending。 -
调度器介入 (VolumeBinding Plugin):Pod 进入调度队列。kube-scheduler 的
VolumeBinding插件不仅会计算 CPU/Mem,还会评估各个 Node 的存储拓扑(Storage Topology)。 -
模拟绑定与预检:调度器假设将 Pod 放到 Node-B(位于 AZ-B,资源充足),并检查该 Node 是否满足 StorageClass 的拓扑限制。
-
注入 Annotation:确认无误后,调度器会在 PVC 上打上一个关键的注解
volume.kubernetes.io/selected-node: Node-B。 -
按图索骥 (Provisioning):此时
external-provisioner监听到 PVC 上出现了selected-node注解,立即去查询 Node-B 对应的CSINode对象,提取其拓扑标签(topology.ebs.csi.aws.com/zone=ap-southeast-1b)。 -
精准建卷:CSI Driver 带着确切的 AZ 信息调用云厂商 API,在 AZ-B 精准创建出 EBS 卷。
-
大功告成:PV 创建,PVC 变为 Bound,Pod 被正式调度到 Node-B,挂载启动。
整个过程,kube-scheduler 掌握了绝对的主动权,存储的创建被迫向计算资源的分布妥协,从根源上消灭了调度死锁。
CSI 拓扑感知底座:CSINode 机制
你可能会问:external-provisioner 怎么知道某个 Node 到底属于哪个存储拓扑域?
这就涉及 K8S CSI 架构中的 csi-node-driver-registrar Sidecar。在每个 Node 上运行的 CSI 节点组件,启动时会向 kubelet 注册自己,并上报节点级别的存储拓扑信息。K8S 会将这些信息持久化在 CSINode 资源中。
我们可以直接抓取一个线上的 CSINode 对象来佐证:
# kubectl get csinode ip-10-0-12-34.ap-southeast-1.compute.internal -o yaml
apiVersion: storage.k8s.io/v1
kind: CSINode
metadata:
name: ip-10-0-12-34.ap-southeast-1.compute.internal
spec:
drivers:
- allocatable:
count: 39
name: ebs.csi.aws.com
nodeID: i-0abcd1234efgh5678
topologyKeys:
- topology.ebs.csi.aws.com/zone
这里的 topologyKeys 明确告知了上层组件,如果要往这台机器挂载 EBS 卷,必须参照 topology.ebs.csi.aws.com/zone 这个 Key 去匹配资源。
常见问题 (FAQ)
Q1:配置了 WaitForFirstConsumer,为什么集群扩容时 PVC 依然一直卡在 Pending? 这是生产环境常踩的坑。PVC Pending 说明调度器连第一步“找到合适的 Node”都没过去。这通常是因为:
-
集群内没有任何 Node 的 CPU/内存资源能满足 Pod 的 request。
-
调度器找到了有资源的 Node,但这台 Node 的拓扑标签与 StorageClass 中的
allowedTopologies冲突。 检查手段:直接kubectl describe pod,看调度失败的 Event 是Insufficient cpu还是volume node affinity。
Q2:StatefulSet 的 Pod 跨可用区调度时,如果原有的 Node 挂了,Pod 漂移到其他 AZ 会发生什么?
会持续处于 Pending。因为 PV 一旦创建,其 NodeAffinity 就已经被写死(例如限定在 AZ-A)。Pod 虽然可以因故障漂移,但 kube-scheduler 评估时发现 AZ-B 的计算资源无法满足这个 PV 的 AZ-A 亲和性,拒绝调度。
对于块存储(如 EBS、阿里云云盘)这是物理限制;如果是分布式文件系统(NFS/CephFS),可以通过多 AZ 共享解决。块存储场景下的容灾,必须依赖应用层的高可用(如 ES/Kafka 的多副本同步),而不是指望 K8S 底层的云盘跨区漂移。
Q3:Pod 一直处于 Terminating,kubelet 日志狂刷 Unmounted failed 或 Multi-Attach error,怎么处理?
这通常是 CSI 卸载流程卡死。K8S 的卸载顺序是:kubelet 发起 UnpublishVolume(解除宿主机挂载) -> Controller 发起 DetachVolume(解除云 API 绑定)。
遇到此类问题,绝对不要手贱强删 Pod(--force --grace-period=0),否则会导致云盘仍在 Node 上残留,最终耗尽机器的最大挂载数(allocatable count)。
正确排查步骤:
-
登录挂载 Node,执行
mount | grep确认挂载点状态,必要时手动umount -f或清除挂死的僵尸进程(如lsof | grep找出的进程)。 -
查看对应的
csi-nodeDaemonSet Pod 的日志,确认是否调用底层 API 超时。
Q4:本地存储(Local Persistent Volume)必须用 WaitForFirstConsumer 吗? 必须。本地盘与特定 Node 物理绑定,如果不延迟绑定,PVC 一旦被随机分配到某个节点的 Local PV 上,Pod 将永远被锁定在那台物理机上。一旦该机器无 CPU 资源,Pod 调度直接暴毙。所以在所有 Node-Local 存储(TopoLVM, OpenEBS LocalPV, 原生 Local Volume)的配置中,这是强制的铁律。