
1. 为什么我把镜像站做成了团队的基础设施1.1 团队协作里最容易忽略的“代码来源统一”诉求早先我们团队拉取开源代码的方式非常随意有人从浏览器直接下载压缩包有人在自己本地随手 clone 到临时目录还有人直接把某个编译产物塞进网盘。结果就是同一个开源组件在不同机器上拿到的版本可能都不一致构建日志一旦出现“这里明明能编过”的争吵排查起来极其痛苦。后来我把所有代码依赖整理了一遍发现真正被频繁使用的是二十几个常见仓库但它们的上游地址分布在不同的作者账号、组织账号下。与其让大家各自维护一份五花八门的地址表不如让团队内部有一个统一的代码入口所有人对同一个组件都从同一个地址拉取构建机更简单直接面向内部服务不依赖任何人的本地环境。这个思路顺理成章地指向了“自建 GitHub 镜像站”。镜像站的核心价值不是说我们非得绕过什么网络限制而是把“团队需要的外来代码”统一收集、统一登记、统一派发变成内网里一份可控的资产。1.2 镜像站的定位只读同步不做开发主仓我需要先明确镜像站和开发主仓之间的边界。镜像站里放的仓库本质上是上游仓库的一份只读副本。分支和标签会按周期同步过来但 Issues、Pull Request、Release 讨论这些交互内容基本不会随 git 对象一起同步。团队内部如果要做二次开发正确做法是在镜像站旁边新建一个独立的开发仓库或者让成员在镜像站里 fork 一份再往 fork 里面推。镜像仓本身保持只读避免有人把临时分支、垃圾 commit 直接推到“标准代码源”上。边界一旦清晰后面所有同步策略、权限设置都顺了。镜像站不需要承担代码评审职责也不需要提供放开写权限的入口。它安静地扮演“统一派发源”把上游代码变成内网中可信、可追溯、可重复获取的依赖来源。2. 选型前必懂拉取式镜像与推送式镜像的底层差异2.1 拉取式服务端定时向上游要数据拉取式镜像的思路很简单自建服务扮演一个“主动访问者”按照预设的周期向 GitHub 发起拉取请求把远端分支和标签同步到本地。典型实现是 Gitea 自带的镜像仓库功能。创建仓库时选择“镜像仓库”填上远端地址后台就会按 cron 调度去同步。这种方式的优点是运维模型非常轻盈自建服务不需要常驻式的外联进程也不需要在 GitHub 侧部署任何代码只要服务器能访问外部代码库就行。缺点是同步节奏受调度周期限制上游如果刚推了 commit最坏情况下要等一个完整间隔才生效。2.2 推送式把同步流程做进自动化流水线推送式的逻辑正好反过来由 GitHub 侧作为发起方在某个定时任务或事件触发下主动把仓库推送到我们的自建服务。实际实现常见于 GitHub Actions 工作流写一个仅包含定时触发的工作流在 Actions 里执行git push --mirror或者只推送指定分支和标签推送目标指向自建服务的仓库地址。这种方式的优点是可以精细控制要同步哪些引用、是否保留完整历史、同步前是否执行脚本任务缺点是每接一个上游仓库就得在那边准备一个工作流文件或者至少维护一份仓库清单批量管理成本比拉取式高。2.3 三条路线横向对比与选择依据路线同步方向管理成本适合场景Gitea 镜像仓库自建服务主动拉取低仓库列表集中维护大多数团队的默认选择手动导入到 Gitee一次性的搬运低但不适合持续跟踪迁仓、一次性备份GitHub Actions 推送上游主动推送到自建服务中每个仓库需要独立配置对引用完整性要求高的核心仓库我最终选择以 Gitea 镜像仓库为主只在少数核心仓库上叠加 Actions 推送。原因很简单多数开源项目每天的分支、标签变化量很小一个稍长一点的同步间隔完全能接受而少数核心仓库承担了构建链路的顶层依赖我希望它们的完整引用树在每一次构建时都能对得上于是额外用推送式补一层保险。3. 基于Gitea搭建镜像站部署、开关配置与首次同步3.1 用Docker Compose跑起一台Gitea搭镜像站的底座我选了 Gitea它轻量、部署快并且原生支持镜像仓库不需要额外写复杂的业务逻辑。以下是我实际使用的 Docker Compose 配置version: 3 services: gitea: image: gitea/gitea:1.21 container_name: gitea restart: always environment: - USER_UID1000 - USER_GID1000 - GITEA__server__DOMAINgit.internal.example - GITEA__server__SSH_DOMAINgit.internal.example - GITEA__server__ROOT_URLhttp://git.internal.example:3000/ - GITEA__database__DB_TYPEsqlite3 - GITEA__database__PATH/data/gitea.db volumes: - ./gitea:/data ports: - 3000:3000 - 2222:22我用了 SQLite 而不是 MySQL因为镜像站的仓库数量在初期只有几十个SQLite 足够可靠又能省掉一个数据库容器。如果你们团队已经跑着成熟的 MySQL也可以切换到 MySQL 后端Gitea 对这两种后端支持都很好。容器启动后访问http://git.internal.example:3000先创建管理员账号。需要注意这里的git.internal.example是团队内网的域名如果你们没有域名直接用服务器 IP 也能跑只是后续克隆地址会更加难记忆。3.2 镜像相关配置项与常见开关Gitea 默认就能创建镜像仓库但如果你们用的是很老的版本或者出现过“仓库设置里找不到镜像入口”的情况需要检查app.ini里的配置项[repository] DISABLE_MIRRORS false [mirror] DEFAULT_INTERVAL 8hDISABLE_MIRRORS负责全局开关设为false表示允许使用镜像功能。DEFAULT_INTERVAL是默认同步间隔我把它设成了8h也就是每天自动同步三次。对于日活跃度很高的仓库这个频率足够如果个别仓库需要更快的响应在创建仓库的时候可以单独修改。修改完app.ini需要重启 Gitea 容器docker compose restart gitea3.3 创建第一个镜像仓库并完成手动同步第一个镜像仓库的创建流程我建议完全走界面操作一遍目的是确认功能本身正常。登录管理员账号后点击“新建仓库”在仓库类型中选择“镜像仓库”填写上游地址例如https://github.com/some-org/example.git如果目标仓库是私有仓库访问凭据中填入有读取权限的用户名和令牌保存后在仓库页面的“镜像同步”区域点击“立即同步”首次同步完成后团队成员的克隆地址就变成git clone http://git.internal.example:3000/mirrors/example.git所有下游使用者只需要知道这一个地址。上游版本更新后镜像会在下一个调度周期自动跟进团队内部完全无感。4. 批量接入与权限控制从三个仓库扩展到五十个仓库4.1 用Migration API批量建镜像仓库只用界面创建三个仓库没问题但当仓库列表变成五十个、包涵不同组织归属时手工操作很容易漏。此时用 Gitea 的 Migration API 批量创建是最省力的方式。核心接口是POST /api/v1/repos/migrate参数中把mirror设为true。下面是一个 Python 示例脚本import requests GITEA_URL http://git.internal.example:3000 TOKEN 你的管理员访问令牌 ORG mirrors repos [ org-a/lib-alpha.git, org-b/lib-beta.git, org-c/tool-gamma.git, ] for full_name in repos: resp requests.post( f{GITEA_URL}/api/v1/repos/migrate, headers{Authorization: ftoken {TOKEN}}, json{ clone_addr: fhttps://github.com/{full_name}, repo_name: full_name.split(/)[-1], repo_owner: ORG, mirror: True, private: True, }, timeout10, ) if resp.status_code in (200, 201): print(fcreated: {full_name}) else: print(ffailed: {full_name}: {resp.text})脚本里我把所有镜像仓库都创建到了mirrors这个组织下并且设置了private: true。好处是权限控制可以按组织整体管理不用逐个仓库调权限。需要注意clone_addr如果包含用户名密码Gitea 会保存起来用于后续同步。出于安全考虑不要在 URL 里直接写明文密码建议使用只读令牌。4.2 以组织为单位的权限模型设计镜像仓库的权限模型不能和普通开发仓库混在一起。普通开发仓库里开发人员往往需要写权限镜像仓库恰好相反绝大多数人应该只有读权限只有管理镜像的管理员才拥有手动同步和配置修改权限。我这边采用了两层组织隔离mirrors组织存放所有镜像仓库默认成员角色是只读dev组织存放实际开发仓库开发人员在这里有正常写权限mirrors里的仓库对dev组织的成员可见但不可写。只有在排障或者手动同步时运维管理员临时把自己提权。这个模式跑下来基本杜绝了“有人在镜像仓库里乱推分支”的可能。4.3 同步状态巡检与失败告警Gitea 文档里提到镜像同步可以通过仓库的事件来感知最直接的做法是每天统计一遍所有镜像仓库的同步状态。我写了一个简单的巡检脚本#!/bin/bash TOKEN你的管理员访问令牌 GITEA_URLhttp://git.internal.example:3000 curl -s -H Authorization: token $TOKEN \ $GITEA_URL/api/v1/repos/search?orgmirrorslimit50page1 \ | jq -r .data[] | [.full_name, .mirror] | tsv脚本会把mirrors组织下的所有仓库列出来再配合 Gitea 仓库详情里返回的同步时间字段就能判断有没有仓库长时间没有更新。对于每天都有新 commit 的上游项目如果镜像仓库的同步时间停在两天前基本可以断定同步链路出了问题。警报可以接入团队群的机器人。Gitea 的 Webhook 中有“镜像同步”相关事件设置一个指向群机器人的 Webhook失败时自动推送消息这是我在维护阶段最依赖的告警入口。5. 维护期踩坑实录体积失控、同步失败与Token过期5.1 大仓库同步卡死浅历史与Blob过滤的瘦身法我遇到的第一类坑是大仓库同步。某个仓库包含了大量二进制资源完整历史可能有七八个 GB。这种仓库首次镜像时极容易同步到一半卡住具体表现是同步任务长时间不结束服务器 CPU 不高但磁盘和网络持续占满。排查以后发现根因是单次同步需要拉取完整历史对象只要某个历史 commit 里的二进制文件过大整条链路就被拖住了。如果这个仓库确实只需要最新代码没必要把完整历史全部搬进镜像站。手工瘦身方案是用 Git 的 blob filter 做浅历史镜像git clone --filterblob:none --bare \ https://github.com/some-org/heavy-repo.git heavy-repo.git cd heavy-repo.git git remote add mirror http://git.internal.example:3000/mirrors/heavy-repo.git git push --mirror mirror--filterblob:none的意思是只获取 commit 和 tree 对象具体文件内容在需要时才懒加载。这样镜像仓库里保留了完整的分支结构和提交历史但不会把每个历史版本里的二进制文件全部存下来。配合后续对该仓库做精准的增量同步体积能控制在可接受范围。5.2 上游接口限流导致的整队列失败另一类极具迷惑性的坑是同步任务批量失败。表现是某一天所有镜像仓库同时同步失败错误信息五花八门有超时、有认证失败、也有直接提示额度受限。我追查后发现Gitea 在首次同步或手动全量同步时会通过 GitHub 的 REST API 和 Git 协议获取元数据。匿名请求的配额非常低一旦同时触发几十个仓库的同步配额很快耗尽后续请求全部失败。解决思路是给 Gitea 镜像仓库的远端地址配置一个有权限的认证令牌。在创建镜像仓库时clone_addr可以写成携带令牌的形式https://your-user-name:your-tokengithub.com/some-org/lib-alpha.git或者更稳妥地在 Gitea 界面填入“镜像认证”信息。这样所有同步请求都按已认证用户计算额度配额限制大幅放宽。5.3 Token过期与私有仓库401排查链路私有仓库的镜像还有一个很隐蔽的问题Token 会过期。第一次配置时同步一切正常过一段时间突然开始报 401很多人第一反应是仓库地址变了其实大概率是访问令牌失效。排查路径我通常是这样走观察失败状态码401 属于认证失败403 多半是配额或权限问题更新为新的只读令牌观察下一次同步是否恢复检查 Gitea 的日志确认远端地址中保存的令牌未被空白覆盖我后来在巡检脚本里额外加了令牌有效期提醒。GitHub 的 fine-grained token 可以设置到期时间期前一个月我会收到提醒提前更新避免同步在重要发布前突然断掉。6. 推送式镜像的补充实践与日常巡检6.1 用自动任务维持完整引用树的仓库如果你们团队和我的情况类似需要完整历史、完整引用树的仓库是少数那建议单独为这几个仓库配置推送式同步。在 GitHub 仓库中新建一个工作流文件内容大致如下name: mirror-push on: schedule: - cron: 0 3 * * * jobs: push: runs-on: ubuntu-latest steps: - name: checkout full history uses: actions/checkoutv4 with: fetch-depth: 0 - name: push mirror run: | git remote add mirror http://git.internal.example:3000/mirrors/core.git git push --mirror mirror env: GIT_PUSH_TOKEN: ${{ secrets.GIT_MIRROR_TOKEN }}这里需要注意fetch-depth: 0才能拿到所有历史git push --mirror会推送全部分支和标签。工作流里的推送地址需要提前在自建服务上建好同名镜像仓库并且把推送令牌配置到 GitHub 仓库的 Secrets 中。我坚持用这种方式处理核心仓库是因为它能把同步动作全部放进流水线里任何一次失败都会体现在工作流运行记录中审核和回溯非常直接。6.2 每次同步都留痕巡检脚本与状态清单维护镜像站最重要的一件事是“所有同步过程都要留痕”。我最后形成的日常巡检体系包含三个动作每天一次 Webhook 告警同步失败立即出现在群里每天一次git ls-remote抽样比对确认核心仓库的上游 commit 是否已进入镜像每周一次仓库体积统计防止某个仓库突然膨胀挤满磁盘仓库体积统计我用最简单的方式实现du -sh /data/gitea/git/repositories/mirrors/*如果发现有仓库体积异常我会单独去查它近期是否新增了大量历史对象。正常仓库体积曲线应该平缓增长突然跳升往往意味着某个上游删除了写库又重建或者自动构建脚本往 git 里塞了临时文件。这套流程跑了几个月之后团队内部已经形成一个共识任何外部代码只要团队在用就必须先进镜像站。上线、构建、发布全程都只面向内网统一的代码源不再有人手工从外网下载也不再有人担心版本对不上。最后分享一个我个人的体会搭建镜像站这件事开头最忌讳的就是贪多求全。先只接入团队实际在用的二三十个高频仓库跑通整个同步、巡检、告警流程再考虑要不要把几百个开源项目全部镜像进来。基础设施的价值在于可预测不在于数量多。