拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

K8s持久化存储核心:PV/PVC/StorageClass与NFS实战

K8s持久化存储核心:PV/PVC/StorageClass与NFS实战

这个系列写到现在,我觉得真正难啃的骨头才刚上桌: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 和 hostPath

Kubernetes 里最简单的卷是 emptyDir。它可以看作一个临时目录,生命周期跟 Pod 一致,Pod 还在,目录就在;Pod 被删,目录跟着消失。它的典型用途是同一个 Pod 内多个容器共享数据,比如日志采集场景:业务容器把日志写入 emptyDir,sidecar 容器读出来转发给日志系统。下面是一个最简单的 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 后来引入了 PV(PersistentVolume)和 PVC(PersistentVolumeClaim),把存储资源的“供”和“求”彻底分开。你可以把 PV 想象成仓库里已经备好的商品,PVC 是用户提交的采购订单,而 StorageClass 则是根据订单自动补货的智能仓库系统。 Pod 只负责声明“我要用这块存储”,至于存储从哪来、是本地盘还是网络盘,Pod 不需要关心。

这套设计解决了两个核心问题:一是存储生命周期与 Pod 解耦,Pod 删了,PV 里的数据还在;二是存储管理与业务解耦,集群管理员统一维护底层存储资源,业务开发者只写 PVC 声明就行。下面的表格是三种卷的直观对比,方便新手上路时建立全局印象。

卷类型数据生命周期多节点共享典型场景风险点
emptyDir跟随 Pod不支持容器间临时共享Pod 删除即丢
hostPath跟随节点不支持单机调试调度跨节点后数据不可用
PV/PVC独立于 Pod取决于后端存储生产环境有状态服务需要理解绑定与回收逻辑

2. 把核心概念一次性讲透:PV、PVC、StorageClass

2.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/static001

capacity 声明存储容量,这里写 10Gi;accessModes 是访问模式,后面单独讲;persistentVolumeReclaimPolicy 是回收策略,决定 PVC 被删除后 PV 里的数据怎么处理,这个字段的重要性远超你的想象,很多生产事故都出在它身上;storageClassName 用来匹配 StorageClass,如果我们想强制使用静态 PV 而不是动态供给,这里要显式写成空字符串。

PV 的完整生命周期可以用四个状态概括:Available(空闲待绑定)、Bound(已被 PVC 绑定)、Released(绑定 PVC 已删、PV 等待管理员回收)、Failed(回收失败,卷不可用)。新创建的 PV 会进入 Available,一旦有 PVC 匹配成功就变为 Bound;PVC 删除后,PV 进入 Released,注意它不会自动回到 Available,需要管理员手动清理或重新导入数据,这个机制看着繁琐,其实是保护机制,防止前面的 PVC 刚删、后面的 PVC 就立刻把数据当成自己的。

2.2 PVC 的请求与绑定规则

PVC 是用户视角的存储申请,写法比较直观:

apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-nginx spec: accessModes: - ReadWriteMany storageClassName: "" resources: requests: storage: 5Gi

PVC 创建之后,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 访问模式与回收策略

访问模式决定了卷能被多少个节点和多少种方式使用,常用有三种:

  • ReadWriteOnce(RWO):卷只能被单个节点以读写方式挂载。注意这里的“单节点”不是说“单个 Pod”,同一个节点上的多个 Pod 其实可以同时使用同一个 RWO 卷,这跟很多人理解的“单 Pod”不一样。
  • ReadOnlyMany(ROX):卷可以被多个节点同时以只读方式挂载,适合共享配置、共享只读资源。
  • ReadWriteMany(RWX):卷可以被多个节点同时以读写方式挂载,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 "<h1>hello 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.100

showmount能看到服务端导出的路径列表,如果看不到,先检查防火墙和/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必须设为空字符串。然后创建 PVC:

kubectl apply -f pv-nfs-001.yaml kubectl get pv kubectl apply -f pvc-nginx.yaml kubectl get pv kubectl get pvc

第一次执行后,PV 状态应该是 Available;PVC 创建后,再查看就能看到 PV 状态变为 Bound,PVC 的 STATUS 也是 Bound。如果 PVC 一直 Pending,不要慌,用kubectl describe pvc pvc-nginx看底部 Events。

随后把 PVC 挂进一个 nginx Deployment:

apiVersion: 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 服务端预写入的<h1>hello from nfs storage</h1>。删除 Pod 后,Deployment 会自动创建新 Pod,因为 PVC 还是同一个,NFS 数据自然原封不动。这种朴素的验证会带给你一种安全感,也是理解后续所有有状态服务的基础。

4. 进阶玩法:动态供给与 StatefulSet 实战

4.1 动态供给的开箱体验

静态 PV 需要手动创建好库存才能绑定,集群规模一大,管理员就要不停建 PV,这显然不是长久之计。动态供给的思路是:用户写一个带StorageClassName的 PVC,StorageClass 背后的 Provisioner 收到请求后,自动到后端存储创建目录,然后生成 PV 完成绑定,全程无需人工介入。

部署一个基于 NFS 的动态供给组件,最简单的方式是用 Helm 安装nfs-subdir-external-provisioner:

helm 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.server=192.168.1.100 \ --set nfs.path=/data/nfs/k8s \ --set storageClass.name=nfs-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 会根据模板自动创建 PVC,PVC 的名字会带上 Pod 序号,比如www-web-0、www-web-1。即使 Pod 被删除重建,它依然会绑定原来的 PVC,天然实现“人换衣服不换鞋”的效果。

4.3 用 StatefulSet 验证数据稳定性

下面是我实际用过的例子,一个非常简单的 nginx StatefulSet,还配了一个 Headless Service:

apiVersion: 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.txt

StatefulSet 的卷模板会自动匹配旧的 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 这层抽象再复杂,落到实际数据上,说到底就是一句话:让数据找到属于自己的家。

返回列表