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

日志归档 .ndjson.gz 打不开:gzip unexpected end of file —— 尾部 CRC32/ISIZE 缺失、哈希口径与落盘对象不一致、分段阈值 off-by-one

日期:2026-09-18-1600 | 环境:Docker(python:3.11-slim,无需特权)| 分支:2026-09-18-1600 关键词:gzip 文件损坏 / unexpected end of file / EOFError Compressed file ended before the end-of-stream marker was reached / BadGzipFile / gzip 尾部 CRC32 ISIZE 缺失 / gzip 魔数 1f8b / gzip.compress vs GzipFile flush close / daemon 线程异步写尾部 / NDJSON 日志归档 / 分段阈值 off-by-one / 断点续传检查点 lines / 哈希口径与落盘对象不一致 / sha256 审计链 / 压缩收益是坏掉也好看的指标 / 静默失败 技术栈:Python 3.11 / gzip / zlib / hashlib / Docker Compose / coreutils


一、问题背景

一个审计日志归档平台:上游埋点每小时吐一个 NDJSON(每行一条 JSON 事件), 归档服务负责「分段 + 压缩落盘」,下游(审计导出 / 大数据导入 / 日志检索)直接读归档目录。

归档要求两条同时成立:

  1. 逐字节保真:归档里必须是上游的原始字节(要做 sha256 审计与链式校验);
  2. 落盘要省磁盘:落盘前做 gzip 压缩。

实现约定是「先对原始字节算哈希 → 再压缩落盘」, 每段配侧车文件 seg-*.raw.sha256 存审计链哈希,offset.json 支持断点续传。

运维反馈:

❌ 审计导出脚本报:gzip: /archive/seg-000000.ndjson.gz: unexpected end of file
❌ 日志检索平台导不进去:EOFError: Compressed file ended before the end-of-stream marker was reached
❌ 但归档服务自己的日志全绿,一条报错都没有
❌ 而且「压缩收益」报表看起来还挺好看

现场事实(都真、别重复验证):

  1. ls -l 看归档目录,*.ndjson.gz 文件存在、大小不为 0;
  2. 每个分段 gzip -t 都报同一句 unexpected end of file;
  3. 归档服务日志每条都写着成功,无任何异常;
  4. 手工补上 8 字节尾部就能解开 —— 文件其实是「gzip 头 + raw deflate」,缺的正是尾部 CRC32+ISIZE;
  5. --compresslevel 0 是现场值;改成 9 产出的文件同样打不开;
  6. 纯 ASCII 小输入经常能正常解开,含多字节 UTF-8 长行、跑久一点就稳定失败 —— 曾被误判成「上游数据编码不干净」;
  7. 侧车文件里没有原始字节长度,只有一串 hex,拿它去 sha256sum seg-*.ndjson.gz 对不上;
  8. offset.json 只有 byte_off / seg_index,没有行数;
  9. 阈值写的是 ROLLOVER_BYTES=8192,但有些段明显超了。

二、复现步骤

git fetch origin && git checkout 2026-09-18-1600
cd scenario/2026-09-18-1600

docker compose up -d --build                 # 首次约 30 秒(只用 Python 标准库)
docker compose exec app bash /ops/run_round.sh
docker compose exec app bash /ops/verify.sh   # 问题态 PASS=2 FAIL=8

docker-compose.yml 刻意不加任何特权(无 privileged / cap_add / /proc/sys), app/ 挂成可写(修复要改的就是这里的 audit_archive.py)。

关键输出(问题态):

[archive] step=created seg=0 gz=seg-000000.ndjson.gz lines=39
[archive] step=checkpoint seg=1 byte_off=9030 segment-flushed
[archive] step=created seg=1 gz=seg-000001.ndjson.gz lines=21
[archive] step=done segments_written=2 next_seg=2 consumed_bytes=13799

③ 压缩收益:原始 13799 字节 → 压缩后 13829 字节 → -0.2%   ← 唯一「坏掉也好看」的指标
⑤ seg-000000.ndjson.gz  gzip -t 失败: unexpected end of file
   seg-000001.ndjson.gz  gzip -t 失败: unexpected end of file

2.1 五条决定性证据(手工核对)

docker compose exec app bash -c '
D=/work/archive
ls -l $D/*.gz                        # 文件存在、大小非 0
gzip -t $D/seg-000000.ndjson.gz      # → unexpected end of file
xxd -l 16 $D/seg-000000.ndjson.gz    # → 1f8b0800000000000003...  有 gzip 魔数!
'

「文件不为空」≠「文件完整」——这是第一个认知陷阱。

再写个一次性脚本把「缺的到底是什么」坐实:

import gzip, struct, zlib
b = open("/work/archive/seg-000000.ndjson.gz","rb").read()
raw = b"".join(open("/data/input.ndjson","rb").readlines()[:39])
tail = struct.pack("<II", zlib.crc32(raw) & 0xffffffff, len(raw) & 0xffffffff)
print("坏文件总长:", len(b), " 末8字节:", b[-8:].hex())
print("期望8字节尾部:", tail.hex())
print("文件里是否有这8字节:", tail in b)
try: gzip.decompress(b)
except Exception as e: print(type(e).__name__, e)
print("补上8字节尾部后再解压:", gzip.decompress(b+tail).count(b"\n"), "行")
坏文件总长: 9045  末8字节: 3a20747275657d0a        ← 是原始数据的尾巴,不是 CRC32/ISIZE
期望8字节尾部: d2c0f33d46230000
文件里是否有这8字节: False
EOFError Compressed file ended before the end-of-stream marker was reached
补上8字节尾部后再解压: 39 行                        ← 坐实:只缺尾部 8 字节

2.2 哈希口径证据

import hashlib, json
P = b"audit-archive-v1\x00"
b = open("/work/archive/seg-000000.ndjson.gz","rb").read()
print("侧车 chained_sha256 :", json.load(open("/work/archive/seg-000000.raw.sha256"))["chained_sha256"][:16])
print("sha256(原始字节)     :", hashlib.sha256(P+raw).hexdigest()[:16])
print("sha256(落盘 gz 字节) :", hashlib.sha256(P+b).hexdigest()[:16])
print("sha256sum gz         :", hashlib.sha256(b).hexdigest()[:16])
侧车 chained_sha256 : 51d041698a6e0c4f
sha256(原始字节)     : 51d041698a6e0c4f   ← 和侧车一样:算的是「没落盘的那串」
sha256(落盘 gz 字节) : 79fbdaae51a1d62f   ← 契约要求的是这个
sha256sum gz         : fa4ceb1bb46013bc

2.3 阈值 off-by-one 证据

src = open("/data/input.ndjson","rb").readlines()
TH, n, c = 8192, 0, 0
for ln in src:
    c += 1; n += len(ln)
    if n >= TH + 3: break        # 现场 line_patience=3
print("首段行数:", c, "原始字节:", n, "超出:", n - TH)
首段行数: 39  首段原始字节: 9030  阈值: 8192  超出: 838 字节(最长一行 1074 字节)

三、根本原因

三个互相独立的缺陷,每一个都单独足以让归档不可用或不可信:

缺陷 1 —— gzip 尾部异步补,进程退出即丢

_hdr = struct.pack("<4sI2B", b"\x1f\x8b\x08", 0, 0, 3)   # 手写 10 字节头
_co = zlib.compressobj(self.compresslevel, zlib.DEFLATED, -15)
_body = _co.compress(raw_bytes) + _co.flush()             # raw deflate(无头无尾)
_wf.write(_hdr); _wf.write(_body)                         # ← 只写了「头 + 数据」

if self.async_flush:
    t = threading.Thread(target=self._lazy_async_tail, args=(gz_path, raw_bytes))
    t.daemon = True
    t.start()          # ★ 尾部交给 daemon 线程,主线程从不 join

_lazy_async_tail 里还有 time.sleep(0.3) 再补 8 字节。 主线程写完立刻继续、进程一退 daemon 线程一起消失 → 尾部永远丢。

一个 gzip 文件 = 头(10B) + 数据 + 尾(8B),少一段都算坏。

⚠️ 重要干扰项:gzip.open(...).write() 之后只 flush() 不 close() 在本环境里反而不会坏(Python 3.11 的 GzipFile.flush() 会写尾部)。 所以「照着网上‘记得 close’的说法去改」修不掉本题。 病根不是「忘了 close」,而是「尾部压根交给了一个不问结果的后台线程」。 必须用 gzip -t 实测,不能靠猜。

缺陷 2 —— 哈希口径 ≠ 落盘对象,且缺原始长度

raw_sha = sha256_hex(CHAIN_PREFIX + prev_chain + raw_bytes)   # ★ 算的是原始字节
# ...但落盘的是 gz 字节
json.dump({"seg":..., "chained_sha256": raw_sha, ...})        # 侧车里没有 raw_bytes

侧车字段只有 ['seg','format','chained_sha256','compresslevel','created']。两层后果:

  1. 重放侧永远验不出损坏 —— 它拿着「另一串字节」的哈希去对,--verify 恒 MISMATCH;
  2. 「解压后长度对不对」无从判起 —— 侧车不记 raw_bytes,审计对账没有抓手。

更隐蔽的是 verify() 自己也用错了口径(拿解压后的原始字节去对侧车里的值), 所以「自己验自己」必然对不上 —— 写盘口径和重放口径必须一起改。

缺陷 3 —— 阈值 off-by-one + 历史旋钮 + 检查点缺行数

consumed += len(raw_line)
pending.append(raw_line)               # ★ 先放行
pending_bytes += len(raw_line)
trigger = self.rollover_bytes
if segs == 0 and self.line_patience:   # ★ 首段阈值被旋钮抬高
    trigger = self.rollover_bytes + self.line_patience
if pending_bytes >= trigger:           # ★ 再判阈值 → 每段最多超「一行最长」的长度

收尾的 save_offset() 又只记 byte_off / seg_index,不记 lines → 回答不了「续传时跳过的行数 == 归档的行数吗」,续传重复/漏行不可判。


四、解决办法

修复 1:一步到位写完整 gzip + 原子落盘

blob = gzip.compress(raw_bytes, compresslevel=self.compresslevel)   # 头+数据+尾部 一次写完
tmp_gz = gz_path + ".tmp"
with open(tmp_gz, "wb") as f:
    f.write(blob); f.flush(); os.fsync(f.fileno())
os.replace(tmp_gz, gz_path)     # 任何时刻读到的都是完整分段

删掉 _lazy_async_tail() 与 threading;line_patience / async_flush 两个历史旋钮停用 (保留参数签名兼容调用方,但不再改变任何行为)。

为什么用 gzip.compress() 而不是 GzipFile? gzip.compress() 把「头 + 数据 + 尾部」一次性算出来,结构上就不存在「忘了写尾部」的空间。 而 GzipFile + flush() 在本环境「碰巧」能写尾部,属于依赖实现细节的侥幸。

修复 2:哈希口径统一到落盘对象,并补齐审计字段

chain = sha256_hex(CHAIN_PREFIX + prev_b + blob)     # blob == 真正落盘的字节
json.dump({
  "chained_sha256": chain,        # 重放侧只用归档目录里的文件就能复算
  "gz_sha256": sha256_hex(blob),  # 裸哈希,便于和 sha256sum 直接对照
  "raw_sha256": sha256_hex(raw_bytes),
  "prev_chained_sha256": prev_b.decode("ascii","replace"),
  "raw_bytes": len(raw_bytes),    # ★ 原始(未压缩)长度,审计对账用
  "gz_bytes": len(blob),
  "lines": len(lines),
  ... })

重放侧同步改对:

got = sha256_hex(CHAIN_PREFIX + prev + blob)   # 对落盘 gz 字节复算
want = sc["chained_sha256"]
len_ok = (int(sc["raw_bytes"]) == len(data))   # 用 raw_bytes 校验解压后长度

chain_of() 也顺手改正:侧车里的 hex 串就是哈希输入的 ASCII 部分,原样返回即可 (原来 if seg_index < 0: return b"\x00" * 0 是 b"" 的迷惑写法,等于没有哨兵值)。

修复 3:阈值「先判断再放行」+ 删旋钮 + 检查点记行数

for raw_line in f:
    # 本行若会让本段超阈值 → 先切段,再把它放进新段(而不是先放再判)
    if pending and pending_bytes + len(raw_line) > self.rollover_bytes:
        write_segment(...); pending, pending_bytes = [], 0
    pending.append(raw_line); pending_bytes += len(raw_line); consumed += len(raw_line)
    if pending_bytes >= self.rollover_bytes:      # 单行就 >= 阈值也要立刻切
        write_segment(...); pending, pending_bytes = [], 0
...
self.save_offset(consumed, next_seg, note="done")     # 收尾也落检查点(带 lines)
def save_offset(self, byte_off, seg_index, note=""):
    lines = self.count_lines_archived()     # 各段侧车 lines 之和
    json.dump({"byte_off":..., "seg_index":..., "lines": lines, ...})

修复前后对照

                      修复前                              修复后
seg-000000.ndjson.gz  9045B  gzip -t 失败             8140B  gzip -t OK
seg-000001.ndjson.gz  4784B  gzip -t 失败             5705B  gzip -t OK
侧车字段              5 个(缺 raw_bytes)             11 个(含 raw_bytes / lines)
offset.json           {byte_off, seg_index}           {byte_off, seg_index, lines:60, ...}
分段原始字节          [9030, 4769](9030 > 8192)     [8117, 5682](都 ≤ 8192)

五、验证

5.1 权威判定 ops/verify.sh:PASS=2 FAIL=8 → PASS=10 FAIL=0

A. 归档产物「下游能不能读」
  ✅ A1 2 个分段全部是合法 gzip(gzip -t 通过、解压非空)
  ✅ A2 全部分段解压后与上游逐段字节级对齐(2 段 / 60 行)
B. 侧车哈希 / 原始长度 / 落盘体积
  ✅ B1 侧车 chained_sha256 可由「上一段值 + 本段落盘 gz 字节」独立复算通过
  ✅ B2 侧车 raw_bytes 与解压后字节数一致
  ✅ B3 归档开销可控:13799 → 13845 字节(-0.3%,未超 1.10×)
C. 参数 / 边界 / 检查点
  ✅ C1 level0=13845 字节 > level9=4267 字节,各级别产物都能还原上游
  ✅ C2 边界三档(阈值前/恰好/阈值后)与对照实现逐字节一致,每段都 ≤ 阈值
  ✅ C3 检查点 lines=60 == 分段行数之和 == 上游 60 行
D. 断点续传
  ✅ D1 中断后 --resume 续跑,与一次性归档逐字节一致(不重复、不漏行)
E. 反向注入回归
  ✅ E1 注入「不写 gzip 尾部」的坏实现后产物全坏(2/2 段 gzip -t 失败、文件非空)

  通过 10 项 / 失败 0 项

5.2 独立第三方验证(不依赖判定脚本)

docker compose exec app bash -c '
# ① 标准工具可读
for p in /work/archive/seg-*.ndjson.gz; do gzip -t "$p" && gzip -dc "$p" | wc -l; done
# ② 拼起来 == 上游(逐字节)
cat /work/archive/seg-*.ndjson.gz | gzip -dc | cmp - /data/input.ndjson && echo 逐字节一致
'
seg-000000.ndjson.gz  gzip -t OK / 38 行 / 8117 字节
seg-000001.ndjson.gz  gzip -t OK / 22 行 / 5682 字节
逐字节一致 OK

侧车与检查点实测:

{"seg":0,"chained_sha256":"111440b7...","gz_sha256":"23e7c809...","raw_sha256":"b8c73129...",
 "prev_chained_sha256":"","raw_bytes":8117,"gz_bytes":8140,"lines":38,"compresslevel":0}
{"byte_off":13799,"seg_index":2,"lines":60,"note":"done"}

5.3 反向回归(证明判定不是「永远绿」)

verify.sh 的 E1 会把修复版注入回坏实现(gzip.compress → GzipFile+flush 不 close), 重跑后产物必须重新变红。实测命中 1 处、2/2 段 gzip -t 失败、文件非空 → 判定有判别力。


六、经验与避坑

  1. gzip -t 的 unexpected end of file ≠「文件是空的」。 文件有魔数 1f 8b、有大小,只是尾部 8 字节(CRC32 + ISIZE)不在。 一个 gzip 文件 = 头(10B) + 数据 + 尾(8B),少一段都算坏。 定位方法:xxd -l 16 看魔数 + 算出期望尾部再判 tail in blob。
  2. flush() 竟然会写尾部 —— 别靠回忆改代码。 Python 3.11 的 GzipFile.flush() 在部分形态下就会写尾部, 照网上「记得 close」的说法去改 修不掉本题。必须 gzip -t 实测。
  3. gzip.compress() > GzipFile。 前者把「头 + 数据 + 尾部」一次性算出来,结构上就不存在「忘了写尾部」的空间; 后者依赖「记得 flush/close」这个纪律,纪律会破。
  4. 别把 daemon 线程当成「收尾保障」。daemon=True 的含义是「主进程退出就杀」——它保证的是丢,不是完成。 任何「必须完成」的收尾都不能交给 daemon 线程。要么同步做,要么 join()。
  5. 哈希口径必须 == 落盘对象。 侧车存 chained_sha256,如果它算的字节和真正落盘的文件不是同一串, 重放侧就永远验不出损坏(它拿「另一串字节」的哈希去对)。 同时必须记原始长度 raw_bytes,否则「解压后长度对不对」无从判起。
  6. 写盘口径和重放口径要一起改。 我第一次只改了 write_segment(),verify.sh 卡在 PASS=9 FAIL=1, --verify 报 sha256 MISMATCH —— 因为重放侧还是老口径。 「自己验自己」对不上,往往是两边口径漂移,不是数据坏了。
  7. 阈值与落笔时机必须是同一个条件。 先 append 再判 >= 阈值,每段会多收一行、最多超出「最长一行」的长度; 正确写法是先判断「本行加进来会不会超」,超了就先切段。 实测首段 9030 字节 vs 阈值 8192,超了 838 字节。
  8. 「让数据看起来自然」的旋钮要敢删。ROLLOVER_LINE_PATIENCE 这类历史调优值本质是把阈值和落盘时机解耦; 留着它,边界检查永远过不了。
  9. 检查点要存「行数」,不能只存 offset。 只存 byte_off 回答不了「跳过的行数 == 归档的行数吗」—— 文件尾部可能是半行、也可能被截断。续传重复/漏行都是这么来的。
  10. 「压缩收益」是唯一一个坏掉也好看的指标。 问题态 -0.2%、修复后 -0.3%,基本没差(level 0 本来就只做容器封装)。 判断归档是否可用只有一个标准:标准 gzip 工具能不能读。
  11. 别把「改了参数后能开」当成修复。 级别 0~9 产出的都该是合法 gzip;「某些输入/某些级别能开」说明触发条件跟 耗时/时序有关(后台线程赶在进程退出前跑完了),不是参数问题。
  12. 清理「未用 import」会打断注入工具。 我删了 import io,结果 inject_bad.py 注入的坏实现用 io.BytesIO() → NameError → E1 报「注入后一个分段都没产出」。 修复方要保留注入/回归工具的依赖,否则会破坏判别力自证。
  13. 别只信「判定脚本给的工具」,也要信它的 bug 报告。 本轮 run_round.sh ⑥ 的 check_archive.sh 判据 2/3 修完仍红。 我把官方对照实现的产物喂给它,同样 PASS=2 FAIL=4 —— 说明是检查器自身有偏差:
    • 判据 2 口径冲突:拿 sha256sum <gz>(裸哈希)比侧车的 chained_sha256 (按契约是 sha256(前缀+上一段+本段gz)),任何合规实现都过不了 (对照实现同时写 chained_sha256 和 gz_sha256,正好说明这俩本来就该分开);
    • 判据 3 运算符优先级 bug:chunk = lines[pos:pos + cnt or 0] 解析成 lines[pos : ((pos+cnt) or 0)],配合滑动窗口起点 max(1, pos-3), seg0(pos=0)唯一位于 index 0 的正确位置被跳过,恒判「内容在上游找不到」。 本轮的权威判定是 ops/verify.sh(达标线 PASS=10 FAIL=0)。 遇到这种情况:别改给定工具去迁就,也别改自己的正确实现去迎合 bug, 记录下来、说清楚哪个才是权威口径。
  14. app/ 挂成 :ro 就改不动。 本 compose 特意挂成可写;看到 Read-only file system 先查 volumes 有没有 :ro。
  15. pkill -f <脚本名> 会杀掉自己的 exec 会话。 调试时用 pkill -f 匹配到了当前 shell 的命令行 → 会话自杀(exit 143)。用精确 PID 或 -x。

七、可复现命令(照抄即可)

git fetch origin && git checkout 2026-09-18-1600
cd scenario/2026-09-18-1600

docker compose up -d --build
docker compose exec app bash ./ops/run_all.sh     # 一键:复现→诊断→判定→修复→复验
docker compose down -v

自己动手修:

docker compose exec app bash
bash /ops/reproduce.sh      # 稳定复现 + 五条证据
bash /ops/why_broken.sh     # 只读诊断(含「不写尾部」最小演示)
bash /ops/verify.sh         # 问题态 PASS=2 FAIL=8
vim /app/audit_archive.py   # 按 §四 自己改(app/ 是挂载可写的)
bash /ops/verify.sh         # 目标 PASS=10 FAIL=0

禁止事项:改 docker-compose.yml / ops/env.sh / conf/archive_policy.env 把 GZIP_COMPRESSLEVEL 调大、改 ROLLOVER_BYTES、把 patience 改 0; 把「压缩收益」当正确性;换回明文/tar 绕过格式契约。


八、同类影响面(哪些场景要查)

  • 任何手写压缩容器格式(gzip/zip/gzip-raw)的代码:尾部校验和/长度是硬要求;
  • 任何把「必须完成」的收尾交给 daemon 线程 / 后台队列的写入路径;
  • 任何哈希口径与落盘对象不一致的审计链、去重、秒传(content-addressed storage);
  • 任何**「先写再判阈值」的分段/分片/分页**逻辑(每片超「一行/一条」的时代价);
  • 任何断点续传只存字节偏移、不存条数/行数的实现。

版权声明

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

分享这篇文章