标签: WaitForFirstConsumer

  • 深入 K8S CSI 存储拓扑:从 Pod 跨可用区调度死锁看 WaitForFirstConsumer 机制

    在跨可用区(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-1b1c,却被 PV 的拓扑亲和性(NodeAffinity)硬生生卡住,引发调度死锁。

    产生这种惨剧的根本原因,在于对应的 StorageClass 采用了 volumeBindingMode: Immediate

    危险的 Immediate 模式

    Immediate 模式下,PVC 的生命周期独立于 Pod。流程如下:

    1. StatefulSet 控制器创建 PVC。

    2. CSI 的 external-provisioner 监听到 PVC 创建,立刻调用后端存储接口(如 AWS API)创建底层卷(EBS)。由于此时没有 Pod 的调度上下文,CSI Driver 只能基于 StorageClass 配置或随机选择一个 AZ 创建卷。

    3. 卷创建完毕,K8S 生成 PV 并与 PVC 绑定。PV 被打上了所在 AZ 的亲和性标签。

    4. 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 时,底层的控制流发生了彻底的翻转:

    1. PVC 处于 Pending:StatefulSet 创建 PVC 后,external-provisioner 会忽略这个 PVC,不发起任何创建卷的动作,PVC 保持 Pending。

    2. 调度器介入 (VolumeBinding Plugin):Pod 进入调度队列。kube-scheduler 的 VolumeBinding 插件不仅会计算 CPU/Mem,还会评估各个 Node 的存储拓扑(Storage Topology)。

    3. 模拟绑定与预检:调度器假设将 Pod 放到 Node-B(位于 AZ-B,资源充足),并检查该 Node 是否满足 StorageClass 的拓扑限制。

    4. 注入 Annotation:确认无误后,调度器会在 PVC 上打上一个关键的注解 volume.kubernetes.io/selected-node: Node-B

    5. 按图索骥 (Provisioning):此时 external-provisioner 监听到 PVC 上出现了 selected-node 注解,立即去查询 Node-B 对应的 CSINode 对象,提取其拓扑标签(topology.ebs.csi.aws.com/zone=ap-southeast-1b)。

    6. 精准建卷:CSI Driver 带着确切的 AZ 信息调用云厂商 API,在 AZ-B 精准创建出 EBS 卷。

    7. 大功告成: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”都没过去。这通常是因为:

    1. 集群内没有任何 Node 的 CPU/内存资源能满足 Pod 的 request。

    2. 调度器找到了有资源的 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 failedMulti-Attach error,怎么处理? 这通常是 CSI 卸载流程卡死。K8S 的卸载顺序是:kubelet 发起 UnpublishVolume(解除宿主机挂载) -> Controller 发起 DetachVolume(解除云 API 绑定)。 遇到此类问题,绝对不要手贱强删 Pod(--force --grace-period=0),否则会导致云盘仍在 Node 上残留,最终耗尽机器的最大挂载数(allocatable count)。 正确排查步骤:

    1. 登录挂载 Node,执行 mount | grep 确认挂载点状态,必要时手动 umount -f 或清除挂死的僵尸进程(如 lsof | grep 找出的进程)。

    2. 查看对应的 csi-node DaemonSet Pod 的日志,确认是否调用底层 API 超时。

    Q4:本地存储(Local Persistent Volume)必须用 WaitForFirstConsumer 吗? 必须。本地盘与特定 Node 物理绑定,如果不延迟绑定,PVC 一旦被随机分配到某个节点的 Local PV 上,Pod 将永远被锁定在那台物理机上。一旦该机器无 CPU 资源,Pod 调度直接暴毙。所以在所有 Node-Local 存储(TopoLVM, OpenEBS LocalPV, 原生 Local Volume)的配置中,这是强制的铁律。