2168 字
约 7 分钟
16
S01-A24 · `set -euo pipefail` 的真实边界:它救不了的三类脚本

一个部署脚本开头写了 set -euo pipefail,团队以为"出错就会停"。上线后发现"部署失败但脚本返回 0"——因为失败发生在"管道里的第一个命令",而 pipefail 只保证"管道整体失败",这个"吃掉"是"退出码语义"的错位:整条管道的退出码 = 最后一段的退出码——而 grep/tee 几乎不失败,于是"前面全炸了,脚本还是 0"。
而那个命令的输出被 grep "吃掉"了。

严格模式是"脚本健壮性"的第一道防线,而它有三处明确失效:管道里的非最后一段、在 if/|| 里被"预期失败"、以及子 shell 与函数里的传播规则。

1. 三个开关各管什么

开关 作用
-e 命令失败即退出
-u 引用未定义变量即退出
pipefail 管道"任一段失败"即算失败

第二个开关的收益常被低估:"拼错变量名"是脚本里最常见的 bug第二项的"典型"是"$DIR 拼成 $DIRR":在无 -u 时它是空字符串,于是 rm -rf "$DIRR/" 变成 rm -rf /——而它"不报错",后果却最重。
——-u 把它从"静默用空值"变成"立即退出"。

2. -e 的四种"不生效"

情形 原因
命令在 if 条件里 "条件失败"是正常流程
命令后跟 || 同上
命令在管道非最后一段 未开 pipefail
命令在子 shell / 函数里且调用处被 || 判定在"调用处"

这个"未被测试"有个正式说法:命令在"条件位置"时不触发 -e——因为"失败"在这个位置是"信息",不是"异常"。
第一项与第二项是"设计如此":set -e 只对"未被测试的命令"生效——而"测试"包括 if、&&、||、!。

"变量拼错"这类问题可以"在 CI 里扫",而它比"上线才发现"便宜:

// 三种"静态检查"(都不需要跑脚本)
1 shellcheck script.sh          # 未定义变量、引号问题(最有效)
2 bash -n script.sh             # 只做语法检查
3 grep -nE '\$[A-Za-z_]+' script.sh | grep -v '\$\{'   # 找"未加花括号"的可疑引用
// 判据:shellcheck 的 warning 全清零,再谈"严格模式"

3. "管道里的失败"为什么最坑

写法 结果
cmd | grep x(无 pipefail) cmd 失败也返回 0
同上 + pipefail 返回非 0(正确)
cmd | tee log | grep x 中间段失败也算

第二项是"必开"的理由:"没有 pipefail 时,脚本的退出码只反映"最后一段"第二项的"反例"很讽刺:"最不会失败的那一段"决定了"整条管道的成败"——于是"加上 | tee log 这个"为了看日志"的动作,把"失败检测"关掉了。
——而"最后一段"通常是 grep/tee 这类"不会失败"的命令。

// 一个"看起来会停"其实不会停的脚本
set -eu
curl -fsS "$URL" | tee app.tar.gz | tar -xz     # curl 失败 → tee 成功 → 退出码 0
// 修法:加 pipefail(且注意 tee 的位置)
set -euo pipefail

"预期失败"的正确写法是"显式判断",而不是"关掉严格模式":

// 反例:靠 set +e 临时关掉
set +e
grep -q foo file
found=$?
set -e

// 正解:把"预期失败"放进条件位置
if grep -q foo file; then
  echo "found"
fi
// 判据:失败"被当作信息"时用 if/&&,而不是"关 -e"

4. 子 shell 与函数的传播规则

情形 是否传播
函数返回非 0(在 if 里调用) 否
子 shell ( ... ) 内失败 是(子 shell 退出)
命令替换 $( ... ) 内失败 视 shell(bash 4.4+ 受 -e 影响)
trap ... ERR 可统一处理

第三项的"表现"很隐蔽:local x=$(cmd) 里 cmd 失败,x 变成空——于是"后续用空值继续",而"日志里什么都没有"。
第三项是"版本相关"的坑:老 bash 里"命令替换内失败"不会让脚本停——于是"取一个失败命令的输出"会"得到空字符串,然后继续"。

"失败定位"的最小实现只有三行,而它把"猜"变成"看":

set -euo pipefail
trap 'echo "FAIL: line $LINENO, cmd=$BASH_COMMAND" >&2' ERR
// 输出示例:FAIL: line 42, cmd=curl -fsS https://...
// 判据:日志里有"行号 + 原文",于是不必"逐段加 echo"

5. "严格模式"的三个配套

配套 作用
trap 'echo FAIL at line $LINENO' ERR 失败时打印位置
set -x(调试时开) 打印每条命令
统一 log 函数 输出"带时间戳与阶段"

第一项是"最值"的:**没有 LINENO 的失败日志"只告诉你命令名"**第一项的"写法"要"短":一行 trap 就够,别写"复杂的清理逻辑"——因为 ERR 里再出错,会让"原始错误"被覆盖。
——而脚本里同名命令可能出现在十处。

"管道"这条还有个"顺序"细节,容易把 pipefail 的效果搞反:

// 三种写法(同一意图:下载并解压,失败就停)
1 curl ... | tar -xz                 # 无 pipefail:curl 失败被忽略
2 set -o pipefail; curl ... | tar -xz  # 有 pipefail:任一段失败即失败
3 curl -o tmp ... && tar -xf tmp     # 不用管道:最直观,推荐
// 判据:能用"&& 串联"就别用"管道",因为"退出码语义"更清楚

6. 排错:五种"脚本没停"

现象 成因 处理方向
失败但退出码 0 管道未 pipefail 加 pipefail
拼错变量静默 未 -u 加 -u
if 里的失败不退出 设计如此 手动检查
子 shell 失败被吞 调用处有 || 显式判断
找不到失败位置 无 ERR trap 加 LINENO

7. 边界:什么情况"严格模式"要慎用

场景 为什么 做法
"预期失败"的探测(grep -q) 失败是正常结果 用 if cmd; then
交互式脚本(读输入) 中途失败不该退出 不开 -e
一次性调试脚本 想"看到全部输出" 不开 -e
已有"逐步检查"的老脚本 改动风险大 逐步引入

最后一条与"迁移"有关的边界:给老脚本加严格模式要"一次一个开关":先 -u(最容易暴露拼写)、再 pipefail、最后 -e——一次全开会让"几十处预期失败"同时报错,无从下手。
判断口径一句话:"这个失败,是"异常"还是"预期结果"。异常(下载失败、构建失败),严格模式是正解;预期(grep 没匹配、test 为假),必须"显式判断"——否则严格模式会把"正常分支"变成"脚本退出"。

再补一条"引号"的规矩,它与严格模式同样重要:所有变量展开都加双引号("$VAR")。因为未加引号时,空值会让"参数消失"——rm -rf $DIR/ 在 DIR 为空时变成 rm -rf /,而 rm -rf "$DIR/" 会"报错但安全"。这条与 -u 是"一对组合":-u 防"未定义",引号防"空值展开"。

而"引号 + -u + pipefail"这三件套,能挡掉"运维脚本事故"的大半——它们都不需要"想清楚业务",只需要"养成习惯",这也是它们值得写进团队规范的原因。

习惯比技巧更可靠,这也是脚本与代码最大的差别。

所以"脚本健壮"这件事,落到最后是三条习惯:变量加引号、模式开三件、失败打行号——而不是"写得多聪明"。

8. 自测

  1. 三个开关各管什么?为什么 -u 的收益常被低估?
  2. set -e 对哪四种情形"不生效"?为什么 if 里的失败不算"异常"?
  3. 为什么"没有 pipefail 时退出码只反映最后一段"?举一个"看起来会停其实不会"的例子。
  4. "命令替换内失败"为什么与 bash 版本有关?
  5. trap ... ERR 搭配什么变量能定位到行?为什么"只有命令名"不够?
  6. 判断"该不该开严格模式"的那个问题是什么?
S01-A24 · `set -euo pipefail` 的真实边界:它救不了的三类脚本
https://www.clxhxhhr.top/posts/6668/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。