服务器出问题时,你还在靠 echo 排错吗
几乎每个站长都写过这样的脚本:一个备份脚本,加了几十行 echo "开始备份"、echo "压缩完成",跑起来看着日志一行行刷过去很安心。可一旦某个凌晨它失败了,你面对的就是一屏没有任何变量值的输出,只能靠猜:是路径错了?是权限问题?还是 gzip 因为磁盘满提前退出了?
真正的问题不是脚本写得不好,而是缺少一套调试方法。Bash 没有断点、没有变量监视窗口,但这不代表它不可调试——恰恰相反,Bash 内置了非常强的追踪能力,只是大部分站长不知道。
本文从一个真实的备份脚本故障出发,系统讲清 Bash 调试的三层手段:set -x 与 PS4 的追踪输出、-n/-v/ERR trap 的静态与运行时检查、以及 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 "备份完成"它至少埋了四个坑:mysql 密码没加引号,一旦密码里有特殊字符就炸;BACKUP_DIR 没加引号,路径含空格就废;目录没建成功也不检查,后面的重定向会把数据写到一个不存在的位置;find 没有 -name 约束,可能把不该删的东西删掉。而最要命的是——脚本最后永远打印「备份完成」,哪怕前面全失败。
这类脚本用肉眼很难审出来。接下来进入正题。
第一层:用 set -x 和 PS4 看清每一步
set -x 是最直接的调试开关。开启后,bash 会把每一条即将执行的命令(变量已经展开之后)打印到标准错误。这一条就足以解决绝大部分「脚本跑到哪一步断了」的问题。
#!/bin/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 让追踪更可读。 默认的 + 前缀信息量太少。自定义 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
# 只追踪到第 20 行(新版 bash 支持)
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 与 trap ERR
set -x 让你看见过程,但不会阻止脚本继续往下跑。set -e 和 set -u 负责的是失败即停。
先分清这几个开关的语义,它们常被混为一谈:
set -e:任何命令返回非 0 就立刻退出脚本。set -u:使用未定义的变量时报错退出(默认 bash 把未定义变量当空字符串,极危险)。set -o pipefail:管道中任何一段失败,整个管道就算失败。默认只看最后一段。set -x:只负责打印,不改变流程。
#!/bin/bash
set -euo pipefail加在脚本开头的这一行能挡掉大量事故。但 set -e 有著名的静默失效场景,必须心里有数:
# 场景 1:命令在 if 条件里 —— set -e 不生效
if grep -q pattern file; then
echo "found" # 这里没问题,grep 非 0 是预期的
fi
# 场景 2:命令在 && / || 中 —— 只有最后一段受保护
false && true # 不退出
false || true # 不退出(|| 兜住了)
false | true # 默认不退出,因为管道只看最后一段
# 加了 pipefail 后才会退出
# 场景 3:! 取反 —— 不生效
! grep -q pattern file # grep 失败不会让脚本退出
# 场景 4:函数体最后一条命令 —— 如果它是失败的,
# 函数返回非 0,但 set -e 只在调用处检查所以真正健壮的脚本不会只靠 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 版的堆栈信息。 有了它们,凌晨 cron 失败时你收到的邮件就直接指向了问题行,不用再靠猜。
注意 trap ... ERR 的触发时机:它在命令失败、但脚本还没退出之前触发,所以你能在里面做清理。另外 ERR trap 在函数内部有「继承」问题,如果函数里也要生效,需要在函数内单独 trap,或开启 set -E(errtrace):
set -Eeuo pipefail # -E 让 ERR trap 在函数、子 shell、命令替换里也生效加上 -E 之后,函数内部命令失败同样会触发你定义的 on_error,这才是完整的方案。
第三层:不执行也能查错 —— bash -n 与 shellcheck
前面两个层次都需要「真的跑一遍」。但如果脚本会删除数据、会重启服务,你不敢随便跑,怎么办?用静态检查。
bash -n 只做语法解析,不执行任何命令。 它能抓到引号不配对、括号没闭合、if/fi 不匹配这类低级但致命的错误。
bash -n /root/backup.sh
# 无输出表示语法正确
# 有输出形如:
# /root/backup.sh: line 18: syntax error: unexpected end of filebash -v 在解析阶段就打印每一行(在变量展开之前),和 -x 正好互补:-v 看原始代码,-x 看展开结果。
bash -v /root/backup.sh 2>&1 | head -30真正该上的是 shellcheck。 它是 Bash 的静态分析器,能识别几百种常见陷阱,包括上面提到的所有问题:没加引号的变量、set -e 失效场景、find 的危险参数、for f in $(ls) 这类错误写法。
# 安装
apt-get install -y shellcheck # Debian/Ubuntu
# 或
yum install -y ShellCheck # RHEL/CentOS
# 使用
shellcheck /root/backup.sh对前面那个问题脚本,shellcheck 会给出类似这样的报告:
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: Double quote to prevent globbing.
^--^ SC2086: Double quote to prevent globbing.
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
# 调试开关:DEBUG=1 ./backup.sh 时开启完整追踪
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 行的追踪输出:
+[backup.sh:13] main: mkdir /root/backups/20261001命令本身没问题。再往上找,你会发现上一次运行的清理逻辑 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。 - 定义
ERRtrap,输出LINENO和BASH_COMMAND。 - 变量引用一律加双引号:
"$VAR",除非明确需要分词。 - 关键操作前后加断言:目录存在、文件非空、命令退出码为 0。
- 临时目录用
mktemp -d,并trap cleanup EXIT。 - 调试时先
bash -n查语法,再shellcheck查语义,最后才真跑。 - 追踪时自定义
PS4带上文件名、行号、函数名。 - 上线前用
DEBUG=1手动跑一遍完整流程,别让第一次真跑发生在凌晨三点。 - 日志同时落文件和标准输出,cron 邮件才不会是一片空白。
- 永远不要相信「脚本跑完没有报错」,要相信「脚本最后一句断言通过了」。
Bash 调试的核心不是记住几十个开关,而是建立一种习惯:让脚本在出错时主动告诉你错在哪一行、执行了什么命令。做到这一点,你花在排错上的时间至少能减少一半。