一、从一条“文件改了但页面没变”的报障说起
上周接到一个报障:运维把一个配置文件改好保存了,服务却没重新加载,页面表现和改之前一模一样。这不是人粗心——他们是靠一个脚本监控文件变化来自动重载的,脚本日志里赫然写着:
inotify_add_watch: No space left on device磁盘明明还有 60% 空闲。这句报错的真实含义和它的字面意思完全不同:inotify 的 watch 描述符用完了,不是磁盘满了。
Linux 内核为每个 inotify 实例分配 watch 表,总上限由 fs.inotify.max_user_watches 控制,默认值在不同发行版上从 8192 到 65536 不等。这个数在十年前够用,放到今天就是笑话:一个开发机的 IDE、一个日志采集工具、一个备份脚本,加起来轻松吃掉几万个 watch。
二、三个内核参数,先分清各自管什么
sysctl fs.inotify.max_user_watches # 每个用户可注册的 watch 总数
sysctl fs.inotify.max_user_instances # 每个用户可创建的 inotify 实例数
sysctl fs.inotify.max_queued_events # 每个实例的事件队列长度- max_user_watches:最常见的那道坎。它按用户统计,不是按进程,所以同一用户下所有进程共享这一份额度。
- max_user_instances:每个
inotify_init调用消耗一个实例。某些库对每个被监控的目录都开一个实例,于是一个监控 5000 个目录的应用直接打爆这个数(默认 128)。 - max_queued_events:事件风暴时的事件缓冲。太多事件来不及读,队列满了之后超出的部分被丢弃——丢弃是静默的,你只会发现“有些变化没触发”,日志里什么都不会有。
No space left on device 这个错误码同时被这几个限制复用,这就是为什么大家第一反应都会去看磁盘。
三、定位:谁在吃 watch
第一步永远是记账。用 inotify 的自带接口看每个实例占了多少:
for f in /proc/*/fdinfo/*; do
pid=$(echo "$f" | cut -d/ -f3)
if grep -q '^inotify' "$f" 2>/dev/null; then
cnt=$(grep -c '^inotify' "$f")
echo "$pid $(cat /proc/$pid/comm 2>/dev/null) $cnt"
fi
done | sort -k3 -rn | head -20输出是按 watch 数排好序的进程列表,第一名往往就是罪魁。我在这台机器上跑出的结果是四个 Electron 编辑器实例占了四万多,再加一个备份工具的递归监控,总量刚好越过 65536 的线。
第二步,确认总量和上限:
cat /proc/sys/fs/inotify/max_user_watches
# 实时统计当前用户已用的 watch 数
find /proc/*/fdinfo -name '*' -exec grep -h '^inotify' {} + 2>/dev/null | wc -l如果“已用”接近上限的 80%,就该动手了,不要等到真打爆。
四、调整上限:怎么写才留得住
临时值用 sysctl 直接写,重启就没了。要持久化,写进 /etc/sysctl.d/ 下自己的文件,而不是改 /etc/sysctl.conf——后者会被包管理器覆盖或与其它工具的条目冲突:
cat > /etc/sysctl.d/60-inotify.conf <<'CONF'
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 1024
fs.inotify.max_queued_events = 32768
CONF
sysctl --system
sysctl fs.inotify.max_user_watches具体数值怎么定?我的经验是按用途区分:
- 开发机 / 桌面:524288 起步。IDE、Docker、各种语言服务器都很能驻留 watch。
- 生产 Web 服务器:不要盲目调大。生产上真正需要监控文件变化的只有少数几个场景(配置热重载、日志采集),几十万 watch 说明有程序在递归监控不该监控的目录,那是要修程序而不是调参数。
- 文件服务器 / 备份节点:监控整个数据盘是常见需求,这类节点给到 1048576 并接受它占用的内存。
内存代价要算清:每个 watch 在 x86_64 上大约占 1KB 内核内存(watch 结构加路径字符串),一百万 watch 就是 1GB 不可换出的内存。这不是免费的午餐。
五、修正监控策略:这才是根治
参数调大只能止血。真正要改的是监控方式,这里有三个高频错误。
错误一:递归监控整个目录树
很多脚本用 inotifywait -r 监控一个目录,看起来省事,实际每一个子目录都要注册一个 watch,再乘上每个目录里的文件。一个 10 万文件的目录树能直接吃掉 15 万 watch。
正确做法是只监控会被修改的目标,不监控整个树。比如只关心 Nginx 的 vhost 配置,就只监控 /etc/nginx/conf.d 这一层,用 close_write 事件触发 nginx -t 再 reload。
inotifywait -m -e close_write,moved_to --format '%w%f' /etc/nginx/conf.d \
| while read -r f; do
case "$f" in
*.conf) nginx -t && systemctl reload nginx || echo "测试失败,未重载: $f" ;;
esac
done注意这里的 -m(持续监控)配合 while 循环,比一个事件起一次进程高效得多;close_write 而不是 modify,否则编辑器写文件时会被触发几十次。
错误二:用轮询假装监控
反过来,也有团队怕 inotify 出问题,干脆用 find -newer 每分钟扫一遍。这个做法在中小目录上是完全可以接受的:inotify 的语义在某些网络文件系统(NFS、部分对象存储挂载)上根本不可靠——远端修改不会在本地产生事件。判断标准很简单:如果被监控的目录在网络文件系统上,就该用轮询。
错误三:事件被丢弃却不知道
队列溢出是静默的,所以监控程序必须做两件事:一是用非阻塞读或独立的读线程,保证事件处理不要阻塞在主循环里;二是定期做一次全量校验,用轮询兜底,把“漏掉的变更”捞回来。生产环境里我通常让监控程序每小时做一次轻量比对,成本极低,能救回绝大多数漏事件。
六、把上限占用做进监控
这次事故暴露的另一件事是:没人知道这个数字正在逼近临界点。在我的所有机器上,下面这个脚本都挂在 cron 里每小时跑一次:
used=$(find /proc/*/fdinfo -name '*' -exec grep -h '^inotify' {} + 2>/dev/null | wc -l)
limit=$(cat /proc/sys/fs/inotify/max_user_watches)
pct=$(( used * 100 / limit ))
echo "inotify used=$used limit=$limit pct=${pct}%"
if [ "$pct" -ge 80 ]; then
echo "WARN: inotify watch 使用率 ${pct}%" >&2
exit 1
fi这个统计跑一次在普通机器上不到 0.3 秒,几乎没有代价。关键是它能在“用户报到线上”之前几天就把趋势暴露出来——watch 用量是渐进增长的,很少一夜之间翻倍。
顺便提一个细节:上面的 find -exec ... + 在文件数极大时有参数长度上限的保护,安全;而 find -exec ... \;(分号结尾)会对每个文件起一次进程,在 /proc 上能跑出几分钟。这个小差别在排查时能省掉很多等待。
七、容器与 systemd 环境下的两个额外坑
上面讲的都是裸机上的形态,容器化部署后 inotify 还有两处会咬人。
第一处是容器的 watch 额度与宿主共享。max_user_watches 是内核级的全局参数,按 UID 统计,容器并不隔离它。所以一个容器的监控程序吃满额度,同 UID 下宿主和其它容器一起遭殃,而报错只出现在别人的日志里,排查方向很容易错。判断方法是在宿主上跑第三节那条 /proc/*/fdinfo 统计,把每个 PID 映射回容器(docker inspect 或 /proc/PID/cgroup),就能立刻定位是哪个容器吃掉的。
第二处是挂载方式决定了事件能不能到达。inotify 依赖文件系统产生事件,绑定挂载(bind mount)在多数情况下能正常传递,但以下几种会失效或者只传递一部分:
- 以只读方式挂载的目录,部分场景下事件被抑制——写入方在容器外,容器内看到的是“文件变了但没事件”;
- 宿主的根目录整体挂进容器(
-v /:/host)时,watch 是加在宿主的文件系统上的,容器销毁后残留的 watch 由宿主内核回收,但注册数量会翻倍; - 网络文件系统(NFS、CIFS)不论怎么挂载都不可靠,这类场景必须用轮询,没有任何参数能修好。
systemd 服务里还有个相关的细节:如果监控程序是被 systemd 拉起的,它的 watch 饿死时 inotify_add_watch 返回 -1,很多程序把这当成“文件不存在”就跳过了,日志里只有一行 warning。对这种服务,建议在 unit 里加一条启动顺序依赖,确保它比被监控的目录就绪更晚,同时把 Restart=on-failure 打开——watch 加失败时进程退出重试,比静默失明强得多。
八、小结:参数是止血,策略才是治病
回到开头那个报障。最终的处置是三步:先把 max_user_watches 从 65536 提到 524288 让服务立刻恢复;再把那个递归监控整个数据目录的脚本改成只监控配置层;最后把使用率监控挂进 cron。
三件事的顺序不能反。先恢复、再修根因、最后加监控——如果顺序反过来,你会在恢复之前就先被“为什么监控没告警”这个问题绊住。这条经验适用于几乎所有容量类事故,不只是 inotify。
