前阵子帮团队搭了一个 GitHub 镜像站,起因很实际:持续集成流水线每次拉第三方依赖都慢得让人心慌,release 里的大文件动不动就中断,同一份制品被十几台构建机反复下载,浪费了不少时间。折腾了一周左右,把 Nginx 缓存、主动预热、监控告警这些环节都过了一遍,总算稳定下来。这篇把完整的搭建和优化思路整理出来,给和我一样需要自建 GitHub 镜像站的朋友做参考。
自建镜像站并不神秘,核心就两件事:一是让重复下载的流量尽可能打在本地,二是让跨地区访问 GitHub 源站时常见的超时和中断问题得到缓解。换句话说,镜像站 = Nginx 反向代理 + 磁盘缓存 + 定时预热 + 必要的保护措施。你可能不需要一上来就搞得很复杂,但先想清楚自己的场景和资源边界,再动手,能少走很多弯路。
1. 自建镜像站前,先想清楚这几件事
1.1 镜像站解决的四个真实问题
很多人一想到 GitHub 镜像站,就默认是为了“访问更快”。其实从实际运维角度看,真正值得用镜像站解决的问题更具体:
- 重复拉取代码和依赖:CI/CD 流水线里,每次构建都要从 GitHub 拉取同一个公共仓库或 release 包,网络一波动就失败。
- 大文件下载中断:release 页面里的二进制安装包动辄几百 MB,直接从源站下载时常半路断掉,没有断点续传就非常痛苦。
- API 速率限制:自动化脚本如果频繁调用 GitHub API 获取版本信息、下载列表,很容易触发限流,影响业务。
- 多机共享缓存:团队里有多台构建机或开发机,各自独立下载相同资源,白白消耗带宽和时间。
所以,自建镜像站的首要目标不是什么“绕过限制”,而是把重复流量就近拦截下来,让回源的次数降到最低。这个思路适用于任何网络环境,纯粹从工程效率出发。
1.2 三种落地方案的取舍
动手之前,先选架构。我见过三种主流方案,各有取舍:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Nginx 反向代理 + 磁盘缓存 | 配置灵活、完全可控、可以精确设计缓存策略 | 需要自己处理 URL 映射、302 重定向等细节 | 有运维能力,想要高可控性的团队 |
| 开源专用工具(如 ghproxy) | 部署快、开箱即用,URL 规则清晰 | 功能固定,二次开发空间小,更新依赖上游 | 个人或小团队快速上线 |
| 仓库同步(Gitee/Gitea/GitLab) | 仓库完全复制,clone 速度快 | 非实时同步,release 大文件支持弱 | 只是需要固定仓库的代码备份 |
我最后选了 Nginx 反向代理方案,原因很简单:镜像站需要同时满足git clone、release 下载、API 请求转发三件事,Nginx 的缓存模块和 rewrite 能力可以应对所有这些场景,后续调优空间也大。如果你只是想要一个给同事用的简单下载加速入口,ghproxy 这类工具会更省事,但别指望它在流量变大后还能保持灵活。
2. 服务器到域名证书:准备工作里的隐藏细节
2.1 服务器配置怎么选才不浪费
镜像站是典型的高带宽、中低 CPU 应用。它的大部分工作都在转发流量和写缓存,真正吃资源的是磁盘 IO 和网络带宽。
- CPU:2 核足够起步。如果你不用 OpenResty/Lua 做复杂逻辑,Nginx 对 CPU 的要求不高。
- 内存:4GB 起步。内存主要被系统的 Page Cache 占用来加速磁盘读取,缓存文件越大,内存越有用。
- 带宽:建议 5Mbps 以上。如果面向团队使用,10Mbps 会更稳。带宽不是越高越好,而是要根据实际的下载流量预算来配。
- 磁盘:这是最容易忽略的。release 大文件缓存和访问日志都落在磁盘上,系统盘不能和缓存目录共用,否则日志一膨胀就把根分区塞满。
一句话:磁盘容量和带宽决定了镜像站的上限,CPU 内存够用就行。
2.2 域名、DNS 与 HTTPS 证书的自动化
镜像站必须用独立域名,不要用 IP 裸奔。独立域名带来的好处是后续接 CDN、做多区域分流、调整 DNS 都很方便。
域名选好后,DNS 记录直接解析到服务器 IP 即可。如果服务器在国内,建议做好备案相关的合规检查;如果服务器在境外,那访问延迟会受到地区影响,需要结合团队分布选择区域。
HTTPS 证书我推荐用 Let's Encrypt 自动签发,配合acme.sh或certbot,配置一条 cron 或 systemd timer 做自动续期。很多镜像站事故不是源站出问题,而是证书过期导致全站不可用,所以这一步一定要自动化。
2.3 磁盘挂载与目录规划
我习惯把镜像站所有数据放在一个独立数据盘下,命名清晰:
/data/github-mirror/cache /data/github-mirror/logs /data/github-mirror/tmp缓存目录单独挂载到数据盘,并且挂载参数加上noatime,可以减少不必要的磁盘写入,对 IO 有一点帮助。文件系统用 ext4 或 xfs 都行。日志目录和缓存目录分开,是因为日志轮转和缓存清理的触发条件不同,混在一起容易出现“日志把磁盘塞满,缓存写不进去”的连锁故障。
3. Nginx 核心配置:从能用到好用的距离
3.1 起步配置:一个能用的反向代理
先看一段最基础的反向代理配置,作用是让github-mirror.example.com/owner/repo/...反向代理到github.com/owner/repo/...:
server { listen 443 ssl http2; server_name github-mirror.example.com; # SSL 证书配置省略 ssl_certificate /etc/letsencrypt/live/github-mirror.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/github-mirror.example.com/privkey.pem; location / { resolver 8.8.8.8 ipv6=off; set $backend "https://github.com"; proxy_pass $backend$request_uri; proxy_set_header Host github.com; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }注意,这里用resolver定义 DNS 解析地址,是为了让 Nginx 能够根据变量动态解析$backend,避免proxy_pass配置中的固定域名在启动时解析一次后就不再更新。
这个配置对普通的仓库页面浏览没问题,但距离“好用”还差很远,因为 GitHub 的 release 下载会涉及 302 重定向到objects.githubusercontent.com,必须单独处理。
3.2 处理 release 下载的 302 重定向
GitHub 的 release 下载流程是这样:客户端请求https://github.com/owner/repo/releases/download/v1.0.0/file.zip,GitHub 服务器返回 302,Location 指向https://objects.githubusercontent.com/...,客户端跟随重定向从对象存储下载文件。
直接在 Nginx 里反向代理,客户端会在最外层就拿到 302 响应,于是它绕过你的镜像站直连 GitHub 的对象存储,镜像缓存完全失效。解决办法是让 Nginx 把对象存储也代理了,并改写响应头里的 Location。
这里我给出一个实用配置,思路是:
- 将 release 下载路径反向代理到
github.com; - 使用
proxy_redirect把响应头里的https://objects.githubusercontent.com/改写成自己的镜像域名; - 增加一个 location 专门反向代理
objects.githubusercontent.com,并开启缓存。
# 处理 release 下载路径 location / { proxy_pass https://github.com; proxy_set_header Host github.com; # 将 objects.githubusercontent.com 的重定向改写为本站地址 proxy_redirect https://objects.githubusercontent.com/ /objects/; } # 代理 GitHub 对象存储 location /objects/ { resolver 8.8.8.8 ipv6=off; set $backend "https://objects.githubusercontent.com"; proxy_pass $backend$request_uri; proxy_set_header Host objects.githubusercontent.com; # 开启缓存 proxy_cache github_cache; proxy_cache_valid 200 7d; proxy_cache_key "$scheme://$host$uri"; }这样客户端实际下载路径变成:
https://github-mirror.example.com/objects/...文件内容会通过镜像站回源到对象存储,然后被 Nginx 缓存下来。第一次请求会比较慢,后续请求就直接命中本地缓存了。
3.3 缓存策略与 Range 请求
大文件下载最怕断点续传失效。客户端通常会携带Range: bytes=0-或一个具体的区间去请求,Nginx 需要把 Range 头透传给上游,同时保证缓存命中。
Nginx 的proxy_cache默认会缓存完整响应,但如果你直接让客户端和源站之间做 Range 请求,Nginx 的缓存行为需要额外配置。关键参数:
proxy_cache github_cache; proxy_cache_valid 200 7d; proxy_cache_valid 206 1h; proxy_cache_key "$scheme://$host$uri"; proxy_set_header Range $http_range; proxy_ignore_headers Cache-Control Expires Set-Cookie;这里的proxy_ignore_headers是常见的必备项。GitHub 对象存储返回的响应头里可能带有Set-Cookie或Cache-Control: private,如果不忽略,Nginx 会因为“不允许缓存”而放弃缓存,导致所有请求都回源。
3.4 用 OpenResty 提升缓存命中率
如果直接用 Nginx 官方版,缓存 key 匹配规则比较死板。GitHub 的很多 URL 带有查询参数,例如?raw=1、?token=xxx,这些参数没有实际意义,却会让缓存 key 变化,导致同一份文件被缓存多份,命中率直线下降。
用 OpenResty 可以更灵活地处理。例如动态去除无意义的 query 参数:
location / { set_by_lua $cache_key ' local uri = ngx.var.uri local args = ngx.req.get_uri_args() -- 只保留有限参数 return uri '; proxy_cache_key "$scheme://$host$cache_key"; proxy_pass https://github.com; }这样即使客户端请求/owner/repo?token=abc和/owner/repo?token=xyz,最终缓存 key 都是/owner/repo,命中率明显提升。OpenResty 的 LuaJIT 性能很强,这步操作对整体吞吐影响很小。
4. 缓存命中率优化与主动预热
4.1 日志里的三个关键数字
配好之后,第一件事不是继续调配置,而是看日志。我在 Nginx 日志格式里加了一个变量$upstream_cache_status,用来记录每次请求的缓存状态:HIT、MISS、EXPIRED、BYPASS 等。
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_range" ' 'cache:$upstream_cache_status';通过日志,你需要关注三个数字:
- 缓存命中率:统计 HIT / 总请求,正常应在 60% 以上。如果长期低于 30%,说明缓存策略有问题,或者请求 URL 中的随机参数太多。
- MISS 请求的 top URL:找到回源最多的路径,优先做预热。
- 5xx 和 429 状态码数量:如果源站开始返回 429,说明回源请求太频繁,可能触发了限流。
统计命令可以用awk快速处理:
awk '{print $NF}' /data/github-mirror/logs/access.log | sort | uniq -c | sort -rn | head4.2 写一个预热脚本
被动缓存有个缺点:新文件第一次被请求时依然要回源,如果这个文件体积很大,第一个下载者会等很久。为了避免“冷启动慢”,可以针对热门仓库做主动预热。
预热脚本的逻辑很简单:定期请求你关心的 release 下载 URL,强制 Nginx 缓存。比如用 Python 脚本拉取 GitHub API 获取某个仓库的最新 release 下载地址,然后依次请求镜像站 URL。
#!/usr/bin/env python3 import requests import time GITHUB_API = "https://api.github.com/repos/{owner}/{repo}/releases/latest" MIRROR_BASE = "https://github-mirror.example.com" repos = [ ("owner", "repo"), ("owner2", "repo2"), ] for owner, repo in repos: try: r = requests.get(GITHUB_API.format(owner=owner, repo=repo), timeout=10) if r.status_code != 200: continue for asset in r.json().get("assets", []): url = asset["browser_download_url"] mirror_url = MIRROR_BASE + url.replace("https://github.com", "") print("Warm up:", mirror_url) requests.get(mirror_url, timeout=30, stream=True) time.sleep(1) except Exception as e: print("Error:", e)注意,预热请求最好限制并发和频率,否则源站可能认为你在攻击它,反而把 IP 封掉。我通常在每次请求之间加 1-2 秒间隔。
4.3 限流与超时保护
镜像站一旦公开出去,就会面临各种突发流量。Nginx 本身有很好的防护能力,关键是用起来:
# 限制客户端请求速率,平均每秒 5 个请求,突增不超过 10 limit_req_zone $binary_remote_addr zone=mirror_limit:10m rate=5r/s; location / { limit_req zone=mirror_limit burst=10 nodelay; proxy_connect_timeout 10s; proxy_send_timeout 60s; proxy_read_timeout 60s; }proxy_connect_timeout要短一点,避免客户端等待太久;proxy_read_timeout要足够长,因为大文件下载可能持续几分钟。这个平衡需要根据实际业务调整。
5. 实战中遇到的网络怪问题
5.1 release 路径 404:一个 rewrite 的锅
我第一次配置时,下载release文件一直返回 404,但仓库首页能访问。排查了很久,最后发现是 Nginx 的location ~正则写得太激进,把/owner/repo/releases/download/...里的/releases匹配掉了一段。
优化前:
location ~ ^/([^/]+)/([^/]+)/releases/download/(.*)$ { proxy_pass https://github.com/$1/$2/releases/download/$3; }这看起来没问题,但如果是多层目录的情况,$3可能不完整。GitHub 的 release download 路径实际上就是/{owner}/{repo}/releases/download/{tag}/{filename},如果 tag 里包含斜杠或文件名被 URL 编码,简单的正则就会漏。
我的经验是:能用前缀匹配就尽量用前缀匹配,少用复杂正则。直接:
location / { proxy_pass https://github.com$request_uri; }简单的透传反而最不容易出错。只有当需要特殊改写时才用 rewrite,并且改完之后必须实测几个典型路径。
5.2 403 权限问题:缓存目录的“隐形权限”
有段时间镜像站经常随机出现 403,但刷新一下又好了。查看错误日志发现是 Nginx 的proxy_cache写缓存失败,因为缓存目录属主不是 Nginx 工作进程。
Nginx 的 master 进程一般是 root,但 worker 进程通常以www-data或nginx用户运行。如果你手工把缓存目录mkdir在/data下,属主是 root,worker 进程没有写权限,自然无法写入缓存文件,于是请求直接失败返回 403。
解决方法是把缓存目录属主改成 Nginx worker 用户:
sudo chown -R www-data:www-data /data/github-mirror/cache sudo chmod -R 755 /data/github-mirror/cache这个坑很容易被忽略,因为裸 Nginx 代理不配缓存时完全正常,一旦开缓存就间歇性报错。
5.3 缓存穿透与回源风暴:恶意请求如何拖垮源站
有次我发现源站突然开始返回大量 429,查看镜像站访问日志后发现,有人在用扫描工具探测路径,生成大量随机 URL,比如/owner/nonexistent/releases/download/v1.0.0/random.zip。这些请求全部回源,导致 GitHub API 和对象存储限流。
解决方案有两个:
- 前置校验:如果 URL 路径不符合
/{owner}/{repo}/releases/download/的格式,直接返回 404,不进行反向代理。 - 缓存锁:开启
proxy_cache_lock on,避免多个相同请求同时回源。这样即使有热点流量,也只有一个请求会真正回源。
proxy_cache_lock on; proxy_cache_lock_timeout 10s;配合limit_req,基本能抵御常见扫描流量。
5.4 大文件缓存不完整:Nginx 临时文件上限
大文件下载到一半就断,明明已经缓存过,但每次请求还是回源。查了 Nginx 文档,发现是proxy_max_temp_file_size限制导致的。
Nginx 缓存文件时,会先把上游响应写入临时文件,如果临时文件的大小超过某个阈值,Nginx 就决定不缓存,直接流式转发给客户端。默认的proxy_max_temp_file_size是 1024m,如果你的 release 包超过 1GB,超出了这个值,缓存就不会生效。
解决办法是调大这个参数,并确保临时目录(proxy_temp_path)所在磁盘有足够空间:
proxy_temp_path /data/github-mirror/tmp; proxy_max_temp_file_size 8192m;大文件缓存对磁盘 IO 的要求比较高,建议临时目录和缓存目录放在同一块高速磁盘上,避免多次跨盘拷贝。
6. 安全加固与日常运维
6.1 防盗链与限速:控制流量成本
镜像站公开后,很容易被第三方网站直接引用。如果有别人在热门页面嵌入你的下载链接,你的带宽成本会瞬间飙升。
可以通过valid_referers限制来源:
location /objects/ { valid_referers none blocked github-mirror.example.com *.example.com; if ($invalid_referer) { return 403; } }但要注意,很多下载命令行工具(wget/curl)不会带 Referer,所以none要保留,否则会误伤正常用户。只针对浏览器请求做防盗链,命令行工具一般不需要。
同时可以限制单个 IP 的下载速率:
location /objects/ { limit_conn addr 4; limit_rate 5m; }这里limit_conn addr需要先在http层定义 zone:
limit_conn_zone $binary_remote_addr zone=addr:10m;6.2 日志轮转与缓存清理
访问日志增长很快,尤其是大文件下载场景,每行日志虽然小,但量一多就会占满磁盘。我用logrotate按天切分,保留 30 天:
/data/github-mirror/logs/access.log { daily rotate 30 compress delaycompress missingok notifempty create 0640 www-data www-data sharedscripts postrotate /usr/sbin/nginx -s reopen endscript }对于 Nginx 缓存目录,依靠proxy_cache_path自带的 manager 机制定期清理:
proxy_cache_path /data/github-mirror/cache levels=1:2 keys_zone=github_cache:10m max_size=50g inactive=30d use_temp_path=off;max_size=50g表示缓存总量超过 50GB 时,manager 进程会自动清理不活跃文件;inactive=30d表示 30 天没有被访问的缓存会被清掉。这个配置能保证缓存目录不会无限膨胀。
6.3 监控告警的整体思路
镜像站在没有告警的情况下运行是很危险的。你可能登录服务器才发现磁盘满了,或者缓存命中率已经掉到 30% 以下。
我用的方案是 Prometheus +nginx_exporter暴露 Nginx Stub Status 数据,再配合node_exporter采集磁盘、CPU、内存指标。
在 Nginx 配置里开启 status 需要在编译时带--with-http_stub_status_module,一般发行版都默认带了:
location /stub_status { stub_status; allow 127.0.0.1; deny all; }然后nginx_exporter会定期抓取stub_status,把连接数、请求数等转成 Prometheus 指标。告警规则我这里给几个重点:
- 5xx 响应占比超过 1%
- 缓存命中率(HIT 占比)低于 50%
/data/github-mirror磁盘使用率超过 80%- Nginx 进程关闭或无法探活
有了这几个告警,镜像站的基本健康状态就能实时掌握。
最后说几句
自建 GitHub 镜像站,最大的感受是“配好只是开始”。缓存策略要观察真实请求来调整,重定向规则要根据源站行为变化来维护,磁盘和带宽消耗要持续监控。如果你没有真实流量,建议先用测试脚本模拟几轮下载,再切正式域名,不然一上线就被存量请求打爆缓存目录是很常见的事。
一个小技巧:把所有 location、缓存策略写在独立的配置片段里,用include引入主配置。这样每次调整只需要改一个文件,还能方便地做 A/B 对比。镜像站这种服务,简单透明比花哨重要得多。