最近一直在整理 k8s 学习笔记,前面几篇都在讲集群怎么搭、Pod 怎么调度、Service 怎么暴露,这些都属于"怎么把应用跑起来"的范畴。但真正用起来之后,你会立刻撞上另一个问题:容器是随时可以消失的,里面的数据怎么办?数据库重启丢数据、日志文件随 Pod 销毁、多个副本之间要共享同一个上传目录……这时候就必须引入持久化存储。而在所有网络存储方案里,NFS 是上手最快、最不需要改造底层架构的一个。
这篇笔记就是我在自己实验环境里完整配置 NFS 并让集群用上它的全过程,包含服务端搭建、客户端打通、PV/PVC 映射、真实挂载验证,以及几个我踩过的坑。如果你已经能把 Deployment 和 Service 玩明白,但还没搞过存储,这篇文章应该能帮你少走不少弯路。
1. 先想清楚:你的集群为什么需要一套网络共享盘
在动手敲命令之前,我建议先花两分钟想清楚 NFS 在你的集群里扮演什么角色。k8s 本身有各种存储抽象,比如 emptyDir、hostPath、PV/PVC,但它们的定位差异非常大。
emptyDir 是跟着 Pod 走的临时目录,Pod 一删文件就没了,适合做缓存之类的场景;hostPath 是把宿主机目录直接映射进容器,虽然数据在节点上留下了,但换个节点调度就找不到了,而且多个副本同时写同一路径非常容易出问题。真正需要的是那种"和节点无关、任何人都能访问"的存储,也就是网络存储。
NFS(Network File System)在这里的优势很直接:它就是一个服务端导出一块目录,客户端通过网络挂载进来,所有节点看到的是同一份数据。挂在 k8s 里之后,不管 Pod 漂移到哪个节点,挂载路径指向的都是同一块网络盘,数据天然共享。这对中小规模集群来说几乎是零成本方案,不需要搭分布式存储,不需要买商业存储设备,一台 Linux 机器就够用。
当然也要说清楚边界。NFS 不是万能的,它不适合做数据库主存储,因为网络文件系统和数据库对 IO 延迟、锁机制的要求不太一致,生产环境的数据库还是老老实实用云盘或分布式存储。NFS 最舒服的场景是:日志汇聚、上传文件共享、静态资源、临时分析数据、多人协作开发环境。所以下面所有操作,也都是按这个使用预期来配置的。
我这次实验环境规划如下,后面所有命令都基于这个拓扑:
| 角色 | 节点名 | IP 地址 | 用途 |
|---|---|---|---|
| NFS 服务端 | storage | 192.168.100.150 | 专门提供共享目录 |
| k8s master | master | 192.168.100.11 | 集群控制面 |
| k8s worker 1 | worker-1 | 192.168.100.21 | 运行应用 |
| k8s worker 2 | worker-2 | 192.168.100.22 | 运行应用 |
操作系统选的是 Ubuntu 22.04,k8s 版本 1.28 左右,不同发行版命令可能有点差异,但思路完全一样。
2. 服务端配置:一台机器作为存储池的关键细节
2.1 安装 NFS 服务端组件
在 storage 机器上,先更新软件源再装服务端包:
sudo apt update sudo apt install -y nfs-kernel-server装完之后可以确认一下服务状态:
sudo systemctl status nfs-server sudo systemctl enable nfs-serverUbuntu 上装了 nfs-kernel-server 之后服务会自动创建,systemctl enable 是确保重启机器之后依然自启。这一步没有任何坑,但如果你的机器是 CentOS/RHEL,包名是 nfs-utils,服务名是 nfs-server,原理相同。
2.2 规划共享目录与权限
我习惯单独建一个数据盘目录,而不是散落在根分区各处,方便后续管理:
sudo mkdir -p /data/k8s sudo chmod 1777 /data/k8s这里我用 1777 而不是普通的 777,是为了让任意客户端进程都能往里面写文件,同时带上 sticky bit 防误删。如果你想更严谨一点,也可以不用 1777,而是把目录 owner 改成 nobody:nogroup,再给 755 权限。具体看你的容器里跑的是什么用户。
需要解释一下为什么在 NFS 服务端这里不是简单粗暴地给 777:服务端权限、NFS 导出选项、客户端挂载权限三者是叠加关系,任何一层拒绝都不行。如果你在服务端就把权限卡死,后面排错会很痛苦。所以我偏向于服务端放宽,靠 NFS exports 选项和 ingress 网络策略来限制来源,这样权责更清晰。
2.3 编辑 /etc/exports,精确控制谁可以挂
/etc/exports 是 NFS 的核心配置文件,每行一条导出规则。我用的配置:
/data/k8s 192.168.100.0/24(rw,sync,no_subtree_check,no_root_squash)这一行拆开看:
- /data/k8s 是导出的目录
- 192.168.100.0/24 表示只允许这个网段的客户端挂载,比用 * 安全得多
- rw 允许读写
- sync 表示写请求必须落盘后才返回成功,速度比 async 稍慢,但不会断电丢数据
- no_subtree_check 减少目录层级变化时的额外检查,提升性能
- no_root_squash 是很多人在 k8s 环境里踩坑的关键,后面我会专门展开讲
写完配置文件后,执行:
sudo exportfs -rav输出类似:
exporting 192.168.100.0/24:/data/k8s可以用 showmount 检查是否导出成功:
sudo showmount -e localhost看到导出的路径就说明服务端配置完成了。这里再加一句,exportfs 命令每次改完 /etc/exports 都要执行,别改完文件不刷新就跑去客户端测试,然后一脸懵。
2.4 防火墙与网络放行
如果你机器上开了 ufw 防火墙,需要放行 NFS 相关端口。Ubuntu 上 NFS v4 通常只需要 2049 端口:
sudo ufw allow from 192.168.100.0/24 to any port nfs但有些环境下 rpcbind 还占着 111 端口,保险起见可以同时放行:
sudo ufw allow from 192.168.100.0/24 to any port 111如果你用的是云厂商安全组,记得在安全组规则里同样放行。这里忽略的话,服务端看着一切正常,客户端 mount 时就会卡在超时上。
3. 集群侧打通:所有节点补齐 NFS 客户端能力
3.1 为什么要每个节点都安装 nfs-common
这一步是很多人容易漏掉的。你可能会想:NFS 客户端工具是不是只要在需要挂载的机器上装就行?在普通 Linux 环境下确实是这样,但 k8s 里不一样。
k8s 的 Pod 调度是动态的,同一个 Deployment 的副本可能被调度到任意一个 worker 节点上。kubelet 在启动 Pod 之前,要根据 PV/PVC 的 spec 去执行实际的 mount 动作,而这个动作依赖节点上安装的 NFS 客户端工具。如果某个 worker 节点没装 nfs-common,Pod 恰好调度过去,kubelet 就会报挂载失败,Pod 一直处于 ContainerCreating。
所以在 master 和所有 worker 节点上都执行:
sudo apt install -y nfs-commonmaster 虽然不跑业务 Pod,但有些时候会有调试用的临时 Pod,或者你会用 kubectl debug 跳到节点上排查,装了没坏处。尤其是你后面可能部署一些需要动态创建 PV 的工具,控制面节点也可能参与挂载验证。
3.2 手动挂载测试,先把底层链路搞定
在 master 节点上先手动测试一下从 k8s 侧访问 NFS 服务端是否正常:
sudo mkdir -p /mnt/nfs-test sudo mount -t nfs 192.168.100.150:/data/k8s /mnt/nfs-test然后写个文件验证读写:
echo "hello k8s nfs" | sudo tee /mnt/nfs-test/test.txt cat /mnt/nfs-test/test.txt看到内容回显,说明服务端、客户端、网络三层链路都通了。这时候再卸载测试挂载:
sudo umount /mnt/nfs-test之所以强调先手动挂载测试,是因为这一步能帮你把问题隔离在最底层。很多 k8s 层面的报错,归根到底是 NFS 服务端或网络的问题,如果这些没验证过就直接上 PV,遇到问题后排查链路更长,人也更容易懵。
3.3 关于自动挂载和 fstab 的必要性
有些文章会建议把 NFS 挂载写进 /etc/fstab,让节点开机自动挂载。但在 k8s 环境里,我不推荐额外加 fstab 自动挂载。
原因很实际:kubelet 会自己处理 NFS volume 的挂载和卸载,你在 fstab 里再加一个手动挂载点,一方面占了服务端的连接数,另一方面如果服务端暂时不可用,节点开机时 fstab 挂载失败可能会影响系统启动流程。NFS 挂载交给 kubelet 就足够了,你所需要做的只是确保 nfs-common 装好,剩下的配置文件都由 PV 的 yaml 描述。
4. 在 K8s 里描述存储:静态 PV 和 PVC 的完整创建过程
4.1 先理解 PV、PVC 和 StorageClass 的关系
初次接触 k8s 存储的人,很容易被 PV、PVC、StorageClass 三个概念绕晕。我打个比方:PV 就像是一间已经装修好的实体仓库,PVC 是你在应用侧写的一张"我要一个多大空间、什么读写模式"的领料单,而 StorageClass 是自动建仓库的施工队。
NFS 服务端把 /data/k8s 目录导出来之后,它还只是一个 Linux 共享目录,k8s 并不知道它的存在。要让 k8s 用它,必须通过 PV 这个对象来声明:这里有一块存储,来源是某台 NFS 服务器的某个路径。然后 PVC 提出需求,k8s 会把满足条件的 PV 和 PVC 绑定在一起。Pod 最终引用的是 PVC,而不是直接写 NFS 地址。
这样设计的好处是应用不需要关心底层存储细节,换存储实现时只需要改 PV 层,Pod 的 yaml 可以完全不动。这也是"存储与计算解耦"的体现。
4.2 创建 NFS 类型的 PV
先创建一个静态 PV 文件,例如 pv-nfs.yaml:
apiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv spec: capacity: storage: 10Gi volumeMode: Filesystem accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain storageClassName: nfs-manual nfs: server: 192.168.100.150 path: /data/k8s创建并查看状态:
kubectl apply -f pv-nfs.yaml kubectl get pv nfs-pv刚开始 PV 状态是 Available,表示已经就绪、等待 PVC 来绑定。
这里解释几个关键字段:
- capacity.storage 写 10Gi,这个数字其实只是一个调度声明,NFS 本身不会真的截断到 10Gi,但你最好设置一个与实际规划相符的值,否则后面容量管理会混乱
- accessModes 用 ReadWriteMany,这是 NFS 最重要的卖点,可以多个节点同时读写;如果写了 ReadWriteOnce,某些调度场景下多个副本会被限制在同一个节点
- persistentVolumeReclaimPolicy 用 Retain,表示 PVC 删除后 PV 及其数据保留,需要人工处理;用 Delete 的话,某些存储插件会自动清理后端数据,NFS 静态配置下我建议 Retain,防止误删
- storageClassName 写 nfs-manual,这个字段必须和 PVC 里的一致,否则绑定会失败
- nfs.server 和 nfs.path 就是服务端 IP 和导出的绝对路径,务必与 /etc/exports 里的路径完全一致
4.3 创建 PVC 并验证绑定关系
PVC 文件 pvc-nfs.yaml:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc spec: accessModes: - ReadWriteMany storageClassName: nfs-manual resources: requests: storage: 5Gi执行:
kubectl apply -f pvc-nfs.yaml kubectl get pvc nfs-pvc kubectl get pv nfs-pv正常情况下,PVC 的 STATUS 会变成 Bound,PV 的状态也从 Available 变成 Bound。绑定背后的匹配逻辑有三条:容量要满足 PVC 的请求、访问模式要一致、storageClassName 要一致。哪条不满足都绑定不上。
有一点需要提醒:PVC 请求 5Gi,PV 给的 10Gi,这种"大于"是被允许的,绑定成功后 PVC 只能感知到 10Gi 里的逻辑容量,但实际写多少只有 NFS 真正来约束。反过来如果 PVC 请求 15Gi,那这个 10Gi 的 PV 就不能匹配。
4.4 为什么这里用静态配置而不是 StorageClass
你可能在别的文章里看到过 nfs-subdir-external-provisioner 这种工具,它可以配合 StorageClass 实现动态创建子目录、自动生成 PV。那确实更自动化,但我专门先把静态 PV 流程讲清楚,是因为动态方案本质上也是先创建一个 NFS 支持的 StorageClass,再由它内部的 provisioner 去代替你拼 PV 的 yaml。不先把静态 PV/PVC 的绑定规则弄明白,动态方案出了问题你根本不知道它在背后替你做了什么。
而且在我的实际项目里,静态 PV 完全不丢人:一块共享盘、多个应用目录划分,这在中大型集群里反而更可控。你想让 A 项目的数据独占某个子目录,就让它的 PV 指到 NFS 的 /data/k8s/a;想让 B 项目共享另一个目录,就再建一个 PV 指到 /data/k8s/b。数据目录的规划权在你手里,而不是让 provisioner 随机生成一堆业务含义不明的目录名。
5. 真实跑一次:Deployment 挂载 PVC 并验证持久化
5.1 编写挂载 PVC 的 Deployment
光有 PV/PVC 不挂到 Pod 上,存储还是死的。下面这个 Deployment 是我用来验证持久化的典型例子,两个副本同时往 NFS 同一路径写日志:
apiVersion: apps/v1 kind: Deployment metadata: name: nfs-demo spec: replicas: 2 selector: matchLabels: app: nfs-demo template: metadata: labels: app: nfs-demo spec: containers: - name: busybox image: busybox command: ["/bin/sh","-c"] args: - while true; do echo "$(date '+%Y-%m-%d %H:%M:%S') from $(hostname)" >> /data/access.log; sleep 10; done volumeMounts: - name: storage mountPath: /data volumes: - name: storage persistentVolumeClaim: claimName: nfs-pvc这里最关键的是 volumes 部分,它声明了使用叫 nfs-pvc 的 PVC,以及容器内 /data 目录映射到这个卷上。busybox 镜像很小,适合做验证,两个副本同时往同一个 access.log 文件追加内容,天然测试了 NFS 多副本并发写的能力。
执行:
kubectl apply -f nfs-demo.yaml kubectl get pods -l app=nfs-demo等 Pod 都进入 Running 后,到服务端看数据:
cat /data/k8s/access.log tail -f /data/k8s/access.log你会在文件里看到两个副本的主机名交替出现,说明它们确实共享了同一份文件。
5.2 验证数据不随 Pod 消失而丢失
这一步是持久化验证的重点,也是很多人心中存疑的地方。先强制删除所有副本:
kubectl delete pods -l app=nfs-demoDeployment 控制器会自动重新创建两个新的 Pod。等待它们 Running 后,再查看日志文件:
tail -n 5 /data/k8s/access.log你会看到新老数据是连续追加的,Pod 的 IP、名称、甚至节点都变了,但文件内容没有断。这就是网络存储相比 emptyDir 和 hostPath 的核心价值:数据生命周期和 Pod 解耦,只要 NFS 服务端还在,数据就在。
5.3 从 Pod 内部反向验证挂载信息
有时候你想确认 Pod 里的挂载来源,可以直接进容器看:
kubectl exec -it nfs-demo-xxxxxxxxx -- sh mount | grep nfs输出里会看到类似:
192.168.100.150:/data/k8s on /data type nfs这一条信息在排查问题时非常有用。如果以后有 Pod 一直处于 ContainerCreating,第一步就是看 describe 里的挂载失败信息,第二步就是确认节点上能不能手动 mount 通。
5.4 读写权限的实际检查
容器内默认以 root 用户运行,前面配置了 no_root_squash,所以理论上写文件没有障碍。但如果你部署的镜像里指定了非 root 用户(比如 securityContext.runAsUser: 1000),那就要确保 /data/k8s 以及它下面的子目录对该 UID 有写权限。
我曾经遇到过一个典型的权限坑:容器以 UID 1000 运行,NFS 服务端 /data/k8s 的属主是 root,权限是 755,结果 Pod 启动没问题,但一写文件就报 Permission denied。解决方式就是在服务端调整目录属主,比如 chown -R 1000:1000 /data/k8s,或者把目录权限改成 777。处于安全考虑,更推荐按实际容器 UID 来设置属主,而不是图省事全给 777。
6. NFS 在 K8s 环境里最容易翻的五个坑
6.1 root_squash 引发的权限黑洞
这是我在 k8s 环境里遇到最多的坑。NFS 默认开启了 root_squash,即客户端以 root 身份访问时,服务端会把它映射成匿名用户(nobody/nogroup),这本来是安全机制,防止客户端 root 在服务端为所欲为。
但 k8s 里很多容器默认就是 root 运行,它们写文件时被 root_squash 映射成 nobody,文件属主和权限立刻变得混乱,甚至直接 Permission denied。我教学环境里为了省心,在 /etc/exports 里加了 no_root_squash,让容器 root 保持 UID 0,写什么都能落盘。生产环境如果对安全要求高,可以按实际容器 UID 定制方案,但一定不要不做测试就默认不管这个选项。
6.2 回收策略 Retain 和 Delete 的语义混淆
如果你把 persistentVolumeReclaimPolicy 配成了 Delete,又用的是某些自动清理的存储类,那删除 PVC 后后端的 NFS 目录子文件可能被一并清理。但静态 NFS 场景下,Delete 其实不会真正删除 NFS 上的数据,只是把 PV 状态变 Released,这会让管理员误以为后端数据也被清了。
我更推荐统一用 Retain。删除 PVC 后 PV 变成 Released,数据还在,你可以人工备份再处理。等确认不需要了,再手动删 PV,然后到 NFS 服务端清理对应目录。注意,Released 状态的 PV 不能直接再次绑定,需要先删除再重建,绑定逻辑才干净。
6.3 mount 选项对故障恢复的影响
kubelet 挂载 NFS 时默认使用的选项是 hard 和 intr 一类的组合,但不同环境下表现可能不同。如果 NFS 服务端短暂不可用,hard 挂载会让客户端一直阻塞重试,进程不退出,看起来像"卡死";soft 挂载则可能让 IO 返回错误,应用反而能感知并处理。
在 k8s 场景里,我倾向于保留 hard 默认,因为很多应用没有完整的错误恢复逻辑,与其让写入静默失败,不如让 IO 阻塞等网络恢复。但这也意味着你必须有 NFS 服务端的监控,否则服务端彻底挂了,所有关联 Pod 都会阻塞在 IO 上,删除都删不掉。这种情况不是 mount 选项能解决的,而是 HA 层面的问题。
6.4 NFS 服务端单点故障,要不要做 HA
NFS 服务端是一台机器,它就天然是一个单点。机器一挂,所有挂载它的 Pod 都受影响。我在前面的方案里没有引入 DRBD、keepalived 这些 HA 架构,因为实验环境不值得。但如果在生产环境里业务对存储连续性要求高,你至少要考虑:
- NFS 服务端做冗余,或者迁移到云上的托管 NAS
- 对 NFS 服务端做定期备份
- 监控挂载点可用性,及时告警
在这个标题范围内,我不会展开讲 HA,但你想用 NFS 承担更重要的业务时,一定要意识到它只是"方便",不是"高可用"。
6.5 网络分区场景下的挂载卡死与 Pod 删除困难
最后一个坑是最难排查的:节点与 NFS 服务端之间网络短暂中断,Pod 里的进程正卡在写文件的系统调用上,此时你执行 kubectl delete pod 会发现 Pod 一直 Terminating,因为容器无法响应 SIGTERM。
这种情况不要慌,先在 NFS 服务端确认网络恢复了,再强制删除:
kubectl delete pod <pod-name> --force --grace-period=0但如果底层网络一直没恢复,强制删除可能也不生效,因为节点 kubelet 根本联系不上。这也是为什么前面说 NFS 服务端必须有监控,最好服务端和集群节点在同一个可靠内网里。跨公网或跨地域挂 NFS,体验会很痛苦。
写在最后的一点实际操作体会
整套流程走下来,我的体会是:NFS 本身不复杂,但 k8s 里的存储链路涉及服务端、客户端、PV 定义、权限、网络好几个层面,任何一层有问题都会表现为 Pod 起不来或写入失败。所以我现在每次配置新的共享目录,都会严格按这个顺序操作——服务端先 exportfs 检查、客户端手动 mount 验证、再建 PV/PVC、最后才跑业务 Pod。这一步一步隔离排查的方式,能省掉大量回头查问题的时间。
有时候想把某个共享目录单独分配给一个应用,又不想改 NFS 服务端路径,我就直接用 /data/k8s 下建子目录的方式,然后 PV 里的 path 指向对应子目录。这样整个集群共用一个 NFS 服务端,但不同业务之间数据互相隔离,管理起来也很方便。
希望这篇笔记能帮你顺利把 k8s 的存储基础补齐。后续如果再深入,我可能会写 NFS 动态供给方案,或者聊聊 NFS 和其他存储方案的性能对比,到时候再继续更新。