ARCHIVE NO. 001
内网镜像站反向代理整理:缓存、Range 与大文件回源
背景
内网镜像站最初的定位很朴素:给隔离区里的几十台机器提供一个 apt/yum 的
就近上游,省掉每次升级都要走一遍出口防火墙。上线时用的是”一个 location、
一条 proxy_pass、一份默认缓存配置”的最小可用版本,跑了半年之后开始暴露问题。
当前承载的源与缓存策略如下,这份表格同时是值班手册里的一页:
| 源 | 上游 | 缓存有效期 | 单对象上限 | 同步周期 |
|---|---|---|---|---|
| Ubuntu APT | 官方 archive(经出口代理) | 6 小时 | 不限制 | 每日 03:20 |
| CentOS Stream | 上游 rsync 镜像 | 12 小时 | 不限制 | 每日 04:00 |
| PyPI | 清华 TUNA | 24 小时 | 512 MB | 每周日 02:00 |
| Docker Registry | 上游 Harbor | 1 小时 | 不限制 | 实时 |
| 内网 Yum 源 | 自建仓库机 | 30 分钟 | 不限制 | 每小时 |
运维约定:镜像站的缓存目录不做备份。缓存是可再生的派生物,不是数据。 真正需要备份的是 nginx 配置与各源的同步脚本,二者都在版本库里。 这条约定是上一次磁盘故障时定的——当时有人试图从快照里恢复一份 400 GB 的 缓存目录,白白占用了三个小时的恢复窗口。
问题一:apt 每次都在重复下载
现象很直观:隔离区一台机器执行 apt update,日志里每个包的下载速度都是
几百 KB/s,而镜像站到上游的带宽明明跑到了 800 Mbps。更奇怪的是,
就算某个包已经被别人下过、缓存里躺着完整副本,速度也没有变化。
根因:Range 请求被当成完整对象
apt 的 Acquire::http 默认会开多个连接、并对每个文件发 Range 请求分段下载。
而 Nginx 的反向代理在收到 Range 请求时,会把整个对象先回源拉全、
再从中截出请求的那一段返回。对缓存来说这不算命中——每个分段请求
都是一次独立的回源,于是同一个文件被拉了几十遍。
更糟的是缓存键:默认的 proxy_cache_key 是 scheme + host + uri
(不含 Range 头),所以不同分段共享同一个缓存条目,
最后写入的那个分段会覆盖掉完整对象,缓存里的副本始终是残缺的。
处理:用 slice 把回源切成固定块
Nginx 的 slice 模块正是为此设计的:把上游响应切成固定大小的块,
每块作为一个独立缓存对象,Range 请求命中对应的块即可,
缺失的块才回源。这样一次全量下载之后,后续任何分段请求都是命中。
proxy_cache_path /data/cache/apt
levels=1:2
keys_zone=apt_cache:512m
max_size=400g
inactive=7d
use_temp_path=off;
server {
listen 80;
server_name mirrors.internal;
location /apt/ {
proxy_pass https://upstream-archive/;
proxy_cache apt_cache;
proxy_cache_valid 200 206 6h;
proxy_cache_key "$scheme$request_method$host$uri";
proxy_cache_lock on;
proxy_cache_lock_timeout 30s;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on;
# 固定 1 MB 的块;过小会产生大量小对象,过大则失去分段意义
slice 1m;
proxy_set_header Range $slice_range;
proxy_set_header If-Range $http_if_range;
# slice 要求缓存能存 206
proxy_force_ranges on;
add_header X-Cache-Status $upstream_cache_status always;
}
}几个必须同时到位的细节:
slice_range变量是slice模块提供的,必须手工塞进proxy_set_header Range,否则上游看到的还是原始请求头。- 缓存键里要带上
$request_method。HEAD 与 GET 的响应体不同, 混在一起会让apt的探测请求污染真实缓存。 proxy_cache_valid里的206不能漏。只写200的话分块响应的 状态码匹配不上,块会被当成不缓存的对象,切片也就白做了。
问题二:并发回源打崩上游
配置改好之后速度上来了,但随即出现了新问题。周一的集中升级窗口里, 上游镜像源把我们的出口 IP 限流了,随后镜像站自己开始大面积 504。
根因:缓存击穿
上百台机器在同一个五分钟窗口里拉同一个大包,缓存里恰好都没有, 于是上百个请求一起回源。上游受不了,返回 5xx,而默认配置下一旦回源失败, Nginx 就直接把错误透传给客户端——上游越慢,回源越多,形成正反馈。
处理:缓存锁 + 降级陈旧副本
proxy_cache_lock on; # 同一 key 只放一个请求回源,其余等待
proxy_cache_lock_timeout 30s; # 等待上限,超时后才各自回源
proxy_cache_background_update on; # 先返回陈旧内容,后台异步更新
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;proxy_cache_use_stale 里的 updating 是关键:它允许在后台更新期间
把陈旧的副本先发出去。对 apt 仓库而言,一个 6 小时前的索引和
6 小时 5 分钟前的索引没有区别,但把请求挂住 30 秒有本质区别。
改完之后同样的集中升级窗口,回源请求数从 4200 次降到 178 次,
X-Cache-Status 的分布从 MISS 为主变成 HIT 与 STALE 为主。
问题三:磁盘写满时的行为
缓存目录 /data/cache 和日志同处一块盘,写满之后 nginx 的行为需要提前想清楚。
max_size 到达上限时 Nginx 的缓存管理器会主动淘汰,但在淘汰追上写入速度之前,
会出现一小段”缓存不工作但也不报错”的窗口——X-Cache-Status 全部是 EXPIRED。
# 巡检时确认淘汰是否跟得上
du -sh /data/cache/apt
grep -E 'cache manager|evict' /var/log/nginx/error.log | tail -20把缓存和日志分盘是最省事的根治办法;做不到的话,至少给 max_size
留出 20% 的余量,别让它贴着分区上限设置。
复盘
- 反向代理和大文件传输是两个问题,
slice是后者的答案; 上slice之前先确认上游支持Range,否则回源会被切成大量无效请求。 - 缓存键的组成决定了命中率,也决定了正确性。
默认键在带
Range、Vary、鉴权头的场景下都是错的。 - 任何缓存都要配一条降级路径。缓存层的高可用不是”缓存永不失效”, 而是”缓存失效时行为退化但不崩”。
- 镜像站属于基础设施,
status定为RESTRICTED: 配置里包含上游地址与出口代理的形态,不对内网之外分发。