最怕半夜爬起来手动同步数据。我之前维护一个发布项目时,线上和备份目录靠 rsync 维持一致,每次改完文件都得手工敲一遍命令,敲错一个参数就可能把线上多余文件删掉。后来我把常用的 rsync 同步命令整理成了下面这个 shell 脚本,带并发锁、日志、SSH 探活和 dry-run 开关,直接丢进 cron 里跑。今天就把这份 rsync 同步 shell 脚本完整分享出来,顺便把脚本里每个参数、每个“保命细节”为什么这么设计讲清楚。无论你是刚接触 rsync 的新手,还是已经在用但经常被各种同步问题折腾的老手,这份脚本都能直接抄作业。
1. 直接上脚本:一份开箱即用的 rsync 同步 shell 脚本
1.1 这个脚本解决的是哪一类问题
先说适用范围。这个脚本解决的是单向同步:把本地某个目录的内容,原样同步到远程服务器(或另一台机器)的指定目录。典型的场景包括:
- 发布静态资源、前端构建产物到服务器;
- 将业务数据、配置文件镜像到备份机;
- 把多台机器上的某个目录收敛到统一位置。
它不适合做双向同步,也不适合当版本管理工具。如果你需要两边互相合并文件,应该去找双向同步方案;如果你需要保留每次变更的历史版本,那这个脚本只能作为起点,后面我会在第 5 节讲怎么扩展成“带快照的同步”。
脚本本身基于常见实践编写,核心设计只有一句话:先探活、再拿锁、记日志、跑 rsync、按退出码上报结果。整条链路没有任何魔法,任何一个环节出问题都能在日志里看到。
1.2 完整脚本代码
把下面的内容保存到/usr/local/bin/rsync_sync.sh,然后执行chmod +x /usr/local/bin/rsync_sync.sh。脚本中所有变量集中在“可修改配置区”,日常使用基本只需要改这一块。
#!/usr/bin/env bash # rsync_sync.sh # 单向同步脚本:本地目录 -> 远程目录(通过 SSH) # 用法: # ./rsync_sync.sh # 正式同步 # ./rsync_sync.sh --dry-run # 演练,只看差异不动数据 set -euo pipefail # ========== 可修改配置区 ========== SOURCE_DIR="/data/www/project/" DEST_USER="devops" DEST_HOST="10.0.0.20" DEST_PORT="22" DEST_DIR="/data/www/project/" EXCLUDE_FILE="/etc/rsync_sync_exclude.txt" LOG_DIR="/var/log/rsync_sync" LOCK_FILE="/tmp/rsync_sync.lock" export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # ========== 配置区结束 ========== # 解析参数 DRY_RUN="" if [ "${1:-}" = "--dry-run" ]; then DRY_RUN="-n" fi # 准备日志目录与锁文件 mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/sync_$(date +%Y%m%d).log" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" } exec 9>"$LOCK_FILE" if ! flock -n 9; then log "另一个 rsync 进程还在运行,本次任务跳过。" exit 0 fi log "=== 开始同步: $SOURCE_DIR -> $DEST_USER@$DEST_HOST:$DEST_DIR (dry-run=${DRY_RUN:-no}) ===" # 源目录检查 if [ ! -d "$SOURCE_DIR" ]; then log "错误:源目录不存在:$SOURCE_DIR" exit 1 fi # 排除文件不存在也没关系,rsync 会警告,这里提前兜底 if [ ! -f "$EXCLUDE_FILE" ]; then touch "$EXCLUDE_FILE" fi # 先通过 SSH 远程建目录,顺带当作连通性检查 remote_cmd="mkdir -p '${DEST_DIR}'" if ! ssh -p "$DEST_PORT" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no \ "$DEST_USER@$DEST_HOST" "$remote_cmd"; then log "错误:SSH 连接或远程目录创建失败:$DEST_USER@$DEST_HOST:$DEST_DIR" exit 1 fi # 组装 rsync 参数 rsync_args=( -avz --delete --partial --timeout=60 --exclude-from="$EXCLUDE_FILE" -e "ssh -p ${DEST_PORT} -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no" ) if [ -n "$DRY_RUN" ]; then rsync_args+=(--dry-run) fi rsync_args+=( "$SOURCE_DIR" "$DEST_USER@$DEST_HOST:$DEST_DIR" ) # 执行同步 if rsync "${rsync_args[@]}"; then log "=== 同步完成 ===" else status=$? log "=== 同步失败,rsync 退出码:${status} ===" exit "$status" fi1.3 第一次运行建议:先 dry-run
拿到脚本后不要直接跑正式同步。建议先执行一遍演练模式,让脚本告诉你“如果同步会发生什么”:
/usr/local/bin/rsync_sync.sh --dry-run--dry-run会在 rsync 命令里加入-n参数,rsync 只做“扫描对比”,列出需要传输和删除的文件,不会实际修改远程目录。这一步非常关键,尤其是第一次运行、目标目录里已经存在大量文件时,你需要在模拟输出里先确认三件事:
- 有没有不打算同步的文件出现在传输列表里;
- 有没有目标机上的文件会被
--delete删除; - 源目录和目标目录的层级是否正确。
确认无误后,再执行一次不带--dry-run的真实同步。第一次同步数据量可能很大,日志会一直增长,这是正常的。脚本输出同时会在终端显示一份,并追加到日志文件,方便一边看一边排查。
2. 拆开看参数:为什么我要这样写 rsync
2.1 -avz 不是银弹,但大多数场景够用
很多教程上来就让你用-avz,这四个字符确实覆盖了绝大多数同步需求,但我建议你对它的含义有数:
-a是--archive的简写,等同于-rlptgoD,也就是递归、保留符号链接、保留权限、保留时间戳、保留属主和属组、保留设备文件和特殊文件。对服务器文件同步来说,这一条基本决定了“同步后的目录像不像原来的目录”。-v是--verbose,输出传输细节。没有它,脚本日志里只能看到一句“完成”,排查时很难定位是哪次同步动到了哪个文件。-z是压缩传输。远程同步时文件内容经过压缩后再传,能明显降低带宽消耗。但如果两台机器在同一内网、带宽充裕,-z反而会消耗两端 CPU,大文件多的时候可能变慢。我现在的做法是:跨机房或低带宽场景保留-z,纯内网同步会去掉。
还有一个容易忽略的参数是-H,保留硬链接。默认情况下 rsync 并不会保存硬链接关系,处理大量同名不同 inode 的文件时会各自复制一份,造成目标机空间膨胀。如果你的源目录里有硬链接,建议在rsync_args里加上-H。
2.2 --delete 是双刃剑,得配合 exclude 和演练使用
--delete的作用是让目标目录完全镜像源目录:源目录中已不存在的文件,目标目录中也会被删除。对“同步”这件事来说,这是保证一致性的核心参数,没有它,目标目录会保留一堆历史残留文件。
但--delete也是最危险的参数。常见的翻车现场是:源目录漏挂载、清空、或者排除规则写错,结果一次执行后目标目录被删掉大量文件。所以我在脚本里做了三层防护:
--exclude-from="$EXCLUDE_FILE"把不需要同步的目录先排除掉;- 第一次使用或改动排除规则后,强制先跑
--dry-run; - 更保守的做法是给 rsync 加上
--backup和--backup-dir,在删除前先备份到目标机指定目录:
rsync -avz --delete --backup --backup-dir="/data/trash/$(date +%F_%H%M%S)" \ "$SOURCE_DIR" "$DEST_USER@$DEST_HOST:$DEST_DIR"教训是:--delete应该开,但应该在确认过 dry-run 输出之后开,而不是从一开始就永远开着。
2.3 用 SSH 通道而不是 rsync daemon 的三个理由
rsync 有两种传输方式:走 SSH,或者走 rsync daemon。这个脚本选择 SSH,原因是:
- 复用系统账号体系和密钥认证。rsync daemon 需要单独维护一份
rsyncd.conf,权限控制也依赖 rsync 用户体系,多一台机器就多一套配置。 - SSH 天然加密。文件内容在网关上不会被明文嗅探,安全性高一个档次。
- 排障顺手。SSH 出问题可以直接
ssh user@host手动测,而 daemon 模式下连接报错信息相对抽象。
使用 SSH 前需要先配好免密登录,否则脚本会因为BatchMode=yes直接失败。初始化密钥可以用:
ssh-keygen -t ed25519 ssh-copy-id -p "$DEST_PORT" "$DEST_USER@$DEST_HOST"脚本里-o BatchMode=yes的意思是:不尝试交互式输入密码。把它放在 cron 任务里非常重要,否则某个凌晨任务会因为等密码输入而挂在那里。ConnectTimeout=10则保证网络异常时能在 10 秒内失败退出,而不是长时间卡住。
2.4 --timeout、--partial 与排除列表:长任务的保命组合
一个同步任务动辄几分钟甚至几小时,网络抖动、文件锁、磁盘繁忙都可能让 rsync 无响应。--timeout=60规定如果 60 秒内没有任何数据传输,rsync 就主动放弃,避免进程永久悬挂。
--partial则是为“断点续传”准备的。默认情况下,如果同步大文件途中失败,rsync 会删掉已经传输的部分内容;加上--partial后,临时文件会保留,下次同步时从断点继续。这个参数对动辄几十 GB 的目录同步效果非常明显,能省下大量重传流量。
排除列表EXCLUDE_FILE是我强烈建议维护的文件。内容类似:
*.log *.tmp cache/ node_modules/ .git/ .DS_Store有了排除列表,日志、缓存、依赖目录这些“不需要同步”的内容就永远不会进入传输清单。排除列表的匹配规则和 rsync filter 规则一致,遇到目录写dir/,遇到文件名写*.log,不能靠感觉乱写,否则排除不生效。
3. 脚本里几个容易忽略的保命细节
3.1 flock 锁:防止两个 rsync 互相打架
cron 任务最经典的问题之一就是“上一次同步还没跑完,下一次任务又启动了”。两个 rsync 同时操作同一个目标目录,轻则日志混乱,重则把目标目录同步到一半的状态,造成数据不一致。
脚本里我用 flock 解决这个问题:
exec 9>"$LOCK_FILE" if ! flock -n 9; then log "另一个 rsync 进程还在运行,本次任务跳过。" exit 0 fiexec 9>lockfile把锁文件绑定到文件描述符 9 上,flock -n 9是尝试非阻塞获取锁。如果获取不到,说明另一个实例正在运行,当前实例直接退出。这里我选择“跳过本次任务”而不是“等上一个任务结束”,因为对定时同步来说,错过一次任务往往比堆叠多个任务更好处理,等到下一个周期再补即可。
要注意的是,flock 的锁是和打开的文件描述符绑定的,不是和锁文件内容绑定的。所以锁文件本身可以一直存在,不必手动清理;进程结束时锁自动释放。
3.2 日志按天落盘:排查问题时不必翻半天
脚本里日志文件名用了日期:
LOG_FILE="$LOG_DIR/sync_$(date +%Y%m%d).log"同一天内的多次执行会写进同一个文件,按天滚动。这样你想看“今天同步发生过什么”,直接:
grep "同步" /var/log/rsync_sync/sync_$(date +%Y%m%d).log就能拿到当天所有执行结果,不需要从几十个带时间戳的文件里拼线索。
另外,脚本里的log()函数同时把内容输出到终端和日志文件,用的是tee -a。这个设计是为了第一次人工调试时能实时看到进度;如果日志目录权限有问题,脚本会在mkdir -p那一步直接失败,不会偷偷把日志丢掉。
3.3 SSH 探活前置:失败要快速失败
脚本在真正调用 rsync 之前,先用 SSH 执行了一条远程建目录命令:
ssh -p "$DEST_PORT" -o BatchMode=yes -o ConnectTimeout=10 \ "$DEST_USER@$DEST_HOST" "mkdir -p '${DEST_DIR}'"这一步有两个作用:一是确认 SSH 能通、密钥能认证;二是确保目标目录存在。如果不做探活直接跑 rsync,网络出问题时 rsync 会反复重试,白等很久;而目标目录不存在时 rsync 也会报一堆看似和同步无关的路径错误。
我见过不少同事的 cron 脚本里没有这一步,某天目标机重启后 SSH 服务没起来,脚本就一直挂在 rsync 的反复重试里,直到下一个任务把它挤爆。前置探活 + 快速失败,是长任务脚本的重要习惯。
3.4 用退出码表达结果,而不是靠肉眼看屏
脚本最后根据 rsync 的退出码决定是否让整个脚本失败:
if rsync "${rsync_args[@]}"; then log "=== 同步完成 ===" else status=$? log "=== 同步失败,rsync 退出码:${status} ===" exit "$status" fi这个设计对 cron 和监控系统非常友好。cron 能感知到脚本退出码非零,从而触发告警邮件或外部监控程序。如果只是把 rsync 扔在脚本最后不管退出码,那么即使同步失败,外层任务也可能认为“脚本执行成功”,问题就藏在日志里没人发现。
4. 生产环境中踩过的 rsync 坑
4.1 源目录末尾斜杠:多一个少一个,结果天差地别
这是 rsync 新手必踩的第一个坑,而且踩了往往很痛。
假设源目录是/data/project,目标目录是/data/backup/:
rsync -avz /data/project /data/backup/最终会生成/data/backup/project/...,源目录作为一个整体被塞进目标目录。
rsync -avz /data/project/ /data/backup/最终会把/data/project里面的内容直接合并到/data/backup/里,不会多一层project目录。
脚本里我把SOURCE_DIR="/data/www/project/"末尾斜杠写死,就是为了确保“源目录内容”直接同步到DEST_DIR。如果你希望目标目录里出现源目录本身,就必须去掉斜杠。这类问题在 dry-run 阶段一眼就能看出来:传输列表里第一行是project/还是./决定了层级。所以每次改动路径后,都值得跑一次 dry-run 确认。
4.2 权限、属主、软链接与硬链接:看起来同步成功,其实没同步干净
-a参数已经包含权限和属主信息,但有个前提:运行脚本的用户需要对源文件有足够的读取权限,最好有 root 权限去保留属主和属组。如果普通用户运行 rsync,同步过去的文件属主会变成当期用户,目标机上的文件权限看起来零零散散,甚至导致服务起不来。
遇到跨 uid/gid 的主机同步,比如源机用 A 用户、目标机用 B 用户,建议考虑--numeric-ids。这个参数强行按数字 uid/gid 同步,而不是按用户名映射,避免因为两边/etc/passwd不一致导致属主漂移。
软链接也是重灾区。rsync 默认按-l保留符号链接,这是对的。但如果你同步的是“相对软链接”,链接目标指向的目录在目标机上不存在,那同步出来的软链接就是一个断链。这种问题 dry-run 看不到,只能在同步后到目标机抽查几个链接试读。硬链接则如前面所说,需要额外加-H才能完整保留。
4.3 网络闪断时,--partial 的价值才真正体现
我最早写脚本时没有--partial,后来同步一个 20 GB 的数据库备份目录,跑到一半网络抖动,rsync 退出码 11。第二次执行时,它又从头开始传那个大文件,等于之前跑的几个小时全白费了。
加上--partial之后,传输中断时已写入目标机的临时文件会被保留,下次 rsync 会做块级别对比,只传没传完的部分。当然,--partial只是“断点续传”,不是“数据校验”。如果你担心网络传输过程中文件静默损坏,可以定期加一次-c做全量校验;但要注意-c会让 rsync 对所有文件做 checksum 计算,CPU 和耗时都会明显上升,不适合每次运行都加。
4.4 exit code 对照表:别让 24 吓到你
rsync 的退出码不是只有 0 和 1,看到非零别急着慌。下面是我实际工作中最常见的几个:
| 退出码 | 含义 | 常见原因与处理 |
|---|---|---|
| 0 | 同步成功 | 无需处理 |
| 11 | I/O 错误 | 检查网络、磁盘空间、文件系统是否只读 |
| 12 | 协议错误 | 通常是 rsync 版本差异导致,重新安装对齐版本 |
| 23 | 部分传输 | 同步过程中有文件被修改,重跑一次 |
| 24 | 部分文件消失 | 源文件在同步期间被删除或轮转,常见于日志文件,确认后忽略 |
我被 24 号退出码坑过一次:业务日志按小时轮转,凌晨同步时源文件被改名,rsync 返回 24,我一度怀疑是脚本问题,折腾半天才发现这是正常现象。现在的建议是:脚本捕获退出码后先记录,再由人工结合当天的文件变更判断;不要因为看到 24 就立刻重跑任务,以免反复同步互相踩踏。
5. 接入 cron、监控,以及下一步能怎么扩展
5.1 cron 里的 PATH、日志与执行频率
把脚本放进 crontab 时,最容易忽略的是 cron 环境里的 PATH 极短。脚本里我已经用export PATH显式设置好路径,否则 cron 里可能找不到rsync或者flock。计划任务示例:
30 1 * * * /usr/local/bin/rsync_sync.sh >> /var/log/rsync_sync/cron.out 2>&1这里把 stdout 和 stderr 一起重定向到 cron 日志,避免 cron 默认发邮件刷爆邮箱。执行频率我建议从小时级开始观察:连续正常跑 24 小时后再改成天级。如果任务本身要跑 40 分钟,而你又设置了每小时执行,flock 的作用就会立刻体现:未完成的下一轮任务自动跳过,系统不会出现两个进程同时抢写的情况。
5.2 把退出码变成告警信号
单纯把脚本加入 cron 还不够,退出码非零时最好有外部动作。最原始的方案是依赖 cron 的MAILTO:设置好MAILTO环境变量后,任务失败时的输出会自动发到邮箱。但很多时候 cron 服务器没有配置邮件服务,更通用的是写一个很薄的 wrapper:
#!/usr/bin/env bash if /usr/local/bin/rsync_sync.sh; then exit 0 else /usr/local/bin/notify.sh "rsync 同步失败,请登录服务器查看日志" exit 1 finotify.sh可以替换成企业微信机器人、短信网关或任何你手头的告警渠道。关键是把 rsync 脚本的退出码作为唯一判断条件,不要让 wrapper 自己额外做判断。
5.3 从单向同步走向滚动快照与多目标
如果确认单向同步稳定之后,你还想保留历史版本,可以在脚本外面套一层“滚动快照”思路:先复制上一份快照(用硬链接方式,几乎不占空间),再对新快照目录执行 rsync,删除旧快照即可。命令思路如下:
prev_snapshot=/backup/snapshot.20250101 new_snapshot=/backup/snapshot.20250102 cp -al "$prev_snapshot" "$new_snapshot" rsync -av --delete "$SOURCE_DIR/" "$new_snapshot/"因为快照之间通过硬链接共享未变化文件,所以空间占用接近增量,却保留了一个完整目录结构。叠加这个思路,你的同步脚本就从“镜像”升级成了“带版本的历史备份”。
多目标同步也很简单:把DEST_HOST、DEST_DIR改成数组,用 for 循环逐个同步。这样发布时一条命令就能把产物推到多台服务器,比手工逐台执行可靠得多。
最后分享一个我自己的使用习惯:任何对脚本的改动,包括换源目录、调整排除规则、切换目标机,我都会先跑一次--dry-run并把输出留档,然后连续盯三天日志再放开。rsync 最大的优点是可预期,最大的风险是你未经演练就相信它。这份脚本不会替你解决所有问题,但它会把每一步都留成日志和退出码,让你从“半夜爬起来修数据”变成“早上看一眼日志就够了”。