做Kubernetes的人迟早要面对一个灵魂拷问:Pod是出了名的“短命鬼”,它一死里面的数据也跟着没了,这谁受得了?所以持久化存储成了绕不过去的一道坎。在众多存储方案里,NFS+PV/PVC这套组合拳在国内中小团队里出镜率极高——上手快、成本低、够用。这篇博文我打算把从零搭建NFS服务端,到在K8s里创建PV/PVC,再到Pod成功挂载使用的完整链路掰开揉碎讲一遍。不管你是刚接触K8s的新手,还是已经在生产环境里折腾过一阵子的老手,这篇文章里都有你能直接拿去用的东西。
1. 这套方案到底解决什么问题
1.1 先搞明白Pod为什么会丢数据
先问一个问题:为什么K8s里跑应用非得配存储?因为Pod的设计理念就是“用完即弃”。Deployment滚动更新时旧Pod被销毁,节点宕机时Pod被重新调度到别的机器,HPA缩容时多余的副本直接杀掉——在这些场景里,容器文件系统里的数据会跟着容器一起消失。这不是bug,是特性。
有人可能会说:“那我用Docker的volume不就行了?”行,但Docker的volume默认绑定在宿主机本地目录。容器被调度到另一台节点之后,新节点上根本没有那个本地目录,数据照样丢。所以跨节点共享才是K8s环境里真正需要解决的存储问题。NFS天生就是干这个的——它把文件系统通过网络共享出去,任何节点只要装了客户端都能挂载同一块远程目录,数据天然就跨节点了。
1.2 为什么偏偏是NFS+PV/PVC这套组合
K8s里能用的存储方案不少,有本地卷、hostPath、云厂商的云盘、Ceph、GlusterFS等等。但NFS在中小团队里始终有一席之地,原因就三个字:太省事。
云盘虽然稳定,但它是云厂商绑定的,换机房、换云就傻眼,而且一台云盘默认只能挂载到一个节点上,做不了多读多写。Ceph功能强大,但部署一套Ceph集群本身就是个大工程,运维成本直接拉满。hostPath最便宜,但数据绑定宿主机,Pod一旦漂移就找不回来数据。相比之下,NFS只需要一台机器(虚拟机也行)把目录导出,所有K8s节点都挂得上去,支持ReadWriteMany多节点同时读写,性能和稳定性对绝大多数业务来说完全够用。
PV/PVC则是K8s里对存储的一层抽象。有了这层抽象,应用开发者不需要知道存储到底是从NFS来的还是从云盘来的,只需声明“我要10G空间、支持多节点读写”,剩下的匹配工作由K8s完成。这种解耦很适合团队协作:运维管存储资源,开发管应用声明。
1.3 PV和PVC的关系:别把它想复杂了
不少人刚开始学PV/PVC时容易绕晕,我建议用一个生活化的类比来理解:把PV当作仓库里的“库存商品”,PVC当作“采购申请单”。
管理员(运维)先往仓库里入库一批商品,也就是创建PV,每件商品都标好了规格:容量多大、支持几人同时用(访问模式)、属于哪个货架分类(storageClassName)。应用开发者(或者K8s里的工作负载)不需要关心仓库里有什么,只需要提交一份采购申请——创建PVC,写明“我要什么规格的货、需要多少容量”。K8s的调度器会在后台帮你自动匹配一张符合条件的PV,把PVC和PV绑定在一起。绑定成功后,Pod就可以通过PVC直接使用这块存储了。
记住这个思路,后面所有YAML配置都不会把你绕晕。
2. 环境准备与NFS服务端搭建
2.1 实验环境一览
动手之前先把环境说清楚。我这套实验环境是三台Ubuntu 24.04的机器:一台单独做NFS服务端,另外两台是K8s集群的节点。你完全可以用一台机器同时跑NFS服务和单节点K8s练手,不影响理解原理。下面是我用的环境参考:
| 角色 | 主机名 | IP地址 | 系统版本 | 软件 |
|---|---|---|---|---|
| NFS服务端 | nfs-server | 192.168.1.100 | Ubuntu 24.04 | nfs-kernel-server |
| K8s节点1 | k8s-node1 | 192.168.1.101 | Ubuntu 24.04 | kubeadm集群 + nfs-common |
| K8s节点2 | k8s-node2 | 192.168.1.102 | Ubuntu 24.04 | kubeadm集群 + nfs-common |
需要强调的是,K8s集群里的每个节点(包括master节点,如果master也可能会调度Pod)都必须安装nfs-common客户端包。光装了NFS服务端、节点缺客户端的话,kubelet在挂载时会直接报错,后面排错的时候会非常痛苦。
2.2 Ubuntu 24.04上搭建NFS服务端
Ubuntu 24.04安装NFS服务端非常简单,三条命令的事:
# 更新软件源 sudo apt update # 安装NFS服务端 sudo apt install -y nfs-kernel-server # 创建要共享的目录 sudo mkdir -p /srv/nfs/k8s # 给目录设置权限,避免后续权限问题 sudo chown nobody:nogroup /srv/nfs/k8s sudo chmod 777 /srv/nfs/k8s这里把共享目录的属主设为nobody:nogroup、权限设为777,是为了避免后面一堆权限报错。注意这不是什么好习惯,生产环境应该按具体的用户和需求收紧权限,但对于学习环境、前期验证环境,这样的配置能让你躲过最常见的那几个坑。
创建好目录之后,还需要把目录“导出”给客户端。这一步通过修改/etc/exports文件来实现:
sudo vim /etc/exports在文件末尾追加一行:
/srv/nfs/k8s *(rw,sync,no_subtree_check,no_root_squash)然后生效配置并确认导出状态:
sudo exportfs -ra sudo exportfs -v如果看到类似/srv/nfs/k8s <world>的输出,说明导出成功。这里值得一提:Ubuntu 24.04默认的NFS版本是4.x,exportfs -v的输出里能看到NFSv4相关的导出信息,旧版本NFSv3也保持兼容,这对K8s的挂载来说通常不是问题。
2.3 exports配置文件里的门道
/etc/exports里那一行配置不算长,但每个参数都有讲究。我第一次配的时候随手抄了个模板没细想,后面被坑得不轻。这里拆开讲一下:
*(rw,sync,no_subtree_check,no_root_squash)里的*表示允许所有网段的客户端访问。生产环境建议改成具体的网段或IP,比如192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash),这样更安全,也能避免外部机器随意挂载。rw表示客户端可读写。如果只配了ro,那K8s的Pod挂载后只能读不能写,很多应用会直接启动失败。sync表示服务端在响应写请求之前,先把数据刷到磁盘上。对应的还有async,性能好但断电丢数据的风险高。数据库这类场景必须用sync,日志这类对丢一点数据不计较的场景可以用async换性能。no_subtree_check主要是减少一些目录权限检查带来的性能开销。对于共享目录嵌套层级较深的场景,建议加上这个参数。no_root_squash是K8s场景下最关键的参数。默认情况下NFS会把客户端的root用户压缩成nobody用户(root_squash),而很多容器的内部进程恰恰是以root身份运行的。如果你不开no_root_squash,容器里往挂载目录写文件时会被强制降权,结果就是各种Permission denied。开了它,root的权限在NFS服务端也能生效,保证容器能正常读写。
2.4 客户端连接前的最后检查
服务端配置完之后,K8s节点的客户端工具要装好。每台节点都执行:
sudo apt update sudo apt install -y nfs-common然后用手动挂载的方式验证连通性:
# 在K8s节点上执行 sudo mkdir -p /mnt/test-nfs sudo mount -t nfs 192.168.1.100:/srv/nfs/k8s /mnt/test-nfs # 在挂载目录里写个测试文件 echo "nfs ok" | sudo tee /mnt/test-nfs/test.txt # 回到服务端看文件是否出现 ls -l /srv/nfs/k8s/test.txt如果服务端能看到test.txt,说明网络、NFS服务、权限都没问题。测完记得卸载:
sudo umount /mnt/test-nfs这一步特别值得做。很多PVC挂载失败的问题,其实在K8s介入之前就已经暴露了——但如果你没有提前手动验证,后面的报错信息会把你带到沟里去。
还有一个小坑:Ubuntu 24.04以及很多现代发行版默认开启了防火墙。如果K8s节点挂载NFS时一直hang住,先检查服务端的防火墙是否放行了NFS相关端口。传统NFS v3需要开放portmapper(111)和mountd等端口,NFSv4通常只需要2049端口。我测试环境为了方便直接让NFS服务走内网,没开防火墙,但生产环境务必把端口开对。
# 在NFS服务端上检查防火墙状态 sudo ufw status # 如果开启了,放行2049端口(NFSv4)和111端口(portmapper) sudo ufw allow 2049/tcp sudo ufw allow 111/tcp3. PV/PVC核心机制解析
3.1 PV的完整生命周期
PV在K8s里是一个集群级别的资源,它独立于任何命名空间而存在。它的一生大致经历四个阶段:Provision(供给)、Bind(绑定)、Reclaim(回收)。
供给有两种方式:静态供给和动态供给。静态供给就是管理员手工创建一批PV,提前准备好容量,等着PVC来领;动态供给则是通过StorageClass和对应的Provisioner插件,在PVC创建的那一刻自动生成PV,不需要管理员操心。资源创建出来之后,PV会处于Available状态,等待被PVC匹配绑定。
绑定是PVC和PV之间的“牵手”动作。当一个PVC被创建后,K8s会扫描集群里所有符合条件的PV,要求是:容量满足、访问模式匹配、storageClassName对得上。绑定成功后PV的状态从Available变成Bound,PVC的spec.volumeName字段会写下对应的PV名称,这个PVC从此就只能使用这一块PV。
回收策略解决的是“PVC删了之后,PV该怎么办”的问题。主要有三种:Retain(保留)、Delete(删除)、Recycle(回收清理后重用,现已废弃)。Retain的意思是PVC删除后PV里面的数据原封不动保留,管理员手动决定是清理还是复用;Delete则是PVC删除后PV连同数据一起删除,常见于云盘这类动态存储的默认行为。对于NFS这种自建存储,我个人强烈建议用Retain,安全,好控制。
3.2 访问模式:别再死记硬背了
K8s里访问模式是存储卷设计里最容易搞混的概念之一。其实就三种常见模式:
| 访问模式 | 缩写 | 含义 | 适用场景 |
|---|---|---|---|
| ReadWriteOnce | RWO | 单节点读写 | 块存储、云盘,比如MySQL单实例 |
| ReadOnlyMany | ROX | 多节点只读 | 配置分享、公共文件读取 |
| ReadWriteMany | RWX | 多节点读写 | 共享文件存储,NFS最擅长的场景 |
NFS天然支持RWX,这是它比云盘强的地方。很多有状态应用(比如多个副本的Web应用、文件上传服务)要求所有副本同时读写同一份数据,用NFS就没问题,用云盘反而做不来。
实际写YAML时要注意,PV里声明的访问模式是它真正支持的能力,PVC里声明的访问模式是应用想用的方式,两者不一定完全相等,但PVC要求的模式必须被PV支持。比如PV声明了RWX和RWO,PVC声明RWO,那这个PVC可以绑定这个PV。如果PVC声明了RWO而PV只声明了RWX,在某些K8s版本里可能会匹配失败,稳妥的写法是PV和PVC保持一致或者PV覆盖更全。
3.3 storageClassName:PV和PVC的“配对暗号”
理解了容量和访问模式之后,storageClassName就是第三个约束条件。它相当于给存储资源分类的标签。比如你的集群里同时有NFS存储和Ceph存储,运维把NFS那批PV标成nfs-storage,把Ceph那批PV标成ceph-storage。应用想用NFS就在PVC里指定storageClassName: nfs-storage,K8s只会去匹配这个类下面的PV,Ceph那批资源再充裕也不理会。
这里有个新手常踩的大坑:如果PVC里不写storageClassName,能不能匹配成功取决于集群是否有默认StorageClass。很多通过云平台或kubeadm搭建的集群默认装了一个云盘类的StorageClass,PVC如果没有显式指定类名,就会走默认的动态供给,跑到云盘上去了,而不是你预期的NFS。反过来,如果你显式指定了storageClassName: "",表示“我不需要StorageClass,只匹配静态PV”,那也不是走动态供给。
所以正确做法是:PV和PVC都显式写上同一个storageClassName。你可以在PV里写storageClassName: nfs,PVC里也写storageClassName: nfs,这样逻辑清晰,后期排错也省心。
3.4 静态供给和动态供给怎么选
静态供给适合存储需求相对稳定、规模不大的场景。运维提前建好几个PV,容量固定,应用按需申领,简单可控。缺点是如果PV容量没规划好,小容量不够、大容量浪费,扩展还要人工介入。
动态供给适合存储需求变化频繁、PV数量多的场景。它的原理是部署一个NFS Provisioner(比如社区常用的nfs-subdir-external-provisioner),它会监听PVC的创建事件,然后自动在NFS服务端建对应子目录、创建PV并完成绑定。应用只需要创建PVC,剩下的全自动化。
如果你刚开始学,建议先把静态供给练熟,把PV/PVC的底层逻辑吃透再上动态供给。动态供给虽然方便,但它引入的自动化也意味着出问题时定位更难——你得去查Provisioner的日志,而不是只看PV/PVC的状态。
4. 从YAML书写到Pod挂载的完整实操
4.1 创建PV:一步步写YAML
进入实操环节。我先创建PV,所有YAML我建议都保存成文件,方便后续维护。先创建nfs-pv.yaml:
apiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv labels: type: nfs spec: capacity: storage: 10Gi volumeMode: Filesystem accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain storageClassName: nfs nfs: path: /srv/nfs/k8s server: 192.168.1.100逐字段解释一下:capacity.storage声明了PV的容量为10Gi,这只是一个“可用容量”的声明,K8s并不会去NFS服务端校验这个目录到底有多大,PVC申请容量时以此为判断依据。volumeMode用默认的Filesystem即可,如果写成Block那就是裸设备模式,对NFS场景不适用。accessModes这里用ReadWriteMany,这是NFS的核心优势。persistentVolumeReclaimPolicy用Retain,PVC删除后数据保留,宁可自己手动清理也不能让数据丢。storageClassName填nfs,这是我自己定义的名字,PV和PVC对得上就行。
nfs这一段是核心。server是NFS服务端IP,path是服务端导出的共享目录,这两个必须和/etc/exports里的一致。如果写错路径,挂载的时候会报错或者挂上一个空目录。
应用配置:
kubectl apply -f nfs-pv.yaml kubectl get pv输出中会看到一个名为nfs-pv、状态为Available的PV,说明它正在等待被PVC认领。
4.2 创建PVC并观察绑定行为
然后创建PVC—nfs-pvc.yaml:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc namespace: default spec: accessModes: - ReadWriteMany resources: requests: storage: 10Gi storageClassName: nfs这里是应用开发者写得最多的一段配置。namespace: default表示这个PVC只存在于default命名空间。accessModes和storageClassName必须和PV匹配。resources.requests.storage申请10Gi,要求和PV容量一致或小于等于PV容量都行。
执行:
kubectl apply -f nfs-pvc.yaml kubectl get pvc kubectl get pv正常情况下PVC的状态会从Pending变成Bound,对应的PV状态也会从Available变成Bound。注意观察两个信息:PVC的VOLUME列会显示绑定的PV名字;PV的CLAIM列会显示被哪个命名空间下的PVC占用了。这说明K8s已经完成了匹配和绑定。
如果PVC一直卡在Pending,别慌,先检查是不是storageClassName写错了、容量是否超过了PV、访问模式是否匹配,后面第5章我会专门讲排错。
4.3 让Pod用上PVC
PV和PVC绑定之后,存储还只是“资源”,必须挂到Pod里才能真正派上用场。创建测试Pod—nfs-test-pod.yaml:
apiVersion: v1 kind: Pod metadata: name: nfs-test-pod spec: containers: - name: nginx image: nginx:alpine ports: - containerPort: 80 volumeMounts: - name: nfs-storage mountPath: /usr/share/nginx/html volumes: - name: nfs-storage persistentVolumeClaim: claimName: nfs-pvc这里有两个关键字段。volumeMounts声明容器内部哪个目录挂载存储,把NFS共享目录挂载到nginx默认的站点根目录/usr/share/nginx/html,这样写入这个目录的文件就能通过nginx直接访问。volumes定义Pod级别的卷,它引用了前面创建的PVCnfs-pvc。
执行:
kubectl apply -f nfs-test-pod.yaml kubectl get pod nfs-test-pod -o wide等Pod变成Running状态后,就可以验证挂载是否生效了。
4.4 数据持久化验证:删掉Pod也不怕
挂载是否生效、数据是否持久化,必须用实际测试来验证,不能只看状态。我在Pod里写一个测试文件,然后删掉Pod重建,看看文件还在不在。
# 进入Pod内部,写一个测试文件 kubectl exec -it nfs-test-pod -- /bin/sh echo "hello nfs persistent" > /usr/share/nginx/html/index.html exit # 在K8s节点上直接查看NFS里的文件 cat /srv/nfs/k8s/index.html上面这一步如果执行成功,说明Pod写入的数据已经落到了NFS服务端。接下来故意销毁这个Pod,再创建一个新Pod使用同一个PVC,验证数据是否还在:
kubectl delete pod nfs-test-pod kubectl apply -f nfs-test-pod.yaml # 等待Pod Running后,再查看文件内容 kubectl exec -it nfs-test-pod -- cat /usr/share/nginx/html/index.html如果还是输出hello nfs persistent,那恭喜你,整个NFS+PV/PVC链路已经跑通了。这时候你才真正理解“持久化”的含义:Pod可以死无数次,数据只要放在NFS上,就永不消失。
这里有一个细节:由于NFS是网络存储,挂载到Pod里的目录本质上就是服务端目录的映射。你可以直接在NFS服务端创建文件,Pod里立刻就能看到,不需要任何额外操作。这就是网络文件系统最直观的好处。
5. 踩坑记录与排错实战
5.1 最常见的坑:PVC一直Pending
PVC卡在Pending是出现频率最高的报错,基本每个刚上手的人都会撞一次。可能的原因很多,我按排查顺序列一个速查表:
| 可能原因 | 排查方法 | 解决办法 |
|---|---|---|
| storageClassName不匹配 | kubectl get pv -o yaml看PV的storageClassName,和PVC对比 | 统一改成同一个类名 |
| 容量不够 | kubectl get pvc -o yaml看status里的conditions信息 | 扩大PV容量或缩小PVC申请 |
| 访问模式不匹配 | 检查PV和PVC的accessModes是否一致 | 统一改成ReadWriteMany |
| PV已被别的PVC绑定 | kubectl get pv看PV状态是否Bound | 新建更多PV或复用已有PV |
| 集群没有匹配的StorageClass | 检查集群是否存在动态供给或静态PV | 明确storageClassName并创建对应PV |
排查的最快方式是看事件信息:
kubectl describe pvc nfs-pvcEvents字段的末尾通常会直接告诉你卡住的原因,比如waiting for a volume to be created, either by external provisioner or manually created。这句只是说“还没匹配到”,具体原因还要结合PV列表来判断。
5.2 权限问题:Permission denied的真相
Pod能启动,但往里写文件时报Permission denied,这也是高频问题。常见的根因是NFS的root_squash没关闭,容器里的root权限被服务端降级成nobody。此时最简单有效的解决方案是在/etc/exports里加上no_root_squash,然后重新导出:
sudo exportfs -ra如果已经加了no_root_squash还报权限问题,那就要检查共享目录本身的属主和权限。我之前遇到过一种情况:服务端共享目录是root所有,权限是755,容器内进程以非root用户运行,写文件时没有写权限。解决办法是把目录属主改为容器运行用户的UID,或者放宽目录权限:
sudo chown nobody:nogroup /srv/nfs/k8s sudo chmod 777 /srv/nfs/k8s用777本身安全性不高,但在内网环境里快速验证链路是没问题的。生产环境建议用更细粒度的权限控制,比如给容器指定fsGroup,让K8s自动调整挂载目录属组。
5.3 挂载hang住与超时排查
Pod一直处于ContainerCreating,kubectl describe pod里看到类似FailedMount的信息,说挂载超时或者无法连接。这种问题的典型原因是网络不通或者NFS服务没起来。
排查顺序是:
- 先在节点上手动执行
mount -t nfs 192.168.1.100:/srv/nfs/k8s /mnt/test,如果直接hang住,说明节点到服务端的网络或NFS服务有问题。 - 检查服务端的
systemctl status nfs-kernel-server是否正常,不正常就重启并查看日志。 - 检查防火墙端口是否开放,特别是NFSv4的2049端口和portmapper的111端口。
- 确认
/etc/exports里的共享路径是否写错。如果路径写错,挂载时会报No such file or directory。
NFS挂载超时还有个容易被忽略的原因:客户端和服务端NFS版本不一致。如果你在节点上手动指定了NFSv3挂载成功,但K8s默认走NFSv4却失败,就要检查服务端是否启用了NFSv4支持。Ubuntu 24.04的nfs-kernel-server默认同时支持v3和v4,一般不会出问题,但老版本系统要注意。
5.4 性能调优经验谈
NFS用起来方便,但性能确实不如本地盘,这是物理限制。在实践中尽可能从配置和业务层面优化:
- 优先用
async还是sync?数据安全优先就用sync,性能优先且能容忍少量数据丢失才用async。MySQL这类数据库永远别上async。 - 网络层面,NFS走千兆网和走万兆网体验天差地别。如果业务对I/O有要求,至少让K8s节点和NFS服务端跑在同一二层网络,降低延迟。
- 调整客户端的挂载参数。在PV的
nfs字段里可以加一些选项吗?实际上PV的NFS配置里没有直接暴露mount options的字段,想要调优需要借助StorageClass的mountOptions,动态供给场景下可以用:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-sc provisioner: nfs-subdir-external-provisioner parameters: archiveOnDelete: "true" mountOptions: - hard - nfsvers=4 - rsize=1048576 - wsize=1048576hard表示NFS请求失败时会持续重试,适合在对数据一致性要求高的场景下使用,防止进程傻等;rsize和wsize调大可以提升大数据块传输效率。不过这些参数对不同内核版本、不同业务模型响应都不一样,建议先在测试环境验证再推生产。
- 如果你发现NFS在高并发下性能明显下降,可以考虑把共享目录拆成多个子目录、创建多个PV分摊负载,别让所有Pod挤在同一个目录里抢锁。毕竟NFS的锁机制在多写多读场景下是有额外开销的。
我自己在实际项目里的体会有几点。第一,静态供给在中小团队里足够用,别一上来就上动态供给,复杂度完全没必要。第二,权限问题一定要在服务端阶段就处理干净,等到Pod起来再排查Permission denied,排查链路长、浪费的时间也多。第三,生产环境的persistentVolumeReclaimPolicy务必用Retain,哪怕麻烦点手动清理数据,也比PVC误删连带PV里的数据一起消失要强得多。
最后再分享一个小技巧:你可以把NFS服务端的共享目录统一放在一个根路径下,比如/srv/nfs下面按应用建子目录,然后为每个子目录创建独立的PV。这样不同应用的数据互相隔离,后面要备份、迁移、清理都方便。K8s的存储设计本身就是“数据和应用生命周期分离”,这个理念在NFS+PV/PVC的组合里体现得最直接,也最容易让新人理解到底什么才是真正的持久化。