前几篇建立了计算(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PVC created


StorageClass provisioner triggered


External Provisioner (CSI Controller Plugin)
├── CreateVolume → cloud/storage API
└── PV created (Bound to PVC)


Pod scheduled to node


CSI Node Plugin (DaemonSet on node)
├── NodeStageVolume (global mount, 设备格式化挂载到 staging path)
└── NodePublishVolume (bind mount into Pod mountPath)


Pod sees volume at mountPath

整个流程分两个阶段:控制面阶段(CreateVolume + PV 创建)发生在云/存储 API 层,与具体节点无关;节点面阶段(NodeStageVolume + NodePublishVolume)发生在 Pod 被调度到的节点上,负责把块设备或文件系统实际挂进容器。

核心对象与数据流

PV 生命周期

PV 是集群级别的对象,描述一块实际存储的位置、容量和访问能力。生命周期状态机:

1
Available → Bound → Released → Recycled/Deleted/Retained
  • 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.comdisk.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# 安装 kind 集群(若未安装)
kind create cluster --name storage-lab

# local-path-provisioner 通常已预装,检查 StorageClass
kubectl get storageclass
# 预期输出:standard (default) rancher.io/local-path

# 创建 PVC
kubectl apply -f - <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: demo-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: standard
EOF

# 观察 PVC 状态(WaitForFirstConsumer 时停在 Pending)
kubectl get pvc demo-pvc
kubectl describe pvc demo-pvc # 查看 Events,观察 provisioner 是否触发

# 创建使用该 PVC 的 Pod
kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
name: demo-pod
spec:
containers:
- name: app
image: busybox
command: ["sh", "-c", "echo hello > /data/test.txt && sleep 3600"]
volumeMounts:
- name: storage
mountPath: /data
volumes:
- name: storage
persistentVolumeClaim:
claimName: demo-pvc
EOF

# 等待 Pod Running 后查看 PV/PVC 绑定状态
kubectl get pv,pvc -o wide

# 观察完整事件序列
kubectl get events --sort-by='.lastTimestamp' | grep -E "pvc|pv|provision|attach|mount"

将实验结果映射回 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
env:
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates: # 每个 Pod 自动创建一个 PVC
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
storageClassName: gp3-ssd
resources:
requests:
storage: 50Gi

StatefulSet 的 Pod 名称固定(postgres-0postgres-1),对应的 PVC 名称也固定(data-postgres-0data-postgres-1)。Pod 因故障重启或节点驱逐时,StatefulSet 控制器保证新 Pod 仍然使用同名 PVC,而不会创建新的空 PVC。

存储故障排查

常见的存储挂载失败场景和排查步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# PVC 长期 Pending
kubectl describe pvc <name>
# 查看 Events:
# - "no persistent volumes available" → 无可用 PV,需创建 PV 或检查 StorageClass
# - "storageclass not found" → StorageClass 名称错误
# - "waiting for a volume to be created" → provisioner 未就绪

# Pod 长期 ContainerCreating
kubectl describe pod <name>
# 查看 Events:
# - "AttachVolume.Attach failed" → ControllerPublishVolume 失败,检查 CSI Controller 日志
# - "MountVolume.SetUp failed" → NodeStageVolume/NodePublishVolume 失败,检查 CSI Node 日志
# - "Unable to attach or mount volumes" → VolumeAttachment 未就绪

# 查看 CSI 驱动日志
kubectl logs -n kube-system -l app=ebs-csi-controller -c ebs-plugin
kubectl logs -n kube-system -l app=ebs-csi-node -c ebs-plugin

# 查看 VolumeAttachment 状态
kubectl get volumeattachment
kubectl describe volumeattachment <name>

练习

  1. 在 kind 集群中创建一个 reclaimPolicy: Retain 的 StorageClass,供给一个 PVC 并写入数据,然后删除 Pod 和 PVC,观察 PV 状态变化,再手动将 PV 状态从 Released 改为 Available(需要清除 spec.claimRef 字段),让另一个 PVC 绑定它。

  2. 创建两个 Pod 和一个 RWX PVC(用 NFS 或 kind 的 local-path 模拟),让两个 Pod 同时向 PVC 挂载路径写文件,观察并发写入的文件是否互相可见,验证 RWX 的实际语义。

  3. 分析 kubectl describe pv <name> 的输出,找到 Source 字段下的存储后端信息,对比 local-path、hostPath、NFS 三种类型的输出差异,理解 PV 如何描述不同类型的底层存储。

  4. 在已运行的 Pod 中执行 df -hfindmnt,找到 PVC 挂载点对应的设备和 mount 选项,与 PV spec 中的 mountOptions 对比,验证 NodePublishVolume 阶段的实际 mount 参数。

  5. 安装 snapshot-controller,为一个有数据的 PVC 创建 VolumeSnapshot,再从 snapshot 恢复为新 PVC,挂载到新 Pod 验证数据完整性,理解快照和备份在可用性上的差异。

VolumeSnapshot 与数据保护

VolumeSnapshot 在 1.20 GA,提供与 PVC 生命周期解耦的快照能力。架构上与 PV/PVC 类似:VolumeSnapshotClass 对应 StorageClass,VolumeSnapshot 对应 PVC,VolumeSnapshotContent 对应 PV。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 创建快照
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: postgres-snap-20260620
namespace: production
spec:
volumeSnapshotClassName: csi-aws-vsc
source:
persistentVolumeClaimName: postgres-data

---
# 从快照恢复为新 PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-restore
namespace: production
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 20Gi
dataSource:
name: postgres-snap-20260620
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io

快照和备份的区别:快照依赖底层存储快照能力(增量、写时复制),存储在同一套基础设施上,速度快但不防止机房故障;备份是将数据复制到独立位置(S3、Glacier),速度慢但提供异地保护。生产数据库通常两者都需要。

卷扩容(Volume Expansion)

StorageClass 设置 allowVolumeExpansion: true 后,可以在线扩容 PVC 而不需要重建 Pod:

1
2
3
4
5
6
7
8
9
10
# 扩容 PVC(只能扩大,不能缩小)
kubectl patch pvc postgres-data \
--patch '{"spec":{"resources":{"requests":{"storage":"50Gi"}}}}'

# 观察扩容状态
kubectl describe pvc postgres-data
# Conditions:
# Type: FileSystemResizePending
# Status: True
# 文件系统 resize 在 Pod 重启或新 Pod 挂载时完成(NodeExpandVolume)

块存储扩容分两步:先调用 ControllerExpandVolume 在云平台扩大磁盘大小,再在下次 Pod 挂载时调用 NodeExpandVolume 完成文件系统(ext4/xfs)的 resize。NodeExpandVolume 在 Pod 运行时即可执行,不需要停机。扩容是单向操作,PVC 的 requests.storage 只能增大不能减小,减容需要重建数据卷。

本地卷(Local Volumes)

对于需要低延迟直接访问本地 NVMe 磁盘的工作负载(Elasticsearch、Cassandra),Local Volume 提供了比 hostPath 更安全的访问方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-nvme-node1
spec:
capacity:
storage: 500Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-nvme
local:
path: /mnt/nvme0n1
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node1

Local Volume 与 hostPath 的本质区别:Local PV 通过 nodeAffinity 与节点绑定,调度器感知这个约束并自动将 Pod 调度到正确节点;hostPath 没有这个约束,Pod 漂移到其他节点后挂载路径内容为空或指向错误数据。Local Volume 不支持动态供给,需要管理员预先创建 PV,通常配合 static provisioner 工具批量管理节点磁盘。

系列导航

参考资料