ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

GitHub 内网镜像搭建实战:把 Clone 从几分钟降到十秒

GitHub 内网镜像搭建实战:把 Clone 从几分钟降到十秒 最近我们组做一次大型依赖升级十几个开发同时开工结果第一波 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?servicegit-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 能力分成了三类这三类的上游域名和流量特征完全不同场景实际访问目标主要传输内容对镜像的需求浏览网页和 APIgithub.com、api.github.comHTML、JSON、静态资源低延迟可接受缓存clone/fetch 仓库github.com/owner/repo.gitGit 对象、引用列表频繁交互缓存难度大下载 Release 附件302 跳转到objects.githubusercontent.com、codeload.github.com几百 MB 的大文件量大需要主动中转拉取容器镜像ghcr.ioDocker 镜像层独立于网页链路另作处理这个区分非常重要。如果你的方案只覆盖了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?servicegit-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 levels1:2 keys_zonegithub_cache:50m \ max_size10g inactive60m use_temp_pathoff; 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 Nginxnginx -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 21同步时加--prune很重要否则上游删除的分支和标签不会同步回来时间久了内网镜像会积累一堆垃圾引用。4.2 Gitea 镜像仓库多仓库管理的正确选择裸仓库法适合临时救急仓库一多就不行了。私有的裸仓库没有界面没有权限控制没有 Web 浏览团队用起来很痛苦。我后来把整个内网 Git 服务迁到了 Gitea它内置了仓库镜像同步功能体验好了不止一个档次。用 Docker 启动一个 Gitea 实例services: gitea: image: gitea/gitea:latest container_name: gitea restart: always environment: - GITEA__server__DOMAINgitea.internal - GITEA__server__ROOT_URLhttp://gitea.internal:3000/ - GITEA__service__DISABLE_REGISTRATIONtrue 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 保留一个额外 remotegit remote add upstream-github https://github.com/myteam/core-lib.git然后提交 PR 时推到upstream-github开发时从origin拉取。这样各司其职不容易出现冲突。4.4 权限控制内网服务也要设门槛内网不等于没有安全边界。Gitea 默认开放注册非常危险我见过不少团队把 Gitea 部署起来之后忘了关注册结果什么人都能注册账号、拉取代码。务必在配置里设置DISABLE_REGISTRATIONtrue或者干脆关闭注册后用管理员账号创建成员。镜像仓库如果是公开项目内网登录即可拉取。如果是私有项目建议设置团队级别的读权限只允许相关成员访问。虽然从技术角度内网风险可控但代码资产的管理规范不能省。5. 实操三Release 与容器镜像的中转缓存5.1 为什么 Release 下载不能直接靠 Nginx 缓存最初我也试图用 Nginx 的缓存来解决 release 大文件下载效果很差原因有两个。第一release 下载地址是动态签名 URLURL 里的 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_keyMINIO_ACCESS_KEY, secret_keyMINIO_SECRET_KEY, secureFalse) headers {Accept: application/vnd.githubjson} if GITHUB_TOKEN: headers[Authorization] fBearer {GITHUB_TOKEN} release requests.get(GITHUB_API, headersheaders).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, headersheaders, streamTrue) as r: r.raise_for_status() client.put_object( BUCKET, object_key, r.raw, length-1, part_size10 * 1024 * 1024, content_typer.headers.get(content-type), ) print(fsynced: {object_key})脚本跑一次之后团队就可以从http://minio.internal/github-release/v1.2.0/app.tar.gz直接下载速度大概率跑满内网带宽。如果团队已经有 Nexus 或 Artifactory也可以用同样的思路走 raw 仓库接口脚本改动很小。5.3 容器镜像 ghcr.io 的同步思路除了普通 release团队如果用了 GitHub 的容器镜像仓库ghcr.ioCI 里docker pull ghcr.io/myteam/xxx:1.0慢也是一个高频痛点。容器镜像的层数据通常体积很大走入口转发不现实最稳的做法是把镜像同步到内网 Harbor。同步工具我推荐 skopeo它可以直接把镜像从 ghcr.io 复制到 Harbor不需要在本地先 docker pull 再 pushskopeo 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 zonegithub_limit:10m rate5r/s; server { location / { limit_req zonegithub_limit burst20 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?servicegit-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 配置不可能包打天下按需分层才是这套体系真正稳定下来的关键。
返回列表