我第一次在 Kubernetes 里部署有状态服务时,被 PV 和 PVC 这两个词折腾得不轻。明明一个 Pod 加一个 volume 就能挂存储,为什么还要多出两个抽象层?后来在测试环境里被 Pending、数据被删、节点挂载失败这些坑轮番教育了一遍,才把这块彻底梳理清楚。
这篇文章不聊虚的,直接从"为什么需要这两个抽象"讲到 NFS 手工静态供给,再讲到 StorageClass 动态供给实战,最后把最常见的排错路径和长期运维习惯一并交代清楚。无论你是刚接触 Kubernetes 的小白,还是已经写过几个 Deployment 但没深究过存储的开发者,本文都能给你一套可以直接抄走的思路。
1. 为什么需要 PV/PVC:把存储从 Pod 生命周期中剥离
1.1 从 emptyDir 和 hostPath 说起
大多数人的 Kubernetes 存储启蒙是从 emptyDir 开始的。它在 Pod 创建时分配一个空目录,Pod 删除时目录一起消失。好处是零配置,坏处也很直接:只要 Pod 因故障重启、被驱逐或重新调度,里面放的数据就全没了。
接着大家会用 hostPath,把节点的本地目录直接映射进容器。这确实能"持久"了,但随之而来的是新的麻烦:hostPath 绑定的是某个具体节点,Pod 一旦被调度到另一台机器,数据就跟原节点分家了。要么你手动设置 nodeSelector 把 Pod 钉死在某个节点上,要么就得忍受数据漂移。单副本应用还能凑合,一旦涉及多副本或数据库集群,这套方案就是灾难。
所以 Kubernetes 需要一套和 Pod 生命周期解耦的存储抽象:Pod 可以死、可以重建、可以迁移,但数据必须待在原地等着新 Pod 来挂载。这就是 PV 和 PVC 存在的根本原因。
1.2 PV 与 PVC 的角色分工:资源池与申请单
PV(PersistentVolume)是集群层面的存储资源,类似共享存储的"库存"。它由管理员预先创建,或由 StorageClass 在需要时自动生成,代表某块真实存储空间:一个 NFS 目录、一块云硬盘、一条 Ceph RBD 块设备,都行。
PVC(PersistentVolumeClaim)是用户发出的存储"申请单"。开发者不关心底层到底是 NFS 还是云盘,只声明:我要多少容量、什么访问模式、要不要特殊性能。控制平面负责在 PV 库存里找一块能满足要求的资源,把它和 PVC 绑定在一起。
这种拆分带来的最大好处是职责清晰:管理员管资源池,开发者管申请消费。底层存储从"某个具体目录"变成"一份符合规格的卷",应用和基础设施之间从此多了一层缓冲。
1.3 Pod 视角:PVC 只是"卷插槽"
实际写 Pod 时,你不需要知道 PV 的名字,只需要在 volumes 里声明 PVC:
volumes: - name: data persistentVolumeClaim: claimName: pvc-nfs-001kubelet 会把与该 PVC 绑定的 PV 所对应的真实存储挂载到容器里。整个链路是 Pod → PVC → PV → 底层存储。
这里有个容易忽略的细节:PV 是集群级对象,不分 namespace;PVC 是命名空间级对象。所以 PVC 只能在同一个 namespace 内被 Pod 引用,跨 namespace 无法直接访问别人申请的卷。多团队共享一套集群时,这是隔离数据的好机制,但也意味着共享存储需要靠 PV 层面的规划来配合。
2. PV 的生命周期、绑定规则与回收策略
2.1 静态供给 vs 动态供给:两种资源供给方式怎么选
静态供给,字面意思就是管理员提前创建好一批 PV,放到资源池里等着 PVC 来匹配。适用于已有固定的共享存储、需要严格规划目录的场景。比如公司买了台 NFS 服务器,划分好目录,管理员在集群里预先登记好这些容量,开发者申请时直接绑定。
动态供给则是在 PVC 创建的那一刻,由 StorageClass 里的 provisioner 自动创建 PV 以及底层对应的存储资源。比如创建一个名为 nfs-dynamic 的 StorageClass,PVC 只要声明使用这个类,系统就自动在 NFS 服务器上创建一个子目录,并生成对应的 PV 对象完成绑定。全程无需管理员干预。
我给团队的建议很简单:测试环境和快速迭代项目优先动态供给,省事;生产环境里有数据库这类需要精细控制备份、性能和生命周期的高价值应用,静态供给反而更稳妥,因为每一步都有人盯着。
2.2 AccessModes 能力矩阵与常见误解
AccessModes 描述的是 PV 可以被多少个节点以什么方式挂载,注意这说的是"能力上限",不是"强制要求"。PVC 声明的模式只要落在 PV 能力范围内就能绑定。常见模式如下:
| 模式 | 含义 | 典型场景 |
|---|---|---|
| ReadWriteOnce | 仅单节点读写 | 一块云盘挂到一个节点上的一个或多个 Pod |
| ReadOnlyMany | 多节点只读 | 配置文件、静态资源共享 |
| ReadWriteMany | 多节点读写 | NFS 共享目录、共享文件存储 |
| ReadWriteOncePod | 仅单 Pod 读写 | 需要严格避免多实例同时写同一卷的数据库 |
一个高频误解是"PV 支持 RWX,PVC 请求 RWO,不能绑定"。实际上只要 PV 的能力包含 PVC 请求的模式即可。NFS 同时支持 ROX 和 RWX,如果 PVC 只申请 RWO,NFS PV 也能满足,因为 RWO 是 RWX 能力的一个子集。反过来,PV 只有 RWO 而 PVC 申请 RWX,绑定就会失败。
另一个坑是 RWO 并不等于"单 Pod"。它仅仅限制单节点,同一节点上多个 Pod 仍然可以同时挂载同一块 RWO 卷。如果你运行的是数据库且极度忌讳多实例同时写数据,在 Kubernetes 1.22 以后可以用 ReadWriteOncePod 把限制收紧到单 Pod。
2.3 PV 状态机与 ReclaimPolicy:删除 PVC 后数据去哪了
PV 会经历几个状态:Available(空闲)、Bound(已绑定)、Released(PVC 已删除但数据未清理)、Failed(回收失败)。
决定"PVC 删除后 PV 和底层数据如何处理"的是回收策略。Retain 模式下,PVC 删除后 PV 保留,数据原封不动,但 PV 进入 Released 状态,不能被新 PVC 直接重新绑定。运维需要手动清理数据、删除 PV 对象,再重新创建 PV 才能复用。Delete 模式则由动态供给插件直接删除底层存储和 PV 对象,数据一并消失。早期还有 Recycle 模式,相当于执行 rm -rf 后清空复用,但早已被标记废弃,不要指望它。
很多人第一次用动态供给时没注意默认回收策略,删掉一个测试 PVC,连带云硬盘和上面所有数据一起没了。生产环境里,我建议对所有重要数据要么显式设置 Retain,要么在动态供给参数里开启归档功能,目的是给自己留一条后悔的余地。
3. 实战一:基于 NFS 手工创建 PV 并绑定 PVC
3.1 为什么拿 NFS 做第一个实战
NFS 是理解 PV/PVC 最合适的载体:协议简单,kubelet 内建支持,不需要额外安装 CSI 驱动,且天然支持多节点读写。先用 NFS 跑通整个静态绑定流程,后面再接触动态供给,会轻松很多。
假设有一台存储服务器 IP 为 192.168.1.100,需要先安装 NFS 服务端并导出一个目录:
# 存储服务器上执行 apt update && apt install -y nfs-kernel-server mkdir -p /srv/k8s/pv-001 chmod 0777 /srv/k8s/pv-001 echo '/srv/k8s 192.168.0.0/24(rw,sync,no_root_squash,no_subtree_check)' >> /etc/exports exportfs -arv systemctl enable --now nfs-serverKubernetes 集群每个节点都要有 NFS 客户端程序,否则挂载阶段会报 mount 失败:
# 每个 K8s 节点执行,Debian/Ubuntu 系 apt install -y nfs-common # 验证能看到共享目录 showmount -e 192.168.1.100注意 exports 配置里用了 no_root_squash,这是为了后续容器内进程以 root 身份写入时不触发权限映射问题。这个参数要结合内网安全环境使用,公网暴露的 NFS 绝不能这么配置。
3.2 编写 PV 与 PVC 清单,静态供给的完整链路
PV 声明的是存储能力和访问方式:
apiVersion: v1 kind: PersistentVolume metadata: name: pv-nfs-001 labels: type: nfs purpose: app-data spec: capacity: storage: 10Gi accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain storageClassName: "" nfs: server: 192.168.1.100 path: /srv/k8s/pv-001关键点有两个。一是 storageClassName 必须显式设为空字符串。如果省略不写,而集群里存在默认 StorageClass,这个 PV 会被自动分配到默认类,后续很难被普通 PVC 匹配。二是 accessModes 给了 ReadWriteMany,实际使用中即便 PVC 只申请 RWO 也能绑定,因为能力包含关系成立。
PVC 清单:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-nfs-001 namespace: default spec: accessModes: - ReadWriteOnce resources: requests: storage: 10Gi storageClassName: ""执行 kubectl apply 后,可以通过以下命令确认绑定结果:
kubectl get pv kubectl get pvc kubectl describe pvc pvc-nfs-001正常情况下 PV 状态会从 Available 变为 Bound,PVC 的 STATUS 也变成 Bound。如果一直 Pending,最可能是 storageClassName 空字符串没有匹配上,或者容量、访问模式不满足。这个排错细节我会在第五节展开。
3.3 挂载到 Pod 并验证数据持久性
接下来创建一个测试 Pod,把 PVC 挂到 /data 目录:
apiVersion: v1 kind: Pod metadata: name: pvc-test spec: containers: - name: busybox image: busybox command: ["sh", "-c", "sleep 3600"] volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: pvc-nfs-001进入容器写入一个测试文件:
kubectl exec -it pvc-test -- sh echo "hello persistent storage" > /data/hello.txt exit kubectl delete pod pvc-test删除 Pod 后,到存储服务器的 /srv/k8s/pv-001 目录里能看到 hello.txt 还在。这就是持久化的意义:Pod 死掉,数据不死。重新创建 Pod 挂载同一个 PVC,文件依然可读。
4. 实战二:通过 StorageClass 实现动态部署,PVC 即创即用
4.1 StorageClass 核心字段:provisioner 是灵魂
静态供给的问题在于扩容和新增存储都要管理员手工操作。在频繁创建新环境、按需扩容的测试场景里,很快就会变成瓶颈。StorageClass 就是为解决这个问题而生的。
它有几个核心字段:
| 字段 | 作用 | 常见值 |
|---|---|---|
| provisioner | 指定存储供给插件 | kubernetes.io/no-provisioner、ceph.com/rbd、开源 NFS 供给插件等 |
| parameters | 供给插件自定义参数 | 存储类型、归档开关、副本数 |
| reclaimPolicy | 回收策略 | Delete 或 Retain |
| volumeBindingMode | 绑定时机 | Immediate 或 WaitForFirstConsumer |
| allowVolumeExpansion | 是否允许扩容 | true 或 false |
volumeBindingMode 值得单独解释。Immediate 是创建 PVC 后立即尝试供给和绑定;WaitForFirstConsumer 则是等 Pod 真正调度到节点后,再根据节点位置决定在哪台机器上创建底层卷。后者对 local PV 这类节点绑定存储至关重要,否则底层卷创建在 A 节点、Pod 却调度到 B 节点,数据就悬空了。
4.2 部署一个 NFS 动态供给组件
Kubernetes 内核不附带 NFS 的动态供给插件,需要部署一个实现 provisioner 接口的控制器。社区里最常见的是 nfs-subdir-external-provisioner 这类方案,它会在 NFS 服务端为每个 PVC 自动创建独立子目录,并生成对应 PV。
整体部署分三步。第一步,准备好连接 NFS 服务端的配置参数;第二步,创建 ServiceAccount 和 RBAC 权限,因为控制器需要操作 PV、PVC、StorageClass 等资源;第三步,部署 Deployment 并创建 StorageClass。
核心的 StorageClass 清单:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-dynamic provisioner: k8s-sigs.io/nfs-subdir-external-provisioner parameters: archiveOnDelete: "true" reclaimPolicy: Delete volumeBindingMode: Immediate allowVolumeExpansion: true这里我把 reclaimPolicy 设置为 Delete,是为了让测试环境能自动清理;同时把 archiveOnDelete 设为 true,删除 PVC 时底层数据不会直接销毁,而是归档到 NFS 服务端的一个目录里。生产环境如果想更稳妥,可以把 reclaimPolicy 改为 Retain,让 PV 在 PVC 删除后保留为 Released 状态,人工介入处理。
控制器 Deployment 大致长这样:
apiVersion: apps/v1 kind: Deployment metadata: name: nfs-subdir-external-provisioner namespace: kube-system spec: replicas: 1 selector: matchLabels: app: nfs-provisioner template: metadata: labels: app: nfs-provisioner spec: serviceAccountName: nfs-provisioner containers: - name: provisioner image: registry.example.com/nfs-subdir-external-provisioner:v4.0.2 volumeMounts: - name: nfs-root mountPath: /persistentvolumes env: - name: PROVISIONER_NAME value: k8s-sigs.io/nfs-subdir-external-provisioner - name: NFS_SERVER value: 192.168.1.100 - name: NFS_PATH value: /srv/k8s volumes: - name: nfs-root nfs: server: 192.168.1.100 path: /srv/k8sRBAC 的职责也要交代清楚,控制器至少要有以下能力:对 persistentvolumes 的 get、list、watch、create、delete;对 persistentvolumeclaims 的 get、list、watch、update;对 storageclasses 的 get、list、watch。缺了这些权限,动态供给会在最后一步悄悄失败,PVC 没有事件但 PV 永远创建不出来。
4.3 创建 PVC 触发自动供给,验证整个链路
部署完 StorageClass 后,创建一个 PVC:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-dynamic-001 namespace: default spec: accessModes: - ReadWriteOnce storageClassName: nfs-dynamic resources: requests: storage: 5Gi执行:
kubectl apply -f pvc-dynamic.yaml kubectl get pvc pvc-dynamic-001 kubectl get pv | grep pvc-dynamic可以看到自动创建的 PV 名字通常是 pvc-xxxx,容量 5Gi,StorageClass 列显示 nfs-dynamic,状态 Bound。存储服务器上也会多出一个对应的子目录。
动态供给最大的价值在于和 StatefulSet 的 volumeClaimTemplates 配合。StatefulSet 每个副本都能自动生成一个独立 PVC,名字格式是 - -<序号>。例如:
apiVersion: apps/v1 kind: StatefulSet metadata: name: web spec: serviceName: web replicas: 3 volumeClaimTemplates: - metadata: name: data spec: accessModes: [ReadWriteOnce] storageClassName: nfs-dynamic resources: requests: storage: 10Gi创建后会自动出现>kubectl describe pvc <pvc-name>
如果事件里出现 no persistent volumes available for this claim and no storage class is set,说明 PVC 没指定 StorageClass,而集群里也没有默认类,且没有匹配的空闲 PV。这时候要么给 PVC 补全 storageClassName,要么让管理员导入合适的 PV。
第二步,检查 StorageClass 是否存在以及 provisioner 是否健康:
kubectl get storageclass kubectl get pods -n kube-system | grep nfs kubectl logs -n kube-system <provisioner-pod>动态供给场景下,provisioner Pod 崩溃或 RBAC 权限不足,PVC 事件往往会显示 failed to provision volume。日志里会明确写出权限错误或 NFS 连接失败。
第三步,检查 PV 匹配条件。静态供给场景下,重点核对 storageClassName 是否完全一致。这里最容易踩空字符串坑:PV 和 PVC 一边写了 storageClassName: "",另一边没写,结果永远无法匹配。同时检查容量和访问模式。容量匹配是最小满足逻辑,PV 容量必须大于等于 PVC 请求值;访问模式则是能力包含关系。
第四步,如果是 WaitForFirstConsumer 模式,Pending 是正常的。没有 Pod 调度时,PVC 会一直等着;一旦 Pod 创建并调度到节点,供给动作才会触发。遇到这种"卡住"先别慌,去看看有没有部署相应数量的 Pod。
5.2 节点挂载失败与权限问题:mount 报错怎么查
绑定成功后,Pod 可能仍然 ContainerCreating,describe 会看到 MountVolume.SetUp failed。最常见的原因是节点缺少 NFS 客户端工具,报错一般是 mount: unknown filesystem type 'nfs' 或 mount.nfs: not found。解决办法就是安装 nfs-common/nfs-utils。
另一个高频原因是权限。NFS 导出配置里如果开了 root_squash,容器内 root 用户会被映射成 nobody,对目录没有写权限。此时 Pod 创建正常但写入文件报 Permission denied。处理方向有两个:在 exports 里为可信网段关闭 root_squash,或者在 Pod 的 securityContext 中设置 fsGroup,让存储目录的组权限匹配容器进程的真实用户。
还有一个隐蔽点:多个 Pod 同时挂载同一个 NFS 卷时,如果应用对文件锁敏感,会出现性能下降甚至卡死。NFS 的锁语义和本地文件系统不同,依赖严格锁的数据库应用尽量不要在 NFS 上跑。真到这一步,就该考虑块存储或分布式文件系统。
5.3 回收策略与扩容的边界:数据安全和灵活性之间的权衡
平时测试中,我见过不少人删掉 PVC 后,看到 PV 状态变成 Released,于是直接执行 kubectl delete pv,结果数据彻底找不回来。Retain 模式下,PV 对象一旦删除,后续就无法再通过 Kubernetes 挂载访问底层数据,等于放弃了对这块存储的所有权。
正确的处理姿势是:如果要复用数据,先在存储服务器上把数据备份或迁走,再删除 PV 对象;如果只是误删 PVC,希望恢复服务,可以重新创建一个指向同一底层目录的新 PV,并用新 PVC 绑定它,但要确保新 PV 的名字和旧 PV 不同,避免冲突。
扩容也常常被误操作。StorageClass 开启 allowVolumeExpansion 后,PVC 容量可以调大,但这个过程有三个边界:扩容只能增不能减;底层存储必须支持在线扩容;PVC 扩容完成后,如果 Pod 所在节点 kubelet 没能自动扩容文件系统,需要重启 Pod 或手动执行文件系统 resize 命令。调容量前务必确认底层存储的实际可用空间,否则 PVC 显示 100Gi,底层磁盘只有 50Gi,扩了也白扩。
6. 一些让存储长期稳定的习惯
6.1 命名与标签规范:别让小问题变成大排查成本
存储资源一旦多起来,最痛苦的不是技术,而是"分不清这是谁的数据"。我给团队定了一套粗粒度规范,运行了很长时间没出过大乱子。
PV 的命名按用途划分,静态供给用 pv-<存储类型>-<编号>,动态供给由系统生成 pvc- 则不干预。PVC 命名采用 <应用名>-<用途>,一个应用的配置文件、数据目录、日志目录要能一眼区分。同时所有静态 PV 必须打上 label,例如 type: nfs、team: platform,PVC 里可以通过 selector 精确匹配,避免容量和访问模式都满足但绑错卷的尴尬。
还有一个容易被忽视的规范:明确默认 StorageClass。如果集群里有多个 StorageClass,务必通过 annotation 设置其中一个为默认类。否则开发者创建 PVC 时忘写 storageClassName,系统会自动绑默认类,可能产生意外费用或绑到不符合预期的存储。静态供给场景下更要小心,我会要求团队在所有静态 PV/PVC 中都显式写明 storageClassName: "",不依赖任何默认行为。
6.2 容量规划与监控:别等磁盘满了才发现
PV/PVC 只是抽象层,底层存储的容量监控不能靠 Kubernetes 内置能力解决。建议把 provisioner 所在节点或 NFS 服务端的磁盘指标接入监控系统,同时采集 PVC 的 actual usage 指标,和 requested 容量做对比。
我见过太多应用把 PVC 申请 100Gi,实际只用了 5Gi,结果一堆集群资源因为"看起来满"而无法调度新 PVC。反过来也有数据库日增几十 Gi,但 PVC 只有 200Gi 没扩过,某天突然写满,整个实例只读的惨痛案例。所以容量规划至少要有两个维度:一是所有 PVC 请求量的总和,二是每个 PVC 的实际水位。前者看集群能不能新建卷,后者看单个应用要不要扩容。
6.3 备份与迁移:把"能不能重建"作为唯一标准
最后想聊一个很多人忽略的原则:Kubernetes 不会替你备份数据。PVC 绑定 PV 只是把存储挂载进去,底层数据是否安全,完全取决于存储自身机制。Retain 策略、快照、异地副本,这些都是运维自己要补的功课。
我的习惯是给重要存储单独拉起备份任务,定期把 NFS 目录或块设备快照复制到独立的备份区域。不要因为 PV 还在、PVC 还 Bound,就默认数据万无一失。跨集群迁移时,尽量用"重建 PVC + 从备份导入"的思路,而不是依赖 PV 对象直接搬家,因为 PV 的底层标识在不同集群间未必通用。
切换到动态供给的归档参数 archiveOnDelete 之后,我又加了一条硬性要求:所有删除操作必须通过 kubectl delete pvc 触发,禁止直接删 PV 或去存储服务器上乱动目录。这套流程下来,测试环境的数据安全事故基本绝迹,生产环境也再没有出现"删库跑路"级别的手滑。
Kubernetes 存储这块,想用好 PV/PVC,核心不在于背出多少参数,而在于理解每个抽象层解决什么问题。把数据生命周期和 Pod 生命周期拆开,把资源供给和资源消费拆开,把回收策略和数据安全绑在一起,你的存储设计就完成了一半。剩下的一半,就是在一次次 Pending 和 mount 报错里,把排错路径焊死在脑子里。