K8s存储核心:PV、PVC与StorageClass概念及实操指南

📅 发布时间:2026/10/16 10:30:48
K8s存储核心:PV、PVC与StorageClass概念及实操指南
1. 厘清盘、目录和卷的边界K8s为什么要绕一层PV/PVC/StorageClass第一次用Kubernetes跑数据库的时候我一度以为存储是最不该有门槛的部分容器里把某个路径挂上不就行了真到配置的时候才发现K8s存储管理并不是“把磁盘塞进Pod”这么直白而是要先和PV、PVC、StorageClass这三个概念打交道。如果一开始没有把这个抽象想明白后面遇到“PVC一直Pending”“PV删不掉”“Pod挂载失败”这类问题排查起来就会非常吃力。我自己最早踩过的坑是拿一个NFS目录直接往Pod里挂。当时觉得反正宿主机能访问NFS容器里也能访问加上一个emptyDir不也有个目录用吗后来服务一扩容问题立刻炸了多个副本同时读写同一个目录数据互相污染Pod被调度到其他节点后原来的挂载点根本不存在更麻烦的是没人说得清这块存储到底归谁管是运维该维护还是应用开发自己维护。K8s把PV、PVC、StorageClass这三个对象拆出来的核心原因就是把“存储资源的供给”和“存储资源的使用”彻底分开。PV是集群里的一块存储资源它可以是NFS目录、云盘、Ceph RBD也可以是节点上一块本地盘PVC是应用发出的“存储需求单”声明我要多大、要什么样的读写模式StorageClass则负责“按需生产”当PVC提需求时自动创建对应的后端存储。这个设计解决的不只是配置方便的问题更关键的是生命周期边界开发不需要知道底层是哪台存储服务器运维不需要每个应用都手动手工建卷。1.1 不搞抽象时容器挂载为什么难以为继如果你只跑一个单节点的测试环境直接在Pod里挂宿主目录确实很简单这也是很多人对K8s存储的第一印象。但一旦环境变成多节点集群问题就不一样了。假设有两个节点节点A和节点B。你手动在节点A的/data/mysql目录放了一份数据然后在Pod里把这个路径挂进去。这个Pod如果始终被调度到节点A暂时没问题。但哪天节点A出问题Pod漂移到节点BK8s发现节点B根本没有/data/mysql这个目录于是只能创建一个空目录挂进去。数据库起来之后数据目录是空的轻则启动失败重则直接覆盖新数据。这种事故我见过不止一次。更麻烦的是权限和归属问题。多个团队共用一个集群时如果每个人都能直接指定宿主目录那不同应用之间根本没有隔离A应用写坏B应用的数据这种事会变成日常。K8s把存储抽象成PV/PVC后普通用户只能看到PVC和StorageClass看不到具体的宿主机路径也不能随便指定任意目录。换句话说存储的“所有权”和“访问权”终于被分开了。1.2 PV是资源PVC是需求单StorageClass是生产线我喜欢用类比来解释这三个对象PV是已经准备好的一块“空地”它可以是一块云硬盘、一台NAS上的共享目录、一个Ceph块设备总之是已经存在的存储资源。PVC是用户填写的“需求单”上面写着我要10G空间、支持单节点读写、最好性能高一点。StorageClass是“按需生产车间”收到PVC这张需求单后它负责动态创建一个PV出来而不是让运维手工去准备。没有StorageClass时工作方式是“静态供给”运维先建好一批PV用户再创建PVC去绑定其中一个。有了StorageClass之后工作方式是“动态供给”用户创建PVC时声明要用哪个StorageClass系统自动创建PV整个过程不用人工干预。这三个对象还有一个生命周期上的微妙区别PV是集群级别的资源不属于任何命名空间PVC是命名空间级别的资源只属于某个命名空间。这意味着你在namespace-a创建的PVC不能直接绑定namespace-b里的PV但PV可以被多个命名空间下的不同PVC绑定前提是规格匹配且尚未被占用。这个区别在后面排查问题时会非常关键。对象作用域核心作用谁来创建PV集群级描述一块具体存储资源运维或动态供给PVC命名空间级描述应用需要的存储规格应用开发者StorageClass集群级定义动态创建PV的模板和策略运维平台团队2. 落地前先定三件事后端类型、回收策略和绑定模式很多人一上来就写YAML写完发现不起作用原因是没想清楚存储后端、回收策略、绑定模式这三个前提。它们是决定PV/PVC行为的底层参数一旦选错后面改起来往往比重新配置还麻烦。2.1 后端类型网络存储、本地存储、CSI插件怎么选K8s标准安装并不会自带上百种存储驱动真正怎么建PV取决于你的后端是什么。网络文件存储比如NFS、CephFS优点是多个节点能同时读写适合共享数据场景缺点是性能受网络影响故障点也多一层。块存储比如云厂商的云盘、Ceph RBD优点是延迟低、IOPS稳定适合数据库、消息队列这类需要高性能的应用通常只能被一个节点读写。本地存储比如hostPath、Local PV性能最好但数据跟节点绑定节点挂了数据可能就没了只能靠上层应用自己做副本。在选择上我一般遵循几个原则测试环境用NFS最省事因为部署简单生产环境的无状态服务用块存储动态供给有状态服务尽量让运维团队评估不要自己拍板。尤其是数据库如果后端存储不支持快照备份策略会非常难做。CSI是现在的标准趋势它让存储厂商只需要实现一套接口K8s就能动态创建、挂载、扩容、快照。如果你用的存储后端提供了CSI驱动优先用它而不是用旧式的in-tree插件或自己拼hostPath。换句话说能动态供给就动态供给避免手工维护一大堆PV。2.2 回收策略Retain、Delete、Recycle到底怎么生效回收策略决定了PVC删除后PV和底层存储怎么处理。看YAML里的persistentVolumeReclaimPolicy字段就能识别。RetainPVC删除后PV不会自动删除状态会变成Released底层数据完整保留。适合需要人工确认数据的场景比如数据库备份。DeletePVC删除后PV以及后端的存储资源也会被删除数据跟着没了。适合临时测试卷。Recycle早期版本会清理目录后重新可用现在基本废弃了不推荐。生产环境我强烈建议默认用Retain尤其是数据库和日志这类数据不可再生的场景。Delete虽然省事但一次手滑删除PVC可能连带把整个存储卷清掉如果后端存储没有回收站后果很难补。有个细节容易忽略回收策略是在PV或StorageClass上配置的。静态创建的PV你在PV里写动态供给时你在StorageClass里写。如果两端不一致以实际创建出来的PV为准。排查时不要只盯着PVC看先查PV当前的persistentVolumeReclaimPolicy是什么值。2.3 绑定模式Immediate和WaitForFirstConsumer的区别StorageClass里有一个volumeBindingMode很多人会跳过它但它直接影响Pod能否调度成功。ImmediatePVC创建后系统立刻创建并绑定PV。这个模式直观但有一个隐患如果存储是局部可访问的比如本地盘而Pod还没确定会被调度到哪个节点系统可能把PV绑到Pod根本去不了的节点上。WaitForFirstConsumerPVC创建后先不绑定等第一个使用这个PVC的Pod出现调度器确定了Pod落点再创建PV并绑定。我见过最典型的翻车场景就是用Local PV时没有设置WaitForFirstConsumer。某个应用创建了PVC系统立刻在节点A创建了一块本地存储但Pod启动后被调度到节点B于是Pod挂载时发现PV不在本节点直接挂载失败日志里只有一句“node(s) didnt have volume zone”或者干脆超时。所以所有和节点数据绑定的存储后端都必须在StorageClass里写volumeBindingMode: WaitForFirstConsumer。3. 静态供给与动态供给实操从手动建PV到StorageClass自动分配理论讲完下面进入实操。我用两个完整例子来展示静态供给和动态供给以及最后Pod怎么消费PVC。你可以直接拿这段配置做原型再根据自己集群的后端调整参数。3.1 静态供给手动创建PV并让PVC绑定假设我有一台NFS存储路径是/exports/data希望让某个应用挂载使用。第一步是创建一个PVapiVersion: v1 kind: PersistentVolume metadata: name: pv-manual-001 spec: capacity: storage: 10Gi volumeMode: Filesystem accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain storageClassName: nfs: path: /exports/data server: 192.168.50.20这里有几个字段必须注意。capacity.storage是PV对外承诺的容量只能写800Gi这种量级不能写成“10GB”这样不规范的格式。accessModes表示访问模式ReadWriteOnce意味着只能被一个节点挂载读写如果应用要跨节点共享就得用ReadWriteMany。storageClassName我刻意写成空字符串是为了避免和集群默认StorageClass产生混淆。静态绑定时如果PV和PVC都写storageClassName: 绑定关系会更明确。然后创建PVCapiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-manual-001 spec: accessModes: - ReadWriteOnce resources: requests: storage: 10Gi storageClassName: 创建后一条命令就能看到绑定结果kubectl get pv pv-manual-001 kubectl get pvc pvc-manual-001如果一切正常PV的STATUS会从Available变成BoundPVC的STATUS会变成Bound。PVC可以理解为拿着一张“需求单”去PV池子里找匹配项PV容量、访问模式、storageClassName都必须匹配。容量匹配时PVC请求的存储不能超过PV声明的容量但PV容量略微大于PVC请求通常也可以绑定这取决于调度逻辑。为了避免这种不够精确的判断生产上我一般把两者容量写成完全一致减少边界问题。3.2 动态供给用StorageClass自动创建PV静态供给的好处是可控缺点是需要运维手工维护PV清单。集群规模一大PV数量多了以后手工创建根本不现实。动态供给只需要创建StorageClass然后创建PVC时指定这个ClassName。下面是一个既支持扩容、又要求延迟绑定的StorageClass示例apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: network-storage-fast provisioner: csi-driver.example.com reclaimPolicy: Retain allowVolumeExpansion: true volumeBindingMode: WaitForFirstConsumer这里的provisioner按你实际集成的存储驱动来填不同驱动有各自的命名规则。我这里的csi-driver.example.com只是占位符。allowVolumeExpansion: true允许后续扩大PVC容量这个能力依赖后端存储驱动有些驱动并不支持必须提前确认。volumeBindingMode设置为WaitForFirstConsumer后PVC创建后不会立刻绑定而是等Pod调度完成后才真正分配存储。接着创建PVCapiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-dynamic-001 spec: accessModes: - ReadWriteOnce resources: requests: storage: 20Gi storageClassName: network-storage-fast关键区别就在storageClassName它填的是StorageClass的名字不是PV的名字。执行后系统会自动创建一个PV名字通常是一长串随机ID。kubectl get pvc pvc-dynamic-001 -w kubectl get pv | grep pvc-dynamic-001当PVC处于Pending状态且Events里写着“waiting for first consumer”时不要急着怀疑配置错误。你应该直接创建使用这个PVC的PodPod被调度到具体节点后PVC会自动完成绑定。这个行为把“存储在哪里”和“容器在哪里”这两个问题绑定到了一起。3.3 在Deployment或StatefulSet里消费PVCPVC建好之后应用侧的使用方式几乎没有差别。不管是静态创建的PVC还是动态创建的PVC在Pod里都是同一个写法。以Deployment为例apiVersion: apps/v1 kind: Deployment metadata: name:>kubectl exec -it deploy/data-writer -- ls -l /data kubectl exec -it deploy/data-writer -- cat /data/date.log如果能在/data里看到写入的文件说明整条链路已经通了。接下来再换回真实业务镜像通常就不会有太大问题。对于有状态应用如果要动态创建多个独立卷比如每个副本一个PVC就应该用StatefulSet里的volumeClaimTemplates而不是在Deployment里手动写PVC。volumeClaimTemplates会自动为每个副本生成一份PVC名字格式是template-name-pod-name。这个设计特别适合Kafka、Elasticsearch这类需要一实例一卷的应用。4. 真实环境最容易翻车的地方Pending、Terminating、挂载权限异常先说一句实话PV/PVC的YAML本身并不复杂难的是跟真实环境、真实存储后端打交道。下面把我自己在集群上踩过的几类问题完整讲一遍每个问题的排查链路和修复思路都会拆开讲。4.1 PVC一直Pending的完整排查链路PVC挂在Pending状态是最常见的问题。看到Pending先执行kubectl describe pvc pvc-name重点看Events输出常见的有这么几类第一种no persistent volumes available for this claim这是典型的静态供给场景说明集群里没有现成的PV能匹配这个PVC。原因通常是容量不够、accessModes不匹配或storageClassName不匹配。我最常遇到的是storageClassName没对上PV里写了某个StorageClassPVC里写了另一个或者写成了空字符串导致两边认不到彼此。第二种waiting for first consumer to be created before binding这不是错误更像是一个“还没开始”的状态。说明StorageClass设置了WaitForFirstConsumer但使用PVC的Pod还没创建。这个场景很容易被误判尤其是习惯性用kubectl get pvc看状态的人看到Pending就容易慌。正确做法是先创建Pod让调度器介入。第三种storageclass not found说明PVC里写的storageClassName在集群里不存在。可以用kubectl get storageclass确认一下名字是否写错特别是大小写和中间的下划线、横线。第四种provisioning failed或invalid provisioner动态供给时出现这个说明StorageClass的provisioner和后端存储驱动不匹配或者驱动组件没部署好。这时候要检查CSI驱动是否在运行常见排查命令是看对应命名空间下的Pod是否Ready以及StorageClass里的provisioner名称是不是集群中注册过的名字。4.2 动态供给成功但Pod挂载失败目录、权限、驱动三层问题有时候PVC已经Bound了PV也创建出来了Pod却一直ContainerCreating。这个时候别再看PVC状态要去看Pod事件kubectl describe pod pod-name挂载失败一般分三层。存储路径问题比如NFS挂载后宿主机上找不到/exports/data这个目录或者权限是root写的普通容器用户进不去。NFS这类网络存储对目录权限的校验比较直接目录必须真实存在并且应用用户的UID要能访问。fsGroup权限问题K8s会在Pod的securityContext里配置fsGroup用来改变挂载卷在容器内的属组。这个机制在本地盘或云盘场景通常有效但NFS这类网络存储是否生效取决于NFS服务器端的配置。如果服务器端强制root_squash那么即使Pod设置了fsGroup文件还是会被映射成nobody写不进去。CSI驱动问题动态供给成功说明provisioner工作正常但挂载环节还涉及另一个组件通常是CSI Node插件。容器里执行mount命令时CSI插件负责和后端通信。如果Node插件没启动或者和Kubelet的socket路径对不上Pod就会卡在挂载阶段。排查时我一般从Pod事件一步步往底层找先看挂载命令是否执行再看宿主机能不能直接访问存储目录最后看CSI插件日志。4.3 PVC删不掉、PV一直Released的根因删除顺序和回收策略是最容易让人迷惑的地方。先记住一个原则有Pod在使用PVC时PVC是删不掉的。K8s会给PVC加一个kubernetes.io/pvc-protectionfinalizer只有当所有引用它的Pod都消失后才会真正删除。如果发现PVC一直处于Terminating先查kubectl get pod --all-namespaces | grep pvc-name找到引用这个PVC的Pod把它删掉或扩缩容到0PVC自然就能删除。如果确实找不到Pod但PVC还是卡在Terminating一般是finalizer卡住了。不建议直接强制删除因为可能造成PV和后端存储不一致。稳妥做法是仔细检查是不是有无状态负载还在引用。PV出现Released状态说明PVC已经删除但回收策略是Retain所以PV没有被自动删除。这种状态下的数据还在底层存储没有动过。如果想重新使用这块盘需要人工清理claimRef并让PV回到Available但这个操作在不同版本上行为略有差异。我的建议是不要在生产环境反复做这类手工操作该保留数据就保留该归档就归档人为干预PV的claimRef很容易埋雷。4.4 容量扩容为什么没生效PVC扩容在K8s里是支持的但前提条件必须同时满足后端存储驱动支持扩容、StorageClass的allowVolumeExpansion为true、PVC的volumeMode是Filesystem或Block且匹配后端能力。扩容操作本身很简单kubectl edit pvc pvc-name把spec.resources.requests.storage改成更大的值。保存后系统会自动触发后端扩容。但你可能遇到改了之后状态仍然是原来的容量或者PVC被更新了但Pod里的文件系统没变大。这一般是后端驱动的异步流程云盘或Ceph需要先扩展块设备然后文件系统才能扩容。某些文件系统需要在节点上进行额外的resize操作而CSI驱动不一定自动执行。遇到这种情况先看PVC的Conditions字段是否提示扩容中再看CSI相关Pod日志不要以为编辑完就万事大吉。5. 我实际用下来的选型思路静态供给和动态供给怎么搭配讲完技术和故障再聊一点更偏“决策”的经验。很多人会问到底用静态供给还是动态供给我的答案不是二选一而是按场景分配。5.1 静态供给适合有存量基础设施的场景如果你的公司已经有一套NAS或集中式存储并且希望所有容器应用都共享这一套存储静态供给是更简单的方案。你只需要按业务容量把NFS目录切成多个PV然后把PV清单交给应用团队让他们在PVC里选择即可。静态供给还有一个好处是命名可读性强。动态供给创建的PV名通常是一长串随机ID排障时很难一眼看出来属于哪个业务。静态PV名可以写得很规则比如pv-log-001、pv-mysql-prod-001人类可读性好很多。但静态供给的缺点也很明显knife就是容量规划和回收依赖人工。假设你创建了20个10Gi的PV业务实际只用了一半剩下的10个就闲置了。磁盘是物理存在的无法“缩回去”给其他业务用。所以静态供给更适合容量需求相对稳定的应用。5.2 动态供给适合弹性环境和自动化平台如果集群面向多个团队业务类型杂、规划难度大动态供给几乎是唯一选择。业务方提交PVC时只需要写容量和StorageClass系统自动调配后端存储不用运维提前准备PV。动态供给特别适合测试环境和CI流水线。一次测试跑完直接删掉PVC和Namespace底层云盘跟着释放资源马上回收给平台。这个体验和云计算平台的“按需分配”是一致的运维成本能降好几个台阶。代价是后端存储本身必须支持API创建卷传统NAS如果只提供手工目录创建就没法做动态供给。这也是为什么现在很多存储产品都开始支持CSI驱动因为只有实现了CSI才能融入K8s的动态供给体系。5.3 我总结的检查清单每次配置完存储我都会按下面这组问题再过一遍PV和PVC的storageClassName是否精确匹配有没有被默认StorageClass干扰PVC需要的accessModes是否和后端能力匹配ReadWriteOnce和ReadWriteMany一定分清楚。StorageClass的volumeBindingMode是否和存储后端匹配Local PV务必用WaitForFirstConsumer。回收策略是不是符合数据安全预期生产数据尽量Retain。后端存储目录是否存在权限是否足够NFS场景尤其要看Root Squash。是否需要设置allowVolumeExpansion必须确认驱动支持再开。数据库这类有状态应用是否做了备份存储层快照和逻辑备份至少有一个。最后分享一个我自己养成的习惯所有存储相关资源都不随手删除确认数据不需要了再清理。因为存储不同于CPU和内存删掉就真的没了哪怕回收策略是Delete也只能保证流程正确不能保证数据不可再生。K8s的存储设计本质是把复杂留给平台把简单留给应用而我们要做的是确保平台这一步的每一个参数都清楚自己在干什么。