线上服务器经常出这种事:某个目录本该只读,结果被人手动改了个配置文件;或者 /etc/nginx/conf.d 下突然多出一个 .conf,reload 之后整站 502。事后翻 ~/.bash_history 往往已经断档,审计日志又重又慢。真正需要的是一层极轻的「哨兵」:目录内容一变,几秒内就把变更路径、事件类型、操作者推到你面前。
inotify 是 Linux 内核提供的文件系统事件通知机制,配合 inotifywait(来自 inotify-tools)就能做出几十行、无依赖、CPU 占用近乎为零的监控脚本。本文给出一份可直接投产的脚本,把「监听 → 过滤 → 限流 → 告警」四件事拆干净,并列出真实踩过的坑和报错。
一、先确认内核与工具链是否就绪
inotify 从 2.6.13 起进入内核主线,现代发行版默认都有,问题通常出在「实例数 / watch 数上限」和「工具是否安装」上。
# 1. 内核是否编译了 inotify
grep -i inotify /boot/config-$(uname -r)
# 期望看到:CONFIG_INOTIFY_USER=y
# 2. 当前三个关键上限(每个都是 per-user 的)
cat /proc/sys/fs/inotify/max_user_watches
cat /proc/sys/fs/inotify/max_user_instances
cat /proc/sys/fs/inotify/max_queued_events
# 3. 谁在占用 inotify 实例(排查 "No space left on device" 必备)
find /proc/*/fd -lname anon_inode:inotify 2>/dev/null | cut -d/ -f3 | sort | uniq -c | sort -rn
# 4. 安装工具
# Debian / Ubuntu
apt-get install -y inotify-tools
# RHEL / CentOS / Rocky
yum install -y epel-release && yum install -y inotify-tools
# 确认版本,1.x 与 3.x 的部分参数行为不同
inotifywait --help | head -n 1
典型报错是 Failed to watch /data; upper limit on inotify watches reached。默认 max_user_watches 在不少云主机上只有 8192,监控一个 node_modules 级别的目录立刻打满。临时与永久调整:
sysctl -w fs.inotify.max_user_watches=524288
sysctl -w fs.inotify.max_user_instances=1024
sysctl -w fs.inotify.max_queued_events=32768
cat >> /etc/sysctl.d/99-inotify.conf <<'EOF'
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 1024
fs.inotify.max_queued_events = 32768
EOF
sysctl --system
还有一个更隐蔽的报错:inotifywait: error while watching ...: No space left on device,但它不是磁盘满,而是 max_user_instances 用完了——常见于同一台机器上跑了好几个 IDE 远程同步、rsync 守护、日志采集器。
二、inotifywait 的核心参数与事件语义
写脚本前必须先搞清楚「要监听哪些事件」,否则告警会被自己淹没。下面这张表是实战中最常打交道的部分:
| 事件 | 触发时机 | 监控配置目录时的用法 |
|---|---|---|
create |
目录内新建文件/子目录 | 高价值,发现新增配置/webshell 落地 |
delete |
文件被删除 | 高价值,误删或恶意清理 |
moved_to / moved_from |
rename / mv 的落点/源点 | 高价值,mv 是绕过 create 的常见手法 |
modify |
文件内容被写入 | 噪音大,一次写会触发多次,需配合限流 |
attrib |
chmod / chown / touch 改元数据 | 中价值,可发现 chmod +x 提权前兆 |
close_write |
可写方式打开后关闭 | 替代 modify 的最优选择,一次写只触发一次 |
open / access |
打开 / 读取 | 不要开,日志量爆炸 |
关键参数:
-m(monitor):常驻监听,不加就只触发一次后退出。-r:递归。注意它只自动建立已有子目录的 watch,新建的子目录需要额外处理(下面讲)。-q:只输出事件本身,去掉 banner,方便脚本解析;-qq连错误也静默。--format:自定义输出,强烈建议用%T %w%f %e,其中%w是被监听目录、%f是文件名、%e是事件。--timefmt '%F %T':配合%T输出可读时间。-e:显式列出事件,多个用逗号分隔,不带空格。--excludei:大小写不敏感的正则排除,用于屏蔽\.swp$、\.log$等。
inotifywait -m -r -q \
--timefmt '%F %T' \
--format '%T %w%f %e' \
-e create,delete,moved_to,moved_from,attrib,close_write \
--excludei '\.(swp|swx|tmp|log)$' \
/etc/nginx/conf.d
输出形如:
2024-05-21 10:12:03 /etc/nginx/conf.d/site.conf CLOSE_WRITE,CLOSE
2024-05-21 10:12:47 /etc/nginx/conf.d/backdoor.php CREATE
2024-05-21 10:13:10 /etc/nginx/conf.d/site.conf MOVED_FROM
三、可投产的监控告警脚本
下面这份脚本把职责切得很清楚:inotifywait 只负责吐事件,主循环负责过滤、按「路径+事件」去重限流、按告警通道分发。整体单文件,只用 bash + inotifywait + curl,不需要 Python。
#!/usr/bin/env bash
# /usr/local/bin/dir-watch.sh
# 用法: dir-watch.sh -d /etc/nginx/conf.d -w "https://oapi.dingtalk.com/robot/send?access_token=xxx"
set -uo pipefail
WATCH_DIR=""
WEBHOOK=""
STATE_DIR="/var/lib/dir-watch"
THROTTLE_SEC=60 # 同一 路径+事件 的最小告警间隔
BATCH_SIZE=20 # 单次告警最多携带的行数
HOSTNAME_S="$(hostname -s)"
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
usage() { echo "usage: $0 -d WATCH_DIR [-w WEBHOOK_URL]" >&2; exit 2; }
while getopts ":d:w:h" opt; do
case "$opt" in
d) WATCH_DIR="$OPTARG" ;;
w) WEBHOOK="$OPTARG" ;;
h) usage ;;
*) usage ;;
esac
done
[[ -n "$WATCH_DIR" && -d "$WATCH_DIR" ]] || { log "watch dir missing: ${WATCH_DIR:-<empty>}"; usage; }
mkdir -p "$STATE_DIR" || { log "cannot create $STATE_DIR"; exit 1; }
# ---------- 告警通道 ----------
alert() {
local title="$1" body="$2"
log "ALERT: $title"
if [[ -z "$WEBHOOK" ]]; then
return 0
fi
# 钉钉机器人 markdown 格式;换成企业微信/Slack 只需改 JSON 结构
local payload
payload=$(printf '{"msgtype":"markdown","markdown":{"title":"%s","text":"#### %s\\n\\n%s"}}' \
"${title//\"/\\\"}" "${title//\"/\\\"}" "${body//\"/\\\"}")
if ! curl -sS --max-time 5 -H 'Content-Type: application/json' \
-d "$payload" "$WEBHOOK" -o /dev/null; then
log "webhook push failed for: $title"
fi
}
# ---------- 限流:同一 key 在 THROTTLE_SEC 内只告警一次 ----------
throttled() {
local key="$1" now last
local f="$STATE_DIR/$(printf '%s' "$key" | md5sum | cut -d' ' -f1)"
now=$(date +%s)
last=$(cat "$f" 2>/dev/null || echo 0)
if (( now - last < THROTTLE_SEC )); then
return 0 # 命中限流
fi
printf '%s' "$now" > "$f"
return 1
}
# ---------- 启动前检查 inotify 余量 ----------
WATCHES=$(cat /proc/sys/fs/inotify/max_user_watches)
log "watching $WATCH_DIR, max_user_watches=$WATCHES"
alert "目录监控已启动: $HOSTNAME_S" "- 目录: \`$WATCH_DIR\`\n- 进程: \`$$\`"
# ---------- 主循环 ----------
# 用 coproc 避免 inotifywait 的 stderr 混进事件流
coproc IW {
exec inotifywait -m -r -q \
--timefmt '%F %T' \
--format '%T|%w%f|%e' \
-e create,delete,moved_to,moved_from,attrib,close_write \
--excludei '\.(swp|swx|tmp|log|pid)$' \
"$WATCH_DIR"
}
trap 'kill "$IW_PID" 2>/dev/null; log "stopped"; exit 0' INT TERM
while IFS='|' read -r ts path events; do
[[ -z "${path:-}" ]] && continue
# 只取主事件做展示,CLOSE_WRITE,CLOSE 这类复合事件取第一个
main_ev="${events%%,*}"
if throttled "$path:$main_ev"; then
continue
fi
body=$(printf -- '- 主机: `%s`\n- 时间: `%s`\n- 路径: `%s`\n- 事件: `%s`' \
"$HOSTNAME_S" "$ts" "$path" "$events")
alert "目录变更: $main_ev" "$body"
done <&"${IW[0]}"
log "inotifywait exited unexpectedly, retrying in 5s"
sleep 5
exec "$0" "$@"
几个设计取舍值得说明:
- 用
close_write而不是modify。一次vim保存会触发多次modify(写 buffer 分片),而close_write只在文件描述符关闭时触发一次,能砍掉 80% 以上的噪音。 - 限流 key 是「路径 + 事件」而不是路径,这样同一文件的「被改」和「被删」都会各报一次,不会被误吞。
- 用
coproc分离 stdout。直接管道读取时,inotifywait的告警信息(watch 失败、目录被删)会走 stderr 打到终端,混在事件中造成解析错乱。 - 自动重启。被监控目录被
rm -rf之后inotifywait会退出,脚本exec自己重来,避免哨兵静默死亡。
交给 systemd 托管
# /etc/systemd/system/dir-watch.service
[Unit]
Description=Directory inotify watcher
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/dir-watch.sh -d /etc/nginx/conf.d -w https://oapi.dingtalk.com/robot/send?access_token=REPLACE_ME
Restart=always
RestartSec=5
User=root
StateDirectory=dir-watch
[Install]
WantedBy=multi-user.target
chmod 0755 /usr/local/bin/dir-watch.sh
systemctl daemon-reload
systemctl enable --now dir-watch.service
systemctl status dir-watch.service --no-pager
journalctl -u dir-watch.service -f
注意 webhook 里的 token 直接写进 unit 文件会随 systemctl cat 泄露给有 sudo 权限的人。生产环境建议改成 EnvironmentFile=/etc/dir-watch.env,文件权限 0600,脚本里用 ${WATCH_WEBHOOK} 读取。
四、必须处理的四个坑
坑 1:新建子目录不会被递归监听
inotifywait -r 只在启动那一刻对已存在的子目录建立 watch。之后 mkdir /data/newdir,/data/newdir 内部的变化收不到——这正是「webshell 先建目录再落文件」能绕过监控的原因。解决办法是监听 create,isdir 并手动补 watch,但 inotifywait 单进程做不到动态添加。工程上的两种替代:
- 用
inotifywait监听create,发现目录后由脚本重启自身(本文脚本已带自动重启逻辑,把-e加上create即可,代价是重启窗口内的事件可能丢)。 - 换
incron或watchman,代价是引入新依赖。
坑 2:inotify 不跟踪「谁改的」
inotify 事件只有路径和类型,没有 UID/PID。要定位操作者,只能配合审计子系统:
# 打开对目标目录的写入审计(CentOS/Rocky 默认装有 auditd)
auditctl -w /etc/nginx/conf.d -p wa -k nginx_conf_watch
# 之后查
ausearch -k nginx_conf_watch -i | tail -n 40
输出里会有 uid=1001 auid=1001 ... comm="vim" exe="/usr/bin/vim",这才是能定责的数据。把 ausearch 的结果拼进告警 body,排查效率提升非常明显。
坑 3:NFS / CIFS / overlayfs 上不生效
inotify 依赖本地文件系统的 inode 事件,跨网络文件系统的变更不会触发。如果监控目录在 NFS 挂载点上,或者容器里监控的是 hostPath 挂载进来的目录,很可能是「脚本跑着但一条事件都没有」。验证方法:
df -T /data
# 若 Type 显示 nfs4 / cifs / fuse.*,inotify 基本不可用
stat -f -c %T /data
坑 4:事件队列溢出导致丢事件
短时间大量写入(比如解压一个 tar 包)会把内核事件队列打满,日志里出现:
inotifywait: EVENT QUEUE OVERFLOW
此时中间的事件已永久丢失。对策是把 fs.inotify.max_queued_events 从默认 16384 调到 65536,并在脚本里对 OVERFLOW 单独告警——「发生了溢出」本身就是重要信号,说明该目录写流量异常。
# 在脚本 while 循环内补充
if [[ "$events" == *"OVERFLOW"* ]]; then
alert "事件队列溢出: $path" "监控目录写入量过大,事件可能丢失"
continue
fi
五、让告警不变成骚扰的调参建议
| 场景 | 推荐事件集 | 限流窗口 |
|---|---|---|
/etc/nginx/conf.d 等配置目录 |
create,delete,moved_to,moved_from,close_write |
60s |
| Web 站点根目录 | create,moved_to,排除 \.(jpg|png|css|js|map)$ |
30s |
| 定期发布目录(每天多次) | delete,moved_from,attrib |
300s |
敏感文件本身(/etc/passwd) |
close_write,attrib |
10s |
另外两点经验:
- 告警里永远带上主机名和完整路径。多个节点共用一个群机器人时,只写路径的告警等于没写。
- 启动时推一条「已启动」消息。如果某天你发现这条消息没来,说明服务根本没起来——这比每天收到变更告警更有价值。
小结
inotify 方案的优势是「零依赖、快、便宜」,代价是它只看得到本地文件系统的元事件,既看不到操作者,也不覆盖网络存储。把它定位成「第一时间发现异常」的哨兵,而不是审计系统,边界就清楚了:告警负责报警,auditd 负责定责,两者组合才是完整链路。脚本本身控制在百行内,便于 review 和随业务调整事件集。
FAQ
Q1:脚本一直在跑,但一条事件都不触发?
按顺序排查:① df -T 确认目录不是 NFS/CIFS;② 确认监控的是目录而不是目录内的具体文件(文件被 vim 写入会走 rename,watch 会失效);③ inotifywait -m -r /your/dir 手动跑一遍,另开终端 touch /your/dir/test 验证;④ 看 journalctl -u dir-watch.service 有没有 Failed to watch。
Q2:报 No space left on device 但磁盘没满?
多半是 fs.inotify.max_user_instances(默认 128)被打满,用前面 find /proc/*/fd -lname anon_inode:inotify 的命令找出占用进程,常见元凶是 IDE 远程同步和同步盘客户端。临时杀掉,长期按上文调大 sysctl。
Q3:文件名带空格或中文会不会解析错乱?
本文脚本用 | 作分隔符——inotify 事件名不会含 |,但文件名可能。稳妥做法是把 --format 改成 %T%0%w%f%0%e,用 NUL 作分隔,循环里改成 while IFS= read -r -d '' 三段读取。含空格的路径在 alert 里已通过字符串变量传递,不会被 word splitting 破坏。
Q4:能不能监控多个目录?
可以。脚本启动判断时把 -d 改成可重复参数,收集成数组,然后 inotifywait ... "${DIRS[@]}" 一次传入即可,输出里的 %w 会自然带上各自目录前缀。注意多目录共用一份限流表是按完整路径做 key,不会互相干扰。
Q5:和 auditd、inotifywatch、fswatch 怎么选?
inotifywatch 是统计工具,跑一段时间后退出并给出汇总,不适合常驻告警。fswatch 跨平台(Linux 后端也是 inotify),适合 macOS 开发机。auditd 能拿到 UID/PID 但开销和配置复杂度都更高。生产上常见组合是「inotify 做实时报警 + auditd 做事后取证」。