GitHub 镜像站这事,我前前后后搭过几次,第一次把同步脚本跑挂,磁盘差点撑爆,后来才慢慢把整套逻辑理顺。如果你也被 "clone 一个大仓库拉到一半断掉""CI 构建时依赖下载反复超时""团队里同一份仓库资源被不同人重复下载" 这些问题折磨,那 GitHub 镜像站大概率是你需要的东西。
先说清楚一个基本概念:这里说的镜像站,不是把 github.com 的所有页面、按钮、登录态都搬一份,而是把你关心的仓库数据、release 附件、raw 文件做一层可用的本地缓存和同步副本。它的价值是让访问更快、带宽更省、出错更少,同时保住团队在断网或上游抖动时的可继续工作能力。
这篇文章适合三类人:一是正在带小团队的后端/运维/DevOps 同学,二是自己维护了一堆开源依赖的个人开发者,三是想给实验室或内部平台搭一套“只读代码仓库源”的负责人。下面我按方案从轻到重逐步拆,尽量做到看了就能动手。
1. 动手前先想清楚:你要的是哪种“镜像站”
1.1 镜像站到底解决什么问题
GitHub 本身是公开的代码托管平台,但它的原始资源体积大、文件多、路径分散。实际使用时,大家卡住的往往不是“能不能访问”,而是“访问太慢”和“重复拉取太多”。
举个例子:团队里五条流水线同时构建,每条流水线都要拉同一个项目仓库,还要下载依赖发布包。如果没有缓存层,这五份资源就全走公网,造成五倍带宽浪费。更麻烦的是,某些大仓库历史提交很多,git clone 时要把所有 object 拉下来,网络稍微抖动一下就中断,没有断点续传,只能从头再来。
镜像站解决的就是这一类问题:把“从 GitHub 取回”变成“从本机/内网取回”,把“每次都取全量”变成“只取增量”。它不改变 GitHub 的公共性,也不替代 GitHub 作为上游源,只是在你的机房、VPC 或组网环境里多放一份可用的副本。
1.2 三种形态的取舍
按实际需求,我习惯把镜像站分成三种形态。它们不是互相替代关系,很多时候需要组合使用。
| 形态 | 核心做法 | 存储成本 | 维护成本 | 适用场景 |
|---|---|---|---|---|
| Git 仓库完整镜像 | 用git clone --mirror或自托管平台同步所有分支和标签 | 中高 | 低 | 团队需要完整 clone、checkout 任意分支 |
| 文件级缓存 | 对 raw 文件、release 附件做 URL 级缓存 | 中 | 低 | CI 构建下载依赖、安装脚本、静态资源 |
| 全站网页镜像 | 爬取 HTML、JS、CSS 等页面资源 | 极高 | 极高 | 不推荐,动态页面难维护且容易越界 |
我为什么每次都不推荐全站网页镜像?因为 GitHub 的页面是动态的,跟登录态、路由、CDN 都耦合,硬要镜像会把大量过期页面和权限异常页面留在本地,后续运维成本高到离谱。真正值得镜像的永远是“数据”而不是“页面”。你只要把 Git 仓库和发行资源镜像到本地,开发人员的体验就已经和用原站没有太大差别了。
2. 容量规划与同步策略:先把账算明白
2.1 存储怎么估
很多人第一反应是“我先建个目录,把仓库 clone 下来再说”。但仓库一多,磁盘很快就爆。Git 仓库不是只有一个工作目录,它的.git目录里存了所有历史 object,体积通常比拉出来的代码工作副本大很多。尤其是一些带大量二进制提交记录、生成资源、历史版本的项目,裸仓库体积可能超过 1GB。
我建议在动手前先做这样一份粗略估算:
- 单仓平均裸仓库体积:可以先挑 10 个目标仓库,跑
du -sh看一下,取中位数。 - 目标仓库数量:按团队实际常用仓库来定,别贪多。
- 文件缓存保留量:按“近 N 天访问过的 release/raw 文件总大小”估算。
- 冗余空间:至少留出 20% 到 30% 的余量。
举个例子:假设你要镜像 200 个仓库,平均每个裸仓库 500MB,那么 Git 部分就是 100GB。再加上 release 附件和 raw 缓存,预留 80GB,再乘以 1.3 的冗余系数,最终磁盘建议 240GB 以上。这只是基础值,如果里面有大型 monorepo 或携带重型资源仓库,这个数还要往上翻。
2.2 同步频率怎么定
同步不是越勤越好。同步太勤,GitHub 的 API 配额和带宽会先扛不住;同步太疏,团队又可能拿到过期代码。
我的实践节奏是这样的:
- 核心活跃仓库:每 5 到 10 分钟同步一次。适合项目团队每天都在推代码的仓库。
- 低频依赖仓库:每 1 到 24 小时同步一次。适合那些只作为依赖引用、不经常变动的仓库。
- raw 文件和 release 附件:不主动定时同步,而是在用户请求时按需回源缓存,命中率上去后再考虑预热。
还要注意 GitHub API 的请求限制。每小时的请求次数有上限,普通令牌通常比匿名请求配额高很多。如果仓库数超过几百个,不要循环用 API 查每个仓库的详情,直接走 git 协议去抓 refs 会更省配额。
2.3 安全与合规边界
镜像公开仓库之前,建议先把边界划清楚。只镜像允许公开发布的开源项目,不镜像对方的私有仓库;不要在本地“顺手”保存你没有权限分发的内容。同时留意各类开源许可证要求:有些项目允许源码分发,但不允许二进制附件被二次分发。你不必把自己变成法律专家,但至少别在官网首页挂一个“全量开源代码下载包”入口。
另外,镜像站一定要设计成只读属性。用户可以 clone、fetch、下载缓存文件,但不能 push、不能被当成存储盘使用。这样既能控制故障半径,也能减少不必要的写放大。
3. 方案一:用裸仓库批量同步,搭一个纯 Git 层面的镜像
3.1 为什么先看裸仓库
裸仓库是 Git 里最轻量的一种仓库形态,它没有工作目录,只有.git里的全部历史对象、分支引用和标签引用。对于只读镜像来说,裸仓库就是最理想的结构:体积更小、同步更快、不容易出现工作区冲突。
而且它不需要安装任何重量级服务,一台 Linux 机器、一个 Git 命令就能跑起来。团队访问时直接git clone http://镜像站地址/owner/repo.git就行,日常使用体验和操作 GitHub 几乎完全一样。
3.2 仓库列表和同步脚本
第一步先维护一个repos.txt文件,每行一个owner/repo,按团队需要添加:
torvalds/linux kubernetes/kubernetes grafana/grafana prometheus/prometheus然后写一个同步脚本。核心逻辑是:如果本地没有这个仓库就做git clone --mirror,如果已经有了就做git fetch --prune。这里用--mirror的好处是会保留远端的所有 refs 映射关系,fetch 时能完整同步新分支、新标签。
#!/usr/bin/env bash set -euo pipefail MIRROR_ROOT=/srv/git-mirror TOKEN="${GITHUB_TOKEN}" while IFS= read -r repo; do [ -z "$repo" ] && continue target="$MIRROR_ROOT/${repo}.git" if [ -d "$target" ]; then git -C "$target" fetch --prune origin '+refs/*:refs/*' else git clone --mirror "https://x-access-token:${TOKEN}@github.com/${repo}.git" "$target" fi done < repos.txt有几处细节值得注意:
- 首次同步建议分批跑,比如一次跑 20 个,避免网络被打满。
GITHUB_TOKEN用环境变量传入,别硬编码在脚本里。- 大仓库 clone 不要开太多并发,磁盘 IO 和网络带宽都容易成为瓶颈。
3.3 定时调度与并发控制
裸仓库同步脚本本身是串行的,单线程跑几个小仓库没问题,但仓库多了就太慢。建议在外层做一层“串行调度、内部分发”的逻辑,或者直接用 systemd timer 控制频率。
我用得比较多的是 systemd timer,比 cron 更容易看日志、更容易设置资源限制。写两个文件:
/etc/systemd/system/git-mirror-sync.service:
[Unit] Description=Sync GitHub mirror repositories [Service] Type=oneshot EnvironmentFile=/etc/git-mirror.env ExecStart=/usr/local/bin/mirror-sync.sh TimeoutStartSec=1800/etc/systemd/system/git-mirror-sync.timer:
[Unit] Description=Trigger GitHub mirror sync every 10 minutes [Timer] OnActiveSec=10min OnUnitActiveSec=10min [Install] WantedBy=timers.target启用后可以看到下一次执行时间:
systemctl daemon-reload systemctl enable --now git-mirror-sync.timer systemctl list-timers git-mirror-sync.timer如果担心并发过高,可以在脚本里用GIT_HTTP_LOW_SPEED_LIMIT这类参数给 Git 设置低网速上限,超过时限就自动失败并退出,避免一次卡死拖垮后面的仓库。
3.4 对外提供简单访问入口
裸仓库如果只放在文件系统里,别人没法直接用。最省事的做法是用git daemon,它专门为匿名只读 Git 访问设计:
git daemon --base-path=/srv/git-mirror --export-all --reuseaddr --port=9418这样团队可以用git://镜像站/owner/repo.git访问。如果公司内网不允许开放 9418 端口,也可以把仓库目录放在 Nginx 静态服务下。不过只做静态文件服务会导致 git smart HTTP 的某些请求没法正确处理,因为 git http-backend 需要执行服务端 CGI 来处理info/refs之类的请求。相比之下,更省心的方式是用自托管 Git 平台,也就是下面的方案二。
4. 方案二:自托管平台做镜像仓库,体验最接近 GitHub
4.1 为什么推荐这个方案
裸仓库方案适合“只要能拉代码就行”的场景,但实际使用中,团队往往会想通过网页看分支、看提交、看标签,甚至做权限控制。这时候直接在裸仓库上重新造轮子非常不划算。
我的经验是:如果团队规模超过 5 人,就直接上自托管 Git 平台。以轻量级平台为例,它用 Go 编写,单文件部署,内存占用很小,自带 SSH 和 HTTPS 访问能力,而且内置了“迁移/镜像仓库”功能,可以定时从 GitHub 拉取远程仓库到本地。这个方案比纯脚本多了用户管理、Web 界面和 API,运维负担反而更低。
有人担心自托管平台功能太多、太重,其实没必要。只要不开一堆插件,它就是一个“带着网页后台的 git 存储层”,存储和同步逻辑比裸仓库脚本更好管。
4.2 首次配置与手动迁移
首次部署完成后,先做一次手动迁移,把流程跑通,再考虑批量。大致的步骤是这样:
- 在 GitHub 上生成一个带仓库读取权限的令牌。
- 在自托管平台里找到“迁移仓库”或“外部仓库”入口。
- 填入 GitHub 仓库地址,例如
https://github.com/owner/repo.git。 - 填上令牌,勾选“镜像”功能。
- 选择同步间隔,例如每 10 分钟同步一次。
手动迁移成功之后,平台会自动保留远程地址和令牌,每次同步只需要在平台后台触发或者按计划执行。第一次全量同步大仓库时可能耗时较长,建议留足超时时间。
4.3 API 批量导入实操
仓库数量一多,手动迁移就不可持续。好在自托管平台都提供 API。下面是一个批量创建镜像仓库的示例脚本:
for repo in $(cat repos.txt); do owner="${repo%%/*}" name="${repo##*/}" curl -s -X POST "https://git-mirror.internal/api/v1/repos/migrate" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d "{ \"clone_addr\": \"https://github.com/${repo}.git\", \"uid\": 1, \"repo_name\": \"${name}\", \"mirror\": true, \"service\": \"github\", \"auth_token\": \"${GITHUB_TOKEN}\" }" done执行之前,先在测试环境跑一次,确认 API 参数是否正确。批量导入建议加上sleep 1之类的间隔,避免触发平台的频率限制。导入完成后,可以去后台看每个仓库的同步状态,有红的就手动点一下重新同步。
4.4 不同步对象的取舍
自托管平台虽然方便,但也不建议把 GitHub 上所有仓库都同步过来。理由很简单:仓库越多,同步时间越长,存储膨胀越快,用户搜索结果里也会出现大量没人看的项目。
我会把目标仓库分成三类:
- 必须同步的:团队自己维护或深度依赖的项目,同步间隔短。
- 按需同步的:偶尔要参考的知名项目,同步间隔长。
- 不主动同步的:只在浏览器里看看文档的项目,不占镜像空间。
这样每个仓库的同步策略清晰,容量控制也好做很多。平台上如果自带了“镜像仓库限速”或“同步日志保留天数”的选项,也建议打开。
5. 方案三:给 Raw 文件、Release 附件做缓存,才是下载提速的关键
5.1 这个方案解决什么问题
Git 仓库同步解决的是“拉代码”的问题,但很多构建场景会直接下载文件,比如raw.githubusercontent.com上的脚本、release 里的二进制压缩包、通过固定 URL 下发的安装包。这些文件不走 Git 对象库,如果每次构建都直接回源,带宽消耗和延迟都会很高。
这个方案的思路很直接:在镜像站前面加一层缓存服务,按 URL 作为缓存键。第一次请求时到 GitHub 回源取文件,之后相同 URL 的请求直接用本地缓存,不再占用外网带宽。
5.2 Nginx 缓存回源的最小配置
我这里直接用 Nginx 来搭,因为它本身就是一个成熟的 HTTP 服务,有缓存模块,运维心智负担小。下面是一份最小可运行配置的大致结构:
proxy_cache_path /var/cache/github levels=1:2 keys_zone=github_cache:200m max_size=50g inactive=30d use_temp_path=off; server { listen 80; server_name raw-mirror.internal; location / { proxy_cache github_cache; proxy_cache_key "$host$uri"; proxy_cache_valid 200 7d; proxy_pass https://raw.githubusercontent.com; add_header X-Cache-Status $upstream_cache_status; } }这里几个关键点:
proxy_cache_path里的levels=1:2表示缓存目录分两层,避免单个目录文件过多。keys_zone定义缓存元数据的内存区域,200MB 足够支撑几万条 URL。proxy_cache_valid 200 7d表示成功的响应缓存 7 天,具体 TTL 看文件类型。add_header X-Cache-Status用于调试,能从响应头里看到HIT、MISS、EXPIRED等状态。
如果你只是想缓存 release 附件,把proxy_pass指向对应的下载域名,再单独配一个带更长 TTL 的 location 块即可。
5.3 缓存失效策略怎么设计
缓存最怕的也是“过期”。GitHub 上存在两类 URL,一类是包含 commit SHA 的不可变地址,一类是分支名、标签名等可变地址。前者的内容一旦发布几乎不变,TTL 可以设得很长;后者的内容会随时更新,TTL 必须短一些。
我习惯把缓存规则按路径分流:
| 路径示例 | 内容性质 | 建议 TTL |
|---|---|---|
/owner/repo/raw/main/xxx | 分支上的源文件 | 10 分钟到 1 小时 |
/owner/repo/raw/abcdef1/xxx | 固定 commit 的文件 | 30 天以上 |
/owner/repo/releases/download/xxx | release 附件 | 365 天 |
/owner/repo/archive/refs/heads/main.zip | 分支压缩包 | 10 分钟到 1 小时 |
/owner/repo/archive/abcdef1.zip | 固定 commit 压缩包 | 30 天以上 |
如果缓存服务支持按 URL 中的一部分自动分类,最好直接做成规则。如果不行,那就按目录前缀分别配不同 location。这个细节对缓存命中率影响很大,值得多花半小时调。
还要注意一点:如果 raw 文件是给 CI 机器用的,建议在请求里带一个固定版本参数,比如加?v=20250101,这样文件名一变,缓存键就变,不会出现“改了内容但下载到旧文件”的问题。
6. 上线后的监控与排查实录
6.1 监控哪些关键指标
镜像站跑起来之后,最怕的不是突然挂掉,而是慢慢劣化:磁盘一天天变满、同步任务开始积压、缓存命中率往下掉。我的监控清单一般包括这几项:
- 磁盘剩余空间:这是第一优先级,空间满了同步必挂。
- 最近一次同步时间:检查每个仓库的更新时间是不是在规定周期内。
- 同步失败次数:看是网络失败还是令牌失效。
- 缓存命中率:从 Nginx 日志里统计
X-Cache-Status的HIT比例。 - 用户拉取延迟:在镜像站请求几个热点仓库,记录耗时有没有明显变化。
日志里最好给每个响应都加上缓存状态字段,否则排查的时候要靠猜。日志上多一个字段,事后少加一个班。
6.2 常见问题速查表
我整理过一份高频问题清单,几乎每次搭建都会遇到其中几项:
| 症状 | 直接原因 | 处理办法 |
|---|---|---|
| 同步脚本报 401/403 | 令牌没权限或过期 | 重新生成令牌,只给只读权限 |
| clone 到一半断掉 | 网络抖动或超时设置过短 | 调大git config http.lowSpeedLimit相关超时参数,重试 |
| 磁盘不够 | 仓库增长超出预期 | 删除低频仓库,或者提升同步频率减少全量拉取 |
| 远端有分支但本地看不到 | 没有执行fetch --prune | 镜像脚本统一加--prune |
| 用户 clone 后仓库特别大 | 历史 object 没有被 gc | 定期执行git gc --auto |
| 缓存命中率低 | TTL 太短或缓存键带随机参数 | 按固定 commit 路径设长 TTL,调整缓存键 |
这里特别提醒一句:git clone --mirror之后的仓库不要在上面做普通提交或修改,它不是给人写代码用的工作仓库。一旦手动改了里面的引用关系,后续增量同步会产生意想不到的分叉,最后还得删除重来。
6.3 运维上容易被忽视的细节
运维层面有三个细节最容易踩坑。
第一个是 token 泄露问题。URL 里直接带 token 虽然能跑,但一旦日志被采集、命令历史被同步,token 就会裸奔。生产环境建议用环境变量配合凭证存储,或者使用自托管平台内置的令牌管理功能。
第二个是小文件过多导致 inode 耗尽。缓存目录里如果存了大量几 KB 大小的 raw 文件,磁盘虽然还有空间,但 inode 可能先满了。这种情况在文件系统里表现为“No space left on device”。解决思路是定期清理过期缓存,或者用大目录分层降低单目录文件数。
第三个是时间同步。定时任务、日志排序、证书校验都依赖正确时间。镜像站所在机器如果时间漂移,轻则日志错乱,重则 HTTPS 请求直接失败。装好 chrony 或 systemd-timesyncd,别让自己成为查半天问题才发现是时间不对的那个人。
7. 落地之后的一些心得
我最后采用的通常是组合方案:团队核心仓库走自托管平台镜像,便于维护和权限管理;release 和 raw 文件走 Nginx 缓存,给 CI 和构建下载提速;少数不常动但必须留底的仓库,用裸仓库脚本保底。三套东西各管一段,互不干扰。
如果你只需要一个人拉代码,那裸仓库方案就够;如果团队有网页浏览和权限需求,自托管平台是首选;如果每天要下载大量 release 和 raw 资源,那缓存方案无论如何都值得先做。顺序应该是:先把缓存搭好,立刻能看到下载速度变化;再上平台镜像,解决团队协作问题;最后补齐脚本和监控,让它长期可靠地跑下去。
这套东西搭起来,前两三天是最忙的,后面基本就是偶尔看一眼告警。等团队成员习惯了从镜像站拉代码、构建时间明显回落之后,你就知道当初花时间搭它是值得的。