大概每个和代码仓库打交道的人都经历过这种场景:git clone一个知名深度学习仓库,进度条跑到90%以上,眼看就要完成,突然报一个fatal: early EOF,然后从头再来;打开某个项目的Release页面,下载按钮转了半天,最后给你跑到一个堪比拨号时代的下载速度。次数多了,人就会开始想,与其忍,不如自己动手搭一套 GitHub 镜像站。
镜像站这个东西,市面上有很多现成的方案,但大多数只解决某一个场景,比如单一网页加速、单一release下载加速。如果你有自己的服务器、有自己的使用场景,完全可以自己搭建一个定制化的镜像站,把连不上的问题、带宽瓶颈问题一次解决掉。这篇文章就基于我自己的实践,把从域名规划、Nginx反代、git clone层镜像到release下载缓存的全过程写下来。适合有Linux操作经验、想给自己或团队搭一套稳定镜像入口的读者参考,也适合那些正在纠结"到底该镜像哪一层"的人先看完再做决定。
1. 动手之前先想清楚:镜像站到底要镜像哪几层?
很多人一上来就想"把整个github.com反代到我自己的域名上",这个思路很容易翻车。原因很简单:GitHub并不是一个单域名站点,它是一大群域名的集合。页面、代码仓库、静态资源、下载文件分别跑在不同子域上,DNS解析到不同IP,走不同链路。只反代一条路,最后大概率做出一个半残的镜像站。
1.1 GitHub站点的三层结构,比想象中要散
我自己的理解里,GitHub大致可以分为下面几层:
| 层级 | 典型域名 | 承载内容 | 是否建议镜像 |
|---|---|---|---|
| 页面层 | github.com | HTML页面、仓库浏览、搜索、登录跳转 | 可选 |
| 静态资源层 | github.githubassets.com | JS、CSS、图片、图标 | 跟着页面层一起做 |
| Git数据层 | github.com | git clone、fetch、push依赖的智能HTTP协议 | 强烈推荐 |
| 下载层 | codeload.github.com、raw.githubusercontent.com、objects.githubusercontent.com | Release附件、tar.gz打包下载、Raw文件、LFS对象 | 强烈推荐 |
用户嘴上说"打开GitHub",但浏览器和git客户端在背后同时和多个子域建立连接。镜像站点只处理了github.com,通常会出现两种情况:网页能打开但样式全丢,因为JS和CSS从github.githubassets.com加载失败;Release页面能显示但点下载没反应,因为真正的下载被重定向到codeload.github.com或者objects.githubusercontent.com,而你的镜像完全没有覆盖这些域名。
1.2 多数人真正需要的,其实是git clone和release下载
页面层虽然是最显眼的,但我得说一句大实话:对绝大多数个人开发者和小团队来说,最痛的往往是两件事——仓库clone不稳定、release文件下载太慢。页面浏览这件事,反而可以接受偶尔卡顿。
- 如果你是开发者,日常动作是clone、pull、看代码,那么git数据层的稳定访问才是刚需。
- 如果你是运维,需要定期把上游几个依赖仓库同步到内网,那么需要的是仓库镜像加内网托管。
- 如果你只是想能快速下载某个二进制、某个训练好的模型文件,release下载缓存是性价比最高的投入。
- 页面级镜像适合团队内部做代码在线阅读、快速浏览,但登录、发issue、提交pull request这类动态交互几乎没法镜像,因为涉及GitHub的session和cookie体系,反代之后很容易出现串号或写操作失败。
所以,搭镜像之前先问问自己:我三个月内最频繁的操作是什么?这个问题想清楚了,再决定做哪一层的镜像。下面我就按这几个方向,把搭建过程拆开讲。
2. 基础设施与域名规划:先想好这几点再动手
2.1 服务器、磁盘、带宽三个硬指标
镜像站其实不挑CPU,挑的是磁盘和带宽。
先说磁盘。Git仓库裸镜像和release缓存都会吃磁盘,而且吃得很凶。一个热度中等的仓库,裸镜像体积在几百MB到几个GB之间;release缓存就更夸张,动辄几十GB甚至上百GB,因为很多项目会把几百MB的安装包、模型权重挂在Release下面。我的建议是,如果条件允许,单独挂一块1TB数据盘给缓存目录用;如果只是小团队内部自用,256GB也能转,但你要在配置里把缓存上限压住,否则跑几个月磁盘就满了。
然后是带宽。镜像站的体验上限取决于你到GitHub源站的回源链路质量,也取决于用户到你服务器的链路质量。如果你只是自己或团队几个人用,10Mbps起步,20~50Mbps体验就很不错;如果你准备拿它做公共镜像,那带宽成本会迅速变成最扎心的问题,一台服务器的出口带宽很快会被下载流量占满。
最后是服务器位置。我记得第一次搭的时候,把服务器放在了一个离用户很近但到GitHub源站绕路的机房,结果发现回源慢得离谱,镜像站反而比直连还难受。这里给一个比较简单的经验:服务器位置要同时考虑用户到它、它到GitHub源站这两段链路。服务对象在国内的,大陆服务器通常需要先确认备案条件;不想碰备案就选大陆以外、网络距离相对近的机房。动手之前先测链路质量,用ping和curl -w "%{speed_download}"多测几次,别想当然。
2.2 子域名规划,建议一次性到位
如果你的镜像目标是"尽量完整",那域名规划建议直接按层级拆开。我用的是这种结构:
gh.example.com→github.comassets.example.com→github.githubassets.comraw.example.com→raw.githubusercontent.comcodeload.example.com→codeload.github.comobjects.example.com→objects.githubusercontent.comavatars.example.com→avatars.githubusercontent.com
拆开的好处有三个。第一,每个子域可以单独配缓存策略,比如assets域里的静态文件URL带hash,可以放心缓存很久;objects和codeload是大文件下载,需要单独设缓存目录和淘汰策略,不能和JS、CSS混在一起。第二,每个子域名可以分别观察流量和错误日志,出问题的时候能最快定位到具体是哪个环节堵了。第三,证书、回源地点、limit_rate限速都能各调各的,互不影响。
api.github.com不建议做镜像。原因是API接口大量携带token、身份信息,你把API反代出去等于把用户身份校验的边界搅浑了,安全风险很高,而且GitHub对API有严格限流,反代并不会帮你规避这个限制,只会给自己找麻烦。
2.3 HTTPS证书与备案问题
每个子域都要配HTTPS证书。域名多,最省事的办法是申请一张通配符证书,比如*.example.com,这样所有子域共用一张,后续新增子域也不用重新签。发行工具我用的是acme.sh,配合Let's Encrypt,三个月自动续期一次。这里有一个容易被忽略的坑:证书续期之后,很多人忘了reload Nginx,导致证书文件已经更新了但进程还在用旧证书,镜像站到时间就悄悄失效。所以后面我会专门讲一下续期触发Nginx reload的配置。
大陆服务器的安全责任中,域名解析是需要先完成ICP备案的,这个是玩服务器的基础操作,不展开细说。如果你没有备案条件,就优先选大陆以外区域来部署。
3. 页面层反向代理配置:让用户能正常打开你的镜像站
3.1 最小可跑通的Nginx配置
如果你决定先做页面层镜像,那么下面这段配置是最小可运行版本。它把所有请求原样转发给github.com,保留原始路径。
server { listen 443 ssl; server_name gh.example.com; ssl_certificate /data/ssl/example.com/fullchain.pem; ssl_certificate_key /data/ssl/example.com/privkey.pem; location / { proxy_pass https://github.com; 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; proxy_ssl_server_name on; proxy_ssl_protocols TLSv1.2 TLSv1.3; proxy_read_timeout 60s; proxy_connect_timeout 10s; } }注意一个细节:proxy_pass https://github.com;结尾是没有斜杠的。这个写法和带斜杠的写法含义完全不同——带斜杠时Nginx会把location匹配到的前缀替换掉,不带斜杠时则会把完整原始URI原样传给上游。对整站镜像来说,我们要的是后者。
proxy_ssl_server_name on;这行看起来不起眼,但缺了它大概率会挂。原因是Nginx作为TLS客户端去连GitHub的时候,如果不开SNI,握手阶段就不会携带github.com这个域名,GitHub那边看到的就是一个IP直连请求,很可能会被拒绝或者握手失败。我在早期踩过这个坑,日志里全是SSL handshake failed,排查半天才想起来是SNI的问题。
3.2 页面层的其他子域怎么补
页面能打开只是一个开始。你会很快发现样式是乱的,这时检查浏览器Network面板,多半能看到github.githubassets.com的超时请求。处理方式就是在同一个Nginx里再加一个server块:
server { listen 443 ssl; server_name assets.example.com; ssl_certificate /data/ssl/example.com/fullchain.pem; ssl_certificate_key /data/ssl/example.com/privkey.pem; location / { proxy_pass https://github.githubassets.com; proxy_set_header Host github.githubassets.com; proxy_ssl_server_name on; proxy_cache assets_cache; proxy_cache_valid 200 24h; } }这里的proxy_cache_valid 200 24h可以放心大胆用,因为assets域的静态资源URL基本都带内容hash,文件内容变了URL就会变,同一个URL对应的内容在长时间内是稳定不变的。
raw.githubusercontent.com、codeload.github.com、objects.githubusercontent.com这几个域名也都是同样的套路,换成各自域名和缓存策略就好。头像域avatars.githubusercontent.com技术上不难,但那个域缓存价值一般,如果觉得配置多得麻烦,可以先不处理,最多就是头像加载慢一点。
3.3 页面层最坑的地方:动态接口和登录态
页面层的只读操作,也就是打开仓库页、看README、浏览目录和issue列表,反代过去基本都能工作。但一旦涉及登录、发帖、星标、release上传这类需要写session的操作,情况就会变得非常诡异。
GitHub的登录态是通过多个cookie和二次校验机制共同维护的。你把流量反代过去,浏览器看到的域名是gh.example.com,cookie会种在这个域名下面,而GitHub服务端实际校验session时,会去看请求里携带的官方域名相关cookie。两边对不上,就会出现"看起来登录成功了,但刷新又变回未登录状态"之类的问题。至于发issue、merge PR这种写操作,失败概率更高。
所以我对页面层镜像的建议是:定位成只读浏览镜像,别指望登录和交互功能。你甚至可以在镜像站首页放一个明显的说明条,告诉用户这是只读镜像,需要登录的操作请回到原站。这样至少不会让用户产生误会。
3.4 用CDN还是自己硬扛?
如果你服务的用户分布很广,可以考虑在镜像站前面再套一层CDN,把静态资源缓存推到边缘节点。但要注意一个取舍:CDN本身也是要成本的,而且如果用户主要在同一个区域,直接自己扛可能比套CDN更稳定、更容易排查问题。我的习惯是先不套CDN跑一段时间,看页面层的流量特征,确实有需要再加也不迟。
4. Git Clone层:URL重写和本地仓库镜像
页面层只能解决"看",解决不了"clone"。因为克隆一个仓库的时候,git客户端走的是GitHub的智能HTTP协议,是要跟源站实时做数据交换的。Nginx反代只是一个转发通道,除非你把GitHub那边的数据缓存下来,否则用户每次clone仍然要从GitHub拉数据,该断的还是会断。
4.1 客户端URL重写:先让团队用上镜像
镜像站搭好之后,最重要的问题是怎么让用户无感地走你的入口。一个最省事的办法是在客户端配置全局URL替换:
git config --global url."https://gh.example.com/".insteadOf "https://github.com/"这句话的意思是:所有原本以https://github.com/开头的Git操作,自动替换成https://gh.example.com/。配置完之后,你照常写git clone https://github.com/foo/bar.git,实际请求会发往gh.example.com。团队内部分发这份配置非常方便,只要一行命令,不用改任何已有脚本和文档。
有一点要提醒:这种替换方式依赖HTTPS证书链正确。你的镜像站必须能提供对gh.example.com的有效证书,git客户端对证书的校验是很严格的。如果用户机器上没有信任你的自签证书,连接会直接报错,所以千万不要想省事用自签证书,老老实实签Let's Encrypt就行。
4.2 仓库定时镜像:真正解决clone断连问题
URL重写只能让流量走你的机器,但如果你的Nginx每次还是回源GitHub,clone的速度和稳定性并不一定比直连好多少。真正能显著改善clone体验的,是在本地维护一份裸仓库,用户clone的时候直接从本地裸仓库读取。
思路其实很简单:用git clone --mirror把远端仓库完整拉下来,然后定时执行git remote update拉取增量。一个最基础脚本长这样:
#!/bin/bash # /data/git-mirror/sync.sh REPOS=( "https://github.com/owner/repo-a.git" "https://github.com/owner/repo-b.git" ) for repo in "${REPOS[@]}"; do mirror_dir="/data/git-mirror/$(basename "$repo")" if [ ! -d "$mirror_dir" ]; then git clone --mirror "$repo" "$mirror_dir" else cd "$mirror_dir" && git remote update fi done--mirror的意思是完整镜像,远端的所有分支、tag、refs都会保留下来,而且以裸仓库形态存储,没有工作区,专门用来给clone提供数据。用cron跑定时同步,比如每天凌晨拉一次,就能保持基本新鲜。
这里要说明一下:这种镜像属于"定时同步",不是实时同步。GitHub上的push在同步时间点之前不会出现在镜像里。如果你的团队对新鲜度要求极高,那就得把同步频次提上来,比如每10分钟跑一次,代价是同步脚本和GitHub源站之间的流量会变大。
4.3 用git http-backend把裸仓库发布出去
本地有裸仓库之后,还要让用户能通过HTTP克隆它。办法是借助git http-backend,这是git自带的一个CGI程序,专门用于通过HTTP提供智能协议。
先装依赖:
apt install fcgiwrap nginx git -y然后配置Nginx:
location ~ /git(/.*) { include fastcgi_params; fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend; fastcgi_param GIT_HTTP_EXPORT_ALL 1; fastcgi_param GIT_PROJECT_ROOT /data/git-mirror; fastcgi_pass unix:/run/fcgiwrap.socket; }这样之后,用户就能通过https://gh.example.com/git/owner/repo-a.git来clone了。注意,裸仓库里如果有仓库名、描述文件,可以自己维护一下,方便展示。没有Gitea那套UI,但胜在极轻量,同步几十个仓库也不会有内存压力。
4.4 用Gitea做可视化镜像仓库,哪类团队更适合
如果你不是只想clone,还想让团队在网页上浏览代码、看commit历史、做权限控制,那纯裸仓库的方式就不够用了。这时候可以用Gitea的仓库镜像功能。在Gitea后台新建仓库时可以直接选"从远程镜像",填上GitHub仓库地址,Gitea会定期拉取更新,并且自带一个完整的Web界面。
两种方式的差异我列个表:
| 对比项 | 裸仓库 + git http-backend | Gitea镜像仓库 |
|---|---|---|
| 资源占用 | 极低,只跑git和nginx | 中高,需要数据库、多个进程 |
| 网页代码浏览 | 不支持 | 完整支持 |
| 权限控制 | 只能靠Nginx的访问控制 | 支持用户、团队、组织 |
| 拉取实时性 | 可高频同步 | 同步周期有最短间隔限制 |
| 维护复杂度 | 低 | 中等 |
我的建议是:5~10个人的技术团队,裸仓库方式足够;需要给几十上百人提供代码浏览和权限管理的,再上Gitea。
4.5 大仓库和LFS的镜像边界
做git仓库镜像时还会遇到一个边界问题:LFS对象。普通git镜像同步的是git的commit数据,LFS对象不包含在内。如果你的上游仓库启用了Git LFS,那么从你的镜像clone下来之后,checkout LFS文件时仍然会回到objects.githubusercontent.com去拉对象。这就是为什么我在最开始强调,下载层也要单独做——只有git数据层镜像、没有LFS对象缓存,某些大仓库的体验还是差的。
5. Release下载缓存:性价比最高的一层镜像
5.1 为什么release下载一定要单独做
很多项目的使用频率里,下载Release的比重远高于clone仓库。模型文件、预编译二进制、安装包,全部挂在Release下面。Release下载看起来是简单的"点一下按钮",实际请求链路却很长:浏览器先请求github.com/owner/repo/releases/download/v1.0.0/file.zip,GitHub返回302重定向,最终把流量导向objects.githubusercontent.com上的签名URL。用户体感上的"速度慢",绝大部分发生在这个最终下载阶段。
这一层镜像做起来也是最划算的,因为它是静态文件访问,Nginx的proxy_cache天然适配,不需要跑任何额外服务,只要配好缓存目录和后端,用户第一次下载回源,之后所有命中缓存的请求都从你的磁盘走。
5.2 Nginx的proxy_cache标准配置
给codeload.example.com的配置大概是这样的:
proxy_cache_path /data/cache/rel levels=1:2 keys_zone=relcache:200m max_size=200g inactive=60d use_temp_path=off; server { listen 443 ssl; server_name codeload.example.com; ssl_certificate /data/ssl/example.com/fullchain.pem; ssl_certificate_key /data/ssl/example.com/privkey.pem; location / { proxy_pass https://codeload.github.com; proxy_set_header Host codeload.github.com; proxy_ssl_server_name on; proxy_cache relcache; proxy_cache_key "$scheme$request_method$host$request_uri"; proxy_cache_valid 200 24h; proxy_cache_valid 301 12h; proxy_cache_valid 302 0; proxy_cache_use_stale error timeout http_502 http_503; proxy_force_ranges on; } }这里几个关键点解释一下。
第一,proxy_cache_valid 302 0是我踩坑之后才改的。Release下载的第一步必然返回302,这个302重定向URL是一个带签名参数的临时地址,签名会过期。如果Nginx把302也缓存了,那用户第二次来访问时拿到的重定向目标很可能是已经失效的地址,表现为"同一个文件第一次下载成功,第二次开始一直报错"。所以302绝不建议长时间缓存,让每次请求都回源拿新鲜的重定向才是安全的。
第二,proxy_cache_key默认已经包含完整URI和参数,但release这个场景签名URL尤其依赖请求参数,显式写出来更稳妥,也方便以后调试。
第三,proxy_force_ranges on用来开启缓存内容的Range请求支持。很多下载工具支持断点续传、多线程分段下载,如果镜像端不处理Range请求,大文件下载一旦中断就要整个重新来,体验会非常糟糕。
5.3 objects域名的重定向流量也要接住
光处理codeload.example.com还不够,因为302重定向之后流量会走到objects.githubusercontent.com,如果没有对应的server块,用户最终还是会绕回GitHub源站。
所以你还得在Nginx里加一个objects.example.com的server块,方式和上面几乎一样,唯一需要注意的是证书、缓存目录可以复用同一个relcache,也可以单独建一个objcache。我自己是单独建的,原因是object域还存在大量LFS对象的下载流量,我希望在监控上把它们分开统计。
5.4 缓存淘汰和磁盘告警
缓存有上限,磁盘更是有上限。max_size=200g的意思是缓存目录最大200GB,Nginx会在达到上限后根据活跃度淘汰旧数据。但你总不能等Nginx主动淘汰,磁盘空间告警还是要自己盯着。
一个简单脚本:
#!/bin/bash THRESHOLD=80 USAGE=$(df /data/cache | awk 'NR==2 {print $5}' | tr -d '%') if [ "$USAGE" -gt "$THRESHOLD" ]; then echo "$(date) /data/cache disk usage: $USAGE%" >> /data/log/disk-alert.log # 这里可以接上你的告警推送 ficron每10分钟跑一次就够了。磁盘问题的处理方式优先级是:先清掉inactive过期缓存,再考虑扩容,最后才是限制缓存体积。注意不要为了省空间把inactive时间设得太短,否则热门release文件也会被频繁清掉,失去缓存意义。
6. 上线以后最容易踩的几个坑
6.1 302被缓存,下载突然全挂
有一个非常经典的故障现象:镜像站刚搭好的头几天,release下载都挺快,某一天用户突然反馈某个文件下载一直失败。我用curl -I https://codeload.example.com/owner/repo/releases/download/v1.0.0/file.zip一查,响应头里出现了X-Cache-Status: HIT,立刻意识到是302被缓存了。
排查的思路很简单:先确认有没有命中缓存,再看缓存了哪个状态码,最后想这个状态码的缓存策略是否合理。修复方法就是把proxy_cache_valid 302改成0,然后清理掉相关缓存key,之后类似问题就再也没有出现过。
6.2 Nginx反代的SNI问题
这个问题第一次遇到会很莫名其妙:镜像站页面偶尔能开,但时不时报502,或者日志里大量SSL handshake failed。原因是Nginx去连接GitHub源站的时候,如果TLS客户端不带上SNI,GitHub侧无法确定访问的是哪个域名,会拒绝握手。
解决办法就是前面配置里那行:
proxy_ssl_server_name on;写配置的时候顺手加上,能少折腾好几个小时。
6.3 大仓库clone还是老是断的话,别在缓存上死磕
Nginx反代对页面、release下载这种静态内容很有用,但对git的clone流程帮助有限。原因是我在第四章讲的,clone过程中git客户端和远端建立的是有状态的数据交换过程,返回的数据并不能简单地缓存成"一个文件"供后续复用。如果你已经把反代配置全调完了,clone大型仓库依然时断时续,那就放弃用反代做git加速的执念,老老实实上本地裸仓库镜像,把用户clone的对象切到本地裸仓库上,稳定性才会有质的提升。
6.4 证书续期之后忘了reload
Let's Encrypt证书的有效期是三个月,所以这个问题每隔三个月就有机会碰一次。哪怕你用acme.sh自动续期,如果续期脚本不触发Nginx reload,Nginx进程手里还是旧证书。某天用户突然说镜像站打不开了,一看浏览器的证书错误提示,你才意识到是证书和进程没对上。
正确做法是在acme.sh的续期命令里加上reload:
acme.sh --install-cert -d example.com -d "*.example.com" \ --key-file /data/ssl/example.com/privkey.pem \ --fullchain-file /data/ssl/example.com/fullchain.pem \ --reloadcmd "systemctl reload nginx"这样每次证书更新完,Nginx会自动reload加载新证书,彻底解决这个问题。
6.5 不做访问控制,就等着流量把自己榨干
镜像站一旦被公开,很容易被各种下载请求刷爆,尤其是那些大文件release。我的做法是在镜像入口加一层Basic Auth或者IP白名单,只服务自己团队。如果你确实需要公开,至少要做单IP限速:
location /download/ { limit_rate 2m; }limit_rate 2m的意思是对单个连接限速到2MB/s,能有效防止某个用户一次性榨干所有带宽。这个度自己灵活掌握,总之别给裸奔。
7. 镜像早点做,做得薄一点
最后聊点相对务虚但重要的事。镜像站不是越全越好,做得薄一点、范围小一点,维护压力会小很多。上面提到的所有组件,我现在的线上组合并不是全部都开着。实际保留的是release下载缓存加几个核心仓库的裸镜像,页面层的全站反代我在跑过一段时间后关掉了——原因很现实:登录态问题解决不了,静态资源和动态接口的耦合太深,总是时不时冒出一些奇怪的小毛病,而我投入维护这个镜像站的时间又有限。
另外一个容易被忽视的点是边界。做镜像站不是把GitHub的内容拿来挂到自己域名下就完了,仓库本身的License、README里对转载镜像的态度、GitHub的品牌使用规范这些都要留意。内部使用基本上没这些问题,一旦做成公开服务,就要考虑清楚内容合规和品牌混淆问题。至少应该在页面上写明这是非官方镜像、原始内容来自GitHub对应仓库。
如果你正处在"要不要自建镜像站"的观望状态,我个人的建议是:先别急着上全套。用我前面提到的客户端URL重写方式,配一个release缓存子域,跑一周,看看是不是真的解决了你遇到的痛点。如果痛点确实解决了,再逐步加仓库镜像层、页面层。反过来一步到位配置全套,后续返工的成本会高得多。