set -euo pipefail 到底防住了什么:6 个把我坑到凌晨的细节

举报
yd_242458115 发表于 2026/10/31 09:52:55 2026/10/31
【摘要】 几十个运维脚本之后,我攒下 set -euo pipefail 的 6 个翻车场景:local 吞掉退出码、pipefail 撞上 head 的 141、set -e 在 if 里失效、set -u 撞空数组、子 shell 丢设置。

凌晨两点被告警叫醒:备份脚本白天手动跑得好好的,进了 cron 就"静悄悄什么都没做",日志目录连个空文件都没留下。查了半小时才发现,脚本第 37 行的 grep 没匹配到内容、退出码 1,然后 set -e 把整个脚本终止了,而写日志那一步排在被终止的命令后面。

那之后我把 set -euo pipefail 写进团队模板的头两行,也顺手踩完了它配套的坑。下面 6 个场景,每条都附能直接贴进终端跑的命令。

一、先搞清楚这行字各自管什么

参数 | 作用 | 它不管的事

-e | 未被检查的命令返回非 0 就退出 | 管道中间段失败不会被它拦住

-u | 引用未赋值变量直接报错退出 | 想取默认值的地方得自己写 ${1:-}

-o pipefail | 管道退出码取最右边非 0 的那个 | cmd | head 会冒出 141,它照抛不误

两条命令对比,看 -e 单独存在多无力:

bash -c 'set -e; false | true; echo "还是走到这了"'
bash -c 'set -e -o pipefail; false | true; echo "这句不会打印"'

二、坑 1:local 把退出码吃掉了

最阴的一个,看起来完全无害:

set -euo pipefail

fetch() {
  local out=$(curl -sS --max-time 10 "$1")   # 域名解析不了,curl 退出码 6
  printf '%s\n' "${out:0:20}"
}
fetch https://example.com/health

curl 明明失败了,脚本却一路跑到底:local 是内建命令,自身退出码 0,把命令替换的失败状态盖住了。export、declare、readonly 同病。先声明再赋值就好:

fetch() {
  local out
  out=$(curl -sS --max-time 10 "$1")
  printf '%s\n' "${out:0:20}"
}

对照实验:同一个函数把域名故意写错,local out=$(...) 版本退出码 0;拆开后立刻以 6 退出。0 和 6 的差别,决定半夜被叫醒的是我还是监控大盘。

三、坑 2:pipefail 撞上 head 和 grep -q 的 141

set -euo pipefail
zcat /var/log/app/app.log.gz | grep -m1 'FATAL' | head -n 1

日志里没有 FATAL,grep 返回 1,整个脚本死。grep -q 更狠:匹配到就立刻退出,前面的 zcat 收到 SIGPIPE,退出码 141,pipefail 把 141 当失败往上抛。脚本却挂了。

按语义分两种:

# 只关心数量:没有匹配就当 0
hits=$(zcat /var/log/app/app.log.gz | grep -c 'FATAL' || true)
echo "hits=$hits"

# 只关心有没有:失败状态显式吃掉
if zcat /var/log/app/app.log.gz | grep -q 'FATAL'; then echo "有 FATAL"; fi

-c 无匹配时退出码同样是 1,但它会打印 0,所以配 || true 才有意义。

四、坑 3:set -e 在这些位置完全不生效

这些位置 -e 会主动让位,一句都不说:

位置 | 举例 | 实际结果

if / elif 的条件 | if cmd; then | cmd 失败不退出,走 else 分支

while / until 条件 | while read -r line | 读到文件尾正常收尾

&& 或 || 左侧 | a && b | a 失败不退出,b 跳过

所以"加了 set -e 就安全"是错觉:真正需要判断的分支它一句都不提醒,只能自己写 else exit 1。

五、坑 4:trap ERR 不加 -E 抓不到函数里的错误

set -eEuo pipefail
trap 'rc=$?; echo "[FAIL] ${BASH_SOURCE[0]}:${LINENO} rc=$rc" >&2' ERR

-E(errtrace)让 ERR trap 继承进函数、子 shell 和命令替换,不加则函数内出错静默无声。LINENO 给的是出错那行,配 BASH_SOURCE 可直接定位。有个细节害我查了两天:trap 里的 $? 必须第一条语句就存下来。我最初写成 echo "fail" >&2; rc=$?,永远打印 rc=0,因为 echo 之后 $? 已刷成 0。

六、坑 5:set -u 撞上空数组

set -u
args=()
printf '%s\n' "${args[@]}"   # bash 4.3 及更早:args[@]: unbound variable

4.4 起才修好,先确认版本别照搬:

bash --version | head -n 1 | grep -oE '[0-9]+\.[0-9]+'

还有老镜像在跑就用可移植形式 ${args[@]+"${args[@]}"}。位置参数一律兜底:可选值 "${1:-}",必填 "${1:?用法: $0 <目录>}",后者打印提示并以 1 退出,比空字符串传下去好看。

七、坑 6:set 出了当前 shell 就不作数

set -euo pipefail 只影响当前 shell 进程。下面这几处会把它完全丢掉:

bash script.sh                  # 新进程,只看脚本自己的 shebang 和首行
ssh "$host" 'cmd1; cmd2'        # 远端 shell 不知道你本地设了什么
sudo -u deploy bash -c 'cmd'    # 同上
xargs -n1 bash -c 'cmd'         # 同上,失败被吞后汇总退出码还是 0

远程那条我会显式带上 ssh "$host" 'set -euo pipefail; cd /srv/app && ./release.sh'。xargs 那条最易忽略:第 3 个文件失败,退出码仍是 0。

八、接手脚本时的两分钟体检清单

1. 第 2 行是不是 set -euo pipefail

2. 全局搜 =$(,凡是 local/export/declare 开头的一律拆两行

3. bash -n script.sh 过语法,shellcheck -S warning script.sh 过静态检查

4. 用一份坏输入实跑一次,确认失败路径的退出码不是 0

一句话结论:set -euo pipefail 是止血带,不是体检报告。它只保证"明显该停的时候会停",那些被 if、local、子 shell 悄悄吞掉的失败,只能靠 trap ... ERR 加第 4 条的实跑兜住。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。