JavaLYG

Bash 任务失败却显示成功:用 pipefail 与 PIPESTATUS 排查 tee 日志管道

备份或构建明明报错,定时任务却显示成功;加了 tee 留日志之后,告警反而不响了。先别怀疑调度器:在 Bash 里,管道默认返回最后一条命令的退出码。下面用可重复的小实验,把业务失败、日志失败和包装脚本的退出码分开检查,不碰生产数据。

一、日志写成功,不等于业务跑成功

常见写法是“业务命令 2>&1 | tee 日志文件”:前半段运行任务,后半段同时把内容送到终端和文件。tee 负责复制输入,不负责理解“备份失败”这几个字。上游退出 23,tee 顺利读完并写完,默认管道仍可能返回 0。调度器看到的是包装脚本的结果,不会替你读日志判案。

本文限定 Linux 上的 Bash 前台管道和 GNU tee。PIPESTATUS 是 Bash 数组,不能把脚本改成 sh 执行。涉及数据库备份等业务,还必须检查产物可恢复性;退出码只是第一道门,不是最终验收。

Bash前台管道退出码:默认取末项,pipefail取最右非零,PIPESTATUS分别保留状态

二、先用无副作用的命令复现

下面两条命令都不操作真实业务,也不创建日志文件。第一条明确关闭 pipefail,返回 0;第二条启用它,返回 23。每次紧接着读取 $?,不要在中间插入别的命令。

bash -c 'set +o pipefail; bash -c "exit 23" | tee /dev/null'
printf '默认管道=%s\n' "$?"
bash -c 'set -o pipefail; bash -c "exit 23" | tee /dev/null'
printf '启用后=%s\n' "$?"

这里的 23 是人为指定的测试值,没有跨工具通用含义。实际程序的退出码要查它自己的文档;若程序自己把错误吞掉后返回 0,pipefail 也无从判断失败。不要把“日志里出现 ERROR”与“进程返回非零”当成同一件事。

三、pipefail 解决聚合,不解决归因

开启 pipefail 后,所有阶段都为 0,管道才返回 0;只要有非零,就取管道中最右侧的非零状态。这里的“最右”是命令位置,不是谁最先结束,也不是最严重的错误。三段分别返回 23、7、0,最终就是 7。

因此,上游业务失败且 tee 也写入失败时,单看 $? 可能只看见 tee 的状态。需要区分责任,就立即复制 PIPESTATUS:它按管道从左到右保存各阶段退出码。对于本文的两段管道,下标 0 是业务,下标 1 是 tee;这不是可以照搬到任意多段管道的固定编号。

“立即”很重要。先 echo 一句提示会覆盖 PIPESTATUS,连先写 rc=$? 也会更新它。正确顺序是先把整个数组复制出来,再打印、分支和返回;不要逐项读取时夹杂其他命令。

四、用一个明确策略的日志包装器

把下列内容保存成 run-with-log.sh,使用 bash 独立执行,不要 source。策略写得很直接:业务失败优先返回业务码;业务成功而日志失败,返回日志码。两项状态都会输出到标准错误,避免聚合码遮住另一个故障。

#!/usr/bin/env bash
# 独立运行,不要 source;业务命令必须使用准确的退出码。
set -u
set -o pipefail
set +e
if (( $# < 2 )); then
    printf '用法:bash run-with-log.sh 日志文件 命令 [参数...]\n' >&2
    exit 64
fi
log=$1
shift
"$@" 2>&1 | tee -- "$log"
status=("${PIPESTATUS[@]}")
printf '业务退出码=%s,日志退出码=%s\n' "${status[0]}" "${status[1]}" >&2
if (( status[0] != 0 )); then
    exit "${status[0]}"
fi
exit "${status[1]}"

用法是 bash run-with-log.sh ./job.log bash ./job.sh。日志目录须提前创建并授予运行用户必要权限。参数经 "$@" 原样传递,不把整条命令拼成字符串交给 eval。要执行复杂业务流程,应让 job.sh 自己有正确的失败传播逻辑,包装器不能修复它内部被吞掉的错误。

这里故意 set +e,让失败管道之后的状态采集一定有机会运行;脚本自己显式 exit,而不是靠自动退出碰运气。它是独立进程,不修改父 Shell 的选项。tee 默认覆盖同名日志;需要追加时改用 tee -a,但应配合独立任务日志名、轮转与容量限制,别让一次排错变成另一场磁盘告警。

五、为什么只加 set -e 仍然不够

没有 pipefail 时,上游失败而末项成功,管道本身仍为 0,set -e 不会为它兜底。即使开启 pipefail,set -e 也可能让脚本在来得及复制数组前退出。if 条件、部分 && 或 || 列表等上下文还会影响它的行为,所以它不是“任何命令失败都立刻退出”的全局保险。

别用结尾的 || true 把失败重新变成成功,也别让包装脚本最后一行打印“完成”后自然退出。若现有脚本依赖 set -e,可以局部设计 if 分支并在分支第一条命令复制状态;必须测试真实调用方式,而不是只在交互终端跑通一次。

六、按现象区分故障分支

现象优先核对处理方向
业务报错,任务仍为 0实际解释器、pipefail、末尾命令用测试退出码跑完整入口
业务为 0,tee 非零路径、权限、剩余空间先修日志存储,不冒称业务失败
两项都非零立即保存的状态数组同时保留证据,按约定返回
数组只剩一个 0管道后有没有 echo 或赋值把数组复制移到紧邻管道处
终端有错误,日志没有2>&1 是否放在管道左侧合并业务标准错误再交给 tee

下游若提前关闭读取端,上游还可能遇到 SIGPIPE;启用 pipefail 后,这类原本被忽略的非零会显露出来。先判断是否属于预期截断,不要一律追加 || true。本文不把后台任务、进程替换或多个并发作业纳入同一数组验收;它们需要额外等待和独立状态管理。

七、用故障注入验收,而不是只看正常日志

本文在 Bash 4.4.20 的隔离夹具中实际完成 12 项检查:包括语法、默认管道掩盖 23、pipefail 返回 23、最右非零返回 7、业务与日志失败组合、标准错误落盘、数组被中间命令覆盖以及参数校验。日志失败通过把临时目录当作文件目标触发,没有故意写满磁盘或改生产权限。

  • 业务退出 0 且日志写入正常,包装器返回 0。
  • 业务退出 23 且日志正常,返回 23,错误输出存在于日志。
  • 业务成功而日志目标不可写入,包装器返回非零。
  • 两者都失败时,两项状态都有记录,返回策略符合约定。
  • 把同样的测试放进实际调度入口,确认外层没有吞码;真实备份再做恢复校验。

上线前还要查一次真实启动命令:定时器、持续集成平台和手工终端可能使用不同解释器、工作目录与环境变量。日志最好使用明确的绝对路径,并由实际运行用户执行验证;管理员能写文件,不代表任务账号也能写。修复后保留一次失败样本及对应告警,防止后续包装层改动把非零状态再次吞掉。

八、把成功标准放回业务,而不是日志末行

pipefail 负责让前台管道失败可见,PIPESTATUS 负责解释是哪一段失败,包装器负责把策略传给调度器。三件事各做各的,才能避免“最后一个搬日志的人没出错,所以整个任务都成功了”的误判。

参考:Bash 手册的 Pipelines 与 PIPESTATUS、Bash 官方 set 说明、GNU tee 手册。这些是长期行为说明,不是近期版本新功能。

🔕 评论已关闭