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漂移到节点B,K8s发现节点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字段就能识别。
- Retain:PVC删除后,PV不会自动删除,状态会变成Released,底层数据完整保留。适合需要人工确认数据的场景,比如数据库备份。
- Delete:PVC删除后,PV以及后端的存储资源也会被删除,数据跟着没了。适合临时测试卷。
- Recycle:早期版本会清理目录后重新可用,现在基本废弃了,不推荐。
生产环境我强烈建议默认用Retain,尤其是数据库和日志这类数据不可再生的场景。Delete虽然省事,但一次手滑删除PVC,可能连带把整个存储卷清掉,如果后端存储没有回收站,后果很难补。
有个细节容易忽略:回收策略是在PV或StorageClass上配置的。静态创建的PV,你在PV里写;动态供给时,你在StorageClass里写。如果两端不一致,以实际创建出来的PV为准。排查时不要只盯着PVC看,先查PV当前的persistentVolumeReclaimPolicy是什么值。
2.3 绑定模式:Immediate和WaitForFirstConsumer的区别
StorageClass里有一个volumeBindingMode,很多人会跳过它,但它直接影响Pod能否调度成功。
Immediate:PVC创建后,系统立刻创建并绑定PV。这个模式直观,但有一个隐患:如果存储是局部可访问的,比如本地盘,而Pod还没确定会被调度到哪个节点,系统可能把PV绑到Pod根本去不了的节点上。WaitForFirstConsumer:PVC创建后,先不绑定,等第一个使用这个PVC的Pod出现,调度器确定了Pod落点,再创建PV并绑定。
我见过最典型的翻车场景,就是用Local PV时没有设置WaitForFirstConsumer。某个应用创建了PVC,系统立刻在节点A创建了一块本地存储,但Pod启动后被调度到节点B,于是Pod挂载时发现PV不在本节点,直接挂载失败,日志里只有一句“node(s) didn't have volume zone”或者干脆超时。所以,所有和节点数据绑定的存储后端,都必须在StorageClass里写volumeBindingMode: WaitForFirstConsumer。
3. 静态供给与动态供给实操:从手动建PV到StorageClass自动分配
理论讲完,下面进入实操。我用两个完整例子来展示静态供给和动态供给,以及最后Pod怎么消费PVC。你可以直接拿这段配置做原型,再根据自己集群的后端调整参数。
3.1 静态供给:手动创建PV并让PVC绑定
假设我有一台NFS存储,路径是/exports/data,希望让某个应用挂载使用。第一步是创建一个PV:
apiVersion: 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: "",绑定关系会更明确。
然后创建PVC:
apiVersion: 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变成Bound,PVC的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调度完成后才真正分配存储。
接着创建PVC:
apiVersion: 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的Pod,Pod被调度到具体节点后,PVC会自动完成绑定。这个行为把“存储在哪里”和“容器在哪里”这两个问题绑定到了一起。
3.3 在Deployment或StatefulSet里消费PVC
PVC建好之后,应用侧的使用方式几乎没有差别。不管是静态创建的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里写了某个StorageClass,PVC里写了另一个,或者写成了空字符串,导致两边认不到彼此。
第二种: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,把它删掉或扩缩容到0,PVC自然就能删除。如果确实找不到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的存储设计本质是把复杂留给平台,把简单留给应用,而我们要做的,是确保平台这一步的每一个参数都清楚自己在干什么。