Skip to content

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 | head

grep 没匹配到返回 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 TERM

INT 是 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/work
text
+ 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
fi

logger -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
fi

shebang + set、日志函数、临时文件加 trap 清理、参数校验、主体逻辑带错误处理——这个骨架能避免大部分脚本事故。

学会了之后

set -e 失败即停、trap 退出清理、错误进 stderr、危险操作前检查、set -x 调试、日志留痕、退出码规范。这些组合起来,脚本失败时能安全停下、能留下排查线索、能被调用方判断结果。

下一篇用这些技术写几个日常运维脚本实例。