GitHub镜像站搭建全指南:从Nginx反向代理到离线仓库同步
入行头几年我一直觉得“镜像站”是个很遥远的东西直到有次公司办公网与研发网做隔离整个研发部门需要在完全离线的环境里复用一批开源组件我才不得不认真研究GitHub镜像站怎么搭。后来发现搭一个能跑的GitHub镜像站并不难难的是想清楚你到底是需要“一个转发入口”还是“一份离线仓库”——这两条路的技术栈、存储成本、维护方式完全不同。这篇内容是我把实际搭过的两种方案、踩过的坑、以及上线之后的运维清单完整整理出来给同样被内网隔离、离线复用、代码备份折腾过的运维和DevOps同行一份可以直接照着做的参考。1. 先分清楚两种形态是转发入口还是离线仓库很多人一提到“搭建GitHub镜像站”第一反应就是“装个Nginx转发一下”。但以我实际的经验来看转发和同步是两个完全不同的物种选错方向后面会非常难受。1.1 反向代理形态一台Nginx就能跑的在线转发这种形态的本质是透明转发。用户访问内网域名比如gitmirror.example.comNginx收到请求后把请求原样转发到真正的GitHub服务再把返回的HTML页面里的链接改写成本站地址让用户感觉“这就是GitHub”。它的优点是部署快、创新性低半小时就能看到效果存储占用极低因为服务器本身不保存仓库内容所有数据都实时来自上游。缺点也很明显镜像站对外网的可用性高度依赖上游一旦访问不到镜像站也跟着废掉。所以这种方案只适合“公司本身能访问GitHub只是需要统一出口域名、做访问审计”的场景救不了“研发网彻底隔离”的命题。1.2 定时同步形态真正把仓库内容落到本地第二种形态是同步镜像。我准备一台带外网访问权限的同步机定期用Git协议把目标仓库完整克隆到本地再推送到内网自建的Gitea或GitLab实例。内网开发人员直接把git remote地址改成内部地址整个克隆、更新过程完全不依赖外网。这种形态的优点是真正的离线可用代码内容可控还能做安全审计缺点是存储成本高、同步有延迟、需要有一台干净的同步环境。如果你需要在隔离网络里长期复用开源代码或者想给核心依赖做一份带完整历史的备份那就直接选这种不要犹豫。1.3 选型决策表与我的选择逻辑我把两种形态放在一张表里方便你根据自己环境快速做决定。维度反向代理形态定时同步形态部署速度约半小时半天到一天存储占用低磁盘只做缓存高仓库越多占用越大数据新鲜度实时取决于同步周期离线可用否是登录、Issue等动态功能部分可用坑多不支持也不需要典型场景统一出口域名、访问审计内网隔离、代码备份、离线分发我当时的情况是开发网完全离线所以直接排除了反向代理选择了同步形态。如果你的公司外网访问本身没问题只是想做一个品牌域名入口那反向代理也够用。2. 轻量方案Nginx反向代理搭建透明访问入口虽然我最终没有用反向代理做主方案但我在测试环境里还是完整跑过一遍这里面有些配置细节值得写下来方便你在做技术预研时避坑。2.1 为什么选Nginx而不是自己写转发程序反向代理这事儿能轮不到自己写程序就别写。Nginx的生态里刚好有一整套需要的配件proxy_pass做转发、sub_filter做HTML内容改写、proxy_cache做静态资源缓存、auth_basic做访问控制全都在配置文件里解决。有个细节要先说sub_filter模块不是默认编进所有发行版的Nginx里的。我用的Ubuntu系统需要安装nginx-extras才能拿到这个模块。安装前先确认一下nginx -V 21 | grep --colorauto sub_filter如果输出为空说明当前Nginx不支持内容改写需要装扩展包或者重新编译。2.2 核心配置与每一个关键参数下面这份配置是我在测试环境验证过的核心思路是把所有github.com的链接改写成本站域名。server { listen 443 ssl http2; server_name gitmirror.example.com; ssl_certificate /etc/nginx/ssl/gitmirror.crt; ssl_certificate_key /etc/nginx/ssl/gitmirror.key; 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_set_header X-Forwarded-Proto https; proxy_redirect https://github.com/ https://gitmirror.example.com/; sub_filter_once off; sub_filter_types text/html text/css application/javascript; sub_filter https://github.com https://gitmirror.example.com; sub_filter http://github.com http://gitmirror.example.com; } }逐项解释一下为什么这样写。proxy_pass https://github.com直接以域名方式转发Nginx会自动处理上游的TLS SNI比用upstream块更省心。proxy_set_header Host github.com必须设置否则GitHub收到请求时看到的是gitmirror.example.com会返回404。proxy_redirect处理的是HTTP响应头里的Location字段sub_filter处理的是响应体里的HTML文本两个缺一不可。缓存不是必须的但如果想给Release下载路径做缓存可以再补一个proxy_cache_path /var/cache/nginx/github levels1:2 keys_zonegithub_cache:128m max_size20g inactive60m use_temp_pathoff;然后在location /里加上proxy_cache github_cache; proxy_cache_valid 200 302 10m;。我实际用下来缓存页面要格外小心。GitHub的首页、仓库列表都是动态内容缓存时间一长用户会看到过期数据。我建议只对/owner/repo/releases/download/这种明确的二进制下载路径做缓存普通HTML页面不配缓存或者缓存时间压到1分钟以内。2.3 这套方案的适用边界与已知的坑第一个坑是二级域名。仓库页面里的克隆地址、下载链接经常会指向codeload.github.com和objects.githubusercontent.com这两个域名也需要在Nginx里单独配置转发否则页面上的超链接还是直达GitHub镜像站就名存实亡了。我当时给每个二级域名都建了独立的server块配置逻辑和上面一样只是把proxy_pass的目标换成对应域名。第二个坑是动态交互。登录、发Issue、PR审批这类功能涉及大量cookie和POST请求反向代理很容易把URL改写到一半导致表单提交失败。如果你只需要浏览代码和下载Release问题不大要是团队想在镜像站上协作那就别想了老老实实走自建Git服务。第三个坑要强调反向代理解决不了离线访问。它只是一个外网出口的统一封装上游一旦不可达整个镜像站就停了。判断这个方案是否适合你就只看一件事——公司能不能访问GitHub。能就选它不能直接看下一章。3. 重量方案定时同步到自建Git服务企业内网核心如果你和我一样面对的是一个完全隔离的研发网那真正要解决的问题是怎么把GitHub上的仓库“搬”到内网并且长期保持更新。这一章是全文的实操核心。3.1 为什么不用Gitea自带的镜像功能先说结论Gitea确实内置了“迁移/镜像”功能适合同步十几个仓库的小规模场景但对几十上百个仓库的批量同步我建议用脚本自己拉。原因有三。第一Gitea的镜像任务是后台队列式的大批量提交时容易出现积压、超时第二它没法对不同的仓库设置不同的同步节奏我想做到“热门的每小时同步、冷门的每天同步”在界面上操作很别扭第三镜像任务的状态、日志、失败重试策略都不够细接入Prometheus监控更麻烦。自己写脚本反而更透明每次同步的结果都可以落盘出了故障一眼能看到。3.2 同步对象与同步名单管理同步之前先做减法。不要试图把所有GitHub仓库都镜像下来没那个必要。我习惯维护一个repo-list.txt每行一个owner/repo比如core/linux rust-lang/rust gitea/gitea kubernetes/kubernetes grafana/grafana如果没有特别需求我默认只同步默认分支和所有tag。这样既保留了完整版本历史又不会因为几个不活跃的feature分支浪费存储。同步名单建议纳入Git管理每次变更都留痕方便回溯。3.3 同步脚本clone --mirror push --mirror我用的核心命令是git clone --mirror。它的作用是把远程仓库的所有分支、tag、refs都克隆成一个裸仓库之后每次更新只需要git remote update --prune就能把上游的新提交同步下来。下面是一个能直接跑的脚本骨架#!/usr/bin/env bash set -euo pipefail UPSTREAM_BASEhttps://github.com LOCAL_BASE/srv/git-mirror GITEA_PUSH_BASEssh://gitgitea.internal LOG_DIR/var/log/gh-mirror mkdir -p ${LOG_DIR} while IFS read -r repo; do repo${repo//[[:space:]]/} [ -z ${repo} ] continue owner${repo%/*} name${repo#*/} mirror_dir${LOCAL_BASE}/${owner}/${name}.git # 首次同步全量克隆 if [ ! -d ${mirror_dir} ]; then git clone --mirror ${UPSTREAM_BASE}/${repo}.git ${mirror_dir} else git -C ${mirror_dir} remote update --prune fi # 推送到内网Gitea同样使用mirror语义 git -C ${mirror_dir} push --mirror ${GITEA_PUSH_BASE}/${owner}/${name}.git echo $(date -Iseconds) ${repo} done ${LOG_DIR}/mirror.log done ${LOCAL_BASE}/repo-list.txt推到Gitea之前得保证目标仓库在Gitea里已经存在。用API批量创建空仓库比较方便curl -X POST \ -H Authorization: token ${GITEA_TOKEN} \ -H Content-Type: application/json \ -d {name:linux,owner:core,private:false,auto_init:false} \ https://gitea.internal/api/v1/orgs/core/repos创建之后执行git push --mirror ssh://gitgitea.internal/core/linux.gitGitea就会收到一份完整的裸仓库。这里注意Gitea端创建仓库时要把auto_init设为false否则仓库里会有一个初始化的README跟镜像推送产生ref冲突。3.4 大仓库与LFS的特殊处理如果你的镜像名单里有大量使用Git LFS的仓库情况会复杂一些。git clone --mirror默认只拉取LFS指针不拉取真正的LFS文件对象。同步机上要在仓库目录里执行git lfs fetch --all然后再推送到内网Gitea时也要确保推送的是LFS对象本身git lfs push --all ${GITEA_PUSH_BASE}/${owner}/${name}.git这一块特别容易忽略。我踩过一坑某次同步完git sf仓库后内网同事clone下来发现LFS文件全是几百字节的指针文件后来才意识到是同步端没有执行lfs fetch --all。存储规划时也务必把LFS体积算进去这个后面章节详细说。Release资产二进制包又是另一层内容。镜像仓库不等于Release下载缓存想在离线环境里也能下载Release包得单独用GitHub API或ghCLI把assets抓下来gh api repos/${owner}/${repo}/releases --paginate \ | jq -r .[] | .assets[]?.browser_download_url \ | xargs -P 4 -n 1 wget -c -P /data/releases/${owner}/${repo}/这串命令的意思是分页拉取所有Release记录提取assets的下载URL然后用4个并发把文件下载到本地的Release目录。下载完成后可以用Nginx或者其他静态文件服务把/data/releases对外暴露内网开发就能直接访问了。3.5 调度与错峰cron和并发控制同步脚本本身不复杂难的是调度的合理性。我现在的做法是每小时增量同步一次热门仓库每天凌晨做一次全量校验和repack。# crontab 0 * * * * /usr/local/bin/gh-mirror.sh --incremental 30 2 * * * /usr/local/bin/gh-mirror.sh --full-check增量同步时要防止同步时间过长导致脚本重入。最直接的办法是用flock给脚本加锁exec 9/var/lock/gh-mirror.lock flock -n 9 || exit 1另外大批量仓库同步时要控制并发数别一把梭。我一般用xargs -P 3把并发控制在3个原因是GitHub的匿名请求有速率限制60次/小时如果走API拉Release信息一定要带上token否则同步还没跑完就被限流了。4. 上线前的硬指标域名、证书、存储和带宽估算很多镜像站建好后出问题都不是出在同步逻辑上而是一开始就没有把域名、证书、存储这些“硬指标”算明白。4.1 域名与内网DNS设计镜像站对外的域名我建议用gitmirror.internal这样明确属于内部的二级域名避免和公司已有的GitLab、Gitea入口混淆。DNS层面做一条CNAME记录指向同步服务器如果公司有多个办公园区就在每个园区的内网DNS里都配置相同的解析保证所有研发同学访问的是同一个域名。域名确定后所有内网clone地址就统一了。以后想从“同步镜像”切换到“自建集群”只需要改DNS指向不用让每个开发改remote这是域名设计的核心价值。4.2 证书方案的两种选择内网镜像站的证书有两个选择。第一种公司内部已有AD/CA体系直接申请签发。这种最省事所有已经加域的机器自动信任内部CAgit clone不会报SSL错误。第二种没有内部CA用自签名证书。这会导致内网git clone时报SSL certificate problem需要让客户端显式信任这份证书。可以把CA证书放到指定路径然后在客户端配置git config --global http.sslCAInfo /etc/ssl/certs/internal-ca.crt注意这是整台机器级别的信任换了新机器要重新配置一次。如果团队人数多建议顺手写一个初始化脚本让研发同学跑一下就能完成信任配置避免每个人手工操作时漏掉。4.3 存储容量估算按仓库体积留三倍余量镜像站的存储需求比想象中大得多。我给一个经过实践的估算公式实际占用 ≈ Git对象体积 LFS体积 Release资产 临时膨胀空间 备份空间举个例子。假设要同步50个仓库平均每个1GB。Git对象体积约50GBLFS体积不好估算按平均2GB算约100GBRelease资产约20GBGit在做repack时临时空间会膨胀到对象总量的1.3到1.5倍也就是额外预留20GB左右再留一份同容量的备份。这样算下来至少需要 50 100 20 20 190 380GB 级别。我的建议是起步容量取“初始对象体积 LFS Release”总和的3倍目录用LVM或ZFS方便以后扩容。千万别只按初始体积买盘等磁盘满了再折腾迁移那是最痛苦的事。4.4 带宽与首次全量同步时间估算首次全量同步是耗时大户我对时间做了个简单估算方便你排计划。假设需要拉取的Git对象总量是40GB出口带宽是50Mbps约6.25MB/s理想耗时是40GB ÷ 6.25MB/s ≈ 6553秒 ≈ 109分钟注意这只是理想值。Git在克隆过程中有握手、对象压缩、校验等开销实际耗时通常是理想值的1.5到2倍大概3小时左右。如果你的仓库里混着大二进制资源耗时会更夸张。所以首次全量同步一定要安排在低峰时段并且脚本里加好--prune参数避免同步中断后残留垃圾数据。5. 同步失败的恢复链路与一致性保障同步脚本跑起来之后最大的问题是“它到底跑成没跑成”。我在维护过程中总结了一套排查链路按顺序走大部分故障都能在半小时内定位。5.1 最常见的三类故障现场第一类是磁盘满。同步大仓库时对象临时膨胀很容易把磁盘撑爆Git进程异常退出留下一个不完整的裸仓库。第二类是fetch中断网络波动导致remote update只更新了一半refsgit对象实际完整但远端refs缺失。第三类是LFS指针残留仓库克隆成功但LFS对象没拉全内网用户拉取时会看到指针文件。这三类故障的症状完全不同但排查思路是一样的。5.2 从日志到fsck的完整排查链路遇到同步失败第一步不是删目录重来而是先查日志tail -n 50 /var/log/gh-mirror/mirror.log日志里通常会留下最后一次成功的时间点以及报错信息。第二步是确认没有残留的Git进程在跑防止误删锁文件后造成更严重的问题ps aux | grep [g]it确认没有进程后再检查裸仓库的锁文件和完整性cd /srv/git-mirror/core/linux.git git fsck --fullgit fsck会扫描整个对象库输出缺失、损坏的对象。如果只有少量坏对象可以尝试git gc --prunenow修复如果坏对象很多或者连fsck都在半路崩了最靠谱的办法是删掉这个仓库的目录重新clone。我经历过两三次重clone虽然耗时但比修破损对象库要省心得多。之后对比远端refs确认同步没有“假成功”git ls-remote origin refs/heads/main git show-ref refs/heads/main如果两条命令输出的commit SHA一致说明当前镜像内容和上游一致不一致说明同步任务只执行到了fetch阶段push阶段没有跑需要重新执行脚本并重点观察push输出。5.3 manifest机制让每次同步有据可查日志只能告诉你“同步跑完了”但判断“这次同步是否有意义”得靠清单。我引入了一个简单的manifest.tsv每次同步完成后记录仓库、分支、HEAD commit、对象包大小和同步时间# owner/repo ref head_sha objects_size sync_time core/linux refs/heads/main 5f3c9a1... 2147483648 2026-04-23T02:30:0008:00脚本里增加一行清点逻辑head_sha$(git -C ${mirror_dir} rev-parse refs/heads/main) objects_size$(du -sb ${mirror_dir}/objects | awk {print $1}) echo -e ${repo}\trefs/heads/main\t${head_sha}\t${objects_size}\t$(date -Iseconds) ${LOG_DIR}/manifest.tsv有了这份清单我每天早上巡检时只需检查“最后一次同步时间距离当前时间是否超过预设周期”“HEAD commit是否连续多天没变化”就能判断同步链路是否健康而不是等开发反馈“代码是一个月前的”才追悔莫及。6. 把镜像站当服务来运营监控、安全与备份镜像站本质上是一个需要值守的小型服务光有同步脚本远远不够。最后这部分是我认为经验价值最高的内容怎么从“能跑”升级到“稳”。6.1 同步任务能跑不等于平台健康我接触过很多搭镜像站的团队脚本一挂cron就当甩手掌柜直到开发同学说拉下来的代码不对才发现问题。我自己的巡检清单包含四件事。第一磁盘使用率。镜像仓库的膨胀速度比你想象得快尤其在有大量LFS仓库的情况下。df -h /srv/git-mirror第二最新同步时间。用manifest文件判断“最近一次同步距今多久”超过两倍同步周期就要告警。tail -n 1 /var/log/gh-mirror/manifest.tsv第三残留锁文件。用一条find命令检查是否有同步中断的残留find /srv/git-mirror -name *.lock正常情况下应该没有输出。有输出就说明有同步任务异常退出需要人工介入。第四同步日志里的error关键字。在cron命令里直接加一行检查grep -iE error|fatal|ssh: connect to host /var/log/gh-mirror/*.log如果团队已经用Prometheus Grafana可以把这些指标都做成监控面板没有的话最简单的方案是写一个巡检脚本把异常输出通过企业微信或邮件发出来。先有告警再谈面板。6.2 安全边界访问控制、密钥与密钥泄露扫描镜像站里保存的是外部开源代码本身不算高度敏感但它处于内网一旦被攻破就可能成为横向移动的跳板。我的安全做法有三条。第一不直接暴露公网。镜像站只能从内网访问如果跨园区访问走企业专线或堡垒机而不是给Nginx开公网端口。第二Gitea的SSH访问单独用一个部署账号密钥单独签发不要用管理员密钥。管理员密钥一旦被拿走等于整个仓库体系都暴露了。第三定期做密钥泄露扫描。同步下来的仓库里可能藏着历史上的硬编码密钥我用pipelines定期跑一次扫描或者直接用gitleaks这类工具扫一遍结果归档备查。6.3 备份策略镜像站要不要备份镜像站的内容理论上可以从上游重新拉取但重新拉一遍的时间成本和带宽成本非常高所以备份不是“可选项”而是“必选项”。我目前的做法是对/srv/git-mirror裸仓库目录做每日增量备份备份策略用rsync --link-dest这样只占增量空间LAST$(ls -d /backup/gh-mirror/* | tail -n 1) rsync -a --link-dest${LAST} \ /srv/git-mirror/ \ /backup/gh-mirror/$(date %F)/另外Gitea本身的用户、权限、配置也要备份直接使用Gitea自带的备份命令gitea dump --config /etc/gitea/app.ini这个命令会把仓库数据、数据库、配置打包成一个zip放到指定备份目录。备份保留周期我建议至少一个月因为有些上游仓库会删除历史tag或rebase分支一旦本地只剩最新状态想要回溯就难了。最后聊一点个人体会。镜像站这个项目最容易被忽略的其实是“同步名单”和“维护习惯”而不是技术本身。我见过太多人搭好一台机器跑通了第一个仓库的同步然后就没有然后了一个月后同步脚本因为token过期悄悄失败开发在拉取时才意识到代码停在了很久之前。所以建议把镜像站当成一个需要值守的小型服务来运营把失败告警和日志纳入日常巡检这比一开始就追求同步多少个仓库更要紧。目前我正在把同样的机制延伸到内网包管理器上让整个离线研发链路的依赖获取都能自洽这也是镜像能力天然该长出去的方向。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →