GitHub 镜像站这个词,在开发团队里几乎天天提到。我最早接触它,是因为每次 CI 构建都要从 GitHub 拉一堆依赖,时不时的超时、断流让构建时间变成玄学;后来帮运维搭内网代码仓库,发现把 GitHub 上的开源项目同步到内网,再配合一套统一的下载入口,能省掉大量重复等待。这篇文章就从实际需求出发,把镜像站的三种常见形态——仓库全量同步、下载缓存代理、定时资产分发——的选型逻辑、搭建步骤和踩坑记录完整过一遍。适用对象是手里有服务器、想在团队内部或自己机器上搭建一个稳定下载入口的开发者,我会尽量把每一步写成可以直接抄作业的配置。
1. 镜像站能解决什么,先想清楚再动手
1.1 镜像站不是只有一个形态
很多人一上来就问“镜像站用什么软件搭”,其实这是个伪问题。镜像站是一类需求的统称,不同场景要解决的问题完全不同,软件的差异只是表象。
第一类是代码仓库镜像。团队内网需要绕过公网波动稳定拿到某个开源仓库的代码,或者希望给 GitLab 外部项目做一份容灾备份,这类需求的核心是“把一个 Git 仓库变成内网的一个只读副本”。典型工具是 Gitea、GitLab 的 Pull Mirror,或者干脆用裸仓库加定时任务。它实时性不需要太高,同步间隔几小时甚至一天都能接受,但仓库数据必须完整,分支和标签不能丢。
第二类是下载缓存代理。开发者从 GitHub 拉 Release 文件和仓库压缩包非常频繁,这些东西体积动辄几十 MB 到几 GB,直接从源站拉会占用大量时间。这类需求的核心是“在服务器上缓存高频访问的大文件,下次来取直接本地回包”。它比仓库镜像实时性要求更高,但缓存的是静态产物,只要版本和 URL 不变,缓存就能一直命中。
第三类是静态资产定时分发。有些成熟项目的二进制其实变化很慢,比如 kubelet、helm、protoc、hadoop 生态的一堆 jar。你完全可以定时把需要的版本拉到自己服务器上,用内网链接统一分发。这种方式不追求“镜像所有内容”,而是精确控制要同步哪些仓库、哪些 tag、哪些文件,存储开销可控,最容易被团队接受。
这三类形态不是互斥的。我见过很多团队先搭仓库同步,发现 CI 依然要下载依赖包,再补一个下载缓存,最后把固定版本挪到静态目录。你也可以从任何一个形态开始,关键是先搞清楚你的痛点是“代码拉不下来”还是“大文件下载慢”。
1.2 三种形态怎么选,一张表说清楚
| 形态 | 存储开销 | 实时性 | 部署难度 | 核心价值 |
|---|---|---|---|---|
| 仓库级只读镜像 | 中等,仓库 .git 体积累积 | 低,分钟到小时级同步 | 低,Gitea 界面点几下就行 | 内网稳定 clone 代码,容灾备份 |
| 下载缓存代理 | 中等,缓存可设置淘汰策略 | 高,第一次回源后立即命中 | 中,需写一点脚本逻辑 | 解决 Release / 压缩包下载慢 |
| 静态资产定时分发 | 可控,只保留指定版本 | 低,依赖定时任务 | 低,脚本加静态目录 | 固定版本工具链内网分发 |
选型的判断标准其实就两条。需要访问完整分支历史、打标签的人,选仓库镜像。只是拿发布产物、不关心历史版本的人,选缓存代理或静态分发。如果你的场景两者都有,仓库镜像打底,下载缓存铺在它前面当入口,这是最舒服的组合。
2. 形态一:仓库级只读镜像,用 Gitea 同步 GitHub
2.1 为什么我选 Gitea 而不是 GitLab
GitLab 的 Pull Mirror 功能同样好用,但做纯粹的只读镜像仓库,Gitea 更轻。GitLab 一个实例光内存就至少要 4GB,跑起来之后后台还有一堆 Runner、Prometheus、PostgreSQL 组件在消耗资源;Gitea 是个单体程序,配合 SQLite 就能跑,512MB 内存的机器照样顺畅,这对很多只有一台小服务器的团队非常友好。
更重要的是 Gitea 原生支持“镜像仓库”的概念,新建仓库时直接选“镜像仓库”,填入源地址就能开始同步,之后界面里能看同步状态、同步时间,还能手动立即同步。GitLab 虽然也能配 remote mirror,但整个界面和权限模型要重很多,为了一个只读副本不值得。
2.2 服务端搭建,docker-compose 是最快的路
我习惯用 Docker 部署,升级和回滚都方便。下面这份 docker-compose.yml 是最小可用版本,实际使用只需要把 git.example.com 换成你的域名。
services: gitea: image: gitea/gitea:latest container_name: gitea restart: unless-stopped environment: - USER_UID=1000 - USER_GID=1000 - GITEA__server__DOMAIN=git.example.com - GITEA__server__ROOT_URL=https://git.example.com/ - GITEA__server__DISABLE_SSH=false - GITEA__database__DB_TYPE=sqlite3 - GITEA__database__PATH=/data/gitea/gitea.db volumes: - ./gitea:/data ports: - "3000:3000"启动之后浏览器访问 http://服务器IP:3000,进入安装页面。这里的关键点是 ROOT_URL 要填最终对外域名,否则生成的克隆地址会是 IP 加端口,别人拿到链接也用不了。数据库直接用 SQLite 就行,不需要单独起 MySQL,少一个进程少一分运维负担。
如果你不想用 Docker,官方也提供 Linux 二进制包,解压后直接运行./gitea web也能跑起来。区别只是更新方式,二进制包升级要手动替换文件,Docker 一条命令搞定,更推荐 Docker 方式。
2.3 建立镜像仓库的关键配置
在 Gitea 登录后点右上角“新建仓库”,页面底部能看到一个“镜像仓库”的开关,选中它,仓库类型自动变成“只读的镜像仓库”。然后在“拉取地址”填 GitHub 仓库的地址,比如:
https://github.com/kubernetes/kubernetes.git如果仓库是公开的,这个地址就够了;如果是私有仓库,需要在地址里带上 Token:
https://<你的GitHubToken>@github.com/yourname/private-repo.git同步间隔我建议设在 180 分钟到 12 小时之间。间隔太短容易触发 GitHub 的频率限制,太长又会让代码不够新。默认的 8 小时其实挺合理,如果有紧急需求,手动点一次“立即同步”就行。
还要注意两个开关:“同步时删除远端不存在的分支”建议打开,避免内网残余一堆已被源仓库清掉的老分支;“镜像仓库同步时使用本地用户”保持默认。创建完成后,Gitea 会在后台跑首次同步,大仓库可能要等几分钟到几十分钟,可以在“站点管理 → 后台任务”里观察进度。
2.4 同步原理和几个实际坑
Gitea 的 Pull Mirror 本质上是定期执行git fetch。Git 的对象存储是内容寻址的,每个对象以 SHA-1 为文件名,内容相同的对象不会重复存储,所以增量同步时第二次以后很轻快,只有新提交产生的对象需要传输。你可以在服务器上进入仓库目录,运行git count-objects -vH看实际的大小和松散对象数量。
实际使用中这几个坑最常遇到:
第一次同步大型仓库经常超时。GitHub 上的 monorepo 仓库 .git 目录动辄几个 GB,首次传输时间可能超过 Gitea 默认的超时配置。建议在首次同步前临时把服务器上的网络超时参数调大,或者选择半夜带宽空闲时创建镜像,不要在工作时间启动。
私有仓库同步提示认证失败。除了在 URL 里带 Token,还要确认 Token 具备repo权限。GitHub 的个人访问令牌默认有很多权限,如果你自定义过 scope,务必检查repo是否勾选。
镜像仓库里没有 LFS 文件。Gitea 的 pull mirror 只是同步 Git 对象,不自动同步 Git LFS 存储。项目如果依赖 LFS,需要在源服务器用git lfs fetch --all拉完再想办法传到镜像侧。我的建议是,如果团队确实依赖 LFS 文件,不要完全依赖仓库镜像,把 LFS 文件单独放进对象存储或 Nextcloud 分发会更稳。
磁盘 inode 被占满。Git 仓库小文件极多,一亿个对象拖垮的可能不是容量而是 inode。部署前用df -i看一眼服务器,inode 使用率超过 80% 就要小心,最好把 Gitea 数据目录放在 XFS 或 ext4 这类支持大量小文件的文件系统上。
3. 形态二:下载缓存代理,Python 小服务吃掉远端的 302
3.1 为什么不用纯 Nginx 反代
很多人第一反应是“用 nginx 反代 github.com 不就行了吗”,我最初也这么试过,结果很快就发现一个关键问题:GitHub 的 Release 下载和仓库压缩包下载,服务端都会返回 302 跳转,浏览器会跟随跳转到对象存储节点,比如objects.githubusercontent.com或codeload.github.com。
nginx 的反向代理基本不会替客户端跟随这些重定向,它只是把 302 响应原样返回给客户端。客户端拿到跳转地址后,仍然要直连那些对象存储域名,下载速度没任何改善。
而且很多响应里的 URL 是绝对路径,指向的域名并不在你代理的范围内,修改proxy_redirect也处理不了跨域名的跳转。所以纯 nginx 做 GitHub 下载加速,很难做完整。
解决思路是用一个服务端脚本去请求目标 URL,主动跟随所有 302,把最终文件下载到本地磁盘,再作为一个完整的响应交给客户端。这样对客户端来说是透明的,它只跟你的服务器通信,速度快、链路单一。
3.2 目录设计与接口约定
我做的缓存代理对外只暴露两个路径,保证服务不会被滥用成任意 URL 抓取器:
https://mirror.example.com/github/{owner}/{repo}/archive/refs/heads/{branch}.zip https://mirror.example.com/github/{owner}/{repo}/releases/download/{tag}/{asset}第一条用来拉仓库源码压缩包,第二条用来拉 Release 附件。客户端使用的时候,把原来的https://github.com/前缀替换成https://mirror.example.com/github/即可,比如:
wget https://mirror.example.com/github/prometheus/prometheus/archive/refs/heads/main.zip wget https://mirror.example.com/github/prometheus/prometheus/releases/download/v2.53.0/prometheus-2.53.0.linux-amd64.tar.gz接口后面我加了一层白名单校验,只允许上述两种路径,其他一律返回 404。这一步很重要,否则一旦服务器不小心暴露在公网,就可能被人拿来做免费代理下载任意内容,流量和信誉都会被打爆。
3.3 核心代码与运行方式
下面是我实际在用的简化版本,Flask 加 requests 就够,没有额外依赖。
import os import hashlib from urllib.parse import urlparse import requests from flask import Flask, abort, send_file app = Flask(__name__) CACHE_DIR = "/data/github-mirror/cache" UPSTREAM = "https://github.com" # 只允许跟随这些域名下的跳转 ALLOWED_NETLOCS = { "github.com", "codeload.github.com", "objects.githubusercontent.com", "raw.githubusercontent.com", "github-releases.githubusercontent.com", } MAX_REDIRECTS = 8 def is_allowed(url): netloc = urlparse(url).netloc.lower() return netloc in ALLOWED_NETLOCS def cache_file(key): digest = hashlib.sha256(key.encode()).hexdigest() return os.path.join(CACHE_DIR, digest[:2], digest[2:]) def resolve_and_download(target): current = target for _ in range(MAX_REDIRECTS): if not is_allowed(current): abort(403) with requests.get( current, stream=True, allow_redirects=False, timeout=(10, 90) ) as resp: if resp.status_code in (301, 302, 303, 307, 308): location = resp.headers.get("Location") if not location: abort(502) if location.startswith("http"): current = location else: current = "https://" + urlparse(current).netloc + location continue if resp.status_code >= 400: abort(resp.status_code) return current, resp abort(502) def stream_to_path(resp, path): os.makedirs(os.path.dirname(path), exist_ok=True) tmp_path = path + ".part" try: with open(tmp_path, "wb") as f: for chunk in resp.iter_content(chunk_size=256 * 1024): if chunk: f.write(chunk) os.replace(tmp_path, path) finally: resp.close() if os.path.exists(tmp_path): os.remove(tmp_path) @app.route("/github/<path:target_path>") def mirror(target_path): path_name = "/" + target_path if ( "/releases/download/" not in path_name and "/archive/" not in path_name and "/raw/" not in path_name ): abort(404) final_url = f"{UPSTREAM}/{target_path}" local_path = cache_file(final_url) if os.path.exists(local_path): return send_file(local_path, conditional=True, max_age=86400) _, resp = resolve_and_download(final_url) stream_to_path(resp, local_path) return send_file(local_path, conditional=True, max_age=86400) if __name__ == "__main__": app.run(host="127.0.0.1", port=8000)几个关键点解释一下。resolve_and_download函数手动处理重定向,每跳一步都检查域名是否在白名单里,这是安全底线。stream_to_path先写临时文件再原子重命名,避免下载到一半的脏文件被后续请求读到。缓存文件名是目标 URL 的 SHA-256 摘要,天然按仓库、tag、文件名区分,不会碰撞。
生产环境用 gunicorn 启动,建议 4 个 worker,前面再用 nginx 做 443 和 TLS 终结:
pip install flask requests gunicorn gunicorn -w 4 -b 127.0.0.1:8000 app:appnginx 配置只需要一个标准的反向代理节点,把/github/路径转发到 8000 端口。整个服务只监听内网或加 Basic Auth,不要裸奔在公网。
3.4 缓存过期与一致性
这个服务的缓存策略是“URL 不变就永不重新拉取”。因为 GitHub 的 Release 文件地址带 tag,发布之后内容基本不可变,压缩包地址带 commit 或分支名,同一个 URL 对应内容也是固定的,所以缓存命中的文件可以放心用。
唯一要注意的是分支类 URL。比如你请求archive/refs/heads/main.zip,分支更新后相同 URL 的内容会变,缓存就可能旧了。我的处理方式很简单,在服务器上加一个定时任务,定期删除超过 7 天没有被访问的缓存文件:
find /data/github-mirror/cache -type f -mtime +7 -delete这样热门文件一直留在磁盘上,冷门文件过期淘汰,下一次请求自动回源重新拉取。对于内网团队这种规模完全够用。
4. 形态三:静态 Release 分发,定时器拉取固定清单
4.1 用 GitHub API 获取资产列表
如果团队只需要固定几个工具链,静态分发是最省心的方案。它的思路是你不再等用户来触发,而是让服务器定时去向 GitHub API 查询指定仓库的最新 Release,然后自动把附件下载到本地,再用一个简单的索引页面让团队自助获取。
GitHub API 的 Release 接口长这样:
GET https://api.github.com/repos/{owner}/{repo}/releases/latest未认证状态每小时 60 次请求限制,但拉取几十个仓库足够用。如果你管理的仓库多,或者希望抓取多个历史版本,建议创建一个 GitHub Token,在请求头里带上认证信息,这样配额会提升到每小时 5000 次,基本不用考虑限制。
4.2 增量下载脚本
下面这个脚本负责定时同步指定仓库的最新 Release,核心特点是有校验和增量判断,文件已经存在且大小一致就直接跳过。
import os import requests TOKEN = os.environ["GH_TOKEN"] DEST_ROOT = "/data/release" HEADERS = { "Authorization": f"token {TOKEN}", "Accept": "application/vnd.github+json", } REPOS = [ "kubernetes/kubernetes", "helm/helm", "prometheus/prometheus", "goharbor/harbor", ] def safe_path(owner, repo, tag, name): return os.path.join(DEST_ROOT, owner, repo, tag, name) def sync_release(owner, repo): url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" resp = requests.get(url, headers=HEADERS, timeout=20) resp.raise_for_status() data = resp.json() tag = data["tag_name"] for asset in data.get("assets", []): name = asset["name"] size = asset["size"] path = safe_path(owner, repo, tag, name) if os.path.exists(path) and os.path.getsize(path) == size: continue os.makedirs(os.path.dirname(path), exist_ok=True) dl = requests.get( asset["browser_download_url"], headers=HEADERS, stream=True, timeout=(10, 120), ) dl.raise_for_status() tmp = path + ".part" with open(tmp, "wb") as f: for chunk in dl.iter_content(chunk_size=256 * 1024): f.write(chunk) os.replace(tmp, path) print(f"downloaded {owner}/{repo} {tag} {name}") for item in REPOS: owner, repo = item.split("/") sync_release(owner, repo)把这段脚本放进 cron,每天凌晨跑一次:
0 2 * * * cd /opt/release-mirror && GH_TOKEN=xxx python3 sync_releases.py >> /var/log/release-mirror.log 2>&1下载过程中请求会自动跟随重定向,GitHub 的 Release 下载 URL 最终会跳到对象存储,requests 默认行为就是跟随的,这里无需额外处理。
4.3 索引页和权限控制
文件都下载到/data/release之后,最简单的方式是 nginx 直接把这个目录暴露成静态站点,并开启 autoindex:
server { listen 443 ssl; server_name mirror.example.com; root /data/release; autoindex on; autoindex_exact_size off; location / { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; } }团队访问https://mirror.example.com/kubernetes/kubernetes/v1.30.0/就能看到文件列表,直接点击下载。加 Basic Auth 是为了防止目录暴露到公网被陌生人消耗流量,如果你只在公司内网使用,也可以把 location 里的allow和deny改成只允许内网网段。
4.4 静态分发适合什么场景
我观察下来,静态分发最受欢迎的场景是给 CI 构建提供固定版本依赖。比如公司内部要统一使用某个版本的 kubectl 或 helm,与其让每台构建机临时去 GitHub 拉,不如让运维把校验好的二进制放到内网地址,流水线里直接写死内网 URL。版本升级也就是改一下脚本里的仓库清单,确定性强,还能避免公网波动影响发布流程。
5. 通用运维事项,不然后面全是坑
5.1 存储与带宽估算
镜像站最容易被低估的是存储和带宽。仓库同步模式下,每个仓库的 .git 体积会随历史增长,一个活跃仓库轻松到 1~2GB。Release 资产更夸张,单个安装包几百 MB 很常见。我建议在规划目录时预留两倍以上缓冲,比如你打算镜像 100 个仓库,先按每个 1GB 算,再留 100GB 余量,磁盘至少 300GB 起步。
带宽估算有个简单公式:单日预期下载量除以白天可用时长,再乘一个高峰冗余系数。假设团队每天通过镜像下载 20GB 文件,主要发生在工作日的 6 小时内,平均速度就是20GB / 6h ≈ 0.9MB/s,听起来不高,但高峰时刻可能是平均值的十倍,所以出口带宽至少按 200Mbps 规划比较稳。内网服务器之间走万兆没压力,真正要留意的是如果镜像站放在公网,云服务器带宽很容易被打满。
5.2 监控与告警
镜像站最怕两件事:磁盘满了和同步失败了。磁盘满会导致 Gitea 无法写入、缓存代理无法存储新文件;同步失败会让内网镜像持续落后,用户拉到的代码越来越旧。
我用的是最简单的监控组合,Prometheus 加 node_exporter 盯磁盘,在告警规则里配置使用率超过 80% 就通知。另外写一个定时脚本检查 Gitea 最近的同步日志:
find /data/gitea/log -type f -mmin -30 | xargs grep -i "mirror" | grep -i "error"如果有错误输出就报警。下载代理进程存活也可以直接用 systemd 管理,服务重启策略设为always,再配合 uptime 监控基本够用。
5.3 安全与合规
镜像站是一个内部基础设施,但它面向 GitHub 的数据做二次分发,还是有几个安全底线要守住。
只镜像公开仓库,并且优先选择可明确看到开源许可证的项目。镜像站本质上分发的是他人项目,不做侵权分发是底线。页面上保留指向原仓库的地址,方便使用者溯源。
不要轻易把镜像站暴露到公网。如果一定要公网对外开放,至少加 Basic Auth 或 IP 白名单,否则你的服务器带宽会变成别人的免费下载节点,遇到恶意刷流量会造成大额账单。
定期更新 HTTPS 证书。Git 客户端和下载工具对证书校验很严格,证书过期会导致 clone 直接失败。用 certbot 自动续期是最省事的路,别手动维护。
5.4 备份与恢复
镜像站的数据虽然大多是从 GitHub 重新拉取的内容,但重新拉一遍的成本依然不低,尤其是大仓库和大量 Release 文件。备份策略我认为不需要太复杂,把下面的目录做增量备份即可:
/data/gitea/ # Gitea 的仓库和数据 /data/github-mirror # 缓存代理的缓存文件 /data/release # 静态分发目录用 restic 备份是一个不错的选择,它做增量备份高效、支持对象存储和本地目录,恢复时直接把文件还原回去。恢复之后如果 Gitea 服务起不来,可以先检查数据目录权限和 SQLite 文件是否完整,大概率没什么问题。
6. 常见问题速查
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| Gitea 镜像同步一直卡住 | 首次同步数据量大,或 GitHub Token 失效 | 查看后台任务日志,更新 Token,点手动同步 |
| clone 内网仓库时提示 403 | 匿名访问频率受限 | 在 Gitea 中创建用户,用账号密码或 SSH Key 访问 |
| 镜像仓库里 LFS 文件为空 | pull mirror 不自动同步 LFS | 单独用git lfs fetch --all同步 LFS 文件 |
| 下载代理返回 502 | 重定向次数超限,或目标域名不在白名单 | 检查是否新出现了 GitHub 对象存储域名,加入白名单 |
| 下载到的文件是 HTML 而不是压缩包 | 请求被 GitHub 重定向到了登录页或限制页 | 检查 URL 是否包含私有仓库路径,代理服务只应处理公开内容 |
| 缓存目录越来越大 | 没有设置淘汰策略 | 加find ... -mtime +7 -delete定时任务 |
| 静态分发目录权限混乱 | 脚本运行用户和 nginx 用户不一致 | 统一用同一个系统用户运行,文件权限设为 644 |
| 证书过期导致下载失败 | 忘记自动续期 | 配置 certbot renew 定时任务,或改用 DNS 验证方式 |
| 团队反映还是慢 | 请求没走镜像域名,直连了源站 | 检查 cli 配置或文档里的下载链接,确认前缀已替换 |
7. 我的实际体会
这三类镜像方案我自己都搭过,也都在真实团队里跑过。我个人的落地顺序是:先上 Gitea 仓库同步,把代码 clone 的稳定性问题解决掉;等团队开始频繁下载 Release 文件,再补一个下载缓存代理;等到有固定版本需要统一管控时,才把常驻资产挪到静态分发目录。个人使用的话,仓库同步其实是性价比最高的第一站,Gitea 装好、点几下鼠标、配好磁盘告警,就能解决大半问题。下载缓存代理虽然要写点代码,但它能同时服务多个仓库的 Release 文件,对 CI 体系的稳定性帮助最明显。最后一个小建议是,所有路径里的访问地址都直接用 HTTPS 域名,别用 IP 加端口,后期一切扩展都方便。