写了十几版之后,我的备份脚本终于靠谱了
我最早的那个备份脚本只有一行: 1 tar -czf /backup/site-$(date +%F).tar.gz /srv/www 放在 crontab 里,每天跑一次。跑了三个月,我从来没打开看过。 后来有一次我要恢复一个误删的文件,才发现那个目录里堆了九十多个压缩包,最大的一个只有 12 字节。 原因是 /srv/www 下面有个目录权限不对,tar 读不了,直接失败。但因为命令在 crontab 里,报错输出到邮件,而我没配邮件,所以什么都没看到。 九十天的备份,全是空的。 从那之后,我把这个脚本重写了十几遍,加了一堆东西。这篇记录一下最终的样子,以及每一处为什么要那么写。 最基础的要求 一个好的备份脚本,至少要满足这几条。 跑失败的时候要能知道。不能像我第一次那样,默默失败九十天。 备份文件本身要能验证。不能只看退出码,得实际检查文件大小和内容完整性。 不能并发跑。如果上一次还没跑完,下一次又启动,两个进程同时写同一个文件,结果就是坏的。 过期的东西要清理。不然磁盘会被占满,最后连带把系统搞挂。 异地要有一份。本机的备份防不了磁盘坏和机器丢。 脚本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 #!/usr/bin/env bash set -Eeuo pipefail IFS=$'\n\t' # ---------- 配置 ---------- BACKUP_ROOT="/backup" SOURCE_DIRS=("/srv/www" "/srv/git" "/etc/nginx") KEEP_DAYS=14 LOG_FILE="/var/log/backup.log" LOCK_FILE="/var/run/backup.lock" REMOTE="remote:mybackup" TG_TOKEN="" TG_CHAT="" # ---------- 工具函数 ---------- log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE" } fail() { log "ERROR: $*" notify "备份失败: $*" exit 1 } notify() { [[ -z "$TG_TOKEN" ]] && return 0 curl -s -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \ -d "chat_id=${TG_CHAT}" \ -d "text=$1" >/dev/null || true } cleanup() { local code=$? if [[ $code -ne 0 ]]; then log "脚本异常退出,退出码 $code" fi rm -f "$LOCK_FILE" } trap cleanup EXIT # ---------- 主流程 ---------- exec 9>"$LOCK_FILE" if ! flock -n 9; then log "上一次备份还在运行,本次跳过" exit 0 fi DATE=$(date '+%F_%H%M') ARCHIVE="${BACKUP_ROOT}/backup-${DATE}.tar.gz" mkdir -p "$BACKUP_ROOT" log "开始备份" tar -czf "$ARCHIVE" \ --exclude='*/cache/*' \ --exclude='*/node_modules/*' \ --exclude='*.log' \ "${SOURCE_DIRS[@]}" 2>>"$LOG_FILE" || fail "tar 打包失败" # 校验 [[ -s "$ARCHIVE" ]] || fail "备份文件为空" SIZE=$(stat -c %s "$ARCHIVE") if [[ $SIZE -lt 1048576 ]]; then fail "备份文件异常小: ${SIZE} 字节" fi # 完整性校验 gzip -t "$ARCHIVE" || fail "压缩包损坏" log "备份完成,大小 $(numfmt --to=iec "$SIZE")" # 清理过期 find "$BACKUP_ROOT" -name 'backup-*.tar.gz' -type f -mtime +"$KEEP_DAYS" -delete log "已清理 ${KEEP_DAYS} 天前的备份" # 异地同步 if command -v rclone >/dev/null 2>&1; then rclone sync "$BACKUP_ROOT" "$REMOTE" --max-age 24h 2>>"$LOG_FILE" \ || log "WARN: 异地同步失败" log "异地同步完成" fi notify "备份完成: $(basename "$ARCHIVE")" 逐段解释 set -Eeuo pipefail 这四个选项,每一个都有用。 -e 让任何命令返回非零就退出。-u 让引用未定义变量时报错。-o pipefail 让管道里任何一个环节失败都能被捕获。-E 让 ERR trap 能被子 shell 继承。 IFS=$'\n\t' 是改字段分隔符。默认是空格、制表、换行。如果某个文件名里有空格,不加这个的话,for f in $files 会把一个文件名拆成两个。改成只认换行和制表,能避免大部分问题。 不过要注意,改 IFS 之后,"$@" 这种引用是安全的,但 $* 的行为会变。所以尽量用 "$@"。 锁 1 2 exec 9>"$LOCK_FILE" flock -n 9 || exit 0 exec 9> 是把文件描述符 9 指向这个锁文件。flock -n 尝试加锁,-n 是非阻塞,拿不到锁就立刻返回失败。 这个锁的作用是防止两个实例同时跑。如果没有它,cron 设的间隔比备份耗时短的时候,就会出现多个进程同时写同一个 tar 文件,结果一定是坏的。 为什么用文件描述符而不是 flock -n /path/to/lock command 这种写法?因为后者在命令结束之后会自动释放,但如果脚本中途被 kill -9,锁文件会留下,虽然不影响下次加锁(flock 的锁是内核管理的,进程死了自动释放),但用 fd 的方式更清晰,而且 trap 里能统一清理。 校验 1 [[ -s "$ARCHIVE" ]] || fail "备份文件为空" -s 是文件存在且大小大于零。 1 gzip -t "$ARCHIVE" 这个会完整解压一遍检查 CRC,不输出内容。对于几百兆的文件,大概花几秒。值得。 1 [[ $SIZE -lt 1048576 ]] 这个阈值是拍脑袋定的。我的站点正常备份在几百兆,如果小于一兆,说明肯定有问题。 这个值要根据你的实际情况调。太小了起不到检查作用,太大了会误报。 清理 1 find "$BACKUP_ROOT" -name 'backup-*.tar.gz' -type f -mtime +14 -delete -mtime +14 是修改时间超过十四天。注意 +14 是大于十四天,也就是第十五天开始删。 有个细节,-mtime 是按二十四小时算的,不是按自然日。所以如果备份是每天同一时间跑,这个判断是准的。 异地同步 1 rclone sync "$BACKUP_ROOT" "$REMOTE" --max-age 24h sync 会让目标端和源端一致,包括删除。这一点要注意,如果本地文件被误删了,云上的也会被删。 想要更安全,用 copy 而不是 sync: 1 rclone copy "$BACKUP_ROOT" "$REMOTE" --max-age 24h copy 只添加和更新,不删除。这样云上会越攒越多,需要另外配生命周期规则来清理。 我选了 sync,因为云上的生命周期规则更难调。但我加了对象存储的版本控制,就算被同步删除,历史版本还在。 --max-age 24h 只同步一天内的新文件,避免每次都全量比对。文件多了之后,这个参数能省很多时间。 告警 我用的是 Telegram Bot,因为它免费、稳定、API 简单。 1 notify "备份完成: $(basename "$ARCHIVE")" 成功也发一条。有人觉得成功不用通知,但我觉得正好相反。如果只有失败才通知,你就不知道"没收到通知"到底是成功了还是脚本根本没跑。 每天收到一条"备份完成",心里踏实。 || true 是让通知失败不影响主流程。告警本身挂了不应该导致备份失败。 cron 的写法 1 2 3 4 5 6 sudo tee /etc/cron.d/backup <<'EOF' SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 15 3 * * * root /usr/local/bin/backup.sh >> /var/log/backup-cron.log 2>&1 EOF cron.d 的格式比 crontab -e 多一个用户字段,就是那个 root。少了这个字段,cron 不会执行,也不报错。 SHELL 和 PATH 这两行最好显式写。cron 的环境变量非常精简,很多命令找不到。 脚本里的 >> /var/log/backup-cron.log 是双保险。脚本自己会写 LOG_FILE,但万一脚本在写日志之前就挂了,cron 的 stderr 能兜住。 日志轮转 /var/log/backup.log 会一直涨,得配 logrotate。 1 2 3 4 5 6 7 8 9 10 sudo tee /etc/logrotate.d/backup <<'EOF' /var/log/backup.log { weekly rotate 8 compress missingok notifempty copytruncate } EOF copytruncate 是复制一份然后清空原文件,而不是改名。因为脚本一直在往这个文件写,改名之后文件描述符还指向旧的,会写到已经轮转的文件里。 几个我踩过的坑 有一次备份跑到一半,机器重启了,tar 文件是截断的。但脚本没有检查,第二天 cron 又跑了一次,生成了新的。旧的那个截断文件一直躺在目录里,直到过期被删。 后来我加了 gzip -t 检查,但那只检查新的那个。旧文件坏了还是不知道。 再后来我加了个每周一次的巡检,遍历所有备份文件,跑 gzip -t,有坏的就告警。 还有一次,rclone 同步的时候网络断了,同步失败,但脚本只记了 WARN 没退出。第二天我看云上的备份,少了三天。 现在我把异地同步的失败也升级成了告警,至少能知道。 定期演练 这一点最重要,也最容易被忽略。 我现在的习惯是每季度做一次恢复演练。随便挑一个备份文件,解压到临时目录,对比一下文件数量和关键文件的大小。 1 2 3 4 mkdir -p /tmp/restore-test tar -xzf /backup/backup-2026-03-04_031500.tar.gz -C /tmp/restore
以上内容来自博客订阅源,版权归原作者。关注作者后可以在 关注列表 第一时间查看作者文章。

登录后,加入这场讨论。
登录参与