zip 分卷归档大文件全部 bad CRC:CRC-32 分块累加丢状态,而自述/健康检查/退出码全绿
日期:2026-10-12 | 技术栈:ZIP 归档格式 / CRC-32 校验和 / zlib.crc32 / 流式写出 / Python struct / 分卷归档 / 健康检查退出码 关键词:
unzip -t bad CRC、CRC-32 分块累加、zlib.crc32 chunk running、data descriptor、spanned zip、空判据表、假绿、fail-closed、退出码恒 0
一、问题背景
一套归档服务:把一批业务文件按 manifest 打包成流式 ZIP,
每 4 个条目滚动切分为一个分卷(archive.z01 / archive.z02 / archive.zip),
交付下游做冷备,下游用标准工具 unzip -t 校验完整性。
上线后下游反馈:分卷里凡是稍大一点的文件,全部校验不过:
$ unzip -t reports/archive/archive.z01
Archive: reports/archive/archive.z01
testing: customers.csv OK
testing: orders-01.ndjson bad CRC 74af6066 (should be 7faed800)
testing: orders-02.ndjson bad CRC 645a660b (should be 345b3d9a)
testing: invoices.csv OK
At least one error was detected in reports/archive/archive.z01.
而归档服务这一侧四个绿灯全亮,没有一个指向真相:
| 绿灯 | 实际说明什么 |
|---|---|
python3 -m app.archiver 退出码 0 | 只证明进程没崩 |
自述 "ok": true / "self_consistent": true | 写死常量,自述字段自证 |
python3 -m app.health 的 {"degraded": false, "issues": []} | BAD_FIELDS = () 空判据表,for 一次都不循环 |
python3 -m app.health 退出码 0 | main() 无条件 return 0,退化成「给自己发合格证」 |
这是一个典型的**「现象与自述完全背离」**问题:外部标准工具报坏,内部监控全绿。
二、复现步骤
git checkout 2026-10-12-1004
cd scenario/2026-10-12-1004
# 方式 A:Docker(推荐)
docker compose up -d --build
docker compose exec -T app bash
bash ops/repro.sh ; echo "repro rc=$?" # 问题态退出码 1(预期复现信号,不是环境坏了)
# 方式 B:本机直接跑(场景只需 Python 3)
bash ops/repro.sh ; echo "rc=$?"
问题态关键输出:
Archive: reports/archive/archive.z01
testing: orders-01.ndjson bad CRC 74af6066 (should be 7faed800)
testing: orders-02.ndjson bad CRC 645a660b (should be 345b3d9a)
不一致 4 条:
- reports/archive/archive.z01:orders-01.ndjson: 归档记录的 CRC-32 与源文件不符 (记录 7faed800,源文件 74af6066)
RESULT: BAD(不一致 4 条)
判定脚本基线:bash ops/verify.sh → PASS=4 FAIL=7 / RESULT: BAD。
三、根本原因
根因 1(题眼):CRC-32 分块累加时丢了上一块的中间状态
app/streamio.py:
def accumulate(running: int, chunk: bytes) -> int:
return (running ^ zlib.crc32(chunk)) & 0xFFFFFFFF # 错:每块独立算再异或
CRC-32 不是「每块算一个再拼起来」,它是一条有状态的移位寄存器:
下一块的起始状态必须是上一块的输出。
zlib.crc32(data, value) 的第二个参数就是「本次计算的起始值」,正确写法是:
return zlib.crc32(chunk, running) & 0xFFFFFFFF # 对:把上一块状态当起始值
因为块长默认 2048 B,所以现象呈现清晰的二分: 「一块就读完」的文件全对,「需要读第二块」的文件全错。
python3 - <<'PY'
import zlib
raw = open("payload/orders-01.ndjson","rb").read()
print("size", len(raw))
print("whole-file crc32 =", hex(zlib.crc32(raw) & 0xffffffff)) # 0x74af6066
run = 0
for i in range(0, len(raw), 2048):
run = (run ^ zlib.crc32(raw[i:i+2048])) & 0xffffffff
print("buggy xor accumulate =", hex(run)) # 0x7faed800 ← 与归档记录一致
run = 0
for i in range(0, len(raw), 2048):
run = zlib.crc32(raw[i:i+2048], run) & 0xffffffff
print("fixed stateful accum =", hex(run)) # 0x74af6066 ← 与源文件一致
PY
关键判读:坏的不是数据字节,是记下来的校验和。
unzip -t 同样区分这两种情况 —— bad CRC 是校验和不匹配,
invalid compressed data / unexpected end of file 才是数据坏。
根因 2(闸门):健康检查是「空判据表 + 退出码恒 0」
app/health.py:
BAD_FIELDS = () # 空判据表,for 一次都不循环
...
except (OSError, json.JSONDecodeError):
return EXIT_OK # 连读不到自述报告都当健康
...
return EXIT_OK # 无条件返回 0,「判不了」被当成「没问题」
for field in BAD_FIELDS 永远不执行 → issues 永远为空 → degraded 永远 false。
即使归档已经烂了,健康检查依然「全绿」且退出码 0。
这两个根因互相掩护:数据错了没人发现,因为闸门是个假的。 只修题眼(CRC)能让探针变绿,但闸门仍然没牙。
四、解决办法
修复 1:app/streamio.py(一行,题眼)
def accumulate(running: int, chunk: bytes) -> int:
"""把一块数据「加进」累积 CRC。"""
- return (running ^ zlib.crc32(chunk)) & 0xFFFFFFFF
+ return zlib.crc32(chunk, running) & 0xFFFFFFFF
修复 2:app/health.py(把判据落在外部能独立重算的事实上)
- 自己解析契约
conf/archiver.env:拿不到 → 退出码 3; - 自己解析
conf/manifest.jsonl,直接读源文件重算 CRC-32(权威事实,不采信自述); - 自己按中央目录解析归档分卷,把归档里存下的字节取回来重算 CRC-32, 同时核对归档记录的 CRC 也等于源文件事实;
- 核对分卷条目数不超契约上限、条目清单与 manifest 逐一对应;
- 自述报告
archive_report.json只用于交叉对账,不作为「是否健康」的依据; - 退出码随事实变化:
0健康 /2有真问题或拿不到事实 /3契约不可用。
坑点:streaming 模式下 local file header 的 size 字段按契约置 0, 取内容长度必须用中央目录的
uncomp_size,不能用 local header 里的字段。
app/archiver.py 无需改动。conf/、docs/、client/、ops/ 是只读考卷,不许改。
五、验证
docker compose cp app/streamio.py app:/srv/app/streamio.py
docker compose cp app/health.py app:/srv/app/health.py
docker compose exec -T app bash -c 'rm -rf app/__pycache__ client/__pycache__; bash ops/verify.sh'
# PASS=11 FAIL=0 / RESULT: OK
docker compose exec -T app bash -c 'bash ops/repro.sh; echo "repro rc=$?"'
# unzip -t 三个分卷全部 No errors detected;探针 RESULT: OK;rc=0
docker compose exec -T app bash ops/negative.sh
# PASS=14 FAIL=0 / RESULT: OK(闸门有牙)
退出码 fail-closed 直采:
healthy rc=0
ARCHIVER_CONTRACT=/tmp/nope.env rc=3 (契约不可用)
删掉 reports/archive rc=2 (拿不到事实 = 判不了 ≠ 没问题)
负向验证里最值得记的一条:把 CRC 累加改回「异或」后, 被挖空的 health 对坏档仍退出码 0 / degraded=false —— 这就是「自述自证 = 假绿」的实证。
六、经验与避坑
- CRC-32 / 校验和是「有状态」的:分块计算时,「下一块的起始值」=「上一块的输出」。 最短自检口诀:同一个文件,分 1 块算、分 N 块算,结果必须一模一样。 不等,就是丢了状态。
unzip -t的报错要会读:bad CRC是校验和记错(内容可能没错),invalid compressed data/unexpected end of file才是数据坏。 这个区分能立刻把排查方向从「数据流」转到「校验和计算」。- 现象呈现「块长二分」时,去看分块累加/拼接那一处。 凡是「小文件全对、超过某个固定字节数的文件全错」,那个字节数就是你的块长。
- 警惕「自述字段自证」:
"ok": true如果是写死的常量, 那它证明的不是健康,只是有人会打字。判据必须落在外部能自己重算一遍的事实上。 - 空判据表是隐蔽的假绿:
for field in ()一次都不循环,issues永远为空 →degraded永远 false。代码看着「有判据逻辑」,实则一条都没跑。 - 健康检查退出码必须随事实变化,且要 fail-closed: 「拿不到数据 = 判不了」必须退出码非 0,绝不能被当成「没问题」。
- 验收要看判别力:只检查「退出码为 0」的断言,在问题态也会误报通过
(问题态也 exit 0)。必须用「问题态能 FAIL、修复态才 PASS」的断言,
并用负向验证(
ops/negative.sh)证明闸门真的有牙。 - 容器操作小坑:
- 非交互流水线里
docker compose exec必须加-T,否则the input device is not a TTY; repro.sh以探针退出码为脚本退出码,问题态返回 1,用; echo rc=$?接住,别用&&;- 改了
app/记得清__pycache__并同步进容器(镜像用COPY打包代码, 直接改宿主机文件容器看不到,用docker compose cp或--build重建)。
- 非交互流水线里
七、同类影响面
- gzip / zlib 流式压缩:
deflate的Z_SYNC_FLUSH与 CRC 累积(crc32的value参数) - PNG / gzip / zlib 的 CRC-32 与 Adler-32 分块计算(Adler-32 的
adler32(data, value)同理) - 任何「边读边算摘要」的实现:MD5/SHA 分块更新(
hashlib的update())、 xxHash / MurmurHash 的流式 API - 大文件分片上传、断点续传里的分片校验和合并(分片各算各的再拼 = 同一类错误)
- 一切「自述字段自证」的监控面板,与退出码恒 0 的巡检脚本
八、可复现命令速查
cd scenario/2026-10-12-1004
docker compose up -d --build
docker compose exec -T app bash
bash ops/repro.sh # 复现:问题态 rc=1(预期)
bash ops/verify.sh # 判定:问题态 PASS=4 FAIL=7 / RESULT: BAD
bash ops/verify.sh --fix # 切参考解
bash ops/verify.sh --restore # 切回问题态
bash ops/negative.sh # 负向验证(证明闸门有牙)
