综合 凌晨 3 点的备份脚本:用 set -x、PS4 行号、trap ERR 和 shellcheck 逐层排查

2026-10-24 20:01:33

凌晨 3 点的备份脚本:用 set -x、PS4 行号、trap ERR 和 shellcheck 逐层排查

一个每天凌晨 3 点备份网站的脚本:

#!/bin/bash
# /root/backup.sh (有问题的版本)
BACKUP_DIR=/root/backups/$(date +%Y%m%d)
mkdir $BACKUP_DIR
mysqldump -uroot -p$DB_PASS myblog > $BACKUP_DIR/db.sql
tar czf $BACKUP_DIR/www.tar.gz /var/www/html
find /root/backups -type f -mtime +7 -delete
echo "备份完成"

这段代码至少有四个问题:mysqldump 的密码没加引号,密码含特殊字符就会出错;BACKUP_DIR 没加引号,路径含空格就会出错;创建目录后不检查是否成功,后面的重定向会把数据写到不存在的路径;find 没有 -name 约束,可能删掉不该删的文件。最严重的问题是脚本最后永远打印「备份完成」,哪怕前面全失败。

第一层:set -x 和 PS4

set -x 开启后,bash 会把每一条即将执行的命令(变量已展开)打印到标准错误。

set -x          # 开启追踪
# ...
set -x
risky_function
set +x          # 关闭追踪

开启后上面脚本的输出变成:

+ BACKUP_DIR=/root/backups/20261001
+ mkdir /root/backups/20261001
+ mysqldump -uroot -pmysecret myblog
+ tar czf /root/backups/20261001/www.tar.gz /var/www/html
+ find /root/backups -type f -mtime +7 -delete
+ echo '备份完成'
备份完成

从这里能直接看出,mysqldump 那行完全没有重定向:$BACKUP_DIR/db.sql 根本没被 bash 当成参数传进去。原因是密码里含空格,命令被截断了。set -x 展示的是展开后的真实命令。

PS4 自定义前缀:

export PS4='+[\033[32m${BASH_SOURCE##*/}:${LINENO}\033[0m] \033[36m${FUNCNAME[0]:-main}\033[0m: '
set -x

输出:

+[backup.sh:12] main: BACKUP_DIR=/root/backups/20261001
+[backup.sh:13] main: mkdir /root/backups/20261001
+[backup.sh:14] main: mysqldump -uroot -pmysecret myblog

带行号可直接跳转,带函数名能看清递归层次。这段 PS4 建议放进 ~/.bashrc,平时不开 set -x 不生效。

不改脚本也能调试:

bash -x /root/backup.sh
bash -x /root/backup.sh 2>&1 | head -20
bash -x /root/backup.sh 2>/tmp/backup.trace

追踪输出走 stderr,不污染正常 echo 输出。调试 cron 时特别有用:cron 会把 stdout 和 stderr 一起发邮件。

第二层:set -e、set -u、pipefail 与 trap ERR

语义区别:

  • set -e:任何命令返回非 0 就立刻退出
  • set -u:使用未定义变量时报错退出(默认当空字符串)
  • set -o pipefail:管道中任何一段失败,整个管道算失败;默认只看最后一段
  • set -x:只打印,不改流程
#!/bin/bash
set -euo pipefail

set -e 有静默失效场景:

# 场景 1:命令在 if 条件里 —— set -e 不生效
if grep -q pattern file; then
    echo "found"
fi

# 场景 2:命令在 && / || 中
false && true   # 不退出
false || true   # 不退出
false | true    # 默认不退出,加 pipefail 才退出

# 场景 3:! 取反 —— 不生效
! grep -q pattern file

# 场景 4:函数体最后一条命令失败时,函数返回非 0,set -e 只在调用处检查

所以配 trap ERR 兜底:

#!/bin/bash
set -euo pipefail

on_error() {
    local exit_code=$?
    local line_no=$1
    echo "[$(date '+%F %T')] 脚本在第 ${line_no} 行失败,退出码 ${exit_code}" >&2
    echo "--- 出错前执行的最后一条命令 ---" >&2
    echo "${BASH_COMMAND}" >&2
    cleanup
    exit "${exit_code}"
}
trap 'on_error ${LINENO}' ERR

cleanup() {
    rm -f /tmp/backup.$$
}

${LINENO} 给出失败行号,${BASH_COMMAND} 给出具体命令,两个合起来相当于 Bash 版堆栈信息。

trap ERR 在命令失败、脚本退出之前触发,所以能在里面做清理。但 ERR trap 在函数内部有继承问题:要在函数、子 shell、命令替换里也生效,需要 set -E(errtrace):

set -Eeuo pipefail

第三层:bash -n、bash -v 与 shellcheck

bash -n 只做语法解析,不执行:

bash -n /root/backup.sh
# 无输出表示语法正确
# /root/backup.sh: line 18: syntax error: unexpected end of file

bash -v 在解析阶段打印每一行(变量展开之前),和 -x 互补:

bash -v /root/backup.sh 2>&1 | head -30

shellcheck 是静态分析器:

apt-get install -y shellcheck     # Debian/Ubuntu
yum install -y ShellCheck         # RHEL/CentOS
shellcheck /root/backup.sh

报告示例:

In /root/backup.sh line 4:
mkdir $BACKUP_DIR
      ^---------^ SC2086: Double quote to prevent globbing and word splitting.

In /root/backup.sh line 5:
mysqldump -uroot -p$DB_PASS myblog > $BACKUP_DIR/db.sql
                             ^----^ SC2086
                  ^--^ SC2086

In /root/backup.sh line 7:
find /root/backups -type f -mtime +7 -delete
^-- SC2185: Some find options take a default path, this may be unintended.

每个 SC 编号都能在 shellcheck 官网查到解释。放进 cron 的脚本先过 shellcheck,零告警再上线。

可以直接抄走的模板

存成 /root/scripts/_lib.sh,source 进每个生产脚本:

#!/bin/bash
# _lib.sh —— 生产脚本通用调试骨架
set -Eeuo pipefail

if [[ "${DEBUG:-0}" == "1" ]]; then
    export PS4='+[${BASH_SOURCE##*/}:${LINENO}] ${FUNCNAME[0]:-main}: '
    set -x
fi

LOG_FILE="/var/log/scripts/$(basename "$0").log"
mkdir -p "$(dirname "$LOG_FILE")"

log() {
    printf '[%s] %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"
}

on_error() {
    local exit_code=$?
    log "ERROR: 第 ${1} 行失败 (exit=${exit_code}),命令: ${BASH_COMMAND}"
    exit "$exit_code"
}
trap 'on_error ${LINENO}' ERR

TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT

main() {
    log "任务开始"
    # ...
    log "任务结束"
}
main "$@"

骨架做到五件事:完整严格模式(含 -E);DEBUG=1 切换追踪;失败记录行号和命令;退出时清理临时目录;输出同时写文件和标准输出。

从报错回头找根因

cron 邮件说备份失败,按顺序查:

第一步,看邮件里的行号。用了模板的话会直接写「第 14 行失败,命令是 tar czf ...」。

第二步,复现并开追踪:

DEBUG=1 bash /root/backup.sh

输出末尾:

+[backup.sh:14] main: tar czf /root/backups/20261001/www.tar.gz /var/www/html
tar: /root/backups/20261001/www.tar.gz: Cannot open: No such file or directory
+[backup.sh:14] main: on_error 14
[2026-10-01 03:00:12] ERROR: 第 14 行失败 (exit=2),命令: tar czf ...

mkdir 明明执行了,目录为什么不存在?回看第 13 行:命令本身没问题。再往上,上一次运行的清理逻辑 find -mtime +7 -delete 把当天刚建的目录也删了——它用 -mtime +7 判修改时间,目录当天被创建又被重命名,时间戳对不上,被当成过期。

第三步,用 shellcheck 验证修复方案:改成先建临时目录、备份完成后原子改名,先 shellcheck 零告警再上线。

第四步,加防御性断言:

mkdir -p "$BACKUP_DIR"
[[ -d "$BACKUP_DIR" ]] || { log "FATAL: 备份目录创建失败 $BACKUP_DIR"; exit 1; }

mysqldump -uroot -p"$DB_PASS" myblog > "$BACKUP_DIR/db.sql"
[[ -s "$BACKUP_DIR/db.sql" ]] || { log "FATAL: 数据库导出为空"; exit 1; }
gzip -t "$BACKUP_DIR/db.sql" 2>/dev/null || true

[[ -s file ]] 检查文件非空,是备份类脚本最值得加的一行——备份文件存在但为空,比备份失败还危险,因为它看上去成功了。

清单

  1. 超过 20 行的脚本,开头写 set -Eeuo pipefail
  2. 定义 ERR trap,输出 LINENO 和 BASH_COMMAND
  3. 变量引用一律加双引号 "$VAR",除非明确需要分词
  4. 关键操作前后加断言:目录存在、文件非空、退出码为 0
  5. 临时目录用 mktemp -d,配 trap cleanup EXIT
  6. 调试顺序:bash -n 查语法 → shellcheck 查语义 → 真跑
  7. 追踪时自定义 PS4 带文件名、行号、函数名
  8. 上线前 DEBUG=1 手动跑一遍
  9. 日志同时写文件和标准输出
  10. 别相信「脚本跑完没报错」,要相信「最后一句断言通过」
复制全文 生成海报 Shell Bash 运维 脚本调试 cron

推荐文章

程序员茄子在线接单