Shell 脚本写崩了怎么排查?我用 set -x 和 trap 找出那几个隐蔽 bug 的经历
Shell 脚本写崩了怎么排查?我用 set -x 和 trap 找出那几个隐蔽 bug 的经历
去年写过一个小脚本,作用是每天凌晨把数据库备份文件压缩、加密、上传到 OSS。测试环境跑了三天没问题,上线第一周就翻车了——备份文件全是 0 字节,OSS 里一个没传上去。日志里什么都没留下。最后查了半天,原因是一个变量在特定条件下为空,脚本没报错就顺着执行下去了,rm 删了原文件,cp 了个空的。
从那以后我写 Shell 脚本养成了一套调试习惯。这篇文章把我觉得最管用的几招整理出来。
第一道防线:set 选项
Shell 默认行为是宽松的——命令失败了继续跑,变量没定义就用空字符串。这对交互式操作来说比较友好,但对自动化脚本是灾难。
我现在的脚本开头基本都这样写:
#!/bin/bash
set -euo pipefail
三个选项分开说:
set -e:命令失败就退出
set -e
cd /nonexistent # 脚本直接退出,不继续执行后面的语句
echo "这里不会执行"
有个容易踩的坑:set -e 对管道中的最后一个命令生效,但管道中间的失败默认不触发退出。比如:
set -e
false | true # 不会退出,因为最后一个命令 true 成功了
echo "这里会执行"
这就是为什么还要加 pipefail。
set -o pipefail:管道里任何一个命令失败都算失败
set -eo pipefail
false | true # 现在会退出
set -u:引用未定义变量直接报错
set -u
echo "$UNDEFINED_VAR" # 报错:UNDEFINED_VAR: unbound variable
这能抓到很多拼写错误。之前我在脚本里写了 $BACKUP_DIR 但实际变量叫 $BACKUP_PATH,没加 set -u 的时候脚本静默处理了空字符串,结果备份写到了根目录。
一个实用的安全写法:如果你确实需要用可能不存在的变量,用 ${VAR:-default}:
echo "${OPTIONAL_VAR:-默认值}"
三件套组合效果
set -euo pipefail
加上之后你会发现之前"能跑"的脚本开始报错了——这其实是好事,说明之前有潜伏的 bug 一直被忽略着。
真正的调试利器:set -x
set -x 会让 Shell 在执行每条命令之前打印出来,前面加 + 号。这是我查脚本问题时的第一反应动作。
#!/bin/bash
set -x
dir="/opt/backup/$(date +%Y%m%d)"
mkdir -p "$dir"
tar -czf "$dir/db.tar.gz" /var/lib/mysql
执行输出类似:
+ dir=/opt/backup/20260509
+ mkdir -p /opt/backup/20260509
+ tar -czf /opt/backup/20260509/db.tar.gz /var/lib/mysql
变量展开后的实际命令一目了然。上次我脚本里 cp "$SRC" "$DST" 静默失败,加了 set -x 立刻发现 SRC 被展开成了空字符串。
局部开关:不用给整脚本加 set -x,只包住怀疑的区域:
# 正常代码
set -x
# 只打印这部分
cp "$SRC" "$DST"
set +x
# 恢复正常
trap 捕获错误现场
脚本跑到一半挂了,事后查日志只有一句模糊的报错。这时候 trap 能帮你抓错误现场。
#!/bin/bash
set -euo pipefail
on_error() {
local exit_code=$?
echo "=============================="
echo "脚本在第 $LINENO 行出错,退出码: $exit_code"
echo "当前执行的命令: $BASH_COMMAND"
echo "=============================="
}
trap on_error ERR
效果:脚本任何一行出错都会触发 on_error,打印行号、错误码和执行到哪条命令。排查效率直接翻倍。
更进一步,加上退出时的清理和调试信息:
cleanup() {
echo "脚本退出,执行清理..."
rm -f /tmp/myapp.lock
}
trap cleanup EXIT
ERR 和 EXIT 可以一起用,互补。
shellcheck:写完别急跑,先过一遍
shellcheck 是 Shell 脚本的静态检查工具,能抓到很多肉眼扫不出来的问题。我装完基本每个脚本都会跑一遍:
# 安装
apt install shellcheck # Debian/Ubuntu
yum install ShellCheck # CentOS/RHEL
# 检查
shellcheck backup.sh
它报的典型问题:
| 警告 | 解释 |
|---|---|
| SC2086: Double quote to prevent globbing | 变量没加双引号,空格会拆分 |
SC2164: Use cd ... \|\| exit |
cd 可能失败,不处理会在错误目录继续跑 |
| SC2046: Quote this to prevent word splitting | $(cmd) 的值没引号会按空格拆分 |
SC2006: Use $(...) instead of `...` |
反引号已过时,用 $() |
举个例子:我之前写 rm -rf $TEMP_DIR/*,shellcheck 提示 SC2115——如果 $TEMP_DIR 为空,这个命令会变成 rm -rf /*。吓得我赶紧改成了 "${TEMP_DIR:?}/*"。
日志:别用 echo,用 logger
脚本跑在 cron 里,echo 的输出没人看。用 logger 把关键信息写到 syslog:
logger -t "backup-script" "备份开始:$BACKUP_DIR"
logger -t "backup-script" -p user.error "备份失败:数据库连接超时"
然后在系统日志里统一查:
journalctl -t backup-script --since "1 hour ago"
或者写独立的带时间戳的日志函数:
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a /var/log/backup.log
}
log "开始备份数据库..."
# 执行操作
log "备份完成,文件大小: $(stat -c%s "$BACKUP_FILE") 字节"
实战:排查一个真实的脚本 bug
场景:备份脚本,目标是把 MySQL dump 出来的文件压缩后上传到 OSS。
#!/bin/bash
DB_NAME="mydb"
DUMP_FILE="/tmp/${DB_NAME}_$(date +%F).sql"
mysqldump -u root "$DB_NAME" > "$DUMP_FILE"
gzip "$DUMP_FILE"
ossutil cp "${DUMP_FILE}.gz" oss://my-bucket/backups/
看起来没问题。但某天失败了——原因是 mysqldump 执行失败(数据库在维护),但没有退出检查,gzip 对着不存在的文件报错,ossutil 上传了一个叫 .gz 的空文件名片段。
修复后的版本:
#!/bin/bash
set -euo pipefail
DB_NAME="mydb"
DATE=$(date +%F)
DUMP_FILE="/tmp/${DB_NAME}_${DATE}.sql"
GZ_FILE="${DUMP_FILE}.gz"
OSS_PATH="oss://my-bucket/backups/${DB_NAME}_${DATE}.sql.gz"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
log "开始备份 $DB_NAME"
if ! mysqldump -u root "$DB_NAME" > "$DUMP_FILE" 2>/tmp/dump.err; then
log "mysqldump 失败:$(cat /tmp/dump.err)"
exit 1
fi
log "dump 完成,文件大小: $(stat -c%s "$DUMP_FILE") 字节"
gzip -f "$DUMP_FILE"
log "压缩后大小: $(stat -c%s "$GZ_FILE") 字节"
if ! ossutil cp "$GZ_FILE" "$OSS_PATH"; then
log "上传 OSS 失败"
exit 1
fi
log "备份完成: $OSS_PATH"
rm -f "$DUMP_FILE" "$GZ_FILE"
改动点:
- 加了
set -euo pipefail mysqldump有失败检查(用 if 判断返回值,set -e 对 if 条件里的命令不生效)- 用固定变量名避免重复拼接
- 加了每一步的日志和文件大小检查
- 上传 OSS 有返回值判断
几个容易翻车的细节
rm 之前确认变量非空
# 危险写法
rm -rf "$DIR/*"
# 安全写法
[[ -n "$DIR" ]] && rm -rf "${DIR:?}"/*
${VAR:?} 如果 VAR 为空或未定义,脚本直接报错退出,不会默默变成 rm -rf /*。
并发执行时加锁
cron 里的脚本如果跑太久,下一次调度可能同时启动第二个实例:
LOCKFILE=/tmp/backup.lock
exec 200>"$LOCKFILE"
flock -n 200 || { echo "已有实例在运行,退出"; exit 1; }
# 脚本逻辑...
flock -u 200
处理带空格的文件名
# 错误
for file in $(ls /backup/); do # 文件名有空格就拆开了
process "$file"
done
# 正确
for file in /backup/*; do
process "$file"
done
总结
Shell 脚本调试的核心思路其实就三条:
- 让错误显形:
set -euo pipefail+set -x - 记录现场:trap 捕获行号 + 带时间戳的日志
- 静态检查:shellcheck 跑一遍再上线
我现在写脚本已经养成了肌肉记忆:新建文件 → 先写 #!/bin/bash 和 set -euo pipefail → 写完跑 shellcheck → 关键操作加日志 → trap 兜底。虽然多了几分钟的功夫,但比起半夜爬起来排 bug,这几分钟太值了。
脚本不是写完就完了,它得经得起凌晨三点没人盯着的时候也能正常工作。
评论 (0)