凌晨两点被叫起来处理一个挂载异常,登录服务端一看,/etc/exports里明明白白写着只允许192.168.10.0/24访问,可对面192.168.20.31那台机器照样把目录挂上了,还往里写了几个测试文件。这不是 exports 写错了,而是限制 IP 这件事在 NFS 上从来就不是改一行配置那么干净——它至少牵涉到 exports 规则解析、mountd 与 nfsd 的监听范围、主机防火墙策略、以及客户端那边的挂载缓存四道关,任何一层留了口子,白名单就等于写在纸上。这篇就把我从零搭一套带 IP 访问限制的 NFS 共享的完整过程摊开讲:每个参数为什么这么写、验证从哪两端各测一遍、以及那些手册里不会写但你大概率会撞上的坑。适合已经能熟练敲 Linux 命令、打算把共享目录用在小集群或实验室环境的同学,也适合此刻正在排查"为什么我的 IP 限制没起作用"的人。
1. 先把"限制 IP"拆开看:NFS 的访问控制到底分几层
很多人对 NFS 权限的直觉是"exports 写了谁能挂,那就只有谁能挂"。这个直觉在 NFSv3 上勉强对一半,在 NFSv4 上甚至不成立。要真正把 IP 限制做扎实,得先搞清楚一次挂载请求在服务端都经过了哪些环节。
1.1 exports 里的 IP 规则只是第一道门
客户端要挂一个 NFSv3 导出,第一步不是直接跟 2049 端口打交道,而是先找 111 端口的 rpcbind,问"mountd 在哪个端口",然后向 mountd 发一个 MOUNT 请求,报上自己的 IP 和想挂的路径。mountd 这时候才去读/etc/exports(准确说是读/var/lib/nfs/etab这张已经编译好的表),判断这个 IP 在不在允许列表里。在,就返回一个文件句柄;不在,直接拒绝。
问题在于,挂载成功之后,客户端的每一次 read/write 走的是 nfsd(2049),而 nfsd 在内核里维护自己的授权缓存,它并不每次读文件都回头去问 mountd"这个 IP 还行不行"。所以你在服务端改了 exports,把某台机器踢掉,只要那台机器还挂着、缓存还没过期,它照样能读写。这个时间窗口短则几十秒,长的时候能拖到你重启服务为止。
NFSv4 更彻底一点:它压根不需要 mountd,伪根和导出信息全部由 nfsd 自己处理,exports 的检查在内核里完成。好处是防火墙只需要开一个 2049,坏处是你exportfs -ra之后如果 nfsd 没重新加载,规则同样是旧的。
我把这四层整理成一张表,排查时按顺序往下捋,基本不会漏:
| 层级 | 生效位置 | 管什么 | 改完要不要动别的 |
|---|---|---|---|
| exports 客户端列表 | mountd / nfsd | 哪些 IP 或网段能挂 | 要exportfs -ra,老连接不受影响 |
| 导出参数(括号里那串) | nfsd | 挂上之后能读还是能写、root 怎么映射 | 同样要重新导出 |
| 主机防火墙 | 内核 netfilter | 包能不能到 111/2049/20048 | 立即生效,但要考虑已建立连接 |
| 客户端挂载状态 | 客户端内核 | 已经挂着的还继续用旧句柄 | 必须 umount 再 mount |
看完这张表你就能理解,为什么"我改了 exports 为什么对面还能进"这个问题有至少三个不同答案,得看那台机器是刚尝试挂载、还是早就挂上了。
1.2 实际生效的是 etab 而不是 exports
这点值得单独拎出来说,因为它是新手最容易卡住的地方。/etc/exports只是源文件,exportfs会把它和/etc/exports.d/*.exports合并、做语法检查、展开主机名,最后生成/var/lib/nfs/etab。真正被 mountd 查询的是 etab。
# 看源文件写了什么 cat /etc/exports # 看实际生效的是什么(这才是真相) cat /var/lib/nfs/etab # 重新加载全部导出 exportfs -ra # 只重新导出某一个路径 exportfs -o rw,sync 192.168.10.0/24:/srv/nfs/share我遇到过不止一次:某人编辑 exports 时手滑多打了一个空格,或者把一个中文全角逗号粘了进去,exportfs -ra报错但他没看输出,结果 etab 里还是旧规则,他在那儿对着 exports 反复检查半小时。养成习惯,改完 exports 一定跟一句exportfs -v,把当前导出的路径、允许的客户端、参数全打出来,肉眼过一遍。
另外还有一张表叫/var/lib/nfs/rmtab,记录着"当前有哪些客户端挂着哪些导出"。这个文件有时候会残留脏数据——客户端非正常关机、网络断了没 umount,记录就一直挂着。它一般不影响新客户端挂载,但会让showmount的输出骗你。真要在维护窗口里清干净,停掉 nfs-server 之后删掉 rmtab 再启动,比什么都快。
2. 服务端打底:从装包到把端口钉死
在谈限制之前,得先有一套能正常跑起来的服务端。这一步没什么花活,但有两个细节决定了后面防火墙好不好配。
2.1 组件清单与最小安装
一套完整的 NFS 服务端其实是几个东西拼起来的:rpcbind负责端口映射(NFSv3 必需,纯 NFSv4 可以不要),nfs-server是主服务,背后还带着rpc.mountd、rpc.statd、rpc.idmapd这些辅助进程。文件锁和 NFSv4 的 id 映射在新版本里已经并进主服务了,不用单独 enable。
包名按发行版系走:企业级发行版上装nfs-utils,Debian 系装nfs-kernel-server,客户端那边对应装nfs-utils或nfs-common。别小看这个差别,我见过有人在 Debian 上systemctl start nfs-server死活起不来,折腾半天发现服务名应该叫nfs-kernel-server。
# 企业级发行版系 dnf install -y nfs-utils rpcbind systemctl enable --now rpcbind systemctl enable --now nfs-server # Debian 系 apt install -y nfs-kernel-server systemctl enable --now nfs-kernel-server # 确认服务真的活着 systemctl status nfs-server --no-pager rpcinfo -p localhostrpcinfo -p localhost这一步别省,它会把当前注册到 rpcbind 上的所有程序、版本、协议、端口列出来。如果 nfs、mountd、portmapper 都在,说明服务端骨架是通的;如果 mountd 那一行缺失,后面客户端挂载会报 "Program not registered",你在 exports 里找一辈子也找不出原因。
2.2 把随机端口钉死,防火墙才有得配
这是整个搭建过程里我最想强调的一步。NFSv3 的 mountd、statd、lockd 默认是随机端口的——每次重启服务换一个。你如果按"开放 2049 就完事"的思路配了防火墙,第一次能用,服务重启一次就全挂了。
解决办法是提前在配置文件里把这些端口固定下来。企业级发行版系一般在/etc/nfs.conf里配,也兼容老的/etc/sysconfig/nfs:
[mountd] port = 20048 [statd] port = 32765 outgoing-port = 32766 [lockd] port = 32767 udp-port = 32767改完systemctl restart nfs-server,再跑一次rpcinfo -p,确认 mountd 老老实实待在 20048 上。端口号选多大无所谓,只要不和系统里已有的服务打架、并且落在 1024 以上(避开特权端口段,省得以后跟别的服务抢)。
提示:如果你的环境可以完全用 NFSv4.1 及以上,其实可以跳过固定端口这一步,因为 NFSv4 只用 2049 一个 TCP 端口。但如果集群里有老系统、或者某些备份软件还依赖 NFSv3,那就老老实实把端口钉死,兼容性优先。
目录这边顺手把权限也定好。我习惯用一个独立的挂载点而不是直接导出/home或者/data的根:
mkdir -p /srv/nfs/share # 用一个固定的属主,后面 anonuid 要和它对上 groupadd -g 3000 nfsgrp useradd -u 3000 -g nfsgrp -s /sbin/nologin nfsuser chown -R nfsuser:nfsgrp /srv/nfs/share chmod 2775 /srv/nfs/share2775里的那个2是 setgid 位,作用是让这个目录下新建的文件自动继承父目录的属组。共享目录里这个位非常实用——不然 A 用户建的文件属组是 A 的私有组,B 用户就算在同一个共享组里也读不了,这种"看起来权限都对就是打不开"的问题能耗掉你一整个下午。
3. exports 里 IP 的几种写法,以及括号参数的取舍
exports 的语法看起来简单,一行一个路径加客户端列表,但写法上的细微差别会导致完全不同的行为,这一节把常用的几种掰开说。
3.1 单机、网段、通配符三种写法的实际差别
最精确的是写单个 IP,只有这个地址能挂。适合点到点的场景,比如只给一台备份服务器开:
/srv/nfs/share 192.168.10.20(rw,sync,no_subtree_check)网段写法用 CIDR,这是生产上最常用的:
/srv/nfs/share 192.168.10.0/24(rw,sync,no_subtree_check)还有一种通配符写法192.168.10.*,看着和 CIDR 差不多,实际语义不一样——它只匹配"前三段是 192.168.10"的地址,掩码是固定的 /24,你没法用192.168.8.*去表达一个跨段的 /23。而且通配符在某些版本上还会触发反向解析,行为不如 CIDR 确定。我的建议是能用 CIDR 就用 CIDR,通配符只在临时调试时图省事用一下。
| 写法 | 匹配范围 | 是否需要 DNS | 推荐度 |
|---|---|---|---|
192.168.10.20 | 单台 | 否 | 高,点对点场景 |
192.168.10.0/24 | 网段 | 否 | 最高 |
192.168.10.* | 仅 /24 通配 | 视版本 | 中,调试用 |
client01.intra | 域名解析结果 | 是,正反解都要 | 低,DNS 不稳就别用 |
* | 所有 | 否 | 极低,等于没限制 |
关于域名那一条多提一句:exports 里写主机名的话,服务端需要能把这个名字解析成 IP,并且很多实现还要求反向解析也能对上。内网 DNS 一旦抽风或者 PTR 记录没配,表现就是明明网络通、服务也在跑,客户端就是挂不上。所以除非你有稳定的内网 DNS,否则一律用 IP 或 CIDR,省事得多。
想给同一个路径配多个客户端来源,直接在同一行后面用空格隔开,每组带自己的括号参数:
/srv/nfs/share 192.168.10.0/24(rw,sync,no_subtree_check) \ 192.168.20.0/24(ro,sync,no_subtree_check) \ 10.8.0.15(rw,sync,no_root_squash,no_subtree_check)这个例子里,10 网段读写、20 网段只读、单台 10.8.0.15 有 root 权限——这种"分级授权"在实际运维里非常常见,比给所有人开同一个权限安全得多。
3.2 括号里那串参数,才是真正决定安全边界的东西
客户端列表管的是"谁能挂",括号里的参数管的是"挂上之后能干什么"。这两个是正交的,很多人只盯着 IP 列表,把括号随手抄了个rw,sync就完事,结果留了一堆后门。
root_squash是默认开启的,意思是客户端的 root 用户访问共享目录时,会被映射成服务端的匿名用户(通常是 nobody)。这个设计很关键——否则任何一台客户端上的 root 都能随便改共享目录里的文件属主,等于把服务端的权限体系交出去了。凡事有例外,比如你需要跑 rsync 保留属主、或者某些数据库备份工具要求 root 直写,那就得开no_root_squash,但开之前先问自己一句:这台客户端是不是完全可信?它被攻破的代价我能不能承受?
no_subtree_check现在是推荐写法。早年默认开子树检查,nfsd 每次文件访问要验证"这个文件确实在我导出的那个子树里",目录被重命名时容易报 stale file handle。关掉之后少一层校验,性能和稳定性都更好,代价是极端情况下的路径穿越风险——但如果你导出的是整个目录而不是目录里的某个深层子路径,这个风险基本可以忽略。
sync和async是一对经典的取舍。sync表示数据落盘才给客户端返回成功,async是先写内存就返回。async 快,但在服务端异常掉电时有丢数据的可能。共享目录存的是配置文件、代码、测试数据这类东西,我一般无脑sync;只有做大文件传输、对吞吐敏感、且数据可重生成的场景才考虑 async。
把这些组合起来,我常用的两套模板是这样的:
# 通用共享:读写,root 映射成匿名,属主统一到 nobody /srv/nfs/share 192.168.10.0/24(rw,sync,no_subtree_check,root_squash,all_squash,anonuid=3000,anongid=3000) # 备份落地:只允许一台机器写,root 不压缩 /srv/nfs/backup 192.168.10.20(rw,sync,no_subtree_check,no_root_squash)第一行里all_squash加anonuid/anongid是个很实用的组合:不管客户端来的是哪个用户,统统映射成服务端的 3000。这样多台机器往同一个目录写文件时,不会出现"这台机器写的文件那台机器改不了"的属主混乱,权限问题一次性消掉。代价是所有客户端的用户隔离就没了,谁能挂上谁就能随便写——所以必须配合严格的 IP 白名单一起用,这也正是本文主题的意义所在。
4. 防火墙这一层:光靠 exports 拦不住扫描
有个认知误区值得点破:exports 的 IP 列表是在 RPC 层做的检查,它只能拦住"已经走到 mountd/nfsd 面前"的请求。如果防火墙把 111 和 2049 对全网开放,那你至少暴露了两个风险:一是别人可以扫到你的 NFS 服务在做端口探测,二是历史上 NFS 相关组件出过不少漏洞,暴露面越小越好。所以 IP 限制要做两层:exports 写白名单,防火墙也写白名单。
4.1 用 firewalld 的富规则精确放行
firewalld 自带nfs、mountd、rpc-bind三个 service 定义,但直接--add-service是对所有来源放行的,不符合我们的要求。正确做法是用 rich rule 绑定源地址:
# 先确认默认区域是 drop 还是 public,决定策略基调 firewall-cmd --get-default-zone firewall-cmd --list-all # 只允许业务网段访问 rpcbind、nfs、mountd firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.10.0/24" service name="rpc-bind" accept' firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.10.0/24" service name="nfs" accept' firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.10.0/24" service name="mountd" accept' firewall-cmd --reload firewall-cmd --list-rich-rules如果默认区域不是 drop 而是 public(很多发行版的出厂设置),那你还要确认 public 区域里没有把 nfs 服务敞开:
# 如果之前误开过,先撤掉 firewall-cmd --permanent --remove-service=nfs firewall-cmd --permanent --remove-service=mountd firewall-cmd --permanent --remove-service=rpc-bind firewall-cmd --reload一个很容易被忽略的细节:--add-rich-rule里的 source 只写 IP 不写端口,意思是"这个来源访问该 service 定义的所有端口"。而nfsservice 的定义里包含 2049 的 tcp 和 udp,mountd包含 20048,rpc-bind包含 111。如果你前面改过 mountd 端口但没同步更新 service 定义,这条规则就落空了。所以固定端口那一步做完之后,顺手检查一下firewall-cmd --info-service=mountd里的端口列表对不对得上。
4.2 纯 NFSv4 环境可以只留一个洞
如果你的所有客户端都支持 NFSv4.1 及以上,事情会简单很多。NFSv4 的所有交互都在 2049 这一个 TCP 端口上完成,rpcbind 和 mountd 都可以不参与。防火墙策略简化为一条:
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.10.0/24" port port="2049" protocol="tcp" accept' firewall-cmd --reload服务端配置也要配合,用伪根的方式组织导出。exports 里加一条fsid=0的根,客户端挂载时用/或者子路径:
/srv/nfs 192.168.10.0/24(ro,fsid=0,crossmnt,no_subtree_check) /srv/nfs/share 192.168.10.0/24(rw,sync,no_subtree_check)crossmnt的作用是让客户端能穿过伪根看到下面挂载的其他文件系统,不加的话子目录里的独立挂载点看不见。客户端挂载写成mount -t nfs -o vers=4.1 192.168.10.10:/share /mnt——注意路径是相对伪根的,不是服务端的绝对路径。这个路径映射关系是 NFSv4 新手最常懵的地方,报错通常是 "No such file or directory",但服务端目录明明存在。
两种方案的取舍看环境:
| 维度 | NFSv3 + 固定端口 | NFSv4.1 单端口 |
|---|---|---|
| 防火墙规则条数 | 3 到 5 条 | 1 条 |
| 兼容老客户端 | 好 | 差 |
| 路径写法 | 服务端绝对路径 | 相对伪根 |
| 需要 rpcbind | 是 | 否 |
| 我的建议 | 混合环境用 | 全新建环境优先 |
5. 验证:从被放行和被拒绝两端各测一遍
配置改完之后最忌讳的就是"服务起来了,我看着没问题"就收工。IP 限制这种东西,只有从一台白名单内的机器和一台白名单外的机器各测一次,才知道是真生效还是假生效。
5.1 白名单内的机器该看到什么
先在服务端自查一遍导出表:
exportfs -v showmount -e localhostexportfs -v输出里会列出每个路径、它的客户端列表、以及展开后的参数。如果这里显示的客户端列表和你 exports 里写的不一致,说明语法被解析错了,回去检查空格和逗号。
然后到一台白名单内的客户端上:
# 装客户端工具 dnf install -y nfs-utils # 先看能不能列到导出,这一步走的是 mountd showmount -e 192.168.10.10 # 正式挂载 mkdir -p /mnt/share mount -t nfs -o vers=4.1 192.168.10.10:/srv/nfs/share /mnt/share # 验证读写 df -h /mnt/share touch /mnt/share/test.$(date +%s) ls -ln /mnt/sharels -ln用数字显示 uid/gid,这样能直接看出root_squash和all_squash有没有按预期把属主映射成 3000。如果建出来的文件属主是 65534(nobody)而不是你配的 anonuid,说明 all_squash 没生效或者 anonuid 写错了位置。
还有个小技巧:挂载完之后用nfsstat -m看挂载参数,确认实际生效的 vers、rsize、wsize、timeo 是多少。有时候/etc/fstab里写的和服务端协商出来的不一样,这个命令能给出真相。
5.2 被拒绝的机器该报什么错
这一步才是验证 IP 限制的关键。找一台不在白名单里的机器(同网段但地址不同,或者干脆另一台虚拟机),执行同样的挂载:
showmount -e 192.168.10.10 mount -t nfs 192.168.10.10:/srv/nfs/share /mnt/test预期是失败,而且失败信息能帮你定位是卡在哪一层。我把常见的几种报错和对应的根因整理成表,这张表我贴在自己的排查笔记里用了很久:
| 报错信息 | 大概率卡在哪层 | 下一步查什么 |
|---|---|---|
access denied by server while mounting | exports 客户端列表 | exportfs -v看该 IP 在不在列表里 |
clnt_create: RPC: Program not registered | mountd 没起或端口不对 | rpcinfo -p看 mountd 是否注册 |
Connection timed out | 防火墙 drop | 抓包看 111/2049 有没有 SYN 回包 |
No route to host | 防火墙 reject 或路由问题 | 检查路由表和防火墙默认动作 |
Permission denied(挂载成功后写) | root_squash 或目录权限 | ls -ln看属主,检查目录 mode |
Stale file handle | 服务端导出变化或子树检查 | 客户端 umount 重挂,服务端重导出 |
mount: wrong fs type | 客户端缺 nfs 工具 | 装 nfs-utils / nfs-common |
有一个坑我强调一下:showmount -e在不同版本上对未授权来源的行为并不一致,有的返回空列表,有的直接超时,有的居然还能列出来。所以不要拿 showmount 的输出来判断 IP 限制是否生效,唯一可靠的判据是真正执行 mount 的结果。这一点吃过亏的人应该都有共鸣——曾经为了一个"showmount 能列出但挂不上"的现象查了两小时,最后发现纯粹是 showmount 的展示逻辑问题,压根不是配置错。
想看清楚请求到底走到哪一步,服务端开着tcpdump看比什么都直接:
# 盯住这个客户端的所有 NFS 相关流量 tcpdump -i any -nn host 192.168.20.31 and \( port 111 or port 2049 or port 20048 \)如果连 SYN 都没有,那是防火墙在网络层就丢了;如果有 SYN 但没有后续,可能是防火墙半开;如果完整握手了但请求被拒,那就是 exports 层面的事。三种情况对应三种改法,比猜快一百倍。
6. 几个真踩过的坑,以及生产上的加固习惯
配置能跑通只是及格线,能不能在半年后不出事,靠的是这些细节。
6.1 写不进去的时候,别急着关 root_squash
"共享目录挂上了但是写不进去"是我被问得最多的问题,而十个人里有八个的解决方案是把root_squash改成no_root_squash。这个改法确实能立刻解决问题,但它同时把服务端的权限体系交出去了——客户端 root 可以 chown 走共享目录里任何文件,包括本来不属于这个共享的其他数据。
正确的排查顺序应该是这样的:先在客户端ls -ld /mnt/share看目录的属主和权限位,再id看当前用户,然后ls -ln看已有文件的 uid/gid。共享目录最常见的三个权限问题:目录属主是 root 且权限 755(其他用户无写权限),目录属组和客户端用户的组对不上,或者 setgid 位没设导致新建文件属组乱掉。这三种情况用chown -R 3000:3000加chmod 2775基本都能解决,压根不需要动 squash 参数。
真正需要no_root_squash的场景其实很窄:备份软件要保留原文件的属主信息、或者某个应用必须以 root 身份在共享目录里创建带特殊属主的文件。这种时候我的做法是把这条导出的作用域收到单个 IP,不要对网段开放:
/srv/nfs/backup 192.168.10.20(rw,sync,no_root_squash,no_subtree_check)一台机器、一个用途、一个参数,风险可控。给整个网段开 no_root_squash 是我见过最典型的"图省事换来的长期隐患"。
6.2 客户端挂载参数与断网后的行为
服务端配好只是半边天,客户端的挂载参数决定了网络抖动时你的业务是卡死还是优雅降级。hard和soft这一对必须理解清楚:hard表示请求无限重试,服务端挂了客户端进程就一直卡在 IO 上等;soft表示超时后返回错误,业务能感知到失败但可能读到不完整数据。
对于挂载配置文件、代码这类只读场景,我倾向soft加上合理的超时,避免服务端一挂整个应用跟着僵死:
192.168.10.10:/srv/nfs/share /mnt/share nfs rw,soft,timeo=100,retrans=2,vers=4.1,_netdev,noatime 0 0timeo的单位是 0.1 秒,timeo=100就是 10 秒;retrans=2是重试次数。这两个值合起来决定了"多久判定失败",设太小在网络抖动时会误报,设太大又失去了 soft 的意义,10 秒两次是我在千兆内网里比较稳的一组。
_netdev这个选项经常被忘。它的作用是告诉系统"这个挂载依赖网络,等网络就绪之后再挂",不加的话开机时可能出现挂载失败、进入紧急模式的情况,尤其是在用 systemd 的新系统上。再加上noatime减少元数据写入,对共享目录这种读多写少的场景收益明显。
客户端这边还有个容易忽略的点:IP 限制生效后,老挂载不会自动断开。你在服务端把某台机器从白名单里删掉,那台机器如果还挂着,它的读写请求会继续成功,直到连接重置或缓存过期。所以下线一台客户端的正确流程是:先在客户端umount,再改服务端 exports,最后exportfs -ra。顺序反了,你就会看到"我明明删了它还能访问"的诡异现象,然后开始怀疑人生。
真要在服务端强制踢人,可以用exportfs -f刷新内核里的导出缓存,或者干脆重启 nfs-server。-f的效果更温和,不会影响其他正常客户端,我一般优先用它。
最后分享一个日常巡检的小习惯:每周挑个时间跑一遍exportfs -v和firewall-cmd --list-rich-rules,把两份输出对一下,看看 exports 里的白名单和防火墙里的白名单是否一致。这个动作花不了一分钟,但能提前发现"某人临时加了一台机器调试完忘了删"这类问题。共享存储的访问面是会随着时间慢慢变宽的,没人盯着,半年后你会发现当初那个精巧的白名单已经变成了半个*。