Shell脚本调试:从瞎猜日志到3分钟定位
发布日期: 2026/08/11 阅读总量: 1

别瞎猜了,用这些工具直接定位

2023年11月,我负责的一个数据同步任务突然失败。日志显示:ERROR: backup file not found。我花了4个小时,各种echo、各种注释代码、各种猜测,最后发现是变量引用时忘记加双引号,文件路径含空格被拆成了两个参数。这个错误,用bash -x一行命令,10秒就能看出来。

从那天起,我把Shell脚本调试的三板斧——bash -xtrap陷阱、shellcheck静态检查——用到了所有脚本里。调试时间从小时级降到了分钟级。

这篇文章不聊理论,直接给你能用的东西:

  • 三个调试工具怎么搭配使用,覆盖"运行前→运行中→崩溃后"全流程
  • 一个可以直接粘贴的调试模板
  • 我在生产环境踩过的8个坑,以及验证过的规避方案

方案对比:三种调试方式怎么选

先讲结论,再展开。我同时用三个方案,不互相替代:

方案工具定位效率适用场景学习成本
静态检查ShellCheck 0.9.0秒级出报告写代码时预防
逐行追踪bash -x (GNU bash 5.2.15)分钟级定位运行中看变量
崩溃捕捉trap + set -e错误时立即知道位置崩溃后拿关键信息

三条数据来自我这边真实脚本统计:

  • 加入set -x追踪后,排查一个中等复杂度脚本(200行左右)的平均耗时从4.2小时降到15分钟
  • 使用trap后,线上脚本故障从"看日志猜原因"变成"直接看错误行号",平均修复时间缩短70%
  • ShellCheck在代码评审阶段拦截了约40%的潜在缺陷(变量未加引号、误用test、管道吞退出码等)

调试方案一:bash -x 逐行追踪

基础用法

bash -x会在每条命令执行前打印该命令,并把变量展开后的值显示出来。这不只是"看日志",这是回放整个执行过程。

# 语法:直接加 -x 参数
bash -x your_script.sh

# 或者脚本内局部开启
#!/bin/bash
set -x          # 从这里开始追踪
cp "$SRC" "$DST"
set +x          # 到这里结束追踪
echo "done"

输出示例(注意+开头的行):

$ bash -x test.sh
+ SRC='/home/user/my file.txt'
+ DST=/backup/
+ cp /home/user/my file.txt /backup/
cp: cannot stat '/home/user/my': No such file or directory

看到没?cp命令把/home/user/myfile.txt当成了两个参数,因为变量没加引号。这种错误,用肉眼扫一眼就定位了。

进阶用法:PS4 让输出更易读

bash -x默认输出行前是+号。脚本长了以后分不清嵌套层级。用PS4环境变量增强输出:

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

# 你的业务代码
check_disk() {
    df -h | grep '/dev/sda1'
}
check_disk

输出就会变成:

+(test.sh:9): check_disk(): df -h | grep '/dev/sda1'

有了文件名、行号、函数名,几百行的脚本也能快速定位到具体位置。

把调试输出写入日志文件

生产环境你不能一直开着set -x刷屏。把调试输出导向文件:

#!/bin/bash
# 调试模式开关:DEBUG=1 ./script.sh
if [[ "${DEBUG:-0}" == "1" ]]; then
    export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
    exec 5>>/tmp/script_debug.log
    BASH_XTRACEFD=5
    set -x
fi

echo "业务代码开始"
# ... 业务逻辑

这样生产环境用DEBUG=1跑一次,追踪信息都在/tmp/script_debug.log里,不污染标准输出。

调试方案二:set -e 与 trap 组合

set -x只能看过程,脚本中途崩溃还是得靠trap抓现场。

不带 trap 的 set -e 是个坑

很多人只写set -e,觉得脚本出错就会退出。但set -e有一个经典陷阱:它只在没有条件判断的简单命令失败时触发。看这段:

#!/bin/bash
set -e

# 这里不会退出!
if grep "error" /var/log/app.log; then
    echo "发现错误"
fi

# 这里也不会退出!
grep "error" /var/log/app.log || echo "grep失败,但脚本继续"

# 这里才会退出
grep "error" /var/log/app.log

if||语境下的命令失败不会触发set -e,这是bash的设计特性,不是bug。但很多新手在这里踩坑。

trap + set -e:出错时打印行号和关键变量

正确的组合是set -eE加上trap '...' ERR

#!/bin/bash
set -eE
trap 'echo "❌ 脚本出错: 第${LINENO}行, 命令: ${BASH_COMMAND}"; exit 1' ERR

# 错误示范:变量没加引号
file_path="/tmp/my data/file"
cat $file_path   # 这里会触发 ERR 陷阱

echo "这行不会执行"

运行结果:

❌ 脚本出错: 第8行, 命令: cat $file_path

加上-E参数,函数内部的ERR也能被捕获,否则trap对函数内的错误失效。

生产级错误处理模板

这是我目前所有脚本都在用的模板,直接拿走:

#!/bin/bash
set -eE -o pipefail
export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'

# 日志函数
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a /var/log/my_script.log
}

# 错误处理
error_handler() {
    local exit_code=$?
    local line_no=$1
    local cmd=$2
    log "❌ 脚本异常退出 | 退出码: ${exit_code} | 行号: ${line_no} | 命令: ${cmd}"
    # 可以在这里加告警通知(企业微信/钉钉webhook)
    # curl -fsSL -H 'Content-Type: application/json' -d "{\"msg\":\"脚本异常\"}" https://qyapi.weixin.qq.com/...
    exit "${exit_code}"
}

trap 'error_handler ${LINENO} "${BASH_COMMAND}"' ERR

# 业务代码
main() {
    # 模拟一个错误
    rm /tmp/不存在的文件_123456
}

main "$@"

调试方案三:ShellCheck 静态检查

bash -x是事后调试,ShellCheck是事前预防。它能发现Shell脚本里2000多种常见问题。

# Ubuntu/Debian 安装
apt install shellcheck -y

# macOS
brew install shellcheck

# 或者用 docker
docker run --rm -v "$(pwd):/mnt" koalaman/shellcheck:stable /mnt/your_script.sh

我以前的一个脚本,shellcheck跑出来的问题:

$ shellcheck backup.sh

In backup.sh line 8:
if [ $? -ne 0 ]; then
     ^-- SC2181: Check exit code directly with e.g. 'if mycmd;', not indirectly with $?.
     ^-- SC2086: Double quote to prevent globbing and word splitting.

In backup.sh line 15:
cp ${SRC_DIR}/* ${BACKUP_DIR}/
    ^-- SC2086: Double quote to prevent globbing and word splitting.
    ^-- SC2115: Use "${var:?}" to ensure this never expands to / .

第15行那个SC2115提示,如果你SRC_DIR变量为空,命令会变成cp /* /backup/——不是开玩笑,这是能把系统搞崩的错误。

一个真实事故的完整排查过程

上面讲了工具,现在用一个真实案例走一遍完整流程。

事故背景

一个定时任务,每隔5分钟把商品数据从A库同步到B库。某天开始频繁失败。脚本大概长这样:

#!/bin/bash
# 同步商品数据
source /etc/profile
DB_HOST="10.0.0.1"
DB_USER="sync_user"
DB_PASS="p@ssw0rd"
DATA_DIR="/data/sync"

# 导出数据
mysqldump -h${DB_HOST} -u${DB_USER} -p${DB_PASS} products > ${DATA_DIR}/products_$(date +%F).sql

# 压缩
gzip ${DATA_DIR}/products_*.sql

# 传输到备份服务器
scp ${DATA_DIR}/products_*.sql.gz backup@10.0.0.2:/backup/products/

第一步:ShellCheck 静态检查

$ shellcheck sync_products.sh

In sync_products.sh line 11:
mysqldump -h${DB_HOST} ... > ${DATA_DIR}/products_$(date +%F).sql
                 ^-- SC2086: Double quote to prevent globbing and word splitting.

In sync_products.sh line 12:
scp ${DATA_DIR}/products_*.sql.gz backup@10.0.0.2:/backup/products/
                 ^-- SC2086: Double quote to prevent globbing and word splitting.
                 ^-- SC2068: Double quote array expansions to avoid re-splitting elements.

提示很明确:DATA_DIR变量没加双引号。但这个脚本在生产跑了一年多没出问题,原因在于路径里没空格。真正的元凶藏在下一层。

第二步:bash -x 追踪

$ bash -x sync_products.sh 2>&1 | tail -20

+ gzip /data/sync/products_2023-11-15.sql
+ scp /data/sync/products_2023-11-15.sql.gz backup@10.0.0.2:/backup/products/
+ date +%F
+ gzip /data/sync/products_2023-11-15.sql
gzip: /data/sync/products_2023-11-15.sql.gz already exists; not overwritten
+ scp /data/sync/products_2023-11-15.sql.gz backup@10.0.0.2:/backup/products/
scp: /backup/products/products_2023-11-15.sql.gz: Permission denied

两个问题浮出水面:

  1. gzip发现同名文件存在,拒绝覆盖——因为脚本重跑时上一次的.gz文件还在
  2. scp报权限不足——备份服务器的backup用户对目标目录没有写权限

第三步:修复代码

#!/bin/bash
# 同步商品数据 - 修复版
set -eE -o pipefail
export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
trap 'echo "❌ 第${LINENO}行出错: ${BASH_COMMAND}"; exit 1' ERR

DB_HOST="10.0.0.1"
DB_USER="sync_user"
DB_PASS="p@ssw0rd"
DATA_DIR="/data/sync"
DATE_STAMP="$(date +%F)"
BACKUP_FILE="${DATA_DIR}/products_${DATE_STAMP}.sql"

# 加锁保护,防止并发执行
LOCK_FILE="/tmp/sync_products.lock"
exec 9>"${LOCK_FILE}"
flock -n 9 || { echo "脚本已在运行, 退出"; exit 1; }

# 导出数据(使用 --single-transaction 避免锁表)
mysqldump --single-transaction -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASS}" products > "${BACKUP_FILE}"

# 压缩(加 -f 强制覆盖)
gzip -f "${BACKUP_FILE}"

# 传输到备份服务器(先检查目标目录权限)
if ssh backup@10.0.0.2 "test -w /backup/products/"; then
    scp "${BACKUP_FILE}.gz" backup@10.0.0.2:/backup/products/
else
    echo "❌ 备份目录无写权限"
    exit 1
fi

echo "✅ 同步完成: $(date)"

修复效果

  • ShellCheck扫描:0 warning,0 error
  • 连续运行30天,0失败
  • 加了flock文件锁,杜绝了并发执行引发的数据竞争
  • 加了ssh test -w预检,提前发现权限问题,避免scp中途失败

避坑:我在生产环境踩过的坑

下面每个坑都是真实事故,每个事故都对应一次凌晨被叫醒的回忆。

坑1:变量不加引号,文件被删光

当时代码是:

rm -rf ${DATA_DIR}/*   # 错误示范

某次配置错误导致DATA_DIR为空,命令变成rm -rf /*。系统文件直接被删了。正确写法:

rm -rf "${DATA_DIR:?}"/*   # 变量为空时直接报错退出

:?语法:变量为空或未定义时,bash直接报错退出,不会执行删除命令。

坑2:管道吞掉退出码

这段代码你以为失败了会退出:

set -e
mysqldump ... | gzip > backup.sql.gz
echo "备份完成"  # 即使 mysqldump 失败,这行也会执行

因为管道命令的退出状态只取最后一个命令(gzip)的。即使mysqldump失败了,gzip成功,整条管道返回0。

解决办法是加set -o pipefail

set -eE -o pipefail
mysqldump ... | gzip > backup.sql.gz
echo "备份完成"  # mysqldump 失败时,这一行不会执行

坑3:使用未声明变量

默认情况下,bash使用未定义的变量时不会报错,值为空。一个小写错误就能让脚本跑得风马牛不相及。

#!/bin/bash
# 使用未声明变量
log_path="/var/log/app"
echo "日志目录: ${log_pth}"    # 拼错了,显示为空
# 结果:日志目录: (空)

set -u让未声明变量直接报错:

#!/bin/bash
set -u
echo "日志目录: ${log_pth}"
# 结果:bash: log_pth: unbound variable

坑4:IFS修改后没还原

某个脚本里改变了IFS(内部字段分隔符)来遍历CSV,但没在结束前恢复。导致脚本后面的所有命令都按逗号分割参数。文件路径里的逗号、空格全部出错。

#!/bin/bash
OLD_IFS="$IFS"
IFS=","
for field in $(cat data.csv); do
    echo "字段: $field"
done
IFS="$OLD_IFS"   # 必须恢复!

坑5:在循环里改全局变量不生效

你以为管道+子shell里改了变量,外面能用?

#!/bin/bash
count=0
cat file.txt | while read line; do
    count=$((count + 1))
done
echo "总行数: $count"  # 输出0!

因为管道右边的while是在子shell里执行的,改动不会传递到父shell。解决办法用进程替换:

#!/bin/bash
count=0
while read line; do
    count=$((count + 1))
done < <(cat file.txt)
echo "总行数: $count"  # 正确

坑6:find | while 执行效率陷阱

find + while处理大量小文件,一次处理一个,巨慢。统计过:处理10万个文件,单线程循环耗时近4分钟。改用xargs -P并行,提升到20秒

#!/bin/bash
# 慢:单线程逐行处理
find /data/files -type f | while read f; do
    gzip "$f"
done

# 快:8核并行
find /data/files -type f -print0 | xargs -0 -P 8 -I {} gzip {}

坑7:set -e 对函数内错误失效

set -e默认在某些上下文(if语句、函数、&&/||)内不生效。加上-E参数并配合trap

#!/bin/bash
set -eE
trap 'echo "错误发生在函数 ${FUNCNAME[0]} 第${LINENO}行"' ERR

my_function() {
    false
    echo "这一行仍然执行"
}

my_function

坑8:CRLF换行符问题

Windows上编辑过的脚本传服务器直接报command not foundbad interpreter,问题在于CRLF换行符。坑的是报错信息完全看不出来和换行符有关。

# 检查是否有CRLF
file script.sh
# 显示 "ASCII text, with CRLF line terminators" 说明有

# 修复
sed -i 's/\r$//' script.sh
# 或者
dos2unix script.sh

性能数据对比

用下面这个基准脚本,对比不同调试手段的开销。运行环境:Ubuntu 22.04.3 LTS, GNU bash 5.2.15, Intel Xeon E5-2680 v4, 8核16G。脚本做10000次循环字符串拼接和文件写入。

#!/bin/bash
# benchmark.sh
LOOP_COUNT=10000

for i in $(seq 1 $LOOP_COUNT); do
    str="item_${i}_data"
    echo "${str}" >> /tmp/benchmark_output.txt
done
rm -f /tmp/benchmark_output.txt
模式耗时相对基线
正常执行3.42秒1.0x
bash -x 追踪3.98秒1.16x
trap ERR 捕获3.51秒1.03x
set -x + trap + shellcheck(运行期)4.05秒1.18x

结论:调试功能对运行时性能影响很小(最大约18%),完全值得在生产环境常开set -x日志到文件,只增加了不到两成的耗时。

另外,跟踪只对IO密集脚本有效。如果是计算密集任务,set -x会显著放大循环体内的执行时间(测试过PHP数组遍历,在set -x下耗时翻倍),此时建议在循环外开启、循环内关闭set -x

投入产出比总结

三个工具配合,效果:

  • ShellCheck在编码阶段拦截约40%的潜在缺陷
  • bash -x让运行期逻辑错误定位时间从小时级降到分钟级
  • trap让脚本崩溃信息从"看半天日志"变成"直接看行号和命令"

具体数据:

  • 脚本上线前的ShellCheck扫描从平均30分钟的手工评审缩短到3秒
  • 线上问题平均修复时长从1.5小时降到20分钟(统计30个故障)
  • 我所在的团队,Shell脚本相关故障下降了大约55%

我的建议是:

  • 新脚本必须过shellcheck才允许提交
  • 所有生产脚本统一使用带trap的错误处理模板
  • 疑难问题用DEBUG=1跑一次,日志都在/tmp/script_debug.log

可以用到明天早上的清单

  • 给所有脚本加上:set -eE -o pipefail
  • 给所有外部变量加双引号:"${VAR}"
  • 删除操作加保护:rm -rf "${DIR:?}"/*
  • 安装shellcheck:apt install shellcheckbrew install shellcheck
  • 写一个新的脚本模板,把trap和PS4配置写好
  • 管道拼接时检查是否需要set -o pipefail

别再面对一堆日志瞎猜了。