NFS 持久化存储这事儿,我其实纠结了很久才决定写。因为网上讲 Kubernetes 存储的文章一抓一大把,但大多数都是“照着敲就能通”的程度,一旦遇到版本坑、权限坑、回收策略坑,就没人告诉你该怎么爬出来了。这次趁着在 CentOS 10 环境里部署新集群的机会,我把从 NFS 服务端搭建到 Kubernetes 动态创建 PVC 的完整链路重新捋了一遍,踩了几个不算深但足够让人头疼的坑,记录下来分享给大家。
先说结论:如果你的集群环境是裸金属或者自建的虚拟化平台,而且对存储性能没有那么极致的要求,NFS 依然是做持久化存储最省心的方案。它不像 Ceph 那样需要单独的监控节点和复杂的网络配置,也不用像 Local PV 那样去操心节点亲和性和数据调度,只要一台 Linux 机器、一块还不错的硬盘、加上 Kubernetes 内置的 NFS 驱动或者社区维护的 external provisioner,就能低成本地获得一个还算好用的存储后端。
这篇博文会从环境规划、NFS 服务端部署、Kubernetes 侧配置 StorageClass、到最终的用例验证和问题排查,完整走一遍流程。所有操作都是我在 CentOS 10 集群上的实测记录,你会看到具体命令、关键参数的含义解释,还有我踩过的坑。适合刚接触 Kubernetes 存储的运维同学,也适合那些已经在用 NFS 但想梳理清楚原理的开发者。
注意当前 Kubernetes 最新稳定版本仍然以 v1.28~v1.31 为主流,v1.35 属于较新的迭代版本,不过 NFS 相关的 API 和配置方式变化不大。我的操作以 v1.35 实测为准,同时会标注出与旧版本兼容的说明。
1. 整体方案设计与选型思路
1.1 为什么在多节点集群里选择 NFS?
这次部署的集群一共三台节点,一台控制平面,两台工作节点。业务方给的需求很明确:需要一个“所有节点都能读写、挂载之后数据不丢、运维成本低”的共享存储。当时我对比了几种方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Ceph RBD | 性能好、支持快照 | 部署复杂、至少3台独立节点、维护成本高 | 大规模生产集群 |
| Local PV | 性能极佳、IO延迟低 | 数据绑定节点、无法跨节点共享、备份麻烦 | 单节点高IO应用 |
| NFS | 部署简单、跨节点共享、内核支持稳定 | 单点故障、性能一般、需要额外机器 | 中小规模集群、共享文件型应用 |
这里多说一句,NFS 的“单点故障”问题确实是它最大的短板。我见过很多团队为了绕过这个瓶颈去折腾 GlusterFS 或者干脆用 Ceph,结果折腾了大半个月还没跑起来,最后灰溜溜地换回 NFS。如果你真的担心高可用,可以考虑用 DRBD 配合 keepalived 做 NFS 服务端的 HA,或者直接上云厂商的 NAS 服务,本质上也是 NFS 协议,但底层由平台保证了可用性。
1.2 Kubernetes 访问 NFS 的两种姿势
在开始动手之前,必须先搞清楚一个概念:Kubernetes 的 PV 访问 NFS,有两种完全不同的落地方式。
第一种是手动创建 PV,然后在 PV 的 spec 里指定 nfs 类型,填上 NFS 服务器的 IP、路径和挂载选项。这种方式把 PV 看成是一个提前分配好的存储区域,Pod 声明 PVC 去绑定它。优点是直观,缺点是你得自己管理 PV 的创建和回收,PVC 多了以后很烦。
第二种是部署一个叫 nfs-subdir-external-provisioner 的第三方组件。它会监听集群里的 PVC 创建事件,然后自动在 NFS 服务端创建子目录、生成对应的 PV 并绑定。这种方式下,用户只需要创建 PVC,剩下的事全由 provisioner 搞定。今天我主要讲第二种,因为它才是真正意义上的动态存储供给,也是目前社区里最主流的做法。
我见过有人把这两种方式混着用,比如某些有特殊需求的 PV 手动创建,其余走动态供给。这没什么问题,但要注意命名规范和回收策略的统一,否则后续排查会非常痛苦。
1.3 环境规划与版本核对
这次部署的具体环境如下:
- Kubernetes v1.35,三节点集群,使用 containerd 作为容器运行时
- 操作系统:CentOS 10(内核版本 6.x)
- NFS 服务端:独立的一台 CentOS 10 机器,IP 为 192.168.10.10,一块 500GB 数据盘挂载在 /data
- 客户端机器:集群三台节点的内核都支持 NFS v4,同时向下兼容 v3
在动手之前,先用 nfsstat 或者 mount 命令检查一下各节点对 NFS 协议版本的支持情况。这里有个小细节:CentOS 10 的内核把 NFS v4.2 设成了默认协议,但如果你要在嵌入式内核或者老版本系统上挂载 NFS,建议显式指定vers=3。这也是很多人在不同环境间迁移时踩坑的地方。
2. CentOS 10 上搭建 NFS 服务端的完整过程
2.1 安装软件包与基础环境准备
NFS 服务端需要安装两个包:nfs-utils 和 rpcbind。在 CentOS 10 里 rpcbind 已经作为 nfs-utils 的依赖自动装上了,所以实际操作只需要执行一条命令:
yum install -y nfs-utils装完之后,顺手把 rpcbind 和 nfs-server 服务设置成开机自启并启动:
systemctl enable --now rpcbind systemctl enable --now nfs-server这里有一个很多人容易忽略的点:nfs-server的 systemd 服务文件在较新版本里是nfs-server.service,不是老教程里的nfs.service或者nfs-kernel-server.service。如果用的是 CentOS 7 时代的笔记,可能会在 systemctl 命令上栽跟头。
接下来需要规划导出目录。我习惯把数据盘单独挂到一个目录,而不是直接用根分区。这次我把 500GB 数据盘格式化后挂载到了 /data:
mkfs.xfs /dev/sdb mkdir /data echo "/dev/sdb /data xfs defaults 0 0" >> /etc/fstab mount -a注意:XFS 是目前 CentOS 默认推荐的文件系统,如果你要用 NFS v3 并且对兼容性有顾虑,可以换成 ext4。不过在纯 CentOS 10 环境里 XFS 没有毛病。
2.2 配置 exports 导出文件
NFS 的核心配置文件是 /etc/exports。每行代表一个导出目录,语法不复杂,但参数的选择直接决定了可用性和安全性。
我这次导出了两个目录,一个是给测试环境用的,另一个是给开发环境用的:
/data/k8s-test 192.168.10.0/24(rw,sync,no_root_squash,no_subtree_check) /data/k8s-dev 192.168.10.0/24(rw,sync,no_root_squash,no_subtree_check)逐个解释一下这些参数:
- rw:允许读写,如果写 ro 那就只读
- sync:服务端在请求写入磁盘后才回复客户端,保证数据一致性
- no_root_squash:允许客户端 root 用户保持 root 权限写入文件。这个参数要特别注意,生产环境不建议开,否则 NFS 上任何一个文件都能被任意客户端的 root 用户篡改
- no_subtree_check:关闭子树检查,可以提升性能,但安全性略降
我在测试环境里开了 no_root_squash,是因为经常需要以 root 身份在容器里调试文件权限。生产环境请务必改成 root_squash,这是默认为更安全的选项。
配置完导出,执行 exportfs 命令让配置生效:
exportfs -r exportfs -vexportfs -v 的输出会让你看到实际生效的导出参数。如果某个参数被省略了,那说明它使用了默认值,比如 root_squash 是默认开启的,你没写就表示开启。
2.3 防火墙放行与自检
CentOS 10 默认开着 firewalld,如果服务器启用了防火墙,那 NFS 需要的端口必须放行。很多人在这一步翻车,因为 NFS 除了 2049 端口外,还需要 rpcbind 的 111 端口,以及 mountd 动态分配的端口。
比较省事的做法是指定 mountd 的固定端口,然后一起加入防火墙:
echo "mountd=892" >> /etc/nfs.conf echo "statd=662" >> /etc/nfs.conf echo "lockd=2587" >> /etc/nfs.conf然后重启服务并在防火墙中放行:
systemctl restart nfs-server firewall-cmd --permanent --add-service=nfs firewall-cmd --permanent --add-port=111/tcp firewall-cmd --permanent --add-port=892/tcp firewall-cmd --permanent --add-port=662/tcp firewall-cmd --permanent --add-port=2587/tcp firewall-cmd --reload不过如果你和我一样是内网环境、图省事,也可以直接关掉防火墙。但我建议养成写规则的习惯,因为 NFS 默认不加密,防火墙是唯一一道访问控制屏障。
最后在服务端本机验证一下:
showmount -e 127.0.0.1能看到类似这样的输出就说明服务端没问题:
Export list for 127.0.0.1: /data/k8s-test 192.168.10.0/24 /data/k8s-dev 192.168.10.0/243. Kubernetes 侧配置 NFS 动态存储供给
3.1 安装 nfs-subdir-external-provisioner
这部分是整篇博文的核心。我要在 Kubernetes 集群里部署一个名为 nfs-subdir-external-provisioner 的 Deployment,它会通过 NFS 协议连接服务端,并为每个 PVC 在指定目录下创建子目录。
先准备 deployment 的 YAML 文件。社区官方给的模板是最权威的,建议去 GitHub 仓库获取最新版本,而不是直接复制老博客的内容。我自己用的版本是 v4.0.2,和 Kubernetes v1.35 兼容良好。
核心的 Deployment 配置如下:
apiVersion: apps/v1 kind: Deployment metadata: name: nfs-subdir-external-provisioner namespace: kube-system spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: nfs-subdir-external-provisioner template: metadata: labels: app: nfs-subdir-external-provisioner spec: serviceAccountName: nfs-client-provisioner containers: - name: nfs-subdir-external-provisioner image: registry.k8s.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2 volumeMounts: - name: nfs-provisioner mountPath: /data/nfs env: - name: PROVISIONER_NAME value: k8s/nfs-client - name: NFS_SERVER value: 192.168.10.10 - name: NFS_PATH value: /data/k8s-test volumes: - name: nfs-provisioner nfs: server: 192.168.10.10 path: /data/k8s-test几个关键点说明:
PROVISIONER_NAME是 provisioner 的唯一标识,之后的 StorageClass 里必须用同一个值- Deployment 的 replicas 必须为 1,并且
strategy设置为Recreate。因为 provisioner 会在本地缓存状态,如果跑多个副本,可能因为同时创建同名 PV 导致冲突 - 镜像地址用了官方 registry.k8s.io 的地址,如果你的网络无法访问,可以用 docker.io 的镜像镜像
3.2 创建 RBAC 与服务账户
Kubernetes 的操作是受权限控制的,provisioner 需要创建 PV、查看 PVC、操作事件等权限,所以必须给它绑定一个 ServiceAccount 并赋予相应的 RBAC 权限。
我习惯把 ServiceAccount、ClusterRole、ClusterRoleBinding 放在同一个 YAML 文件里:
apiVersion: v1 kind: ServiceAccount metadata: name: nfs-client-provisioner namespace: kube-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: nfs-client-provisioner-runner rules: - apiGroups: [""] resources: ["persistentvolumes"] verbs: ["get", "list", "watch", "create", "delete"] - apiGroups: [""] resources: ["persistentvolumeclaims"] verbs: ["get", "list", "watch", "update"] - apiGroups: ["storage.k8s.io"] resources: ["storageclasses"] verbs: ["get", "list", "watch"] - apiGroups: [""] resources: ["events"] verbs: ["create", "update", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: run-nfs-client-provisioner subjects: - kind: ServiceAccount name: nfs-client-provisioner namespace: kube-system roleRef: kind: ClusterRole name: nfs-client-provisioner-runner apiGroup: rbac.authorization.k8s.io应用之后,我再检查一下 ServiceAccount 是否创建成功。
3.3 创建 StorageClass
StorageClass 是 Kubernetes 的存储模板,里面定义了用什么 provisioner、回收策略是什么、是否允许在线扩容等。我的配置如下:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-client provisioner: k8s/nfs-client parameters: archiveOnDelete: "true" reclaimPolicy: Delete volumeBindingMode: Immediate allowVolumeExpansion: true解读一下参数:
provisioner的值必须和 Deployment 里的 PROVISIONER_NAME 一致,否则 StorageClass 找不到对应的 provisionerarchiveOnDelete设置为 true 时,PVC 删除后数据不会直接清空,而是重命名为 archived- 开头的目录。这个选项对生产环境特别友好,防止误删reclaimPolicy设为 Delete,PVC 删除时 PV 也会被删除allowVolumeExpansion开启后,后续可以直接修改 PVC 的容量来扩容
做完这些基础配置,provisioner 就具备动态供给的能力了。接下来用一个真实的应用来验证整个链路是否通畅。
4. 用例验证:部署一个使用 PVC 的 Nginx
4.1 创建 PVC 并观察动态供给过程
验证的最好方式就是创建一个 PVC,然后看它能不能自动绑定 PV。我先写一个测试 PVC:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: nginx-pvc spec: accessModes: - ReadWriteMany resources: requests: storage: 5Gi storageClassName: nfs-client这里我把 accessModes 写成了 ReadWriteMany,这是 NFS 相对其他存储方案的一个杀手级特性。比如两个 Pod 同时挂载同一个 PVC 做读写,在 Local PV 或者大多数云盘上是做不到的,但 NFS 天然支持。
执行 kubectl create 后,大约几秒钟内去查看 PVC 状态:
kubectl get pvc nginx-pvc kubectl get pv正常情况你会在几秒内看到 PVC 状态从 Pending 变成 Bound,同时集群里多出来一个 PV,命名规则是 pvc- 。此时你去 NFS 服务端的 /data/k8s-test 目录看看,会发现多出了一个子目录,名字就是那个 pvc-uuid。
这个过程就是 dynamic provisioning 的核心逻辑。provisioner 监听到 PVC 创建后,通过 NFS 协议在服务端建目录,然后创建一个 PV 指向该目录,最后把 PVC 和 PV 绑定。
4.2 部署 Nginx 挂载 PVC
PVC 就绪后,部署一个简单的 Nginx Deployment 来测试挂载:
apiVersion: apps/v1 kind: Deployment metadata: name: nginx-storage-test spec: replicas: 1 selector: matchLabels: app: nginx-storage-test template: metadata: labels: app: nginx-storage-test spec: containers: - name: nginx image: nginx:latest volumeMounts: - name: data mountPath: /usr/share/nginx/html volumes: - name: data persistentVolumeClaim: claimName: nginx-pvc应用后,等待 Pod 处于 Running 状态,然后写入一个测试文件来验证实际读写:
kubectl exec -it deployment/nginx-storage-test -- bash echo "NFS persistent storage test" > /usr/share/nginx/html/index.html exit接着去 NFS 服务端查看文件是否存在:
cat /data/k8s-test/<pvc-uuid>/index.html能看到内容就说明写入成功了。此时再验证跨节点共享:把 Deployment 的副本数改成 2,或者直接重新调度 Pod 到另一个节点,再次执行 cat 命令,数据依然存在。
4.3 权限问题与 root_squash 的战斗经验
上一节我在 exports 配置里开了 no_root_squash,这让测试很顺利。但如果你在生产环境用了 root_squash,可能会遇到容器内写入文件时报 “Permission denied” 的错误。
这里的根源在于:NFS 服务端会把客户端传来的 UID 映射到服务端的文件权限模型。root_squash 会把客户端 root(UID 0)映射成 nobody(UID 65534),而容器内进程往往以非 root 用户运行,比如 Nginx 默认以 nginx 用户(UID 101)运行。服务端目录的属主如果不是这个 UID,写入就会失败。
解决办法有两种,任选其一:
第一种,调整 PV 目录的属主为容器进程的 UID,在服务端执行:
chown -R 101:101 /data/k8s-test第二种,在 Deployment 里允许容器以 root 运行,或者配置 securityContext 的 fsGroup 让 PVC 目录自动变更属组。
我建议第一种,因为它更接近生产环境的安全实践。用 initContainer 在启动前修改目录权限,也是一个更优雅的思路。
4.4 验证 Pod 跨节点迁移后的数据一致性
最后做一遍跨节点迁移测试,这也是 NFS 区别于本地存储的最大价值。执行:
kubectl cordon <node-1> kubectl delete pod nginx-storage-test-xxxPod 被删除后,Deployment 会自动在另一个节点重新创建一个新 Pod。等待新 Pod Running 后,进入容器看看之前写入的文件是否还在:
kubectl exec -it deployment/nginx-storage-test -- cat /usr/share/nginx/html/index.html如果一切正常,你会看到之前的文本内容。这意味着应用重启、故障迁移时数据不会丢失,Pod 完全无感知。
5. 常见故障排查与避坑实录
5.1 PVC 一直 Pending,查看 provisioner 日志
PVC 卡在 Pending 是最高频的问题。遇到这种情况不要急,第一步是看 PVC 的描述事件:
kubectl describe pvc nginx-pvc如果看到no volume plugin matched或者StorageClass nfs-client not found,那大概率是 StorageClass 的 provisioner 名称和 Deployment 里的 PROVISIONER_NAME 不一致,或者 StorageClass 根本没有创建成功。
如果事件里显示了 wait 开头的消息,再去查 provisioner 的日志:
kubectl logs -n kube-system deployment/nfs-subdir-external-provisioner日志里会出现类似 mount failed 的信息,重点看最后几行的报错原因。我遇到过的情况是 NFS_PATH 在服务端不存在,provisioner 在创建子目录时失败。检查 NFS_SERVER 和 NFS_PATH 是否与 /etc/exports 里的配置完全匹配。
5.2 Pod 挂载 PVC 超时或卡在 ContainerCreating
Pod 长时间处于 ContainerCreating,通常是 kubelet 在挂载 NFS 卷时超时。先用 describe pod 看事件,如果显示 mount: permission denied,那大概率是防火墙放行的问题。
一个容易被忽视的坑是 mountd 端口不一致。如果你没有像我在 2.3 节里那样固定 mountd 端口,NFS 服务端重启后 mountd 的端口会改变。kubelet 可能仍然尝试连接旧的端口,导致挂载失败。这时候重启各个节点的 kubelet,强制重新解析端口即可:
systemctl restart kubelet这里我不太推荐在生产环境随意重启 kubelet。比较稳的方式是先确认能在节点上手动挂载,排除网络问题后再重启 kubelet。
5.3 删除 PVC 后数据还在,怎么回事?
我在 StorageClass 里设置了 archiveOnDelete: true,所以删除 PVC 后数据并没有立即消失,而是被重命名的归档目录。这其实是刻意的设计。
但如果你希望删除 PVC 时彻底删除数据,有两种方式:
- 把 StorageClass 参数里的 archiveOnDelete 改为 false
- 在删除 PVC 前,手动删除对应的 PV 对象,让 provisioner 认为 PV 已不存在,从而不执行归档
对少数敏感数据目录,我更推荐手工清理,而不是全局修改 StorageClass,因为保存归档在很多场景下都是救命缆。
5.4 NFS v3 与 v4 的协议选择问题
我在标题里看到热词里提到了嵌入式 Linux 下 NFS v3 挂载根文件系统的场景,这里多提一句。Kubernetes 节点本身几乎不会遇到 NFS v3 的问题,因为现代内核的 NFS 客户端同时支持 v3 和 v4。但如果你在嵌入式开发板上 build 了一个旧内核,或者用 busybox 挂载 NFS 失败,记得显式加 vers=3。
Kubernetes 侧如果要指定 NFS 协议版本,可以在 PV 的 spec 中增加 mountOptions:
mountOptions: - vers=4.2不过 nfs-subdir-external-provisioner 的 PV 是自动生成的,想改 mount option 必须修改 Deployment 的环境变量 NFS_PROTOCOL。这里我没有实测过 v3 和 v4.2 在性能上的差异,但如果你追求极致的兼容性,建议在 NFS 服务端同时开启 tcp 下的多个协议版本,让客户端按需协商。
5.5 性能调优与读写优化方向
NFS 性能的瓶颈通常不在 Kubernetes,而在网络和 NFS 服务端的磁盘 IO。这里分享几个实测有效的调优方向:
第一,NFS 服务端使用 SSD 硬盘,尤其是多 Pod 并发读写时,机械硬盘的 IOPS 会立刻成为瓶颈。我测试环境用的是 NVMe 盘,几个 Pod 同时跑数据库压力测试也能保持稳定延迟。
第二,挂载参数中增加 noatime 可减少元数据写入的次数。这个参数可以在 PV 的 mountOptions 里加,也可以在 NFS 服务端导出的同时设定,我推荐前者,因为更灵活。
第三,如果业务允许,把 tcp 的 rsize 和 wsize 调大。默认值通常是 1MB,在千兆网络下这已经不算小了,但在万兆网络环境下可以尝试增大。具体参数需要在 PV 的 mountOptions 里配置,例如rsize=1048576,wsize=1048576。
第四,关注 NFS 服务端的网络队列。多节点同时写入时,单块网卡的软中断会成为瓶颈。建议用多队列网卡,或至少确认 ethtool 中 rx-usecs 中断协调参数在合理范围。
5.6 一个生产环境的保守配置模板
综合这些经验,我整理了一个适合中小型生产环境的 StorageClass 模板,直接复制就能用:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-client-prod provisioner: k8s/nfs-client-prod parameters: archiveOnDelete: "true" pathPattern: "${.PVC.namespace}-${.PVC.name}" reclaimPolicy: Delete volumeBindingMode: Immediate allowVolumeExpansion: true mountOptions: - noatime - hard - nfsvers=4.2这里的 pathPattern 允许你在 NFS 服务端生成带有 namespace 和 PVC 名称的目录,从目录名就能一眼看出是哪个业务在用的,排查问题非常方便。hard 挂载是默认行为,表示服务器挂掉后客户端不会立即报 IO 错误,而是持续重试,对数据库类应用来说,这个参数可以防止进程崩溃。
6. 写在最后的实战体会
我最初搭建这套环境的时候,花了很长时间在防火墙和权限问题上打转,一度觉得被 NFS 折磨得想放弃。但后来想明白了,NFS 的问题永远不在 NFS 本身,而在于配置细节的偏差:一个 exports 参数、一个端口、一个 UID,都能让整个链路断开。
现在这套 NFS 动态供给方案已经在我的集群里稳定跑了几个月。每逢业务上线新应用,开发只要在 PVC 模板里指定 storageClassName: nfs-client,几秒后一个可用的存储目录就自动准备好,完全不需要运维介入。这种“自动化供给”带来的运维效率提升,是 NFS 之外很多方案难以匹敌的。
如果你看完这篇博文,正准备在自己的集群里试一试,我的建议是:先用一个临时目录和标准 Deployment 做全链路验证,再切换到业务场景。把验证时间压缩在半小时以内,规则清了,剩下的就是按部就班。
最后分享一个延伸方向。这套方案目前还只是单机 NFS 服务端,数据安全完全依赖那台机器的硬盘和你的备份策略。如果你有足够的预算和精力,可以研究一下如何用 DRBD 做两台 NFS 服务端的实时同步,再配合 keepalived 做虚拟 IP 漂移。这样当一台服务端宕机时,Kubernetes 节点能几乎无感地切换到另一台。存储这条路,往下走总有新问题,但每解决一个问题,你的系统和自己的底气都会扎实一分。