# k8s 存储管理


## 导读

容器文件系统随 Pod 销毁而消失，有状态应用需要数据持久化才能正常运行。K8s 通过 Volume 机制把外部存储挂载进容器，又以 PV/PVC/StorageClass 三层抽象解耦存储供给与消费，让应用开发者无需关心底层存储实现。本篇梳理 Volume 类型与持久化抽象、静态与动态供给差异、Pod 挂载全流程，以及 CSI 插件机制与主流存储后端。

## 核心概念

Volume 是 Pod 中可供容器访问的目录，生命周期与 Pod 绑定。**emptyDir** 在 Pod 调度到节点时创建，初始为空，Pod 内所有容器可共享读写，Pod 删除后数据一并清除，适合临时缓存、多容器文件交换等场景。**hostPath** 把节点上的文件或目录直接挂载进 Pod，数据在 Pod 销毁后仍保留在节点上，但 Pod 漂移到其他节点时无法访问原有数据，仅适用于 DaemonSet 或需要访问节点文件系统的特殊场景。

configMap 和 secret 作为特殊的 Volume 类型，把 API 对象中的数据以文件形式注入容器。configMap 挂载后每个 key 成为一个文件，内容为对应的 value，适合注入配置文件；secret 与 configMap 结构一致，但数据经过 base64 编码，且 tmpfs 挂载不落盘，用于传递敏感信息如密码、证书、令牌。这两种 Volume 都是只读的，由 kubelet 定时同步更新。

持久化存储通过三层抽象实现解耦。**PersistentVolume（PV）** 是集群中的一块存储资源，由管理员预先创建或由 StorageClass 动态供给，生命周期独立于 Pod，包含容量、访问模式、回收策略等属性。**PersistentVolumeClaim（PVC）** 是用户对存储的申请，声明所需容量、访问模式和 StorageClass 名称，控制面自动寻找匹配的 PV 并绑定。**StorageClass** 定义存储的类型和供给参数，指向一个 Provisioner，由它负责在存储后端创建实际卷并生成 PV 对象。

```mermaid
graph TB
    P[Pod] -->|volumeMounts 挂载| V[Volume]
    V -->|persistentVolumeClaim 引用| PVC[PersistentVolumeClaim]
    PVC -->|绑定| PV[PersistentVolume]
    PV -->|provisioned by| SC[StorageClass]
    SC -->|调用| PROV[Provisioner]
    PROV -->|创建实际卷| BACK[存储后端<br/>NFS / Ceph / 云盘 / ...]
    PV -->|指向| BACK
```

*图 1：PV / PVC / StorageClass 三层抽象与 Pod 的绑定关系*

图 1 展示了从 Pod 到实际存储的完整层次。Pod 通过 `volumeMounts` 把 Volume 挂到容器内路径，Volume 再通过 `persistentVolumeClaim` 字段引用 PVC，PVC 与 PV 一一绑定，PV 则指向底层真实存储。StorageClass 不直接参与挂载链路，它是 PV 的"模板工厂"，负责按 PVC 的规格动态生成 PV 和对应后端卷。这种分层使得应用只需声明 PVC，存储管理员只需维护 StorageClass，双方职责清晰。

访问模式描述卷的挂载方式，**ReadWriteOnce** 表示仅可被单个节点以读写方式挂载（同一节点上的多个 Pod 可共享），**ReadOnlyMany** 表示可被多个节点以只读方式挂载，**ReadWriteMany** 表示可被多个节点同时读写。并非所有存储后端都支持全部模式，例如块存储通常只支持 ReadWriteOnce，文件存储如 NFS、CephFS 支持 ReadWriteMany。

## 图解原理

静态供给由管理员预先创建一批 PV，用户提交 PVC 后，控制面的 **PersistentVolumeController** 在现有 PV 中查找容量和访问模式匹配的对象，找到则将二者绑定。动态供给则无需提前创建 PV，当 PVC 指定了 StorageClass 且没有匹配的现成 PV 时，StorageClass 对应的 Provisioner 会自动在存储后端创建卷，并据此生成一个新的 PV 对象，再与 PVC 完成绑定。动态供给大幅减少了管理员的手动操作，是生产环境的主流方式。

```mermaid
flowchart LR
    A[用户提交 PVC] --> B{匹配现有 PV?}
    B -->|是| C[PV 控制器绑定 PVC 与 PV]
    B -->|否| D[查找 StorageClass]
    D --> E[调用 Provisioner]
    E --> F[在存储后端创建实际卷]
    F --> G[自动创建 PV 对象]
    G --> C
    C --> H[Pod 引用 PVC]
    H --> I[kubelet 挂载卷到容器]
```

*图 2：动态供给的请求与创建链路*

图 2 描述了从 PVC 提交到 Pod 挂载的完整流程。用户提交 PVC 后，控制面先尝试匹配已有 PV，若未命中则走动态供给路径：根据 `storageClassName` 找到 StorageClass，调用其 Provisioner 在后端创建实际卷，再自动生成 PV 对象，随后 PVC 与新 PV 绑定。Pod 启动时，kubelet 根据 PVC 找到对应的 PV，调用存储插件把实际卷挂载到节点的全局路径，再通过 bind mount 映射进容器的指定目录。

回收策略决定 PVC 删除后 PV 的去向。**Retain** 保留 PV 和后端数据，需管理员手动清理，适合重要数据；**Delete** 在 PVC 删除时同时删除 PV 和后端存储卷，由动态供给的 StorageClass 默认使用，适合临时或可重建的数据；**Recycle** 已废弃，其行为是对卷执行 `rm -rf` 后重新可用，新的存储实现应优先使用动态供给替代。

Pod 挂载卷的过程分两步。**Attach** 阶段由控制面的 AttachDetachController 执行，把存储卷附加到 Pod 所在节点（对块存储而言就是 attach 磁盘，对文件存储则跳过此步）。**Mount** 阶段由 kubelet 中的 volume manager 执行，把已附加的卷格式化（按需）并挂载到节点的 `/var/lib/kubelet/pods/<pod-uid>/volumes/` 目录，再 bind mount 进每个容器的指定路径。卸载时按相反顺序执行 unmount 和 detach。

## CSI 插件机制与常见存储后端

早期 K8s 的存储插件都写在核心代码库中，新增存储支持需要发版，迭代缓慢。**CSI（Container Storage Interface）** 定义了一套标准接口，把存储供给、挂载、快照等能力从 K8s 核心解耦，存储厂商只需实现 CSI Driver 即可接入 K8s，无需修改主干代码。CSI Driver 通常以 StatefulSet 或 DaemonSet 形式部署在集群中，包含 controller 组件（负责卷的创建/删除/快照等控制面操作）和 node 组件（负责节点上的挂载/卸载）。

常见存储后端各有适用场景。**NFS** 部署简单、支持 ReadWriteMany，适合轻量共享存储，但性能和可靠性依赖底层网络和 NFS 服务器，生产环境需做高可用。**Ceph** 提供块存储（RBD）、文件存储（CephFS）和对象存储（RGW）三种接口，支持动态供给和快照，可靠性高、扩展性好，是自建集群的主流选择，运维复杂度也相对较高。**云厂商块存储**（AWS EBS、阿里云云盘、腾讯云 CBS 等）通过各自的 CSI Driver 接入，支持动态供给、快照、扩容，与云主机亲和性好，适合云上生产环境。**Local Persistent Volume** 直接使用节点本地磁盘，延迟最低、性能最好，但 Pod 被调度到其他节点时数据不可用，适合对延迟敏感且可接受节点级故障的有状态应用，需配合节点亲和性使用。

## 动手实操

以下示例在集群中演示静态 PV 创建、PVC 绑定与 Pod 挂载，随后配置 NFS StorageClass 实现动态供给。操作前确认集群节点可访问存储后端，使用 NFS 时需在节点安装 `nfs-utils` 或 `nfs-common` 包。

先创建一个基于 NFS 的静态 PV，容量 5Gi，访问模式 ReadWriteMany，回收策略 Retain。`server` 和 `path` 替换为实际 NFS 服务器地址和共享路径。

```yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-pv-5g
spec:
  capacity:
    storage: 5Gi
  accessModes:
    - ReadWriteMany
  persistentVolumeReclaimPolicy: Retain
  storageClassName: nfs-static
  nfs:
    server: 192.168.1.100
    path: /data/k8s-pv
```

提交后查看 PV 状态，`STATUS` 应为 `Available`，表示尚未被任何 PVC 绑定。

```bash
kubectl apply -f nfs-pv.yaml
kubectl get pv nfs-pv-5g
```

接着创建 PVC，申请 2Gi 空间，指定 storageClassName 为 `nfs-static`，访问模式与 PV 一致即可触发绑定。PVC 的容量不超过 PV 容量、访问模式兼容，且 storageClassName 相同，控制面就会自动完成绑定。

```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-pvc
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 2Gi
  storageClassName: nfs-static
```

提交后观察 PV 和 PVC 的状态变化。PV 的 `STATUS` 从 `Available` 变为 `Bound`，PVC 的 `STATUS` 也变为 `Bound`，二者一一对应。

```bash
kubectl apply -f pvc.yaml
kubectl get pvc data-pvc
kubectl get pv nfs-pv-5g
```

创建 Pod 引用该 PVC，通过 `volumeMounts` 挂载到容器的 `/data` 目录。Pod 内写入 `/data` 的文件会持久化到 NFS 后端，Pod 删除重建后数据仍然存在。

```yaml
apiVersion: v1
kind: Pod
metadata:
  name: storage-demo
spec:
  containers:
  - name: app
    image: nginx:1.27
    volumeMounts:
    - name: data
      mountPath: /data
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: data-pvc
```

进入容器写入测试文件，删除 Pod 后重建，验证数据仍然存在。

```bash
kubectl apply -f pod-pvc.yaml
kubectl exec storage-demo -- sh -c 'echo "hello storage" > /data/test.txt'
kubectl delete pod storage-demo
kubectl apply -f pod-pvc.yaml
kubectl exec storage-demo -- cat /data/test.txt
```

接下来演示动态供给。部署 NFS CSI Driver 后，创建一个 StorageClass，指向 NFS Provisioner。`provisioner` 字段的值需与实际部署的 CSI Driver 名称一致，`parameters` 中的 server 和 share 替换为真实值。

```yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nfs-csi
provisioner: nfs.csi.k8s.io
parameters:
  server: 192.168.1.100
  share: /data/k8s-dynamic
reclaimPolicy: Delete
volumeBindingMode: Immediate
```

创建一个新的 PVC，指定 `storageClassName: nfs-csi`，无需手动创建 PV。提交后观察，Provisioner 会自动在 NFS 后端创建子目录并生成对应 PV 对象，PVC 自动变为 Bound 状态。

```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: dynamic-pvc
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 10Gi
  storageClassName: nfs-csi
```

查看自动生成的 PV，其名称由系统随机生成，容量和访问模式与 PVC 一致。删除 PVC 后，根据 `reclaimPolicy: Delete`，PV 和后端子目录会被一并清理。

```bash
kubectl get pvc dynamic-pvc
kubectl get pv | grep dynamic-pvc
```

把 StorageClass 设为默认类后，PVC 不指定 `storageClassName` 也会自动使用该类。默认类通过 annotation `storageclass.kubernetes.io/is-default-class: "true"` 标记，一个集群中只应有一个默认 StorageClass。

```bash
kubectl patch storageclass nfs-csi -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
kubectl get storageclass
```

## 常见问题与避坑

**PVC 一直 Pending 先查 StorageClass 是否存在**。PVC 指定的 storageClassName 在集群中不存在，或对应的 Provisioner 未部署，动态供给就无法触发，PVC 永远停在 Pending。执行 `kubectl describe pvc <name>` 查看 Events，再用 `kubectl get sc` 确认 StorageClass 列表。

**块存储的 ReadWriteOnce 不是单 Pod 读写**。ReadWriteOnce 限制的是"单个节点"而非"单个 Pod"，同一节点上的多个 Pod 可以同时挂载并写入。若需要严格的单实例写入，需在应用层面加锁，或使用 StatefulSet 的 volumeClaimTemplates 配合有序部署。

**删除 PVC 前确认 reclaimPolicy 是否可接受数据丢失**。动态供给的 StorageClass 默认 `reclaimPolicy: Delete`，PVC 删除后 PV 和后端数据都会被清除。重要数据应把 StorageClass 的 reclaimPolicy 改为 Retain，或在删除 PVC 前先备份数据。

## 小结与进阶

PV/PVC/StorageClass 三层抽象把存储的供给与消费彻底解耦，动态供给免去了管理员手动预配的负担，CSI 机制则让存储接入标准化。理解 Volume 类型的适用场景、绑定规则与挂载流程，是排查有状态应用存储问题的基础。下一篇进入配置与密钥管理，拆解 ConfigMap 与 Secret 的多种注入方式及安全最佳实践。

