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

gzip 归档报 invalid block type / invalid compressed data--format violated:gzip 头 FLG 非法 + deflate 流没有结束标记(BFINAL)

日期:2026-09-24 环境:Linux + Docker + Python 3.12(zlib / gzip) 技术栈:Python zlib.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名称含义
0x02FHCRC头后跟 2 字节头 CRC
0x04FEXTRA头后跟 XLEN(2) + XLEN 字节额外数据
0x08FNAME头后跟 NUL 结尾的文件名
0x10FCOMMENT头后跟 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 字节,不加任何占位块

三条硬约束:

  1. flush(zlib.Z_FINISH) —— 不能是 Z_SYNC_FLUSH,更不能是裸 flush();
  2. FLG = 0x00 —— 不声明任何额外头字段;
  3. 尾部只有 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 后 verifyPASS=3 FAIL=3(A1/A2/B2 挂)—— 判据确实有牙
注入后重跑归档器rc=0,verify 回到 PASS=6 FAIL=0(可自愈存量坏档)
归档器退出码(正常)rc=0 + "msg":"manifest verified","bad":0

六、经验与避坑

  1. Z_SYNC_FLUSH ≠ 结束流。 它只刷缓冲、留一个未结束的同步点; 流的结束必须靠 Z_FINISH(末块 BFINAL=1)。 二者是功能不同的两个东西,不是「刷新程度」的差别。
  2. gzip 头是 10 字节固定长度,FLG 每一位都是「后面还有东西」的承诺。 手拼 gzip 头时漏一个 \x00,就会把 0xff 顶到 FLG 位, 于是文件从第一个字节之前就坏了。 能用标准库就别手拼:gzip.compress(data, compresslevel=9) 或 gzip.GzipFile(fileobj=..., mtime=0);要自定义 mtime 时 gzip.compress 还支持 mtime 参数(Python 3.11+)。
  3. CRC32/ISIZE 正确 ≠ 文件可解。 尾部是两个独立字段, 结构和校验和可以「一错一对」。别把 CRC 通过当成格式合法的证据。
  4. 同一个报错可能有多个来源,必须做「最小化 / 二分定位」。 本题 invalid block type 同时来自「头部 FLG 非法」和「尾部垃圾」。 逐块喂数据(找到「失败在 chunk 0」)是区分二者的关键手法。
  5. invalid block type 与 format violated 报错位置差很远是正常的。 Python gzip 走「先解头字段、再 inflate」的完整路径; gzip -t 是 C 实现,可能在头部字段越界时就报 format violated。 看到不同工具报不同错,说明失败在不同层,不要假设它们指向同一个 bug。
  6. 「内容寻址」的共享目录里,任何缓存下来的 size 都会过期。 同名文件的内容会随「谁最后写」而变化。 判重/对账一律用内容标识(解压后 sha256 或源 sha256),需要字节数就现场 stat。
  7. 写入必须原子发布。 tmp + fsync + os.replace 是三步:不 fsync 掉电会丢数据, 不 os.replace 读方会看到半截文件。多进程共享目录时这条是硬要求。
  8. 健康核对必须核对「真值」,否则它只会制造虚假信心。 只比 (名字, 大小) 的核对,对「尾部带垃圾但大小自洽」的文件永远报 OK。 核对解压结果 + 让退出码跟随核对结果,才谈得上「自报健康」。
  9. 自报成功的退出码 0 是静默数据损坏的温床。 写入侧必须自己解一遍/校验一遍,失败就非 0 退出 + ERROR 日志。 本题修复前 bad=0 + rc=0 + 全 INFO,正是最坑的形态。
  10. bind mount 上的 __pycache__ 会让你改代码不生效。 反复编辑 + 容器内 import 后可能读到与源码不一致的 .pyc, 症状是「inspect.getsource() 是新代码,行为是旧的」。 改完代码先 rm -rf app/__pycache__,再怀疑别的。
  11. 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。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-24-0800-gzip-invalid-block-type

分享这篇文章