先说结论:NFS 卡死不在 NFS 服务上
挂载 NFS 共享目录的服务器最容易出现一种故障:SSH 登录上去后发现命令敲不动,随便跑个 ls 就卡住,终端不再返回。这时候管理员的第一反应通常是去看 NFS 服务端,登录挂载机的 NAS 或存储机重启 nfsd。但在绝大多数情况下,服务端一切正常,真正出问题的是客户端内核的 NFS 挂载状态:它把一个本该快速失败的远程操作,变成了一个没有超时的、不可中断的等待。
这种故障的麻烦之处在于它会放大:卡住的不只是一个命令,而是任何 touch 到这个挂载点的进程,包括 crontab 里的备份脚本、PHP 的日志写入、甚至 systemd 的开机流程。我处理过的两次事故里,一次是首页白屏,一次是新机器根本起不来——原因都只是一个 20 秒内就能修好的挂载参数。
第一步:用 /proc 里的三个文件判断现场
卡住的终端里什么都跑不了,所以要新开一个 SSH 连接。如果连新连接都建不起来,说明系统已经进入更严重的状态(通常是所有进程都堵在 D 状态,导致 fork 失败),这时候只能走控制台。能登录的情况下,按顺序看三个地方。
第一个是挂载参数本身。cat /proc/mounts | grep nfs 会输出每个挂载点使用的完整参数。重点看这三项:
hard 表示操作会无限重试,必须等服务端恢复
soft 表示超时后返回 EIO 错误给应用
timeo=600 单位是 0.1 秒,即 60 秒
retrans=2 每次请求的重传次数如果看到的是 hard,timeo=600,retrans=2,那么这个挂载点在服务端失联时理论上可以在 60×2 秒后由内核返回错误。但如果看到的是 hard 而没有明确 timeo(某些发行版默认值时有时无),或者是 hard,intr,那基本就是无限等待。特别注意 intr 这个参数:从 2.6.25 开始它就基本失效了,内核里对 NFS 请求的可中断支持只对 SIGKILL 以外的一部分信号生效,实际效果是「按 Ctrl+C 也不会返回」。所以很多老教程里教你加 intr,对现代内核没意义。
第二个是当前阻塞的进程:
ps -eo pid,stat,wchan:30,cmd | awk '$2 ~ /D/ ' | head -30NFS 卡死时这些进程的状态列是 D,wchan 列会显示 nfs_wait_on_request、rpc_wait_bit_killable 或 nfs_file_operations 这类函数名。D 状态的进程连 kill -9 都杀不掉,这是判断的关键——很多人反复 kill 却毫无反应,就以为是「进程僵死」,其实是内核在等远程响应,根本不检查信号。
第三个是 RPC 层的统计:
cat /proc/net/rpc/nfs | head -5
# net 0 0 0 表示这一行是计数类
# rpc 后面三个数是 calls / retrans / 认证刷新看 retrans 是否在持续增长:watch -n1 'grep ^rpc /proc/net/rpc/nfs'。如果在涨,说明请求发出去没有回应,网络或服务端的问题;如果完全不动,说明请求已经排在内核队列里等重传定时器,是 timeo 太长。
第二步:分清三种「NFS 卡」的成因
同样是卡住,成因差别很大,处置方式也完全不同。
成因一:服务端真的没了。存储机断电、nfsd 进程崩了、或者共享目录所在的分区掉了。特征是客户端 retrans 涨、服务端 showmount -e 无响应。这种只能等或先把客户端摘掉。值得注意的是,即使服务端恢复,客户端的 NFS TCP 连接也可能已经躺在 TIME_WAIT 之外,需要 umount -f 再重新挂载才能恢复,单纯重启 nfsd 往往不够。
成因二:网络路径上的设备丢包或重置。这个问题最隐蔽,因为 ping 可能是通的(ICMP 走的是另一条策略)。典型是云环境的跨可用区挂载,中间有个 NAT 网关连接数打满,或者安全组规则调整把 2049 以外的 rpcbind 端口挡了。诊断方式是在服务端和客户端同时抓包比对:
tcpdump -i any -nn 'port 2049' -c 50客户端看到大量重传(同一个序列号重复发),服务端却一个包都没收到,说明丢在中间。反过来服务端收到请求但不回,说明是服务端的导出配置或权限问题,此时客户端表现就是永久等待。
成因三:挂载参数本身不合理。这是最常见也最容易根治的一类。默认的 hard,timeo=600 意味着服务端失联时应用要等 60 秒才拿到错误,而如果上层是 PHP-FPM 这种每个请求都要 stat 一下共享目录的场景,60 个并发请求同时卡住,进程池瞬间就被占满,看起来像「网站被打挂了」。这类故障其实在服务端重启完成后会自动恢复,但过程中被打死的业务不会自己回来。
第三步:用 umount -f 和 -l 的区别救场
服务端恢复后,客户端挂载点往往还是不能用。umount /mnt/nfs 会直接报 device is busy,因为还有 D 状态进程在引用它。此时的正确顺序是:
- 先确认服务端已经能正常响应:在客户端执行
showmount -e 服务端IP,能列出导出说明 rpcbind 和 nfsd 都活了。 - 尝试
umount -f /mnt/nfs。-f是强制卸载,会在失败时改为中断挂载上的所有操作,然后重试。对 NFS 而言它会主动放弃在途请求。 - 如果
-f也报 busy,用umount -l /mnt/nfs(lazy umount)。它只是把挂载点从命名空间里摘掉,实际的内核数据结构要等所有使用者退出才释放。所以-l之后df里看不到这个挂载点了,但 D 状态进程仍然存在,需要等待或者重启。
实操中的取舍:-l 能让新进程不再卡住,但不能救已经卡住的进程。如果卡住的是关键服务(比如 mysqld 的 datadir 在 NFS 上,这是另一个严重的架构错误),那么 -l 之后再重新挂载到同一路径,新旧挂载会打架,反而可能二次出错。正确做法是先停掉引用该挂载点的 systemd 服务,等 D 状态进程自然消失,再挂载。
有一类情况是 umount -f 报 cannot unmount ... device is busy 且反复尝试都失败,说明有人在挂载点的当前工作目录里。用 lsof +D /mnt/nfs 2>/dev/null | head 找出来,但注意 lsof 本身也会卡在 D 状态目录上,所以更安全的做法是查 /proc/*/cwd 的符号链接:
for p in /proc/[0-9]*/cwd; do
t=$(readlink "$p" 2>/dev/null)
case "$t" in /mnt/nfs*) echo "$p -> $t";; esac
done第四步:长期方案——把 timeo 和 soft/hard 想清楚
故障处理完之后一定要回顾参数。hard 与 soft 的选择不是「哪个更好」,而是看数据的性质。
必须用 hard 的场景:任何写入型的、不能丢数据的操作。数据库文件、程序代码目录、日志的最终落盘点。soft 在超时后会给应用返回 EIO,对于写操作这意味着静默失败——一个日志框架可能只是抛个异常就继续跑,数据就丢了。
可以用 soft 的场景:只读的、可以快速降级的访问。比如缩略图缓存目录、只用来读配置的共享目录。这里的关键是应用必须正确处理 EIO,不能假设文件一定存在。
决定用 hard 之后,要接受「会阻塞」这个事实,所以重点转向缩短阻塞时间:
# /etc/fstab 中推荐的挂载参数
nas:/export/data /mnt/data nfs4 hard,timeo=100,retrans=3,rsize=1048576,wsize=1048576,nofail,x-systemd.automount,x-systemd.idle-timeout=600 0 0这里 timeo=100 是 10 秒,retrans=3 表示三次重传,理论上 30 秒后返回错误。相比默认的 60 秒已经好很多,而且 10 秒这个值对大多数内网 NFS 足够——如果内网 NFS 的响应超过 10 秒,那本身就是一个需要修的问题,而不是靠调大 timeo 掩盖。
三个容易被忽略的 fstab 参数:
nofail:挂载失败不阻止开机。没有这个参数,存储机没起来的话服务器就卡在启动阶段,只能进单用户模式改 fstab,这才是真正灾难性的。x-systemd.automount:延迟挂载,只有第一次访问该路径时才去连服务端。开机速度不受影响,且服务端晚起来也不影响。缺点是第一次访问会慢一下。x-systemd.idle-timeout=600:配合 automount 用,空闲 10 分钟自动卸载。这样服务端重启后,只要客户端空闲过,下次访问会重新挂载,自动恢复。
第五步:让卡死提前被发现
NFS 卡死最坏的情况不是它发生,而是它发生了半天没人知道。一个成本极低的探针脚本就能解决:
#!/bin/bash
# 每 5 分钟由 systemd timer 触发,超时 5 秒即判定异常
MNT=/mnt/data
if ! timeout 5 stat "$MNT" >/dev/null 2>&1; then
echo "NFS mount $MNT unresponsive at $(date -Is)" >> /var/log/nfs-probe.log
# 可选:尝试一次强制重挂
# umount -f "$MNT" && mount "$MNT"
fi这里的关键是 timeout 5。stat 一个 NFS 挂载点在正常情况下是几毫秒,用 5 秒作为阈值足够宽松。脚本由 systemd timer 而不是 cron 拉起,因为 cron 在这种场景下有个致命问题:如果上一次任务还卡在 D 状态,cron 会不断 fork 新实例,几十个卡住的进程会把进程表吃满,最终导致整机无法登录。systemd timer 默认不会并发执行同一个 unit,天然避开了这个坑。
两个常见的错误直觉
直觉一:重启客户端服务器就能解决。 这个做法确实有效,但代价是丢掉所有内存里的状态。而且如果 fstab 里没有 nofail,重启过程中会在挂载阶段卡住几分钟到十几分钟,如果存储机还没恢复,甚至根本起不来。所以「重启」不是解决方案,只是逃避。
直觉二:把网站文件放在 NFS 上可以多台机器共享。 这是一个非常流行的错误架构。NFS 的元数据操作(stat、open、readdir)延迟比本地盘高一个数量级,WordPress 这类每个页面要 stat 几十个文件的程序,性能会掉到原来的十分之一;更糟的是 flock 在 NFS 上的语义在不同实现间差异很大,Typecho 之类的程序写文件锁时会出现莫名其妙的并发错误。如果确实需要多机共享文件,静态资源走对象存储 + CDN,代码走 git 部署,只有用户上传的附件适合放共享存储,而且要配 cachefilesd 做本地缓存。
收尾:一次事故应该留下的东西
处理完这类故障,我通常会确认三件事已经落地:所有 NFS 挂载在 fstab 里都有 nofail 和明确的 timeo;有一个能报出来的探针脚本;以及一份记录服务端 IP、导出路径、挂载参数的清单。第三条看起来多余,但真正出事的时候,找谁要权限、服务端是哪台机器,往往比技术排查更耗时。