Kubernetes 基础体系 · 第 38/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes CSI:Controller、Node、Attach、Mount、快照和故障

CSI(Container Storage Interface)是 Kubernetes 与存储系统之间的插件接口。它把“创建一块云盘”“把云盘连接到某台节点”“在节点上格式化并挂载”“创建快照”等操作,从 Kubernetes 核心代码中分离出来,由存储厂商或社区插件实现。

理解 CSI 不能只记住“Controller 管控制面、Node 管节点”。一个 Pod 使用 PVC 时,至少涉及以下对象和过程:

  1. 用户通过 PVC 请求存储;
  2. StorageClass 指定动态供应参数;
  3. external-provisioner 调用 CSI Controller 创建后端卷;
  4. Kubernetes 创建 PV,并将 PVC 绑定到 PV;
  5. external-attacher 使后端卷连接到目标节点;
  6. kubelet 通过 CSI Node 插件完成设备发现、格式化、挂载和 bind mount;
  7. Pod 使用挂载后的目录;
  8. 删除或迁移 Pod 时,流程反向执行;
  9. 快照则由 VolumeSnapshot API、snapshot-controller 和 external-snapshotter 协作完成。

这些步骤不是所有存储都完全相同。块存储、文件存储、拓扑受限存储、支持或不支持 Attach 的 CSI 驱动,在中间步骤上会有差异。


一、CSI 的责任边界

1. CSI 不负责容器运行时和网络

Kubernetes 中常被并列提到的 CRI、CNI、CSI,解决的是三个不同问题:

接口 解决的问题 典型调用方 典型结果
CRI 如何创建和管理容器 kubelet → 容器运行时 容器进程、网络命名空间、容器生命周期
CNI Pod 如何接入网络 容器运行时或网络插件 网卡、IP、路由、网络策略
CSI Pod 如何获得持久化存储 kubelet、控制器侧边车 → 存储插件 云盘、文件卷、节点挂载目录

例如:

  • CRI 创建 PostgreSQL 容器;
  • CNI 为 PostgreSQL Pod 分配 10.0.2.15
  • CSI 将一个云盘挂载到 Pod 的 /var/lib/postgresql/data

CSI 不创建容器,也不分配 Pod IP;CNI 不创建 PV;CRI 通常也不知道后端云盘的快照和复制策略。

1. CSI 的两个服务

CSI 插件通常提供两个逻辑服务:

Controller 服务

Controller 服务负责与存储后端交互,典型 RPC 包括:

  • CreateVolume
  • DeleteVolume
  • ControllerPublishVolume
  • ControllerUnpublishVolume
  • ValidateVolumeCapabilities
  • CreateSnapshot
  • DeleteSnapshot
  • ListSnapshots
  • ControllerExpandVolume

它通常运行在控制器 Pod 中,可以是 Deployment,也可以是 StatefulSet。Controller 不应该假设自己运行在目标节点上。

Node 服务

Node 服务负责某一台节点上的本地操作,典型 RPC 包括:

  • NodeStageVolume
  • NodeUnstageVolume
  • NodePublishVolume
  • NodeUnpublishVolume
  • NodeGetInfo
  • NodeExpandVolume
  • NodeGetVolumeStats

它通常以 DaemonSet 运行,每个可使用该存储的节点都有一个 Node 插件实例。Node 服务能够访问宿主机设备、挂载点和内核能力,因此需要较高权限,常见配置包括 hostNetworkhostPIDprivileged 或特定的 hostPath

Controller 和 Node 不是两个可选的命名概念,而是两类不同的执行位置:

  • Controller 负责“后端资源和节点级连接”;
  • Node 负责“目标节点上的设备和文件系统”。

部分 CSI 驱动只提供文件系统网络挂载,不需要传统意义上的 Attach;但仍然需要 Node 服务执行挂载。


二、CSI 插件不是 Kubernetes 单体组件

CSI 驱动一般由以下部分组成:

graph LR
    U[用户: PVC / Pod / VolumeSnapshot] --> API[Kubernetes API Server]

    API --> P[external-provisioner]
    API --> A[external-attacher]
    API --> R[external-resizer]
    API --> S[snapshot-controller]
    API --> ES[external-snapshotter]

    P --> CC[CSI Controller Service]
    A --> CC
    R --> CC
    ES --> CC

    K[kubelet] --> NR[csi-node-driver-registrar]
    K --> NS[CSI Node Service]
    NR --> NS

    CC --> B[存储后端]
    NS --> N[节点设备 / 文件系统 / 挂载点]
    N --> Pod[Pod 容器]

这里有一个容易混淆的事实:Kubernetes 通常不会直接实现所有 CSI RPC,而是通过一组 sidecar 调用 CSI gRPC 服务。

例如:

  • external-provisioner 监听 PVC 和 StorageClass,调用 CreateVolume
  • external-attacher 监听 VolumeAttachment,调用 ControllerPublishVolume
  • external-resizer 监听扩容请求,调用控制器和节点扩容 RPC;
  • external-snapshotter 监听 VolumeSnapshotContent,调用 CreateSnapshot
  • snapshot-controller 负责 VolumeSnapshot API 对象与 VolumeSnapshotContent 的协调;
  • csi-node-driver-registrar 将 CSI Node 插件注册到 kubelet。

这些组件的具体镜像版本必须与 Kubernetes 版本、CSI 驱动版本和 CSI sidecar 兼容。不能根据某篇旧文档直接复制镜像标签到生产环境。


三、PVC、PV 和动态供应

1. PVC 是需求,PV 是绑定结果

PVC(PersistentVolumeClaim)描述工作负载需要什么存储:

  • 容量;
  • 访问模式;
  • StorageClass;
  • 可选的数据源。

PV(PersistentVolume)描述一个已经存在或由控制器创建的存储资源:

  • 后端卷标识;
  • CSI 驱动名;
  • 容量;
  • 回收策略;
  • 节点拓扑;
  • 挂载参数。

动态供应的逻辑可以写成:

PVC+StorageClassCreateVolumePVPVC/PV Bound\text{PVC} + \text{StorageClass} \Rightarrow \text{CreateVolume} \Rightarrow \text{PV} \Rightarrow \text{PVC/PV Bound}

其中 CreateVolume 的输入通常包括 PVC 请求的容量、卷能力、拓扑和 StorageClass 参数。

一个最小的 StorageClass 和 PVC 示例:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: example-csi
provisioner: example.csi.vendor
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
allowVolumeExpansion: true
parameters:
  type: fast
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-demo
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: example-csi
  resources:
    requests:
      storage: 20Gi

这个示例不能直接创建真实磁盘,因为 example.csi.vendor 是占位的驱动名。只有集群中已经安装并注册了同名 CSI 驱动,kubectl apply 后 PVC 才可能变为 Bound

检查过程:

kubectl apply -f storage.yaml
kubectl get pvc data-demo
kubectl describe pvc data-demo
kubectl get pv
kubectl get storageclass example-csi -o yaml

可能看到:

NAME        STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
data-demo   Pending                                      example-csi     12s

Pending 不是“磁盘已经创建但挂载失败”的同义词。它可能表示:

  • 没有安装匹配的 provisioner;
  • StorageClass 参数无效;
  • 后端配额不足;
  • 使用 WaitForFirstConsumer,尚未出现能决定拓扑的 Pod;
  • external-provisioner 没有运行;
  • PVC 与静态 PV 无法匹配。

2. WaitForFirstConsumer 的因果关系

如果存储有可用区或节点拓扑限制,立即创建卷可能选错区域。因此 volumeBindingMode: WaitForFirstConsumer 会延迟绑定:

  1. PVC 创建,但暂不供应;
  2. Pod 被调度器分析;
  3. 调度器根据节点拓扑和卷约束筛选候选节点;
  4. provisioner 使用选定拓扑调用 CreateVolume
  5. PV 与 PVC 绑定;
  6. Pod 继续挂载。

这不是“挂载失败后的重试”,而是为了让调度结果参与卷供应决策。


四、Attach:把后端卷连接到节点

1. Attach 与 Mount 不是一回事

对云块存储而言:

  • Attach 通常意味着云平台将卷连接到某个节点实例;
  • Mount 意味着节点操作系统把设备或远端文件系统挂载到目录;
  • Pod 使用的是最终的 bind mount 或文件系统路径。

因此:

后端卷存在卷已 Attach卷已 Mount\text{后端卷存在} \neq \text{卷已 Attach} \neq \text{卷已 Mount}

一个卷可能已经创建,但尚未连接到任何节点;也可能已经 Attach,但文件系统尚未挂载。

2. VolumeAttachment 的作用

当 CSI 驱动需要控制器侧 Attach 时,external-attacher 会创建或协调 VolumeAttachment 对象。它表达类似的意图:

apiVersion: storage.k8s.io/v1
kind: VolumeAttachment
metadata:
  name: example-attachment
spec:
  attacher: example.csi.vendor
  nodeName: worker-1
  source:
    persistentVolumeName: pvc-xxxx
status:
  attached: true
  attachmentMetadata:
    devicePath: /dev/example

实际名称和 metadata 由控制器生成。排查时可以查询:

kubectl get volumeattachment
kubectl describe volumeattachment <name>

status.attached: true 表示控制器认为 ControllerPublishVolume 已成功,不表示 Pod 内已经可以访问目录。之后还必须经过 NodeStage 和 NodePublish。

3. AttachRequired

CSIDriver 对象描述驱动能力:

kubectl get csidriver
kubectl get csidriver example.csi.vendor -o yaml

其中 spec.attachRequired 表示是否需要控制器侧 Attach:

  • true:通常需要 ControllerPublishVolume,会有 VolumeAttachment;
  • false:Kubernetes 跳过控制器 Attach,直接进入节点侧流程。

attachRequired: false 常见于某些网络文件系统或不需要将块设备连接到节点的驱动。不能因为没有 VolumeAttachment 就认定 CSI 异常;必须先看 CSIDriver 的能力声明。

4. Attach 的并发和幂等

控制器可能因为重试、leader 切换或网络超时重复调用 Attach。CSI 驱动必须实现幂等语义:

  • 已经 Attach 到目标节点,再次 Attach 应返回成功或等价结果;
  • 正在 Attach 时发生超时,控制器会重新查询或重试;
  • 一个 ReadWriteOnce 卷通常不能安全地同时 Attach 到多个节点;
  • 存储后端和驱动必须处理“旧节点仍认为已连接,但 Kubernetes 已尝试迁移”的中间状态。

如果云厂商 API 返回“卷已连接到其他实例”,Kubernetes 通常不会强行覆盖这个状态,而是等待驱动完成 Unpublish、后端解除连接或人工修复。


五、Mount:NodeStage、NodePublish 和 Pod 目录

1. Node 插件如何被 kubelet 使用

Node 插件通过 Unix Domain Socket 提供 CSI gRPC 服务。csi-node-driver-registrar 将插件信息注册给 kubelet,使 kubelet 知道:

  • 节点上有哪些 CSI 驱动;
  • 驱动的 socket 在哪里;
  • 驱动是否支持某些 Node 能力。

在节点上检查 CSI 相关 Pod:

kubectl -n kube-system get pods -o wide | grep -i csi
kubectl -n kube-system logs <csi-node-pod> -c csi-node-driver-registrar

如果 Node 插件没有注册,常见结果是 kubelet 事件中出现:

driver name example.csi.vendor not found in the list of registered CSI drivers

这时问题还没有到“格式化失败”阶段,而是 kubelet 根本找不到 Node 驱动。

2. NodeStageVolume:节点级暂存

对于块设备文件系统卷,kubelet 通常先调用:

NodeStageVolume(volume_id, staging_target_path, volume_capability)

Node 插件可能执行:

  1. 找到 Attach 后出现的设备;
  2. 检查文件系统;
  3. 必要时执行 mkfs
  4. 将设备挂载到节点级 staging 目录。

例如逻辑上可能形成:

/dev/sdb
  └── /var/lib/kubelet/plugins/kubernetes.io/csi/.../globalmount

NodeStage 的设计允许同一节点上的多个 Pod 共享一次节点级挂载,再通过不同目标目录暴露给各个 Pod。

对于某些文件系统型驱动,可能不存在本地块设备,但仍可以使用 staging 目录建立节点级挂载。

3. NodePublishVolume:暴露给 Pod

之后 kubelet 调用:

NodePublishVolume(volume_id, target_path, staging_target_path, volume_capability)

Node 插件通常将 staging 目录 bind mount 到 Pod 的目标路径:

globalmount
  ├── Pod A volume target
  └── Pod B volume target

NodePublishVolume 的目标路径通常位于 kubelet 管理的 Pod volume 目录。容器运行时随后看到的是这个已经准备好的路径,而不是 CSI gRPC 调用本身。

4. 反向卸载

Pod 删除、卷不再使用或节点清理时,通常按相反方向执行:

NodeUnpublishVolume
    ↓
NodeUnstageVolume
    ↓
ControllerUnpublishVolume
    ↓
DeleteVolume(取决于 PV reclaimPolicy)

并非每次都立即执行所有步骤。例如:

  • 同一节点仍有其他 Pod 使用该卷时,不能提前 Unstage;
  • Pod 删除但 PVC 仍存在时,不能 DeleteVolume;
  • PV 的 reclaimPolicy: Retain 时,删除 PVC 不会自动删除后端卷;
  • 节点失联时,正常 Unpublish 可能无法完成。

5. Mount 失败的典型层次

事件中的错误位置很重要:

错误位置 更可能的原因
CreateVolume 后端配额、参数、权限、区域
ControllerPublishVolume Attach 权限、卷已被其他节点占用、云 API 延迟
NodeStageVolume 设备未出现、文件系统损坏、mkfs 或 mount 参数错误
NodePublishVolume 目标路径、权限、bind mount、SELinux
容器启动后读写失败 文件系统权限、fsGroup、应用自身、I/O 错误

应先查看 Pod 事件:

kubectl describe pod <pod-name>
kubectl get events --sort-by=.lastTimestamp

再查看 PV、PVC 和 CSI Pod 日志:

kubectl describe pvc <pvc-name>
kubectl describe pv <pv-name>
kubectl -n kube-system logs <csi-controller-pod> -c csi-provisioner
kubectl -n kube-system logs <csi-controller-pod> -c csi-attacher
kubectl -n kube-system logs <csi-node-pod> -c csi-plugin

不要在节点上直接删除 kubelet 的挂载目录来“清理故障”。这可能造成仍在使用该目录的容器失去文件系统,甚至损坏数据。应先确认 Pod 已停止、引用已解除,再根据驱动文档处理残留挂载。


六、一个完整的 Pod 使用流程

下面的 Pod 假设前面的 PVC 已经成功绑定:

apiVersion: v1
kind: Pod
metadata:
  name: storage-demo
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["/bin/sh", "-c"]
      args:
        - |
          date > /data/created-at
          while true; do
            date >> /data/heartbeat
            sleep 10
          done
      volumeMounts:
        - name: data
          mountPath: /data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: data-demo

执行:

kubectl apply -f pod.yaml
kubectl get pod storage-demo -w
kubectl exec storage-demo -- sh -c 'cat /data/created-at; tail /data/heartbeat'

预期过程是:

  1. 调度器选择节点;
  2. 若使用延迟绑定,PVC 可能在此时才供应;
  3. external-provisioner 创建后端卷;
  4. external-attacher 按需要创建 VolumeAttachment;
  5. Node 插件完成 staging 和 publish;
  6. kubelet 启动容器;
  7. 容器在 /data 看到持久化文件。

删除 Pod 后重新创建:

kubectl delete pod storage-demo
kubectl apply -f pod.yaml
kubectl exec storage-demo -- cat /data/created-at

如果 PVC、PV 和后端卷没有被删除,created-at 应仍然存在。这个验证的是“Pod 生命周期与卷生命周期分离”,不是“所有数据都具备备份能力”。


七、访问模式与并发边界

访问模式是 Kubernetes 对卷使用方式的声明,常见模式包括:

  • ReadWriteOnce(RWO):一个节点可以读写;
  • ReadOnlyMany(ROX):多个节点只读;
  • ReadWriteMany(RWX):多个节点读写;
  • ReadWriteOncePod(RWOP):一个卷只能被一个 Pod 使用。

这些模式不是所有 CSI 驱动和后端都支持。特别是 RWO 经常被误解为“只能有一个 Pod 使用”。更准确地说,RWO 主要约束节点级读写;同一节点上的多个 Pod 是否允许同时使用,还取决于驱动、kubelet 和应用语义。RWOP 才是更强的单 Pod 约束,但也要求支持对应能力的 Kubernetes 与 CSI 组件。

一个反例是:两个 Pod 分别被调度到两个节点,二者都引用同一个 RWO PVC。Kubernetes 可能让其中一个 Pod 成功挂载,另一个长期处于 ContainerCreating,事件中出现类似:

Multi-Attach error for volume ...

这不是简单的 Mount 命令错误,而是卷的并发使用约束与调度结果冲突。


八、扩容:容量变大不等于文件系统立即变大

PVC 扩容通常包含两个阶段:

  1. 控制器侧扩容后端卷;
  2. 节点侧扩展分区或文件系统。

如果驱动支持扩容,StorageClass 需要:

allowVolumeExpansion: true

修改 PVC:

kubectl patch pvc data-demo \
  -p '{"spec":{"resources":{"requests":{"storage":"40Gi"}}}}'

观察:

kubectl get pvc data-demo -w
kubectl describe pvc data-demo

可能出现以下中间状态:

  • 后端云盘已经从 20 GiB 变为 40 GiB;
  • PV 容量状态尚未更新;
  • 节点侧文件系统尚未执行扩展;
  • Pod 重启或重新挂载后才完成文件系统扩容,取决于驱动和文件系统。

因此,不能只检查云厂商控制台中的容量,也不能只看 PVC 的 spec.resources.requests。应同时检查 PVC 状态、PV 状态、CSI resizer 日志和节点内的 df -h


九、VolumeSnapshot:快照 API 的对象关系

Kubernetes 的快照 API 使用 snapshot.storage.k8s.io/v1。核心对象有三个:

  • VolumeSnapshotClass:指定哪个 CSI 驱动和删除策略;
  • VolumeSnapshot:用户请求创建的快照;
  • VolumeSnapshotContent:集群中某个实际后端快照的表示。

对象关系可以表示为:

VolumeSnapshotClass
        ↓ driver / deletionPolicy
VolumeSnapshot
        ↔ VolumeSnapshotContent
                         ↓
                   后端 Snapshot

VolumeSnapshot 是命名空间级对象,通常由用户创建;VolumeSnapshotContent 是集群级对象,通常由控制器创建或绑定。

1. 创建快照

示例:

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
  name: example-snapshot-class
driver: example.csi.vendor
deletionPolicy: Delete
---
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: data-demo-snapshot
spec:
  volumeSnapshotClassName: example-snapshot-class
  source:
    persistentVolumeClaimName: data-demo

执行:

kubectl apply -f snapshot.yaml
kubectl get volumesnapshot data-demo-snapshot -w
kubectl describe volumesnapshot data-demo-snapshot
kubectl get volumesnapshotcontent

成功后应关注:

status:
  readyToUse: true
  restoreSize: 20Gi

readyToUse: true 表示控制器认为后端快照已经可以作为恢复源。它不等价于“应用已经一致地提交了所有事务”。

deletionPolicy 的语义很关键:

  • Delete:删除 VolumeSnapshot 时,控制器请求删除后端快照;
  • Retain:删除 Kubernetes 快照对象后保留后端快照资源。

后端快照是否真正独立、是否占用完整容量、删除是否异步,属于存储厂商实现差异。

2. 从快照恢复 PVC

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-restored
spec:
  storageClassName: example-csi
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
  dataSource:
    name: data-demo-snapshot
    kind: VolumeSnapshot
    apiGroup: snapshot.storage.k8s.io

恢复要求通常包括:

  1. 快照已经 readyToUse: true
  2. CSI 驱动支持从快照创建卷;
  3. dataSource 指向同一命名空间内的 VolumeSnapshot;
  4. 请求容量不小于快照恢复所需容量;
  5. StorageClass 和驱动能力匹配。

恢复创建的是一个新的 PVC 和通常新的后端卷,不是把原 PVC 的名字“回滚”到过去状态。

3. 从 PVC 克隆 PVC

某些 CSI 驱动支持卷克隆:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-clone
spec:
  storageClassName: example-csi
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
  dataSource:
    name: data-demo
    kind: PersistentVolumeClaim

克隆与快照恢复不同:

  • 克隆的源是 PVC;
  • 快照恢复的源是 VolumeSnapshot;
  • 两者都不是跨所有驱动通用的能力;
  • 后端可能实现为快速复制、写时复制或完整复制。

必须查驱动文档确认支持情况,不能仅因为 API 对象可以提交,就推断后端一定支持。


十、快照的一致性和备份边界

1. 崩溃一致性不等于应用一致性

假设应用向数据库写入一笔事务,数据可能同时存在于:

  • 数据库内存;
  • 数据库 WAL 或 redo log;
  • 操作系统页缓存;
  • 存储设备缓存;
  • 后端卷数据块。

直接创建卷快照通常只能保证某种后端定义的瞬时状态。它可能接近“机器突然断电后磁盘留下的状态”,即崩溃一致性(crash consistency),但不一定保证应用一致性(application consistency)。

应用一致性通常需要:

  1. 暂停写入或执行数据库 checkpoint;
  2. 刷新应用和操作系统缓存;
  3. 创建快照;
  4. 恢复应用写入。

例如,不能把下面的流程当作 PostgreSQL 的严格备份协议:

kubectl create -f volumesnapshot.yaml

更可靠的数据库备份可能需要数据库原生备份工具、WAL 归档、备份代理或 CSI 协调机制。具体方式取决于数据库和存储系统。

2. 快照不是完整备份

卷快照通常只覆盖卷数据,不自动覆盖:

  • Deployment、StatefulSet、Service 等 Kubernetes 对象;
  • Secret、ConfigMap、RBAC;
  • 应用版本和配置;
  • 数据库集群拓扑;
  • 跨集群恢复所需的映射关系;
  • 快照所在存储系统本身的可用性。

因此,完整灾备至少要同时考虑:

灾备能力=应用一致性+数据副本+Kubernetes 元数据+恢复编排+故障域隔离\text{灾备能力} = \text{应用一致性} + \text{数据副本} + \text{Kubernetes 元数据} + \text{恢复编排} + \text{故障域隔离}

同一存储阵列中的快照,不能自动抵御阵列级故障;同一区域的复制,也不能自动抵御区域级故障。


十一、故障排查:按状态边界定位

CSI 故障最有效的排查方法不是反复重启 Pod,而是确定流程停在哪个状态。

1. PVC 长期 Pending

检查:

kubectl get pvc,pv,sc
kubectl describe pvc <pvc-name>
kubectl get pods -A | grep -E 'provision|csi'

判断逻辑:

  • 没有 PV,且 PVC 需要动态供应:检查 provisioner;
  • PVC 事件提示无匹配 StorageClass:检查 storageClassName
  • 使用 WaitForFirstConsumer 且没有 Pod:这是预期延迟;
  • 后端权限或配额错误:查看 controller 和 provisioner 日志;
  • provisioner 名称不匹配:StorageClass 的 provisioner 必须与 CSI 驱动注册名一致。

2. PVC 已 Bound,但 Pod Pending 或 ContainerCreating

检查:

kubectl describe pod <pod-name>
kubectl get volumeattachment
kubectl describe volumeattachment <attachment-name>
kubectl get csinode
kubectl get csidriver

如果是 Attach 阶段,重点看:

  • VolumeAttachment 是否存在;
  • attached 是否为 true
  • 节点名是否正确;
  • 后端是否仍把卷连接到旧节点;
  • 驱动是否声明 attachRequired: false

如果 Attach 已成功,继续看 Node 插件日志和 kubelet 事件,因为失败可能发生在 NodeStage 或 NodePublish。

3. 设备存在但 mount 失败

这通常已经越过了供应和 Attach 阶段。可能原因包括:

  • 文件系统类型与 fsType 不匹配;
  • 文件系统损坏;
  • 节点缺少 mount helper;
  • 挂载参数不受内核支持;
  • SELinux、AppArmor 或权限策略阻止操作;
  • 设备路径尚未稳定出现;
  • 块设备被其他进程占用。

应在目标节点上以只读、谨慎方式检查设备和挂载状态。生产环境不要随意执行 fsck -y、重新格式化或删除挂载目录;这些动作可能直接破坏数据。

4. 卷无法卸载

常见原因:

  • 进程仍持有工作目录或文件句柄;
  • 容器运行时没有完成清理;
  • Node 插件重启导致状态丢失;
  • 节点失联;
  • 网络文件系统连接中断;
  • 驱动返回非幂等错误。

可以检查节点上的挂载关系和进程占用,但修复动作必须遵循驱动和云平台的 fencing 规则。对于块设备,如果旧节点仍可能写入,直接在新节点强制 Attach 可能导致文件系统损坏。


十二、故障恢复中的幂等、超时和状态漂移

CSI 调用通过网络完成,因此调用者可能遇到这种情况:

  1. kubelet 调用 NodePublishVolume
  2. CSI 插件已经完成挂载;
  3. 网络响应丢失;
  4. kubelet 认为调用失败并重试;
  5. 插件发现目标路径已经存在。

如果 CSI 驱动不是幂等的,重试可能导致“目标已存在”“重复挂载”或错误清理。正确实现应把“已经达到目标状态”视为成功,或者通过查询确认实际状态。

状态漂移也可能发生:

  • Kubernetes 对象显示未 Attach,但云平台已连接;
  • VolumeAttachment 显示 attached,但节点设备没有出现;
  • Pod 已删除,但 Node 插件仍保留挂载;
  • 后端卷已删除,但 PV 对象因控制器故障仍存在。

这时应分别对比四类事实:

Kubernetes 对象状态
CSI sidecar 日志
CSI 驱动日志
存储后端实际状态

不要只相信其中一层。status 是控制器观察到的状态,不一定是后端实时事实。


十三、节点故障与强制迁移风险

对于 RWO 块卷,节点宕机后,Kubernetes 可能需要把 Pod 调度到新节点。但安全迁移要求旧节点不会继续写入,也就是需要 fencing 或等价的存储侧保护。

危险流程如下:

旧节点失联
    ↓
Kubernetes 认为 Pod 应迁移
    ↓
新节点尝试 Attach
    ↓
后端仍认为旧节点持有卷

如果此时绕过保护强制把卷接到新节点,而旧节点实际上仍运行并写入,就可能产生双写和文件系统损坏。生产环境的恢复策略应明确:

  • 云平台是否支持强制分离;
  • 强制分离是否有数据损坏风险;
  • CSI 驱动如何报告旧节点状态;
  • StatefulSet 的故障转移时间;
  • 数据库是否能通过 WAL 或日志恢复;
  • 是否需要人工确认 fencing。

“Pod 一直 Terminating”不应直接通过删除 VolumeAttachment 或 PV 解决。删除 Kubernetes 对象可能掩盖后端仍然占用卷的事实,增加双挂载风险。


十四、CSI 与 StatefulSet 的关系

StatefulSet 常通过 volumeClaimTemplates 为每个副本创建独立 PVC。以三个副本为例,通常得到:

data-db-0
data-db-1
data-db-2

这三个 PVC 不会因为属于同一个 StatefulSet 就自动共享一个卷。每个副本的存储生命周期通常与其稳定网络身份和序号相关。

删除 StatefulSet 与删除 PVC 是两个不同动作。是否保留 PVC,取决于资源策略和具体配置;生产环境不能只凭“删除 Pod 会不会丢数据”的经验判断,应实际检查 PVC、PV 的回收策略以及 StatefulSet 的保留行为。

CSI 只负责卷的供应、连接、挂载和快照等存储动作,不负责数据库主从复制、选主、事务一致性或应用级故障转移。


十五、生产中最容易混淆的几个结论

“PVC Bound 就代表应用可以读写”

不成立。Bound 只说明 PVC 与 PV 已建立绑定关系。Attach、NodeStage、NodePublish 和容器权限仍可能失败。

“VolumeAttachment attached 就代表 Pod 已经挂载”

不成立。它只代表控制器侧 Attach 成功。节点侧仍可能找不到设备、无法格式化或无法完成 mount。

“快照成功就代表数据库可恢复”

不成立。快照可能只是崩溃一致性状态,且恢复还需要 Kubernetes 对象、数据库配置、密钥和应用编排。

“RWO 只能被一个 Pod 使用”

不完全准确。RWO 的核心是单节点读写约束;同节点多 Pod 的具体行为与驱动及挂载方式有关。需要单 Pod 约束时,应确认 RWOP 支持。

“删除 PV 就能修复存储故障”

高风险。PV 是 Kubernetes 对后端卷的引用和生命周期对象。删除它可能导致引用丢失、回收动作触发或排障线索消失,不会自动修复后端卷、文件系统或旧节点占用问题。

“CSI 驱动版本只要能启动就兼容”

不成立。CSI sidecar、Kubernetes API、CSI 驱动、快照 CRD、云厂商 API 和节点内核能力都存在兼容性边界。升级前应查对应版本矩阵,并在测试集群验证供应、Attach、挂载、扩容、快照和恢复,而不只是验证 Pod 能启动。


十六、用一条状态链理解 CSI

一个动态供应的块存储卷,可以抽象为以下状态:

PVC Pending
  ↓ CreateVolume 成功
PV Created / PVC Bound
  ↓ 需要 Attach 时创建 VolumeAttachment
ControllerPublishVolume 成功
  ↓ NodeStageVolume
节点级 Staged
  ↓ NodePublishVolume
Pod 目标路径 Published
  ↓
容器可访问

每个箭头都有独立的前置条件:

可访问PublishedStagedAttached(若需要)ProvisionedBound\text{可访问} \Rightarrow \text{Published} \Rightarrow \text{Staged} \Rightarrow \text{Attached(若需要)} \Rightarrow \text{Provisioned} \Rightarrow \text{Bound}

反向清理则要求引用关系逐层消失:

先 Unpublish再 Unstage再 Unpublish Controller最后按回收策略删除后端卷\text{先 Unpublish} \rightarrow \text{再 Unstage} \rightarrow \text{再 Unpublish Controller} \rightarrow \text{最后按回收策略删除后端卷}

这条链解释了为什么“PVC 已绑定但 Pod 仍启动失败”是正常可能性,也解释了为什么强行删除中间对象可能造成更严重的数据风险。

CSI 的核心不是某个 YAML 字段,而是控制器侧的后端资源协调、节点侧的设备和挂载操作、Kubernetes 对象状态以及存储系统真实状态之间的闭环。掌握 Controller、Node、Attach、Mount 和快照的边界后,才能把一次故障准确归因到供应、拓扑、连接、节点挂载、应用一致性或后端存储,而不是笼统地称为“PVC 挂载失败”。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。