别瞎猜了,用这些工具直接定位
2023年11月,我负责的一个数据同步任务突然失败。日志显示:ERROR: backup file not found。我花了4个小时,各种echo、各种注释代码、各种猜测,最后发现是变量引用时忘记加双引号,文件路径含空格被拆成了两个参数。这个错误,用bash -x一行命令,10秒就能看出来。
从那天起,我把Shell脚本调试的三板斧——bash -x、trap陷阱、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/my和file.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
两个问题浮出水面:
gzip发现同名文件存在,拒绝覆盖——因为脚本重跑时上一次的.gz文件还在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 found或bad 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 shellcheck或brew install shellcheck - 写一个新的脚本模板,把trap和PS4配置写好
- 管道拼接时检查是否需要
set -o pipefail
别再面对一堆日志瞎猜了。