凌晨 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 ]] 检查文件非空,是备份类脚本最值得加的一行——备份文件存在但为空,比备份失败还危险,因为它看上去成功了。
清单
- 超过 20 行的脚本,开头写
set -Eeuo pipefail - 定义 ERR trap,输出
LINENO和BASH_COMMAND - 变量引用一律加双引号
"$VAR",除非明确需要分词 - 关键操作前后加断言:目录存在、文件非空、退出码为 0
- 临时目录用
mktemp -d,配trap cleanup EXIT - 调试顺序:
bash -n查语法 → shellcheck 查语义 → 真跑 - 追踪时自定义 PS4 带文件名、行号、函数名
- 上线前
DEBUG=1手动跑一遍 - 日志同时写文件和标准输出
- 别相信「脚本跑完没报错」,要相信「最后一句断言通过」