第 9 章 · 08 综合实战:一个可上线的备份脚本 本节摘要:这是全章的综合应用题,也是「能用」与「能上线」的检验场。我们要把前七节学的每一块都串起来,写一个完整的、真正能进生产环境的备份脚本:用变量与展开参数化路径、用 做条件判断、用 / 处理多目录、用函数组织代码、用 保证遇错即停、用 在 Ctrl+C 时清理锁文件、用 配上规范的 帮助和参数解析。它接收源目录与目标路径参数,用 做增量同步,写日志,支持 dry-run 模式,能被 定时调用。读完这一节,你会看到一个「真正生产级」的 Shell 脚本长什么样——前面七节学的每一块,在这里都不是装饰,而是缺一不可的零件。
本节摘要:这是全章的综合应用题,也是「能用」与「能上线」的检验场。我们要把前七节学的每一块都串起来,写一个完整的、真正能进生产环境的备份脚本:用变量与展开参数化路径、用
[[ ]]做条件判断、用for/while处理多目录、用函数组织代码、用set -euo pipefail保证遇错即停、用trap在 Ctrl+C 时清理锁文件、用getopts配上规范的-h帮助和参数解析。它接收源目录与目标路径参数,用rsync做增量同步,写日志,支持 dry-run 模式,能被cron定时调用。读完这一节,你会看到一个「真正生产级」的 Shell 脚本长什么样——前面七节学的每一块,在这里都不是装饰,而是缺一不可的零件。本章结尾点题:当你写 Shell 脚本写到「想让这个脚本语言本身变得更好用」,那就是姊妹篇《造轮子工程》第 2 章「造一个 Shell」的入口。
内容来源:综合本章前七节知识与原项目「Linux 命令大全」整理,套用体系化模板。
阅读完本节,你应当能够:
set -euo pipefail、trap、getopts)综合运用到一个真实场景。rsync -a --delete 实现增量同步,理解「镜像备份」的语义。usage()、-h、-n(dry-run)、-v(verbose)、-q(quiet)等规范选项。flock 或锁文件防止脚本并发执行,并用 trap 保证锁一定被释放。cron 定时调用、把日志写到 syslog 或文件的脚本。你可能在终端敲过 cp -r /home/alice /backup/alice-$(date +%F)。这能用,但有几个问题:
cron 任务,失败了你也不知道。cron 触发时上一次还没跑完,两个备份互相踩。一个「能上线」的备份脚本要解决这些问题。下面这个脚本不是教学玩具,它经过实战检验,可以作为你备份脚本的起点。
| 前七节 | 在本脚本里的体现 |
|---|---|
| 01 变量与展开 | $src、$dst、$LOG、$(date +%F) 命令替换 |
| 02 条件判断 | [[ -d $src ]] 检查目录、[[ -n $EXCLUDE ]] 判断非空 |
| 03 循环 | for src in "${SOURCES[@]}" 遍历多个源 |
| 04 函数 | usage、log、cleanup、do_backup |
05 set -euo pipefail |
脚本开头第二行 |
06 trap |
trap cleanup EXIT INT TERM 释放锁、关日志 |
07 getopts |
解析 -h、-n、-v、-q、-c 配置文件 |
每一节都是这个脚本的一块拼图。读完你应该能体会到——学的时候每块都看似简单,组合起来才显出威力。
下面是完整的 backup.sh。建议先通读一遍,再看后面的拆解。
#!/usr/bin/env bash # # backup.sh —— 增量镜像备份脚本 # # 用 rsync 做增量同步,支持多源、dry-run、verbose、日志、锁。 # 适合放进 crontab 定时跑。 # # 用法: ./backup.sh [-hnvq] [-c 配置文件] 源目录 [源目录...] 目标目录 # -h 显示帮助 # -n dry-run,只打印不真的同步 # -v 详细输出 # -q 安静模式(只打印错误) # -c FILE 指定配置文件(默认 /etc/backup.conf) # set -euo pipefail # ---------- 全局配置 ---------- readonly VERSION="1.0.0" readonly PROG_NAME=$(basename "$0") readonly DEFAULT_CONFIG="/etc/backup.conf" readonly LOCK_FILE="/var/lock/backup.lock" readonly LOG_FILE="/var/log/backup.log" # 运行时变量(会被参数覆盖) CONFIG_FILE="$DEFAULT_CONFIG" DRY_RUN=0 VERBOSE=0 QUIET=0 # 配置项(从配置文件读取) SOURCES=() DEST="" EXCLUDE="" # ---------- 函数 ---------- # 日志函数:根据 VERBOSE/QUIET 决定是否打印,并写入日志文件 log() { local level=$1 shift local msg="[$(date '+%F %T')] [$level] $*" echo "$msg" >> "$LOG_FILE" if [[ $QUIET -eq 1 && $level != "ERROR" ]]; then return fi if [[ $VERBOSE -eq 1 || $level == "ERROR" ]]; then echo "$msg" fi } # 帮助文本 usage() { cat <<EOF $PROG_NAME v$VERSION —— 增量镜像备份脚本 用法: $PROG_NAME [-hnvq] [-c 配置文件] 源目录 [源目录...] 目标目录 选项: -h 显示本帮助 -n dry-run 模式(只打印将要做什么,不真同步) -v 详细输出 -q 安静模式(只打印错误) -c FILE 配置文件(默认 $DEFAULT_CONFIG) 示例: $PROG_NAME -v /home/alice /backup $PROG_NAME -n -c /etc/backup.conf $PROG_NAME -h 配置文件格式(每行一项): SOURCE=/path/to/src # 可多次出现,指定多个源 DEST=/path/to/backup # 目标目录 EXCLUDE=*.tmp # 排除模式(可选) EOF } # 读取配置文件 load_config() { local file=$1 if [[ ! -f $file ]]; then log "ERROR" "配置文件不存在: $file" return 1 fi # 安全地逐行读取(用 source 会有注入风险,这里手动解析) while IFS='=' read -r key value; do # 跳过空行和注释 [[ -z $key || $key == \#* ]] && continue case $key in SOURCE) SOURCES+=("$value");; DEST) DEST=$value;; EXCLUDE) EXCLUDE=$value;; *) log "WARN" "配置文件里出现未知键: $key";; esac done < "$file" } # 清理函数:trap 调用,释放锁 cleanup() { if [[ -n ${LOCK_FD:-} ]]; then exec {LOCK_FD}>&- # 关闭锁文件描述符 rm -f "$LOCK_FILE" log "INFO" "锁已释放" fi log "INFO" "脚本退出" } # 获取锁:防止并发执行 acquire_lock() { # 用文件描述符 9 关联锁文件,flock 锁这个 fd exec {LOCK_FD}>"$LOCK_FILE" if ! flock -n "$LOCK_FD"; then log "ERROR" "另一个备份实例正在运行,退出" return 1 fi } # 执行单个源的备份 do_backup() { local src=$1 local dst=$2 if [[ ! -d $src ]]; then log "ERROR" "源目录不存在: $src" return 1 fi local rsync_opts=(-a --delete) [[ $DRY_RUN -eq 1 ]] && rsync_opts+=(-n) [[ -n $EXCLUDE ]] && rsync_opts+=(--exclude="$EXCLUDE") log "INFO" "开始备份: $src -> $dst (dry-run=$DRY_RUN)" # 用 rsync 做增量镜像同步 if ! rsync "${rsync_opts[@]}" "$src/" "$dst/"; then log "ERROR" "rsync 失败: $src" return 1 fi log "INFO" "备份完成: $src" } # ---------- 参数解析 ---------- while getopts ":hnvqc:" opt; do case $opt in h) usage; exit 0;; n) DRY_RUN=1;; v) VERBOSE=1;; q) QUIET=1;; c) CONFIG_FILE=$OPTARG;; \?) log "ERROR" "未知选项: -$OPTARG"; usage; exit 1;; :) log "ERROR" "选项 -$OPTARG 缺少值"; usage; exit 1;; esac done shift $((OPTIND - 1)) # 注册 trap(必须在 acquire_lock 之前,保证锁一定被释放) trap cleanup EXIT INT TERM # ---------- 加载配置 ---------- if [[ $# -ge 2 ]]; then # 命令行传了源和目标,优先于配置文件 DEST=${!#} # 最后一个参数是目标 for ((i=1; i<=$#-1; i++)); do # 其余是源 SOURCES+=("${!i}") done log "INFO" "使用命令行参数:${SOURCES[*]} -> $DEST" else # 没够参数,回退到配置文件 load_config "$CONFIG_FILE" fi # ---------- 参数校验 ---------- if [[ ${#SOURCES[@]} -eq 0 || -z $DEST ]]; then log "ERROR" "缺少源目录或目标目录" usage exit 1 fi if [[ ! -d $DEST ]]; then log "INFO" "目标目录不存在,创建: $DEST" mkdir -p "$DEST" fi # ---------- 主流程 ---------- acquire_lock log "INFO" "==== 备份开始 (pid $$) ====" failed=0 for src in "${SOURCES[@]}"; do # 每个源备份到目标下同名子目录 target_subdir="$DEST/$(basename "$src")" if ! do_backup "$src" "$target_subdir"; then failed=1 fi done if [[ $failed -eq 0 ]]; then log "INFO" "==== 所有备份成功 ====" exit 0 else log "ERROR" "==== 部分备份失败 ====" exit 1 fi
#!/usr/bin/env bash set -euo pipefail readonly VERSION="1.0.0"
#!/usr/bin/env bash:用 env 找 bash,比 #!/bin/bash 更可移植(不同系统 bash 路径可能不同)。set -euo pipefail:健壮性第一件,遇错即停、拒未定义、管道穿透。readonly:把常量标记为只读,防止后续被误改(任何赋值都会报错)。所有函数集中在脚本顶部,主逻辑放底部。每个函数职责单一:
log:统一的日志函数,同时写文件和终端,受 VERBOSE/QUIET 控制。usage:帮助文本,多处复用(-h、缺参数、出错时)。load_config:安全地解析配置文件(不用 source,避免注入)。cleanup:trap 调用的清理函数,释放锁。acquire_lock:用 flock 加排他锁,防止并发。do_backup:单次备份的核心,封装 rsync 调用。💡 技巧:
log这种「单一职责的小函数」是组织脚本的利器。它把「打印格式 + 写日志 + 控制静默」三件事打包,调用方只写log "INFO" "开始备份"一行。这是函数让命令可复用的精髓。
while getopts ":hnvqc:" opt; do ... done shift $((OPTIND - 1))
健壮性第三件。-h、-n、-v、-q 是开关,-c 带值。前导 : 开启安静错误模式。
trap 注册(第 127 行)trap cleanup EXIT INT TERM
健壮性第二件。必须在 acquire_lock 之前注册——这样即使 acquire_lock 之后任何一步失败,cleanup 都会运行并释放锁。
脚本支持两种使用方式:
./backup.sh /home/alice /backup./backup.sh -c /etc/backup.conf后者适合 cron 定时调用(不用每次传一堆路径)。配置文件解析用手写的 while read,不用 source——source 会执行配置文件里的任意代码,有安全风险。
acquire_lock # 加锁 for src in "${SOURCES[@]}"; do # 遍历每个源 do_backup "$src" "$DEST/$(basename "$src")" done
for 遍历多个源(第 03 节的循环),每个源备份到目标下的同名子目录。failed 变量记录是否有失败——注意这里没有用 set -e 让单次失败停掉所有源,因为「一个源失败,其他源还是要继续」是合理需求。这是第 05 节「-e 不适合所有场景」的实例。
rsync -a /home/alice /backup # 把 alice 目录本身复制到 /backup/alice rsync -a /home/alice/ /backup # 把 alice 目录的内容复制到 /backup
尾部斜杠改变 rsync 的语义:有斜杠是「内容」、无斜杠是「目录本身」。备份时通常要 src/(内容),让目标成为源的镜像。这个坑几乎每个 rsync 用户都踩过。
source 加载配置文件有注入风险source /etc/backup.conf # 如果配置里有 rm -rf /,就完了
永远不要 source 不可信的配置文件。本脚本用 while IFS='=' read 手动解析,只接受 KEY=value 这种格式,其他内容被忽略。这是脚本安全的基本素养。
cron 调用时 PATH 不一样0 2 * * * /usr/local/bin/backup.sh
cron 环境的 PATH 比登录 Shell 小得多,rsync、flock 可能找不到。解法:脚本开头显式设置 PATH,或用绝对路径:
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
mkdir 比 touch 更可靠# touch 版(有竞态) [[ -e $LOCK ]] && exit 1 touch $LOCK # mkdir 版(原子操作,推荐) mkdir "$LOCK" 2>/dev/null || { echo "已在运行"; exit 1; }
本脚本用 flock(更专业),但如果你系统没有 flock,用 mkdir 比 touch 安全——mkdir 是原子操作,不会有两个进程同时通过 [[ -e ]] 检查。
set -e 让单个源失败停掉所有源set -e for src in "${SOURCES[@]}"; do do_backup "$src" "$dst" # 这条失败,set -e 让整个循环退出 done
如果你希望「一个源失败不影响其他源」,要么把 set -e 在循环内临时关掉,要么用 do_backup ... || failed=1 显式吞错。本脚本用了后者。
编辑 crontab:
# 每天凌晨 2 点备份 0 2 * * * /usr/local/bin/backup.sh -c /etc/backup.conf >> /var/log/backup.cron.log 2>&1
>> /var/log/backup.cron.log 2>&1:把 cron 自己的输出也存起来(脚本内部已经写 /var/log/backup.log,这一层是兜底)。-c /etc/backup.conf:用配置文件,cron 调用不传一堆路径。⚠️ 注意:
cron不会给你发邮件,除非你配了MAILTO。生产环境建议脚本失败时主动echo "..." | mail -s "备份失败" admin@example.com,或者对接告警系统。
# 看帮助 ./backup.sh -h # dry-run:先看看会做什么 ./backup.sh -n -v /home/alice /backup # 真备份,详细输出 ./backup.sh -v /home/alice /home/bob /backup # 用配置文件,安静模式(cron 用) ./backup.sh -q -c /etc/backup.conf
配置文件 /etc/backup.conf 示例:
# 备份源(可多行) SOURCE=/home/alice SOURCE=/home/bob SOURCE=/etc # 目标 DEST=/backup/daily # 排除模式 EXCLUDE=*.tmp
这个脚本是个起点,生产环境你可能还需要:
--link-dest 或单独的「轮转」逻辑)。rsync 到本地后再 gpg 加密,或直接用 borg/restic 这类专业备份工具。rsync 支持 user@host:path,可备份到远端服务器。curl 一个健康检查端点(如 healthchecks.io),脚本没跑就告警。bats 给关键函数写测试。💡 技巧:当你的备份需求复杂到需要以上功能时,认真考虑用
borg/restic这类专业工具,而不是继续手写 Shell。Shell 脚本最擅长「编排命令」,复杂算法和去重加密该交给专用工具。这是第 00 节说的「Shell 适合编排、不适合算法」的边界。
set -euo pipefail、trap、getopts(健壮性三件套),缺一不可。rsync -a --delete src/ dst/ 是镜像备份的标准写法,尾部斜杠决定「复制内容」还是「复制目录本身」。flock(或退而求其次的 mkdir),并配 trap cleanup EXIT 保证锁一定被释放。source——source 会执行任意代码,有注入风险。cron 调用时 PATH 不同,脚本开头要显式设 PATH 或用绝对路径。set -e 在循环里要灵活:「单个源失败不影响其他源」时用 cmd || failed=1 显式吞错。borg/restic;Shell 擅长编排,不擅长算法。恭喜你读完了《Linux 命令实战教程》的最后一章。回顾这一章,你从「会敲命令」走到了「会写脚本工具」:
test/[/[[ ]] 三件套,让脚本学会「看情况办事」。for/while/until,让脚本学会「重复」。set -euo pipefail、trap、getopts,这是业余脚本与生产脚本的分水岭。至此,全书「会用命令 → 会写脚本 → 会造工具」的上升路径,你已经走完了前两段。前八章教你怎么用别人造好的命令,第 9 章教你怎么把这些命令组合成自己的工具。
但走到这里,你可能会冒出一个新问题:
「Shell 这个语言本身挺别扭的——为什么赋值不能有空格?为什么
if后面要跟then?为什么[是命令、[[是关键字?为什么变量默认是全局的?为什么没有真正的数组、没有对象、没有异常?」
当你写 Shell 写到这一步,你已经摸到了「语言设计」的边缘。Shell 这些「奇怪」的设计,大多是 1970 年代 Bourne Shell 留下的历史包袱,Bash 在兼容的枷锁下只能打补丁。如果你想知道「一个 Shell 应该长什么样」、「这些设计为什么这么选」、「我自己能不能造一个更好的」,那就到了姊妹篇《造轮子工程》第 2 章「造一个 Shell」的入口。
在那一章里,你会从零开始实现一个 Shell:读入一行命令、切分词法、展开变量、fork 子进程、exec 命令、wait 它结束、处理管道和重定向——你会亲手造出 echo、ls、grep 这些命令赖以运行的「容器」。你会理解为什么 | 是这样工作的、为什么 $? 存在、为什么 set -e 能让脚本停下来。第 9 章学的所有「奇怪行为」,在造过一遍 Shell 之后,都会变得理所当然。
命令是别人造的工具,脚本是工具的组合,而 Shell 本身是承载这一切的语言。读完这本《Linux 命令实战教程》,你已经站在了「使用者 → 组合者 → 创造者」路径的中点。再往前一步,就是《造轮子工程》。
我们姊妹篇见。