高防CDN 源站被 Range: bytes=0-0 打穿:Nginx slice 分片回源落地、cache key 漏 $slice_range 引发的文件错乱,与 max_ranges 的兜底价值
事故现场:不是 DDoS,是 206
上周三凌晨,一个下载类客户的源站(Apache 2.4 + PHP-FPM)被打到不可用。奇怪的是高防节点侧的入向带宽只有 2.1Gbps,远没到清洗阈值,CC 防护也没触发告警——攻击流量看起来“很温和”。
源站上看到的是另一幅画面:
topload 冲到 180,全是 httpd 的 D 状态ss -s里 Apache 的 ESTAB 只有 800 多,但是pidstat -d 1显示每个 httpd 进程每秒读 40MB 以上- access log 刷屏:
"GET /pkg/app-2.3.1.tar.gz HTTP/1.1" 206 1048576,而且同一个 URI 每秒被拉几十次 tcpdump抓到大量请求头里写着Range: bytes=0-0、bytes=0-1
结论很清晰:攻击者打的是 Range 碎片请求。每个请求只索要 1 个字节,但 Apache 送达 206 之前必须 open + stat + 定位文件,大文件在 page cache 冷的时候还要真的读盘。请求成本 O(1) 个字节,服务端成本是一次完整的文件打开与 seek,这是典型的放大器。更狠的是单包里塞多段:
Range: bytes=0-0,0-1,0-2,0-3,...,0-999
一个 1KB 的 TCP 包,Apache 要解析出一千个 range 区间、做一千次区间合并判断,再拼 multipart/byteranges 响应。几十个并发就能把 CPU 吃到 100%。Nginx 侧默认 max_ranges 是 0(不限制),这个口子是敞开的。
第一刀:边缘封住多段 Range
先止血,别急着上缓存架构。max_ranges 从 Nginx 1.1.2 就有,但绝大多数站点没写。行为是:请求中的 range 段数超过设定值时,Nginx 直接忽略 Range 头,返回 200 全量——注意这是回全量,不是 416,流量上会有一波脉冲,所以兜底值别设成 1 就完事,得配合缓存。
# 高防CDN 边缘默认 server 块
server {
listen 443 ssl http2;
server_name cdn.example.com;
# 多段 Range 一律降级为单段,超过直接忽略
max_ranges 2;
# 碎片请求单独限速,桶要小、速率要低
limit_req_zone $binary_remote_addr zone=rangeburst:20m rate=8r/s;
location / {
limit_req zone=rangeburst burst=16 nodelay;
proxy_pass http://origin_pool;
}
}
但 max_ranges 2 挡不住 bytes=0-0 这种合法单段请求。bytes=0-0 是 RFC 允许的,视频拖拽、断点续传都会用。所以真正要解决的是回源放大:让边缘把“任意字节区间”的请求,统一收敛成固定大小的分片去源站取,取回来缓存,后续同片的任意区间都命中缓存。
第二刀:slice 模块做定长分片回源
Nginx 的 slice 模块(1.9.8+)干的就是这件事:客户端要 bytes=1024-2047,你配了 slice 1m,Nginx 内部把上游请求改写为 Range: bytes=0-1048575,取回整个 1MB 分片存 cache,再从里面切出客户端要的那 1KB 返回。分片大小是 2 的幂,Nginx 会向下对齐到 block 边界。
proxy_cache_path /data/cdn_cache levels=1:2
keys_zone=slice_cache:128m
max_size=400g
inactive=7d
use_temp_path=off;
server {
listen 443 ssl http2;
server_name cdn.example.com;
location /pkg/ {
# 分片粒度:1m 是 CPU / 回源放大的平衡点,别超过 4m
slice 1m;
proxy_cache slice_cache;
# 关键:$slice_range 必须进 key,否则不同分片互相覆盖
proxy_cache_key "$scheme$host$uri$is_args$args|$slice_range";
proxy_cache_valid 200 206 12h;
proxy_cache_valid 404 1m;
# 上游请求头改写
proxy_set_header Range $slice_range;
proxy_set_header If-Range "";
# 源站不支持 Range 时,强制 Nginx 自己切
proxy_force_ranges on;
# 热点分片合并回源
proxy_cache_lock on;
proxy_cache_lock_timeout 8s;
proxy_cache_lock_age 12s;
# slice 与 gzip 不兼容,这个 location 必须关压缩
gzip off;
add_header X-Slice $slice_range always;
add_header X-Cache $upstream_cache_status always;
proxy_pass http://origin_pool;
}
}
这套配置上线后,源站的 206 响应数从每秒 4 万降到每秒 200 以内,读放大直接消失。下面是几个必须踩过才知道的点。
坑一:cache key 漏了 $slice_range,文件内容会串
这是最阴的一个。slice 模块会为每个分片生成不同的 $slice_range,但如果 proxy_cache_key 里没带上它,所有分片共用一个 key,后写覆盖先写。表现是:客户端下到 60% 之后内容错乱,md5sum 对不上,但 Nginx 日志全是 HIT,看起来一切正常。
验证方法很简单,绕开浏览器直接分段拉:
# 边缘节点上执行,对比整包与分段拼接的 MD5
curl -s -o full.bin https://cdn.example.com/pkg/app.tar.gz
curl -s -o p0.bin -r 0-1048575 https://cdn.example.com/pkg/app.tar.gz
curl -s -o p1.bin -r 1048576-2097151 https://cdn.example.com/pkg/app.tar.gz
cat p0.bin p1.bin > part.bin
md5sum full.bin part.bin
两个值不一样,第一个怀疑对象就是 cache key。另外可以在响应头里打印 $slice_range,如果同一 URI 的不同分段请求返回的 X-Slice 值相同或者是空,说明 slice 没生效或 key 写漏了。
坑二:slice 和 gzip 是互斥的
官方文档一句话带过“incompatible with gzip”,实际后果是:一旦在 slice 的 location 里开了 gzip on,上游返回的压缩字节流和原文件偏移量对不上,bytes=1048576- 切出来的是压缩流中间某一段,客户端解压直接报 unexpected end of file,或者拿到一堆乱码。所以在 slice 的 location 里显式 gzip off,同时向上游清掉 Accept-Encoding:
proxy_set_header Accept-Encoding "";
要压缩就在边缘做后置压缩(比如对已完整缓存的小文件另起一个 location 走 brotli),别和分片混在一个 location 里。
坑三:源站忽略 Range 时,proxy_force_ranges 不是可选项
Apache 的 mod_headers 或某些 PHP 动态输出会剥掉 Accept-Ranges,上游直接回 200 全量。slice 拿到一个 200 响应,会把它当作“整个文件”塞进当前分片的 key 里,后续所有分片请求全部命中这个错误条目。1.15.7 之后用 proxy_force_ranges on 可以让 Nginx 不管上游支不支持,自己按 Range 语义处理响应
顺带,源站 Apache 侧必须显式允许 Range,否则整个链路白搭:
<Directory /data/pkg>
# 默认就是 on,但有的加固模板会关掉
Header set Accept-Ranges bytes
# 大文件场景关掉 mmap,避免文件被替换时 SIGBUS
EnableMMAP Off
EnableSendfile On
</Directory>
# 源站不再处理多段,直接交给边缘
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_RANGE} ^bytes=[0-9]+-,[0-9]+-
RewriteRule .* - [R=416,L]
</IfModule>
Apache 的 EnableMMAP Off 在 CDN 源站是标配。源站在发版时 rsync 覆盖大文件,mmap 映射的旧 inode 被 truncate,worker 进程收到 SIGBUS 直接崩,日志里只留一行 child pid xxx exit signal Bus error,排查起来非常费劲。
容量账:缓存目录和句柄要提前算
slice 之后 cache 文件数会暴增。一个 2GB 的安装包按 1m 切片,就是 2048 个文件,加上 levels=1:2 的三级目录,inode 消耗要提前算:
# 假设日回源 500GB,切片后落盘约 3 倍(热点分散 + 尾部碎片)
# 3 * 500GB / 1MB ≈ 150 万个 inode
df -i /data
# 边缘 worker 句柄
worker_rlimit_nofile 200000;
# 缓存热点文件元数据,减少 stat
open_file_cache max=200000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors off;
我们这次用的是轻云互联的高防节点,清洗侧没问题,压力全压在边缘本地盘上——所以缓存目录务必放 NVMe,机械盘在 1m 切片 + 高并发随机读的场景下 IOPS 直接见底,iostat -x 1 里 await 冲到 200ms 以上,cache miss 会进一步放大回源。另外注意 open_file_cache_errors off,默认 on 时缓存的 404 会让后续合法请求也被挡住一小段时间,并发下发版期很容易踩。
排错工具箱
这套东西出问题时的定位顺序,基本都是固定的:
# 1. 抓边缘发往源站的真实请求头,确认 Range 是否被改写
tcpdump -i eth0 -A -s0 'tcp port 80 and (tcp[((tcp[12]::4)*4):4] = 0x47455420)'
# 2. 看 worker 在干什么,是不是卡在 open/read
strace -f -p $(pgrep -n nginx) -e trace=openat,pread64,sendto -c
# 3. 源站侧实时统计 206 与多段 Range 比例
tail -F /var/log/httpd/access_log | awk '$9==206' | wc -l
# 4. cache 命中率,slice 模式下 206 的 HIT 才是有效命中
awk '{print $NF}' /data/logs/cdn.log | sort | uniq -c
# 5. 单分片是否被正确缓存
curl -sI -r 0-1023 https://cdn.example.com/pkg/app.tar.gz | grep -i x-cache
最后补一个容易忽略的:proxy_cache_lock 在 slice 模式下的锁粒度是每个分片一把锁,不是每个文件。热点安装包的首个分片会成为串行瓶颈,P99 会有一段抬升。实测把 proxy_cache_lock_age 从默认 5s 提到 12s 能明显减少“锁提前释放导致二次回源”的抖动,但如果你的缓存盘很慢,锁等待会堆积成超时,这时候要优先解决磁盘,而不是调锁参数。
小结
高防 CDN 的抗打能力,很多时候不取决于清洗中心能扛多少 Gbps,而取决于边缘有没有把“廉价请求 → 昂贵回源”这条放大链路掐断。Range 只是其中一种,思路是通用的:把客户端的任意粒度请求,收敛成服务端可控的固定粒度,再让缓存吃掉重复。max_ranges 兜底、slice 收敛、proxy_cache_lock 合并、源站 Apache 剥掉多段与 mmap,四步缺一个都可能被绕过。