tee 有日志,构建就成功了吗?20 个 Bash 管道退出码实验
在构建脚本后面加上 | tee build.log,可以同时看终端输出和保存日志。但“日志文件生成了”“终端没有停下来”和“构建成功”是三件不同的事。下面用合成命令复现这些差异,再给出一种同时保留整体状态和分段状态的写法。
本文讨论 Bash 的前台同步管道。实验只使用 true、false、printf、显式 exit 和 tee,不连接业务系统。每个场景都在独立临时目录、独立 Bash 进程中运行;测试开始时明确关闭 errexit 和 pipefail,随后按场景开启,避免继承终端配置影响结果。
一、先区分三个结果
看这条命令:
(printf 'partial\n'; exit 42) | tee build.log
左侧先输出一行文本,再用退出码 42 表示失败;右侧 tee 成功接收文本,并写入日志。默认配置下,整条管道却返回 0。
这不是 tee 把失败修好了。日志内容只是 partial,上游仍然以 42 退出。GNU Bash 手册规定,未启用 pipefail 时,管道的整体退出状态取最后一个命令的状态;此处最后一个命令就是 tee。(参考:GNU Bash Reference Manual,Pipelines 章节。)
因此,观察结果要分开记:上游退出码为 42;tee 退出码为 0;整条管道默认退出码为 0。日志是否存在无法替代其中任何一项。
tee 的职责是复制标准输入到标准输出和指定文件。需要注意,它默认覆盖已有的目标文件;本实验始终在新建的临时目录里运行,实际使用时应自行选择日志目录与追加策略。(参考:GNU Coreutils Manual,tee invocation 章节。)
二、pipefail 改的是状态规则
在 Bash 中启用:
set -o pipefail
(printf 'partial\n'; exit 42) | tee build.log
同样的输出、同样的日志内容,这次管道返回 42。只改变状态汇总规则,不能让失败的构建变成功,也不会回滚已经写出的文件。
多个阶段同时失败时,结果取管道位置最右侧的非零退出码:
set -o pipefail
(exit 7) | (exit 3) | true
printf 'pipeline=%s\n' "$?"
# pipeline=3
这里返回 3,不是 7。“右侧”指命令在管道中的排列位置,不应当理解为对失败发生时间的排序。上面的整体状态规则来自 Bash 手册,返回值则由实验脚本单独核验。
还要区分“失败状态”与“立即终止脚本”:
set -o pipefail
false | true
printf 'after\n'
这个场景仍然输出 after,而整个脚本最终返回 0,因为最后执行的 printf 成功了。pipefail 本身不等于 set -e。
三、PIPESTATUS 要及时保存
要知道具体哪一段失败,可以立即读取 Bash 数组 PIPESTATUS:
(exit 7) | (exit 3) | true
printf '%s\n' "${PIPESTATUS[*]}"
# 7 3 0
它记录最近一条前台管道中各命令的退出状态;单条命令也会更新它。这一定义可在 Bash 的变量手册中查到,本地 man bash 的 PIPESTATUS 条目也作了相同说明。(参考:GNU Bash Reference Manual,Bash Variables 中的 PIPESTATUS 条目。)
容易踩坑的是先保存 $?,再读数组:
set -o pipefail
(exit 7) | (exit 3) | true
rc=$?
printf 'rc=%s stages=%s\n' "$rc" "${PIPESTATUS[*]}"
# rc=3 stages=0
rc 正确保留了整体退出码,但赋值命令本身成功执行后,PIPESTATUS 已更新。此时输出的 0 不能再解释成原来的三个阶段全部成功。
实验采用同一条数组赋值,同时捕获两份状态:
set -o pipefail
(exit 7) | (exit 3) | true
snapshot=( "$?" "${PIPESTATUS[@]}" )
printf '%s\n' "${snapshot[*]}"
# 3 7 3 0
第一个元素是整条管道的状态,后续元素按顺序对应各个阶段。展开右侧表达式后,再执行数组赋值;后续打印、判断都使用保存好的数组。
四、set -e 也需要看使用位置
实验中,单独设置 set -e 后执行 false | true,脚本仍会继续,因为默认的管道整体状态是 0。设置 set -e -o pipefail 后,在普通命令位置执行同一条管道,脚本以 1 退出,后面的 printf 不再执行。
但不能把它概括成“出现非零就必定退出”。Bash 对 if 条件、部分 && / || 列表等位置有例外。本文只演示这些特定场景,不试图用一句口诀覆盖全部 shell 语义。(参考:GNU Bash Reference Manual,The Set Builtin 章节。)
如果希望保留日志、打印诊断并向调用方返回管道状态,可以显式处理成功和失败分支:
#!/usr/bin/env bash
set -e -o pipefail
if ./build.sh 2>&1 | tee build.log; then
snapshot=( "$?" "${PIPESTATUS[@]}" )
else
snapshot=( "$?" "${PIPESTATUS[@]}" )
fi
printf 'pipeline=%s build=%s tee=%s\n' \
"${snapshot[0]}" "${snapshot[1]}" "${snapshot[2]}"
exit "${snapshot[0]}"
两个分支都把状态捕获作为第一条命令,避免诊断输出先覆盖现场。2>&1 将上游标准错误也交给 tee;这是日志合并选择,和是否启用 pipefail 是两个问题。
这里的 ./build.sh 指外部可执行脚本,它内部如何处理失败仍由自身负责。若换成 shell 函数,放进 if 条件可能影响函数内部的 -e 行为,不能直接照搬结论。也不要习惯性追加 || true:实验显示,这会让原本的失败路径继续执行并最终返回成功。
五、20 个场景的核验范围
独立脚本逐项比较退出码、标准输出、标准错误,并在涉及 tee 时读取实际日志内容。覆盖范围如下:
| 编号 | 场景 | 核验重点 |
|---|---|---|
| 1—3 | 默认管道规则 | 左侧失败、末尾失败、三段管道 |
| 4—7 | pipefail 开关 | 左侧失败、右侧非零、全成功、重新关闭 |
| 8—12 | 后续执行与错误处理 | pipefail 不自动退出、set -e 组合、条件分支、忽略失败 |
| 13—15 | 状态保存时机 | 直接读取、赋值覆盖、同时保存两份状态 |
| 16—18 | tee 和日志 | 上游失败但日志存在、整体失败、标准错误合并 |
| 19—20 | 显式分支包装 | 失败和成功两条路径都正确保存与返回状态 |
本地验证环境为 macOS、GNU Bash 3.2.57,20/20 场景通过。
这组实验没有覆盖后台管道、进程替换、信号导致的退出、磁盘写满或其他 shell。特别是某个下游提前退出导致的 SIGPIPE,需要结合具体命令另做实验。本文的示例用于定位状态判断问题,不能替代构建工具自身的错误处理。
日常排查时,可以把“日志内容”“整条管道返回值”“每个阶段返回值”分别记录。只有明确需要的阶段都成功,才能继续后续发布或部署步骤。
本文由 AI 辅助整理,代码示例使用合成数据并通过独立脚本执行核验;未使用真实业务日志、账号凭据或生产环境数据。
- 点赞
- 收藏
- 关注作者
评论(0)