日志归档 .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 事件), 归档服务负责「分段 + 压缩落盘」,下游(审计导出 / 大数据导入 / 日志检索)直接读归档目录。
归档要求两条同时成立:
- 逐字节保真:归档里必须是上游的原始字节(要做 sha256 审计与链式校验);
- 落盘要省磁盘:落盘前做 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
❌ 但归档服务自己的日志全绿,一条报错都没有
❌ 而且「压缩收益」报表看起来还挺好看
现场事实(都真、别重复验证):
ls -l看归档目录,*.ndjson.gz文件存在、大小不为 0;- 每个分段
gzip -t都报同一句unexpected end of file; - 归档服务日志每条都写着成功,无任何异常;
- 手工补上 8 字节尾部就能解开 —— 文件其实是「gzip 头 + raw deflate」,缺的正是尾部 CRC32+ISIZE;
--compresslevel 0是现场值;改成9产出的文件同样打不开;- 纯 ASCII 小输入经常能正常解开,含多字节 UTF-8 长行、跑久一点就稳定失败 —— 曾被误判成「上游数据编码不干净」;
- 侧车文件里没有原始字节长度,只有一串 hex,拿它去
sha256sum seg-*.ndjson.gz对不上; offset.json只有byte_off/seg_index,没有行数;- 阈值写的是
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']。两层后果:
- 重放侧永远验不出损坏 —— 它拿着「另一串字节」的哈希去对,
--verify恒MISMATCH; - 「解压后长度对不对」无从判起 —— 侧车不记
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 失败、文件非空 → 判定有判别力。
六、经验与避坑
gzip -t的unexpected end of file≠「文件是空的」。 文件有魔数1f 8b、有大小,只是尾部 8 字节(CRC32 + ISIZE)不在。 一个 gzip 文件 =头(10B) + 数据 + 尾(8B),少一段都算坏。 定位方法:xxd -l 16看魔数 + 算出期望尾部再判tail in blob。flush()竟然会写尾部 —— 别靠回忆改代码。 Python 3.11 的GzipFile.flush()在部分形态下就会写尾部, 照网上「记得 close」的说法去改 修不掉本题。必须gzip -t实测。gzip.compress()>GzipFile。 前者把「头 + 数据 + 尾部」一次性算出来,结构上就不存在「忘了写尾部」的空间; 后者依赖「记得 flush/close」这个纪律,纪律会破。- 别把 daemon 线程当成「收尾保障」。
daemon=True的含义是「主进程退出就杀」——它保证的是丢,不是完成。 任何「必须完成」的收尾都不能交给 daemon 线程。要么同步做,要么join()。 - 哈希口径必须 == 落盘对象。
侧车存
chained_sha256,如果它算的字节和真正落盘的文件不是同一串, 重放侧就永远验不出损坏(它拿「另一串字节」的哈希去对)。 同时必须记原始长度raw_bytes,否则「解压后长度对不对」无从判起。 - 写盘口径和重放口径要一起改。
我第一次只改了
write_segment(),verify.sh卡在PASS=9 FAIL=1,--verify报sha256 MISMATCH—— 因为重放侧还是老口径。 「自己验自己」对不上,往往是两边口径漂移,不是数据坏了。 - 阈值与落笔时机必须是同一个条件。
先
append再判>= 阈值,每段会多收一行、最多超出「最长一行」的长度; 正确写法是先判断「本行加进来会不会超」,超了就先切段。 实测首段 9030 字节 vs 阈值 8192,超了 838 字节。 - 「让数据看起来自然」的旋钮要敢删。
ROLLOVER_LINE_PATIENCE这类历史调优值本质是把阈值和落盘时机解耦; 留着它,边界检查永远过不了。 - 检查点要存「行数」,不能只存 offset。
只存
byte_off回答不了「跳过的行数 == 归档的行数吗」—— 文件尾部可能是半行、也可能被截断。续传重复/漏行都是这么来的。 - 「压缩收益」是唯一一个坏掉也好看的指标。
问题态
-0.2%、修复后-0.3%,基本没差(level 0 本来就只做容器封装)。 判断归档是否可用只有一个标准:标准 gzip 工具能不能读。 - 别把「改了参数后能开」当成修复。 级别 0~9 产出的都该是合法 gzip;「某些输入/某些级别能开」说明触发条件跟 耗时/时序有关(后台线程赶在进程退出前跑完了),不是参数问题。
- 清理「未用 import」会打断注入工具。
我删了
import io,结果inject_bad.py注入的坏实现用io.BytesIO()→NameError→ E1 报「注入后一个分段都没产出」。 修复方要保留注入/回归工具的依赖,否则会破坏判别力自证。 - 别只信「判定脚本给的工具」,也要信它的 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, 记录下来、说清楚哪个才是权威口径。
- 判据 2 口径冲突:拿
app/挂成:ro就改不动。 本 compose 特意挂成可写;看到Read-only file system先查 volumes 有没有:ro。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);
- 任何**「先写再判阈值」的分段/分片/分页**逻辑(每片超「一行/一条」的时代价);
- 任何断点续传只存字节偏移、不存条数/行数的实现。
