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

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 全绿,一个错误都没有

现场确认过的真事实(用来缩小范围):

  1. curl -I /seed.bin 显示 Accept-Ranges: bytes、Content-Length: 25000000、X-Seed-Md5 与服务器上 md5sum 一致;
  2. **单请求(不带 Range)**下载整份文件,md5 完全一致 —— 服务端文件本身是好的;
  3. 只有分块 / 续传拼出来的文件 md5 不一致,而且稳定复现;
  4. 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
  1. 服务端日志里每一个 Range 头都被打印出来了(请求确实收到了);
  2. 有趣的是:前三块的 sha 完全相同(7cb349b5)——第 0、1、2 块的字节内容其实是同一个东西;
  3. 换 --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] 这种「截断凑长度」正是让失败看起来像成功的典型写法。 缺的三件事一个都不能少:

  1. status == 206(不是 200);
  2. Content-Range 与请求区间逐字段吻合;
  3. 实收字节数精确等于期望长度。

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 可复用的排查思路

  1. 先分清「宣告」与「本次响应」。Accept-Ranges: bytes 是能力宣告,它不保证这一次返回的是 206。 看到 Accept-Ranges 就以为「Range 肯定生效」是本题最大的坑。
  2. 盯 Content-Range 的 end 字段。 它是闭区间上界,必须 ≤ size - 1。 最危险的写法是「只给 start、不给 end」(bytes=N-),兜底值错一个字节,整条语义就错了。
  3. 警惕「多个分块内容相同」。 如果 part1、part2 的 sha 和前 8 位和 part0 一样,说明服务端把每个请求都当成了「文件开头」—— 这是「Range 被忽略、回 200 全量」的定义式指纹。
  4. 别只看 len(data) == expected。body[:length] 这种「截断凑长度」是典型的「让失败看起来像成功」。 要校验的是三件事:status == 206、Content-Range 与请求区间逐字段吻合、实收字节数精确相等。
  5. Python 切片越界不报错,所以「用错的 end 去切片」看起来完全正常 —— 但头里报的那个错 end 会让标准客户端判响应非法,或污染它计算的 total。 头与流必须自洽。
  6. 「配置项污染」很容易被放过。DOWNLOAD_RANGE_MODE=none 看起来像「部署配置,本来就这样」。 正确的处理不是「改成 full 就收工」,而是让代码拒绝这种非法状态(启动即失败)。 而且光改 full 仍会失败(缺陷 1 还在),要一起修。
  7. 别只修一个分支。end 的两条路径(显式给了 end / 只给 start)都要 clamp, 只修一条会出现「某些客户端好了、某些还坏」的假性修复。

6.2 本次踩到的 5 个环境坑

#坑现象解法
Aapp/ops/conf 是 :ro 只读挂载容器内打补丁 OSError: [Errno 30] Read-only file system在宿主机源码上打补丁,再 docker compose up -d --build
Bpkill -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
Dwhy_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 一句话带走

「日志全绿 + 文件坏了」= 一定有某个校验把「长度对」当成了「内容对」。 分布式/传输场景里,收到的东西是不是我要的那一段,永远要独立验证, 不能只看「收到了多少」。

版权声明

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

分享这篇文章