早先有次内部演练我把一个有状态服务布到 Kubernetes 上容器一重启数据全没。排查的时候发现数据写在了容器的可写层所谓“持久化”其实挂了一个 emptyDirPod 一重建目录就清空了。那次之后我才把 PersistentVolume、PersistentVolumeClaim 和 StatefulSet 这套组合认真啃了一遍也是从那时候开始踩出一堆文档里查不到的坑。这篇是这个系列里我自己觉得最实用的一篇之一。核心就讲三件事持久存储 PV/PVC 的底层原理StatefulSet 为什么天生适合承载有状态服务以及用 NFS 分别走静态供给和动态供给两种方式把一套带独立存储的多副本服务完整跑起来。适合已经会用 Deployment、但对存储和 StatefulSet 还停留在“听说过”阶段的同学看过之后可以直接照着操作也能把日常排错的思路理顺。1. 存储抽象体系容器为什么记不住数据PV/PVC 怎么解决1.1 容器天然“记不住”数据很多人第一次踩坑都是在同一个地方容器正常跑服务也正常写数据但只要 Pod 被删除、被重新调度或者节点发生迁移数据就像蒸发了一样。原因不在应用而在于容器文件系统的生命周期设计。每个容器最底层是镜像的只读层容器内写入的数据落在可写层。可写层和容器是同生共死的容器一删这层内容跟着销毁。即使 Pod 里只有一个容器Pod 被重建后也是全新的文件系统。你可以在容器内随便创建文件但只要 Pod 不在文件就不再属于任何东西。emptyDir 卷的误导性很强。它确实能提供“容器重建后数据还在”的效果但前提是 Pod 没有删除。emptyDir 的生命周期跟 Pod 绑定Pod 一旦被删除emptyDir 里的所有数据也随之清理。它的典型用途是容器间共享临时目录、缓存、日志临时落盘而不是业务数据的最终存储。我们内部演练那次服务日志、任务结果全写在 emptyDir 里Pod 一重建所有运行记录都清空了复盘时真的很被动。hostPath 是另一种常见的过渡方案直接把节点上的目录挂给 Pod。单机测试没问题但集群里问题就大了Pod 被调度到节点 B数据却留在节点 A服务跑起来就等于换了一份空数据。所以 K8s 才需要真正的存储抽象层把“存储资源的提供”和“存储资源的使用”彻底分开。1.2 PV 与 PVC存储界的“房源”和“购房合同”PersistentVolume 和 PersistentVolumeClaim 这一对概念用生活类比其实很好理解。PersistentVolume 是集群里的存储资源它由管理员预先准备好底层可以是 NFS 目录、云硬盘、Ceph RBD、本地磁盘等。PV 相当于“房源”它自己知道背后的真实存储在哪、容量多大、能支持什么访问方式。PersistentVolumeClaim 则相当于“购房合同”或“租房需求单”使用方只需要声明“我需要一块 2GiB 的存储、支持单节点读写”集群会在现有的 PV 里找到匹配的房源把两者绑定。这种设计的价值在于解耦。应用开发者不用关心底层到底用的 NFS 还是云盘只需要在 Pod 里声明使用某个 PVC而存储管理员可以统一规划底层资源按需提前创建不同容量、不同性能的 PV。两边各管各的互不越界。PVC 和 PV 是一对一绑定的。一旦 PVC 绑定到某个 PV这个 PVC 就独占了这个 PV别的 PVC 不能再复用。Pod 使用存储时是通过 PVC 间接引用 PV再由 kubelet 把真实存储挂载进容器里。这个绑定关系在 Pod 整个生命周期内保持不变也是后面 StatefulSet 能实现“身份与数据绑定”的基础。1.3 StorageClass 与动态供给从手工到自动如果只有 PV 和 PVC运维工作还是很重。管理员要提前预估集群里有多少应用、各自需要多大存储然后把 PV 一个一个创建出来。PV 数量不够PVC 就只能一直 Pending创建太多又浪费资源。这个模式叫静态供给。StorageClass 的出现把这件事变成了自动化。StorageClass 像一台“存储自助售卖机”里面定义了交给哪个 Provisioner 去创建存储、创建出来的 PV 有什么回收策略、底层存储是什么类型。PVC 只要声明storageClassName: nfs-dynamic并写明容量请求Provisioner 收到请求后会自动创建底层存储和对应的 PV随后完成绑定。静态供给是“你提前买好车位车来了再分配”动态供给是“车到了停车场门口停车场现场给你修一个车位出来”。在云环境或搭建好外部存储驱动之后动态供给基本成了默认选择因为它把“管理员手工创建 PV”这件事几乎完全消灭了。但这不代表静态供给没有价值很多私有化环境里存储驱动没有接好或者需要对存储做严格审计的时候静态供给依然是稳扎稳打的做法。第四章我会把两种方式都演示一遍。2. PV 核心机制访问模式、回收策略与绑定规则全拆解2.1 三种访问模式别只看字面意思PV 在创建时可以声明自己支持的访问模式AccessModesPVC 在申请时也会声明自己需要的访问模式。K8s 要求 PV 的访问模式集合必须“包含” PVC 请求的模式两者是包含关系不是完全相等。访问模式一共有三种访问模式缩写含义典型存储ReadWriteOnceRWO单个节点可读可写云硬盘、本地盘ReadOnlyManyROX多个节点可同时只读共享只读数据ReadWriteManyRWX多个节点可同时读写NFS、CephFS最容易踩坑的是 RWO。很多人把它理解成“同一时间只能有一个 Pod 使用”这是不准确的。它的准确定义是“单个节点可读写”意思是同一个节点上的多个 Pod 是可以同时使用这个卷的。只是很多存储驱动在实现层面更严格限制一次只能挂载到一个 Pod所以行为会表现得像是“只能单 Pod 使用”。日常我们记住一条就够了如果两个 Pod 可能被调度到不同节点且都要读写同一块 RWO 卷那一定会出问题应改用 RWX 或让每个 Pod 使用独立的 RWO 卷。另外PV 还有一个volumeMode字段默认是Filesystem也就是挂载成目录使用还有一种Block模式直接把块设备交给 Pod适合某些数据库或专用软件。因为使用场景相对少这里我就不展开了。2.2 回收策略数据安全的第一道闸门当 PVC 被删除PV 会根据自身的persistentVolumeReclaimPolicy决定底层数据怎么处理。这个字段非常重要很多人把 PVC 一删就发现数据没了根因就在这。回收策略PVC 删除后 PV 的状态底层数据适合场景RetainPV 进入 Released数据保留数据还在生产数据库、不可重建数据DeletePV 被直接删除底层存储通常也会被删数据被清除测试环境、可重建缓存Recycle清理数据后回到 Available数据被清除旧版本可用现基本废弃动态供给创建的 PV回收策略继承它所属 StorageClass 的reclaimPolicy。很多 StorageClass 的默认策略是 Delete这意味着只要 PVC 被删除底层云盘或存储目录很可能一并销毁数据没有抢救机会。生产环境如果用的是默认 SC最好先确认一下它的回收策略再决定要不要把有状态的存储 PVC 挂上去。Retain 策略看起来安全但有一个隐藏成本PVC 删除后 PV 虽然还在却会一直停在 Released 状态而且不能被新的 PVC 直接绑定。因为 PV 里还残留着旧的claimRef引用需要管理员手动清理。这个坑我放到第五章专门讲因为它是静态供给里最容易卡住人的问题。2.3 PV 状态机与绑定规则PV 有四种生命周期状态Available、Bound、Released、Failed。AvailablePV 还没被任何 PVC 绑定池子里待分配。Bound已经被某个 PVC 绑定两者一对一绑定成功。ReleasedPVC 被删除但 PV 上的数据还在等待管理员处理。Failed自动回收或者动态供给过程出错。PVC 想要绑定到某个 PV需要满足几个条件。第一PV 的容量要大于等于 PVC 请求的容量。第二PV 的访问模式集合要包含 PVC 请求的访问模式。第三storageClassName 必须匹配第四如果 PVC 的volumeMode有声明也要和 PV 一致。另外PVC 还可以通过selector指定标签去筛选 PV比如要求 PV 带disktype: ssd。这里有一个非常容易误解的地方静态供给时PV 和 PVC 的 YAML 里写storageClassName: 并不代表“没设置”而是主动表示“不使用动态供给只看静态 PV”。如果你不写这个字段而集群恰好存在默认 StorageClassPVC 就会走自动动态供给反而不会去绑定你手工创建的 PV。很多人的静态绑定调不通第一个原因就在这。理解 PV 的绑定规则和状态机不只是为了创建存储更是为了排错。看到 PVC Pending第一反应不是慌而是按这些条件逐一核对基本都能快速找到问题。3. StatefulSet 与存储的三种绑定关系独立 PVC、稳定身份、有序调度3.1 Deployment 承载不了有状态服务的三个原因Deployment 本身设计用来管理“无状态”应用它的问题有三个。第一个问题是所有副本共用同一个 PVC。Deployment 的 Pod 模板里如果要挂 PVC所有副本都会挂同一个 PVC。如果这个卷是 RWO两个 Pod 被调度到不同节点第二个 Pod 就会因挂载冲突起不来。即使底层是 RWX 共享存储服务之间也缺少“数据隔离”。对数据库这种每个节点都要独立数据的应用来说这是不可接受的。第二个问题是 Pod 名字不稳定。Deployment 创建的 Pod 名是“Deployment 名 随机后缀”滚动更新后名字直接变化。很多有状态服务需要固定的主机名或固定的网络标识来维护集群成员关系比如数据库副本之间互相识别、选主、同步全靠这些标识。名字一变整个集群关系就乱了。第三个问题是启停顺序不可控。Deployment 扩容时并行创建 Pod缩容时随机删除 Pod无法保证谁是第一个启动、谁是最后一个退出。分布式有状态服务初始化时通常有依赖比如新节点要等第一个节点就绪后才能加入集群Deployment 完全做不到。3.2 volumeClaimTemplates每个副本一份专属 PVCStatefulSet 解决“独立存储”靠的是volumeClaimTemplates字段。这个字段和 Deployment 里的 Pod 模板类似但位置在 StatefulSet 的 spec 下面。它描述的是“每个副本应该申请什么样的 PVC”StatefulSet 控制器会为每个 Pod 自动生成一份对应的 PVC。这个自动生成过程是最核心的机制。PVC 的命名规则是“volumeClaimTemplates 名称 StatefulSet 名称 序号”。比如 StatefulSet 叫demo-webvolumeClaimTemplates 里定义的卷名是data那么第 0 个副本生成的 PVC 就叫>mkdir -p /data/nfs/pv0 mkdir -p /data/nfs/pv1 mkdir -p /data/nfs/pv2然后配置导出。在生产环境一定要限制来源网段不要直接放开全部地址cat /etc/exports EOF /data/nfs 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash) EOF exportfs -ravno_root_squash这个参数会在后面引发的权限问题里发挥作用等于是在 NFS 层面允许容器里的 root 写入。如果不想放开就需要用fsGroup或者把 NFS 目录属主改成容器进程的用户否则容器里写文件会报 Permission denied。这个坑下文还会再强调。最后在 Kubernetes 里创建一个测试命名空间后面所有资源都丢进去kubectl create ns k8s-storage-demo4.2 静态供给实操手工创建 PV让 StatefulSet 自动绑定静态供给的核心是“PV 提前存在”。我先创建两个 PV 做演示第三个故意不建方便后面观察 PVC Pending 的情况。一个 PV 的 YAML 长这样apiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv-0 spec: capacity: storage: 2Gi volumeMode: Filesystem accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain storageClassName: nfs: server: 192.168.1.100 path: /data/nfs/pv0注意三个细节。第一accessModes是 ReadWriteOnce静态绑定时 PVC 也声明 RWO就能匹配。第二persistentVolumeReclaimPolicy用 RetainPVC 删掉之后数据不会自动消失方便演示。第三storageClassName: 明确禁掉动态供给让 PVC 只会找这些静态 PV。nfs-pv-1的 YAML 除了名字和 path 外完全一样把 path 改成/data/nfs/pv1就行。创建之前最好在 NFS 服务端确认目录存在否则后面挂载会失败。执行kubectl apply -f nfs-pv-0.yaml kubectl apply -f nfs-pv-1.yaml接下来创建 Headless Service 和 StatefulSet。StatefulSet 的 YAML 重点看volumeClaimTemplatesapiVersion: v1 kind: Service metadata: name: demo-web-headless namespace: k8s-storage-demo spec: clusterIP: None selector: app: demo-web ports: - port: 80 --- apiVersion: apps/v1 kind: StatefulSet metadata: name: demo-web namespace: k8s-storage-demo spec: serviceName: demo-web-headless replicas: 3 selector: matchLabels: app: demo-web template: metadata: labels: app: demo-web spec: containers: - name: nginx image: nginx:1.25 ports: - containerPort: 80 volumeMounts: - name: data mountPath: /usr/share/nginx/html volumeClaimTemplates: - metadata: name: data spec: accessModes: [ReadWriteOnce] storageClassName: resources: requests: storage: 2GiStatefulSet 控制器在创建 Pod 时会自动生成 PVC。>kubectl -n k8s-storage-demo get pvc kubectl get pv你会看到两个 PVC 处于 Bound 状态第三个 PVC 是 Pending。这也是有状态服务在存储规划上必须“按副本数预判容量”的原因。要恢复第三个副本补充创建第三个 PV 即可kubectl apply -f nfs-pv-2.yaml过十几秒再看 PVC三个应该全部绑定demo-web-2也会正常 Running。4.3 结果验证删除 Pod 之后数据是否安然无恙存储绑定做没做对不能只看状态要真的写入数据再验证。我先往三个 Pod 各自写一个标记文件kubectl -n k8s-storage-demo exec demo-web-0 -- sh -c echo node-0 /usr/share/nginx/html/index.html kubectl -n k8s-storage-demo exec demo-web-1 -- sh -c echo node-1 /usr/share/nginx/html/index.html kubectl -n k8s-storage-demo exec demo-web-2 -- sh -c echo node-2 /usr/share/nginx/html/index.html然后故意删掉其中两个 Pod模拟异常重建kubectl -n k8s-storage-demo delete pod demo-web-0 demo-web-1StatefulSet 会自动把同名 Pod 重新创建出来。等 Pod 进入 Running 状态后再去读文件kubectl -n k8s-storage-demo exec demo-web-0 -- cat /usr/share/nginx/html/index.html kubectl -n k8s-storage-demo exec demo-web-1 -- cat /usr/share/nginx/html/index.html如果你看到的输出仍然是node-0和node-1说明数据确实写到了 NFS 底层目录Pod 的销毁重建没有影响持久化数据。如果文件内容为空或者不存在那就要回头查 PVC 到底有没有绑定成功。数据之所以还在是因为 PVC 绑定的 PV 在 Pod 重建后依然存在新建的demo-web-0会复用>apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-dynamic provisioner: k8s-sigs.io/nfs-subdir-external-provisioner parameters: archiveOnDelete: truearchiveOnDelete: true的作用是PVC 被删除时不会立刻把 NFS 上的数据目录清掉而是先归档这对数据保护很友好。如果你的场景确实可以丢数据可以设置成 false。然后创建一套新的 StatefulSet名字用demo-web-dynamic避免和刚才的静态 PVC 冲突。核心区别就是volumeClaimTemplates里的storageClassName改成nfs-dynamicapiVersion: apps/v1 kind: StatefulSet metadata: name: demo-web-dynamic namespace: k8s-storage-demo spec: serviceName: demo-web-headless replicas: 2 selector: matchLabels: app: demo-web-dynamic template: metadata: labels: app: demo-web-dynamic spec: containers: - name: nginx image: nginx:1.25 ports: - containerPort: 80 volumeMounts: - name: data mountPath: /usr/share/nginx/html volumeClaimTemplates: - metadata: name: data spec: accessModes: [ReadWriteOnce] storageClassName: nfs-dynamic resources: requests: storage: 2Gi创建后观察以下资源kubectl -n k8s-storage-demo get pvc -l appdemo-web-dynamic kubectl get pv | grep nfs-dynamic你会发现 PVC 变成 BoundPV 不再是我手工命名的nfs-pv-*而是自动生成的pvc-随机串。这时手动扩容试试kubectl -n k8s-storage-demo scale statefulset demo-web-dynamic --replicas3扩容到 3 个副本后第 3 个 PVC 会自动被创建出来并完成绑定完全不需要干预。这就是动态供给的便利底层存储按需分配副本扩多少PVC 就能跟着出现多少。5. 避坑指南PVC 卡 Pending、挂载失败与数据误删排查5.1 PVC Pending加快定位的三个维度PVC 一直是 Pending是存储相关最常遇到的故障没有之一。看到这个状态不要马上重启服务按下面三个维度排查。第一看 PVC 事件。kubectl describe pvc 名称是第一步所有无法绑定的原因都会出现在 Events 里。静态供给最常见的事件是 “no persistent volumes available for this claim and no storage class is set”翻译过来就是“没有可用的 PV也没有配置 SC”本质是 PV 不满足绑定条件。接下来核对容量、访问模式、storageClassName 三个条件尤其是 storageClassName 是否都写成了空字符串。第二看 StorageClass 是否存在且名字正确。PVC 里写的storageClassName必须在集群里真实存在否则直接 Pending。动态供给时还要确认 Provisioner Pod 是否正常运行很多情况下是外部组件没跑起来导致动态供给请求发出去了没人响应。第三看 PV 是不是已经被别的 PVC 占用。PV 是独占资源一个 Bound 状态的 PV 不可能再绑给别的 PVC。查kubectl get pv的时候注意 STATUS 列Released 状态的 PV 也不能马上绑定需要先清理 claimRef。5.2 Pod 一直 ContainerCreating挂载失败与 NFS 权限PVC 已经 Bound但 Pod 一直 ContainerCreating这种问题集中在挂载阶段。先执行kubectl describe pod 名称看 Events 里是否有 Multi-Attach 错误、FailedMount、权限拒绝之类的字眼。NFS 挂载有几个常见触发点。第一NFS 服务端导出的目录在客户端不可见可能是 exports 配置不对也可能 Node 上没有装 NFS 客户端。第二路径不存在PV 里写的 path 在 NFS 服务端不是有效目录挂载自然失败。第三权限问题容器内进程写文件时提示 Permission denied大概率是 NFS 上的目录属主和容器内用户 UID 不一致。权限坑的解决办法通常有两种。一种是在 NFS 服务端放宽目录属主但生产环境不建议。另一种是在 Pod 里设置fsGroup让挂载卷的文件组归属到指定 GIDspec: securityContext: fsGroup: 2000设置之后K8s 在挂载时会对卷做 chown 处理容器进程对目录的访问权限会得到修正。不过 fsGroup 修改对已经存在的海量文件会触发递归 chown文件很多时启动可能变慢这个要注意。5.3 PV 变成 Released 无法复用Retain 策略的隐藏坑有些 PV 配了 Retain 策略PVC 被删后 PV 变成了 Released但把它重新拿给新 PVC 使用时怎么也绑定不上。原因在于 PV 的claimRef还残留旧 PVC 的引用。虽然 PVC 已经不在了PV 依然认为它被旧 PVC 占用无法进入 Available。处理方法有两种。第一种最简单直接删除这个 PV清理底层数据或重新指定路径再创建同样的 PV。第二种是手动清理 claimRefkubectl patch pv nfs-pv-0 -p {spec:{claimRef:null}}清理之后 PV 通常就会回到 Available可以被新 PVC 复用。这里特别提醒如果 PV 是静态供给且指向 NFS 原有路径底层数据还在重建 PV 后新 PVC 会重新读到旧数据。很多团队利用这个特性做数据恢复但如果不小心忽略了这一点也容易造成“为什么新环境有旧数据”的困惑。5.4 数据一旦删除就没救了回收策略选型建议在真正理解回收策略之前我在测试环境就亲手删掉过一次比较重要的数据。当时 PVC 挂在动态供给的 StorageClass 上回收策略默认是 Delete我把 PVC 当作临时资源清理掉了结果底层云盘一并删除数据直接消失。这里分享几条选型经验。生产级数据存储PV 的回收策略必须设成 Retain或者 StorageClass 里明确指定 Retain。PVC 可以被删除但底层的存储卷必须保留至少留一个人工介入的机会。动态供给场景下外部 Provisioner 的archiveOnDelete参数如果能设置建议开启等于给数据误删多上了一道保险。测试环境可以接受 Delete因为它省去了手动清理 PV 的麻烦。但删之前还是要想清楚这个 PVC 里存的东西有没有另一份副本没有就宁可从 Retain 开始。5.5 排查命令速查最后把存储和 StatefulSet 排查常用的命令整理成一张表实际排错时直接照着敲就行。命令用途典型观察点kubectl get pv看集群里现有 PV 状态STATUS 是否 Bound/Releasedkubectl get pvc -n ns看 PVC 是否 BoundPVC 是否 Pendingkubectl describe pvc name -n ns看 PVC 绑定失败原因Events 里的关键报错kubectl get storageclass查看 SC 列表及 Provisioner名字是否与 PVC 中一致kubectl describe pod name -n ns看 Pod 挂载失败原因FailedMount、事件内容kubectl get statefulset -n ns看 StatefulSet 副本状态期望副本数和当前副本数kubectl logs pod -n ns查看应用内错误应用是否在启动时报存储路径异常这套命令组合起来基本能覆盖从 PVC 申请到最终挂载的整条链路。最后再分享一点我在实际环境中的体会。StatefulSet 加持久化存储这套组合最大的风险并不在部署那一刻而在后续运维。PVC 要不要清理、PV 回收策略怎么定、底层存储空间会不会被孤儿 PVC 占满这些问题只有提前想清楚才能在出故障时保住数据和业务。我个人的习惯是任何一个新的有状态服务上线前先画清楚一条 PVC 生命周期链路从创建到绑定从删除到回收每一步都要有明确的预期。这样后面无论做扩容、升级还是迁移都不会被“数据到底在哪”这个问题反复折磨。
阅读完成 · 觉得有帮助?