聊到 Kubernetes 的持久化存储,NFS 配合 PV、PVC 这套组合,几乎是自建集群和中小团队逃不开的第一站。原因很直接:NFS 部署简单、天然支持多个节点同时挂载,而 PV(PersistentVolume)和 PVC(PersistentVolumeClaim)又是 K8s 里把存储和业务解耦的标准姿势。三样东西叠在一起,你就能得到一个"存储不绑定任何节点、业务不用关心服务器 IP"的稳定底座。
接下来我准备把 NFS 服务端搭建、PV/PVC 的 yaml 写法、Deployment 里怎么挂载,以及我在实际运维中踩到的一堆坑从头到尾捋一遍。这篇内容不是抄官网文档,是我在测试环境和生产环境反复验证过的完整过程,适合刚入门 K8s 存储、或者想在自建集群里把 NFS 存储链路跑通的同学直接抄作业。
1. 理清协作关系:NFS、PV、PVC 各自负责什么
1.1 Pod 直接挂 NFS 不行吗,为什么非要绕一层 PV/PVC
先说个最原始的方案:K8s 的 Pod 定义里确实可以直接写 NFS 挂载,比如 volumes 字段直接指定nfs.server和nfs.path。这在单机测试时很快,但放到真实环境里问题立刻冒出来:
- NFS 服务器的 IP 和路径写死在业务 yaml 里,哪天迁移存储、换 IP,你得把每个用到它的 Deployment 都翻出来改一遍。
- 没有任何容量概念,一个业务随时可以占满整块 NFS 空间,其他业务被拖死。
- 没有生命周期管理。谁在用这块存储、用了多少、删业务之后数据怎么处理,全靠人工记录,出了事只能对着日志猜。
PV/PVC 这套机制就是为了解决这些问题产生的。PV 相当于管理员预先登记好的"房源信息",声明了这套房有多大、在哪个小区、是什么户型;PVC 则是业务方提交的"租房申请",声明我需要多大面积、要哪种户型。K8s 调度器负责把两者撮合在一起,之后业务侧只需要在 yaml 里写claimName引用 PVC,完全不接触 NFS 服务器的具体信息。
我习惯把它叫"存储描述与存储使用的分离"。这个分离带来的最大好处是:业务团队只需要提需求(PVC),基础设施团队负责准备资源(PV),两边通过匹配规则对接,互不干扰。
1.2 静态供给和动态供给,本文讲的是哪条路
PV 的产生有两种方式:
- 静态供给:管理员提前手工创建 PV,PVC 申请时去匹配现成的 PV。简单可控,但需要人工预估容量提前建好。
- 动态供给:通过 StorageClass 配合 provisioner,在 PVC 创建时自动拉起一个 PV。省事,但需要额外部署 provisioner 组件。
很多入门文章一上来就教你搞 StorageClass 动态供给,但我个人建议先把静态链路吃透。原因很简单:动态供给的 provisioner 也是通过一个容器去调用 NFS API 创建目录,最终的挂载原理和静态方式完全一样。如果你不理解 PV/PVC 的匹配规则,不理解回收策略的差异,直接跳去用 StorageClass,遇到问题你会毫无线索。所以本文老老实实从静态讲起,最后再补一段怎么从静态平滑过渡到动态。
1.3 NFS 这种存储适合什么场景
我常用 NFS 的场景可以帮你做对照:
- 多 Pod 共享读写,比如多个 Web 副本都要读写同一个上传目录,此时需要 ReadWriteMany(RWX)能力,NFS 是自建环境里最容易实现的方案。
- 没有云厂商的云盘/EBS,纯裸机或虚拟机自建 K8s,需要一个跨节点共享的存储介质。
- 开发测试环境想快速给各种服务提供存储,NFS 一条命令就能架起来。
- 日志归档、文件分发这类对性能不敏感、但读写频次不低的场景。
反过来,数据库这种对 IOPS 和延迟极敏感、需要强一致性的负载,别放 NFS 上。NFS 再稳妥也有网络开销和写入锁机制,跑关系型数据库容易成为整个系统的瓶颈。这点后面我会单独分析。
2. Ubuntu 端搭建 NFS 服务:exports 参数与连通性验证
2.1 安装和最小配置步骤
服务端我用的是 Ubuntu 22.04 LTS,内核版本不影响 NFS 的基本使用,流程上是一致的。先安装 NFS 内核服务:
sudo apt update sudo apt install -y nfs-kernel-server然后创建一个共享目录。注意这个目录所在分区最好有足够的空闲空间,不要放在/tmp或者系统根分区这种容易被清理的地方:
sudo mkdir -p /data/nfs sudo chown nobody:nogroup /data/nfs sudo chmod 755 /data/nfs接下来编辑/etc/exports。这个文件是 NFS 服务的核心配置,每一行定义了一个导出目录和访问规则:
/data/nfs 192.168.1.0/24(rw,sync,no_root_squash,no_subtree_check)这里我用了整个内网网段作为授权范围,你也可以直接写具体节点 IP,比如192.168.1.31(rw,sync,no_root_squash,no_subtree_check)。配置生效后重启或刷新服务:
sudo exportfs -ra sudo exportfs -v sudo systemctl restart nfs-kernel-server用exportfs -v查看是否导出成功。正常情况下会输出类似/data/nfs 192.168.1.0/24这样的信息。
2.2 exports 参数背后的"为什么"
新手最容易犯的错是照抄网上配置,却不理解每个参数的含义。我逐个说清楚:
| 参数 | 作用 | 不设的后果 |
|---|---|---|
| rw | 允许读写访问 | 默认只读,容器里写文件直接报权限错误 |
| sync | 写操作同步刷盘后再响应 | 如果写 async,性能稍好但宕机会丢数据 |
| no_root_squash | 保留客户端 root 用户的权限,不压制为 nobody | 容器内如果以 root 写文件,会被 NFS 映射成 nobody,出现 Permission denied |
| no_subtree_check | 关闭子树检查,避免文件重命名/删除时出现奇怪错误 | 某些版本下导致可重入目录的警告 |
| fsid=0 | 给 NFSv4 提供伪根目录 | 多目录导出时 NFSv4 客户端挂载可能失败 |
尤其注意no_root_squash和sync这两个。容器里跑的进程如果是以 root 身份运行,而 NFS 默认的 root_squash 会把 root 映射成匿名用户,最终你会在 Pod 里反复看到 Permission denied。排查起来你会怀疑是 PVC 的问题,其实根子在 exports。sync也是一条安全底线,我见过有人为了压测性能临时开async,结果服务器断电后目录里全是损坏的半截文件。
2.3 客户端连通性测试:别急着配 PV
先把服务端的连通性验证一遍,再回到 K8s 里操作。到任意一台 K8s 节点上执行:
sudo apt install -y nfs-common sudo mkdir -p /mnt/nfs_test sudo mount -t nfs 192.168.1.100:/data/nfs /mnt/nfs_test echo "hello nfs" | sudo tee /mnt/nfs_test/test.txt sudo cat /mnt/nfs_test/test.txt sudo umount /mnt/nfs_test如果这几步顺利,说明 NFS 服务端和客户端之间的网络、权限、版本协商都没有问题,后续配置 K8s 时就少了一半排查工作。如果手动挂载都失败,不要急着去 K8s 里查,先把本步骤解决。常见报错和处理我放在第 5 章。
2.4 NFSv3 和 NFSv4 的选择问题
服务端默认会同时监听 v3 和 v4 协议,但客户端挂载时具体走哪个版本是可以指定的。我的建议是:K8s 节点和容器镜像里的挂载操作统一指定vers=4.1(或回退到vers=3),避开 NFSv4.0。
这里有个历史坑:Linux 内核从 4.x 开始把 NFSv4.0 标记为不安全,部分发行版默认禁用了它,导致在 K8s 里挂载时卡在 mount 阶段、Pod 一直 ContainerCreating。而嵌入式和 IoT 场景下,rk3568 这类开发板做根文件系统挂载时又偏偏依赖 NFSv3(网络热词"嵌入式 linux 根文件系统挂载 使用 nfs v3"说的就是这个)。如果你既要在 K8s 集群里用,又要给嵌入式板子提供根文件系统,服务端保持默认的双栈支持即可,客户端按需指定版本,不用在服务端做额外限制。
3. PV 与 PVC 的 yaml:从绑定规则到状态机
3.1 PV 的 yaml 字段逐行拆解
服务端就绪后,我们来创建 PV。下面的 yaml 是我在项目里最常用的一份,每行都有讲究:
apiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv-data labels: storage-type: nfs spec: capacity: storage: 10Gi volumeMode: Filesystem accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain storageClassName: "" nfs: server: 192.168.1.100 path: /data/nfs readOnly: false mountOptions: - vers=4.1capacity.storage:你给这个 PV 标注的容量。注意这只是给调度器看的"标签",NFS 本身不做配额限制。想真正限制单个目录用量,得在 NFS 服务端用 quota 之类的机制。accessModes:NFS 支持 ReadWriteMany 和 ReadOnlyMany,理论上也支持 ReadWriteOnce,但 RWX 是 NFS 区别于大多数块存储的核心优势。一 Pod 如果需要多个节点同时挂载,必须声明 RWX。persistentVolumeReclaimPolicy:这里用了 Retain(保留)。意思是 PVC 删除后 PV 里的数据还在,管理员需要手动清理。这是新手最容易误解的点,后面细说。storageClassName: "":显式声明不使用 StorageClass,走纯静态绑定。如果漏掉这行,而集群里又存在默认 StorageClass,PVC 会跑去匹配默认类,导致绑定不上。mountOptions:挂载参数,我在这里指定 vers=4.1 就是为了避开前面说的 NFSv4.0 问题。nfs.path:填服务端 shared 目录,nfs.server填服务端 IP。路径写错通常不会有明确报错,但挂载后目录会是空的,所以要仔细核对。
把 PV 创建出来:
kubectl apply -f pv.yaml kubectl get pv nfs-pv-data状态应该是 Available,表示还没被任何 PVC 绑定。
3.2 PVC 的 yaml 与匹配规则
PVC 的 yaml 看起来更简单:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc-data namespace: default spec: accessModes: - ReadWriteMany volumeMode: Filesystem resources: requests: storage: 10Gi storageClassName: ""apply 之后查看绑定情况:
kubectl apply -f pvc.yaml kubectl get pvc nfs-pvc-data kubectl get pv这里要讲清楚 K8s 是怎么把 PVC 和 PV 匹配上的,三个条件缺一不可:
- 容量足够:PV 的 capacity 大于等于 PVC 的 requests。
- accessModes 匹配:PV 的 accessModes 要包含 PVC 请求的模式。
- storageClassName 一致:都为空字符串就互相匹配;如果有 selector,还需标签匹配。
PVC 的resources.requests.storage写多少,实际就会在 PV 里占用多少这一语义,但物理上 NFS 目录不会真的划出 10Gi 的空间。你可以在 NFS 服务端用du查看实际用量,不受 PV 声明限制。
3.3 PV 状态机的解读:Available 到 Bound 再到 Released
PV 的状态一共四个,理解这个状态机对排障帮助很大:
- Available:未被绑定。
- Bound:已被某个 PVC 绑定,此时 PVC 也能看到对应的 VolumeName。
- Released:PVC 被删了,PV 被释放,但因为回收策略是 Retain,PV 里的数据和资源没有清理。
- Failed:自动回收失败,常见于后端存储有问题。
我经常收到问题:"我把 PVC 删了想重建,结果新 PVC 一直 Pending。" 这就是因为旧 PV 进入了 Released 状态,而默认策略下 Released 的 PV 不会被自动重新绑定。你需要手动做两步:删除掉 PV 对象,然后重新 apply 一份同样的 PV yaml,它就从 Released 变回 Available,新 PVC 才能绑定上去。
看起来是"同样的 yaml 再创建一次",但实际数据始终在 NFS 目录里。这就是 Retain 策略的语义,也是我先把它讲透的原因。
4. 挂载进 Deployment:多副本共享读写与权限修复
4.1 写一个双副本 Nginx 挂载 PVC
PVC 绑定好后,我们来一个实战例子:两个 Nginx 副本同时挂载同一个 PVC,把 NFS 当作共享的网站根目录。这能直接验证 RWX 的真实效果。
apiVersion: apps/v1 kind: Deployment metadata: name: nginx-nfs-demo spec: replicas: 2 selector: matchLabels: app: nginx-nfs template: metadata: labels: app: nginx-nfs spec: containers: - name: nginx image: nginx:1.24 command: ["/bin/sh", "-c"] args: - sleep 3600 volumeMounts: - name: nfs-volume mountPath: /usr/share/nginx/html volumes: - name: nfs-volume persistentVolumeClaim: claimName: nfs-pvc-data这里我让容器起来后先 sleep,方便我们进入容器验证。实际生产里不需要 command/args,替换成你自己的启动命令即可。
部署后检查一下:
kubectl apply -f deployment.yaml kubectl get pod -l app=nginx-nfs kubectl exec -it nginx-nfs-demo-xxxxx -- /bin/bash进入第一个容器,写入一个文件:
echo "shared file from pod-1" > /usr/share/nginx/html/index.html cat /usr/share/nginx/html/index.html再进入第二个容器,你能直接看到index.html的内容。这个动作证明了两个 Pod 共享同一块存储,写入后立即可见。如果你想更醒目一点,可以从第二个 Pod 再写一个文件,回到第一个 Pod 查看。
4.2 权限问题:为什么容器里明明有 root 却写不了
这是 NFS 挂载场景里出现频率最高的权限问题。现象是这样的:Pod 正常运行,容器内touch /data/file时报Permission denied,但你在宿主机上手动写同一个 NFS 共享目录完全没问题。
原因在 NFS 的 root_squash 机制上。NFS 服务端默认会拒绝 root 用户直接操作共享目录,把所有来自 root 的请求映射成匿名用户(通常叫 nobody)。而很多基础镜像(包括 nginx、busybox)容器内进程默认就是 root,于是"容器内是 root → 到服务端被 squash → 变成 nobody → nobody 没有写目录的权限"。
解决思路有两个:
方式一:改 exports,加no_root_squash。这是全局生效的,会给所有 root 请求真实 root 的权限。适合测试环境和自己可控的内网环境。生产环境如果你想让某个 Pod 内的特定用户有权限,更推荐方案二。
方式二:在 Pod 定义里配置 fsGroup 和 runAsUser,让写入时的 uid 与你 NFS 目录的属主保持一致。例如你希望写入的 uid 是 2000,那么:
securityContext: runAsUser: 2000 runAsGroup: 2000 fsGroup: 2000然后回到 NFS 服务端,把共享目录属主改成 2000:
sudo chown -R 2000:2000 /data/nfs这样容器内以 uid 2000 写入,就不会被 root_squash 影响,也避免了给全部客户端开放 root 权限的风险。两种方式我都在项目里用过,你现在只需要知道它们对应的就是 exports 参数和 securityContext 字段。
4.3 PVC 删除、PV 删除、数据到底还在不在
测试完功能,我还建议你亲手走一遍"清理链路",这比背一百遍文档都管用。假设刚才的 Deployment 还在,PVC 也已绑定 PV。依次执行:
kubectl delete deployment nginx-nfs-demo kubectl delete pvc nfs-pvc-data kubectl get pv nfs-pv-data此时 PV 的状态会变为 Released。去 NFS 服务端看一眼/data/nfs里的文件,你会发现数据原封不动。这正是 Retain 策略的意义:K8s 只是解绑了引用,不会主动删你的数据。
如果你试图直接用旧 PV 对象再绑定一个同名 PVC,会发现新 PVC 永远 Pending。因为 Released 状态不会重新变为 Available。正确的复用方式是:
kubectl delete pv nfs-pv-data kubectl apply -f pv.yaml重新创建的 PV 会以 Available 状态上线,新 PVC 就能绑定了。数据从头到尾没丢。
相比之下,如果回收策略是 Delete,PV 删除时后端存储会一起被清理,但这是由 provisioner 决定的行为。静态 NFS 场景本身没有提供自动删除目录的实现,所以规范上必须用 Retain,否则容易出现"PV 没了、NFS 里数据也找不到归属"的窘境。
5. 实操中反复踩到的坑:从 Pending 到 mount 失败
5.1 Pod 一直 ContainerCreating,describe 里说 mount 失败
挂载类问题在 K8s 里表现非常一致:Pod 起不来,kubectl describe pod的事件里有MountVolume相关报错。但报错文案不同,根因也完全不同,我按频率整理了一个排查表:
| 报错特征 | 根因 | 解决 |
|---|---|---|
| mount.nfs: Operation not permitted | 客户端缺少 nfs-common,或内核 nfs 模块未加载 | apt install nfs-common |
| mount.nfs: access denied by server | exports 授权范围没包含该节点 IP,或 root_squash 压制 | 检查 exports 配置,加 IP 白名单并 exportfs -ra |
| mount.nfs: Network is unreachable | 防火墙或网络隔离,111/2049 端口不通 | 放行端口,或检查节点的 routes |
| rpc.nfsd: writing to /proc/fs/nfsd failed | 服务端 nfs-kernel-server 未重启/加载失败 | 重启 nfs-kernel-server |
| nfs server ... not responding, still trying | 版本协商卡住(v4.0 典型表现) | 挂载选项加 vers=4.1,或改用 vers=3 |
我的排查顺序永远是从外到内:先在 K8s 节点上手动执行mount -t nfs,能挂上再查 K8s 配置;不能挂上就逐参数检查。手动挂载成功后,再去观察 Pod 的 yaml 是不是把 server/path 写错了,或者 PVC 名字是不是和 Deployment 里的 claimName 不一致。
5.2 PVC 一直 Pending,明明 PV 已经创建了
PVC 卡在 Pending 是最常见的问题,原因无非以下几种:
- accessModes 不一致。比如 PV 只允许 RWX,但 PVC 里申请的是 RWO,这时绑定永远不成功。解决:把两边模式改成一致。
- storageClassName 对不上。我见过最隐蔽的情况是:PV 里写了
storageClassName: nfs,PVC 里忘了写,结果 PVC 去匹配集群的默认 StorageClass,两条线索永远碰不上。 - 容量不足。PV 标了 10Gi,PVC 要 15Gi,永远匹配不上。
操作建议是快而准地看输出:
kubectl get pv kubectl get pvc -A kubectl describe pvc nfs-pvc-dataEvents 里通常会用一句话告诉你为什么 Pending。如果你初次排障,describe是信息量最大的工具,别只盯着 get。
另外提醒一点:PV 是集群级资源,不区分 namespace;PVC 是命名空间级资源,只在它所属的 namespace 内生效。所以"我明明看到 PV 是 Available,为什么 default 空间里的 PVC 不绑定"这件事,要先确认 PV 没有被其他 namespace 里的 PVC 占走。
5.3 exportfs 后依然 access denied 的隐性原因
你有没有遇到过这种情况:exports 里的 IP 网段明明是包含节点地址的,showmount -e也能看到共享目录,但挂载时仍然 access denied?我在 Ubuntu 上踩过一次,原因是客户端来源 IP 走的是另一个出口 IP,特别是节点有多网卡时,服务端看到的是带内管理网段的 IP,而 exports 里写的是业务网段的地址。
排查方法很简单,在 K8s 节点上执行:
ip addr show看挂载请求实际从哪个 IP 出去,确保 exports 的网段包含这个 IP。另一个容易忽略的地方是 NFS 依赖的rpcbind服务。如果你把 2049 端口放行了,但 111 端口(rpcbind)没放,showmount可能正常,真正 mount 时 RPC 协商会被防火墙拦下来。Ubuntu 的规则可以简单加一条:
sudo ufw allow from 192.168.1.0/24 to any port 111,2049 proto tcp sudo ufw allow from 192.168.1.0/24 to any port 111,2049 proto udp5.4 性能预期:NFS 到底能跑多快,别把它当本地盘用
NFS 在局域网里的吞吐表现,正常配置下能达到接近千兆/万兆网络的极限,顺序读写通常可以跑满网卡。但随机读写和元数据操作(大量小文件创建、删除、重命名)会明显慢于本地盘,因为它每次操作都要经历一次网络往返,且受制于服务端的单机 IO。
我的建议是:上线前先做一轮简单的 fio 压测,至少知道这条链路的基线水平,后续业务说"慢"的时候你才有对比数据。一个很常用的测试命令:
fio --name=test --directory=/data/nfs/fio --rw=randwrite --bs=4k --size=512M --numjobs=4 --group_reporting如果这个数字远低于你的预期,先查网络,再查服务端磁盘,最后才查 K8s 层。很多刚接触 NFS 的同学一看到"共享存储"就默认它性能很强,实际上它强在共享与简单,而不是强在随机 IO。跑高并发小文件业务的 Pod,建议挂在节点本地盘上,别往 NFS 里塞。
5.5 从静态走向动态:StorageClass + nfs-subdir-external-provisioner 是怎么工作的
把静态链路走通之后,你会发现有一个繁琐点:每来一个新项目,都要手工建 PV 和 PVC。如果是几十上百个项目,这项工作会变成灾难。这时候就该考虑动态供给了。
最常用的方案是部署nfs-subdir-external-provisioner,它的本质是一个 Deployment,监听 PVC 创建事件,然后调用 NFS 服务端的 API,在共享目录下自动创建子目录,再以这个子目录为 path 动态生成一个 PV。集群里的 PVC 只要声明storageClassName: nfs-client,就会触发这个 Provisioner 自动完成"建目录→建 PV→绑定"的全过程。
功能上确实省事,但我要提醒一句:动态供给解决的是"创建 PV"的自动化问题,之前讲的所有细节——权限、回收策略、NFS 版本、性能基线——一个都没有消失。反而因为 PV/PVC 都是自动生成的,出问题时你更难看清楚是谁创建的、参数对不对。所以纸上得来终觉浅,先把静态这条路走一遍,再用动态工具把它固化下来,你的理解会比直接复制文档深得多。
我自己把静态链路跑通之后,就有意识地给所有 NFS 相关对象打上了便于识别的标签,比如 PV 加storage-type: nfs、PVC 加team: data,这样kubectl get pv -l storage-type=nfs一眼就能筛出所有 NFS 存储资源。NFS 挂载 PV 和 PVC 这套组合,本身不是新鲜技术,但它衔接了存储底层和 K8s 调度层,细节非常多。希望这篇内容能帮你少走点弯路,尤其是 exports 参数和 PV 状态机这两个地方,多看一眼前面梳理的对照表,通常能省下一次通宵排障的折腾。