
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 这套机制的核心思路其实特别朴素把存储资源变成集群里的一等公民。它引入了两个对象来分别代表供需双方。**PersistentVolumePV**是集群级资源由存储管理员或者自动化组件创建描述的是我这里有一块 100G 的 NFS 目录、读写模式是 RWX、回收策略是 Retain。它不知道谁会来用它也不需要知道。**PersistentVolumeClaimPVC**是命名空间级资源由业务方创建描述的是我需要一块至少 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/data01capacity.storage看起来最直白但有个高频坑单位。100Gi和100G不是一回事Gi 是二进制1Gi 1024Mi 1073741824 字节G 是十进制1G 1000000000 字节。差了约 7.4%。如果你的底层存储是按十进制算的 100GPV 里写成 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之后删了 PVCPV 会变成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 为例服务端需要做三件事建目录并给权限mkdir -p /export/data01 chown -R 1000:1000 /export/data01。这里的 UID 要和容器里跑业务的用户对齐否则挂进去就是 Permission denied。配置 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。重载配置并验证exportfs -rav然后在某个节点上手动mount -t nfs 10.0.0.21:/export/data01 /mnt touch /mnt/test验证读写。这一步千万别跳PV 建好了但底层挂不上Pod 的事件信息会很含糊排查时间翻倍。3.2 PV 与 PVC 的落地与验证动作PV 用上一节那份 YAMLPVC 这样写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 变成 BoundCLAIM 列显示 demo/pvc-app-data kubectl get pvc -n demo # 期望 STATUS 是 BoundVOLUME 列显示 pv-nfs-data-01CAPACITY 显示 100Gi有个细节容易让人困惑PVC 申请 50Gi绑定到 100Gi 的 PV 之后PVC 的 CAPACITY 显示的是100Gi不是 50Gi。因为绑定是整个卷的绑定PV 剩余的空间不会退回去。所以静态供给时 PV 的容量规划要尽量贴合实际需求建一堆 500Gi 的 PV 结果业务只要 10Gi浪费很严重。3.3 local PV为什么必须配 nodeAffinitylocal 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: WaitForFirstConsumerapiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: local-ssd provisioner: kubernetes.io/no-provisioner volumeBindingMode: WaitForFirstConsumer为什么必须这么配如果用的是默认的ImmediatePVC 一创建就立刻绑定 PV绑定完调度器才知道这个 Pod 需要 node01。于是可能出现 Pod 被调度到 node02、挂载失败、反复重试的情况。WaitForFirstConsumer的作用是把绑定时机推迟到 Pod 调度阶段让调度器同时考虑节点资源够不够和这块 PV 在哪台机器上一次算清楚。还有一个前提容易被忽略/data/ssd/vol01这个目录必须在 node01 上事先创建好。local PV 的 provisioner 是no-provisioner它不会帮你建目录。目录不存在Pod 会卡在 ContainerCreatingkubelet 日志里能看到 mount 失败。4. 动态供给StorageClass 才是生产主线4.1 StorageClass 干了什么默认类怎么设静态供给在规模小的时候够用一旦 PVC 数量上到几十个手工建 PV 就是纯体力活了而且容易出错。动态供给的思路是业务方只创建 PVCStorageClass 里配置的 provisioner 自动创建对应的 PV 和底层存储卷。一份典型的动态供给 StorageClassapiVersion: 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一次定义批量生成有状态服务的典型模式是每个副本一份独立存储这时候用volumeClaimTemplatesapiVersion: 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 在下次挂载时执行文件系统级的 resizeext4 用 resize2fsxfs 用 xfs_growfs。如果 Pod 正在运行且卷已经挂着很多 CSI 驱动需要重启 Pod才能完成这一步。不重启的话df -h看到的还是旧容量但 PVC 状态已经显示新容量了——这个不一致最容易让人误判成扩容失败。另外缩容是不支持的。PVC 的 storage 只能往上调调小了直接被 API Server 拒绝。所以容量规划的原则是宁可分多次小步扩也别一开始就规划得过大因为扩容易、缩做不了。6.3 权限问题fsGroup 与目录属主PV 挂进容器之后写入报 Permission denied这个问题的根源几乎都在 UID 上。三种解法存储侧把目录 owner 改成容器运行的 UID。适合 NFS、hostPath 这种你能控制服务端权限的场景。在 Pod 的 securityContext 里设fsGroupkubelet 会在挂载后递归修改卷的组权限。缺点是大目录递归改权限非常慢几百万个文件的卷上能让 Pod 启动卡十几分钟。容器内用 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 cant 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 vers4.1 10.0.0.21:/export/data01 /mnt/test如果手动挂载也超时那问题就在网络或存储服务端安全组规则、NFS 端口2049是否放通、服务端 export 是否包含该节点的网段、服务端 max connections 是否打满。如果手动挂载成功但 Pod 还是卡住问题在 kubelet 侧常见的坑是 PV 里写的 NFS 版本和客户端内核支持的不一致比如写了vers3但存储侧只开了 v4或者反过来。我们后来在集群里加了一条巡检规则每个节点定时探测一次关键 NFS 端点异常就告警。因为这类故障一旦发生受影响的是所有新启动的 Pod不只是某一个业务。8. 裸机与混合环境的落地建议8.1 一个可执行的存储分层方案在裸机或自建环境里我的建议是分三层用不同的 StorageClass 承载层级底层介质StorageClass回收策略扩容适用场景高性能本地 NVMelocal-ssdRetain否数据库、消息队列、需要低延迟的写密集业务通用网络存储nfs-standardRetain是日志、附件、中等 IO 的业务数据临时本地盘local-scratchDelete否CI 缓存、临时解压、可重建的中间产物高性能层用 local PV 加 WaitForFirstConsumer代价是扩不了容、换不了节点所以只给那些明确知道自己数据量的服务用。通用层是绝大多数业务的选择。临时层鼓励用 emptyDir只有明确需要跨 Pod 重启保留的才用 PVC。8.2 上线前必须跑的验证清单不管静态还是动态我都要求在做完存储配置后跑一遍这份清单五条都过了才允许业务接入读写验证挂载后写入一个文件删 Pod 重建文件还在。跨节点验证RWO 卷在 Pod 漂移到另一节点后能正常挂载先删旧 Pod 再起新的。回收验证在预发环境删一次 PVC确认 PV 状态符合预期Retain 变 ReleasedDelete 消失。权限验证容器内的非 root 用户能写入挂载目录。容量验证df -h看到的大小和 PVC 申请值一致注意 Gi 与 G 的换算。这五条看着简单但我在不同团队里见过每一条都至少踩过一次。尤其是第 2 条和第 5 条前者是测试环境永远测不出来后者是单位算错了要等磁盘写满才发现。最后分享一个我自己的习惯给每个 PV 都强制打三个 label——owner、env、>