LANUNION ARCHIVELANUNION蓝色札记

← 档案索引

ARCHIVE NO. 001

内网镜像站反向代理整理:缓存、Range 与大文件回源

CLASSIFICATIONINFRASTRUCTURE
DATE2026.09.08
状态 STATUSRESTRICTED
READING TIME5 MIN
REVISION2.0

背景

内网镜像站最初的定位很朴素:给隔离区里的几十台机器提供一个 apt/yum 的 就近上游,省掉每次升级都要走一遍出口防火墙。上线时用的是”一个 location、 一条 proxy_pass、一份默认缓存配置”的最小可用版本,跑了半年之后开始暴露问题。

当前承载的源与缓存策略如下,这份表格同时是值班手册里的一页:

上游缓存有效期单对象上限同步周期
Ubuntu APT官方 archive(经出口代理)6 小时不限制每日 03:20
CentOS Stream上游 rsync 镜像12 小时不限制每日 04:00
PyPI清华 TUNA24 小时512 MB每周日 02:00
Docker Registry上游 Harbor1 小时不限制实时
内网 Yum 源自建仓库机30 分钟不限制每小时

运维约定:镜像站的缓存目录不做备份。缓存是可再生的派生物,不是数据。 真正需要备份的是 nginx 配置与各源的同步脚本,二者都在版本库里。 这条约定是上一次磁盘故障时定的——当时有人试图从快照里恢复一份 400 GB 的 缓存目录,白白占用了三个小时的恢复窗口。

问题一:apt 每次都在重复下载

现象很直观:隔离区一台机器执行 apt update,日志里每个包的下载速度都是 几百 KB/s,而镜像站到上游的带宽明明跑到了 800 Mbps。更奇怪的是, 就算某个包已经被别人下过、缓存里躺着完整副本,速度也没有变化。

根因:Range 请求被当成完整对象

apt 的 Acquire::http 默认会开多个连接、并对每个文件发 Range 请求分段下载。 而 Nginx 的反向代理在收到 Range 请求时,会把整个对象先回源拉全、 再从中截出请求的那一段返回。对缓存来说这不算命中——每个分段请求 都是一次独立的回源,于是同一个文件被拉了几十遍。

更糟的是缓存键:默认的 proxy_cache_keyscheme + host + uri (不含 Range 头),所以不同分段共享同一个缓存条目, 最后写入的那个分段会覆盖掉完整对象,缓存里的副本始终是残缺的。

处理:用 slice 把回源切成固定块

Nginx 的 slice 模块正是为此设计的:把上游响应切成固定大小的块, 每块作为一个独立缓存对象,Range 请求命中对应的块即可, 缺失的块才回源。这样一次全量下载之后,后续任何分段请求都是命中。

TERMINAL / NGINXLINE 01–31
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 就直接把错误透传给客户端——上游越慢,回源越多,形成正反馈。

处理:缓存锁 + 降级陈旧副本

TERMINAL / NGINXLINE 01–04
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 为主变成 HITSTALE 为主。

问题三:磁盘写满时的行为

缓存目录 /data/cache 和日志同处一块盘,写满之后 nginx 的行为需要提前想清楚。 max_size 到达上限时 Nginx 的缓存管理器会主动淘汰,但在淘汰追上写入速度之前, 会出现一小段”缓存不工作但也不报错”的窗口——X-Cache-Status 全部是 EXPIRED

TERMINAL / BASHLINE 01–03
# 巡检时确认淘汰是否跟得上
du -sh /data/cache/apt
grep -E 'cache manager|evict' /var/log/nginx/error.log | tail -20

把缓存和日志分盘是最省事的根治办法;做不到的话,至少给 max_size 留出 20% 的余量,别让它贴着分区上限设置。

复盘

  • 反向代理和大文件传输是两个问题,slice 是后者的答案; 上 slice 之前先确认上游支持 Range,否则回源会被切成大量无效请求。
  • 缓存键的组成决定了命中率,也决定了正确性。 默认键在带 RangeVary、鉴权头的场景下都是错的。
  • 任何缓存都要配一条降级路径。缓存层的高可用不是”缓存永不失效”, 而是”缓存失效时行为退化但不崩”。
  • 镜像站属于基础设施,status 定为 RESTRICTED: 配置里包含上游地址与出口代理的形态,不对内网之外分发。