rsync + inotifywait 实时文件同步实战:多机代码/备份准实时一致,防抖合并、flock 串行与静默失败监控

为什么我还是选 rsync + inotifywait 做实时同步

做个人站长的这些年,我试过不少文件同步方案。Syncthing 好用,但它是端到端的双向同步,版本冲突处理起来让人头大;NFS 挂载简单,一旦网络抖动整个目录会卡死;对象存储(OSS/S3)适合静态资源,但要跑脚本、要改配置文件就不方便。真正让我多年没换的,是 rsync 配合 inotifywait 这套「事件驱动 + 增量传输」的组合。它足够土,但足够可控:哪台机器是主、哪台是备,脚本里写得明明白白,出问题时日志也清清楚楚。

这篇文章我不讲泛泛的概念,只讲清楚三件事:inotifywait 到底监听什么、rsync 的增量参数怎么配才不会误删、以及这套方案在真实生产里会踩哪些坑。文中命令可以直接抄进你服务器的 systemd service 里用。

一、先理解两个工具各自的角色

inotifywait 来自 inotify-tools 包,它的作用是借助 Linux 内核的 inotify 机制,监听某个目录下文件系统的变化事件:创建(create)、修改(modify)、删除(delete)、移动(moved_to / moved_from)、属性变更(attrib)、关闭写入(close_write)等等。注意,它只负责告诉你「什么变了」,不负责搬运文件。它是一条命令,运行后会阻塞,每发生一个事件就往标准输出打一行。

rsync 负责真正的搬运。它的核心优势是「差分传输」:只传目标端没有或不同步的文件,并且对相同文件可以用滚动校验(默认算法)比较块级差异,只传变化的块。配合 --delete 还能让目标端与源端严格一致。

把两者拼起来就是:inotifywait 发现变了 → 触发一次 rsync → rsync 把差量推过去。没有变化时 rsync 不跑,CPU 与带宽几乎为零,这正是它比「每分钟全量 rsync」高明的地方。

二、安装与最朴素的监听脚本

Debian/Ubuntu 下:

apt update
apt install -y rsync inotify-tools

CentOS/Rocky 下:

yum install -y rsync inotify-tools

先做最朴素的验证,确认事件能被抓到。开一个终端跑:

inotifywait -mrq --timefmt '%Y-%m-%d %H:%M:%S' --format '%T %w%f %e' \
  -e modify,create,delete,move,attrib,close_write \
  /data/www

参数含义要记牢:-m 持续监听(不加它只报一次就退出),-r 递归子目录,-q 静默(只输出事件行,不打印启动信息),--format 决定输出格式,-e 后面列事件类型。这里有个新手必踩的坑:如果你只监听 modify,很多编辑器(尤其 Vim、Sublime)保存文件时是先写临时文件再 rename 覆盖,真正的「保存完成」事件其实是 moved_to。所以实践里我习惯把 close_write 和 move 一起监听起来,确保「文件写完」才触发同步。

三、把监听脚本写成生产级

下面的脚本是我用了很久的模板,做了三件工程上的加固:防抖合并(避免一次保存触发多次同步)、串行锁(避免多个 rsync 叠在一起互相打架)、失败重试。

#!/bin/bash
# /usr/local/bin/live-sync.sh
SRC="/data/www/"
DST="backup@10.0.0.12:/data/www/"
LOG="/var/log/live-sync.log"
LOGFILE="/tmp/sync.log"
LOCK="/tmp/live-sync.lock"

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

# 用 flock 保证同一时刻只有一个 rsync 在跑
do_sync() {
  (
    flock -x 200
    rsync -az --delete --partial --bwlimit=8000 \
      --exclude '.git/' --exclude 'cache/' --exclude '*.tmp' \
      -e "ssh -p 22 -o StrictHostKeyChecking=no" \
      "$SRC" "$DST" >> "$LOG" 2>&1
    log "sync done, exit=$?"
  ) 200>"$LOCK"
}

log "watcher started"
inotifywait -mrq --timefmt '%F %T' --format '%T %w%f %e' \
  -e modify,create,delete,move,attrib,close_write "$SRC" |
while read -r tm file ev; do
  # 简单防抖:事件到达后等 1 秒,把这一秒内的变化合并成一次同步
  sleep 1
  log "event: $ev $file"
  do_sync
done

几个关键参数值得单独说:

  • -a 等价于 -rlptgoD,保留符号链接、权限、时间戳、属主属组、设备文件;-z 传输时压缩,适合跨公网。
  • --delete 让目标端删除源端已删的文件。⚠️ 这是最危险的参数,源目录路径末尾的斜杠千万不能写错。正确的写法是 /data/www/(同步目录的内容)而不是 /data/www(会连目录本身一起塞进目标)。
  • --partial 断点续传,网络断了重跑不用从头来。
  • -e "ssh ..." 指定走 SSH,比默认的 rsh 安全。
  • 排除了 cache/ 和 .git/ 这类高频变化、又不需要同步的目录,否则会疯狂触发。

四、为什么这个脚本我用 systemd 而不是 nohup

直接 nohup ./live-sync.sh & 也能跑,但服务器重启后就没了,而且进程挂了没人拉起来。正确做法是交给 systemd 托管:

# /etc/systemd/system/live-sync.service
[Unit]
Description=Live file sync watcher
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/live-sync.sh
Restart=always
RestartSec=5
User=root
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

然后 systemctl daemon-reload && systemctl enable --now live-sync。注意 LimitNOFILE=65536 这一行:inotify 每监听一个目录就消耗一个文件描述符,当你的站有 node_modules 这种几万个小文件时,默认的 1024 很容易爆掉。同时还要关注内核参数 /proc/sys/fs/inotify/max_user_watches,如果监听的目录数超过它的默认值(通常 8192 或 65536),会静默失败。调法:

sysctl fs.inotify.max_user_watches=524288
sysctl fs.inotify.max_user_instances=512
# 永久生效
echo "fs.inotify.max_user_watches=524288" >> /etc/sysctl.conf
echo "fs.inotify.max_user_instances=512" >> /etc/sysctl.conf

五、SSH 免密与安全边界

rsync 走 SSH 需要免密。别用 root 直连,建一个只做同步的专用账号:

# 在主服务器上生成密钥
ssh-keygen -t ed25519 -C "sync-key" -f ~/.ssh/sync_key -N ""
ssh-copy-id -i ~/.ssh/sync_key.pub backup@10.0.0.12

更严格一点,可以在目标机的 ~/.ssh/authorized_keys 里给这把 key 加 command= 限制,或者用 from= 限定来源 IP,让这把钥匙只能从主站来、只能调 rsync:

from="10.0.0.11",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA... sync-key

这样一来,就算这把私钥泄露,攻击者也没法拿它弹个 shell 出来。

六、定向同步与排除规则的实战细节

真实站点里,源目录往往是混着代码、上传附件、缓存、日志的。如果整目录同步,每次都会被日志和缓存的写入事件刷屏,触发一堆无意义的 rsync。我的做法是在 rsync 参数里用 --exclude 精确排掉噪音目录,但这里有个容易忽略的顺序问题:rsync 的 exclude 规则是从上到下匹配的,一旦某条规则排除了父目录,子目录的 re-include 就无效了。所以如果想让 runtime/ 排掉、但保留 runtime/config/,必须写成:

rsync -az --delete \
  --exclude 'runtime/*' \
  --exclude '!runtime/config/' \
  --exclude '!runtime/config/**' \
  --exclude 'logs/' \
  --exclude '*.log' \
  --exclude '.env' \
  /data/www/ backup@10.0.0.12:/data/www/

另外还有一个安全细节:.env、config/database.php 这类含密码的文件,我通常不希望它们被同步到备份机,因为备份机的权限管理往往没有主站严格。加 --exclude '.env' 只是防呆,真要严格,应该在备份端用独立账号 + 只读挂载,从权限层面杜绝误写入。

七、同步方向的一致性:单向才是最优解

这套方案天生是单向的——主站推、备份机收。很多人会问:「那备份机上改的文件能不能反向同步回主站?」技术上当然可以再跑一个反向的 watcher,但我强烈不建议,理由有两个:

  1. 双向是冲突的温床。同一文件在两边几乎同时被改,谁赢谁输没有确定规则,最终得到一个「半截文件拼接体」的事故我见过不止一次。Syncthing 用版本号+冲突副本处理这个问题,但你要自己实现就是给自己挖坑。
  2. 单向的职责边界最清晰。主站是唯一事实来源(single source of truth),备份机只是镜像。要上线新代码,永远在主站操作,同步会自动流过去;备份机出了问题,从主站重推一次即可恢复,不需要「合并」。

如果确实需要在备份机上做只读操作(比如备份机同时兼静态资源服务器),那就把备份机上的这份目录设为只读,从物理上防止有人在上面改文件引发方向混乱。

八、监控与告警:别等出事了才发现同步断了

同步服务最怕的不是报错,而是静默失败——进程还在、日志没报错、但文件其实早就不推了(常见原因是 SSH key 过期、目标机磁盘满、网络策略变更)。所以要加一个「心跳式」的校验:

#!/bin/bash
# /usr/local/bin/sync-healthcheck.sh
SRC="/data/www/"
DST="backup@10.0.0.12:/data/www/"
DIFF=$(rsync -n --delete -ai "$SRC" "$DST" 2>/dev/null | wc -l)
if [ "$DIFF" -gt 20 ]; then
  echo "警告:主备差异文件数 $DIFF,疑似同步中断" | \
    mail -s "live-sync 告警" admin@example.com
fi

这里用的是 rsync -n(dry-run 空跑)加上 -ai 逐文件输出,它只比对不传输,代价很小,却能立刻反映出「主备到底差了多少个文件」。差异数持续大于某个阈值(比如 20)就说明同步链路出了问题。把它挂进 crontab 每 30 分钟跑一次:

*/30 * * * * /usr/local/bin/sync-healthcheck.sh

这种「以终态差异为信号」的监控,比监控进程存活靠谱得多——进程活着不代表同步真的在干活。做完这一步,这套 rsync + inotifywait 方案才算真正能睡觉安心。

九、这套方案的边界与替代思路

说句实话,rsync + inotifywait 有两个已知短板:

  1. 不是实时。inotify 触发有延迟,再加上脚本里为了防抖 sleep 了 1 秒,实际上是一个「准实时」方案。要跑到毫秒级实时,得上 lsyncd(它内部就是 inotify + rsync,只是用 C 写、性能更好)或 DRBD 这种块级方案。
  2. 大量小文件时压力大。一次 rsync 要遍历比对成千上万个文件,inode 一多,扫描阶段就很耗时。这种情况我会给 rsync 加 --delete-delay、并把高频目录排除掉。

但对绝大多数个人站长来说——一两个 G 的代码和内容目录、一天几百次改动——这套方案稳、可控、零成本,够用了。真正要升级时,方向也就那么两条:文件数量上去了换 lsyncd,要求强一致和低延迟就上分布式存储。先把土办法用明白,比一上来堆重型方案要务实得多。

Last modification:October 6th, 2026 at 01:24 pm

Leave a Comment