存储体系:PV、PVC、StorageClass 与 CSI
前几篇建立了计算(Pod 调度和运行)和网络(Pod 间通信和服务发现)的模型。有状态应用(数据库、消息队列、对象存储)需要持久化存储,Pod 重启后数据不丢失。Kubernetes 通过 PV/PVC/StorageClass 三层抽象解耦了存储的供应和使用。
在虚拟机时代,存储通常由运维工程师手动挂载:NFS 目录、SAN LUN、本地磁盘,挂载路径硬编码在部署脚本里。容器化之后,Pod 随时可能漂移到不同节点,存储和计算的生命周期必须显式解耦。Kubernetes 的回答是引入三个层次:PV(实际存储的抽象)、PVC(应用的存储需求声明)、StorageClass(动态供应的策略工厂)。PV/PVC 的绑定机制和 CSI 驱动如何让外部存储系统成为 K8s 的一等公民,是本篇的核心内容。
CSI(Container Storage Interface)的出现解决了 in-tree 驱动的历史包袱。此前,每个云厂商、每个存储系统都要向 Kubernetes 主代码库提交驱动代码,升级路径混乱且周期漫长。CSI 定义了一套 gRPC 接口,任何存储厂商都可以编写独立的驱动程序,以 DaemonSet + Deployment 的形式部署到集群,实现与 Kubernetes 的解耦。
主数据流
1 | |
整个流程分两个阶段:控制面阶段(CreateVolume + PV 创建)发生在云/存储 API 层,与具体节点无关;节点面阶段(NodeStageVolume + NodePublishVolume)发生在 Pod 被调度到的节点上,负责把块设备或文件系统实际挂进容器。
核心对象与数据流
PV 生命周期
PV 是集群级别的对象,描述一块实际存储的位置、容量和访问能力。生命周期状态机:
1 | |
- Available:PV 已存在,等待被 PVC 绑定。
- Bound:PV 与某个 PVC 建立了绑定关系,其他 PVC 无法再绑定这个 PV。
- Released:与之绑定的 PVC 被删除,PV 进入此状态等待回收策略处理。
- 回收策略三选一:Retain(保留数据,需管理员手动清理)、Delete(删除底层存储资产)、Recycle(已废弃,早期用 scrub 清空数据后再次 Available)。
Released 状态的 PV 不会自动被新 PVC 绑定,即便容量和访问模式完全匹配。这是为了防止残留数据被另一个应用意外读取。管理员确认数据处理完毕后,需要手动清除 PV 的 claimRef 字段,PV 才回到 Available。
PVC 绑定:三重匹配
PVC 创建后,控制面在所有 Available PV 中寻找满足条件的对象,三个维度缺一不可:
第一,访问模式(AccessModes)必须兼容。PV 声明它支持哪些模式,PVC 声明它需要哪些模式:
- RWO(ReadWriteOnce):同一时刻只有一个节点可以读写,块存储的典型模式。
- ROX(ReadOnlyMany):多节点只读,适合静态资产场景。
- RWX(ReadWriteMany):多节点同时读写,需要网络文件系统(NFS、CephFS、Azure Files 等)支持。
- RWOP(ReadWriteOncePod):Kubernetes 1.22+ 引入,将单写限制精细到 Pod 粒度,而非节点粒度。
第二,存储量。PVC 声明 requests.storage,PV 提供 capacity.storage,PV 容量必须大于等于 PVC 请求量。Kubernetes 选择满足条件的最小 PV(best-fit 策略),减少资源浪费。
第三,StorageClass。PVC 通过 storageClassName 字段引用,必须与 PV 的 storageClassName 匹配,或者两者都为空(静态供给时可省略)。
StorageClass 与动态供给
静态供给要求管理员预先创建 PV,规模大时不现实。StorageClass 引入动态供给:当 PVC 引用某个 StorageClass 且没有现成的 Available PV 可匹配时,Kubernetes 触发该 StorageClass 的 provisioner 动态创建 PV。
StorageClass 关键字段:
provisioner:驱动名称,例如ebs.csi.aws.com、disk.csi.azure.com。parameters:传递给 provisioner 的参数,例如磁盘类型(gp3、premium-ssd)、IOPS、加密配置等。reclaimPolicy:动态创建的 PV 默认使用此回收策略,通常设为 Delete。volumeBindingMode:Immediate(立即供给)或 WaitForFirstConsumer(延迟到 Pod 调度后供给,避免 Pod 和 PV 在不同可用区)。
WaitForFirstConsumer 是重要的最佳实践。若设为 Immediate,PV 在 PVC 创建时立即在某个可用区创建,但 Pod 可能被调度到另一个可用区,导致块存储无法跨区挂载而失败。
CSI 架构
CSI 驱动由两个组件组成,部署方式不同,职责明确分离。
CSI Controller Plugin 通常以 Deployment 形式运行,靠近控制平面(但不在控制平面内)。它实现 Controller 服务接口:
CreateVolume/DeleteVolume:调用存储后端 API 创建/删除存储卷。ControllerPublishVolume/ControllerUnpublishVolume:将卷附加到某个节点(对应 AWS EBS Attach、Azure Disk Attach 操作)。CreateSnapshot/DeleteSnapshot:可选能力,支持快照功能。
CSI Node Plugin 以 DaemonSet 形式运行在每个节点上,实现 Node 服务接口:
NodeStageVolume:将块设备挂载到节点全局路径(staging path),通常在此完成文件系统格式化和初次挂载。一个卷在同一节点上只执行一次 stage。NodePublishVolume:通过 bind mount 将 staging path 映射到 Pod 专属的 mount path。同一个卷可以 publish 给同一节点上的多个 Pod(ROX 场景)。NodeUnpublishVolume/NodeUnstageVolume:卸载的逆向操作。
VolumeAttachment
VolumeAttachment 是一个集群级 API 对象(storage.k8s.io/v1),记录"某个 PV 附加到某个节点"这个事实。它由 attach-detach controller 创建,CSI external-attacher sidecar 监听这个对象并调用 ControllerPublishVolume。VolumeAttachment 的存在让 kubelet 可以判断块设备是否已就绪,再决定是否继续执行 NodeStageVolume。
sidecar 容器是 CSI 驱动设计的关键模式。external-provisioner 监听 PVC 变更,external-attacher 监听 VolumeAttachment,node-driver-registrar 在 kubelet 插件目录注册驱动的 Unix socket。CSI 驱动本身只需实现 gRPC 接口,不需要知道如何与 Kubernetes API 交互,复杂性由这些公共 sidecar 分担。
可运行实验
在 kind 集群中,local-path-provisioner 是最便捷的动态供给实现。
1 | |
将实验结果映射回 K8s 对象
Events 中观察到的典型序列:
| Event Message | 对应的 K8s 对象动作 |
|---|---|
Provisioning PVC demo-pvc |
StorageClass provisioner 被触发 |
Successfully provisioned volume pvc-xxx |
CreateVolume 完成,PV 对象创建 |
WaitForFirstConsumer: waiting for pod |
volumeBindingMode 延迟供给等待调度 |
AttachVolume.Attach succeeded |
ControllerPublishVolume + VolumeAttachment 就绪 |
MountVolume.SetUp succeeded |
NodeStageVolume + NodePublishVolume 完成 |
kubectl get pv,pvc -o wide 会显示 STORAGECLASS、REASON、ACCESSMODES、STATUS、CLAIM 等字段,PVC STATUS 从 Pending 变为 Bound 代表整个供给链路成功。
深入排查时,kubectl describe pv <name> 会显示 Source 字段,包含底层存储后端的具体信息(local-path 显示节点路径,EBS 显示卷 ID)。kubectl describe volumeattachment 则能追踪 attach/detach 的中间状态。
模式提炼
PV/PVC 是"存储的声明-供给分离"模式。申请方(PVC)描述意图,供给方(PV/StorageClass/provisioner)响应意图。这和接口编程的思想一致——Pod 不关心存储的物理实现,只声明需要多大、什么访问模式,底层可以换 EBS、Ceph、NFS 而无需修改 Pod spec。
CSI 则是"标准化存储驱动接口"模式。gRPC 接口定义了 Kubernetes 和存储系统之间的契约,存储厂商实现契约而不依赖 Kubernetes 内部细节,Kubernetes 升级时驱动无需同步更新。这和 JDBC 把数据库驱动标准化的思路完全对应。
sidecar 模式贯穿 CSI 设计:CSI 驱动专注于存储语义,与 K8s API 交互的职责委托给公共 sidecar,两者通过 Unix socket 上的 gRPC 通信。职责边界清晰,各自独立演进。
工程迁移表
| 场景 | Java/传统技术 | Kubernetes 对应 |
|---|---|---|
| 数据库连接池声明(需要什么,不关心实现) | DataSource interface |
PVC(声明存储需求) |
| JDBC 驱动(标准接口的具体实现) | MySQL Connector/J | CSI 驱动 |
| NIO FileChannel(操作系统文件 IO) | FileChannel.open(path) |
NodePublishVolume(挂进 Pod) |
| AWS EBS 手动 attach(attach 到 EC2 实例) | aws ec2 attach-volume |
ControllerPublishVolume |
| 文件系统格式化(mkfs.ext4) | 初始化数据库 schema | NodeStageVolume(首次格式化) |
| 磁盘快照(LVM snapshot / ZFS snapshot) | 数据库 BACKUP 命令 | VolumeSnapshot |
| 不同环境使用不同存储规格 | Spring Profile 切换 DataSource | 不同环境使用不同 StorageClass |
常见误解
误解一:PVC 等于一个目录
PVC 只是一个"存储申请"的 API 对象,本身不存储任何数据,也没有对应的目录。底层数据的实际位置由 PV 指定(可能是块设备 /dev/sdb、NFS 路径 nfs-server:/exports/vol1、本地路径 /opt/local-path-provisioner/pvc-xxx)。当 PVC 被挂载进 Pod 时,kubelet 通过 bind mount 将 PV 对应的路径映射到 Pod 容器内的 mountPath。删除 PVC 不会删除数据,除非 PV 的回收策略是 Delete 且 provisioner 响应了删除请求。
误解二:RWX 所有存储类型都支持
RWX 要求存储系统原生支持多节点并发写入。块存储(EBS、Azure Disk、GCE PD)从根本上不支持 RWX——一块磁盘同一时刻只能附加到一个实例。强行声明 RWX 会导致 PVC 无法绑定到块存储类型的 PV。支持 RWX 的后端需要是网络文件系统:NFS(包括云厂商托管的 EFS、Azure Files、Cloud Filestore)、CephFS、GlusterFS。使用时还要考虑并发写入带来的文件锁和一致性问题,网络文件系统的 fsync 语义和本地磁盘有差异,数据库不能直接运行在 RWX NFS 上。
误解三:删除 PVC 一定删除数据
实际行为取决于 PV 的 reclaimPolicy:Retain 时删除 PVC 后,PV 进入 Released 状态,底层数据完整保留,需管理员手动处理;Delete 时删除 PVC 后,Kubernetes 自动删除 PV 对象并调用 DeleteVolume 清理底层存储。动态供给时,StorageClass 的 reclaimPolicy 决定动态创建的 PV 采用哪个策略。如果生产数据库的 StorageClass 未显式设置 reclaimPolicy,默认值为 Delete,误删 PVC 会导致数据不可恢复。
误解四:CSI 驱动只需要部署 DaemonSet
完整的 CSI 驱动通常包含两部分:Controller Plugin(Deployment,负责云存储资源生命周期)和 Node Plugin(DaemonSet,负责节点本地挂载)。两部分的职责边界清晰:Controller Plugin 调用云 API,Node Plugin 执行文件系统操作。此外,还需要部署多个 external sidecar(external-provisioner、external-attacher、node-driver-registrar),这些 sidecar 是官方维护的通用组件,负责与 Kubernetes API 的交互,让 CSI 驱动本身只需要关注存储语义。某些纯文件系统类驱动(如 NFS subdir external provisioner)没有需要 attach 的块设备,可以省略 external-attacher,但大多数块存储驱动需要完整的四个组件。
有状态应用的存储选型指南
不同类型的有状态应用对存储的要求差异很大:
| 应用类型 | 推荐存储方案 | 原因 |
|---|---|---|
| 关系型数据库(PostgreSQL、MySQL) | RWO 块存储(EBS gp3、Azure Disk Premium) | 需要强一致性 fsync,本地磁盘延迟低 |
| 键值存储(Redis 持久化) | RWO 块存储或本地 SSD | AOF/RDB 写入频繁,对 iops 敏感 |
| 搜索引擎(Elasticsearch) | Local Volume 或高 IOPS 块存储 | 大量随机读写,延迟敏感 |
| 消息队列(Kafka) | Local Volume 或高吞吐块存储 | 顺序写入,吞吐量优先 |
| 对象存储网关 | RWX 网络文件系统 | 多副本同时访问共享数据 |
| 日志归档 | RWX NFS 或对象存储 CSI | 多 Pod 写入,容量优先 |
| 静态资源服务 | ROX NFS 或 ConfigMap | 只读,多 Pod 共享 |
StatefulSet 与 PVC 的配合:StatefulSet 通过 volumeClaimTemplates 为每个 Pod 自动创建专属 PVC,Pod 重建后仍然绑定到同一个 PVC,保证数据不丢失。这是有状态应用部署到 Kubernetes 的标准模式。
1 | |
StatefulSet 的 Pod 名称固定(postgres-0、postgres-1),对应的 PVC 名称也固定(data-postgres-0、data-postgres-1)。Pod 因故障重启或节点驱逐时,StatefulSet 控制器保证新 Pod 仍然使用同名 PVC,而不会创建新的空 PVC。
存储故障排查
常见的存储挂载失败场景和排查步骤:
1 | |
练习
-
在 kind 集群中创建一个
reclaimPolicy: Retain的 StorageClass,供给一个 PVC 并写入数据,然后删除 Pod 和 PVC,观察 PV 状态变化,再手动将 PV 状态从 Released 改为 Available(需要清除spec.claimRef字段),让另一个 PVC 绑定它。 -
创建两个 Pod 和一个 RWX PVC(用 NFS 或 kind 的 local-path 模拟),让两个 Pod 同时向 PVC 挂载路径写文件,观察并发写入的文件是否互相可见,验证 RWX 的实际语义。
-
分析
kubectl describe pv <name>的输出,找到Source字段下的存储后端信息,对比 local-path、hostPath、NFS 三种类型的输出差异,理解 PV 如何描述不同类型的底层存储。 -
在已运行的 Pod 中执行
df -h和findmnt,找到 PVC 挂载点对应的设备和 mount 选项,与 PV spec 中的mountOptions对比,验证 NodePublishVolume 阶段的实际 mount 参数。 -
安装 snapshot-controller,为一个有数据的 PVC 创建 VolumeSnapshot,再从 snapshot 恢复为新 PVC,挂载到新 Pod 验证数据完整性,理解快照和备份在可用性上的差异。
VolumeSnapshot 与数据保护
VolumeSnapshot 在 1.20 GA,提供与 PVC 生命周期解耦的快照能力。架构上与 PV/PVC 类似:VolumeSnapshotClass 对应 StorageClass,VolumeSnapshot 对应 PVC,VolumeSnapshotContent 对应 PV。
1 | |
快照和备份的区别:快照依赖底层存储快照能力(增量、写时复制),存储在同一套基础设施上,速度快但不防止机房故障;备份是将数据复制到独立位置(S3、Glacier),速度慢但提供异地保护。生产数据库通常两者都需要。
卷扩容(Volume Expansion)
StorageClass 设置 allowVolumeExpansion: true 后,可以在线扩容 PVC 而不需要重建 Pod:
1 | |
块存储扩容分两步:先调用 ControllerExpandVolume 在云平台扩大磁盘大小,再在下次 Pod 挂载时调用 NodeExpandVolume 完成文件系统(ext4/xfs)的 resize。NodeExpandVolume 在 Pod 运行时即可执行,不需要停机。扩容是单向操作,PVC 的 requests.storage 只能增大不能减小,减容需要重建数据卷。
本地卷(Local Volumes)
对于需要低延迟直接访问本地 NVMe 磁盘的工作负载(Elasticsearch、Cassandra),Local Volume 提供了比 hostPath 更安全的访问方式:
1 | |
Local Volume 与 hostPath 的本质区别:Local PV 通过 nodeAffinity 与节点绑定,调度器感知这个约束并自动将 Pod 调度到正确节点;hostPath 没有这个约束,Pod 漂移到其他节点后挂载路径内容为空或指向错误数据。Local Volume 不支持动态供给,需要管理员预先创建 PV,通常配合 static provisioner 工具批量管理节点磁盘。
系列导航
- 00 核心概念导读
- 01 API Server 与声明式 API
- 02 etcd 与持久化
- 03 控制器模式与 Informer 机制
- 04 Pod 生命周期
- 05 调度器深入
- 06 kubelet 与容器运行时
- 07 网络模型与 CNI
- 08 Service 与 kube-proxy
- 09 Ingress 与 Gateway API
- 10 存储体系
- 11 配置与密钥管理
- 12 RBAC 与安全模型
- 13 资源管理与自动伸缩
- 14 CRD 与 Operator 模式
- 15 Helm 与应用打包
- 16 可观测性
- 17 生产集群运维
参考资料
- Kubernetes Persistent Volumes — 官方文档,PV/PVC 状态机和字段语义的权威来源
- Container Storage Interface (CSI) Specification — CSI gRPC 接口定义,包含 CreateVolume、NodeStageVolume 等调用的完整契约
- Kubernetes CSI Developer Documentation — CSI 驱动开发指南,包含 sidecar 容器(external-provisioner、external-attacher、node-driver-registrar)的职责说明
- local-path-provisioner — kind 默认使用的轻量动态供给实现,源码可读性好,适合理解供给流程
- Storage Classes — StorageClass 字段和各云厂商 provisioner 配置参考
