Bash 管道退出码:别让 tee 把前面的失败藏起来

10-01 3阅读

构建命令报错了,CI 却显示成功,一个常见原因是输出被接到 tee 后,只检查了整条管道的默认状态。本文针对 GNU Bash 5.x 的前台管道;PIPESTATUS 是 Bash 特性,不要把示例直接交给其他 sh 实现运行。先在独立测试脚本里理解语义,再修改承担发布任务的自动化脚本。

Bash 管道退出码:别让 tee 把前面的失败藏起来

AI生成概念配图:管道中间步骤失败,但最后一个步骤仍可能成功。仅辅助理解,不代表真实界面或实测结果。

先用不会改文件的例子重现

#!/usr/bin/env bash
# 此演示没有启用 set -e
false | true
printf "default=%s\n" "$?"

set -o pipefail
false | true
printf "pipefail=%s\n" "$?"

默认情况下,前台管道的退出状态取最后一个命令,所以第一个输出为零。启用 pipefail 后,如果存在失败,状态取最右侧非零命令的退出码;全部成功时才为零。因此第二个输出为一。它不会停止管道中已经启动的其他进程,也不会自动回滚前面完成的写入。

这解释了 ./build.sh | tee build.log 为什么可能掩盖构建失败:tee 把收到的内容写入日志后成功退出,但构建本身可能早已失败。反过来,构建成功而日志写入失败也值得处理,特别是日志属于审计或交付证据时。一个总状态能回答是否失败,却不足以定位失败阶段。

立即保存每个阶段的状态

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

if ./build.sh 2>&1 | tee build.log; then
  stages=("${PIPESTATUS[@]}")
else
  stages=("${PIPESTATUS[@]}")
fi

printf "build=%s tee=%s\n" "${stages[0]}" "${stages[1]}"
if (( stages[0] != 0 )); then
  exit "${stages[0]}"
fi
exit "${stages[1]}"

示例假定 build.sh 是已经审查过的本地脚本。PIPESTATUS 按顺序保存最近前台管道各阶段的状态;下一条命令就可能更新它,所以两个分支中的第一条语句都必须完成数组复制。不要先 echo、打印日期或执行其他检查再读取。保存后可以安全记录和做业务判断。

将管道放在 if 条件中,能显式处理成功与失败,也避免依赖 set -e 在这里自动退出。示例优先保留构建失败码,否则返回 tee 的状态,这是一项可解释的脚本策略,不是 Bash 强制规定的规则。如果还有上传或部署阶段,应为每一步分别定义失败时允许继续到哪里。

非零不总是故障,零也不等于业务完成

grep 没找到匹配时会返回非零,这可能是正常的分支条件;但读取文件出错又是另一种状态。应依据所用工具的手册区分,而不是在整条管道末尾加 || true。后者可能把真正的权限错误、磁盘满和命令缺失一起隐藏,让自动化输出看起来永远成功。

类似 producer | head 的用法中,消费者提前关闭管道,生产者可能收到 SIGPIPE。启用 pipefail 后,原来被忽略的状态可能浮现。应先判断“只需要前几行”是不是设计目标,再调整数据获取方式或明确处理该场景,不能把所有新出现的非零状态都当成应用回归。

把状态传播作为测试项

set -e 在条件、列表和管道等上下文中有例外,不能替代明确的错误处理。为脚本至少准备前段失败、末段失败、全部成功和预期无结果四类测试,并检查最终退出码、日志和产物是否符合约定。后台任务还需要显式等待并处理各自状态,不属于本文前台管道的保证范围。

可靠脚本不仅要留下错误文字,还要让调用方拿到正确状态。日志负责解释发生了什么,退出码负责阻止不该继续的后续动作;把两者一起设计,才能避免“报错了却照常发布”。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。