朕喜技术(ZhenxiJS)
2026.09.28
1 分钟阅读

备份脚本报成功其实一个文件都没备份:bash set -e 的例外 + cmd && rc=0 赋值不执行 + 结尾没有 exit 导致调度器恒 exit 0

日期:2026-09-28 · 环境:Debian bookworm / bash 5.2 / Docker Compose · 技术栈:Shell(bash)、cron、备份脚本、监控告警、退出码契约 关键词:set -e 失效、cmd && rc=0 不执行、脚本退出码恒为 0、exit $?、|| rc=$?、备份假成功、cron 静默失败、监控没告警、backup round OK、run log 撒谎、shell 退出码等于最后一条命令、pipefail、timeout 退出码 124 透传

一、问题背景

一套每日备份任务,cron 凌晨调 ops/scheduler.sh,调度器再调 ops/backup.sh。 监控只看调度器的退出码:0 成功、非 0 告警。 backup.sh 自带质量闸门(文件数 < MIN_FILES 或字节数 < MIN_BYTES 就 exit 4)。

某天凌晨源目录因挂载点抖动短暂变空。次日巡检发现备份产物里 0 个文件,但:

  • scheduler.sh 退出码是 0
  • run log 写着 backup round OK
  • 监控面板全绿,一条告警都没发

backup.sh 明明 exit 4,scheduler.sh 却把失败完整吞掉了。

二、复现步骤

cd scenario/2026-09-28-1600
docker compose up -d --build
bash ops/repro.sh          # 一键复现(自动置空源目录,跑完还原)

分步版:

# 1) 制造现场:源目录置空(模拟挂载点抖动 / 上游同步未完成 / 卷挂错)
docker compose exec -T app /srv/ops/scenario_empty_source.sh

# 2) 备份脚本自己是知道失败的
docker compose exec -T app /srv/ops/backup.sh ; echo "backup.sh rc = $?"
#   [backup] ... files=0 bytes=0 failed=0
#   [backup] VERDICT: insufficient - backup is NOT healthy
#   backup.sh rc = 4                       ← 正确

# 3) 调度器(cron 真正的入口)把失败吞了
docker compose exec -T app /srv/ops/scheduler.sh ; echo "scheduler rc = $?"
#   [sched] ... attempt 1
#   [sched] ... backup round OK            ← 0 个文件却说 OK
#   scheduler rc = 0                       ← 失败被吞掉

# 4) 磁盘真值
docker compose exec -T app find /srv/data/backup -type f | wc -l   # → 0

# 5) 外部探针(监控系统视角)
docker compose exec -T app python3 /srv/client/check_backup.py
#   scheduler_exit_code = 0
#   disk_backup_files   = 0
#   log_claims_ok       = True
#   RESULT: BAD

结构化验收基线:bash ops/verify.sh → RESULT: BAD (PASS=7 FAIL=1),失败在 B3。

三、根本原因

用 bash -x 抓 xtrace,ops/scheduler.sh 循环体:

14  + rc=0                        ← 初始值
21  + timeout 60 /srv/ops/backup.sh    ← 失败退出,实际 rc=4
22  + '[' 0 = 0 ']'               ← !!rc 从没被赋值,还是 0
23  + break
25  + log 'backup round OK'

第 21→22 行之间没有 + rc=4,说明赋值语句根本没执行。三处缺陷叠加:

缺陷 1:cmd && rc=0 的赋值只在成功时执行

timeout "$SCHED_TIMEOUT" /srv/ops/backup.sh >>"$RUNLOG" 2>&1 && rc=0

&& 右侧只在左侧成功时执行。备份失败 → 右侧跳过 → rc 保持旧值 0。 重试逻辑 [ "$rc" = 0 ] && break 于是第一次就 break,SCHED_RETRY_MAX 形同虚设。

缺陷 2:set -uo pipefail 少了 -e,且 &&/|| 左侧本就豁免 set -e

set -uo pipefail      # 没有 -e

set -e 的例外规则之一是:&&、||、! 左侧的命令失败不触发 set -e 退出。 所以这里即使补上 -e,也不能靠它兜住 —— 必须显式捕获 $?。

缺陷 3(最致命):结尾没有 exit "$rc"

log "scheduler finished"     # log() 内部是 `echo ... | tee -a "$RUNLOG"`

shell 脚本的退出码 = 最后一条命令的退出码。tee 几乎恒返回 0, 所以 scheduler.sh 退出码永远是 0。

这一条单独就足以让监控失效:即使 rc 算对了、日志也写对了, 调度器退出码照样是 0。「日志内容」和「退出码」可以互相矛盾。

四、解决办法

改 ops/scheduler.sh,三处:

-set -uo pipefail
+set -euo pipefail
@@
-  timeout "$SCHED_TIMEOUT" /srv/ops/backup.sh >>"$RUNLOG" 2>&1 && rc=0
+  # 关键:用 `|| rc=$?` 捕获失败退出码(`cmd && rc=0` 只在成功时赋值,失败时 rc 不变)
+  rc=0
+  timeout "$SCHED_TIMEOUT" /srv/ops/backup.sh >>"$RUNLOG" 2>&1 || rc=$?
   [ "$rc" = "0" ] && break
@@
 log "scheduler finished"
+
+# 关键:显式 exit,否则脚本退出码 = 最后一条命令(tee) 的退出码,恒为 0
+exit "$rc"

为什么这样改:

  • || rc=$?:|| 右侧在左侧失败时执行,正好用来捕捉真实退出码。
  • rc=0 每轮先重置:避免残留上一轮的值。
  • set -e 与 || rc=$? 不冲突:|| 左侧失败被 set -e 豁免(正是所需), 其余未显式处理的失败会被 set -e 兜住,早暴露。
  • exit "$rc" 必须有:末尾 log() 的 tee 会覆盖脚本退出码。

改完必须重建镜像(脚本是 COPY 进镜像的,restart 无效):

docker compose up -d --build
# 调试期快速迭代:
# docker compose cp ops/scheduler.sh app:/srv/ops/scheduler.sh

五、验证

bash ops/repro.sh
[sched] ... attempt 1 failed (rc=4), will retry
[sched] ... attempt 2 failed (rc=4), will retry
[sched] ... attempt 3 failed (rc=4), will retry
[sched] ... backup round FAILED after 3 attempt(s) (last rc=4)
>>> scheduler.sh exit code = 4          ← 失败透出,且重试了 1+2=3 次

scheduler_exit_code = 4
disk_backup_files   = 0
log_claims_ok       = False
RESULT: OK
bash ops/verify.sh
[PASS] A1 正常场景调度器退出码 0
[PASS] A2 恰好备份 6 个文件(.tmp/.swp 被排除)
[PASS] A3 备份字节数 123361 >= MIN_BYTES 120000
[PASS] B1 backup.sh 自身以非 0 退出(rc=4)
[PASS] B2 空源下确实一个文件都没备份
[PASS] B3 调度器退出码非 0(rc=4)—— 失败被如实上报
[PASS] C1 连续两轮调度均成功(可重复执行)
[PASS] C2 两轮各留下一个独立备份目录(共 2 个)

RESULT: OK  (PASS=8 FAIL=0)

run log 诚实性:

docker compose exec -T app sh -c "grep -c 'backup round OK' /srv/state/scheduler.log || echo 0"
# → 0   (失败轮次不再出现成功措辞)

超时路径也要透出(timeout 返回 124),等价脚本实测:

# attempt 1 failed rc=124 / attempt 2 failed rc=124 / verdict rc=124 / script rc=124

六、经验与避坑

  1. cmd && var=... 是经典的「赋值不执行」陷阱。 想捕获退出码一律写 cmd || rc=$?,别写 cmd && rc=0。 前者在失败时执行,后者在成功时才执行 —— 方向正好相反。
  2. shell 脚本的退出码 = 最后一条命令的退出码。 结尾只要有一个 echo | tee、rm -f、[ ... ] 之类的命令, 真实退出码就被覆盖成 0。任何脚本都该以 exit "$rc" 收尾, 调度器 / cron / CI 入口尤其必须。
  3. set -e 不是万能的。 &&、||、! 左侧,以及 if/while 条件里的命令, 失败都不触发 set -e。别指望加个 -e 就万事大吉。
  4. 「退出码」和「日志措辞」是两条独立证据链。 「日志里有 ERROR」不能证明退出码对,「退出码是 0」也不能证明活儿干成了。 验收一定要站在监控视角:只看退出码 + 磁盘真值 + run log 三方交叉。 本题的外部探针 client/check_backup.py 就是这个思路。
  5. 失败路径要有独立断言,别只测成功路径。 这个 bug 在源目录正常时完全不显形 —— A 组 3 条断言全绿。 只有「置空源目录」这一条负向断言(B3)能抓住它。 给自己的脚本/流水线补一条「故意让它失败,退出码必须非 0」的测试。
  6. 重试逻辑要验证「真的重试了」。 本题缺陷让第一次就 break, 重试配置 SCHED_RETRY_MAX=2 纯属摆设。验收时数一下 attempt 次数(应为 3)。
  7. 容器里改脚本记得 rebuild。 脚本 COPY 进镜像后,docker compose restart 跑的还是旧代码;要么 up -d --build,要么 docker compose cp 覆盖。 改完源码没生效,是排查时最容易白耗时间的一类问题。
  8. docker compose exec 会把容器退出码当自己的返回码。 在 set -e 的脚本里直接调会被连带退出;想取值就 docker compose exec -T app sh -c 'cmd; echo $?'。
  9. run_id 用秒级时间戳时,同秒连跑会撞目录名,测试里加 sleep 2。 别把这类测试自身的问题误判成业务 bug。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-28-1600-bash-set-e-cmd-and-and-rc-0

分享这篇文章