凌晨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_DIRS和BACKUP_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天的运行数据:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 脚本运行失败次数 | 47 | 3 |
| 因变量未定义导致错误 | 12 | 0 |
| 因未加引号导致路径拆分 | 18 | 0 |
| rm -rf 误删目录 | 2 | 0 |
| 定位一个线上脚本问题的平均耗时 | 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 只是工具,最终判断靠人。
调试工作流总结
我现在写脚本的标准流程:
- 写完后立刻跑
shellcheck script.sh,把所有错误清零。 - 加上
set -Eeuo pipefail和IFS=$'\n\t'。 - 执行一次
bash -x script.sh,看真实展开的命令,重点检查变量是否为空、路径是否正确。 - 涉及删除命令,一律走防呆函数,禁止裸用
rm -rf。 - 在CI里加一道
shellcheck,防止后来的人把代码改坏。
这套流程帮我们把脚本问题定位时间从平均38分钟降到6分钟,最关键的是,再没出现过二次事故。