1. 为什么PV和PVC是K8s里最容易被低估的一对组合
刚接触K8s那会儿,我对PV和PVC的态度就是“能跑就行”。Pod里挂个emptyDir,重启数据就没了,当时觉得无所谓,反正都是无状态服务。直到有一次帮朋友处理一个自建的小型网盘服务,Pod一重建,用户上传的文件全丢,那一刻我才真正意识到:持久化存储不是可选项,是生产环境的入场券。
PV和PVC这对组合,本质上解决的是“Pod生命周期与数据生命周期不一致”的问题。Pod是临时的、可调度的、随时可能被删掉重建的;但数据不行,数据得活着,得跟着存储走,不能跟着Pod走。K8s的设计思路很清晰:把“存储资源”和“存储需求”拆开,让运维管存储,让开发管需求,中间通过绑定机制自动撮合。
这套机制听起来简单,但实际用起来坑不少。比如PVC一直Pending、PV绑定了却挂载失败、Pod删了PVC还在计费、StorageClass动态供给出来的卷大小不对……这些问题我在不同环境里都踩过。所以这篇内容我打算把PV和PVC从概念到实操、从静态供给到动态供给、从排查到避坑,完整地捋一遍。不管你是刚学K8s的运维新手,还是已经用过但没系统整理过的开发者,应该都能从中找到能直接抄作业的部分。
2. PV和PVC的核心概念与设计逻辑拆解
2.1 PV到底是什么:集群级别的存储资源抽象
PV,全称PersistentVolume,翻译过来叫持久卷。你可以把它理解成集群里的一块“存储地盘”,它不属于任何命名空间,是集群级别的资源。这块地盘可以是NFS的一个目录、Ceph的一个RBD块设备、云厂商的一块云盘,也可以是本地磁盘的一个路径。
PV有自己的生命周期,独立于任何Pod。它里面记录了这块存储的容量、访问模式、回收策略、后端存储类型等元信息。运维人员或者StorageClass控制器负责创建PV,Pod本身不直接碰PV,而是通过PVC来申请。
这里有个关键点:PV是“资源提供方”,不是“资源使用方”。它只负责描述“我这里有多少存储、支持什么访问方式”,至于谁来用、怎么用,它不关心。这种职责分离的设计,让存储管理和应用管理可以解耦,运维不用关心每个应用怎么用存储,开发也不用关心底层存储是NFS还是Ceph。
2.2 PVC是什么:命名空间级别的存储申请
PVC,全称PersistentVolumeClaim,翻译过来叫持久卷声明。它是命名空间级别的资源,由开发者或者应用部署者创建,用来表达“我需要一块存储,容量多大、读写模式是什么”。
PVC就像一张“存储申请单”,上面写着需求:我要10Gi、我要能被多个Pod同时读写、我要用SSD类型的存储。K8s的控制平面会拿着这张申请单去找匹配的PV,找到就绑定,找不到就Pending等着。
PVC的好处在于,它把存储的“消费”和“供给”彻底分开了。开发只需要写一个PVC,声明自己要什么,不用管底层是NFS还是Ceph,不用管IP地址和挂载参数。这种抽象让应用的可移植性大大增强,同一套YAML换个集群也能跑,只要目标集群有对应的StorageClass或者PV。
2.3 PV和PVC的绑定机制:一场精准的“配对游戏”
PV和PVC的绑定不是随便配的,K8s有一套匹配规则。核心匹配维度包括:
- 容量:PV的容量必须大于等于PVC申请的容量。比如PVC要10Gi,PV有20Gi,可以绑定;PV只有5Gi,绑不了。
- 访问模式:PVC声明的访问模式必须是PV支持的模式之一。PV支持ReadWriteMany,PVC要ReadWriteOnce,可以绑;反过来就不行。
- StorageClass:如果PVC指定了storageClassName,PV的storageClassName必须一致。如果PVC没指定,PV也没指定,两者可以匹配;但如果PVC指定了而PV没指定,或者反过来,通常绑不上。
- Selector:PVC可以通过label selector进一步筛选PV,这个用得少,但在多租户场景下有用。
绑定是一对一的,一个PV只能被一个PVC绑定,一个PVC也只能绑一个PV。绑定关系建立后,PV的状态从Available变成Bound,PVC的状态也变成Bound。如果PVC删了,PV会根据回收策略决定是保留、删除还是回收。
注意:绑定是“独占”的,不是“共享”的。如果你想让多个Pod共享同一块存储,需要的是PV支持ReadWriteMany访问模式,而不是让多个PVC绑同一个PV。
2.4 访问模式的差异:RWO、ROX、RWX、RWOP到底怎么选
访问模式是PV和PVC匹配的关键参数,也是实际使用中最容易搞混的地方。K8s定义了四种访问模式:
| 访问模式 | 缩写 | 含义 | 典型后端 |
|---|---|---|---|
| ReadWriteOnce | RWO | 只能被单个节点以读写方式挂载 | 云盘、本地盘、Ceph RBD |
| ReadOnlyMany | ROX | 可以被多个节点以只读方式挂载 | NFS、CephFS |
| ReadWriteMany | RWX | 可以被多个节点以读写方式挂载 | NFS、CephFS、GlusterFS |
| ReadWriteOncePod | RWOP | 只能被单个Pod以读写方式挂载 | 云盘、本地盘 |
RWO和RWOP的区别在于粒度:RWO是节点级别,同一个节点上的多个Pod可以同时挂载;RWOP是Pod级别,严格限制只有一个Pod能挂。RWOP是K8s 1.22之后才稳定的特性,适合有状态应用的单实例场景。
选访问模式的原则很简单:看你的后端存储支持什么,再看你的应用需要什么。云盘通常只支持RWO,NFS支持RWX,本地盘只支持RWO。如果你的应用需要多个Pod同时写同一块存储,那就必须选支持RWX的后端,比如NFS或者CephFS。
2.5 回收策略:PV删了之后数据怎么办
PV的回收策略决定了PVC释放后,PV和里面的数据怎么处理。有三种策略:
- Retain:保留PV和数据,PV状态变成Released,需要手动清理和重新绑定。这是最安全的策略,适合重要数据。
- Delete:删除PV和后端存储资产。动态供给的PV默认就是Delete,云盘会被直接删掉。
- Recycle:已废弃,早期版本用rm -rf清理卷内容,现在不推荐使用。
生产环境里,重要数据一律用Retain。我见过太多因为默认Delete策略导致云盘被误删的案例,数据恢复成本极高。动态供给虽然方便,但默认的Delete策略是一把双刃剑,用之前一定要确认后端存储的生命周期管理策略。
3. 静态供给与动态供给的实操全流程
3.1 静态供给:手动创建PV再绑定PVC
静态供给是最原始的方式,运维手动创建PV,开发创建PVC,两者匹配后绑定。这种方式适合存储资源有限、需要精细控制的场景。
先创建一个NFS类型的PV:
apiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv-demo labels: type: nfs spec: capacity: storage: 10Gi accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain storageClassName: nfs-static nfs: path: /data/nfs/demo server: 192.168.1.100这里几个关键参数:capacity声明容量,accessModes声明访问模式,persistentVolumeReclaimPolicy设成Retain保证数据安全,storageClassName用来和PVC匹配,nfs段指定后端NFS的地址和路径。
创建PVC来申请:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc-demo namespace: default spec: accessModes: - ReadWriteMany resources: requests: storage: 5Gi storageClassName: nfs-staticPVC申请5Gi,PV有10Gi,容量匹配;访问模式都是ReadWriteMany,匹配;storageClassName都是nfs-static,匹配。创建后两者会自动绑定。
在Pod里使用这个PVC:
apiVersion: v1 kind: Pod metadata: name: nfs-pod-demo spec: containers: - name: app image: nginx:alpine volumeMounts: - name: data mountPath: /usr/share/nginx/html volumes: - name: data persistentVolumeClaim: claimName: nfs-pvc-demoPod通过persistentVolumeClaim字段引用PVC,K8s会自动找到绑定的PV并挂载。
实操心得:静态供给时,PV的capacity可以大于PVC的requests,但反过来不行。我一般会把PV容量设得比预期大20%左右,留点余量,避免后期扩容麻烦。
3.2 动态供给:StorageClass自动创建PV
动态供给是现代K8s集群的主流方式。运维只需要创建StorageClass,定义好后端存储的Provisioner和参数,开发创建PVC时指定StorageClass,系统会自动创建对应的PV。
先创建一个NFS的StorageClass(需要提前部署nfs-subdir-external-provisioner):
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-dynamic provisioner: k8s-sigs.io/nfs-subdir-external-provisioner parameters: server: 192.168.1.100 path: /data/nfs/dynamic archiveOnDelete: "true" reclaimPolicy: Retain volumeBindingMode: Immediateprovisioner指定供给器,parameters传给供给器的参数,reclaimPolicy设成Retain,volumeBindingMode设成Immediate表示PVC创建后立即绑定。
开发创建PVC时指定这个StorageClass:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc-dynamic spec: accessModes: - ReadWriteMany resources: requests: storage: 5Gi storageClassName: nfs-dynamicPVC创建后,provisioner会自动在NFS的/data/nfs/dynamic目录下创建一个子目录,并生成对应的PV。整个过程不需要运维手动干预。
注意:动态供给的PV名字通常是自动生成的,格式类似pvc- 。如果你需要追踪某个PVC对应的后端存储路径,可以通过PV的volumeName或者直接看NFS目录下的子目录名。
3.3 从Pod到PVC到PV的完整链路验证
创建完Pod后,怎么确认存储真的挂上了?我一般按这个顺序排查:
- 看PVC状态:
kubectl get pvc,确认STATUS是Bound。 - 看PV状态:
kubectl get pv,确认对应的PV是Bound,且CLAIM指向正确的PVC。 - 看Pod状态:
kubectl describe pod <pod-name>,确认Volume挂载没有报错。 - 进Pod验证:
kubectl exec -it <pod-name> -- df -h,看挂载点是否存在,容量是否正确。 - 写测试文件:
kubectl exec -it <pod-name> -- touch /mount-path/test.txt,然后删掉Pod重建,再进新Pod看文件是否还在。
这套流程走下来,基本能确认整条链路是通的。如果中间任何一步有问题,下一章会详细讲排查方法。
3.4 扩容操作:PVC怎么在线扩容
K8s 1.11之后支持PVC扩容,但需要StorageClass的allowVolumeExpansion设为true。扩容操作很简单,直接改PVC的spec.resources.requests.storage:
kubectl patch pvc nfs-pvc-dynamic -p '{"spec":{"resources":{"requests":{"storage":"10Gi"}}}}'改完之后,PVC的status里会出现conditions,表示正在扩容。扩容完成后,文件系统会自动resize(取决于后端存储和文件系统类型)。
实操心得:不是所有后端都支持在线扩容。NFS通常可以,云盘要看厂商实现,Ceph RBD需要文件系统支持。扩容前一定要确认后端能力,否则可能卡在Resizing状态。
4. 常见问题排查与避坑指南
4.1 PVC一直Pending的六种原因
PVC Pending是最常见的问题,原因通常有这几类:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| PVC Pending,无PV | 没有匹配的PV,或StorageClass不存在 | kubectl get pv看是否有Available的PV;kubectl get sc看StorageClass是否存在 |
| PVC Pending,有PV但不绑 | 容量、访问模式、StorageClass不匹配 | kubectl describe pvc看Events里的匹配失败原因 |
| PVC Pending,StorageClass有问题 | Provisioner未部署或报错 | kubectl get pods -n <provisioner-namespace>看供给器Pod状态 |
| PVC Pending,后端存储不可达 | NFS挂载失败、云盘API限流 | 看provisioner日志,检查网络和权限 |
| PVC Pending,配额限制 | 命名空间有ResourceQuota | kubectl describe quota -n <namespace> |
| PVC Pending,volumeBindingMode=WaitForFirstConsumer | 没有Pod使用该PVC | 创建使用该PVC的Pod后会自动绑定 |
WaitForFirstConsumer这个模式特别容易让人困惑。它的设计目的是让PV在Pod调度后再绑定,确保PV和Pod在同一个可用区。如果你创建了PVC但没创建Pod,PVC就会一直Pending,这是正常行为,不是bug。
4.2 Pod挂载失败的五种典型报错
Pod挂载失败通常表现为Pod卡在ContainerCreating状态,describe pod能看到具体报错:
- MountVolume.SetUp failed for volume: 通常是NFS服务器不可达、路径不存在、权限不对。检查NFS的exports配置和网络连通性。
- Unable to attach or mount volumes: 云盘场景常见,可能是云盘还在挂载中,或者节点和云盘不在同一可用区。
- failed to mount the volume: 文件系统损坏或者挂载参数不对。可以尝试手动在节点上挂载验证。
- volume is already exclusively attached to one node: RWO卷被多个节点同时挂载,检查是否有多个Pod跨节点使用同一个RWO PVC。
- permission denied: 容器内用户没有权限写挂载目录。可以通过securityContext指定fsGroup,或者调整NFS的权限配置。
避坑技巧:NFS场景下,如果Pod里写文件报Permission denied,大概率是NFS服务端的squash配置问题。可以在exports里加no_root_squash,或者给PV加mountOptions指定uid和gid。
4.3 数据丢失的三种高危场景
数据丢失是持久化存储最严重的问题,我总结了三类高危场景:
场景一:误用Delete回收策略。动态供给默认Delete,PVC一删,云盘直接销毁。生产环境一定要把reclaimPolicy改成Retain,或者至少确认后端有快照备份。
场景二:PVC被误删。PVC删了之后,如果PV是Retain,数据还在但PV变成Released,需要手动清理spec.claimRef才能重新绑定。如果PV是Delete,数据直接没了。
场景三:多Pod同时写RWO卷。RWO卷在节点级别是独占的,但如果两个Pod调度到同一节点,K8s允许它们同时挂载。这时候如果两个Pod同时写同一块存储,文件系统可能损坏。RWOP模式可以避免这个问题。
4.4 性能调优的三个关键参数
存储性能直接影响应用响应速度,这几个参数值得关注:
- mountOptions:NFS可以调rsize和wsize,默认是1M,大文件场景可以调到4M或更大。云盘可以调discard和nobarrier。
- volumeBindingMode:Immediate适合对调度延迟敏感的场景,WaitForFirstConsumer适合多可用区场景,避免PV和Pod跨区。
- StorageClass的parameters:不同provisioner支持的参数不同,比如Ceph RBD可以指定pool、imageFeatures、fsType等。
实操心得:NFS场景下,如果应用是小文件密集读写,建议把rsize和wsize调小到64K或128K,减少网络往返开销。大文件顺序读写则调大,提升吞吐。
5. 生产环境落地的经验与建议
5.1 命名规范与标签体系
生产环境里PV和PVC数量多了之后,管理是个大问题。我建议建立一套命名规范:
- PV命名:
<env>-<backend>-<app>-<seq>,比如prod-nfs-webapp-001。 - PVC命名:
<app>-<component>-pvc,比如webapp-data-pvc。 - 标签:至少打上
env、app、team、backend四个标签,方便筛选和审计。
标签体系配合kubectl的label selector,可以快速定位某个应用的所有存储资源。比如kubectl get pvc -l app=webapp就能列出webapp相关的所有PVC。
5.2 监控与告警配置
PV和PVC的监控主要关注几个指标:
- PVC使用率:
kubelet_volume_stats_used_bytes / kubelet_volume_stats_capacity_bytes,超过80%告警。 - PV状态:Available、Bound、Released、Failed,Failed状态要立即处理。
- Provisioner健康度:供给器Pod的存活状态和错误日志。
- 后端存储健康度:NFS服务端可达性、云盘API限流情况。
Prometheus配合kube-state-metrics可以采集这些指标,Grafana做面板展示。告警规则建议设置PVC使用率超过85%触发Warning,超过95%触发Critical。
5.3 备份策略的制定
持久化存储的备份不能只靠K8s层面的机制,需要结合后端存储的能力:
- 快照:云盘和Ceph支持快照,可以定期打快照,恢复速度快。
- 文件级备份:NFS场景可以用rsync或者Velero做文件级备份。
- 应用级备份:数据库类应用最好用自带的备份工具,比如mysqldump、pg_dump,保证一致性。
备份策略要定期演练恢复流程,不然真出问题的时候手忙脚乱。我一般建议至少每季度做一次恢复演练,确认备份可用。
5.4 多租户场景下的隔离
多租户集群里,存储隔离很重要。可以通过这几个维度做隔离:
- 命名空间隔离:PVC是命名空间级别的,天然有隔离性。
- StorageClass隔离:不同租户用不同的StorageClass,后端存储和参数都分开。
- ResourceQuota:限制每个命名空间的存储总量和PVC数量。
- NetworkPolicy:如果后端存储走网络,可以用NetworkPolicy限制访问。
注意:PV是集群级别的,多租户场景下要避免租户直接创建PV,应该统一由管理员或者StorageClass动态供给。
5.5 版本升级时的注意事项
K8s版本升级时,存储相关的API版本可能有变化。比如extensions/v1beta1的StorageClass在1.22被移除,必须迁移到storage.k8s.io/v1。升级前要检查所有PV、PVC、StorageClass的apiVersion,确保兼容。
另外,CSI驱动的版本也要和K8s版本匹配。升级K8s之前,先确认CSI驱动支持新版本,否则可能导致存储挂载失败。
6. 写在最后的一些个人体会
PV和PVC这套机制,刚上手的时候觉得绕,用熟了之后会发现设计得很优雅。它把存储的复杂性和应用的简单性平衡得很好,开发只需要关心“我要多大、怎么读写”,运维只需要关心“后端存储怎么配、怎么扩”。
我踩过最大的坑是早期用动态供给时没改回收策略,一个测试环境的PVC删了之后,云盘直接被销毁,虽然只是测试数据,但也吓出一身冷汗。从那以后,所有生产环境的StorageClass我都强制设成Retain,并且加了定期快照。
另一个体会是,存储问题往往不是K8s层面的问题,而是后端存储的问题。PVC Pending、Pod挂载失败,最后排查下来经常是NFS服务端配置不对、云盘API限流、网络不通这些底层原因。所以排查的时候不要只盯着K8s看,要往下钻,看后端存储的日志和状态。
最后分享一个小技巧:如果你不确定一个PVC对应的后端存储路径在哪,可以kubectl get pv <pv-name> -o yaml看spec里的后端配置,NFS会显示server和path,云盘会显示volumeId。顺着这个线索去后端存储里找,基本都能定位到。