inotify 实时文件同步实战:给个人网站做一套秒级备份与篡改审计

当备份从「每天一次」变成「实时发生」

大部分个人站长对数据安全的认知,停留在「装个 crontab,每天凌晨 rsync 一次」这个层面。这套方案在绝大多数情况下是够用的——毕竟你也不是每秒都在改网站。但它有一个共同的软肋:从你改完文件到下一次备份之间,存在一个最长可达 24 小时的窗口。如果在下午三点误删了一个重要的 PHP 文件,或者被挂马的程序在下午四点开始篡改你的文章,你要么等到第二天早上才发现,要么在发现时已经没有任何干净副本了。

真正让人难受的还不是「丢了一点改动」,而是「不知道自己丢了什么」。rsync 的增量同步只告诉你「传输完成」,不告诉你「哪些文件发生了变化」。当网站上出现了一个不是你写的后门文件,rsync 会忠实地把它同步到备份服务器上——备份服务器反而成了恶意代码的传播节点,这大概是所有备份方案里最讽刺的一种失败。

本文要解决的,就是把备份从「定时任务」升级成「实时事件驱动」,并且在这个过程中额外获得一个能力:知道每一次文件变化是什么、发生在什么时候、是谁改的。核心工具就两个,inotifywait 负责监听文件事件,rsync 负责传文件,中间用一个队列和节流器把它们粘起来。这套方案我已经在几个站上跑了一年多,内存占用稳定在 20MB 以内。

先想清楚:为什么不直接监听后触发 rsync

最直觉的写法是「inotifywait 捕获到一个事件,立刻执行一次 rsync」。这个方案在两分钟内就会暴露问题:WordPress 保存一篇文章,底层会触发几十次文件写入(临时文件、rename、目录属性更新),编辑器的自动保存更是每个字都写一次盘。如果每次事件都触发一次全量目录扫描,你的磁盘 IO 会被打满,服务器负载瞬间飙起来。

所以正确的结构是「生产者-消费者 + 时间窗口合并」:

  • 生产者inotifywait 持续监听,把发生变化的路径追加到一个事件队列文件里
  • 消费者:一个独立的守护进程循环,每隔 10 秒读一次队列,把去重后的路径集合交给 rsync
  • 节流:消费者在一次处理完成后强制 sleep,确保不会出现「事件比传输快」的雪崩

这个结构的好处是无论文件变化多频繁,rsync 最多每 10 秒跑一次,而且每次跑的都是「这 10 秒内真正变化过的文件清单」。

安装与基础监听

# Debian/Ubuntu
apt-get install -y inotify-tools rsync

# 确认内核的 inotify 上限,默认 8192 常常不够用
sysctl fs.inotify.max_user_watches
sysctl fs.inotify.max_user_instances

# 提高上限(写进 /etc/sysctl.d/ 才能持久化)
cat > /etc/sysctl.d/99-inotify.conf <<'EOF'
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 512
EOF
sysctl --system

max_user_watches 这个参数值得单独说一句。每个被监听的目录会消耗一个 watch,一个中等规模的 WordPress 站点(包含 wp-content/uploads 下几万张图片)很容易超过默认的 8192。当 watch 耗尽时,inotifywait 不会报错退出,而是静默地不再收到新目录的事件——你会以为备份还在跑,实际上新创建的目录已经监控不到了。这种「静默失效」是这类方案最大的坑,后面会讲怎么用自检兜住。

监听时还有一个必须做的决定:递归监听到什么程度。全站递归最简单,但 node_modules、缓存目录、session 目录会带来海量无意义事件。更好的做法是列出必须监控的目录白名单:

# 只监控真正需要保护的目录
WATCH_DIRS=(
    /www/wwwroot/example.com/wp-content/themes
    /www/wwwroot/example.com/wp-content/plugins
    /www/wwwroot/example.com/wp-content/uploads
    /www/wwwroot/example.com/*.php
    /etc/nginx
    /etc/php
)

生产者脚本:inotifywait 的正确用法

#!/bin/bash
# /usr/local/bin/zz-watch.sh
# 持续监听,把变化路径追加到队列文件
set -uo pipefail

QUEUE=/var/lib/zzsync/queue.txt
LOG=/var/log/zzsync/watch.log
WATCH_ROOT=/www/wwwroot/example.com/wp-content

mkdir -p "$(dirname "$QUEUE")" "$(dirname "$LOG")"

# 启动时清空队列,避免上次残留导致误判
: > "$QUEUE"

inotifywait -m -r -q \
    --timefmt '%Y-%m-%d %H:%M:%S' \
    --format '%T|%w%f|%e' \
    -e modify,create,delete,move,attrib \
    "$WATCH_ROOT" 2>>"$LOG" | \
while IFS='|' read -r ts path ev; do
    # 过滤掉明显不需要同步的临时文件
    case "$path" in
        *.swp|*.tmp|*~|*.log|*/cache/*|*/session/*) continue ;;
    esac
    printf '%s|%s\n' "$ev" "$path" >> "$QUEUE"
done

这里有几个细节值得展开。第一,set -uo pipefail 只对管道前的命令生效,管道后半段的 while 循环运行在子 shell 里,所以循环内部的变量不会影响到主 shell——这正是我们想要的,因为整个脚本就是一层转发。不要在循环里做任何复杂逻辑,它必须尽可能快,否则 inotify 的内核缓冲区(默认 16384 字节)会溢出。第二,move 事件要特别关注,因为绝大多数编辑器保存文件的方式是「写临时文件 → rename 覆盖原文件」,rename 在 inotify 里表现为 MOVED_TO,如果你的过滤规则只认 createmodify,会漏掉一大半真实的文件更新。第三,那个 case 过滤列表不是可选项而是必需品,没有它队列文件会以每秒几行的速度膨胀。

队列文件本身需要一个轮转机制,否则长时间运行后会变成几百 MB 的巨物。用一个简单的判断就行:如果队列超过 5 万行,说明消费者已经跟不上生产者的速度了,这时候直接截断并告警比继续堆积更有意义:

# 在消费者循环里
LINES=$(wc -l < "$QUEUE")
if [ "$LINES" -gt 50000 ]; then
    echo "$(date) queue overflow: $LINES lines, truncating" >> "$LOG"
    : > "$QUEUE"
fi

消费者脚本:批量去重 + rsync 传输

#!/bin/bash
# /usr/local/bin/zz-sync.sh
set -uo pipefail

QUEUE=/var/lib/zzsync/queue.txt
LOG=/var/log/zzsync/sync.log
STATE=/var/lib/zzsync/last_list.txt
REMOTE="backup@10.0.0.8:/data/backup/example/"
INTERVAL=10

log() { echo "$(date '+%F %T') $*" >> "$LOG"; }

while true; do
    sleep "$INTERVAL"

    if [ ! -s "$QUEUE" ]; then continue; fi

    # 1) 取出并清空队列(先重命名,避免边读边写)
    mv "$QUEUE" "${QUEUE}.processing"
    cut -d'|' -f2- "${QUEUE}.processing" | sort -u > /tmp/zz_paths.txt

    # 2) 过滤掉已经不存在的路径(delete 事件)
    : > /tmp/zz_files.txt
    while IFS= read -r p; do
        [ -e "$p" ] && printf '%s\n' "$p" >> /tmp/zz_files.txt
    done < /tmp/zz_paths.txt

    COUNT=$(wc -l < /tmp/zz_files.txt)
    if [ "$COUNT" -eq 0 ]; then
        rm -f "${QUEUE}.processing"
        continue
    fi

    # 3) 用 files-from 精确传输变化文件
    if rsync -a --files-from=/tmp/zz_files.txt \
              --relative --no-implied-dirs \
              -e 'ssh -i /root/.ssh/backup_key -o StrictHostKeyChecking=yes' \
              / "$REMOTE" >> "$LOG" 2>&1; then
        log "synced $COUNT files"
        cp /tmp/zz_files.txt "$STATE"
    else
        log "rsync FAILED for $COUNT files, requeueing"
        # 失败时把路径退回队列,下轮重试
        while IFS= read -r p; do echo "retry|$p"; done \
            < /tmp/zz_files.txt >> "$QUEUE"
    fi

    rm -f "${QUEUE}.processing" /tmp/zz_paths.txt /tmp/zz_files.txt
done

这段脚本里有三个我认为必须解释清楚的设计。第一是「先 mv 再处理」:如果直接读取队列文件同时生产者还在往里追加,会出现读写交错导致行被截断,表现为 rsync 报「no such file」但实际上文件存在。用 mv 把队列改名,生产者会立刻创建一个新的空队列继续写,两边互不干扰,这是 shell 里实现「原子取队列」最简单可靠的方式。

第二是 --files-from 配合 --relative:这是整个方案的关键。普通的 rsync -a src/ dest/ 会扫描整个源目录树来对比,几万个文件的话每次都要几秒到几十秒;而 --files-from 只处理清单里的文件,一次同步几个文件通常在 0.1 秒内完成。必须加 --relative,否则 rsync 会把文件全部平铺到目标目录,把所有目录结构都丢掉。

第三是失败重试:网络抖动、目标机重启都会让 rsync 失败。如果失败后什么都不做,那批变化就永久丢失了,而且你并不会知道。把路径退回队列让它下轮重试,配合日志里的 rsync FAILED,能让这种问题在变成事故之前被发现。

把「谁改了什么」变成可查询的历史

实时同步解决了「丢改动」的问题,但「被篡改」的问题需要一个额外的能力:清楚知道每个文件的变化时间线。这可以在生产者脚本里顺手做掉——把事件按天写入归档日志,形成一个轻量级的文件审计流水:

# 在生产者 while 循环开头插入
DAY=$(date +%Y%m%d)
printf '%s %s %s\n' "$ts" "$ev" "$path" >> "/var/log/zzsync/audit-$DAY.log"

# 日常查询:今天被改过的 PHP 文件
grep -E 'MODIFY|CREATE' /var/log/zzsync/audit-$(date +%Y%m%d).log \
    | grep '\.php$' | awk '{print $3}' | sort -u

# 找出深夜(0-6点)被修改的可执行文件——典型的挂马时间点
awk '$1 ~ / (0[0-6]):/ && $3 ~ /\.(php|js|sh)$/ {print}' \
    /var/log/zzsync/audit-*.log | head -50

第二条命令我强烈建议做成每天早上的定时邮件。正常的站长不会在凌晨三点改 wp-content/themes 里的 PHP 文件,如果这条命令有输出,你的站点很可能已经被入侵了。这比任何安全插件都更早、更准确地发现问题,因为它检测的是「文件系统层面的真实变化」,而不是「可疑的登录行为」。

归档文件本身也要注意权限和保留策略:chmod 600chattr +a(只追加),保留 90 天。攻击者拿到 www 用户的权限后第一件事往往是清理日志,把审计日志放在只有 root 能读的目录里、并设为只追加,能显著提高被发现后的溯源能力。

用 systemd 管起来,并且加上自检

两个脚本都用 systemd unit 托管,这样崩溃能自动重启,日志也能统一到 journald:

# /etc/systemd/system/zz-watch.service
[Unit]
Description=zz1984 file watch producer
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/zz-watch.sh
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

消费者的 unit 除了同样配置外,再加一行 ExecStartPre=/bin/mkdir -p /var/lib/zzsync,避免重启后目录不存在导致启动失败。

最后回到前面提到的「静默失效」问题。inotifywait 进程活着,不代表它还在正常工作——watch 耗尽、目录被删除重建、内核队列溢出,这三种情况都会让它「假活」。所以必须有一个外部心跳来检测:消费者脚本每次成功同步都更新一个时间戳文件,再用一个独立的 timer 检查这个文件的年龄:

#!/bin/bash
# /usr/local/bin/zz-heartbeat.sh —— 每天检查一次
STATE=/var/lib/zzsync/last_sync
if [ ! -f "$STATE" ]; then
    echo "backup: no sync state file" | mail -s "ZZSYNC ALERT" you@example.com
    exit 1
fi
AGE=$(( $(date +%s) - $(stat -c %Y "$STATE") ))
# 正常情况下每天都有变化,超过 48 小时没同步几乎肯定异常
if [ "$AGE" -gt 172800 ]; then
    echo "backup: last sync $((AGE/3600))h ago" | mail -s "ZZSYNC ALERT" you@example.com
    exit 1
fi
# 顺便确认进程还活着
systemctl is-active --quiet zz-watch.service || \
    echo "producer not active" | mail -s "ZZSYNC ALERT" you@example.com

这个心跳脚本是整个方案里最不起眼、但最关键的一环。没有它,你会在某一天需要恢复数据时,才发现备份已经静默失效了三个月——这种事情在运维圈里的发生频率远比你想象的高。监控「监控本身」,是让自动化真正可信的唯一方式。

Last modification:September 18th, 2026 at 12:24 pm

Leave a Comment