
做Kubernetes的人迟早要面对一个灵魂拷问Pod是出了名的“短命鬼”它一死里面的数据也跟着没了这谁受得了所以持久化存储成了绕不过去的一道坎。在众多存储方案里NFSPV/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 为什么偏偏是NFSPV/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-server192.168.1.100Ubuntu 24.04nfs-kernel-serverK8s节点1k8s-node1192.168.1.101Ubuntu 24.04kubeadm集群 nfs-commonK8s节点2k8s-node2192.168.1.102Ubuntu 24.04kubeadm集群 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.xexportfs -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需要开放portmapper111和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变成BoundPVC的spec.volumeName字段会写下对应的PV名称这个PVC从此就只能使用这一块PV。回收策略解决的是“PVC删了之后PV该怎么办”的问题。主要有三种Retain保留、Delete删除、Recycle回收清理后重用现已废弃。Retain的意思是PVC删除后PV里面的数据原封不动保留管理员手动决定是清理还是复用Delete则是PVC删除后PV连同数据一起删除常见于云盘这类动态存储的默认行为。对于NFS这种自建存储我个人强烈建议用Retain安全好控制。3.2 访问模式别再死记硬背了K8s里访问模式是存储卷设计里最容易搞混的概念之一。其实就三种常见模式访问模式缩写含义适用场景ReadWriteOnceRWO单节点读写块存储、云盘比如MySQL单实例ReadOnlyManyROX多节点只读配置分享、公共文件读取ReadWriteManyRWX多节点读写共享文件存储NFS最擅长的场景NFS天然支持RWX这是它比云盘强的地方。很多有状态应用比如多个副本的Web应用、文件上传服务要求所有副本同时读写同一份数据用NFS就没问题用云盘反而做不来。实际写YAML时要注意PV里声明的访问模式是它真正支持的能力PVC里声明的访问模式是应用想用的方式两者不一定完全相等但PVC要求的模式必须被PV支持。比如PV声明了RWX和RWOPVC声明RWO那这个PVC可以绑定这个PV。如果PVC声明了RWO而PV只声明了RWX在某些K8s版本里可能会匹配失败稳妥的写法是PV和PVC保持一致或者PV覆盖更全。3.3 storageClassNamePV和PVC的“配对暗号”理解了容量和访问模式之后storageClassName就是第三个约束条件。它相当于给存储资源分类的标签。比如你的集群里同时有NFS存储和Ceph存储运维把NFS那批PV标成nfs-storage把Ceph那批PV标成ceph-storage。应用想用NFS就在PVC里指定storageClassName: nfs-storageK8s只会去匹配这个类下面的PVCeph那批资源再充裕也不理会。这里有个新手常踩的大坑如果PVC里不写storageClassName能不能匹配成功取决于集群是否有默认StorageClass。很多通过云平台或kubeadm搭建的集群默认装了一个云盘类的StorageClassPVC如果没有显式指定类名就会走默认的动态供给跑到云盘上去了而不是你预期的NFS。反过来如果你显式指定了storageClassName: 表示“我不需要StorageClass只匹配静态PV”那也不是走动态供给。所以正确做法是PV和PVC都显式写上同一个storageClassName。你可以在PV里写storageClassName: nfsPVC里也写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.yamlapiVersion: 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用RetainPVC删除后数据保留宁可自己手动清理也不能让数据丢。storageClassName填nfs这是我自己定义的名字PV和PVC对得上就行。nfs这一段是核心。server是NFS服务端IPpath是服务端导出的共享目录这两个必须和/etc/exports里的一致。如果写错路径挂载的时候会报错或者挂上一个空目录。应用配置kubectl apply -f nfs-pv.yaml kubectl get pv输出中会看到一个名为nfs-pv、状态为Available的PV说明它正在等待被PVC认领。4.2 创建PVC并观察绑定行为然后创建PVC—nfs-pvc.yamlapiVersion: 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用上PVCPV和PVC绑定之后存储还只是“资源”必须挂到Pod里才能真正派上用场。创建测试Pod—nfs-test-pod.yamlapiVersion: 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那恭喜你整个NFSPV/PVC链路已经跑通了。这时候你才真正理解“持久化”的含义Pod可以死无数次数据只要放在NFS上就永不消失。这里有一个细节由于NFS是网络存储挂载到Pod里的目录本质上就是服务端目录的映射。你可以直接在NFS服务端创建文件Pod里立刻就能看到不需要任何额外操作。这就是网络文件系统最直观的好处。5. 踩坑记录与排错实战5.1 最常见的坑PVC一直PendingPVC卡在Pending是出现频率最高的报错基本每个刚上手的人都会撞一次。可能的原因很多我按排查顺序列一个速查表可能原因排查方法解决办法storageClassName不匹配kubectl get pv -o yaml看PV的storageClassName和PVC对比统一改成同一个类名容量不够kubectl get pvc -o yaml看status里的conditions信息扩大PV容量或缩小PVC申请访问模式不匹配检查PV和PVC的accessModes是否一致统一改成ReadWriteManyPV已被别的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一直处于ContainerCreatingkubectl 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 - nfsvers4 - rsize1048576 - wsize1048576hard表示NFS请求失败时会持续重试适合在对数据一致性要求高的场景下使用防止进程傻等rsize和wsize调大可以提升大数据块传输效率。不过这些参数对不同内核版本、不同业务模型响应都不一样建议先在测试环境验证再推生产。如果你发现NFS在高并发下性能明显下降可以考虑把共享目录拆成多个子目录、创建多个PV分摊负载别让所有Pod挤在同一个目录里抢锁。毕竟NFS的锁机制在多写多读场景下是有额外开销的。我自己在实际项目里的体会有几点。第一静态供给在中小团队里足够用别一上来就上动态供给复杂度完全没必要。第二权限问题一定要在服务端阶段就处理干净等到Pod起来再排查Permission denied排查链路长、浪费的时间也多。第三生产环境的persistentVolumeReclaimPolicy务必用Retain哪怕麻烦点手动清理数据也比PVC误删连带PV里的数据一起消失要强得多。最后再分享一个小技巧你可以把NFS服务端的共享目录统一放在一个根路径下比如/srv/nfs下面按应用建子目录然后为每个子目录创建独立的PV。这样不同应用的数据互相隔离后面要备份、迁移、清理都方便。K8s的存储设计本身就是“数据和应用生命周期分离”这个理念在NFSPV/PVC的组合里体现得最直接也最容易让新人理解到底什么才是真正的持久化。