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