最近我们组做一次大型依赖升级,十几个开发同时开工,结果第一波 clone 就把人卡在原地。有人仓库拉到一半报错,有人 CI 里拉依赖直接超时重试。问题根本不是代码冲突,而是大家都在等 GitHub 传数据。GitHub 的服务节点主要在海外,跨洋链路的物理时延和带宽拥塞是客观存在的,平时自己拉一两个小仓库没什么感觉,一旦团队并发拉取,问题就会被无限放大。我当时也劝大家“多试几次”,但治标不治本。最后花了两天时间,在团队内网搭了一套镜像服务,把 clone 速度从几分钟压到了十秒以内。这篇文章就是完整搭建记录,覆盖方案选型、Nginx 入口转发配置、仓库级镜像同步、Release 下载瓶颈,以及后续维护踩过的坑。适合同样被 Git 拉取耗时折磨的中小型团队、实验室、企业内部开发组,也适合想了解 GitHub 数据链路构成的朋友。
1. 别急着搭:先拆解 Git 拉取的三个慢点
1.1 一条 clone 命令到底经历了什么
很多人在 GitHub 镜像这件事上失败,不是因为不会配 Nginx,而是没搞清楚慢在哪里。先看一条git clone https://github.com/owner/repo.git命令背后发生了什么:
- DNS 解析
github.com,拿到服务器 IP。 - TCP 三次握手:客户端到服务器一个往返(RTT),如果物理距离远,这个 RTT 本身就很高。
- TLS 握手:再增加一到两个往返。
- 发送
GET /owner/repo.git/info/refs?service=git-upload-pack,获取仓库引用列表。 - 根据客户端和服务端对象差异,再发一个
POST /owner/repo.git/git-upload-pack,传输 Git 对象数据。
关键点在于:Git 的 smart HTTP 协议是典型的“请求-响应”模式,每发一个请求都要等一个完整的 RTT。RTT 高的时候,即使带宽很宽,实际吞吐也上不去。这背后是网络里经典的带宽时延积(BDP)问题:管道容量 = 带宽 × RTT。如果 RTT 是 200ms,带宽是 100Mbps,理论上单连接的管道容量大约 2.5MB。一旦出现少量丢包,TCP 拥塞控制会主动收缩窗口,有效吞吐会掉到几 Mbps。这就是为什么你下载小文件感觉还行,一拉大仓库速度就惨不忍睹。
我常用一个类比:带宽相当于高速公路宽度,RTT 相当于收费站离你家的距离。收费站太远,你每次调头都要多花时间;高速上再宽,只要有人频繁急刹(丢包),整个车流速度也起不来。所以 Git 镜像要解决的,本质上是“降低 RTT”和“减少跨网丢包”这两个问题,而不是单纯把带宽堆上去。
1.2 网页、仓库、Release 其实是三条不同的链路
很多教程喜欢笼统地说“GitHub 慢”,但实际慢的场景是分层的。我在搭镜像前,把团队常用的 GitHub 能力分成了三类,这三类的上游域名和流量特征完全不同:
| 场景 | 实际访问目标 | 主要传输内容 | 对镜像的需求 |
|---|---|---|---|
| 浏览网页和 API | github.com、api.github.com | HTML、JSON、静态资源 | 低延迟,可接受缓存 |
| clone/fetch 仓库 | github.com/owner/repo.git | Git 对象、引用列表 | 频繁交互,缓存难度大 |
| 下载 Release 附件 | 302 跳转到objects.githubusercontent.com、codeload.github.com | 几百 MB 的大文件 | 量大,需要主动中转 |
| 拉取容器镜像 | ghcr.io | Docker 镜像层 | 独立于网页链路,另作处理 |
这个区分非常重要。如果你的方案只覆盖了github.com这个域名,那 release 下载和源码归档包的重定向链路大概率管不住。后面我在实操环节会单独讲这个坑。
1.3 先花五分钟做一次测速,判断瓶颈
动手前先量化一下问题。用 curl 看关键节点的耗时:
# 测 clone 链路中 info/refs 的响应时间 curl -o /dev/null -s -w \ 'DNS:%{time_namelookup} Connect:%{time_connect} TLS:%{time_appconnect} Total:%{time_total}\n' \ "https://github.com/octocat/Hello-World.git/info/refs?service=git-upload-pack" # 测 release 下载的重定向链路 curl -L -o /dev/null -s -w \ 'Total:%{time_total} Speed:%{speed_download}\n' \ "https://github.com/owner/repo/releases/download/v1.0.0/package.zip"如果 DNS 耗时很长,先检查本地 resolver;如果 Connect 和 TLS 耗时长,基本可以确认是链路 RTT 问题;如果 Total 长但速度表数字很难看,大概率是拥塞和丢包。把这三个数字记录下来,后面搭完镜像再跑一遍同样的命令,用数据说话。
2. 三种镜像思路怎么选:入口转发、仓库同步、制品中转
2.1 入口转发型:最贴近原始访问方式
这是最直觉的一种思路:在内网搭一个 Nginx 服务,把访问 GitHub 的请求转发到上游github.com,再把响应原样返回给使用者。团队使用的时候,只需要把 URL 里的github.com换成内网域名即可。
优点是覆盖范围广,任意仓库都能走这个入口,不需要提前同步。缺点是首次访问仍然依赖上游链路质量;Git 操作里的 POST 请求不适合缓存,缓存价值有限;而且 GitHub 的重定向域名很多,一个入口解决不了所有问题。我的判断是:入口转发适合作为应急手段,或者团队只有十几个活跃仓库、先跑通再优化的过渡方案。
2.2 仓库同步型:把核心仓库搬到内网
入口转发的最大问题是“实时依赖上游”。要彻底解决,就得让数据真正落到内网。方法是通过git clone --mirror把团队的核心仓库完整复制到内网,再定时去 GitHub 拉取增量更新。团队日常 clone、fetch 全部走内网地址,只有同步这个动作需要访问外网。
优点是日常使用完全不依赖上游,速度和稳定性彻底可控。缺点是你只能覆盖明确圈定的仓库,且镜像更新有延迟。对于团队长期维护的十几个核心仓库,这是最实用的方案。
2.3 制品中转型:专治 CI 和装机场景
release 大文件、源码归档包、容器镜像这类“制品”,本质上是一次生成、多次使用的数据。把它们主动同步到内网对象存储或制品库,再让 CI 和团队成员从内网拉取,效果远好于 Nginx 缓存。
这是因为 release 下载地址是带签名的动态 URL,签名一变,缓存就很难命中。而且这些文件几百 MB 甚至数 GB,冷缓存下的首次回源依然很慢。制品中转属于“换条路走”——不再试图优化链路,而是改变数据来源。
2.4 选型对照表
| 维度 | 入口转发型 | 仓库同步型 | 制品中转型 |
|---|---|---|---|
| 覆盖范围 | 所有github.com路径 | 指定仓库 | 指定 release/镜像 |
| 实时性 | 实时 | 分钟级到小时级 | 触发式或定时 |
| 资源占用 | CPU、内存、带宽随请求波动 | 磁盘较多,同步时占用带宽 | 对象存储容量 |
| 日常依赖上游 | 是 | 仅同步时 | 仅同步时 |
| 实现成本 | 低 | 中 | 中高 |
| 最适合场景 | 临时应急、零星拉取 | 日常核心仓库开发 | CI/CD、版本发布、批量装机 |
我实际搭建的顺序是:先做入口转发应急,第二天补上仓库同步,最后用制品中转解决 CI 的 release 拉取问题。三个阶段逐步叠加,每个阶段都能独立产生价值。
3. 实操一:Nginx 入口转发服务的完整搭建
3.1 准备域名、证书和目录
先准备一台 Linux 服务器,2 核 4G 的配置对中小团队绰绰有余。域名建议用内网域名,例如git-mirror.internal,并在内网 DNS 里加一条 A 记录,指向这台服务器。没有内网 DNS 的话,临时在开发机的 hosts 文件里加也行,但长期看还是建议统一 DNS。
证书是个隐蔽的坑。第一次实验我建议直接用 HTTP,先把链路跑通,再考虑 HTTPS。如果必须 HTTPS,不要用自签证书后让每个人去改 git 配置,正确做法是在内网建一个私有 CA,然后把根证书分发到所有开发机的系统信任库。这一步越早做越好,否则后面会不断遇到证书报错。
3.2 Nginx 主配置逐段解释
假设我的内网服务域名是git-mirror.internal,服务器能把请求转发到外网github.com。下面这个配置是我实际跑过的版本:
# 缓存目录,注意 levels 和 keys_zone 的写法 proxy_cache_path /var/cache/nginx/github levels=1:2 keys_zone=github_cache:50m \ max_size=10g inactive=60m use_temp_path=off; server { listen 80; server_name git-mirror.internal; # 上游地址固定为 github.com 的 443 端口 location / { # 转发到上游 proxy_pass https://github.com; # 必须把 Host 头设置成 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_set_header X-Forwarded-Proto $scheme; # 访问上游时通过 SNI 携带 github.com proxy_ssl_server_name on; proxy_ssl_name github.com; # 关闭上游压缩,否则 sub_filter 无法替换内容 proxy_set_header Accept-Encoding ""; # 修改响应头中的 Location,避免 302 把客户端引导回 github.com proxy_redirect https://github.com/ /; proxy_redirect http://github.com/ /; # 缓存策略:POST 请求和 .git 相关请求不做缓存 set $skip_cache 0; if ($request_method = POST) { set $skip_cache 1; } if ($request_uri ~* "\.git/") { set $skip_cache 1; } if ($request_uri ~* "git-upload-pack") { set $skip_cache 1; } proxy_no_cache $skip_cache; proxy_cache_bypass $skip_cache; proxy_cache github_cache; proxy_cache_valid 200 302 10m; proxy_cache_valid 404 1m; # 大文件转发,超时时间放宽 proxy_read_timeout 300s; proxy_connect_timeout 10s; proxy_buffer_size 16k; proxy_buffers 16 16k; } }这里有几个关键点。
proxy_set_header Host github.com必须保留。如果按默认把git-mirror.internal传给上游,GitHub 会认为这是一个陌生的非法请求,返回 403 或异常页面。- 关闭
Accept-Encoding是为了让上游返回未压缩内容,这样后续sub_filter才能替换 HTML 里的链接。代价是流量略大,但内网带宽通常不是瓶颈。 .git路径跳过缓存尤其重要。Git 仓库的引用是动态变化的,一旦缓存了info/refs,客户端可能拿到过期引用,拉出旧代码,这个错误非常隐蔽。
接下来处理页面里的链接。浏览器访问 GitHub 页面时,返回的 HTML 里到处都是https://github.com/...。要让整个页面在内网环境下可用,需要把这些链接替换成内网域名:
location / { # 只对 HTML 做替换,git 请求不受影响 sub_filter_once off; sub_filter 'https://github.com' 'http://git-mirror.internal'; sub_filter 'http://github.com' 'http://git-mirror.internal'; sub_filter_types text/html; }注意sub_filter_once off表示替换所有出现的位置,而不是只替换第一处。实测中这个配置对仓库主页、文件浏览页都有效。如果哪天你发现网页打开了但样式全乱,十有八九是没关压缩或者 sub_filter 类型不对。
3.3 codeload 重定向:入口转发最容易漏掉的一环
下载源码归档包时,GitHub 的行为不是直接返回文件,而是先返回一个 302,把客户端引导到https://codeload.github.com/owner/repo/tar.gz/refs/heads/main。如果入口转发只处理了github.com,客户端解析codeload.github.com还是会回到慢链路,镜像形同虚设。
我的做法是再加一个转发域名,把codeload也接入进来:
server { listen 80; server_name codeload-mirror.internal; location / { proxy_pass https://codeload.github.com; proxy_set_header Host codeload.github.com; proxy_ssl_server_name on; proxy_ssl_name codeload.github.com; proxy_redirect https://codeload.github.com/ /; } }然后在主 server 里增加一条重定向改写:
proxy_redirect https://codeload.github.com/ http://codeload-mirror.internal/;这样客户端访问http://git-mirror.internal/owner/repo/archive/refs/heads/main.zip时,会先被转发到codeload.github.com,响应里的Location再被改写成codeload-mirror.internal,整个过程对用户完全透明。
但这里要提醒一句:release 下载有时不止一层重定向,objects.githubusercontent.com还会跳到 CDN 域名。如果跳转链太长,用入口转发去逐层处理是不划算的。这类场景直接跳到第 5 章的制品中转方案更省力。
3.4 验证入口转发是否生效
配置完成后,先 reload Nginx:
nginx -t && nginx -s reload然后逐项验证:
# 验证网页响应 curl -I http://git-mirror.internal/octocat/Hello-World # 验证 git clone git clone http://git-mirror.internal/octocat/Hello-World.git # 验证源码归档重定向 curl -IL http://git-mirror.internal/octocat/Hello-World/archive/refs/heads/main.zip对比首章记录的测速数据,你会看到 Connect 耗时大幅下降,因为请求终止在内网服务器。但 Total 耗时的改善程度取决于首跳回源速度。如果首次 clone 速度仍然难看,说明上游链路还是瓶颈,下一步就要升级到仓库同步方案。
4. 实操二:仓库级镜像,让团队 clone 时间落到秒级
4.1 裸仓库法:一条命令建立镜像
入口转发只是第一步。要想让团队日常工作彻底不依赖上游质量,必须把核心仓库搬到内网。最简单的方式是用--mirror克隆一个裸仓库:
mkdir -p /data/git-mirror && cd /data/git-mirror git clone --mirror https://github.com/myteam/core-lib.git--mirror和--bare的区别在于:镜像克隆会把源仓库的所有引用,包括本地分支、远程分支、标签,全部复制下来,并且后续可以用git remote update完整同步。普通裸仓库只复制默认分支,达不到镜像效果。
更新频率取决于团队活跃度。我建议每 15 分钟到 1 小时同步一次,用 crontab 或 systemd timer 实现:
#!/bin/bash # /usr/local/bin/sync-github-mirror.sh cd /data/git-mirror/core-lib.git git remote update --prune*/15 * * * * /usr/local/bin/sync-github-mirror.sh >/dev/null 2>&1同步时加--prune很重要,否则上游删除的分支和标签不会同步回来,时间久了内网镜像会积累一堆垃圾引用。
4.2 Gitea 镜像仓库:多仓库管理的正确选择
裸仓库法适合临时救急,仓库一多就不行了。私有的裸仓库没有界面,没有权限控制,没有 Web 浏览,团队用起来很痛苦。我后来把整个内网 Git 服务迁到了 Gitea,它内置了仓库镜像同步功能,体验好了不止一个档次。
用 Docker 启动一个 Gitea 实例:
services: gitea: image: gitea/gitea:latest container_name: gitea restart: always environment: - GITEA__server__DOMAIN=gitea.internal - GITEA__server__ROOT_URL=http://gitea.internal:3000/ - GITEA__service__DISABLE_REGISTRATION=true volumes: - ./gitea:/data ports: - "3000:3000" - "2222:22"然后进入 Gitea 管理界面,依次操作:
- 新建组织或仓库,仓库类型选择“镜像仓库”。
- 填写上游地址,例如
https://github.com/myteam/core-lib.git。 - 如果是私有仓库,填一个 GitHub 只读 token,公开仓库则不需要。
- 在“同步间隔”里设置好时间,Gitea 会后台自动更新。
这个方案有一个额外好处:镜像仓库同步完成之后,团队可以直接在 Gitea 网页上浏览代码、查看分支、对比历史,所有操作都在内网完成,和用 GitHub 的体验几乎没有差别。而且 Gitea 把同步进度和错误信息都显示在仓库设置页里,出了问题一眼就能看到,不需要登录 GitHub 排查。
4.3 团队 remote 地址一键切换
仓库建好之后,最烦人的是让团队每个人修改本地仓库的 remote 地址。写一个批量脚本,对一台开发机上的所有仓库统一处理:
#!/bin/bash # 遍历所有仓库目录,把 origin 切换到内网镜像 for repo in ~/code/*/; do if [ -d "$repo/.git" ]; then cd "$repo" git remote set-url origin http://gitea.internal:3000/myteam/$(basename "$repo").git echo "updated: $repo" fi done手动改单个仓库只需要一条命令:
git remote set-url origin http://gitea.internal:3000/myteam/core-lib.git踩坑提醒:镜像仓库默认是只读的,如果团队有人不小心把git push origin指向内网地址,会收到拒绝推送的错误。正确流程是 clone 和 pull 走镜像,push 仍然推到 GitHub。可以让开发者给 GitHub 保留一个额外 remote:
git remote add upstream-github https://github.com/myteam/core-lib.git然后提交 PR 时推到upstream-github,开发时从origin拉取。这样各司其职,不容易出现冲突。
4.4 权限控制:内网服务也要设门槛
内网不等于没有安全边界。Gitea 默认开放注册非常危险,我见过不少团队把 Gitea 部署起来之后忘了关注册,结果什么人都能注册账号、拉取代码。务必在配置里设置DISABLE_REGISTRATION=true,或者干脆关闭注册后用管理员账号创建成员。
镜像仓库如果是公开项目,内网登录即可拉取。如果是私有项目,建议设置团队级别的读权限,只允许相关成员访问。虽然从技术角度内网风险可控,但代码资产的管理规范不能省。
5. 实操三:Release 与容器镜像的中转缓存
5.1 为什么 Release 下载不能直接靠 Nginx 缓存
最初我也试图用 Nginx 的缓存来解决 release 大文件下载,效果很差,原因有两个。第一,release 下载地址是动态签名 URL,URL 里的 token 和签名会变化,缓存键一直在变,命中率低得可怜。第二,release 文件体积通常很大,即使缓存命中,首次冷缓存回源依然要把整个几百 MB 的文件从上游拉下来,这个过程同样慢。
与其优化链路,不如直接把文件搬到内网。团队或 CI 从内网对象存储下载,速度完全可控。
5.2 用 GitHub API 把 Release 资产同步到对象存储
我写了一个轻量 Python 脚本,定时把指定仓库的最新 release 资产同步到 MinIO。核心逻辑如下:
import os import requests from minio import Minio GITHUB_API = "https://api.github.com/repos/myteam/core-lib/releases/latest" GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") MINIO_ENDPOINT = "minio.internal:9000" MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY") MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY") BUCKET = "github-release" client = Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False) headers = {"Accept": "application/vnd.github+json"} if GITHUB_TOKEN: headers["Authorization"] = f"Bearer {GITHUB_TOKEN}" release = requests.get(GITHUB_API, headers=headers).json() object_key_prefix = f"{release['tag_name']}" for asset in release["assets"]: # asset["browser_download_url"] 就是最终下载地址 download_url = asset["browser_download_url"] filename = asset["name"] object_key = f"{object_key_prefix}/{filename}" # 流式下载并上传到 MinIO,避免一次性读进内存 with requests.get(download_url, headers=headers, stream=True) as r: r.raise_for_status() client.put_object( BUCKET, object_key, r.raw, length=-1, part_size=10 * 1024 * 1024, content_type=r.headers.get("content-type"), ) print(f"synced: {object_key}")脚本跑一次之后,团队就可以从http://minio.internal/github-release/v1.2.0/app.tar.gz直接下载,速度大概率跑满内网带宽。如果团队已经有 Nexus 或 Artifactory,也可以用同样的思路走 raw 仓库接口,脚本改动很小。
5.3 容器镜像 ghcr.io 的同步思路
除了普通 release,团队如果用了 GitHub 的容器镜像仓库ghcr.io,CI 里docker pull ghcr.io/myteam/xxx:1.0慢也是一个高频痛点。容器镜像的层数据通常体积很大,走入口转发不现实,最稳的做法是把镜像同步到内网 Harbor。
同步工具我推荐 skopeo,它可以直接把镜像从 ghcr.io 复制到 Harbor,不需要在本地先 docker pull 再 push:
skopeo copy \ docker://ghcr.io/myteam/core:1.0 \ docker://harbor.internal/myteam/core:1.0 \ --dest-creds admin:xxxxxx如果有多个镜像,写个 for 循环批量处理即可。之后把 CI 里的镜像地址全部改成harbor.internal,构建拉取耗时直接降低一个数量级。
6. 运行两个月的维护笔记:证书、缓存、限流与监控
6.1 证书过期和客户端信任问题
内网自签证书最大的坑不是搭建,而是分发。Mac 上双击安装到钥匙串、Windows 上导入到受信任的根证书颁发机构,这只是第一步。git 在 Mac 上默认使用系统的证书库,但某些终端环境或代理环境下 git 会使用自己的 CA 文件,导致报错SSL certificate problem: self-signed certificate。
最省心的做法是选一台内网 CA 服务器,统一签发,统一分发。如果实在没有条件,也要在内网文档里写清楚每个平台的安装步骤。千万别为了让问题消失,就叫大家全局设置git config --global http.sslVerify false。这个命令一旦传播开,未来任何中间人问题都会被无声忽略,隐患很大。
6.2 Git 智能 HTTP 的缓存红线
Git 的 refs 是动态变化的,入口转发服务对.git路径一定要跳过缓存。我在第 3 章的配置里已经用$skip_cache做了处理,这里再强调一下原理:如果info/refs被缓存,客户端拿到的引用可能是旧的,下载时以为本地是最新,实际上漏掉远程新提交,这类问题很难被发现,因为页面和 clone 都正常,只有代码内容不对。
6.3 并发拉取导致上游限流怎么办
入口转发会让团队的请求全部从镜像服务器的一个 IP 出去。人少没事,人多的时候就容易被 GitHub 判断为异常流量。表现是返回 403、要求验证或者速率限制。
解决方案有两个。一是给 Nginx 加访问限速,避免单个客户端的请求过于密集:
limit_req_zone $binary_remote_addr zone=github_limit:10m rate=5r/s; server { location / { limit_req zone=github_limit burst=20 nodelay; # ... 原有转发配置 } }二是把大流量场景引导到仓库同步和制品中转方案。入口转发只承担小文件和零散请求,核心仓库用内网同步后的地址,自然就规避了上游限流。
6.4 简单的效果监控方法
镜像服务不能搭完就不管。我写了一个最简单的 shell 脚本,每 10 分钟跑一次,记录两个指标:内网镜像端点响应时间,以及最近一次同步是否成功。
#!/bin/bash # /usr/local/bin/check-mirror.sh START=$(date +%s%N) curl -o /dev/null -s "http://gitea.internal:3000/myteam/core-lib.git/info/refs?service=git-upload-pack" END=$(date +%s%N) echo "$(date +'%F %T') total_ms=$(( (END - START) / 1000000 ))" >> /var/log/mirror-check.log配合 crontab 记录即可。如果某一天 Total 突然涨上去,说明同步任务可能挂了,或者上游开始限流,需要去看 Gitea 后台的同步日志。这个检查脚本虽然简陋,但足够发现问题。
6.5 公网暴露边界:镜像服务只做内网
镜像服务务必只绑定内网 IP,不要开放公网端口。如果团队有远程办公需求,走公司现有的远程接入通道访问内网,而不是把 80 和 443 暴露到公网。原因很简单:镜像服务一旦对外,任何人都能把它当免费加速节点刷流量,轻则你的服务器被打满,重则你的出口 IP 被 GitHub 限流,内部开发也跟着遭殃。
我在实际维护中还有另一个体会:镜像服务不是一次性建设,而是要跟着团队流量模式不断调整。最初只跑通入口转发,觉得问题解决了;直到被 codeload 重定向和 release 动态签名折腾之后,才意识到不同类型的请求要走向不同的通道。给后来者的建议是,动手前先问清楚团队最痛的是哪类操作——是开发机的 clone、CI 的拉取,还是发布时的下载。三个场景解法完全不同,一个 Nginx 配置不可能包打天下,按需分层,才是这套体系真正稳定下来的关键。