脚本打印 done,文件却是 0 字节:一次被 tee 吞掉的退出码排查
故障现场
夜间任务读取一天的应用程序日志,让模型把每行标成噪音或信号,再把保留的行写进汇总文件。任务跑在一台常开的小机器上,调用 HTTP 端点,结束时往日志里打印 done。那个日志我只有感觉不对时才打开。
48 小时实验的第二天早上,汇总文件是 0 字节,日志里写着 done,而且是两次。
问题出在 job、shell 还是我?这个问题花了我两天,试了三个错误理论。答案比它们都便宜:管道报告的是 tee 的退出状态,不是真正干活的进程的。
看起来没问题的朴素版本
#!/usr/bin/env bash
# naive-run.sh — the version that lied to me
for id in $(cat ids.txt); do
curl -sS -X POST "$BASE_URL/chat" \
-H "Content-Type: application/json" \
-d "{\"input\": \"$id\"}" \
| tee -a out.jsonl
done
echo done
tee -a 只要能打开输出文件就退出 0,所以即使 curl 中途被杀,每一轮看着都成功;echo done 在结尾无条件执行。顶部加上 set -euo pipefail,同一个脚本讲的会是完全不同的故事。
两行验证
$ false | tee /dev/null; echo "status=$?"
status=0
$ set -o pipefail; false | tee /dev/null; echo "status=$?"
status=1
0 字节文件的全部谜团就在这里:管道是健康的,写端是正常的,生产者在写出任何值得保留的内容之前就被杀了。
谁杀了生产者
打开 pipefail 后,状态是 137。这不是应用错误码,是 128 + 9,9 号信号是 SIGKILL。SIGKILL 不会跟你商量——要么内核 OOM killer,要么 cgroup 限制,要么不耐烦的 supervisor。两步检查把候选分开:
journalctl -k --since "24 hours ago" | grep -i -E "killed process|oom",看带时间戳的内核证据。cat /sys/fs/cgroup/memory.events(cgroup v2),看oom_kill计数器,进程死时它才加一。
计数器动了,就不是慢、不是被限流、不是被限速——是撞上了你从没量过的内存上限。需要补一句:有些容器里 memory.max 读出来是 max,但宿主机在别处施加上限,所以计数器比限制文件可信。
在第一行之前先跑预检
我丢掉的大部分时间,是在第 300 行才发现上限,而不是在第 0 秒。所以 runner 现在预检不过就拒绝启动,并把发现的东西打出来。
#!/usr/bin/env bash
# preflight.sh — abort early, loudly, with reasons
set -euo pipefail
say() { printf '%s %s\n' "[preflight]" "$*"; }
mem_max=$(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo unknown)
mem_cur=$(cat /sys/fs/cgroup/memory.current 2>/dev/null || echo unknown)
say "cgroup memory.max=$mem_max current=$mem_cur"
if [ "$mem_max" != "unknown" ] && [ "$mem_max" != "max" ]; then
avail=$(( mem_max - mem_cur ))
if [ "$avail" -lt 100000000 ]; then
say "REFUSING: less than ~100MB of cgroup headroom"; exit 3
fi
fi
df -Pk . | awk 'NR==2 { if ($4 < 200000) { print "[preflight] REFUSING: low disk"; exit 3 } }'
command -v curl >/dev/null || { say "REFUSING: no curl on PATH"; exit 3; }
[ -r ids.txt ] || { say "REFUSING: ids.txt missing"; exit 3; }
say "ok: $(wc -l < ids.txt) ids queued"
exit 3 是故意的:它表示「拒绝启动」,跟「启动了但失败」是完全不同的信号,我的告警把这两种情况分开处理。
checkpoint 让被杀变得可承受
SIGKILL 不是修一次就完的 bug,它是天气。真正的修法是让死亡变便宜——每完成一条记录追加一行 JSON,flush 并 fsync,下次运行跳过已完成的 id。
import json, os, pathlib
OUT = pathlib.Path("out.jsonl")
def done_ids() -> set[str]:
if not OUT.exists():
return set()
ids = set()
for line in OUT.read_text(encoding="utf-8").splitlines():
try:
ids.add(json.loads(line)["id"])
except (json.JSONDecodeError, KeyError):
continue # a torn final line is expected after SIGKILL
return ids
def append(record: dict) -> None:
with OUT.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
f.flush()
os.fsync(f.fileno()) # survive the next kill
有两个细节值得保留。每条记录都 fsync 会牺牲吞吐,但恢复时账是实的;跳过最后一行畸形数据,意味着一次撕裂写入不会毒掉整次续跑。然后结尾对账:数 out.jsonl 里的唯一 id,跟 ids.txt 的行数比,不一致就非零退出,而不是打印 done。
贴在任务旁边的退出码表
| Status | Meaning | First thing to check |
| 0 | Clean exit | Did pipefail actually apply? |
| 1 | Generic failure | stderr, not the log file |
| 2 | Shell misuse | quoting, missing argument |
| 126 / 127 | Not executable / not found | PATH inside cron, not your interactive shell |
| 137 | SIGKILL | cgroup oom_kill, kernel log |
| 141 | SIGPIPE | a downstream head closed the pipe |
| 143 | SIGTERM | supervisor or deploy restarted you |
取舍
预检探针留着:十行 bash 每次都比六小时糊涂便宜。不要信以 done 结尾的日志行,信对账计数。set -euo pipefail 放进每个碰数据、碰钱、碰 cron 的脚本;退出码表贴在任务旁边,别放没人会在事故时打开的 wiki。要改的是顺序——先读资源上限,再写循环,而不是被杀了三次之后才去读。
还有一层比 pipefail 更深的:朴素版本里 done 是一个声明,不是一次测量。就算设了 pipefail,一个处理了 2000 个 id 里的 1900 个、在最后一次 curl 死掉的循环,留下的仍是半截文件和一个非零退出,写在你不常看的日志里。收住这一类问题的办法,是结尾的数量断言:进多少 id,出多少行,比一下。一行 test "$(wc -l < ids.txt)" -eq "$(wc -l < out.jsonl)" 把「管道感觉没问题」变成可检查的不变量;它也接住了 pipefail 单独漏掉的情况——curl -sS 在 HTTP 500 时退出码仍是 0,不加 --fail,整晚的 500 都会把错误体流进 out.jsonl,而每一步状态全是绿的。预检的习惯还能往前推一步:先用一个 canary id 走完整条管道,断言有一行格式正确的输出落地,再开始循环。
代价与边界说清楚:每条记录都 fsync 换来的是一致性,不是吞吐。对延迟敏感或写入量大的任务,得把 fsync 频率降下来,或者改成分段 checkpoint,代价是被杀后要重放更多。这套流程不适合需要 p99 延迟保证的人,不适合处理不能离开批准边界的受监管数据,也不适合任务本身无法做成幂等的人。如果重跑会给客户重复扣款或重复发邮件,先修那个,再谈优化。
最后一条容易漏:curl -sS 在 500 时退出码是 0,得加 --fail 才能把非 2xx 变成非零退出,否则错误体会安安静静写进你的汇总文件。