Appearance
15|错误处理与调试
前面写的脚本,逻辑对了就能跑。但真实环境里脚本会失败——文件不存在、命令连不上、变量拼错、管道里某段出错。这些失败如果不处理,脚本要么静默继续跑出错误结果,要么报一堆看不懂的错。
本篇讲脚本怎么防错、出错怎么调试、怎么让失败时有据可查。
一、set -e:失败就停
第 2 篇讲过 set -euo pipefail 是生产脚本的标准开头。这里深入讲 -e 的细节。
-e 让命令失败时脚本立即退出,不在错误状态下继续:
bash
#!/usr/bin/env bash
set -e
mkdir /data/backup
cp /etc/nginx/nginx.conf /data/backup/
echo "备份完成"如果 /data 不存在,mkdir 失败,set -e 让脚本在这里退出,不会继续跑 cp(那会失败得更难看)。
但 -e 有几个不生效的情况:
bash
set -e
# 命令在 if 条件里,失败不退出
if grep error /var/log/messages; then
echo "有错误"
fi
# 命令后跟 || 或 &&,失败不退出
grep error /var/log/messages || echo "没找到"
# 命令在管道非末段(除非配 pipefail)
grep error log | headgrep 没匹配到返回 1,这是预期失败。要让 -e 不在这种地方误杀,用 || true 显式说明"失败也继续":
bash
set -e
grep error /var/log/messages || true # 没找到也不退出二、trap:退出时清理
脚本中途失败退出,可能留下临时文件、没关的锁。trap 在脚本退出时(不管正常还是失败)执行清理:
bash
#!/usr/bin/env bash
set -euo pipefail
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"' EXIT
# 往临时文件写数据
echo "工作数据" > "$tmpfile"
# 假设这里失败,脚本退出
cp /not-exist /tmp/
# 退出时 trap 清理 tmpfile,不会残留trap '命令' EXIT 在脚本退出时执行命令。$tmpfile 的变量名要带引号,否则 trap 在注册时就展开。
trap 还能捕获信号——比如用户 Ctrl+C(SIGINT)时做清理:
bash
trap 'echo "被中断,清理中..."; rm -f "$tmpfile"; exit 1' INT TERMINT 是 Ctrl+C,TERM 是 kill 默认发的信号。
三、错误信息输出到 stderr
正常信息进 stdout,错误信息进 stderr。这样调用方能分开处理:
bash
echo "处理完成" # stdout
echo "错误: 文件不存在" >&2 # stderr>&2 把输出重定向到 stderr(文件描述符 2)。这样调用方可以:
bash
./script.sh > result.txt 2> error.log # 正常结果和错误分开
./script.sh 2>/dev/null # 忽略错误错误信息带上上下文,方便排查:
bash
if ! ssh "$host" "uptime"; then
echo "[$(date '+%F %T')] $host 连不上" >&2
exit 1
fi四、防御式编程:先检查再执行
危险操作前先检查条件,避免在错误状态下执行:
bash
# 删除前确认目录存在且是预期路径
target="/var/log/old"
if [[ ! -d "$target" ]]; then
echo "错误: $target 不是目录" >&2
exit 1
fi
# 防止 target 为空导致 rm -rf /
if [[ -z "$target" ]]; then
echo "错误: target 为空" >&2
exit 1
fi
rm -rf "${target:?target 不能为空}/*"${var:?msg} 是参数扩展的报错机制——变量为空时打印 msg 并退出,专门防 rm -rf /$VAR 这种灾难。
删除操作再加一层确认:
bash
# 关键删除前 dry-run,看要删什么
echo "将删除以下文件:"
find "$target" -name "*.log" -mtime +30
read -p "确认删除?(y/N) " confirm
[[ "$confirm" == "y" ]] || { echo "已取消"; exit 0; }
find "$target" -name "*.log" -mtime +30 -delete五、调试:set -x
脚本行为和预期不符,看不清每步执行了什么。set -x 让 bash 打印执行的每条命令:
bash
#!/usr/bin/env bash
set -x
user="deploy"
echo "用户: $user"
mkdir -p /tmp/worktext
+ user=deploy
+ echo '用户: deploy'
用户: deploy
+ mkdir -p /tmp/work每条命令前加 +,变量展开后的实际值也显示。排查"变量拼错""命令拼错"特别有用。
调试完关掉 set -x,或只对某段开:
bash
set -x
# 要调试的代码
set +x六、调试单个函数
脚本太大不想全开 -x,可以临时在函数里加 set -x:
bash
check_disk() {
set -x
local usage
usage=$(df "$1" | awk 'NR==2 {gsub(/%/,"");print $5}')
set +x
echo "$usage"
}或用 bash -x script.sh 从命令行启动时开调试:
bash
bash -x myscript.sh arg1 arg2七、日志记录
生产脚本要留日志,出事后能追溯。简单做法用 logger 把日志写进 syslog:
bash
#!/usr/bin/env bash
set -euo pipefail
log() {
logger -t myscript "$*"
echo "[$(date '+%T')] $*" >&2
}
log "开始备份"
if tar -czf /backup/$(date +%F).tar.gz /data; then
log "备份成功"
else
log "备份失败"
exit 1
filogger -t myscript 给日志打个标签,在 /var/log/messages 里能 grep 出来:
bash
grep myscript /var/log/messages八、退出码约定
脚本退出码让调用方判断结果:
bash
exit 0 # 成功
exit 1 # 一般失败
exit 2 # 用法错误(参数不对)调用方配合 && ||:
bash
if ./check.sh; then
echo "检查通过"
else
echo "检查失败,退出码 $?"
fi九、一个防错脚本的结构
把上面的防错手段组合,一个生产脚本的标准结构:
bash
#!/usr/bin/env bash
set -euo pipefail
# 日志函数
log() { echo "[$(date '+%F %T')] $*" >&2; }
# 临时文件 + 退出清理
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"' EXIT
# 参数校验
target=${1:?用法: $0 <目标目录>}
[[ -d "$target" ]] || { log "错误: $target 不是目录"; exit 1; }
# 主体逻辑
log "开始处理 $target"
if find "$target" -name "*.log" -mtime +7 -delete; then
log "清理完成"
else
log "清理失败"
exit 1
fishebang + set、日志函数、临时文件加 trap 清理、参数校验、主体逻辑带错误处理——这个骨架能避免大部分脚本事故。
学会了之后
set -e 失败即停、trap 退出清理、错误进 stderr、危险操作前检查、set -x 调试、日志留痕、退出码规范。这些组合起来,脚本失败时能安全停下、能留下排查线索、能被调用方判断结果。
下一篇用这些技术写几个日常运维脚本实例。