Shell脚本调试技巧与常见陷阱
发布日期: 2026/08/17 阅读总量: 1

凌晨2点15分,我的清盘脚本把产品图全删了

监控告警:线上订单服务磁盘使用率99%。我爬起来第一件事是检查定时清理脚本的日志——昨晚3点清理脚本跑过,日志没有任何报错,但/data/app/upload目录下的当天产品图片全部消失了。

查了半天,原因就一行:rm -rf $LOG_DIR/*。那天配置文件里变量名拼错,LOG_DIR 成了空字符串,命令展开成 rm -rf /*。幸好在应用权限下没删到系统盘,但应用目录已经稀碎。从备份恢复花了3小时,当天订单量下降15%。

从那以后,我把Shell脚本从“能跑就行”改成了“必须可调试,必须能防呆”。这篇文章把方法写下来,都是拿事故换来的。

方案对比:为什么echo打印不行

先说结论:我维护着公司50多个生产脚本,按使用频率排序:

方案原理优点缺点推荐场景
echo 打印手动输出变量值直观,零学习成本改动源码、污染输出、$?被覆盖临时验证一次性的小逻辑
bash -x / set -x执行前打印每条扩展后的命令不改源码,能看到真实执行命令输出量大,有管道时易刷屏定位错误发生的那几行
shellcheck静态分析AST提前发现90%的语法级错误不执行代码,查不出运行时逻辑写完必查,CI里加一道

还有 bashdb 这类的断点调试器,学习成本高,生产环境很少用。我们团队50多个脚本,没有一个人依赖它。

一个充满陷阱的备份脚本

下面这个脚本“很常见”:备份MySQL、打包上传目录、清理7天前的旧备份。

#!/bin/bash
# backup.sh  故意写得很危险
BACKUP_DIRS=/data/backup
APP_DIR=/data/app/upload
DATE=$(date +%Y%m%d)

mkdir -p $BACKUP_DIR/$DATE
mysqldump -uroot -ppassword orders > $BACKUP_DIR/$DATE/db.sql
tar -zc $APP_DIR > $BACKUP_DIR/$DATE/upload.tar.gz
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -exec rm {} \;

先别往下看,3秒内你能找出几个问题?

  • BACKUP_DIRSBACKUP_DIR 拼写不一致
  • 变量没加引号,路径里一旦有空格,命令会被拆开
  • mysqldump 失败后脚本会继续执行,不会退出
  • rm -rf 没有防呆保护

方案A:echo 打印的经典翻车

有人为了调试,在脚本里加了这种代码:

#!/bin/bash
DEBUG=1
BACKUP_DIRS=/data/backup
APP_DIR=/data/app/upload
DATE=$(date +%Y%m%d)

mkdir -p $BACKUP_DIR/$DATE
mysqldump -uroot -ppassword orders > $BACKUP_DIR/$DATE/db.sql
[ -n "$DEBUG" ] && echo "mysqldump exit: $?"
tar -zc $APP_DIR > $BACKUP_DIR/$DATE/upload.tar.gz
[ -n "$DEBUG" ] && echo "tar exit: $?"

这个 echo $? 已经成为经典的“二次背锅”错误:$? 只保留上一条命令的退出码,但 [ -n "$DEBUG" ] && echo ... 这条组合命令执行后,$? 已经变成 echo 的退出码(0)。如果 mysqldump 失败了,echo "mysqldump exit: 0",误导你半天。

另外你还要在所有可疑点手工加代码,改完还得记得删,万一忘删,生产日志里全是调试语句。

方案B:bash -x 直接锁定变量展开后的真实命令

无需修改脚本,直接执行:

bash -x backup.sh

输出片段:

+ BACKUP_DIRS=/data/backup
+ APP_DIR=/data/app/upload
+ date +%Y%m%d
+ mkdir -p /data/backup/
+ mysqldump -uroot -ppassword orders
+ tar -zc /data/app/upload
+ find /data/backup -name '*.tar.gz' -mtime +7 -exec rm '{}' ';'

看到 mkdir -p /data/backup/ 了吗?因为 BACKUP_DIR 没赋值,$BACKUP_DIR 为空,所以路径直接少了一层。如果你用 echo 打印,可能还能看出来,但 bash -x 把这个“空变量展开”直接暴露在执行行里,比 echo 直观得多。

bash -x 也有缺点:管道和重定向多时输出会非常多,而且它只告诉你“做了什么”,不告诉你“该不该这么做”。比如 rm -rf /,它一样会打印,但不会阻止。

方案C:shellcheck 静态检查

写完脚本后跑一下 shellcheck 是必须的。我这里用的是 shellcheck 0.9.0,Ubuntu 上 apt install shellcheck,macOS 上 brew install shellcheck

shellcheck backup.sh

输出:

In backup.sh line 9:
mkdir -p $BACKUP_DIR/$DATE
         ^-- SC2086: Double quote to prevent globbing and word splitting.
         ^-- SC2154: BACKUP_DIR is referenced but not assigned.

两个致命错误都被抓出来了:变量未加引号、变量未定义。shellcheck 不会执行代码,它只做静态分析,所以效率极高。我们团队把这个命令直接挂进了GitLab CI,所有脚本合并前必须通过 shellcheck,问题脚本几乎绝迹。

优化调试体验:PS4、局部set -x、日志输出

bash -x 默认只输出到 stderr,输出前缀只是一个 +。在复杂脚本里不知道当前在哪一行,可以用 PS4 把行号、函数名、层级打出来:

#!/bin/bash
export PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-}: '
set -x
echo "test"
set +x

执行效果:

+test.sh:5:main: echo test

这样你一眼就能看到是哪一行、哪个函数在跑。

如果不想让调试信息污染终端,可以把 xtrace 输出重定向到文件:

#!/bin/bash
exec 3>> /tmp/my_debug.log
BASH_XTRACEFD=3
set -x
# 你的代码
set +x
exec 3>&-

生产环境不建议长开 set -x,建议用 trap DEBUG 或者直接在脚本头部写一个开关:

#!/bin/bash
[[ "${DEBUG:-}" == "1" ]] && set -x
# 正常业务代码

set -euo pipefail:救命四件套

我写的每个生产脚本,前五行都是这样的:

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'

逐个解释:

  • -E:让 ERR trap 被继承到 shell 函数和外部命令。
  • -e:任何命令返回非0值立即退出。
  • -u:使用未定义变量直接报错退出。
  • -o pipefail:管道中只要有一个命令失败,整个管道就算失败。
  • IFS=$'\n\t':把默认的分隔符从空格/制表符/换行改成换行和制表符,避免文件名里的空格把循环拆开。

注意:set -e 有失效场景。下面这段代码,你猜会不会退出?

#!/bin/bash
set -e
mysqldump -uroot -ppassword orders | gzip > db.sql.gz
echo "脚本没有退出"

答案是不会退出。管道返回的是最后一个命令 gzip 的退出码,gzip 一般会成功,所以 mysqldump 哪怕失败了,整个管道还是0。这就是必须加 set -o pipefail 的原因。

还有一种情况,命令在 if 判断条件里,set -e 不会生效:

#!/bin/bash
set -e
if grep "error" /var/log/app.log; then
  echo "发现错误"
fi
echo "grep 失败也不退出"

逻辑上这是合理的,但你要知道这一点,别指望所有非0都退出。

完整可运行的安全版 backup.sh

#!/usr/bin/env bash
# 安全备份脚本
set -Eeuo pipefail
IFS=$'\n\t'

LOG_FILE="/var/log/backup.log"

log() {
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
    echo "$msg" | tee -a "$LOG_FILE"
}

safe_rm() {
    local path="${1:-}"
    if [[ -z "$path" || "$path" == "/" || "$path" == "./" ]]; then
        log "拒绝删除危险路径: [$path]"
        return 1
    fi
    rm -rf -- "$path"
}

main() {
    local BACKUP_DIR="/data/backup"
    local APP_DIR="/data/app/upload"
    local DB_USER="root"
    local DB_PASS="password"
    local DB_NAME="orders"
    local DATE
    DATE=$(date +%Y%m%d%H%M%S)

    mkdir -p "$BACKUP_DIR/$DATE"

    if mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$BACKUP_DIR/$DATE/db.sql"; then
        log "数据库备份成功: db.sql"
    else
        log "数据库备份失败,终止脚本"
        exit 1
    fi

    if tar -zc "$APP_DIR" > "$BACKUP_DIR/$DATE/upload.tar.gz"; then
        log "应用目录打包成功: upload.tar.gz"
    else
        log "应用目录打包失败"
        exit 1
    fi

    # 删除7天前的打包文件,调用安全删除函数
    while IFS= read -r oldfile; do
        safe_rm "$oldfile"
        log "删除旧备份: $oldfile"
    done < <(find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7)

    log "全部完成,备份目录: $BACKUP_DIR/$DATE"
}

main "$@"

这里的 while IFS= read -r oldfile 是为了安全遍历 find 结果,即使文件名有空格也不会被拆开。旧备份删除方式没有用 -exec rm,而是交给 safe_rm 函数做路径校验,杜绝删根目录。

效果数据:事故率从30%降到0

我们对公司26个核心脚本做了相同的加固处理(加安全头、shellcheck、safe_rm、日志函数),并统计了改造前和改造后各90天的运行数据:

指标改造前改造后
脚本运行失败次数473
因变量未定义导致错误120
因未加引号导致路径拆分180
rm -rf 误删目录20
定位一个线上脚本问题的平均耗时38分钟6分钟
脚本执行成功率91.7%99.8%

剩余3次失败均来自外部依赖(MySQL 挂了、磁盘满),脚本本身没有任何写错。

避坑:我踩过的6个Shell致命陷阱

坑1:set -e 在管道和if条件里失效

上面讲过了。处理方式:加 set -o pipefail,或者对必需成功的命令单独用 if 包裹。

坑2:变量没加引号,文件路径里有空格就拆家

path="/data/my backup"
rm -rf $path
# 等价于 rm -rf /data/my backup

这里会删除 /data/my 和 backup 两个目录。必须写成 rm -rf "$path"

坑3:$? 一旦被 echo 就没了

mysqldump ...
echo "mysqldump 执行结果: $?"
if [ $? -ne 0 ]; then
  exit 1
fi

上面的 $? 在 echo 之后已经改变了,if 判断的其实是 echo 的退出码。正确做法:if ! mysqldump ...; then exit 1; fi

坑4:用 sh 执行 bash 脚本

sh 在 Ubuntu 上是指向 dash 的,不支持数组、[[ ]]{1..10} 等语法。所以脚本第一行写 #!/usr/bin/env bash,然后给执行权限,直接用 ./script.sh 跑,别用 sh script.sh

坑5:find 的 -exec 终止符格式

# 错误:{} 和 ; 之间多了空格
find . -name "*.log" -exec rm -rf {} ;

# 正确:; 紧跟 {} 后面,或者用 +
find . -name "*.log" -exec rm -rf {} \;
find . -name "*.log" -exec rm -rf {} +

注意 -exec rm -rf {} + 中,{} + 必须在一起,中间不能有空格。经验不足的人容易写成 {} + 作为文件名,导致报错。

坑6:shellcheck 报 SC2034 时,不要简单修掉变量,而是检查拼写

我见过有人为了让 shellcheck 通过,把“未被使用”的变量声明直接删了。结果这个变量本来应该在别处使用,删了之后路径变成了空。正确做法是搜索这个变量的所有引用,检查有没有拼错。shellcheck 只是工具,最终判断靠人。

调试工作流总结

我现在写脚本的标准流程:

  1. 写完后立刻跑 shellcheck script.sh,把所有错误清零。
  2. 加上 set -Eeuo pipefailIFS=$'\n\t'
  3. 执行一次 bash -x script.sh,看真实展开的命令,重点检查变量是否为空、路径是否正确。
  4. 涉及删除命令,一律走防呆函数,禁止裸用 rm -rf
  5. 在CI里加一道 shellcheck,防止后来的人把代码改坏。

这套流程帮我们把脚本问题定位时间从平均38分钟降到6分钟,最关键的是,再没出现过二次事故。