ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

K8s持久化存储实战:PV/PVC、StorageClass与Pending排查

K8s持久化存储实战:PV/PVC、StorageClass与Pending排查

Provisioning 失败、Pod 卡在 ContainerCreating、PVC 一直 Pending——这类工单我在值班周期里见过太多次,八成以上最后都落到同一个对象上:Persistent Volume。Kubernetes 的基础篇写到第十八节,绕不开这块内容,因为它不像 Deployment 那样"改个镜像就重启",存储对象的坑往往是数据级的,一次误删可能就是几小时的恢复窗口。这篇就按我自己带人时的讲法来:先把 PV 这套抽象为什么存在讲透,再把 YAML 字段一个个拆开,然后给两套能直接抄的落地方案(静态 NFS 与动态 StorageClass),最后把排查链路和几个真实踩过的坑摊开。适合已经能跑通 Pod、Deployment,但在持久化这一步总感觉"配了能跑、出问题不会查"的同学,也适合正准备把无状态服务往有状态迁移、需要给团队定一套存储规范的资深工程师。

1. 从 emptyDir 到 PV:这套抽象到底在解什么题

1.1 内联存储声明的三处硬伤

刚接触 K8s 的人通常会先学 emptyDir 和 hostPath,因为它们写起来最省事:在 Pod 的 spec.volumes 里加一段,容器里挂个 mountPath,完事。这个方式在单机 Docker 时代是够用的,但放到集群里马上就暴露问题,而且不是小问题,是结构性的。

第一处硬伤是存储配置和业务描述耦合在一起。一个 Deployment 的 YAML 里如果塞了 NFS 服务端地址、路径、挂载参数,那这份 YAML 就绑死在这套存储上了。哪天存储要迁移,你得把所有引用它的工作负载挨个改一遍。更麻烦的是开发同学往往不知道存储侧的参数该怎么填,只能去问运维,一来一回就是半天。

第二处硬伤是生命周期不对等。emptyDir 跟着 Pod 走,Pod 删了数据没了;hostPath 跟着节点走,Pod 漂到别的节点就看不到原来的数据。而真实业务里的数据生命周期往往比 Pod 长得多——数据库的数据文件、消息队列的持久化日志、训练任务的 checkpoint,这些东西必须活得比 Pod 久,甚至比整个集群的某次重建还久。

第三处硬伤是没有配额和准入控制。你没法在 Pod 层面声明"我最多用 20G",也没法让集群管理员统一规定"开发环境最多申请 50G,超过要走审批"。因为内联声明里根本没有容量这个概念,节点磁盘写满了才会发现问题,那时候已经是事故了。

提示:hostPath 在现在的版本里依然存在,但它适合的场景非常窄——单节点测试、需要访问节点上特定设备文件(如 /dev/ 下的某些设备)的守护进程。用它存业务数据,等于把 K8s 好不容易做出来的调度自由度又还回去了。

1.2 PV/PVC 的分层设计:把"谁提供"和"谁使用"解耦

PV 这套机制的核心思路其实特别朴素:把"存储资源"变成集群里的一等公民。它引入了两个对象来分别代表供需双方。

**PersistentVolume(PV)**是集群级资源,由存储管理员或者自动化组件创建,描述的是"我这里有一块 100G 的 NFS 目录、读写模式是 RWX、回收策略是 Retain"。它不知道谁会来用它,也不需要知道。

**PersistentVolumeClaim(PVC)**是命名空间级资源,由业务方创建,描述的是"我需要一块至少 50G 的存储、要能读写"。它不知道底层是 NFS 还是 Ceph,也不关心。

中间由 kube-controller-manager 里的 PersistentVolumeController 负责撮合,撮合成功就是"绑定"(Bound)。这个设计的好处用一句话概括:业务方只需要说需求,存储方只需要说供给,两边不用互相认识。这就是为什么很多人把它类比成"存储界的 Pod 与 Node 关系"——Pod 说我要 2 核 4G,不用管调度到哪台机器;PVC 说我要 50G 可读写,不用管底层是什么存储。

这个解耦带来的直接收益是三个层面。对业务方,迁移存储类型时只需要关注 PVC 的字段是否满足,不用改工作负载描述。对平台方,可以引入 StorageClass 做动态供给,把"申请存储"这件事从人工审批变成自助服务。对治理层,ResourceQuota 可以按命名空间限制 PVC 的总申请量,把成本控制前置到准入阶段。

PV 和 PVC 之间是一对一绑定关系,一旦绑上就独占。这点跟很多人直觉不太一样——有人以为一个 PV 可以被多个 PVC 共享,实际上不行。共享同一个存储目录的正确做法是让多个 Pod 挂载同一个 PVC。

2. PV 对象的字段骨架:每个字段背后的取舍逻辑

2.1 capacity 与 accessModes:最容易被误读的两个字段

先看一份最小的 PV 定义,然后逐字段拆。

apiVersion: v1 kind: PersistentVolume metadata: name: pv-nfs-data-01 spec: capacity: storage: 100Gi accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain storageClassName: nfs-slow nfs: server: 10.0.0.21 path: /export/data01

capacity.storage看起来最直白,但有个高频坑:单位。100Gi和100G不是一回事,Gi 是二进制,1Gi = 1024Mi = 1073741824 字节;G 是十进制,1G = 1000000000 字节。差了约 7.4%。如果你的底层存储是按十进制算的 100G,PV 里写成 100Gi,那实际可用空间是 100Gi = 107.4G 字节,超出底层配额,写入到后面就报 No space left on device。反过来写成 100G,又浪费了 7 个多 G。我的习惯是跟底层存储的计量单位保持严格一致,并且在 PV 的 annotation 里写清楚底层实际容量,方便后面排查。

accessModes是误解重灾区。四个取值:

模式缩写含义常见误读
ReadWriteOnceRWO只能被单个节点以读写方式挂载误以为是"单个 Pod",其实同一节点上多个 Pod 可以同时挂
ReadOnlyManyROX可被多个节点以只读方式挂载误以为所有存储都支持
ReadWriteManyRWX可被多个节点以读写方式挂载误以为 NFS 一定支持,实际受服务端 export 配置约束
ReadWriteOncePodRWOP只能被单个 Pod以读写方式挂载1.29 才 GA,老集群可能不认

RWO 那个误读值得展开说一下,因为它导致过真实事故。RWO 的约束粒度是节点,不是 Pod。假设你有一个 RWO 的 PVC,挂了两个副本的 Deployment,调度器把它们放在同一个节点上,两个 Pod 都能正常挂载,看起来一切正常。等某次节点故障,Pod 被重新调度到不同节点,第二个 Pod 就会卡在 ContainerCreating,事件里报Multi-Attach error for volume。这个错误的迷惑性在于——它只在跨节点时出现,测试环境单节点跑得好好的,一上生产就炸。

RWX 也一样,不是写了就一定生效。NFS 服务端的/etc/exports里如果配了ro(只读),你在 PV 里写 RWX,挂载能成功,但写入直接报 Read-only file system。这类问题 PV 层面完全看不出来,只能到存储侧去核对。

2.2 回收策略与 volumeMode:数据保命的两个开关

persistentVolumeReclaimPolicy决定 PVC 删掉之后 PV 怎么处理,三个取值:

  • Retain:保留 PV 和底层数据,PV 状态变成Released,但不会被新的 PVC 自动绑定,需要人工介入清理。生产环境里但凡涉及业务数据,我无一例外选这个。
  • Delete:自动删除 PV 对象和底层存储卷。动态供给出来的 PV 默认就是这个,适合临时数据、CI 缓存、可重建的计算中间产物。
  • Recycle:已经被标记废弃,新版本里不推荐使用,实际行为是rm -rf卷内的内容再重新变 Available。风险太高,不要碰。

这里有个非常经典的坑要提前说:选Retain之后,删了 PVC,PV 会变成Released而不是Available。很多人以为"数据还在,重建个 PVC 就能再绑上",结果新 PVC 一直 Pending。原因是 Released 状态的 PV 上还留着旧的spec.claimRef,指向已经删除的 PVC,绑定器不会把它分配给新 PVC。正确处理是先删掉 claimRef 字段,或者直接把 PV 对象删掉重建(底层数据不受影响),才能重新进入 Available。

volumeMode只有两个值:Filesystem(默认)和Block。前者挂载成目录,后者是把裸块设备直接给容器用。除了跑数据库自建文件系统、或者用某些需要裸设备的中间件,绝大多数场景用默认的 Filesystem 就行。需要注意的是PVC 和 PV 的 volumeMode 必须一致,不一致就绑不上,而且报错信息不太直观。

2.3 storageClassName、claimRef、nodeAffinity:三条隐性约束

storageClassName是绑定匹配的头号条件。规则很死板:PV 和 PVC 的storageClassName字段必须完全相同,空字符串和空字符串匹配,非空和非空要字面相等。这里有个历史遗留的坑——早期用 annotationvolume.beta.kubernetes.io/storage-class来标注,现在虽然还兼容,但和 spec 字段同时存在时行为容易出岔子,统一用 spec 字段,别混着来。

claimRef是给 PV 预绑定用的。如果你想让某块 PV 只给某个特定 PVC 用(比如财务系统的数据盘不能给别的业务占了),可以在 PV 里写:

spec: claimRef: namespace: finance name: pvc-ledger-data

这样其他 PVC 就抢不走了。反过来,如果 PV 是被动态供给创建的,这个字段会自动填上创建者的信息。

nodeAffinity只在 local PV、hostPath 这类节点绑定的存储上才有意义。因为数据物理上就在某台机器上,Pod 必须调度到那台机器才能挂。这个字段和 StorageClass 的volumeBindingMode: WaitForFirstConsumer是配套的,后面会细说。

3. 静态供给实战:手写 PV 并让 PVC 成功绑定

3.1 存储侧的准备工作必须走在前面

静态供给的意思是管理员提前把 PV 建好,业务方写 PVC 去匹配。很多人在这个环节翻车,根本原因是先写 YAML 后配存储,顺序反了。正确的顺序是存储侧先就位。

以 NFS 为例,服务端需要做三件事:

  1. 建目录并给权限:mkdir -p /export/data01 && chown -R 1000:1000 /export/data01。这里的 UID 要和容器里跑业务的用户对齐,否则挂进去就是 Permission denied。
  2. 配置 exports:/export/data01 10.0.0.0/24(rw,sync,no_subtree_check,no_root_squash)。no_root_squash这个选项要慎用,它让客户端的 root 保留 root 权限,安全上不推荐;但如果不加,容器里以 root 运行又需要写权限的镜像就会失败。折中方案是镜像里用非 root 用户跑,把目录 owner 设成对应的 UID。
  3. 重载配置并验证:exportfs -rav,然后在某个节点上手动mount -t nfs 10.0.0.21:/export/data01 /mnt && touch /mnt/test验证读写。这一步千万别跳,PV 建好了但底层挂不上,Pod 的事件信息会很含糊,排查时间翻倍。

3.2 PV 与 PVC 的落地与验证动作

PV 用上一节那份 YAML,PVC 这样写:

apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-app-data namespace: demo spec: accessModes: - ReadWriteMany storageClassName: nfs-slow resources: requests: storage: 50Gi

注意 PVC 里只有requests,没有limits的概念——PVC 申请多少就是多少,不支持"上限"。这也是为什么配额要靠 ResourceQuota 来管。

应用之后按这个顺序验证:

kubectl get pv pv-nfs-data-01 # 期望 STATUS 从 Available 变成 Bound,CLAIM 列显示 demo/pvc-app-data kubectl get pvc -n demo # 期望 STATUS 是 Bound,VOLUME 列显示 pv-nfs-data-01,CAPACITY 显示 100Gi

有个细节容易让人困惑:PVC 申请 50Gi,绑定到 100Gi 的 PV 之后,PVC 的 CAPACITY 显示的是100Gi,不是 50Gi。因为绑定是整个卷的绑定,PV 剩余的空间不会退回去。所以静态供给时 PV 的容量规划要尽量贴合实际需求,建一堆 500Gi 的 PV 结果业务只要 10Gi,浪费很严重。

3.3 local PV:为什么必须配 nodeAffinity

local PV 是裸机环境里性能最好的选择,直接吃本地 SSD,没有网络存储的延迟。但它有个硬约束:数据在哪台机器上,Pod 就必须调度到哪台机器。

apiVersion: v1 kind: PersistentVolume metadata: name: pv-local-ssd-node01 spec: capacity: storage: 200Gi accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain storageClassName: local-ssd local: path: /data/ssd/vol01 nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: - node01

配套的 StorageClass 必须写volumeBindingMode: WaitForFirstConsumer:

apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: local-ssd provisioner: kubernetes.io/no-provisioner volumeBindingMode: WaitForFirstConsumer

为什么必须这么配?如果用的是默认的Immediate,PVC 一创建就立刻绑定 PV,绑定完调度器才知道这个 Pod 需要 node01。于是可能出现 Pod 被调度到 node02、挂载失败、反复重试的情况。WaitForFirstConsumer的作用是把绑定时机推迟到 Pod 调度阶段,让调度器同时考虑"节点资源够不够"和"这块 PV 在哪台机器上",一次算清楚。

还有一个前提容易被忽略:/data/ssd/vol01这个目录必须在 node01 上事先创建好。local PV 的 provisioner 是no-provisioner,它不会帮你建目录。目录不存在,Pod 会卡在 ContainerCreating,kubelet 日志里能看到 mount 失败。

4. 动态供给:StorageClass 才是生产主线

4.1 StorageClass 干了什么,默认类怎么设

静态供给在规模小的时候够用,一旦 PVC 数量上到几十个,手工建 PV 就是纯体力活了,而且容易出错。动态供给的思路是:业务方只创建 PVC,StorageClass 里配置的 provisioner 自动创建对应的 PV 和底层存储卷。

一份典型的动态供给 StorageClass:

apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: standard-rwo annotations: storageclass.kubernetes.io/is-default-class: "true" provisioner: csi.example-storage.io parameters: type: ssd zone: cn-north-1a reclaimPolicy: Delete allowVolumeExpansion: true volumeBindingMode: WaitForFirstConsumer

几个字段的实际影响:

  • provisioner是存储插件注册的名字,kubernetes.io/no-provisioner表示不自动创建(用于 local PV、hostPath 这类手工准备的场景),其他值都是具体 CSI 驱动的标识。
  • reclaimPolicy在这里设的是默认值,动态创建的 PV 会继承。如果业务需要保留数据,可以在 PVC 上通过 annotation 覆盖,或者干脆让平台方按命名空间提供不同的 StorageClass。
  • allowVolumeExpansion打开之后才允许对 PVC 扩容,不开的话改 PVC 的 storage 字段会被 API Server 直接拒绝。
  • volumeBindingMode前面说过了,拓扑受限的存储(local、部分云盘)必须用WaitForFirstConsumer。

默认 StorageClass 靠那个 annotation 标记。整个集群建议只设一个默认类,设多个的话,没写 storageClassName 的 PVC 行为不确定,可能绑到你不期望的存储上。这个坑我见过一次——测试环境两个类都标了 default,结果 CI 创建的 PVC 随机落到高性能盘上,账单出来才发现。

4.2 StatefulSet 的 volumeClaimTemplates:一次定义批量生成

有状态服务的典型模式是每个副本一份独立存储,这时候用volumeClaimTemplates:

apiVersion: apps/v1 kind: StatefulSet metadata: name: app-db spec: serviceName: app-db-headless replicas: 3 selector: matchLabels: app: app-db template: metadata: labels: app: app-db spec: containers: - name: db image: example/db:1.0 volumeMounts: - name: data mountPath: /var/lib/db volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: standard-rwo resources: requests: storage: 100Gi

三个副本会生成三个 PVC:># 直接 patch PVC 的 requests kubectl patch pvc pvc-app-data -n demo -p '{"spec":{"resources":{"requests":{"storage":"200Gi"}}}}'

但这里有个关键细节:PV 的 capacity 会跟着变,底层存储卷也会扩,但容器里的文件系统不一定立刻扩。Kubernetes 的处理方式是让 kubelet 在下次挂载时执行文件系统级的 resize(ext4 用 resize2fs,xfs 用 xfs_growfs)。如果 Pod 正在运行且卷已经挂着,很多 CSI 驱动需要重启 Pod才能完成这一步。不重启的话,df -h看到的还是旧容量,但 PVC 状态已经显示新容量了——这个不一致最容易让人误判成扩容失败。

另外,缩容是不支持的。PVC 的 storage 只能往上调,调小了直接被 API Server 拒绝。所以容量规划的原则是"宁可分多次小步扩,也别一开始就规划得过大",因为扩容易、缩做不了。

6.3 权限问题:fsGroup 与目录属主

PV 挂进容器之后,写入报 Permission denied,这个问题的根源几乎都在 UID 上。三种解法:

  1. 存储侧把目录 owner 改成容器运行的 UID。适合 NFS、hostPath 这种你能控制服务端权限的场景。
  2. 在 Pod 的 securityContext 里设fsGroup,kubelet 会在挂载后递归修改卷的组权限。缺点是大目录递归改权限非常慢,几百万个文件的卷上能让 Pod 启动卡十几分钟。
  3. 容器内用 initContainer 先跑一遍 chown。控制粒度最细,但要考虑镜像里有没有对应工具。

我的默认选择是方案 1,因为它在存储侧一次配好,不用每次启动都折腾。方案 2 只在文件数量少、且没有存储侧管理权限的时候用。

7. 几个真实踩过的坑与完整排查链路

7.1 删了 PVC 之后数据"消失",其实是被 Delete 策略清了

现象:某次清理测试环境,有人把命名空间整个删掉,重新部署之后发现配置数据全没了,但 PVC 和 PV 都重新创建成功,状态正常。

排查链路是这样的:先确认 PV 是不是新创建的——kubectl get pv看AGE列,如果 AGE 只有几分钟,说明是新建的。再回看 StorageClass 的reclaimPolicy,果然是Delete。命名空间删除时 PVC 被删,触发 PV 回收,底层卷跟着没了。整个过程完全符合设计,没有任何"诡异性",但对使用者来说就是数据凭空消失。

教训很直接:测试环境用 Delete 没问题,但只要这个命名空间里存放了任何难以重建的数据,就必须换成 Retain 的 StorageClass。我们的做法是按命名空间的用途分成两类类:*-ephemeral和*-durable,业务方自己选,选错了在代码评审阶段就拦下来。

7.2 Multi-Attach 报错的完整排查路径

现象:滚动更新时新 Pod 卡在 ContainerCreating,事件里报Multi-Attach error for volume "pvc-xxxx", Volume is already exclusively attached to one node and can't be attached to another。

排查顺序:

第一步,确认 PVC 的 accessModes。kubectl get pvc <name> -o yaml | grep -A3 accessModes,如果是 RWO,那这个报错是符合预期的,问题在使用方式上而不是配置上。

第二步,确认哪些 Pod 在用它。kubectl get pods -A -o json | jq筛出引用该 PVC 的 Pod,看它们分布在哪些节点。滚动更新时新旧 Pod 会短暂共存,如果新旧副本被调度到不同节点,RWO 卷没法同时挂两边,新 Pod 就卡住了。

第三步,确认滚动更新的策略。把maxSurge设成 0、maxUnavailable设成 1,先删旧 Pod 再起新 Pod,避免共存。代价是有短暂的服务中断,但在 RWO 卷约束下这是必要取舍。

第四步,如果是 StatefulSet,情况会好一些,因为它的默认更新策略是逆序逐个替换,同一时刻只有一个副本在动。但手动删 Pod 时就可能触发,操作前要确认清楚。

这个坑的隐蔽性在于——它只在多节点集群 + 滚动更新时出现,单节点测试环境永远复现不了。

7.3 挂载超时:NFS 服务端不可达的排查顺序

Pod 卡在 ContainerCreating,事件里Unable to attach or mount volumes: timed out waiting for the condition。这类问题的排查要从下往上走:

# 1. 看 Pod 在哪台节点 kubectl get pod <name> -n <ns> -o wide # 2. 到该节点上查 kubelet 日志中相关片段 journalctl -u kubelet -n 200 | grep -i -E "mount|nfs|volume" # 3. 在节点上手动挂载验证 mount -t nfs -o vers=4.1 10.0.0.21:/export/data01 /mnt/test

如果手动挂载也超时,那问题就在网络或存储服务端:安全组规则、NFS 端口(2049)是否放通、服务端 export 是否包含该节点的网段、服务端 max connections 是否打满。如果手动挂载成功但 Pod 还是卡住,问题在 kubelet 侧:常见的坑是 PV 里写的 NFS 版本和客户端内核支持的不一致,比如写了vers=3但存储侧只开了 v4,或者反过来。

我们后来在集群里加了一条巡检规则:每个节点定时探测一次关键 NFS 端点,异常就告警。因为这类故障一旦发生,受影响的是所有新启动的 Pod,不只是某一个业务。

8. 裸机与混合环境的落地建议

8.1 一个可执行的存储分层方案

在裸机或自建环境里,我的建议是分三层,用不同的 StorageClass 承载:

层级底层介质StorageClass回收策略扩容适用场景
高性能本地 NVMelocal-ssdRetain否数据库、消息队列、需要低延迟的写密集业务
通用网络存储nfs-standardRetain是日志、附件、中等 IO 的业务数据
临时本地盘local-scratchDelete否CI 缓存、临时解压、可重建的中间产物

高性能层用 local PV 加 WaitForFirstConsumer,代价是扩不了容、换不了节点,所以只给那些明确知道自己数据量的服务用。通用层是绝大多数业务的选择。临时层鼓励用 emptyDir,只有明确需要跨 Pod 重启保留的才用 PVC。

8.2 上线前必须跑的验证清单

不管静态还是动态,我都要求在做完存储配置后跑一遍这份清单,五条都过了才允许业务接入:

  1. 读写验证:挂载后写入一个文件,删 Pod 重建,文件还在。
  2. 跨节点验证:RWO 卷在 Pod 漂移到另一节点后能正常挂载(先删旧 Pod 再起新的)。
  3. 回收验证:在预发环境删一次 PVC,确认 PV 状态符合预期(Retain 变 Released,Delete 消失)。
  4. 权限验证:容器内的非 root 用户能写入挂载目录。
  5. 容量验证:df -h看到的大小和 PVC 申请值一致,注意 Gi 与 G 的换算。

这五条看着简单,但我在不同团队里见过每一条都至少踩过一次。尤其是第 2 条和第 5 条,前者是"测试环境永远测不出来",后者是"单位算错了要等磁盘写满才发现"。

最后分享一个我自己的习惯:给每个 PV 都强制打三个 label——owner、env、>

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进