HTTP Range 静默降级:分块/断点续传下载的文件损坏,但日志全绿(Content-Range end off-by-one + 客户端不校验响应)
日期:2026-09-18-0800 | 分支:
2026-09-18-0800| 第64题 环境:Docker(python:3.11-slim,仅标准库,无需特权) 技术栈/关键词:HTTP Range、206 Partial Content、Content-Range、Accept-Ranges、断点续传、resume、 分块下载、文件损坏、md5 不一致、静默降级、静默损坏、off-by-one、curl -r、wget -c、aria2 分片、bytes=N-、416 Requested Range Not Satisfiable、DOWNLOAD_RANGE_MODE、body[:length]截断凑长度
一、问题背景
一个文件分发平台:种子服务 seed_server.py 对外提供 /seed.bin 下载,
客户端 chunk_download.py 按 chunk-size 把文件切成 N 块,
每块发一个 Range: bytes=<start>-<end> 请求,落到 out.bin.part<i>,最后按序拼接。
运维反馈:
❌ 用户投诉:通过网页上传的安装包,下载下来「有时能用、有时装不上」
❌ 重试 N 次都一样,换网络、换机器还是坏
❌ 但是服务端日志、客户端 resume.log 全绿,一个错误都没有
现场确认过的真事实(用来缩小范围):
curl -I /seed.bin显示Accept-Ranges: bytes、Content-Length: 25000000、X-Seed-Md5与服务器上md5sum一致;- **单请求(不带 Range)**下载整份文件,
md5完全一致 —— 服务端文件本身是好的; - 只有分块 / 续传拼出来的文件
md5不一致,而且稳定复现; resume.log里每一块都写着成功:
part0 status=status=200 cr='' cl='25000000' bytes=8000000 sha=7cb349b5 ok
part1 status=status=200 cr='' cl='25000000' bytes=8000000 sha=7cb349b5 ok
part2 status=status=200 cr='' cl='25000000' bytes=8000000 sha=7cb349b5 ok
part3 status=status=200 cr='' cl='25000000' bytes=1000000 sha=fa44dcc8 ok
- 服务端日志里每一个
Range头都被打印出来了(请求确实收到了); - 有趣的是:前三块的
sha完全相同(7cb349b5)——第 0、1、2 块的字节内容其实是同一个东西; - 换
--chunk-size 1000000 / 5000000 / 8000000 / 12500000,全都坏。
第 6 条是关键线索:三块内容一模一样,说明每一块拿到的都是文件开头。
二、复现步骤
git fetch origin && git checkout 2026-09-18-0800
cd scenario/2026-09-18-0800
docker compose up -d --build # 首次约 30 秒(只需 Python 标准库)
docker compose exec app bash # 进容器
bash /ops/verify.sh # ① 问题态判定:PASS=3 FAIL=7
bash /ops/reproduce.sh # ② 稳定复现损坏(含逐块 diff)
bash /ops/why_corrupt.sh # ③ 只读诊断:Range / Content-Range 全景
① 判定脚本:PASS=3 FAIL=7
A. 服务端「宣告」与「实际行为」是否自洽(3 条)
✅ A1 HEAD 宣告 Accept-Ranges: bytes
❌ A2 Range=bytes=8000000- -> code=200 Content-Range=[空] end=空 实收=25000000
❌ A3 Range=bytes=0-99999999 -> code=200 Content-Range=[空] end=空 实收=25000000
B. 分块下载完整性(3 条)
❌ B1 分块下载 md5 不一致(得到 a545d89d76db1ab250d684dd0456743b,期望 c2814b5daa3425ac55d58b1e3d70bd84)
✅ B2 分块下载大小一致(25000000)
❌ B3 分块错位:BAD [1, 2, 3]
C. 失败必须可见(2 条)
❌ C1 文件已损坏(BAD [1, 2, 3]),但 resume.log 里 4 条 Range 分块记录 4 条全是「ok」= 静默损坏,零报错
✅ C2 缺陷版下载器已把静默降级判为失败(4/4 块被拒)
D. 断点续传与幂等(2 条)
❌ D1 断点续传结果不一致
❌ D2 chunk-size 这些档位结果不一致: 1000000 5000000 8000000 12500000
通过 3 项 / 失败 7 项
② 复现「日志全绿但文件坏」
① 单个完整请求(无 Range)
整份下载 md5 = c2814b5daa3425ac55d58b1e3d70bd84 ← 服务端文件好着呢
② 分块下载(缺陷版客户端):chunk-size=8000000
[downloader] part0 status=status=200 cr='' cl='25000000' bytes=8000000 sha=7cb349b5 ok
[downloader] part1 status=status=200 cr='' cl='25000000' bytes=8000000 sha=7cb349b5 ok
[downloader] part2 status=status=200 cr='' cl='25000000' bytes=8000000 sha=7cb349b5 ok
[downloader] part3 status=status=200 cr='' cl='25000000' bytes=1000000 sha=fa44dcc8 ok
③ 校验
分块拼接 md5 = a545d89d76db1ab250d684dd0456743b
种子文件 md5 = c2814b5daa3425ac55d58b1e3d70bd84
❌ md5 不一致 —— 复现成功:文件损坏
④ 每一块各差了多少?
第 0 块 [0:8000000] ✅ 一致
第 1 块 [8000000:16000000] ❌ 不一致 实际前16B=1042c83e... 期望前16B=d24ab727...
第 2 块 [16000000:24000000] ❌ 不一致 实际前16B=1042c83e... 期望前16B=79af402c...
第 3 块 [24000000:25000000] ❌ 不一致 实际前16B=1042c83e... 期望前16B=58b83146...
→ 共有 3 个分块内容错误
⑤ resume.log 里每一块都被记成「成功」了吗?
Range 分块记录行数: 4 其中含 ' ok' 的行数: 4
⚠️ 日志全绿,但文件是坏的 —— 这就是「静默损坏」
③ 诊断全景(why_corrupt.sh)
1) HEAD 头怎么说(注意 Accept-Ranges —— 它会骗你)
Accept-Ranges: bytes
X-Range-Mode: none
Content-Length: 25000000
2) 带 Range 的请求,响应到底是不是 206?
HTTP/1.1 200 OK ← ★ 不是 206
X-Range-Ignored: 1 ← ★ 服务端明说自己忽略了
Content-Length: 25000000 ← ★ 回的是全量
4) 只给 start 的 Range(bytes=N-)会被解析成什么区间?
'bytes=8000000-' -> start=8000000 end=25000000 length=17000001
Content-Range 会写成: bytes 8000000-25000000/25000000 ← ❌
(文件最后一个字节下标是 24999999)
5) 分块拼接的实际形状:第 0 块 vs 第 1 块前 32 字节
part0 00000000: 1042 c83e bbbe 6bf8 4bf7 ca3f 806d 549f
part1 00000000: 1042 c83e bbbe 6bf8 4bf7 ca3f 806d 549f ← 两行完全一样
三、根本原因
3.1 三层独立缺陷叠加
缺陷 1(服务端 parse_range):end off-by-one + 从不 clamp
m = re.match(r"bytes=(\d*)-(\d*)", hdr)
start = int(m.group(1)) if m.group(1) else 0
end = int(m.group(2)) if m.group(2) else total # ← ❌ 应该是 total - 1
# 而且显式给了 end 也要 clamp:end = min(end, total - 1)
Content-Range 的 end 是闭区间上界,合法上限是 size - 1。
「只给 start、不给 end」(bytes=N-,最常见的续传写法)时,兜底写成 total 就多算了一个不存在的字节。
更隐蔽的是:x[start:end] 的 end 越界时 Python 切片自动截断、不抛异常,
所以「用错的 end 去切片」看起来完全正常 —— 但 Content-Range 头里报的那个 end 是错的,头与流不自洽。
缺陷 2(服务端 do_GET):收到 Range 却静默回 200 + 全量
if RANGE_MODE_DEFAULT.lower() in ("none", "off", "0", "false"):
# 忽略 Range 头,返回整个文件 + 200
conf/range_policy.default 里的默认档被写成了 DOWNLOAD_RANGE_MODE=none,
docker-compose.yml 的 env 也是 none。
RFC 7233 确实允许「不支持 Range 的服务器忽略 Range 头返回 200 全量」,
但客户端必须能识别这一点。只要客户端不管状态码就写盘:
- 第 0 块拿到的是文件开头(碰巧正确);
- 第 1、2… 块拿到的还是文件开头,却被当成第 1、2… 块原样写进 part 文件。
「宣告支持 Range」+「实际降级」= 最毒的静默损坏。
缺陷 3(客户端 fetch_part):不校验响应,截断凑长度
data = body[:length]
ok = (len(data) == length) # ← 只要长度够就算成功
服务端回 200 且 Content-Length: 25000000,body[:8000000] 恰好 8000000 字节,
长度检查必然通过 → 写盘 → 记 ok。全程零校验、零报错。
3.2 为什么日志会全绿?
客户端唯一的判据是「读到的字节数够不够」,
而 body[:length] 这种「截断凑长度」正是让失败看起来像成功的典型写法。
缺的三件事一个都不能少:
status == 206(不是 200);Content-Range与请求区间逐字段吻合;- 实收字节数精确等于期望长度。
3.3 三个「验证者」都失效了
| 失效点 | 说了什么 | 漏了什么 |
|---|---|---|
Accept-Ranges: bytes | 「我支持 Range」 | 是能力宣告,不保证本次响应是 206 |
resume.log 的 ok | 「每块都成功」 | 只比对长度,没比对内容归属 |
Content-Length: 25000000 | 「返回 25000000 字节」 | 客户端只读自己那一段,多的部分被静默丢掉 |
四、解决办法
4.0 先认清环境约束(否则修不动)
docker compose exec app bash -c 'mount | grep -E "/app|/conf"'
# git-clone-yyds on /app type overlay (ro,relatime,...) ← ro 只读!
# git-clone-yyds on /conf type overlay (ro,relatime,...) ← ro 只读!
app/、ops/、conf/ 是 :ro 只读挂载,容器内原地改源码必然失败:
OSError: [Errno 30] Read-only file system: '/app/seed_server.py'
正确做法:修复写在「宿主机源码」上,再 docker compose up -d --build 让只读挂载看到新内容。
(宿主机源码正是要被 git 提交的东西,所以这条路更正确。)
4.1 服务端:三条一起修
# ✅ 修复 1:闭区间上界必须 clamp 到 total - 1,两个分支都不能漏
if end_raw:
end = min(int(end_raw), total - 1) # 显式 overlong 也要收敛
else:
end = total - 1 # bytes=N- 兜底到最后一个字节
return start, end
# ✅ 修复 2:Content-Range 报「实际发送的区间」,与 Content-Length 严格自洽
self.send_header("Content-Range", "bytes %d-%d/%d" % (start, end_clamped, size))
# ✅ 修复 3:收到 Range 绝不静默回 200 全量;宣告了就必须兑现
if RANGE_MODE_DEFAULT.lower() in ("none", "off", "0", "false"):
self._send_common(500, [("Content-Type", "text/plain")],
b"refusing to ignore Range header silently")
return
# ✅ 修复 4:把「静默降级」变成启动即失败(部署错了就立刻炸出来,而不是等用户投诉)
RANGE_MODE_DEFAULT = os.environ.get(RANGE_MODE_ENV, "full") or "full"
if RANGE_MODE_DEFAULT.lower() in ("none", "off", "0", "false"):
raise SystemExit(
"[seed] FATAL: DOWNLOAD_RANGE_MODE=%r 不允许部署:"
"服务已宣告 Accept-Ranges: bytes,忽略 Range 头会造成客户端静默损坏"
% RANGE_MODE_DEFAULT)
4.2 客户端:三件事缺一不可,否则拒绝写盘
CONTENT_RANGE_RE = re.compile(r"^\s*bytes\s+(\d+)-(\d+)/(\d+|\*)\s*$", re.IGNORECASE)
expect_end = start + length - 1
m = CONTENT_RANGE_RE.match(content_range or "")
cr_ok = bool(m) and (int(m.group(1)), int(m.group(2))) == (start, expect_end)
if status != 206 or not cr_ok or len(body) != length:
self._log(idx, "REJECT status=%s cr=%r expect=bytes %d-%d/206 got=%d"
% (status, content_range, start, expect_end, len(body)), 0, "-")
self.results[idx] = {"status": status, "len": len(body), "sha": "-", "ok": False,
"content_range": content_range}
return # ★ 直接 return,绝不写盘
data = body # 不再 body[:length] 截断凑长度
配套:任一块失败 → 中止、不 merge、不产出 out.bin、退出码 3。
4.3 配置层
conf/range_policy.default : DOWNLOAD_RANGE_MODE=none → full
docker-compose.yml : DOWNLOAD_RANGE_MODE: "none" → "full"
⚠️ 只改配置档
full不算修完。验证过:单独用full起服务,缺陷 1 立刻原形毕露 ——Range: bytes=8000000- → 206 Content-Range: bytes 8000000-25000000/25000000 Content-Length: 17000000 ← 头声明 17000001,流里 17000000,不自洽 Range: bytes=0-99999999 → 206 Content-Range: bytes 0-99999999/25000000 ← end 越界,从未 clamp所以必须同时修 5 处代码 + 2 处配置。
五、验证
cd scenario/2026-09-18-0800
bash ops/apply_fix.sh # 幂等打补丁
docker compose up -d --build # 让只读挂载看到新源码
docker compose exec app bash /ops/verify.sh
5.1 判定:PASS=10 FAIL=0
A. 服务端「宣告」与「实际行为」是否自洽(3 条)
✅ A1 HEAD 宣告 Accept-Ranges: bytes
✅ A2 Range=bytes=8000000- -> 206 + Content-Range=[8000000-24999999/25000000] + 实收 17000000 字节(end=size-1 正确)
✅ A3 Range=bytes=0-99999999 -> 206 + Content-Range=[0-24999999/25000000] + 实收 25000000 字节(已 clamp)
B. 分块下载完整性(3 条)
✅ B1 分块下载 md5 一致(c2814b5daa3425ac55d58b1e3d70bd84)
✅ B2 分块下载大小一致(25000000)
✅ B3 每个分块字节级全等
C. 失败必须可见(2 条)
✅ C1 下载结果正确(Range 分块日志 4 行 / 4 行成功)
✅ C2 下载正确,无需拒绝任何分块(日志 4 行)
D. 断点续传与幂等(2 条)
✅ D1 断点续传(跳过已存在 part)结果一致
✅ D2 chunk-size ∈ {1M,5M,8M,12.5M} 结果全部一致
通过 10 项 / 失败 0 项
5.2 边界 Range 全量回归
bytes=8000000- -> 206 Content-Range: bytes 8000000-24999999/25000000
bytes=0- -> 206 Content-Range: bytes 0-24999999/25000000
bytes=0-99999999 -> 206 Content-Range: bytes 0-24999999/25000000 ← clamp 成功
bytes=24999999- -> 206 Content-Range: bytes 24999999-24999999/25000000 ← 单字节末位
bytes=24999998-24999999 -> 206 Content-Range: bytes 24999998-24999999/25000000
bytes=99999999- -> 416 Content-Range: bytes */25000000 ← 超范围 start 正确报 416
5.3 反向验证(本题灵魂):坏的时候必须看得见
只让结果对不够,必须证明坏的时候不会静默。把服务端换回缺陷版、客户端保持严格:
[downloader] part0 REJECT status!=206(服务端忽略 Range,返回了全量): status=200 cr='' expect=bytes 0-7999999/206 got=25000000 bytes
[downloader] part1 REJECT status!=206(服务端忽略 Range,返回了全量): status=200 cr='' expect=bytes 8000000-15999999/206 got=25000000 bytes
[downloader] part2 REJECT status!=206(服务端忽略 Range,返回了全量): status=200 cr='' expect=bytes 16000000-23999999/206 got=25000000 bytes
[downloader] part3 REJECT status!=206(服务端忽略 Range,返回了全量): status=200 cr='' expect=bytes 24000000-24999999/206 got=25000000 bytes
[downloader] ❌ 4/4 块校验失败 [0, 1, 2, 3] —— 中止,不生成 /work/downloads/e1.bin
[downloader] ❌ 下载失败(4 个块被拒)—— 退出码 3
--- 生成的 out.bin: 不存在 ---
4/4 块被拒、不产出文件、退出码 3。静默损坏通道被彻底关闭。
5.4 非法档位 fail-fast
$ env DOWNLOAD_RANGE_MODE=none python3 seed_server.py --port 8092 --seed-dir /data
[seed] FATAL: DOWNLOAD_RANGE_MODE='none' 不允许部署:服务已宣告 Accept-Ranges: bytes,
忽略 Range 头会造成客户端静默损坏
退出码=1
5.5 一键闭环(两种运行侧都跑到 10/0)
宿主机: [run_all] 运行侧 = 宿主机 → 1. PASS=3 FAIL=7 ;6. PASS=10 FAIL=0 ;退出码=0
容器内: [run_all] 运行侧 = 容器内 → 1. PASS=3 FAIL=7 ;6. PASS=10 FAIL=0 ;退出码=0
六、经验与避坑
6.1 可复用的排查思路
- 先分清「宣告」与「本次响应」。
Accept-Ranges: bytes是能力宣告,它不保证这一次返回的是 206。 看到Accept-Ranges就以为「Range 肯定生效」是本题最大的坑。 - 盯
Content-Range的end字段。 它是闭区间上界,必须 ≤size - 1。 最危险的写法是「只给 start、不给 end」(bytes=N-),兜底值错一个字节,整条语义就错了。 - 警惕「多个分块内容相同」。
如果
part1、part2的 sha 和前 8 位和part0一样,说明服务端把每个请求都当成了「文件开头」—— 这是「Range 被忽略、回 200 全量」的定义式指纹。 - 别只看
len(data) == expected。body[:length]这种「截断凑长度」是典型的「让失败看起来像成功」。 要校验的是三件事:status == 206、Content-Range与请求区间逐字段吻合、实收字节数精确相等。 - Python 切片越界不报错,所以「用错的 end 去切片」看起来完全正常 ——
但头里报的那个错
end会让标准客户端判响应非法,或污染它计算的 total。 头与流必须自洽。 - 「配置项污染」很容易被放过。
DOWNLOAD_RANGE_MODE=none看起来像「部署配置,本来就这样」。 正确的处理不是「改成 full 就收工」,而是让代码拒绝这种非法状态(启动即失败)。 而且光改full仍会失败(缺陷 1 还在),要一起修。 - 别只修一个分支。
end的两条路径(显式给了 end / 只给 start)都要 clamp, 只修一条会出现「某些客户端好了、某些还坏」的假性修复。
6.2 本次踩到的 5 个环境坑
| # | 坑 | 现象 | 解法 |
|---|---|---|---|
| A | app/ops/conf 是 :ro 只读挂载 | 容器内打补丁 OSError: [Errno 30] Read-only file system | 在宿主机源码上打补丁,再 docker compose up -d --build |
| B | pkill -f "seed_server.py" 会话自杀 | exec 退出码 143,命令莫名其妙中断 | 用 ps -eo pid,args | grep "[s]eed_server.py" | awk '{print $1}' 精确取 PID 再 kill |
| C | 宿主机经 publish 端口拉大文件极慢 | 25MB 拉 30~60s 且 stall 在 24276992 字节;容器内 <1s | 验证脚本必须在容器内跑,宿主机只用来探 /healthz |
| D | why_corrupt.sh 里 $(dirname "脚本") 占位符没替换 | 从别处调用报 bash: ./start_service.sh: No such file or directory | 改成 $(dirname "$0") |
| E | 官方 apply_fix.sh 不幂等、不兼容只读挂载 | 第二次跑 AssertionError: rangeless-stub anchor not found;且引用了未定义的 CONTENT_RANGE_RE 导致 NameError;没改 compose env 会让 fail-fast 后的容器起不来 | 重写为「语义特征判幂等 + 自动识别可写侧 + 补齐正则常量与 env 补丁」 |
6.3 检测「静默损坏」的通用套路
# 1) 服务端文件本身好不好?(排除源文件问题)
curl -s $URL -o /tmp/whole && md5sum /tmp/whole
# 2) 单个 Range 请求到底返回什么?(status / Content-Range / Content-Length 三者一起看)
curl -s -D - -o /dev/null -H "Range: bytes=8000000-" $URL | grep -iE '^HTTP|content-range|content-length'
# 3) 只给 start 的 Range 解析出的 end 是否 <= size-1 ?
# 4) 各分块的 sha 是否互不相同?(相同 = 每块都是文件开头)
# 5) 逐块字节级比对,定位到「第几块错」
6.4 同类影响面
任何一个「端点声明支持 Range,但实际可能返回全量」的下载链路都可能中招:
wget -c 断点续传、curl -r、aria2c -x N 多线程分片、
云存储分片下载 SDK、CDN 回源、HLS/DASH 的分片拉取、大模型/数据集分块下载。
统一检查三件事:宣告是否兑现、Content-Range 是否自洽、客户端是否拒绝静默降级。
6.5 一句话带走
「日志全绿 + 文件坏了」= 一定有某个校验把「长度对」当成了「内容对」。 分布式/传输场景里,收到的东西是不是我要的那一段,永远要独立验证, 不能只看「收到了多少」。
