gzip 归档报 invalid block type / invalid compressed data--format violated:gzip 头 FLG 非法 + deflate 流没有结束标记(BFINAL)
日期:2026-09-24 环境:Linux + Docker + Python 3.12(
zlib/gzip) 技术栈:Pythonzlib.compressobj、gzip、GNU gzip 1.12、Docker Compose、rsync 判重 关键词:invalid block type、Error -3 while decompressing data、invalid compressed data--format violated、gzip -t返回 2、gzip -t返回 1、BadGzipFile、Not a gzipped file、Z_SYNC_FLUSH、Z_FINISH、BFINAL、gzip FLG、FEXTRA、FCOMMENT、deflate 流没有结束标记、gzip 头 10 字节、 归档文件打不开、gzip 解压失败、rsync 判重同名同大小、内容寻址 blobs、gzip 压缩级别差异
一、问题背景
一个日志归档服务(archive-a)每天把 /srv/logs/incoming/ 下的业务日志
(.ndjson / .log)压成 .ndjson.gz,落到按内容命名的共享目录:
/srv/archive/
├── blobs/<sha256(源文件原始字节)>.ndjson.gz
├── manifest.json
├── peers/<host>.json # 各机的「同伴快照」
└── peer-inbox/<host>.json # 本机自己报上来的清单
文件名的设计意图:同一条日志在两台机器上必然同名,搬运脚本(rsync)就能
认出来跳过;另外顺手写一份「同伴快照」记录本机归档文件的 (名字, 大小),
跑完核对一次快照 ↔ 目录,日志打 peer snapshot verified、bad=0。
但下游读不了这些归档。整个归档流程退出码 0、日志里一行 ERROR 都没有:
{"msg": "archived", "file": "app-20260924.ndjson.ndjson.gz", "raw_bytes": 524351, "gz_bytes": 66790}
{"msg": "peer snapshot written", "path": "/srv/archive/peers/archive-a.json", "entries": 3}
{"msg": "peer snapshot verified", "entries": 3, "bad": 0}
{"msg": "archive done", "files": 3, "raw_bytes": 917701, "gz_bytes": 117573}
下游消费方:
$ python3 client/check_archive.py
[check] blobs = 3
{ "ok": 0, "bad": 3, "detail": [ { "why": "error: Error -3 while decompressing data: invalid block type" }, ... ] }
RESULT: BAD
系统工具也不通过:
$ gzip -t /srv/archive/blobs/c9c03d8....ndjson.gz
gzip: ...ndjson.gz: invalid compressed data--format violated (rc=2 / rc=1)
现象总结:归档器说自己归档成功,产出的归档文件却根本解不开。 (这就是最危险的一类故障:写入侧自报健康 + 退出码 0,把「静默数据损坏」藏起来。)
二、复现步骤
cd scenario/2026-09-24-0800
docker compose up -d --build
docker compose exec -T app ./ops/repro.sh # 复现:RESULT: BAD
docker compose exec -T app ./ops/verify.sh # 判定:PASS=0 FAIL=6
repro.sh 做的事:造约 900 KiB 业务日志(512K + 256K + 128K)→ 跑归档器
(GZIP_LEVEL=9)→ 让对端 archive-b 归档同一批日志(GZIP_LEVEL=1)→
gzip -t 逐个检查 → 下游脚本读归档。
最小复现(不需要 docker):
import zlib
raw = b"hello world, this is a test line\n" * 100
# ---- 问题写法 ----
co = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS)
body = co.compress(raw) + co.flush(zlib.Z_SYNC_FLUSH) # 只刷缓冲,不结束流
bad = body + b"\xff" * 8 # 尾部再拼「占位块」
d = zlib.decompressobj(-zlib.MAX_WBITS)
try:
d.decompress(bad)
except Exception as e:
print("BAD ->", e) # BAD -> Error -3 while decompressing data: invalid block type
# ---- 正确写法 ----
co2 = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS)
good = co2.compress(raw) + co2.flush(zlib.Z_FINISH) # ★ 结束 deflate 流
d2 = zlib.decompressobj(-zlib.MAX_WBITS)
out = d2.decompress(good)
print("GOOD ->", d2.eof, out == raw) # GOOD -> True True
非法 gzip 头的最小复现:
import gzip, zlib, struct
raw = b"x" * 100000
body = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS)
body = body.compress(raw) + body.flush(zlib.Z_FINISH)
trailer = zlib.crc32(raw).to_bytes(4, "little") + len(raw).to_bytes(4, "little")
bad_header = b"\x1f\x8b\x08\x00" + b"\x00\x00\x00\x00" + b"\x00\xff" # FLG 位置被 0xff 顶掉
blob = bad_header + body + trailer
# 注意:这个函数本身要正确拼 10 字节头,下面演示「少一个字节造成错位」
更贴近现场的写法:header = b"\x1f\x8b\x08\x00" + mtime.to_bytes(4,"little") + b"\x00\x00\xff"
—— mtime=0 时多出的 \x00 把 0xff 顶上 FLG 位,得到 1f 8b 08 ff ...。
三、根本原因
两个独立缺陷叠加。分开看每一个都能让文件打不开,所以必须同时修。
原因 1(主因):gzip 头 FLG = 0xff,解压方去读不存在的头字段
gzip 成员结构:
+---+---+---+---+--------+---+---+
|ID1|ID2| CM|FLG| MTIME |XFL|OS | 10 字节头
+---+---+---+---+--------+---+---+
| deflate 数据(必须结束,末块 BFINAL=1) |
+--------------------------------+
| CRC32(4) | ISIZE(4) | 8 字节尾
现场问题文件的头 10 字节是 1f 8b 08 ff 00 00 00 00 00 ff —— FLG = 0xff。
FLG 低 5 位的含义:
| bit | 名称 | 含义 |
|---|---|---|
0x02 | FHCRC | 头后跟 2 字节头 CRC |
0x04 | FEXTRA | 头后跟 XLEN(2) + XLEN 字节额外数据 |
0x08 | FNAME | 头后跟 NUL 结尾的文件名 |
0x10 | FCOMMENT | 头后跟 NUL 结尾的注释 |
0xff 把这些位全置上了。解压方于是跳过真正的 deflate 数据开头去读这些
字段:FEXTRA 会把 body 的前 2 个字节当成 XLEN,指针直接跑偏。
流从第一个字节之前就已经错位 → inflate 读到非法块头 → invalid block type。
为什么 CRC32/ISIZE 反而是对的? 因为它们是代码用 crc32(raw) 和 len(raw)
单独算出来拼在末尾的 —— 内容本身没错,错的只是「流的结构」。
这解释了最迷惑人的现象:CRC 校验能过,文件却解不开。
原因 2:deflate 流没有结束标记(Z_SYNC_FLUSH 而非 Z_FINISH)
co.compress(raw) + co.flush(zlib.Z_SYNC_FLUSH)
Z_SYNC_FLUSH 会把压缩缓冲刷干净,但刷出来的是一个「未结束的同步点」
(尾部固定 00 00 ff ff)—— 流并没有结束。deflate 规范规定:
流的结束由最后一块的 BFINAL 位置 1 表示,zlib 里对应 Z_FINISH。
问题代码在 Z_SYNC_FLUSH 之后又拼了一个 b"\xff" * 8 的「尾部收尾信息占位块」。
解压方越过同步点继续解析时,会把这 8 个 0xff 当作「下一个 deflate 块的块头」:
BTYPE 取值 0x03 是保留值(非法)→ 同样是 invalid block type。
# 实测:未结束的流 + 8 字节垃圾,必然报 invalid block type
zlib.decompressobj(-zlib.MAX_WBITS).decompress(
co.compress(raw) + co.flush(zlib.Z_SYNC_FLUSH) + b"\xff" * 8
)
# -> Error -3 while decompressing data: invalid block type
同一个错误报错,两个不同来源。 逐块喂数据定位到「失败发生在 chunk 0」, 才排除掉尾部垃圾、锁定头部错位。
原因 3(放大伤害):两段写入,无原子性
with open(out_path, "wb") as f: f.write(blob[:-8]) # 先写数据
with open(out_path, "ab") as f: f.write(blob[-8:]) # 再补尾部
两段之间文件是残缺的(缺 CRC32/ISIZE),且 app 与 peer 通过同一个
Docker volume 共享 blobs/ —— 两段写会与对方的写交错覆盖,
留下既不属于谁、又解不开的「混合字节」文件。
原因 4:判重口径错(把 size 当身份)
blobs/ 是按内容命名的共享目录。同一份日志在两边压缩档不同:
level=9 -> size=66777 sha256=d45c1a47f52270d6 raw=524351
level=1 -> size=88535 sha256=caa4e933a06bb08f raw=524351
size 差 33%,但解压后完全一致。所以:
- 判重必须用内容标识(
content_key= 源内容 sha256),不能用size。 用「同名 + 同大小」判重 → 每次都被判成「不一样」→ rsync 反复重搬。 - 清单里不要缓存
size:对端跑完后,同一个文件名的字节数已经换成对端的了, 你写的size变成「别人的字节数」,直接误导搬运脚本。需要字节数就自己stat。
原因 5:健康核对没有牙
核对逻辑只比 (名字, 大小) —— 一份尾部带垃圾的归档大小也自洽,
所以永远 bad=0,永远 archive done + 退出码 0。
这就是「归档器说自己归档成功」的出处。
四、解决办法
只改归档器实现(app/archiver.py),不动配置和镜像。
1. 产出「一个完整自洽的 gzip 成员」
def gz_blob(raw: bytes, level: int, mtime: int = 0) -> bytes:
co = zlib.compressobj(level, zlib.DEFLATED, -zlib.MAX_WBITS)
body = co.compress(raw)
body += co.flush(zlib.Z_FINISH) # ★ 结束 deflate 流(BFINAL=1)
# ★ 10 字节头:ID1 ID2 CM FLG MTIME(4) XFL OS,FLG 必须 0x00
header = b"\x1f\x8b\x08\x00" + mtime.to_bytes(4, "little") + b"\x00\xff"
trailer = zlib.crc32(raw).to_bytes(4, "little") + (len(raw) & 0xFFFFFFFF).to_bytes(4, "little")
return header + body + trailer # ★ 尾部只有 8 字节,不加任何占位块
三条硬约束:
flush(zlib.Z_FINISH)—— 不能是Z_SYNC_FLUSH,更不能是裸flush();FLG = 0x00—— 不声明任何额外头字段;- 尾部只有
CRC32 + ISIZE(8 字节)—— 多一个字节都是「文件多余字节」。
2. 落盘:一次写完 + tmp/os.replace 原子发布
tmp_path = out_path + ".tmp.%d" % os.getpid()
with open(tmp_path, "wb") as f:
f.write(blob); f.flush(); os.fsync(f.fileno())
os.replace(tmp_path, out_path) # ★ 原子发布,读方永远看不到半截文件
3. 清单:读磁盘真值,写契约字段,写自己的目录
def gz_read_raw(path):
import gzip
with gzip.open(path, "rb") as f:
return f.read()
raw = gz_read_raw(p)
entries.append({
"name": m["sha256"] + ".ndjson.gz", # blobs/ 下的真实文件名
"content_key": hashlib.sha256(raw).hexdigest(), # ★ 解压后内容的 sha256
"raw_bytes": len(raw), # ★ 解压后字节数
}) # ★ 不写 size
dst = os.path.join(out, "peer-inbox", host + ".json") # ★ 不占用 peers/
4. 核对要有牙,退出码要跟随核对结果
try:
raw = gz_read_raw(p)
except Exception as ex:
log("ERROR", "entry not readable as gzip member", name=e["name"], error=str(ex))
bad += 1
continue
if e["content_key"] != hashlib.sha256(raw).hexdigest() or e["raw_bytes"] != len(raw):
bad += 1
...
return 0 if healthy else 1 # ★ 健康核对不过就不许报「成功」
五、验证
cd scenario/2026-09-24-0800
docker compose exec -T app bash -c 'rm -rf /srv/archive/* /scenario/app/__pycache__'
docker compose exec -T app ./ops/repro.sh # RESULT: OK
docker compose exec -T app ./ops/verify.sh # PASS=6 FAIL=0
实测输出:
gzip -t 0ab7c007....ndjson.gz -> OK (rc=0)
gzip -t 53e7b430....ndjson.gz -> OK (rc=0)
gzip -t c9c03d82....ndjson.gz -> OK (rc=0)
{"ok": 3, "bad": 0, "detail": [{"verdict":"OK","raw_bytes":131110,"trailing_bytes":0}, ...]}
RESULT: OK
== A1: 归档内容与源文件逐字节一致 [PASS]
== A2: 归档是自洽 gzip 成员、尾部无多余字节 [PASS]
== B1: 清单条目带 content_key(与源绑定、与压缩档无关) [PASS]
== B2: 清单 raw_bytes 与解压结果一致 [PASS]
== B3: 清单 size 与产出方真实文件一致 [PASS]
== B4: 清单写在 peer-inbox/<本机 host>.json [PASS]
PASS=6 FAIL=0
改动前后字节对比:
问题态头: 1f 8b 08 ff 00 00 00 00 00 ff <-- FLG=0xff(非法)
问题态尾: ... 3f 00 00 00 | ff ff ff ff ff ff ff ff | 1b 8a 1c 3b 26 00 02 00
^^^^^^^^^^^^^^^^^^^^^^^^^ 多余的 8 字节占位块
修复后头: 1f 8b 08 00 00 00 00 00 00 ff <-- FLG=0x00
修复后尾: ... f8 4b fd bf | 1b 8a 1c 3b 26 00 02 00
^^^^^ CRC32 ^^^^^^^^^ ISIZE,紧贴流尾
补充验证:
| 验证项 | 结果 |
|---|---|
| 幂等(连跑 3 次) | 3 次都是 RESULT: OK / PASS=6 FAIL=0 |
对端 GZIP_LEVEL=1 共存后重跑 | 两档 size 不同、解压 raw 一致,verify 仍 PASS=6 |
反向注入坏字节 ./ops/inject_bad.sh 后 verify | PASS=3 FAIL=3(A1/A2/B2 挂)—— 判据确实有牙 |
| 注入后重跑归档器 | rc=0,verify 回到 PASS=6 FAIL=0(可自愈存量坏档) |
| 归档器退出码(正常) | rc=0 + "msg":"manifest verified","bad":0 |
六、经验与避坑
Z_SYNC_FLUSH≠ 结束流。 它只刷缓冲、留一个未结束的同步点; 流的结束必须靠Z_FINISH(末块BFINAL=1)。 二者是功能不同的两个东西,不是「刷新程度」的差别。- gzip 头是 10 字节固定长度,
FLG每一位都是「后面还有东西」的承诺。 手拼 gzip 头时漏一个\x00,就会把0xff顶到FLG位, 于是文件从第一个字节之前就坏了。 能用标准库就别手拼:gzip.compress(data, compresslevel=9)或gzip.GzipFile(fileobj=..., mtime=0);要自定义 mtime 时gzip.compress还支持mtime参数(Python 3.11+)。 - CRC32/ISIZE 正确 ≠ 文件可解。 尾部是两个独立字段, 结构和校验和可以「一错一对」。别把 CRC 通过当成格式合法的证据。
- 同一个报错可能有多个来源,必须做「最小化 / 二分定位」。
本题
invalid block type同时来自「头部 FLG 非法」和「尾部垃圾」。 逐块喂数据(找到「失败在 chunk 0」)是区分二者的关键手法。 invalid block type与format violated报错位置差很远是正常的。 Pythongzip走「先解头字段、再 inflate」的完整路径;gzip -t是 C 实现,可能在头部字段越界时就报format violated。 看到不同工具报不同错,说明失败在不同层,不要假设它们指向同一个 bug。- 「内容寻址」的共享目录里,任何缓存下来的
size都会过期。 同名文件的内容会随「谁最后写」而变化。 判重/对账一律用内容标识(解压后 sha256 或源 sha256),需要字节数就现场stat。 - 写入必须原子发布。
tmp + fsync + os.replace是三步:不fsync掉电会丢数据, 不os.replace读方会看到半截文件。多进程共享目录时这条是硬要求。 - 健康核对必须核对「真值」,否则它只会制造虚假信心。
只比
(名字, 大小)的核对,对「尾部带垃圾但大小自洽」的文件永远报 OK。 核对解压结果 + 让退出码跟随核对结果,才谈得上「自报健康」。 - 自报成功的退出码 0 是静默数据损坏的温床。
写入侧必须自己解一遍/校验一遍,失败就非 0 退出 +
ERROR日志。 本题修复前bad=0+rc=0+ 全INFO,正是最坑的形态。 - bind mount 上的
__pycache__会让你改代码不生效。 反复编辑 + 容器内 import 后可能读到与源码不一致的.pyc, 症状是「inspect.getsource()是新代码,行为是旧的」。 改完代码先rm -rf app/__pycache__,再怀疑别的。 python:3.12-slim里没有xxd,用od -A d -t x1看 hex。
可复现命令清单(复制即用)
cd scenario/2026-09-24-0800
docker compose up -d --build
# 问题态
docker compose exec -T app ./ops/repro.sh # RESULT: BAD
docker compose exec -T app ./ops/verify.sh # PASS=0 FAIL=6
docker compose exec -T app /bin/bash -c \
'od -A d -t x1 -j $(( $(stat -c%s /srv/archive/blobs/*.ndjson.gz | head -1) - 40 )) /srv/archive/blobs/*.ndjson.gz'
# 反向验证「判据有牙」
docker compose exec -T app ./ops/inject_bad.sh
docker compose exec -T app ./ops/verify.sh # PASS=3 FAIL=3
# 修复态
docker compose exec -T app /bin/bash -c 'rm -rf /srv/archive/* /scenario/app/__pycache__'
docker compose exec -T app ./ops/repro.sh # RESULT: OK
docker compose exec -T app ./ops/verify.sh # PASS=6 FAIL=0
完整操作过程、定位细节与踩坑记录见
scenario/2026-09-24-0800/SOLUTION.md。
