
这个系列写到现在我觉得真正难啃的骨头才刚上桌Kubernetes 的持久化存储。前面聊 Pod、Deployment、Service再复杂也不过是资源编排可一旦牵扯到数据“Pod 重启后数据原地蒸发”这个事实马上就会把很多入门者打回原形。你在部署 MySQL、Nacos、ES 这类有状态服务时如果没把存储模型搞明白数据丢一次就能让你重新体会什么叫欲哭无泪。这篇第十四篇我打算把存储这条线拉完整从容器文件系统为什么靠不住到 emptyDir、hostPath 这些基础卷的适用边界再到 PV、PVC、StorageClass 的设计逻辑最后用 NFS 做一套“静态供给 动态供给 StatefulSet 挂载”的完整实操。实验环境是我本机用 kubeadm 装的 v1.26.0 集群kubeadm init时的 preflight 检查一次通过输出里明确写着[init] using kubernetes version: v1.26.0环境干净适合照着复现。文章末尾会给你一份常见坑位速查表基本都是我实测踩过的。1. 先别急着敲命令搞清楚容器为什么存不住数据1.1 容器文件系统的“短命”本质很多人第一次接触 Docker 时会有一个错觉容器里能写文件那数据是不是就安全了实际上容器镜像是由一层层只读层组成的运行时在容器内写入的文件都落在可写层。Pod 被删除时整个可写层会跟着一起销毁这就像你把字写在便利贴上便利贴被撕掉的时候字也就没了。更麻烦的是调度问题。Kubernetes 的调度器会在集群节点之间自由切换 Pod这次跑在 node1下次可能就被调度到 node2。如果你只是把数据写在某个节点的本地目录里Pod 一旦搬家新节点上根本没有这些数据服务直接变成“失忆状态”。所以存储必须从 Pod 的生命周期中剥离出来有一个独立的生命周期这正是 Kubernetes 存储体系的核心出发点。1.2 最基础的卷emptyDir 和 hostPathKubernetes 里最简单的卷是 emptyDir。它可以看作一个临时目录生命周期跟 Pod 一致Pod 还在目录就在Pod 被删目录跟着消失。它的典型用途是同一个 Pod 内多个容器共享数据比如日志采集场景业务容器把日志写入 emptyDirsidecar 容器读出来转发给日志系统。下面是一个最简单的 emptyDir 示例apiVersion: v1 kind: Pod metadata: name: demo-empty-dir spec: containers: - name: writer image: busybox command: [sh, -c, echo hello /cache/hello.txt; sleep 3600] volumeMounts: - name: cache mountPath: /cache - name: reader image: busybox command: [sh, -c, sleep 3600] volumeMounts: - name: cache mountPath: /cache volumes: - name: cache emptyDir: {}emptyDir 解决不了数据持久化于是有同学想到了 hostPath把节点上的某个目录直接挂载到 Pod 里。这样做确实能“落地”但坑非常大。Pod 可以被调度到任意节点而 hostPath 是绑定具体节点目录的如果 Pod 被调度到另一台节点挂载的就是那台新节点上的目录数据依然找不到。除非你用 nodeSelector 把 Pod 强行固定在某台节点可这样又丧失了 Kubernetes 最宝贵的弹性调度能力。1.3 Kubernetes 给出的答案把存储变成一等资源emptyDir 和 hostPath 都是“以 Pod 为中心”的存储方案视角从一开始就错了。Kubernetes 后来引入了 PVPersistentVolume和 PVCPersistentVolumeClaim把存储资源的“供”和“求”彻底分开。你可以把 PV 想象成仓库里已经备好的商品PVC 是用户提交的采购订单而 StorageClass 则是根据订单自动补货的智能仓库系统。 Pod 只负责声明“我要用这块存储”至于存储从哪来、是本地盘还是网络盘Pod 不需要关心。这套设计解决了两个核心问题一是存储生命周期与 Pod 解耦Pod 删了PV 里的数据还在二是存储管理与业务解耦集群管理员统一维护底层存储资源业务开发者只写 PVC 声明就行。下面的表格是三种卷的直观对比方便新手上路时建立全局印象。卷类型数据生命周期多节点共享典型场景风险点emptyDir跟随 Pod不支持容器间临时共享Pod 删除即丢hostPath跟随节点不支持单机调试调度跨节点后数据不可用PV/PVC独立于 Pod取决于后端存储生产环境有状态服务需要理解绑定与回收逻辑2. 把核心概念一次性讲透PV、PVC、StorageClass2.1 PV 的字段与生命周期PV 是集群级别的资源它描述的实际是一块真实存储的“信息卡”。我们看一个 NFS 类型的 PV 声明里面包含几个非常重要的字段apiVersion: v1 kind: PersistentVolume metadata: name: pv-nfs-001 spec: capacity: storage: 10Gi accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain storageClassName: nfs: server: 192.168.1.100 path: /data/nfs/k8s/static001capacity 声明存储容量这里写 10GiaccessModes 是访问模式后面单独讲persistentVolumeReclaimPolicy 是回收策略决定 PVC 被删除后 PV 里的数据怎么处理这个字段的重要性远超你的想象很多生产事故都出在它身上storageClassName 用来匹配 StorageClass如果我们想强制使用静态 PV 而不是动态供给这里要显式写成空字符串。PV 的完整生命周期可以用四个状态概括Available空闲待绑定、Bound已被 PVC 绑定、Released绑定 PVC 已删、PV 等待管理员回收、Failed回收失败卷不可用。新创建的 PV 会进入 Available一旦有 PVC 匹配成功就变为 BoundPVC 删除后PV 进入 Released注意它不会自动回到 Available需要管理员手动清理或重新导入数据这个机制看着繁琐其实是保护机制防止前面的 PVC 刚删、后面的 PVC 就立刻把数据当成自己的。2.2 PVC 的请求与绑定规则PVC 是用户视角的存储申请写法比较直观apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-nginx spec: accessModes: - ReadWriteMany storageClassName: resources: requests: storage: 5GiPVC 创建之后Kubernetes 会去寻找一个“够格”的 PV 完成绑定。所谓够格需要同时满足几个条件PVC 请求的容量不能超过 PV 的容量但 PV 可以比请求更大PV 的访问模式必须能覆盖 PVC 要求的模式storageClassName 必须完全一致包括两边都是空字符串volumeMode 也要匹配。一旦绑定成功PVC 和 PV 就形成了一对一的关系这个关系是持久的直到 PVC 被删除。新手最容易踩的坑就是静态 PV 与默认 StorageClass 的冲突。如果你的集群里存在一个被标记为默认的 StorageClass通常云环境会自动创建那么创建 PVC 时不写 storageClassName系统会悄悄走动态供给根本不会去绑定你手写的 PV。所以在静态供给实验里PV 和 PVC 都得显式声明storageClassName: 才能绕开默认行为。我一开始做实验时PVC 卡在 Pending 好半天就是用kubectl describe pvc一看事件才发现是这个原因。2.3 访问模式与回收策略访问模式决定了卷能被多少个节点和多少种方式使用常用有三种ReadWriteOnceRWO卷只能被单个节点以读写方式挂载。注意这里的“单节点”不是说“单个 Pod”同一个节点上的多个 Pod 其实可以同时使用同一个 RWO 卷这跟很多人理解的“单 Pod”不一样。ReadOnlyManyROX卷可以被多个节点同时以只读方式挂载适合共享配置、共享只读资源。ReadWriteManyRWX卷可以被多个节点同时以读写方式挂载NFS、CephFS 这类共享文件系统天然支持块存储一般不支持。回收策略方面Retain 会让 PV 在 PVC 删除后保留数据需要管理员手动处理Delete 会在 PV 删除时连底层存储一起删掉Recycle 是一个比较老旧的清理策略现在已经不推荐使用了。生产环境我强烈建议用 Retain多花几分钟手动清理比误删数据之后花几天做恢复要划算得多。2.4 StorageClass 与动态供给PV 如果全靠管理员手动创建就好比每个部门申请一个文件目录都要给运维开工单规模一大就废了。StorageClass 的引入就是为了解决这个问题它定义了一套“按需生产”的规则用户提交 PVC 时集群里的 Provisioner 组件会根据 StorageClass 的配置自动创建底层存储再生成一个 PV 完成绑定。用户感知到的结果就是我提交一个 PVC没过几秒它就 Bound 了背后没有一个运维去手工建目录。StorageClass 里有三个核心字段provisioner 指定谁来创建存储parameters 是传给 provisioner 的参数比如 NFS 路径、磁盘类型等reclaimPolicy 决定动态生成的 PV 被删除时的回收策略。这种“自助申请、自动配给”的模式才是 Kubernetes 能在大规模集群里高效运转的底气。3. 手把手实操用 NFS 静态供给跑通第一个持久化应用3.1 为什么选 NFS 作为入门后端Kubernetes 支持的存储后端五花八门入门阶段我推荐 NFS理由是它足够简单、跨节点共享能力强而且几乎所有 Linux 发行版都自带支持。生产环境里 NFS 的性能和可用性可能不够打但作为理解 PV/PVC 机制的试验场它比云盘、Ceph 这些动不动就涉及复杂初始化流程的方案友好太多。你先通过 NFS 把“存储独立于 Pod”这件事刻进脑子里再去上手生产级 CSI 方案会顺很多。我的测试拓扑很简单集群用 kubeadm 安装版本 v1.26.0三节点一个 master 两个 worker。NFS 服务端我用一台单独的服务器 192.168.1.100 充当实际上你也可以把它装在其中任意一个节点上只是要记得 pod 调度时不要破坏业务。下面所有步骤都是我在这个环境里完整跑过的。3.2 准备 NFS 服务端和客户端先在 NFS 服务端安装并导出目录apt install nfs-kernel-server -y mkdir -p /data/nfs/k8s/static001 echo h1hello from nfs storage/h1 /data/nfs/k8s/static001/index.html chmod -R 755 /data/nfs/k8s echo /data/nfs/k8s 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash) /etc/exports exportfs -rav这里的no_root_squash是为了测试方便让容器里 root 用户也能正常写入。生产环境不建议这么开权限控制应该用更细粒度的方式做。接下来所有 Kubernetes 节点都要安装 NFS 客户端工具apt install nfs-common -y showmount -e 192.168.1.100showmount能看到服务端导出的路径列表如果看不到先检查防火墙和/etc/exports的语法。我建议在 Kubernetes 介入之前先在宿主机上手动挂载一次排除网络问题mkdir -p /mnt/nfs-test mount -t nfs 192.168.1.100:/data/nfs/k8s /mnt/nfs-test ls /mnt/nfs-test/static001 umount /mnt/nfs-test这个“先手动挂载验证”的习惯非常重要。很多朋友一上手就把 NFS 服务端配好直接创建 PV结果 Pod 一直 ContainerCreating排查半天才发现是宿主机根本连不上 NFS白白浪费时间。3.3 创建静态 PV、PVC 并部署 nginx接下来创建 PV就是我前面给出的pv-nfs-001.yaml。这里只有一个细节要控制storageClassName必须设为空字符串。然后创建 PVCkubectl apply -f pv-nfs-001.yaml kubectl get pv kubectl apply -f pvc-nginx.yaml kubectl get pv kubectl get pvc第一次执行后PV 状态应该是 AvailablePVC 创建后再查看就能看到 PV 状态变为 BoundPVC 的 STATUS 也是 Bound。如果 PVC 一直 Pending不要慌用kubectl describe pvc pvc-nginx看底部 Events。随后把 PVC 挂进一个 nginx DeploymentapiVersion: apps/v1 kind: Deployment metadata: name: nginx-storage spec: replicas: 1 selector: matchLabels: app: nginx-storage template: metadata: labels: app: nginx-storage spec: volumes: - name: html persistentVolumeClaim: claimName: pvc-nginx containers: - name: nginx image: nginx:1.25 ports: - containerPort: 80 volumeMounts: - name: html mountPath: /usr/share/nginx/html这里把 NFS 目录挂载到了 nginx 的站点根目录。接下来验证数据是否真的随 Pod 持久化kubectl get pod -o wide kubectl exec -it nginx-storage-xxx -- cat /usr/share/nginx/html/index.html kubectl delete pod nginx-storage-xxx kubectl get pod -w第一次 exec 看到的应该是 NFS 服务端预写入的h1hello from nfs storage/h1。删除 Pod 后Deployment 会自动创建新 Pod因为 PVC 还是同一个NFS 数据自然原封不动。这种朴素的验证会带给你一种安全感也是理解后续所有有状态服务的基础。4. 进阶玩法动态供给与 StatefulSet 实战4.1 动态供给的开箱体验静态 PV 需要手动创建好库存才能绑定集群规模一大管理员就要不停建 PV这显然不是长久之计。动态供给的思路是用户写一个带StorageClassName的 PVCStorageClass 背后的 Provisioner 收到请求后自动到后端存储创建目录然后生成 PV 完成绑定全程无需人工介入。部署一个基于 NFS 的动态供给组件最简单的方式是用 Helm 安装nfs-subdir-external-provisionerhelm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/ helm install nfs-subdir-external-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \ --set nfs.server192.168.1.100 \ --set nfs.path/data/nfs/k8s \ --set storageClass.namenfs-client安装完成后集群里会自动出现一个名为nfs-client的 StorageClass。创建 PVC 只需要声明apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-dynamic spec: accessModes: - ReadWriteMany storageClassName: nfs-client resources: requests: storage: 3Gi提交后用kubectl get pvc pvc-dynamic观察几秒内就会从 Pending 变成 Bound你根本看不到 PV 的手工创建过程。到这一步动态供给的威力就体现出来了底层存储就像自来水管道打开龙头就有水不用自己挖井。4.2 StatefulSet 为什么必须配合持久化存储Deployment 创建的 Pod 是“无状态”的名字随机、身份不固定对存储也没有特殊要求但 MySQL 主从、ZooKeeper、Elasticsearch 这类有状态应用每个 Pod 都需要稳定的身份和专属的存储StatefulSet 就是为此而生的。StatefulSet 里有一个非常关键的特性叫volumeClaimTemplates它好比给每个副本自动生成一条 PVC 的“模板生产线”。新建 Pod 时Kubernetes 会根据模板自动创建 PVCPVC 的名字会带上 Pod 序号比如www-web-0、www-web-1。即使 Pod 被删除重建它依然会绑定原来的 PVC天然实现“人换衣服不换鞋”的效果。4.3 用 StatefulSet 验证数据稳定性下面是我实际用过的例子一个非常简单的 nginx StatefulSet还配了一个 Headless ServiceapiVersion: v1 kind: Service metadata: name: web spec: clusterIP: None selector: app: stateful-pvc-demo --- apiVersion: apps/v1 kind: StatefulSet metadata: name: web spec: serviceName: web replicas: 2 selector: matchLabels: app: stateful-pvc-demo template: metadata: labels: app: stateful-pvc-demo spec: containers: - name: nginx image: nginx:1.25 volumeMounts: - name: www mountPath: /usr/share/nginx/html volumeClaimTemplates: - metadata: name: www spec: accessModes: [ReadWriteOnce] storageClassName: nfs-client resources: requests: storage: 2Gi创建后观察kubectl get sts kubectl get pvc kubectl get pods -o wide你会看到web-0和web-1两个 Pod同时存在www-web-0和www-web-1两条 PVC分别对应两个 Pod。接下来做一个残酷测试往web-0里写入一个文件然后强制删除这个 Pod等它重新启动后文件还在kubectl exec web-0 -- sh -c echo data from web-0 /usr/share/nginx/html/my.txt kubectl delete pod web-0 kubectl get pod -w kubectl exec web-0 -- cat /usr/share/nginx/html/my.txtStatefulSet 的卷模板会自动匹配旧的 PVC所以数据不会丢。这个特性对有状态应用来说就是生命线ZooKeeper 的 myid 文件、MySQL 的数据目录全都依赖这套机制。5. 常见问题与排查技巧实录5.1 排查必知命令存储排障离不开几个命令建议直接背下来kubectl describe pvc看 PVC 的事件和绑定状态kubectl describe pv看 PV 的详细信息kubectl describe pod看挂载失败的具体报错kubectl get events --sort-by.lastTimestamp看全局事件流。大多数存储问题的第一现场就藏在这些输出里别急着改配置先看清原因。5.2 PVC 一直 Pending这是出现频率最高的问题原因也比较集中。第一种是没有匹配的 PV 或 StorageClass可能是容量不够、访问模式不匹配、storageClassName 不一致第二种是动态供给的 Provisioner 没装好或权限不对PVC 事件里会出现provisioning failed的字样第三种是底层存储网络不通比如 NFS 服务端防火墙挡住了。排查时先看事件事件里通常会直接告诉你卡在哪一步。5.3 目录能挂载但写不进去Pod 能正常启动但往里写文件时提示 Permission denied多半是 NFS 权限问题。我遇到过的场景是NFS 服务端导出的目录属主是 root而容器进程以非 root 用户运行自然就没权限。测试期可以临时开no_root_squash但是更规范的思路是设置 fsGroup让系统自动调整卷的属组。也可以先手动在宿主机挂载一次写一写、删一删基本就能定位是网络问题还是权限问题。5.4 数据神秘消失如果你发现数据“没了”先别怀疑 Kubernetes 有 bug八成是回收策略搞的鬼。假设 PV 的persistentVolumeReclaimPolicy是 Delete当你随手删掉 PVC 或 PV 时底层存储的目录会被一并清除。我见过有同学在测试环境里为了清理资源删了 PV结果把 NFS 上一堆业务数据清光了。所以在关键 PV 上务必使用 Retain数据找回永远比数据丢失简单。5.5 常见存储问题速查表现象可能原因排查思路PVC 一直 Pending无匹配 PV、动态供给失败kubectl describe pvc查看事件Pod 一直 ContainerCreating后端存储不可达、依赖组件没装kubectl describe pod查看挂载报错挂载成功但写入报权限错误NFS 导出权限、属主不匹配、fsGroup 缺失宿主机手动挂载测试检查 exports 配置Pod 重启后数据丢失用的 emptyDir 或 hostPath 且节点漂移检查卷类型改用 PV/PVC类目删除后数据被清空回收策略为 Delete立刻检查 PV 的 reclaimPolicy生产务必备份StatefulSet 重建后数据不匹配volumeClaimTemplates 名称或 SC 对不上查看 PVC 名称和 Pod 序号是否一致6. 老司机的几点心得体会写到这里我想聊几句存储选型的真实经验。很多人一上来就追求 Ceph、Longhorn 这种分布式存储认为只有“重武器”才配得上生产环境但实际上存储选型最怕的是炫技。你的业务如果只是文件共享、静态资源读取、日志汇总NFS 完全够用你的数据库如果对 IOPS 和延迟有硬性要求再贵的共享文件系统也顶不住老老实实上块存储才是正解。存储选型的第一原则永远是把数据和业务场景对齐。我自己的遭遇可以当反面教材有段时间我为了省事把所有 PV 的回收策略都设成 Delete结果一次误操作删除 PVC 后底层大量数据直接没了。那次之后我立了一个规矩所有关键 PV 一律 Retain并且定期备份底层目录。平时觉得回收策略无关紧要真出事才知道它比任何花哨的高可用设计都实在。如果你刚看完这篇我建议你至少动手做两件事第一按第三节的 NFS 静态供给流程完整跑一遍重点观察 PV 状态从 Available 到 Bound 的变化第二按第四节部署一个带 volumeClaimTemplates 的 StatefulSet删掉 Pod 再验证数据。这些实验加起来只要一个小时但是你会把存储模型从“听说”变成“理解”。Kubernetes 这层抽象再复杂落到实际数据上说到底就是一句话让数据找到属于自己的家。