标签: Kubernetes存储

  • 深入 K8S CSI 存储雪崩排查:Immediate 模式引发的跨可用区调度死锁与 Finalizer 僵尸惨案

    排查过程中经常能遇到一种让人血压飙升的场景:业务侧跑来报障,说 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-protection Finalizer 会拦截删除操作。

    • 当你强制干掉 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 存储异常速查表

    1. 查调度模式冲突:检查 StorageClass 是否为 Immediate 且集群为多可用区。只要符合这两条,立刻改成 WaitForFirstConsumer

    2. 查 PV 拓扑亲和性kubectl get pv -o yaml,查看 nodeAffinity 中声明的 Zone,是否与 Pod 最终想要调度的 Node 所在的 Zone 完全一致。

    3. 查挂载残留对象:排查 kubectl get volumeattachments 列表中是否有长时间 Attached: true 但实际 Pod 已经销毁的僵尸记录。

    4. 查 CSI 控制平面:抓取 external-provisionerexternal-attacher 容器的日志,搜索 Failed to attach volumerate exceeded 关键字,确认是否因 API 限流导致状态不一致。

    存储无小事。在基础设施即代码的今天,任何一行缺乏底层逻辑支撑的 YAML,都有可能在深夜掀起一场毁灭性的雪崩。敬畏数据,敬畏拓扑。