rsync 增量备份本身很可靠,但一旦被 cron 或 systemd timer 调度起来,就会遇到两个非常现实的问题:第一,上一次备份还没跑完(源端文件多、网络慢、磁盘抖动),下一次调度又启动了,两个 rsync 进程同时写同一个目标目录,轻则重复传输浪费带宽,重则 rsync: mkstemp "/backup/.../.file.XXXXXX" failed: Permission denied、临时文件残留、甚至把目标目录的权限位改乱;第二,某些 rsync 会因为目标端 NFS 卡死或对端 SSH 无响应而挂起数小时,把备份窗口彻底拖垮,日志里看不到进度,只能人工上去 kill,kill 完还要手工清理锁文件。本文用一个 Shell 包装脚本解决这两件事:用 flock 保证同一时刻只有一个备份实例在跑,用 timeout 给单次运行设硬上限,并处理超时后的退出码与清理逻辑。
问题拆解:为什么不能只用 flock,也不能只用 timeout
很多人第一反应是写 flock -n /var/lock/backup.lock rsync ...,这确实解决了并发问题,但解决不了挂死问题——锁会被一个永远不结束的 rsync 持有,后续调度全部被 -n 跳过,等到运维发现时,已经连续几天没有新备份了,而且日志里只有一行「lock busy」,很容易被忽略。
反过来,只用 timeout 3600 rsync ... 也不行。timeout 到点会向 rsync 发 SIGTERM,但两个 rsync 并发写入的破坏是发生在时间窗口内的,timeout 只管单次时长,管不了重叠。所以正确的组合是:flock 在外层做互斥,timeout 在内层做时长约束,并且把「因锁被占用而跳过」和「因超时被杀死」区分成不同退出码,让监控可以分别告警。
还有一个容易被忽略的点:flock 默认作用于文件描述符,而不是进程名。如果脚本内部 fork 出子进程(例如 rsync 通过 ssh 拉起的远端进程),锁的继承关系需要理解清楚。下面逐层展开。
flock 的三种用法与选择依据
flock 来自 util-linux 包,几乎所有现代发行版都自带(Debian/Ubuntu 的 util-linux 2.20 以上,RHEL/CentOS 7 自带 2.23)。它有三种典型用法:
| 用法 | 示例 | 锁的生命周期 | 适用场景 |
|---|---|---|---|
| 包装命令 | flock -n /run/backup.lock rsync ... |
命令结束即释放 | 简单单行,无法做退出码分支 |
| 子 shell 内 | flock -n 200 ... 200>/run/backup.lock |
整个子 shell 存续期间 | 需要在锁内执行多段逻辑 |
| 脚本内 exec | exec 200>/run/backup.lock; flock -n 200 |
脚本进程退出才释放 | 推荐,退出码与清理都可控 |
本文采用第三种。exec 200>file 打开文件描述符 200,flock -n 200 在这个 fd 上加非阻塞排他锁。之所以不用默认的 /var/lock,是因为在部分系统上 /var/lock 是指向 /run/lock 的符号链接,tmpfs 挂载,重启即清空,这其实是好事;但如果你希望锁文件跨重启保留用于排查,可以放到 /var/lib/backup/。注意:flock 锁的是 inode,不是路径,所以锁文件不能被删除重建,否则两个进程会各锁一个 inode 而互不排斥。
关于 -n 与 -w:-n 是 --nonblock,拿不到锁立即返回 1;-w 30 是 --wait 30,最多等 30 秒。备份场景建议用 -n,因为等待没有意义——上一次备份还在跑,等 30 秒大概率也等不到,不如直接跳过并在日志里记一笔。
注意:
flock对 NFS 上的锁文件行为依赖 NFS 版本与 lockd 状态。锁文件请务必放在本地文件系统(/run或/var/lock),不要放在备份目标目录里。
timeout 的退出码、信号与 rsync 的配合
timeout 默认发送 SIGTERM,若进程组在宽限期内未退出,可以再加 -k 指定 SIGKILL 的延迟。核心退出码约定:
- 命令正常结束:返回命令本身的退出码(rsync 成功是 0)。
- 超时被杀死:返回 124(SIGTERM 生效)或 137(
-k后 SIGKILL 生效,即 128+9)。 - 命令被别的信号杀死:返回 128 + 信号编号。
这里有个真实坑:rsync 自己也有退出码,且部分非零码并不代表失败,比如 23(Partial transfer due to error,通常是源端有文件在传输期间被删除)和 24(Partial transfer due to vanished source files)在备份场景里很常见,不应直接判为致命错误;而 10(socket I/O error)、12(protocol data stream error)、30(timeout in data send/receive)才是需要告警的。包装脚本如果简单用 set -e,rsync 返回 24 就会让脚本中断并保留锁文件,反而制造问题。
所以 timeout 的用法要写成:timeout -k 60 "$MAX_SECONDS" rsync ...,其中 -k 60 表示 SIGTERM 之后 60 秒仍未退出则 SIGKILL。rsync 收到 SIGTERM 后会在当前文件传完后退出并删除临时文件,给它 60 秒宽限是合理的;如果对端 SSH 已死,60 秒后 SIGKILL 收场。
另一个细节:timeout 默认只杀直接子进程,rsync 若通过 ssh 拉起的远端进程不会自动死。可以加 --kill-after 配合 --signal=TERM,但在本地 rsync(源和目标都是本地挂载)场景下不存在这个问题。远程场景建议在 rsync 里加 --timeout=600(无数据 600 秒则 rsync 自行退出,退出码 30),让 rsync 自己先处理网络层挂起,外层 timeout 作为兜底。
完整脚本:backup.sh
下面这个脚本可以直接投产。设计要点:日志带时间戳与 run id;锁获取失败退出码 75(EX_TEMPFAIL,systemd 可识别为「暂时失败」);超时退出码沿用 124/137;rsync 的 23/24 降级为警告;结束时用 trap 清理临时 pid 文件。
#!/usr/bin/env bash
# /usr/local/bin/backup.sh
# rsync 增量备份:flock 排他 + timeout 限时
set -uo pipefail
SRC="/data/app/"
DST="/backup/app/"
LOG="/var/log/backup/app.log"
LOCK="/run/backup-app.lock"
STATE_DIR="/var/lib/backup"
PIDFILE="${STATE_DIR}/app.pid"
MAX_SECONDS=3600 # 单次运行硬上限:1 小时
KILL_GRACE=60 # SIGTERM 后 60 秒 SIGKILL
mkdir -p "$(dirname "$LOG")" "$STATE_DIR"
log() {
printf '%s [%s] %s\n' "$(date '+%F %T')" "$$" "$*" | tee -a "$LOG" >&2
}
# 1) 排他锁:非阻塞,拿不到就跳过本次调度
exec 200>"$LOCK"
if ! flock -n 200; then
log "SKIP: another backup instance holds ${LOCK} (holder pid=$(cat "$LOCK" 2>/dev/null || echo unknown))"
exit 75
fi
# 2) 记录本次运行的 pid,便于排查
echo $$ > "$PIDFILE"
cleanup() {
rm -f "$PIDFILE"
log "END rc=$1"
}
trap 'cleanup $?' EXIT
log "START src=$SRC dst=$DST max=${MAX_SECONDS}s"
# 3) 预检查:源目录存在且可读
if [ ! -d "$SRC" ]; then
log "FATAL: source $SRC not found"
exit 66
fi
# 4) 执行 rsync,外层 timeout 兜底
# --partial-dir 保证中断后可续传;--delete-delay 降低删除风险
timeout --signal=TERM --kill-after="${KILL_GRACE}" "$MAX_SECONDS" \
rsync -aHAX --numeric-ids \
--partial-dir=.rsync-partial \
--delete --delete-delay \
--timeout=600 \
--stats \
--exclude-from=/etc/backup/exclude.txt \
"$SRC" "$DST" >>"$LOG" 2>&1
RC=$?
case "$RC" in
0)
log "OK: rsync finished"
;;
23|24)
log "WARN: rsync partial transfer (rc=$RC), check vanished source files"
;;
124)
log "TIMEOUT: killed by SIGTERM after ${MAX_SECONDS}s"
exit 124
;;
137)
log "TIMEOUT: killed by SIGKILL after ${KILL_GRACE}s grace"
exit 137
;;
30)
log "ERROR: rsync I/O timeout (rc=30), peer unreachable?"
exit 30
;;
12)
log "ERROR: rsync protocol stream error (rc=12)"
exit 12
;;
*)
log "ERROR: rsync failed rc=$RC"
exit "$RC"
;;
esac
exit 0
几个必须解释的参数:-aHAX 是 -rlptgoD 加 -H(硬链接)、-A(ACL)、-X(xattr),备份场景基本都要;--numeric-ids 避免跨主机 uid/gid 映射错乱;--partial-dir=.rsync-partial 让中断的大文件在下次运行时能续传,而不是从零开始,这一点配合 timeout 尤其重要——超时杀掉的往往是某个巨型文件,没有 partial-dir 的话下次还得重传;--delete-delay 把删除推迟到传输结束后统一执行,降低「源端误删导致目标端立即被删」的窗口风险。别忘了 /etc/backup/exclude.txt 需要真实存在,否则 rsync 报 rsync: failed to open exclude file /etc/backup/exclude.txt: No such file or directory (2) 并返回 23。
调度侧配置:cron 与 systemd 的差异
cron 写法(每 15 分钟一次,上一次没跑完会自动被 flock 跳过):
# /etc/cron.d/backup-app
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/15 * * * * root /usr/local/bin/backup.sh >> /var/log/backup/cron.log 2>&1
如果改用 systemd timer,注意用 Type=oneshot 加 ExecStart,并且不要开 RemainAfterExit;同时把 TimeoutStartSec= 设得比脚本内的 MAX_SECONDS 更大(比如 3900),否则 systemd 会先杀掉整个 cgroup,脚本里的 trap 来不及记录日志。实测中常见的错误配置是 TimeoutStartSec=infinity 加上脚本内又没有 timeout,一旦 rsync 挂死,systemctl stop 之后 unit 长期处于 deactivating 状态,需要 systemctl kill -s SIGKILL --kill-who=all backup-app.service 才能收场。
# /etc/systemd/system/backup-app.service
[Unit]
Description=rsync incremental backup for /data/app
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
TimeoutStartSec=3900
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
配合的 timer 用 OnUnitActiveSec=15min 即可。systemd 会把退出码 75 视为失败,如果你不想让「跳过」污染 unit 状态,可以在 service 里加 SuccessExitStatus=75,把它当作正常退出处理,同时监控侧通过日志关键字 SKIP 单独统计。
验证与故障注入
写完脚本必须验证三件事:锁确实互斥、超时确实生效、超时后锁确实被释放。可以用 sleep 模拟一个长任务来注入故障:
# 终端 A:手动持有锁 120 秒
flock -n /run/backup-app.lock -c 'echo holder=$$; sleep 120'
# 终端 B:此时运行脚本,应立即退出 75
/usr/local/bin/backup.sh; echo "rc=$?"
# 验证 timeout:临时把 MAX_SECONDS 改小,或直接用 timeout 测
timeout -k 5 2 sleep 30; echo "rc=$?" # 输出 124
把脚本里的 MAX_SECONDS 临时设为 5 并指向一个大目录,应该看到日志出现 TIMEOUT: killed by SIGTERM,随后 flock 立即可再次获取——因为 fd 200 随进程退出自动关闭,内核释放锁,这也是为什么不需要手工删锁文件。
真实环境中你还会遇到这些报错,对照处理:
flock: 200: Bad file descriptor—— 说明脚本在exec 200>之前就执行了flock -n 200,或使用了set -C(noclobber)导致重定向失败。检查顺序,必要时改成exec 200>>"$LOCK"。rsync: [sender] write error: Broken pipe (32)—— 通常是外层 timeout 发送 SIGTERM 后,sender 侧管道中断。属于预期行为,不必当成 bug 去修。rsync error: some files/attrs were not transferred (see previous errors) (code 23)—— 结合--stats输出看是哪个文件;常见原因是目标端磁盘满(No space left on device)或 SELinux 拒绝,用ausearch -m avc -ts recent确认。timeout: failed to run command 'rsync': No such file or directory—— cron 的 PATH 与登录 shell 不同,脚本里用绝对路径/usr/bin/rsync最稳。- 日志里出现连续多条
SKIP—— 说明单次运行时间已经超过调度间隔,应该调大间隔或排查 rsync 变慢的原因(源端inotify未生效导致全量扫描、目标端 NFS 延迟高)。
小结
flock 负责「同一时刻只有一个实例」,timeout 负责「单次不许无限期跑」,两者分层且互不干扰:锁在外层保证互斥,超时在内层保证时效,退出码把「跳过」「超时」「部分传输」「致命错误」四类结果区分开,让 cron 或 systemd 与监控系统都能正确反应。锁文件放本地 /run,不要放进备份目录;rsync 加 --partial-dir 让超时后的续传不白干;timeout -k 的宽限期要覆盖 rsync 清理临时文件的时间。把这几点固定成模板,后面每加一个备份任务,只改 SRC/DST/LOCK 三个变量即可。
FAQ
flock 的锁文件被删除了会怎样?
flock 锁的是 inode。若有人 rm 掉锁文件,原持有者仍锁着旧 inode,新进程 exec 200>file 会创建一个新 inode 并成功加锁,互斥失效。所以锁文件不要放进会被 rsync --delete 清理的目录,也不要由清理脚本定期删除。
timeout 返回 124,但 rsync 已经传完了大部分文件,算失败吗?
算「未完成」。因为有 --partial-dir=.rsync-partial,下次运行会从断点续传,不会从零开始。建议在监控里把 124 当作 warning 而非 critical,同时观察连续出现次数:连续多次 124 说明 MAX_SECONDS 设置过小或数据量已超出窗口,需要调整。
为什么用 flock -n 而不是 flock -w 300?
备份是周期性任务,等待上一次结束没有业务价值,反而会拉长调度队列、制造堆积。拿不到锁就跳过,由下一次调度接手,是更简单的策略。如果业务要求「必须连续,不能跳过」,那应该改用 systemd Type=simple + Restart=on-failure 的常驻模式,而不是在 cron 里等待。
远程 rsync 超时后,对端 sshd 上还残留 rsync 进程怎么办?
本地 SIGKILL 不会终止远端进程。在远端 ~/.ssh/authorized_keys 的对应 key 前加 command="rsync --server ..." 限制,或使用 ServerAliveInterval=30 / ServerAliveCountMax=3 的 ssh 选项,让连接层在 90 秒内自行断开,远端 rsync 收到 EOF 后退出。配合 rsync 的 --timeout=600 双保险。
锁文件里为什么建议写入 pid?
flock 本身不记录持有者,排查「谁占着锁」时只能靠 pid。脚本里 echo $$ > "$PIDFILE" 虽然与 flock 是两套机制(pid 文件不是锁),但能让你用 ps -p $(cat /var/lib/backup/app.pid) 快速定位。注意 pid 文件必须在持有锁之后再写,否则会覆盖真实持有者的 pid。