inotify 监控目录并触发告警的轻量脚本

线上服务器经常出这种事:某个目录本该只读,结果被人手动改了个配置文件;或者 /etc/nginx/conf.d 下突然多出一个 .conf,reload 之后整站 502。事后翻 ~/.bash_history 往往已经断档,审计日志又重又慢。真正需要的是一层极轻的「哨兵」:目录内容一变,几秒内就把变更路径、事件类型、操作者推到你面前。 inotify 是

线上服务器经常出这种事:某个目录本该只读,结果被人手动改了个配置文件;或者 /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

另外两点经验:

  1. 告警里永远带上主机名和完整路径。多个节点共用一个群机器人时,只写路径的告警等于没写。
  2. 启动时推一条「已启动」消息。如果某天你发现这条消息没来,说明服务根本没起来——这比每天收到变更告警更有价值。

小结

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 做事后取证」。