Shell 脚本出错别只加 set -e:pipefail 和 ERR trap 的边界

40 阅读3分钟

Shell 错误处理封面

Shell 脚本出错别只加 set -e:pipefail 和 ERR trap 的边界

我见过不少部署脚本,开头先写一行 set -e,出了问题还是照样往下跑。后来排查发现,不是 Bash 没有退出,而是失败刚好落在了它的例外里,或者被管道最后一个命令的成功状态盖住了。

这篇只讲一件事:怎么让 Shell 的失败可见,而且知道它为什么失败。

Shell 失败排查顺序

先看管道:默认只认最后一段

下面这段的退出码是 0:

set -e
false | cat
echo "survived"

false 返回 1,但管道默认取最后一个命令 cat 的状态,所以脚本继续执行。把 pipefail 打开,管道才会返回最右侧的非零状态:

set -e -o pipefail
false | cat
echo "never reached"

我在 Bash 5.2 上跑这两段,前一段打印了 survived,后一段直接以 1 退出。这里的顺序很重要:只写 set -e,日志里甚至看不到「哪条管道里的前置命令挂了」。

通常我会把脚本开头写成:

#!/usr/bin/env bash
set -Eeuo pipefail

-u 是未设置变量报错,和管道无关。它也有自己的例外,比如需要判断变量是否存在时要写成 ${value-},别把所有选项混成一句「严格模式」就算讲完。

set -e 不是 try/catch

Bash 手册对 errexit 的描述很长,记住几个会让它暂时失效的场景就够排障了:

set -e

if grep -q "ready" app.log; then
  echo "已就绪"
fi

curl -fsS "$URL" && echo "请求成功"
echo "这行仍可能执行"

if 的条件、while/until 的判断、&& 或 || 链中除了最后一段、管道中除最后一段的命令,以及被 ! 取反的命令,都不按普通失败触发退出。

这不是说 Bash 随机。它是在把「失败是控制流的一部分」和「失败是意外」区分开。问题在于,函数调用也会继承调用位置的语境:

set -e

check() {
  false
  echo "函数里继续了"
}

if check; then
  echo "调用方把它当判断"
fi

这里 check 放在 if 条件里,函数体里的失败也可能被当成判断的一部分。要是这一步不能失败,就别把它塞进条件语境,直接调用并明确处理返回值:

if ! check; then
  printf 'check failed\n' >&2
  exit 1
fi

ERR trap 负责补上下文,不负责改变语义

ERR trap 和 -e 遵循同一组例外。它不是「任何非零都报警」:

set -Eeo pipefail

trap 'rc=$?; printf "ERROR rc=%s line=%s cmd=%s\n" "$rc" "$LINENO" "$BASH_COMMAND" >&2' ERR

grep -q "ready" app.log

上面这条 grep 如果只是「没找到」而不是系统故障,放在 if 判断里就不会触发 trap。这个行为反而很有用:业务上允许的未命中,不应该和真正的 I/O 错误混成一条告警。

-E(errtrace)解决的是继承问题。没有它,函数、命令替换和子 shell 里的 ERR trap 默认不会继承。线上脚本一旦把关键逻辑封进函数,少了 -E,你会发现顶层有日志,函数内部却没有。

日志里建议至少放四个字段:退出码、行号、当前命令、脚本名。不要只写「执行失败」,下一次定位还得重新跑一遍。

我会留下的最小模板

#!/usr/bin/env bash
set -Eeuo pipefail

log_error() {
  local rc=$?
  printf '[%s] rc=%s line=%s cmd=%s\n' \
    "$(basename "$0")" "$rc" "$LINENO" "$BASH_COMMAND" >&2
}
trap log_error ERR

download() {
  curl --fail --silent --show-error --location "$1"
}

if ! payload="$(download "$URL")"; then
  printf 'download failed\n' >&2
  exit 1
fi

printf '%s\n' "$payload"

这里故意把「允许失败」写成 if ! ...; then,而不是赌 set -e 会替我做判断。需要保留命令输出时,用赋值包住命令替换,再由显式分支决定错误文案。

最后再提醒一个容易误判的点:trap 里第一句要先保存 $?。如果先调用 printf 或别的命令,原始退出码就被覆盖了。

这几个边界在面试里常被压成一句「Shell 严格模式」,但真正写过发布脚本的人,通常会先问:失败在哪个语境里?管道的状态是谁返回的?日志有没有把那一行留下来。