NFS 深入:server / client / K8s NFS / 经典坑
NFS 是企业里最普及的共享存储——简单、便宜、随处可用。但 NFS 也是 K8s 集群里最容易踩坑的存储——pod 卡 D 状态、stale handle、性能踩坑、节点挂了所有 pod 跟着挂。
这一篇把 NFS 从协议到 K8s 集成讲透。
这篇要回答什么
- NFSv3 / NFSv4 / NFSv4.1 区别?K8s 该用哪个?
- NFS server 怎么搭、有哪些关键参数?
- NFS mount options 那么多、哪些是关键的?
- 为啥 NFS server 一挂 pod 全卡 D 状态?
- K8s 怎么用 NFS PV?什么时候不该用?
softvshardmount、syncvsasync、tcpvsudp怎么选?- stale file handle 是什么?怎么修?
1. NFS 协议简介
graph LR
subgraph 客户端节点
App["应用<br>open() / read() / write()"]
VFS["VFS"]
NFSClient["NFS Client<br>(kernel)"]
end
subgraph 网络[网络 RPC over TCP/UDP]
Net["mount/rpcbind/nfs/lockd"]
end
subgraph NFS服务端
NFSD["nfsd<br>(kernel)"]
ExportFS["导出的目录<br>/exports/data"]
end
App --> VFS --> NFSClient
NFSClient -.RPC.-> Net
Net -.RPC.-> NFSD
NFSD --> ExportFS
style 客户端节点 fill:#e1f5ff
style NFS服务端 fill:#ffe1f5
NFS 把远端目录"投射"到本地文件系统——应用以为在本地操作、实际走网络。
NFSv3 vs NFSv4 vs NFSv4.1
| 维度 | NFSv3 | NFSv4 | NFSv4.1 |
|---|---|---|---|
| 端口 | 多端口(111 rpcbind + lockd + nfs) | 单端口 2049 | 单端口 2049 |
| 防火墙友好 | ❌ 难配 | ✅ | ✅ |
| 状态 | 无状态 | 有状态(更可靠) | 有状态 |
| 锁机制 | 单独 lockd | 内嵌 | 内嵌 |
| ACL | 弱 | POSIX 兼容 + Win | 同 v4 |
| 并行 I/O | ❌ | ❌ | ✅ pNFS |
| K8s 推荐 | 简单场景 | 生产推荐 | 大规模 |
结论:K8s 集群里用 NFSv4 / v4.1——单端口、防火墙好配、有状态更可靠。
看 NFS 协议版本
$ mount | grep nfs
nfs.example.com:/exports/data on /mnt/data type nfs4 (...,vers=4.2,...)
^^^^^^^^^
协议版本
强制版本:
mount -t nfs -o vers=4.2 nfs.example.com:/exports/data /mnt/data
2. NFS server 搭建 (Ubuntu)
# 1. 装 NFS server
apt install -y nfs-kernel-server
# 2. 创建导出目录
mkdir -p /exports/data
chown nobody:nogroup /exports/data
chmod 0777 /exports/data # 简化(实际要细粒度)
# 3. 配置导出
cat > /etc/exports <<'EOF'
/exports/data 10.0.24.0/24(rw,sync,no_subtree_check,no_root_squash,fsid=0)
EOF
# 4. 应用
exportfs -ra
systemctl enable --now nfs-kernel-server
# 5. 验证
exportfs -v
showmount -e localhost
/etc/exports 字段解读
/exports/data 10.0.24.0/24(rw,sync,no_subtree_check,no_root_squash,fsid=0)
^^^^^^^^^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
(1) (2) (3)
| 标记 | 含义 |
|---|---|
| (1) | 导出的目录 |
| (2) | 允许哪些客户端(CIDR / hostname / *) |
| (3) | 选项 |
关键选项:
| 选项 | 含义 | 建议 |
|---|---|---|
rw / ro | 可写 / 只读 | 看需求 |
sync / async | server 写盘 sync vs async | 生产用 sync(数据可靠) |
no_subtree_check | 关闭子树检查 | 推荐开(性能 + 避免边缘 bug) |
root_squash (默认) | 把 root 用户映射成 nobody | 安全推荐 |
no_root_squash | 允许 root 真 root | 慎用(K8s pod 用 root 写 NFS 需要) |
all_squash | 所有用户映射 nobody | 公共只读场景 |
anonuid=N / anongid=N | 指定 squash 用户的 UID/GID | |
secure (默认) | 客户端必须用 <1024 端口 | 防火墙严要求 |
insecure | 允许 >1024 端口 | 容器场景常需要 |
fsid=N / fsid=root | 文件系统 ID | NFSv4 root 必加 |
多个导出
/exports/shared 10.0.24.0/24(rw,sync,no_subtree_check)
/exports/readonly *(ro,sync,no_subtree_check)
/exports/configs 10.0.24.5(rw,sync,no_subtree_check,no_root_squash) 10.0.24.6(ro,sync)
注意客户端和 (选项) 之间没空格:
/exports 10.0.24.0/24(rw) ✅
/exports 10.0.24.0/24 (rw) ❌ 不一样!第二种 = 默认 ro
少一个细节让你权限全错。
exportfs 操作
exportfs -v # 看当前 exports
exportfs -ra # reload 配置
exportfs -u <client>:<path> # 卸下某客户端
exportfs -au # 全卸
showmount —— 列 server 的 exports
# 本机
$ showmount -e localhost
Export list for localhost:
/exports/data 10.0.24.0/24
# 远端
$ showmount -e nfs.example.com
K8s 排错快速验证 NFS server 健康用。
3. NFS client mount
# 临时挂
mount -t nfs -o vers=4.2,rw,hard,timeo=600,retrans=2 \
nfs.example.com:/exports/data /mnt/data
# 持久(/etc/fstab)
echo "nfs.example.com:/exports/data /mnt/data nfs vers=4.2,rw,hard,timeo=600,retrans=2,_netdev,nofail 0 0" \
>> /etc/fstab
mount options 关键讲解
hard client 重试到天荒地老(默认)
soft 超时后报错给应用
生产 99% 用 hard——soft 在 NFS server 重启 / 网络抽风时会让应用拿到 EIO 错误、数据可能丢。
代价:server 真挂了 client 卡死、umount 也卡。
proto=tcp (默认 v4+)
proto=udp (老 v3 默认)
用 TCP——更可靠、更稳。UDP 在拥塞时丢包多。
sync client 同步写、慢但安全
async client 异步写、快但断电可能丢
mount 选项的 sync 和 server 端的 sync 含义不同。client 的 async(默认)+ server 的 sync = 数据安全的常见组合。
timeo=600 每次 RPC 超时秒数 (默认 60 = 6秒)
retrans=2 重试次数 (默认 3)
timeo 是十分之一秒、所以 timeo=600 = 60 秒。
NFS server 偶尔卡几秒 → 不希望立即失败。 但 hard mount 下 timeo 大不会影响最终行为、只影响内核 log 输出节奏。
noatime 访问文件不更新 access time
nodiratime 访问目录不更新 access time
relatime relative atime (默认)
NFS 上 atime 更新 = 每次 read 也变 write → 性能差 + 不必要写。生产几乎都加 noatime,nodiratime。
_netdev 告诉 systemd 这是网络依赖
nofail 挂不上不让系统启动失败
/etc/fstab 里 NFS 行必加 _netdev,nofail——见 fstab.md。
标准生产 mount options
mount -t nfs -o \
vers=4.2,proto=tcp,\
hard,timeo=600,retrans=2,\
rsize=1048576,wsize=1048576,\
noatime,nodiratime,\
_netdev,nofail \
nfs.example.com:/exports/data /mnt/data
| 选项 | 作用 |
|---|---|
vers=4.2 | NFSv4.2 |
proto=tcp | TCP(v4 默认) |
hard | 卡死也不放弃 |
timeo=600 | 60s 超时 |
retrans=2 | 重试 2 次 |
rsize/wsize=1M | 单次读写大小 1M(大文件大幅提升) |
noatime | 不更新 atime |
_netdev,nofail | systemd 友好 |
4. K8s 用 NFS 的几种方式
方式 1:直接 NFS volume(不推荐)
volumes:
- name: data
nfs:
server: nfs.example.com
path: /exports/data
readOnly: false
不经过 PV / PVC、每个 pod 自己挂。问题:
- 不能 K8s 管理(扩缩 / 监控 / 配额)
- 每个 pod 都自己写 server / path、易错
- 没有 StorageClass 抽象
只在快速测试时用。
方式 2:静态 NFS PV(推荐)
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-pv-shared
spec:
capacity:
storage: 100Gi
accessModes: [ReadWriteMany] # NFS 天生 RWX
persistentVolumeReclaimPolicy: Retain
nfs:
server: nfs.example.com
path: /exports/data
mountOptions: # 设 mount options
- vers=4.2
- hard
- timeo=600
- rsize=1048576
- wsize=1048576
- noatime
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: shared-data
spec:
accessModes: [ReadWriteMany]
resources:
requests:
storage: 100Gi
volumeName: nfs-pv-shared # 显式绑定
方式 3:NFS CSI driver(生产推荐)
# 装 csi-driver-nfs
helm install csi-driver-nfs csi-driver-nfs/csi-driver-nfs \
--namespace kube-system \
--set kubeletDir=/var/lib/kubelet
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-sc
provisioner: nfs.csi.k8s.io
parameters:
server: nfs.example.com
share: /exports/data # 父目录
subDir: ${pvc.metadata.namespace}-${pvc.metadata.name} # 每个 PVC 一个子目录
reclaimPolicy: Retain
volumeBindingMode: Immediate
mountOptions:
- vers=4.2
- hard
- timeo=600
- noatime
效果:
$ kubectl apply -f pvc.yaml
# StorageClass 自动:
# - 在 NFS server 的 /exports/data 下创建子目录 default-my-pvc/
# - 创建 PV 包装这子目录
# - bind PVC
方式 4:NFS Subdir External Provisioner(更早期方案)
类似 csi-driver-nfs、但用老式 external-provisioner。已经被 csi-driver-nfs 替代、新部署用 CSI。
5. NFS 在 K8s 里的"幽灵 pod" 问题
生产事故 #1:NFS server 挂导致所有 pod 卡 D
1. 集群 N 个 pod 用 NFS PV
2. NFS server 突然宕机(或网络断 5 分钟)
3. 客户端 hard mount → 应用 syscall 卡内核 D 状态
4. kubelet 健康检查也卡(因为也访问 NFS 文件)
5. **kubelet 失去和 apiserver 心跳** → 节点 NotReady
6. 所有 pod(不只是用 NFS 的)被影响
为什么 hard mount 还是用 hard?因为 soft 时应用直接拿 EIO、可能数据丢更糟。两害相权取其轻。
解决思路(不能完美、只能缓解):
- NFS server 高可用(双机热备 / DRBD / Ceph 等)
- 应用容忍 NFS 卡死(重试 / 降级)
- 优先用云 NFS(AWS EFS / Aliyun NAS)—— 底层有高可用
- 限制 NFS PV 用途(不要给系统组件用、只给业务)
检测 D 状态 pod
# 节点上
$ ps aux | awk '$8 ~ /D/ {print}'
USER PID ... STAT ... COMMAND
root 1234 ... D ... java -jar app.jar
^ 卡 D
D 状态 = 不可中断的睡眠(通常等 I/O)。kill -9 也杀不掉。
# 看进程在等啥
$ cat /proc/1234/wchan
nfs_wait_on_request ← NFS 在等
唯一办法:等 NFS 恢复 + 进程自己继续。或者 reboot 节点。
umount -l 救命招
NFS server 没救了、本地 mount 卡死,怎么办?
$ umount /mnt/data
umount: /mnt/data: target is busy
$ umount -f /mnt/data
umount.nfs: ... Stale file handle
$ umount -l /mnt/data # lazy umount
# 立即"断开"、但已经持有 fd 的进程继续等 NFS
# server 恢复后这些进程才能完成
umount -l 不会立即释放——已有 fd 的进程仍卡。但新进程不再访问 NFS,节点能恢复其它工作。
6. NFS 性能调优
1. rsize / wsize 调大
mount -o rsize=1048576,wsize=1048576 ...
默认很小(4K-64K)、网络好的话调到 1M。大文件传输性能 10x 提升。
注意:太大 + 网络丢包率高 = 重传整 chunk、反而慢。1M 是 sweet spot。
2. server 端 async(慎用)
/exports/data 10.0.0.0/24(rw,async,no_subtree_check)
^^^^^
async = NFS server 收到 write、放内存就返回成功、后台慢慢落盘。性能数倍。
代价:server 断电 = 这些数据丢。仅限"可以重新计算的数据"(缓存 / 中间结果)。
3. server 端线程数
# /etc/default/nfs-kernel-server
RPCNFSDCOUNT=64 # 默认 8 太少
并发客户端多时调大。看 /proc/net/rpc/nfsd 第 1 行:
th 8 0 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000 0.000
^ 8 个线程、0 次满负载、其它桶都 0
如果某桶非 0、特别是最后几个桶,说明线程经常满 → 调大 RPCNFSDCOUNT。
4. K8s 节点参数
# /etc/sysctl.d/99-nfs.conf
sunrpc.tcp_max_slot_table_entries = 128 # 默认 65、调大并发
sunrpc.tcp_slot_table_entries = 128
# 应用
sysctl --system
5. 监控 NFS client 性能
nfsstat -m # 看每个挂载点的统计
$ nfsstat -m
/mnt/data from nfs.example.com:/exports/data
Flags: rw,relatime,vers=4.2,...
...
nfsstat -c # client 操作统计
$ nfsstat -c
Client rpc stats:
calls retrans authrefrsh
123456 45 123456
^^^
重传数大 → 网络问题
Client nfs v4:
null read write commit open ...
0% 45% 30% 5% 2% ...
retrans 持续涨 = 网络丢包。
详见 nfsstat.md。
7. stale file handle —— 经典错误
$ ls /mnt/data
ls: cannot access '/mnt/data': Stale file handle
含义:这个 NFS 句柄已失效。常见原因:
- server 端文件被删 / 移动——client 还在用旧 inode
- server 重建文件系统(mkfs)后挂回来 → 所有 inode 重新分配
- server 改了 fsid(
/etc/exports里的fsid=改了)
修:重新挂:
umount /mnt/data
mount -t nfs ... /mnt/data
或者 lazy umount:
umount -l /mnt/data
mount -t nfs ... /mnt/data
K8s pod 里出现 stale → pod 重启就好(重新 mount)。
8. K8s NFS 用例 / 反用例
- 配置共享:多个 pod 读同一份配置 / 模板
- 静态资源:图片 / 静态 HTML(多个 web server pod 共享)
- 共享数据集:多个训练任务读同一数据
- 临时共享:CI / 构建产物共享
- 小文件 + 不需高 IOPS:日志归档、文档站点
- 数据库(MySQL / PostgreSQL)——NFS 没真正的文件锁、写性能差、断网灾难
- 高 IOPS 应用——NFS 远低于 SSD
- etcd / 关键有状态服务——NFS 单点会让 K8s 控制面挂
- 大量并发写同一文件——NFS 锁有 bug 历史
- OS-level cgroup / 系统文件——明显跨网络不合理
数据库为啥不能放 NFS
MySQL / PostgreSQL 假设底层有:
- 真正的 fsync 语义(写真落盘后才返回)
- 可靠的文件锁(pid file / row lock)
- 稳定低延迟
NFS 三条都不满足。生产经常听到 "把 MySQL 放 NFS 上数据库损坏"——根因都在这。
数据库永远用块存储(云盘 / Local PV / Ceph RBD)+ RWO PV。
9. 反面教材合集
反面 1:用默认 sync mount option(搞混了 client vs server)
mount -t nfs -o sync ... # client sync
client sync = 每次 write 都等 server ACK 才返回 = 超慢。
正确:client 默认 async(不写 sync)、server 端 export 设 sync。
反面 2:/etc/exports 客户端和括号之间有空格
/exports/data 10.0.0.0/24 (rw)
^^^^
有空格 = 给 10.0.0.0/24 默认权限(ro) + 给其它客户端 (rw)
exportfs -v 看实际生效:
/exports/data 10.0.0.0/24
(ro,sync,...) ← 实际 ro
/exports/data *
(rw,sync,...) ← 反而给所有人 rw
安全大坑。
正确:
/exports/data 10.0.0.0/24(rw,sync,no_subtree_check)
^
没空格
反面 3:K8s pod 写 NFS 报 EACCES
$ kubectl exec my-pod -- touch /data/test
touch: /data/test: Permission denied
K8s pod 用 root(UID 0)写、NFS server 默认 root_squash 把 root 映射成 nobody → nobody 没写权限。
修法 A:server 关 squash(有安全风险):
/exports/data 10.0.0.0/24(rw,no_root_squash,...)
修法 B:pod 用非 root 用户、确保 NFS 目录有对应权限:
securityContext:
runAsUser: 1000
fsGroup: 1000
server 上:
chown -R 1000:1000 /exports/data
修法 C:用 all_squash + anonuid=1000:
/exports/data 10.0.0.0/24(rw,all_squash,anonuid=1000,anongid=1000,...)
所有客户端写入都变成 UID 1000。
反面 4:NFS PV reclaim policy 设 Delete
spec:
persistentVolumeReclaimPolicy: Delete
nfs: ...
PVC 删 → K8s 试图删 PV → NFS PV 没有底层 driver 知道怎么"删 NFS 数据" → PV 状态卡 Released。
NFS PV 永远用 Retain。要清数据手动操作 NFS server。
反面 5:不设 mountOptions 默认挂
spec:
nfs:
server: ...
path: ...
# 没 mountOptions
默认 mount options 可能 server 端协商出最差组合:
- vers=3 而不是 4
- rsize=4096 而不是 1M
- 没 noatime
永远显式设 mountOptions。
反面 6:NFS PV 跨集群迁移
kubectl apply -f nfs-pv.yaml # 把 PV yaml 应用到新集群
PV claimRef 含旧集群的 PVC UID → 新集群里 PVC 创建 UID 不同 → bind 失败。
修:删除 yaml 里的 claimRef、uid 字段、status 段。
反面 7:用 * 暴露 NFS
/exports/data *(rw,sync,no_root_squash)
^
所有 IP!
NFSv3 没认证、NFSv4 默认 sys auth(仅 IP+UID)。* 暴露 = 任何能访问 server 的人能读写数据。
修:
- 限制 IP / CIDR
- 用 NFSv4 + Kerberos 真认证(复杂、生产很少)
- 防火墙做 IP 白名单
10. 排查 cheatsheet
"K8s pod 卡 ContainerCreating + NFS PV"
# 1. PV / PVC 状态
$ kubectl get pv,pvc
# Bound ?
# 2. NFS server 通吗
$ ssh m4 'showmount -e nfs.example.com'
Export list for nfs.example.com:
/exports/data 10.0.24.0/24
# 3. 节点能挂吗(手动测)
$ ssh m4 'mkdir -p /tmp/nfs-test && mount -t nfs nfs.example.com:/exports/data /tmp/nfs-test && ls /tmp/nfs-test'
# 4. K8s describe pod 看具体错
$ kubectl describe pod my-pod
Events:
FailedMount ... mount failed: ...
# 5. kubelet 日志
$ ssh m4 'journalctl -u kubelet --since "5 min ago" | grep -i nfs'
"NFS 慢"
# 1. 网络
$ mtr -rn -c 30 nfs.example.com
# 2. client 重传率
$ nfsstat -c | grep retrans
# 3. server 线程满
$ ssh nfs-server 'cat /proc/net/rpc/nfsd | head -1'
# th 8 1234 ... ← 1234 次满载
# 4. mount options
$ mount | grep nfs
# 看 rsize / wsize / vers
# 5. server 磁盘 / CPU
$ ssh nfs-server 'iostat -x 1 5'
"stale file handle"
# 客户端
$ ls /mnt/data
ls: Stale file handle
# server 端
$ ssh nfs-server 'ls -li /exports/data' # 看 inode 还在不
# 重新挂
$ umount -l /mnt/data
$ mount -t nfs ... /mnt/data
11. 下一步
| 篇 | 内容 |
|---|---|
| 00-storage-mental-model.md | 心智模型 |
| 01-container-volumes.md | 容器挂载 |
| 02-k8s-volumes.md | K8s Volume 大全 |
| 03-pv-pvc-storageclass.md | PV / PVC / CSI |
| 本篇 | NFS 深入 |
| 05-distributed-storage.md | Ceph / Longhorn |
| 06-storage-troubleshooting.md | 故障 runbook |