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

StorageClass 与动态供给:Provisioner、Binding、扩容和拓扑

1. StorageClass 解决什么问题

Kubernetes 中,**PersistentVolume(PV)**描述一块已经存在或即将创建的持久化存储资源,**PersistentVolumeClaim(PVC)**描述工作负载需要什么样的存储。PVC 不直接实现存储,它只是向集群提出请求:

  • 需要多少容量;
  • 需要什么访问模式;
  • 使用文件系统还是块设备;
  • 使用哪类存储;
  • 是否需要特定拓扑或节点。

**StorageClass(存储类)**则把“这类存储应该如何创建”抽象出来。它通常包含:

  • provisioner:负责创建卷的 CSI 驱动名称;
  • parameters:传给驱动的存储参数;
  • reclaimPolicy:PVC 删除后 PV 如何处理;
  • volumeBindingMode:何时把 PVC 与 PV 绑定;
  • allowVolumeExpansion:是否允许在线或离线扩容;
  • mountOptions:挂载时使用的选项;
  • allowedTopologies:允许创建卷的拓扑范围。

因此,动态供给的逻辑可以概括为:

PVC 请求+StorageClass 策略Provisioner 创建 VolumePVPVC 绑定\text{PVC 请求} + \text{StorageClass 策略} \rightarrow \text{Provisioner 创建 Volume} \rightarrow \text{PV} \rightarrow \text{PVC 绑定}

这里的“创建 Volume”不是 Kubernetes API Server 直接调用云厂商 API,而是由某个存储实现完成。现代 Kubernetes 通常通过 CSI(Container Storage Interface)驱动完成这一过程。

需要区分三个概念:

  1. 声明:PVC 表示应用的存储需求;
  2. 供给:Provisioner 根据 PVC 创建实际存储并生成 PV;
  3. 使用:调度器、Attach/Mount 流程和 kubelet 让 Pod 访问已经绑定的卷。

PVC 处于 Bound 状态,只表示它已经绑定到一个 PV,并不保证卷已经成功挂载,也不保证底层存储当前可用。


2. 从 PVC 到 Pod 使用卷的完整组件链路

动态供给至少涉及以下角色:

组件 主要职责
API Server 保存 StorageClass、PVC、PV、Pod 等对象
PV Controller 处理 PVC/PV 的匹配、绑定和回收相关状态
External Provisioner 监听 PVC,根据 StorageClass 调用 CSI Controller 的 CreateVolume
CSI Controller Plugin 调用存储系统 API,创建、删除、扩容卷,并可能处理 Attach
CSI Node Plugin 在具体节点上执行 Stage、Publish、Mount 等操作
Scheduler 选择 Pod 节点,并参与延迟绑定的拓扑决策
kubelet 在节点上协调 CSI Node Plugin,把卷挂载到 Pod

CSI 驱动通常由多个容器组成,而不是一个单独的进程。典型控制面组件包括:

  • external-provisioner:动态供给;
  • external-attacher:处理需要 Attach 的卷;
  • external-resizer:处理扩容;
  • external-snapshotter:处理快照相关对象。

这些 sidecar 与 CSI Controller Plugin 通信,CSI Controller Plugin 再与真实存储系统通信。CSI Node Plugin 通常以 DaemonSet 形式部署在每个需要使用卷的节点上。

典型数据流如下:

sequenceDiagram
    participant U as 用户
    participant A as API Server
    participant P as PV Controller
    participant S as Scheduler
    participant EP as External Provisioner
    participant CC as CSI Controller
    participant K as Kubelet
    participant CN as CSI Node Plugin
    participant ST as 存储系统

    U->>A: 创建 StorageClass
    U->>A: 创建 PVC
    A-->>P: PVC 事件
    P-->>EP: PVC 需要动态供给
    EP->>CC: CreateVolume
    CC->>ST: 创建真实卷
    ST-->>CC: 返回 volumeHandle
    CC-->>EP: 返回卷信息
    EP->>A: 创建 PV
    P->>A: 将 PVC 与 PV 绑定

    U->>A: 创建引用 PVC 的 Pod
    S->>A: 观察 Pod、PVC、PV 和拓扑
    S->>A: 绑定 Pod 到节点
    K->>CN: NodeStage/NodePublish
    CN->>ST: 准备并挂载卷
    CN-->>K: 挂载完成
    K-->>U: 容器可访问挂载路径

实际环境中,如果卷需要云平台级 Attach,流程中还会出现 Attach/Detach Controller 和 external-attacher。某些存储类型不需要独立 Attach,例如部分网络文件系统;是否需要 Attach 由 CSI 驱动能力和存储实现决定。

2.1 Provisioner 不等于 CSI 驱动本身

StorageClass 中的:

provisioner: csi.example.com

表示一个 CSI 驱动名称。它通常与 CSI 驱动注册的 GetPluginInfo 名称一致。external-provisioner 监听到 PVC 后,会根据这个名称寻找对应的 CSI 控制器服务。

因此,以下配置不会凭空提供存储:

provisioner: csi.example.com

只有当集群中确实安装并运行了名称为 csi.example.com 的 CSI 驱动及其 sidecar 时,动态供给才可能成功。示例中的 csi.example.com 是占位名称,不能直接用于生产环境。

可以先检查集群实际安装了哪些 StorageClass 和 provisioner:

kubectl get storageclass
kubectl get storageclass -o custom-columns=NAME:.metadata.name,PROVISIONER:.provisioner,BINDING:.volumeBindingMode,EXPAND:.allowVolumeExpansion

预期输出类似:

NAME            PROVISIONER                  BINDING               EXPAND
fast            csi.example.com              WaitForFirstConsumer  true
shared-files    nfs.csi.k8s.io               Immediate             true

PROVISIONER 的值必须与已安装驱动注册的名称对应。不要把云厂商文档中的 parameters 或 driver 名称直接套用到另一个 CSI 驱动。


3. StorageClass 的关键字段

下面是一个通用结构。驱动专属参数只能根据对应 CSI 驱动文档填写。

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast
provisioner: csi.example.com
parameters:
  type: premium
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
mountOptions:
  - noatime

这个对象可以通过 kubectl apply 提交,但只有集群中安装了 csi.example.com,并且该驱动支持 type: premiumnoatime 等配置时,后续 PVC 才能成功供给。

3.1 provisioner

provisioner 决定谁处理使用该 StorageClass 的 PVC。

动态供给失败时,常见事件包括:

failed to provision volume with StorageClass "fast":
rpc error: code = InvalidArgument desc = unsupported parameter

或者:

no storage plugin matched

第一类通常是驱动参数错误;第二类常见于驱动未安装、名称不匹配或 provisioner 控制器未正常运行。

3.2 parameters

parameters 是驱动相关的键值对。例如某个块存储驱动可能接受:

parameters:
  type: premium
  encrypted: "true"

typeencrypted 是否有效、取值是什么,都不是 Kubernetes StorageClass API 的通用保证。Kubernetes 只负责保存并传递这些参数,语义由 CSI 驱动定义。

生产中尤其要注意:

  • 参数名称拼写错误可能直到创建 PVC 后才暴露;
  • 某些参数会影响计费、性能等级或可用区;
  • 某些驱动参数引用 Secret,Secret 命名空间和引用方式由驱动约定;
  • 修改 StorageClass 通常不是修改已有卷配置的通用方式,已经创建的卷通常不会自动改变类型或性能等级。

3.3 reclaimPolicy

常见值是:

  • Delete:PVC 删除后,PV 通常被回收,动态创建的底层卷也通常由 CSI 驱动删除;
  • Retain:PVC 删除后保留 PV 和底层数据,需要人工处理。

Delete 的删除链路通常是:

删除 PVC
→ PV 进入 Released 或被控制器处理
→ external-provisioner 调用 DeleteVolume
→ 底层卷删除

这里的“通常”很重要:最终是否删除底层卷取决于 CSI 驱动是否正确实现删除操作,以及回收流程是否遇到权限、网络或云平台保护策略。

Retain 不是“自动备份”。它只是避免 Kubernetes 自动删除卷,仍然需要人工确认:

  • PV 是否包含敏感数据;
  • 是否可以重新绑定;
  • 是否需要清理旧的 claimRef
  • 是否要建立新的 PVC;
  • 是否要防止误挂载到错误的应用。

StorageClass 的默认回收策略通常为 Delete;生产环境应显式设置,避免依赖默认值。

3.4 allowVolumeExpansion

allowVolumeExpansion: true

只表示该 StorageClass 允许请求扩容,不代表所有卷都能扩容,也不代表扩容一定在线完成。CSI 驱动还必须实现相应的 Controller 和 Node 扩容能力。

扩容是“增加请求容量”,不是修改原容量:

spec:
  resources:
    requests:
      storage: 200Gi

100Gi 改为 200Gi 合法;从 200Gi 改回 100Gi 的缩容通常不支持。Kubernetes 不会通过截断文件系统的方式自动缩容,因为这可能破坏数据。

3.5 volumeBindingMode

有两个核心值:

  • Immediate
  • WaitForFirstConsumer

它们决定 PVC 何时绑定和动态创建卷,后文会详细说明。对于具有可用区、机架、节点或区域限制的存储,WaitForFirstConsumer 往往是关键配置。

3.6 mountOptions

mountOptions 会传递给挂载流程,但 Kubernetes 不会验证每个选项是否被底层文件系统或 CSI 驱动支持。错误选项可能导致挂载失败:

MountVolume.SetUp failed ... mount failed: wrong fs type, bad option

不要把 NFS 专用选项、XFS 专用选项或云厂商专用选项无条件用于所有驱动。


4. 动态供给的前置条件:PVC 如何表达需求

一个 PVC 的典型定义如下:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: database-data
  namespace: app
spec:
  storageClassName: fast
  accessModes:
    - ReadWriteOnce
  volumeMode: Filesystem
  resources:
    requests:
      storage: 100Gi

各字段的含义如下:

  • storageClassName: fast:请求使用名为 fast 的 StorageClass;
  • accessModes:请求卷的访问模式;
  • volumeMode: Filesystem:Pod 中看到的是文件系统目录;
  • resources.requests.storage:请求的最小容量。

PVC 能否绑定到某个 PV,至少需要满足以下条件:

PV 可用 PV 容量PVC 请求容量 访问模式兼容 volumeMode 相同 StorageClass 相同 selector 满足 拓扑约束可满足\begin{aligned} &\text{PV 可用} \\ &\land\ \text{PV 容量} \geq \text{PVC 请求容量} \\ &\land\ \text{访问模式兼容} \\ &\land\ \text{volumeMode 相同} \\ &\land\ \text{StorageClass 相同} \\ &\land\ \text{selector 满足} \\ &\land\ \text{拓扑约束可满足} \end{aligned}

其中“拓扑约束可满足”在不同绑定时机下表现不同。尤其是 Immediate 可能先绑定,再发现 Pod 无法调度;WaitForFirstConsumer 会把卷创建或绑定推迟到 Pod 调度决策可用之后。

4.1 容量不是精确匹配

PVC 请求 100Gi,PV 容量为 120Gi,两者可以绑定:

PVC request = 100Gi
PV capacity = 120Gi
100Gi <= 120Gi

Kubernetes 不会因为容量多出 20Gi 而拒绝匹配。PV 的 capacity.storage 是可供绑定判断使用的容量声明,而不是应用一定可以精确使用的物理容量保证。

4.2 storageClassName 的三个重要状态

显式指定:

storageClassName: fast

表示使用 fast

显式指定空字符串:

storageClassName: ""

表示该 PVC 不使用 StorageClass 动态供给,也不会被默认 StorageClass 自动填充。它通常用于静态 PV 场景。

完全省略:

spec:
  resources:
    requests:
      storage: 100Gi

表示允许集群的默认 StorageClass 参与。默认 StorageClass 由注解标记:

metadata:
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"

集群中最好只维护一个明确的默认 StorageClass。多个默认类会增加行为歧义,且默认类变化可能影响新建 PVC 的结果。已经绑定的 PVC 不会因为默认 StorageClass 后续变化而重新选择存储。

4.3 访问模式不是应用级并发控制

常用访问模式包括:

  • ReadWriteOnce(RWO):卷可被一个节点以读写方式挂载;
  • ReadOnlyMany(ROX):卷可被多个节点只读挂载;
  • ReadWriteMany(RWX):卷可被多个节点读写挂载;
  • ReadWriteOncePod(RWOP):卷只能被单个 Pod 使用,适用于支持该能力的 CSI 驱动和相关组件。

ReadWriteOnce 的“一次”主要针对节点,不是 Pod。多个 Pod 位于同一个节点时,可能同时使用一个 RWO 卷。因此,RWO 不能替代数据库锁、文件锁或应用层主从协调。

相反,即使 PVC 声明了 RWX,应用也必须能够正确处理多实例并发写入。访问模式描述存储系统允许的挂载方式,不自动提供分布式一致性协议。


5. Binding:PVC 与 PV 如何建立关系

**Binding(绑定)**是 PVC 与一个 PV 建立一对一关系的过程。

绑定完成后通常可以看到:

kubectl get pvc database-data -n app
NAME            STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS
database-data   Bound    pvc-3e7...                                 100Gi      RWO            fast

PVC 的 .spec.volumeName 会指向 PV,PV 的 .spec.claimRef 会指向该 PVC。两者共同表达“这个 PV 已经被这个 PVC 占用”。

可以用以下命令查看完整关系:

kubectl get pvc database-data -n app -o yaml
kubectl get pv pvc-3e7... -o yaml

典型结果会包含:

# PVC
status:
  phase: Bound
  boundVolume: pvc-3e7...

# PV
spec:
  claimRef:
    namespace: app
    name: database-data

字段的具体状态还可能包含 UID 等信息,用于避免同名对象被错误复用。

5.1 动态绑定的中间状态

创建 PVC 后,常见状态转换是:

Pending
  ↓
Provisioning
  ↓
PV 创建成功
  ↓
Bound

Provisioning 往往不是 PVC 的独立 phase,而是事件或控制器处理过程。PVC 的 status.phase 主要常见为:

  • Pending
  • Bound
  • Lost

诊断时不要只看 phase,应同时查看事件:

kubectl describe pvc database-data -n app

可能看到:

Normal  ExternalProvisioning  waiting for a volume to be created
Normal  Provisioning           External provisioner is provisioning volume
Normal  Provisioned             Successfully provisioned volume

5.2 手工 PV 与动态 PV 的区别

静态 PV 是管理员先创建:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: static-data
spec:
  capacity:
    storage: 100Gi
  accessModes:
    - ReadWriteOnce
  storageClassName: ""
  persistentVolumeReclaimPolicy: Retain
  csi:
    driver: csi.example.com
    volumeHandle: preexisting-volume-id

对应 PVC:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: static-data-claim
  namespace: app
spec:
  storageClassName: ""
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Gi

这里的 storageClassName: "" 很重要:它避免 PVC 被默认 StorageClass 动态供给。否则,PVC 可能得到一块新的动态卷,而不是绑定管理员准备好的静态 PV。

5.3 selector 会限制动态供给

PVC 可以设置 selector:

spec:
  selector:
    matchLabels:
      environment: production

这会要求绑定的 PV 带有匹配标签。某些动态供给流程无法同时满足 selector 和自动创建要求,因此不能把“有 selector”与“必然能动态创建”混为一谈。使用 selector 时,应确认对应 provisioner 是否支持这一组合。


6. ImmediateWaitForFirstConsumer

6.1 Immediate:PVC 创建后立即供给

volumeBindingMode: Immediate

流程是:

创建 PVC
→ 立即创建真实卷
→ 创建 PV
→ PVC 绑定 PV
→ Pod 后续调度

它适合拓扑不敏感的存储,例如一个可以从所有节点访问的共享文件系统。

但对于区域型块存储,这个顺序可能产生问题。假设:

  • 节点 node-a 位于 zone-a
  • 节点 node-b 位于 zone-b
  • CSI 驱动在 zone-a 创建了卷;
  • Pod 由于资源、污点或亲和性只能调度到 zone-b

那么 PVC 可能已经是 Bound,但 Pod 无法使用这个卷:

0/2 nodes are available:
1 node(s) had volume node affinity conflict

这不是 Binding 失败,而是绑定发生得太早,导致后续调度约束无法满足。

6.2 WaitForFirstConsumer:等待 Pod 的调度上下文

volumeBindingMode: WaitForFirstConsumer

典型流程变为:

创建 PVC
→ PVC 保持 Pending
→ 创建引用该 PVC 的 Pod
→ Scheduler 综合 Pod 与存储拓扑选择节点
→ Provisioner 按选择结果创建卷
→ 创建 PV 并绑定 PVC
→ Pod 在该节点挂载卷

示例:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: zonal-block
provisioner: csi.example.com
parameters:
  type: premium
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
allowVolumeExpansion: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: app-data
  namespace: app
spec:
  storageClassName: zonal-block
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 50Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: app
  namespace: app
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["/bin/sh", "-c", "echo ready; sleep 3600"]
      volumeMounts:
        - name: data
          mountPath: /data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: app-data

这个配置的前提是:

  1. csi.example.com 已安装;
  2. 该驱动支持拓扑感知供给;
  3. 节点具有驱动所识别的拓扑标签;
  4. CSI sidecar 和调度器版本、配置能够协同处理延迟绑定。

仅仅把 volumeBindingMode 改成 WaitForFirstConsumer,并不能让不支持拓扑的驱动自动获得拓扑能力。

6.3 PVC 长时间 Pending 是否一定是故障

使用 WaitForFirstConsumer 时,只有 PVC 而没有任何 Pod 消费它,PVC 保持 Pending 是正常的:

kubectl get pvc app-data -n app
NAME      STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS
app-data  Pending                                      zonal-block

此时应查看:

kubectl describe pvc app-data -n app

如果事件表明正在等待消费者,说明延迟绑定正在生效。若已经有 Pod,仍然 Pending,则应继续检查 Pod:

kubectl describe pod app -n app

重点观察:

  • 是否存在不可满足的节点亲和性;
  • 是否有污点但没有对应 toleration;
  • 节点资源是否不足;
  • 是否所有候选节点都不具备 CSI 驱动;
  • 是否发生 volume node affinity conflict
  • 是否有调度器或 provisioner 错误事件。

7. 拓扑:卷为什么不能随便放置

**拓扑(topology)**描述资源所在的区域、可用区、机架或节点范围。常见标签键包括:

topology.kubernetes.io/region
topology.kubernetes.io/zone
kubernetes.io/hostname

具体支持哪些键由 CSI 驱动和集群配置决定。拓扑的本质是限制:

卷位置允许位置\text{卷位置} \in \text{允许位置}

而 Pod 调度还要满足:

Pod 节点卷可访问位置\text{Pod 节点} \in \text{卷可访问位置}

两者的交集为空时,Pod 就无法调度或挂载。

7.1 allowedTopologies

StorageClass 可以限制动态卷只能创建在特定拓扑中:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: zone-a-only
provisioner: csi.example.com
volumeBindingMode: WaitForFirstConsumer
allowedTopologies:
  - matchLabelExpressions:
      - key: topology.kubernetes.io/zone
        values:
          - zone-a

这表示动态创建的卷只能位于 zone-a。它不是把所有节点都移动到 zone-a,也不是给 Pod 自动添加节点亲和性。

如果 Pod 自身只能调度到 zone-b,而 StorageClass 只允许 zone-a,则不存在满足条件的节点。结果通常表现为 Pod Pending,事件中出现类似:

node(s) had volume node affinity conflict

或:

pod has unbound immediate PersistentVolumeClaims

7.2 PV 的 nodeAffinity

拓扑型 PV 通常会带有节点亲和性:

spec:
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: topology.kubernetes.io/zone
              operator: In
              values:
                - zone-a

这表示该 PV 只能在满足条件的节点上使用。对于动态创建的卷,这类约束通常由 provisioner 根据存储系统返回的拓扑写入 PV。

nodeAffinity 是 PV 的可用位置约束,不是普通 Pod 的偏好设置。Pod 如果没有合适节点,调度器不能忽略它。

7.3 Immediate 的拓扑反例

假设集群有两个节点:

node-a: zone-a
node-b: zone-b

一个 Immediate StorageClass 创建卷后,存储系统返回:

volumeHandle: vol-001
volume zone: zone-a
PV nodeAffinity: zone-a

而 Pod 还有如下约束:

nodeSelector:
  topology.kubernetes.io/zone: zone-b

则约束集合为:

Pod 可用位置={zoneb}\text{Pod 可用位置} = \{zone-b\}

PV 可用位置={zonea}\text{PV 可用位置} = \{zone-a\}

{zoneb}{zonea}=\{zone-b\} \cap \{zone-a\} = \varnothing

所以:

  1. PVC 可以是 Bound
  2. PV 可以是 Available 之外的正常绑定状态;
  3. Pod 仍然无法调度;
  4. 删除并重建 Pod 不会改变已经绑定的 PV;
  5. 通常需要调整 Pod 约束,或在确认数据可删除后删除 PVC/PV/底层卷重新供给。

WaitForFirstConsumer 的价值,就是尽量在创建卷之前计算这个交集。

7.4 StatefulSet 与拓扑

StatefulSet 的 volumeClaimTemplates 会为每个 Pod 创建独立 PVC,例如:

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

每个 PVC 都可能触发独立的动态供给。对于跨可用区的 StatefulSet,需要同时考虑:

  • 每个副本的 Pod 调度位置;
  • 每个 PVC 的卷拓扑;
  • 副本之间是否需要分散到不同区域;
  • 数据库自身的复制机制;
  • 存储系统是否支持跨区域访问。

一个 StatefulSet 副本的 RWO 卷不能因为增加副本数就自动变成共享卷;每个副本通常需要自己的 PVC。若数据库需要故障域级高可用,不能仅依赖 Kubernetes 把多个 RWO 卷创建出来,还要由数据库复制协议保证数据可用性。


8. Binding 不是 Attach,也不是 Mount

这三个阶段经常被混淆。

8.1 Binding

Binding 建立 Kubernetes 对象关系:

PVC ↔ PV

它主要回答:

这个 PVC 使用哪一个 PV?

完成 Binding 不代表节点已经能访问卷。

8.2 Attach

Attach 通常把卷连接到某个节点,例如云平台可能需要把云盘挂载到虚拟机。它主要回答:

这个卷是否已经连接到目标节点?

并非所有存储都需要独立 Attach。CSI 驱动通过能力声明和控制器流程决定是否需要。

8.3 Mount 与 Publish

节点侧通常包含多个逻辑阶段:

  1. NodeStageVolume:在节点上准备卷,常见做法是格式化并挂载到 staging 目录;
  2. NodePublishVolume:把 staging 路径绑定挂载到 Pod 使用的目标路径;
  3. kubelet 把目标路径挂入容器。

文件系统卷大致是:

块设备
→ 文件系统格式化
→ 节点 staging 目录
→ Pod 目录
→ 容器内 /data

块设备模式则不是让 kubelet把卷作为目录直接使用,而是把设备暴露给容器。PVC 示例:

spec:
  volumeMode: Block

容器需要使用:

volumeDevices:
  - name: data
    devicePath: /dev/xvdb

如果 volumeMode 与 PV 不匹配,PVC 不能正常绑定;如果应用本身期待文件系统,却请求了 Block,即使卷已绑定,应用也无法按目录方式访问。


9. 扩容:从 PVC 请求到文件系统变大

扩容包含至少两个层次:

控制器扩容底层卷容量增加节点侧扩容文件系统扩容\text{控制器扩容} \rightarrow \text{底层卷容量增加} \rightarrow \text{节点侧扩容} \rightarrow \text{文件系统扩容}

9.1 配置允许扩容

StorageClass:

allowVolumeExpansion: true

检查:

kubectl get sc fast -o jsonpath='{.allowVolumeExpansion}{"\n"}'

如果输出为:

true

说明该 StorageClass 允许 PVC 请求扩容。仍然要确认 CSI 驱动支持 ControllerExpandVolume,并且节点插件支持需要的 NodeExpandVolume

9.2 执行扩容

初始 PVC:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: database-data
  namespace: app
spec:
  storageClassName: fast
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Gi

修改为:

spec:
  resources:
    requests:
      storage: 200Gi

可以使用:

kubectl patch pvc database-data -n app \
  --type merge \
  -p '{"spec":{"resources":{"requests":{"storage":"200Gi"}}}}'

观察状态:

kubectl get pvc database-data -n app -w

检查详细事件:

kubectl describe pvc database-data -n app

成功后,通常会看到:

status:
  capacity:
    storage: 200Gi

但过程可能分为不同阶段:

  • PVC requests.storage 已变为 200Gi
  • PV capacity.storage 仍暂时是 100Gi
  • 控制器调用 CSI ControllerExpandVolume
  • PV 容量更新;
  • 如果卷已挂载,节点插件执行文件系统扩容;
  • PVC 状态最终反映新容量。

不要只在修改后立即读取一次对象就判断失败。扩容是异步操作,应结合事件、CSI 控制器日志、节点插件日志和底层存储平台状态确认。

9.3 在线扩容与文件系统边界

块设备扩大后,文件系统仍可能保持旧大小。CSI 驱动必须完成节点侧扩容,文件系统还必须支持相应的扩展操作。

常见边界包括:

  • 卷未挂载时,可能只完成底层块设备扩容;
  • 卷已挂载时,支持在线扩容的文件系统可以继续使用;
  • 某些文件系统或驱动要求卸载后扩容;
  • 用户态看到的 df -h 可能比 PVC 的 status.capacity 更新更晚;
  • 设备容量、分区容量、文件系统容量是三个不同观察层次。

可以在 Pod 内检查:

kubectl exec -n app database-pod -- df -h /var/lib/database

也可以在节点侧由管理员检查块设备和挂载状态,但不能把进入节点执行命令当作所有环境都适用的通用诊断方式。托管 Kubernetes 往往不允许或不建议直接修改节点设备。

9.4 扩容失败后的处理

典型错误:

failed to expand volume
rpc error: code = Unimplemented desc = volume expansion is not supported

说明驱动没有实现对应能力,或者 sidecar 与驱动版本不匹配。

另一个常见问题是请求值已经变大,但实际 PV 没有更新:

PVC request: 200Gi
PV capacity: 100Gi

此时不要尝试把请求值改回更小值来“重置”。应检查:

kubectl describe pvc database-data -n app
kubectl describe pv <pv-name>
kubectl -n <csi-namespace> logs deploy/<external-resizer>
kubectl -n <csi-namespace> logs <csi-controller-pod> -c <driver-container>

具体资源名称随 CSI 驱动安装方式变化。恢复动作通常是修复驱动权限、云平台配额、API 连通性或 sidecar 配置,然后等待控制器重试。

9.5 扩容与 StatefulSet

StatefulSet 的 volumeClaimTemplates 描述如何创建 PVC,但已经创建的 PVC 是独立对象。修改模板不应被理解为“自动修改所有现有 PVC 的容量”。

已有卷通常需要逐个修改:

kubectl patch pvc data-db-0 -n app \
  --type merge \
  -p '{"spec":{"resources":{"requests":{"storage":"200Gi"}}}}'

然后验证每个副本的:

  • PVC 请求值;
  • PV 容量;
  • 文件系统实际容量;
  • 数据库是否感知到新空间;
  • 扩容期间是否需要停止或切换副本。

10. 一个完整的动态供给示例

下面使用虚构的 csi.example.com,因此“可执行”的含义是 YAML 语法和 Kubernetes 对象关系完整;真正执行前,必须把 provisioner、参数和镜像环境替换为已安装驱动支持的值。

10.1 创建 StorageClass

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast-zonal
provisioner: csi.example.com
parameters:
  type: premium
reclaimPolicy: Retain
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer

应用:

kubectl apply -f storageclass.yaml
kubectl get sc fast-zonal

预期:

NAME         PROVISIONER        RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION
fast-zonal   csi.example.com    Retain          WaitForFirstConsumer   true

选择 Retain 是为了演示数据保留风险:删除 PVC 后,底层数据不会按默认流程立即删除。生产中是否选择 Retain,取决于数据恢复和清理流程,不能简单认为它永远更安全,因为遗留卷也可能带来成本和敏感数据暴露。

10.2 创建 PVC

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: web-data
  namespace: app
spec:
  storageClassName: fast-zonal
  accessModes:
    - ReadWriteOnce
  volumeMode: Filesystem
  resources:
    requests:
      storage: 10Gi

命令:

kubectl create namespace app
kubectl apply -f pvc.yaml
kubectl get pvc web-data -n app

在还没有 Pod 消费它时,使用 WaitForFirstConsumer 的 PVC 处于 Pending 是预期行为。

10.3 创建消费者 Pod

apiVersion: v1
kind: Pod
metadata:
  name: web
  namespace: app
spec:
  containers:
    - name: writer
      image: busybox:1.36
      command:
        - /bin/sh
        - -c
        - |
          date >> /data/heartbeat
          sleep 3600
      volumeMounts:
        - name: data
          mountPath: /data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: web-data

执行:

kubectl apply -f pod.yaml
kubectl get pod web -n app -w
kubectl get pvc web-data -n app -w
kubectl get pv

成功路径是:

  1. Scheduler 为 Pod 找到满足资源、污点、亲和性和卷拓扑的节点;
  2. PVC 的延迟绑定条件被满足;
  3. external-provisioner 调用 CSI CreateVolume
  4. CSI 驱动返回底层卷标识;
  5. provisioner 创建 PV;
  6. PV Controller 将 PVC 绑定到 PV;
  7. kubelet 调用节点侧 CSI 操作;
  8. 容器中 /data/heartbeat 可写入。

验证:

kubectl exec -n app web -- cat /data/heartbeat

如果 Pod 已调度但容器没有运行,继续查看:

kubectl describe pod web -n app
kubectl describe pvc web-data -n app
kubectl get pv

应区分故障阶段:

  • PVC 仍 Pending:关注 provisioner、Binding、调度拓扑;
  • PVC Bound 但 Pod Pending:关注调度器和 PV 的 nodeAffinity;
  • Pod 已调度但 ContainerCreating:关注 Attach、Stage、Publish、Mount;
  • 容器运行但写入失败:关注文件系统权限、只读挂载、容量和应用用户 UID。

11. 删除、回收与“数据是否还在”

删除消费者 Pod 通常不会删除 PVC:

kubectl delete pod web -n app

这通常只触发卸载流程,PVC 和 PV 仍然存在。重新创建引用同名 PVC 的 Pod,可以再次使用同一卷。

删除 PVC 才会进入回收流程:

kubectl delete pvc web-data -n app

如果 StorageClass 使用:

reclaimPolicy: Retain

可能看到:

kubectl get pv
NAME                                       STATUS     CLAIM   STORAGECLASS   RECLAIM POLICY
pvc-...                                    Released           fast-zonal     Retain

Released 表示原 PVC 已删除,但 PV 仍保留。它通常不会自动绑定给新的 PVC,因为原 PV 仍带有旧的 claimRef,而且重新使用前必须确认数据归属和清理策略。

如果使用:

reclaimPolicy: Delete

动态创建的 PV 通常会触发底层卷删除。删除 PVC 前应确认:

  • 数据是否已经备份;
  • 是否仍有其他业务依赖该卷;
  • CSI 驱动的删除权限是否正确;
  • 云平台是否有回收站、快照或延迟删除机制;
  • 数据保留要求是否允许自动删除。

生产故障中,误删 PVC 可能比 Pod 故障更严重。PV 的回收策略是生命周期控制,不是备份机制。


12. 常见失败路径与诊断顺序

12.1 PVC 一直 Pending,事件显示没有可用 PV

可能原因:

  • StorageClass 名称错误;
  • 没有默认 StorageClass;
  • provisioner 未安装;
  • CSI 控制器未运行;
  • parameters 不被驱动支持;
  • 云平台配额不足;
  • WaitForFirstConsumer 尚未出现消费者;
  • PVC selector、访问模式或 volumeMode 与现有 PV 不匹配。

诊断:

kubectl get pvc <name> -n <namespace>
kubectl describe pvc <name> -n <namespace>
kubectl get sc <storage-class> -o yaml
kubectl get pv

如果是动态供给,继续定位 provisioner:

kubectl get pods -A | grep -i csi
kubectl get events -A --sort-by=.lastTimestamp

CSI 部署所在命名空间和 Pod 名称因发行版不同,不应假设固定名称。

12.2 PVC Bound,但 Pod 无法调度

重点检查:

kubectl describe pod <pod> -n <namespace>
kubectl get pv <pv-name> -o yaml

特别查看:

spec:
  nodeAffinity:

以及 Pod 的:

  • nodeSelector
  • nodeAffinity
  • tolerations
  • 资源请求;
  • 其他卷的拓扑限制。

如果使用 Immediate 且 PV 已落在错误可用区,调度器不能简单把卷移动到另一个区域。更换节点标签也不能改变云盘真实位置;应该根据数据保留要求决定调整 Pod 约束、迁移数据还是重建卷。

12.3 PVC Bound,但 Pod 卡在 ContainerCreating

查看事件:

kubectl describe pod <pod> -n <namespace>

常见错误:

FailedAttachVolume
FailedMount
MountVolume.SetUp failed
rpc error: code = DeadlineExceeded

诊断方向:

  • CSI Node Plugin 是否运行在目标节点;
  • CSI Controller 是否完成 Attach;
  • 节点是否能访问存储服务;
  • 节点上是否存在所需的内核模块或挂载工具;
  • Secret 是否存在且权限正确;
  • 文件系统类型和 mountOptions 是否正确;
  • 卷是否已被其他节点占用;
  • 云平台 API 是否限流或返回配额错误。

这里不要反复删除 Pod 作为主要诊断方法。删除 Pod 通常只会重复同一失败路径,先保留事件和 CSI 日志更有价值。

12.4 扩容请求已更新,但容量没有变

先区分三种值:

kubectl get pvc <name> -n <namespace> \
  -o jsonpath='request={.spec.resources.requests.storage} capacity={.status.capacity.storage}{"\n"}'

kubectl get pv <pv-name> \
  -o jsonpath='pv-capacity={.spec.capacity.storage}{"\n"}'

可能出现:

request=200Gi capacity=100Gi
pv-capacity=100Gi

这表示扩容请求已经提交,但控制器尚未成功扩展底层卷。

如果 PVC 和 PV 都显示 200Gi,但容器内仍是 100Gi

kubectl exec -n app <pod> -- df -h /data

则问题位于节点侧扩容或文件系统刷新阶段,而不是控制器供给阶段。


13. API 与实现边界

13.1 Kubernetes 保证的部分

Kubernetes StorageClass API 定义并管理:

  • StorageClass 对象及其字段;
  • PVC 对 StorageClass 的引用;
  • PV/PVC 的绑定关系;
  • 回收策略字段;
  • 延迟绑定模式;
  • 扩容请求和状态表达;
  • 拓扑约束对象模型。

13.2 CSI 驱动负责的部分

CSI 驱动负责具体实现:

  • 如何创建和删除底层卷;
  • 哪些参数有效;
  • 是否支持某种访问模式;
  • 是否支持 Attach;
  • 是否支持快照;
  • 是否支持控制器扩容和节点扩容;
  • 如何处理拓扑;
  • 如何执行格式化、Stage、Publish 和 Mount;
  • Secret 如何认证;
  • 失败如何重试或返回错误。

因此,“StorageClass 设置了 allowVolumeExpansion: true”不能推导出“该云盘一定可以在线扩容”;“PVC 设置了 RWX”也不能推导出“任意 CSI 驱动都能提供多节点读写”。

13.3 版本与兼容性

现代集群使用 storage.k8s.io/v1 的 StorageClass API。CSI 是当前主流扩展方式,早期内置卷插件和旧版 in-tree provisioner 已逐步迁移或弃用;具体迁移状态取决于 Kubernetes 版本、发行版和云厂商。

以下能力对版本和组件版本较敏感:

  • ReadWriteOncePod 需要 CSI 驱动和相关 sidecar 支持;
  • 在线扩容需要 CSI Controller、Node 插件及 sidecar 协同;
  • 拓扑感知供给依赖 Scheduler、external-provisioner 和 CSI 驱动共同支持;
  • 快照依赖 VolumeSnapshot 相关 CRD、snapshot-controller 和 CSI 驱动能力;
  • 云厂商的 StorageClass 参数不是 Kubernetes 通用 API。

升级 Kubernetes 或 CSI 组件时,应同时检查:

kubectl get csidrivers
kubectl get csinodes
kubectl get storageclass -o yaml
kubectl get pods -A | grep -i csi

不要只升级 Kubernetes 控制面而忽略 CSI sidecar 和节点插件的兼容性。


14. 生产中需要明确的取舍

14.1 默认 StorageClass 不是数据分类系统

默认 StorageClass 只能解决“未指定存储类的 PVC 用哪个类”。它不能表达:

  • 数据库和日志使用不同性能;
  • 测试环境和生产环境使用不同保留策略;
  • 不同租户使用不同加密密钥;
  • 不同应用必须位于不同故障域。

这些需求应通过显式 StorageClass、命名空间策略、准入控制和应用配置表达,而不是依赖默认值。

14.2 DeleteRetain 的选择取决于生命周期

临时环境通常适合自动删除,以避免遗留资源。生产数据可能需要 Retain,但必须配套:

  • 数据归档流程;
  • PV Released 清理流程;
  • 底层卷盘点;
  • 恢复演练;
  • 删除审批或准入限制。

没有配套运营流程时,Retain 只会把删除责任从控制器转移给人工。

14.3 拓扑不是高可用的同义词

WaitForFirstConsumer 解决的是“卷创建位置与 Pod 调度位置协调”的问题,不会自动复制数据,也不会使单副本数据库跨区域高可用。

如果底层卷只存在于一个可用区,那么该卷所在区域故障时,Pod 可能无法在另一个区域直接启动。跨区域高可用需要存储系统复制、数据库复制、快照恢复或应用级容灾设计。

14.4 容量成功不代表性能成功

PVC 只表达容量和访问模式等约束。StorageClass 的 parameters 可能选择 IOPS、吞吐、介质类型或加密方式,但 Kubernetes 本身不验证业务是否达到预期性能。

验收存储时至少要分别验证:

  • PVC/PV 容量;
  • Pod 内文件系统容量;
  • 挂载读写权限;
  • 节点和区域故障下的可用性;
  • 扩容流程;
  • 删除后的数据行为;
  • CSI 控制器和节点插件的恢复能力。

StorageClass 的核心价值,是把“存储请求”和“存储实现”解耦;动态供给的核心价值,是让 PVC 触发标准化的卷创建流程。真正理解它,必须把 Binding、调度拓扑、CSI 控制器、节点挂载和生命周期回收看成一条连续链路,而不能把 Bound 状态误认为“数据已经可用”。


系列导航与关联阅读

官方资料

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