凌晨两点多被电话叫起来,说某业务的报表数据全没了。排查一圈发现 Pod 因为节点资源不足被驱逐重建,容器里的/data目录跟着新的容器生命周期一起归零了。这不是什么罕见事故,恰恰是每个刚接触容器化的人都会撞上的第一堵墙:容器的文件系统是临时的,Pod 一重建,写在里面的东西就没了。Kubernetes 的存储体系就是为这件事准备的,而 Persistent Volume(下面统一简称 PV)是这套体系里承上启下的那块基石。很多人学 PV 的时候会卡在一个很奇怪的位置——YAML 会抄,kubectl get pv会看,但一旦 PVC 卡在 Pending、Pod 起不来报挂载失败,就完全不知道从哪下手。这篇内容就干一件事:把 PV 这个概念从"它是什么"一直讲到"出问题怎么查",中间穿插我在实际集群里踩过的坑和验证过的配置。适合已经能跑起 Pod、Deployment,但对存储还是一知半解的人,也适合已经把 PV 用起来但没搞明白绑定规则和回收策略的人。
1. 先把概念捋直:PV、PVC、StorageClass 到底谁管什么
1.1 从一次 Pod 重启数据全丢说起
要理解 PV 存在的意义,得先接受一个反直觉的事实:Pod 的本地磁盘从来就不是用来存数据的。Pod 里的容器共享一个临时卷,这个卷的生命周期和 Pod 完全绑定,Pod 被删除、被驱逐、被调度到别的节点重建,这个临时卷就跟着销毁。哪怕你只是改了个镜像 tag 做滚动更新,新 Pod 起来的那一刻,老 Pod 里写的文件就已经没了。
我更愿意把 Pod 类比成一家快餐店的临时摊位:摊位摆在哪里、摆多久,由调度器说了算,收摊之后摊位上的一切都要清走。那数据放哪?得放仓库。PV 就是集群里由管理员预先准备好、或者由系统自动创建出来的"仓库",它是一个独立的集群级资源,和 Pod 之间没有从属关系。Pod 死了,仓库还在;Pod 换到另一个节点重建,只要仓库能被那个节点访问到,数据就还在。
这里有个细节很多人第一次听到会愣一下:PV 属于集群级别,不属于任何命名空间。你可以用kubectl get pv看到全集群所有的 PV,不需要加-n。而 PVC(PersistentVolumeClaim)是命名空间级别的,它像一个"领料单",由业务方在自己的命名空间里提交,说明我需要多大空间、什么访问模式,然后由系统去匹配一个合适的 PV 交给它。这个"管理员准备资源、业务方提需求、系统做撮合"的模型,是理解整个 K8s 存储体系的钥匙。
1.2 三个对象的职责边界
新手最容易混淆的就是 PV、PVC、StorageClass 这三个东西,我一般用下面这张表来给团队里新同学讲:
| 对象 | 作用域 | 谁来创建 | 核心职责 |
|---|---|---|---|
| PersistentVolume | 集群级 | 管理员手写,或由 StorageClass 自动创建 | 描述一块真实存在的存储,包括容量、访问模式、后端类型、回收策略 |
| PersistentVolumeClaim | 命名空间级 | 业务方(开发/运维) | 声明需求,如"我要 10Gi、ReadWriteOnce",作为 Pod 挂载存储的入口 |
| StorageClass | 集群级 | 管理员 | 描述"怎么自动创建 PV",包含制备器(provisioner)、参数、绑定模式 |
职责分清之后,链条就很顺了:存储管理员用 StorageClass 定义"这类存储怎么造",或者干脆手工写几个 PV;业务方写一个 PVC 说要多少空间;控制器负责把 PVC 和某个 PV 绑在一起;Pod 的volumes里引用 PVC 名字,就拿到了这块存储。整条链路上,业务方永远不直接接触 PV,只看 PVC,这就是所谓"关注点分离"。
反过来看,如果不做这层抽象,让 Pod 直接写 NFS 地址或者云盘 ID,会有什么后果?一是业务方必须知道存储底层的所有细节,包括 IP、路径、鉴权;二是换个存储后端就得改所有业务 YAML;三是没有任何配额和权限约束,谁都能声明一块巨大的盘。PV/PVC 这套抽象,本质上是在存储这件事上加了一层"申请-审批-分配"的流程。
1.3 为什么不让 Pod 直接挂存储
有人会问,K8s 不是有volumes字段吗,直接在里面写nfs不就行了?确实可以,而且早期很多集群就是这么用的。但这种写法有几个绕不过去的问题。
第一是可移植性差。业务 YAML 里硬编码了 NFS 服务器地址,这个 YAML 拿到测试环境、拿到另一个集群就跑不了,必须改。而用 PVC,不同环境只要准备同名 PVC 对应的存储即可,业务 YAML 一个字不用动。
第二是权限和配额无法管控。直接挂 NFS 意味着任何有 Pod 创建权限的人都能挂任意路径,这在多租户集群里是灾难。PVC 则可以配合 ResourceQuota 限制每个命名空间能申请多少存储总量,配合 RBAC 限制谁能创建 PVC。
第三是生命周期管理缺失。直接挂 NFS 的 Pod 删除后,NFS 上的数据没人管,日积月累一堆孤儿目录。PV 有回收策略,可以配置成删除 PVC 时自动清理后端存储,也可以配置成保留待人工处理,这个开关的价值在长期运维里非常明显。
第四是状态不可观测。PV 和 PVC 都有自己的状态字段,kubectl get pvc一眼能看出是 Pending 还是 Bound,出问题时有明确的排查入口。直接写nfs的 Pod 挂载失败时,报错信息往往埋在 kubelet 日志里,排查成本高得多。
注意:并不是说 Pod 里绝对不能写
volumes.nfs,做临时实验、单机验证时怎么写都行。但只要是要上生产、要被别人复用、要跨环境部署的东西,一律走 PVC,这条规矩在团队里越早立越好,能省掉后面大量的返工。
2. PV 对象的核心字段与选型逻辑
2.1 容量、访问模式、回收策略三件套
一个 PV 的spec里有几个字段是必须吃透的,我把它们称为"三件套":容量(capacity)、访问模式(accessModes)、回收策略(persistentVolumeReclaimPolicy)。
容量字段看起来最简单,capacity.storage: 10Gi,但它有个容易误解的点:这个值是"声明值",不是"实际值"。PV 描述的是"我这里有 10Gi 可用",K8s 并不会去校验你的 NFS 目录是不是真的有 10Gi 空间。动态供给时,制备器会按这个数字去创建底层卷;静态供给时,这个数字纯粹是给匹配用的标签。所以手工写 PV 时,容量填多少要以实际后端能提供的为准,填虚了迟早出问题。
访问模式决定了这块存储能被多少个节点、以什么方式挂载。回收策略决定了 PVC 删除之后,PV 和后端数据怎么处理。这三个字段加上storageClassName,基本就决定了这个 PV 的"性格"。
我见过不少团队的 PV 是复制粘贴出来的,accessModes全填ReadWriteOnce,reclaimPolicy全填Retain,也不管后端到底支不支持。这种配置在测试环境跑得好好的,一上生产就会暴露问题——比如多副本的 Pod 挂在同一个 NFS 上,明明需要ReadWriteMany,填了ReadWriteOnce之后第二个副本就卡在挂载失败。
2.2 访问模式的真实语义与常见坑
访问模式有四种,定义是这样的:
| 模式 | 缩写 | 语义 |
|---|---|---|
| ReadWriteOnce | RWO | 只能被单个节点以读写方式挂载 |
| ReadOnlyMany | ROX | 可以被多个节点以只读方式挂载 |
| ReadWriteMany | RWX | 可以被多个节点以读写方式挂载 |
| ReadWriteOncePod | RWOP | 只能被单个 Pod以读写方式挂载 |
这里最容易踩坑的是 RWO 的定义。注意它说的是"单个节点",不是"单个 Pod"。也就是说,同一个节点上的多个 Pod 可以同时挂载同一个 RWO 卷,因为它们共享同一个挂载点。这个语义在很多文档里被简化成"单 Pod 读写",导致有人以为 RWO 能防并发写,结果两个 Pod 落在同一节点上同时写同一个目录,数据直接写花。
RWOP 才是真正的"单 Pod 独占",这个模式在较新的版本里才稳定下来,它的价值场景是那些绝对不能并发访问的存储,比如某些数据库的数据文件目录。如果你用的是老版本集群,RWOP 可能不可用,就得靠调度约束(比如podAntiAffinity)来间接保证。
还有一个高频误解:访问模式是 PV 的"能力声明",不是"限制开关"。你声明了 RWX,不代表后端真的支持多节点并发写。比如 NFS 客户端确实支持多节点挂载,但如果多个 Pod 同时写同一个文件,仍然可能出现数据覆盖。真正做多副本共享写的时候,应用层的一致性还得自己保证,K8s 只是在挂载层面给你开了门。
提示:判断该用哪种访问模式,别看"我觉得",要看后端存储的实际能力。块存储类(云盘、本地盘、iSCSI)绝大多数只支持 RWO;文件存储类(NFS、CephFS、某些 NAS)才支持 RWX;对象存储根本不走这套访问模式,它有自己的接口。
2.3 后端选型:从 hostPath 到分布式存储
选存储后端这件事,本质上是在拿"隔离性、性能、可运维性、成本"四个维度做权衡。下面这个表是我整理的一个粗略对照,注意这里不点名具体厂商,只讲类型:
| 后端类型 | 典型场景 | 优点 | 主要限制 |
|---|---|---|---|
| hostPath / local PV | 单节点测试、本地缓存、节点级代理 | 零依赖、性能最高 | 与节点强绑定,Pod 必须调度到同一节点,节点故障数据丢失 |
| NFS | 中小规模共享存储、配置共享、开发测试 | 部署简单、天然支持 RWX | 单点风险、性能受网络和单机 NFS 服务限制 |
| 块存储(云盘/本地 SSD) | 数据库、消息队列等有状态服务 | 性能稳定、隔离性好 | 一般只能 RWO,跨可用区迁移受限 |
| 分布式文件系统 | 大规模共享读写、AI 训练数据 | 支持 RWX、容量弹性 | 运维复杂度高、对网络要求高 |
| 对象存储(走 CSI 挂载) | 备份、归档、大文件读取 | 容量近乎无限、成本低 | 不是块设备语义,小文件性能差、不适合数据库 |
我的一般建议是这样的:开发和测试环境直接上 NFS 或者本地目录凑合,生产上有状态服务优先用块存储,需要多副本共享读写的场景再考虑分布式文件系统。别一上来就追求"最先进"的方案,很多团队上分布式存储之后发现运维成本远超收益,最后还是退回块存储加应用层的分片。
hostPath 特别需要单独说一句。它在单节点实验里非常方便,但绝对不能在多节点生产集群里用来存业务数据。原因很直接:hostPath 挂的是节点本地目录,Pod 一旦被调度到另一个节点,看到的就是另一个节点的目录,数据"凭空消失"。而且 hostPath 没有任何隔离,容器里可以对宿主机目录做任何操作,安全风险极高。如果确实需要本地存储,用local类型的 PV 配合nodeAffinity,至少调度层面是受控的。
2.4 卷模式:Filesystem 与 Block 的区别
PV 还有一个容易被忽略的字段volumeMode,默认是Filesystem,另一个值是Block。
Filesystem模式最好理解:K8s 会在挂载前把设备格式化成文件系统(通常是 ext4 或 xfs),然后挂到容器的目录上,容器里看到的是普通目录。绝大多数应用用的都是这个。
Block模式则跳过格式化,直接把裸块设备交给容器,容器里看到的是一个类似/dev/sdb的设备文件。这个模式主要用于那些自己要管理文件系统的场景,比如某些数据库希望自己格式化裸盘,或者某些存储引擎需要直接操作块设备。用它的前提是容器里得有对应的工具,否则拿到裸设备也没法用。
这个字段在实际工作中用得不多,但一旦用错就非常麻烦:PVC 声明的volumeMode必须和 PV 一致才能绑定,不一致就会一直 Pending。我遇到过一次,某个同事从别处抄了个 PVC YAML,里面带着volumeMode: Block,但集群里的 PV 都是 Filesystem,排查了半天才发现是这个字段在作怪。
3. 静态供给实操:手写 PV 加 PVC 把 Pod 跑起来
3.1 先把后端存储准备好
动手写 YAML 之前,后端存储必须先能通。我以 NFS 为例走一遍完整流程,因为它是学习 PV 最合适的载体:配置简单、天然支持 RWX、报错信息也比较好懂。如果你手边没有 NFS,用一台机器装nfs-utils或者nfs-kernel-server就能搭起来。
服务端要做的事是三件:建目录、配导出、启服务。假设我们规划了三个目录/data/pv001、/data/pv002、/data/pv003,导出配置大概长这样:
# /etc/exports /data/pv001 192.168.10.0/24(rw,sync,no_subtree_check,no_root_squash) /data/pv002 192.168.10.0/24(rw,sync,no_subtree_check,no_root_squash) /data/pv003 192.168.10.0/24(rw,sync,no_subtree_check,no_root_squash)这里的no_root_squash是个关键参数,它的意思是"不要把客户端的 root 用户映射成匿名用户"。如果不加这个参数,容器里以 root 身份写文件时会被 NFS 服务端降权成nobody,表现出来的现象就是"Pod 起来了,但一写文件就 Permission denied"。这个坑我踩过不止一次,尤其是在用一些默认以 root 运行的镜像时。
导出配置改完执行exportfs -rav让配置生效,然后在任意一个集群节点上手动挂一下验证:
mkdir -p /mnt/test && mount -t nfs 192.168.10.10:/data/pv001 /mnt/test touch /mnt/test/probe && ls -l /mnt/test能写进去就说明链路通了。这一步千万别跳过,因为 PV 绑定成功后如果后端不通,报错会出现在 kubelet 的挂载日志里,排查起来绕远路。先手工验证,把问题挡在 YAML 之前。
3.2 写 PV:逐字段解释为什么这么填
后端通了,开始写 PV。下面这个 YAML 是我平时用的模板,字段注释写得比较细:
apiVersion: v1 kind: PersistentVolume metadata: name: pv-nfs-001 spec: capacity: storage: 10Gi # 声明容量,用于和 PVC 匹配 accessModes: - ReadWriteMany # NFS 支持多节点读写 persistentVolumeReclaimPolicy: Retain # 先保留,确认数据无用再手工清理 storageClassName: manual-nfs # 静态供给也要写,用于和 PVC 匹配 mountOptions: - hard - nfsvers=4.1 - noatime nfs: server: 192.168.10.10 path: /data/pv001几个字段的选择理由说一下。persistentVolumeReclaimPolicy我填的是Retain,不是Delete。原因很实际:NFS 后端没有真正的"删除卷"语义,Delete策略在这种后端上往往表现为"什么也不做",与其留个误导性的配置,不如明明白白写Retain,让运维流程明确知道要人工清理。如果是云盘这种支持真正删除的后端,那Delete就很合适。
mountOptions里的hard表示 NFS 挂载遇到服务端无响应时会一直重试而不是报错返回,这个对数据一致性更友好;noatime关掉访问时间更新,能减少不必要的写操作,在只读为主的场景下提升明显。nfsvers=4.1是显式指定协议版本,避免客户端和服务端协商出意料之外的版本——这个在混合环境的集群里尤其重要。
storageClassName这个字段在静态供给时也必须写,而且要和 PVC 里的值完全一致才能绑定。很多人以为只有动态供给才需要 StorageClass,其实静态 PV 一样要参与这个匹配。如果 PV 上不写storageClassName,PVC 上也不写,那它们可以匹配;但只要一边写一边不写,就绑不上。
3.3 写 PVC:容量匹配的真实规则
PV 准备好了,接下来是业务方视角的 PVC:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-app-data namespace: demo spec: accessModes: - ReadWriteMany storageClassName: manual-nfs resources: requests: storage: 8Gi这里有个很重要的规则:PV 的容量必须大于等于 PVC 请求的容量,绑定后 PVC 实际独占整个 PV。上面这个 PVC 请求 8Gi,匹配到的是 10Gi 的 PV,绑定之后这 10Gi 就归它了,剩下的 2Gi 不会分给别人。所以静态供给的容量规划要粗放一点,尽量让 PV 容量和 PVC 请求接近,否则浪费会很明显。
匹配逻辑不止看容量。控制器的筛选顺序大致是这样:先按storageClassName过滤,再按accessModes过滤(PV 必须包含 PVC 请求的所有模式),再按容量过滤(PV 容量 >= PVC 请求),最后如果有selector或volumeName就进一步收窄。全部条件满足的 PV 里,控制器一般挑容量最小的那个,避免大材小用。
还有一个进阶玩法叫"预绑定":在 PV 里写claimRef指定命名空间和 PVC 名字,这个 PV 就只对这个 PVC 可见。这在多团队共享集群、需要精确控制存储分配时很有用,但日常用得不多,知道有这回事就行。
3.4 把 Pod 挂上去并验证
PVC 绑定成功后,Pod 里引用就很简单了:
apiVersion: v1 kind: Pod metadata: name: demo-app namespace: demo spec: containers: - name: app image: busybox:1.36 command: ["sh", "-c", "while true; do date >> /data/heartbeat.log; sleep 30; done"] volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: pvc-app-data部署之后按这个顺序验证,一步都别省:
kubectl -n demo get pvc pvc-app-data # 状态必须是 Bound kubectl get pv pv-nfs-001 # 状态 Bound,CLAIM 列指向 demo/pvc-app-data kubectl -n demo exec demo-app -- cat /data/heartbeat.log kubectl -n demo delete pod demo-app kubectl -n demo apply -f pod.yaml kubectl -n demo exec demo-app -- cat /data/heartbeat.log # 数据应该还在,且继续追加最后那个"删 Pod 再重建,数据还在"的验证是整个实验的核心目的。如果你直接把 Pod 删了重建,文件内容还是连续的,说明 PV 这条链路真正打通了。我建议每个刚接触 PV 的人都亲手做一遍这个实验,比看十篇文档都管用。
4. 动态供给:StorageClass 与 CSI 的工作链路
4.1 StorageClass 到底省掉了什么
静态供给的问题在于不 scale。业务方要一块 50Gi 的盘,管理员得先手写一个 PV;下个月要 200Gi,又得写一个。集群里几十个 PV 手工维护,标签、容量、路径全靠人记,出错概率极高。
StorageClass 的思路是"把造 PV 的模板固定下来,按需自动生成"。业务方只管提 PVC,控制器发现没有现成 PV 匹配,就调用 StorageClass 指定的 provisioner 去后端实时创建一块卷,再自动生成对应的 PV 对象,直接绑给 PVC。整个过程业务方无感知。
一个典型的 StorageClass 长这样:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast-ssd provisioner: csi.example.com # 具体值取决于你集群里装的 CSI 驱动 parameters: type: ssd zone: cn-hangzhou-a reclaimPolicy: Delete volumeBindingMode: WaitForFirstConsumer allowVolumeExpansion: true这四个字段各有讲究。provisioner必须和集群里实际部署的 CSI 驱动对应,写错了 PVC 就永远 Pending,且事件里只有一句很含糊的 "no provisioner"。parameters是透传给驱动的,值取决于驱动实现,得查对应驱动的文档。reclaimPolicy在动态场景下通常写Delete,因为卷是自动创建的,删除时自动回收才符合预期。volumeBindingMode和allowVolumeExpansion下面单独说。
4.2 WaitForFirstConsumer 解决的拓扑问题
volumeBindingMode有两个值:Immediate和WaitForFirstConsumer。
Immediate的意思是 PVC 一创建就立刻去后端创建卷并绑定。听起来很合理,但在多可用区集群里会出大问题:PVC 创建时 Pod 还没调度,系统不知道 Pod 会落到哪个可用区,就先在某个区创建了卷。等 Pod 调度时,如果因为资源原因被调度到另一个区,就会发现卷在别的区,挂不上,Pod 一直起不来。
WaitForFirstConsumer把创建时机推迟到"有 Pod 使用这个 PVC 并且调度器已经决定了 Pod 的节点"之后。调度器会把这个卷的拓扑约束纳入调度决策,确保 Pod 落到卷所在的可用区。这是多云可用区集群里必须开启的配置,没有之一。
我见过一个集群因为这个字段没设,跨区扩容节点之后大批有状态 Pod 起不来,报错是node(s) had volume node affinity conflict。这个报错信息其实挺明确,但如果不知道volumeBindingMode这回事,很难联想到根因。
4.3 扩容和快照的实践要点
allowVolumeExpansion: true打开之后,PVC 就可以通过修改spec.resources.requests.storage来扩容。操作很简单:
kubectl -n demo patch pvc pvc-app-data -p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'但有三个前提必须清楚。第一,底层存储驱动得支持在线扩容,有些驱动只支持离线扩。第二,扩容只能增不能减,K8s 不支持缩容,想缩只能新建 PVC 再迁数据。第三,文件系统层面也需要扩展,好在主流 CSI 驱动会通过resize2fs之类的工具自动完成,但如果你用的是Filesystem模式且文件系统是老式的,可能需要手工介入。扩容后kubectl get pvc的 CAPACITY 列可能不会立刻更新,等一会儿或者看一眼 PV 的容量字段更准。
快照是另一个常用能力,对应VolumeSnapshot和VolumeSnapshotClass这两个对象。它的价值在于做数据备份和快速克隆——先给现有 PVC 拍个快照,再用这个快照创建新 PVC,就能得到一份完整的数据副本。做数据库迁移、版本回滚、测试环境造数据的时候非常方便。前提同样是 CSI 驱动支持,并且集群里装了 snapshot-controller。
5. 生命周期状态与运维操作
5.1 状态机:从 Pending 到 Released 的完整路径
PV 和 PVC 都有状态字段,读懂这两个状态基本就能定位一半的问题。
PVC 的状态比较简单:Pending表示还没绑定到 PV,Bound表示已绑定,Lost表示原本绑定的 PV 消失了,Terminating表示正在删除但还没删掉。
PV 的状态更丰富:
| 状态 | 含义 | 常见成因 |
|---|---|---|
| Available | 空闲,可被绑定 | 刚创建完成 |
| Bound | 已绑定到某个 PVC | 正常使用中 |
| Released | PVC 已删除,但 PV 未回收 | Retain策略下的正常状态 |
| Failed | 自动回收失败 | 后端存储异常、脚本执行失败 |
| Pending | 等待创建或初始化 | 动态供给中,驱动还在创建卷 |
Released这个状态最容易被误认为故障。其实它在Retain策略下是完全正常的:PVC 删了,PV 上的数据还在,卷不能被新 PVC 直接复用,必须先手工处理。处理方式是把 PV 上的claimRef清掉,让它变回Available:
kubectl patch pv pv-nfs-001 --type json \ -p '[{"op":"remove","path":"/spec/claimRef"}]'执行完kubectl get pv就能看到状态回到Available。但一定要记住,这只是在 K8s 层面解绑,后端数据还在,如果确认数据可以丢弃,还得去 NFS 目录里手工清理,否则下一个用这块卷的 PVC 会看到一堆旧文件,很可能导致应用启动异常。
5.2 回收策略的行为差异与数据安全
三种回收策略里,Recycle已经被废弃,实际只需要在Retain和Delete之间做选择。
Retain是最安全的:PVC 删除后 PV 保留,后端数据保留,状态变Released,需要人工介入才能复用。适合生产环境的有状态服务,尤其是数据库这类"删错了就完了"的场景。
Delete是最省心的:PVC 删除后 PV 对象被清理,后端卷也一并删除。适合动态供给的临时环境、测试环境、无状态服务的数据盘。但风险也很明显——误删 PVC 等于误删数据,且没有回收站。
我的习惯是:生产环境的 StorageClass 一律Retain,测试环境一律Delete。这个区分最好在集群初始化时就定好,别等到出了事再改。另外可以配合ResourceQuota限制单个命名空间能申请的总存储量,再加一层保险,防止某个业务方随手申请个几百 Gi 把配额吃光。
5.3 权限与 subPath:几个容易翻车的细节
挂载成功不等于应用能用。权限问题在 PV 场景里的出现频率远超你的想象,最典型的表现是 Pod 起来了,日志里一堆Permission denied。
第一个要关注的字段是securityContext.fsGroup。它的作用是让 kubelet 在挂载卷之后,把挂载点的属组改成指定的 GID,并给目录设置 setgid 位,这样目录下新建的文件也会继承这个组。对于"容器以非 root 用户运行"的场景,这个字段几乎是必须的。但如果后端是 NFS 且配了root_squash,kubelet 以 root 身份去改权限会被降权,改不动,这时候就得在 NFS 服务端调整配置或者预先设好目录权限。
第二个坑是subPath。用subPath可以把卷里的某个子目录挂到容器内,避免多个容器共享同一个卷时互相干扰:
volumeMounts: - name: data mountPath: /app/logs subPath: logs但subPath有个众所周知的行为:挂载 ConfigMap、Secret 时用subPath,内容不会随原对象更新而热更新。这个规则同样适用于 PV,只是 PV 的内容本来就是应用自己写的,影响相对小。真正需要注意的是subPath指向的目录必须事先存在,否则挂载会失败。动态供给时目录是空的,需要在 initContainer 里先mkdir -p出来。
第三个是mountPropagation。默认情况下,容器内的挂载不会传播到宿主机,宿主机的挂载也不会传播到容器。某些场景(比如容器内要跑自己的挂载操作,或者要监听宿主机上的挂载变化)需要显式设置Bidirectional,但这会带来安全风险,非必要不开。
提示:如果确认是权限问题但一时找不到原因,最快的排查方式是把 Pod 临时改成
sleep infinity,进容器里ls -ln /挂载路径看属主属组,再对比容器内运行进程的 uid/gid。数字对不上就是权限问题,不用再猜。
6. 常见问题排查实录
6.1 PVC 一直 Pending 的排查路径
这是最高频的问题,排查可以按这个顺序走。
先看kubectl describe pvc <name> -n <ns>的 Events。如果是静态供给,常见报错是no persistent volumes available for this claim and no storage class is set——意思是既没有可匹配的 PV,也没指定 StorageClass。这时候回头检查 PV 的storageClassName、accessModes、容量是不是都满足。
如果是动态供给,报错往往是waiting for a volume to be created或者failed to provision volume。这类问题多半出在 provisioner 侧:CSI 驱动的 Pod 没起来、RBAC 权限不足、parameters里的值后端不认、配额超限。按这个顺序查kubectl -n kube-system get pod | grep csi、驱动日志、StorageClass 参数拼写。
还有一种情况是 PVC 指定了不存在的 StorageClass,报错是storageclass.storage.k8s.io "xxx" not found,这种最好排查。但反过来,PVC 指定了storageClassName: ""(空字符串),语义是"我只用没有 StorageClass 的静态 PV",这个写法很多人不知道,看到空值以为是没写,其实是不一样的。
6.2 Pod 挂载失败:从事件到 kubelet 日志
Pod 卡在ContainerCreating,describe pod里能看到FailedMount事件。这个事件信息通常比较含糊,比如mount failed: exit status 32,看不出根因。
我的排查顺序是这样的:先确认 PV 状态和 PVC 状态都正常;再确认后端存储从目标节点上能访问(注意是目标节点,不是你现在登录的跳板机);然后看 kubelet 日志里更详细的挂载命令输出。
NFS 场景最常见的三个原因:服务端防火墙没放行端口、/etc/exports里的网段写错、客户端没装nfs-utils。前两个可以用showmount -e <server>快速验证,第三个看节点上有没有mount.nfs这个命令。本地盘场景最常见的是nodeAffinity不匹配,Pod 被调度到了没有这块盘的节点上,报错是node(s) had volume node affinity conflict。
云盘场景还有一个高频问题:卷和节点不在同一个可用区。这个在WaitForFirstConsumer模式下基本不会发生,但在Immediate模式下非常常见,尤其是有状态服务扩容的时候。
6.3 Released 卷复用与数据残留
前面提过Retain策略下 PVC 删除后 PV 变Released,需要手工清claimRef。这里补充一个实操细节:清完claimRef之后,PV 状态会短暂显示Available,但如果此时有另一个 PVC 恰好匹配上,就会立刻绑走。如果这块盘上有旧数据且你不想让它被复用,清claimRef之前务必先把后端数据清干净,或者干脆给 PV 打个标记,用selector控制匹配范围。
还有一个隐蔽问题:清掉claimRef后重新绑定,新的 Pod 可能因为目录里残留的旧文件启动失败,比如某些应用的初始化脚本会检查"目录必须是空的"。这类问题不会在 K8s 层面报错,只会在应用日志里体现,排查时容易走偏。
6.4 常见问题速查表
| 现象 | 大概率原因 | 处理动作 |
|---|---|---|
| PVC 一直 Pending,无 StorageClass | 静态 PV 不满足容量或访问模式 | describe pvc看事件,核对 PV 字段 |
| PVC Pending,有 StorageClass | provisioner 不可用或参数错误 | 检查 CSI 驱动 Pod 状态与日志 |
| Pod 卡 ContainerCreating | 挂载失败 | 看FailedMount事件,到目标节点手工挂载验证 |
| 容器内写文件 Permission denied | fsGroup 未设或 NFS root_squash | ls -ln看属主,调整 securityContext 或导出参数 |
| PV 显示 Released 无法复用 | Retain 策略未清理 | 清理后端数据后移除claimRef |
| 扩容后容量没变 | 驱动不支持或文件系统未扩展 | 查驱动文档,看 PV 容量与实际卷大小 |
| Pod 报 node affinity conflict | 本地盘 PV 与节点不匹配 | 检查 PV 的nodeAffinity与节点标签 |
7. 我在这块踩过的几个真实坑
说几个具体的。第一个是刚上手时用 hostPath 存业务日志,本地测试一切正常,上了三节点集群之后发现日志分散在不同节点上,查问题时得挨个节点翻。后来换成 NFS 加 RWX 才解决,但那时候已经积累了半个月的日志需要手工合并。
第二个是关于mountOptions的。有一次线上 NFS 服务端短暂重启,客户端 Pod 全部卡死,进程处于不可中断状态,kill -9都没用。根因就是默认的挂载参数是hard加无限重试,服务端不响应时客户端会一直等下去。后来我们调整了timeo和retrans参数,让超时可控,服务端重启时客户端能快速失败并重连,而不是整体挂死。这个参数怎么调没有标准答案,得结合自己的网络质量和业务容忍度做实验。
第三个是容量规划。早期我们都是按"大概够用"来申请,结果一年后发现一堆 PV 的利用率不到 10%。后来加上了监控采集 PV 的实际使用量,定期做一次盘点,把低于阈值的卷合并或者迁移。存储这东西,申请的时候手一松,后面就是长期的浪费。
还有一个是我个人觉得最值得强调的:不要在 PV 的容量上做过度承诺。手工写 PV 时,把 NFS 目录的容量写成 500Gi,但实际那块盘只有 200Gi,K8s 不会校验,PVC 会顺利绑上,应用会正常工作,直到某一天写满。那一刻的故障往往非常难查,因为 K8s 层面一切正常,只有应用报磁盘满。所以 PV 的capacity应该反映真实可用空间,宁小勿大,扩容比缩容容易得多。
最后分享一个排查习惯:遇到存储相关的疑难问题,我第一件事是把范围缩小到"K8s 层面"还是"存储层面"。具体做法是绕过 K8s,直接在目标节点上手工执行 mount 命令。如果手工能挂上、能读写,问题就在 K8s 配置(PV/PVC/调度/权限);如果手工也挂不上,问题就在存储后端本身,跟 K8s 一点关系都没有。这个二分法能省掉大量无效排查,是我这些年最常用的一招。