Shell 脚本出错别只加 set -e:pipefail 和 ERR trap 的边界
我见过不少部署脚本,开头先写一行 set -e,出了问题还是照样往下跑。后来排查发现,不是 Bash 没有退出,而是失败刚好落在了它的例外里,或者被管道最后一个命令的成功状态盖住了。
这篇只讲一件事:怎么让 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 严格模式」,但真正写过发布脚本的人,通常会先问:失败在哪个语境里?管道的状态是谁返回的?日志有没有把那一行留下来。