Featured image of post 我把 148 个 GitHub 仓库整成了「不可能全丢」:一次 3-2-1 备份的完整复盘

我把 148 个 GitHub 仓库整成了「不可能全丢」:一次 3-2-1 备份的完整复盘

账号说封就封?把 148 个仓库按 3-2-1 原则做成三份拷贝:自建 Gitea、加密异地冷备、自管网盘包。含 gickup/rclone/Gitea 的五个实战坑,和一套可复制的验收清单。

我数了一下自己 GitHub 账号里的仓库:148 个,126 个私有、22 个公开,里面还混着 3 个 fork,只算仓库对象(不含元数据)就有 3 个多 GB。五年多的提交历史、没写完的实验代码、跑着自动化的脚本,全在。

然后我盯着这个数字看了一会儿,问了自己一个问题:如果这个账号明天没了,我还能剩下什么?

答案让我不太舒服。所以上周我花了一个通宵,把这些仓库变成了一份"封号、宕机、误删三件事都打不穿"的备份。这篇文章是完整复盘——架构、工具、五个坑、验收方法,全部可复制。

3-2-1:老原则,新落地

备份只有一条铁律,叫 3-2-1:至少 3 份拷贝,放在至少 2 种不同的存储介质上,其中至少 1 份在异地。它的本意是让"单点故障"不存在:磁盘坏了有另一块盘,机房烧了有另一个机房。

我把这个原则落成了下面这张图:

备份架构:GitHub 为源,东京 VPS 做双份落地,加密后一份异地、一份自管
3-2-1 落地架构

  • 拷贝 1(东京 VPS):自建 Gitea 私有镜像 + 本地裸仓库。Gitea 给你一个随时能浏览、能 clone、能当退化版工作流的"活的备份",而不是一堆 tar 包。
  • 拷贝 2(几千公里外的另一台 VPS):加密冷备。上传前先过一层加密,目录名、文件名全是乱码。
  • 拷贝 3(自管):加密密文 + 解密钥匙打成一个包,放进自己的网盘。这一份的意义是"平台以外"——连我自己的两台 VPS 都失联,它还在。

关键设计只有一个:钥匙与密文分离。异地那台机器上,刻意不存任何解密密钥。它就算整台被人搬走,对方得到的依然是一堆乱码文件。

工具怎么拼:三个工具,各管一摊

管镜像挺多,我最终只用了三个主力:

gickup(v0.10.45)—— 仓库镜像引擎,一条配置管全部仓库,支持增量同步和"镜像模式"(远端删了本地也删)。用它把普通大小的仓库推到 Gitea 和本地裸目录。

原生 git —— 大仓库专用通道。4 个超过 100MB 的仓库(最大的一个 1.3GB)全部改走 git clone --mirror + git push --mirror,暴力但可靠。

github-backup(0.65)+ rclone(1.75)—— 前者把 issues、PR、wiki、releases、gists、关注列表这些仓库以外的元数据导成 JSON,后者负责加密和异地同步。很多人备份只备代码,不知道 issues 里可能躺着更值钱的决定过程。

加密效果直接看图——同一个目录,服务器上看到的是这种东西:

终端截图:密文目录名是一串乱码,用 rclone 加上钥匙再看才还原成明文目录名
没有钥匙(上)与有钥匙(下)看到的同一个目录

上面是服务器磁盘里真实存在的名字;下面是用 rclone 挂上密钥后看到的同一个目录。没有那把钥匙,这些文件没有任何意义。

五个让我想通宵的坑

**1. 大仓库会把镜像工具干到 OOM。**gickup 底层用的是 go-git,它会把整个 pack 读进内存再处理。80MB 的仓库没事,200MB 的开始玄学,1.3GB 的直接触发内核 OOM killer。别硬刚,设一个阈值(我用 100MB),超过的全部走原生 git 通道。

**2. Gitea 默认不允许"push 自动建仓"。**你兴冲冲地往一个不存在的仓库地址推代码,得到的是 Push to create is not enabled for users 加一个 403。解法是先调一下创建接口把仓库建出来,再推。建仓接口是幂等的:仓库已存在返回 409,忽略即可,所以我直接把它写进了备份脚本,push 前先建仓。

**3. 服务器到 GitHub 的 DNS 会抽风。**人家抽风不是你的错,但备份链必须能扛。我把大仓库段的"拉取"和"推送"各包了 3 次重试,中间留 45 秒,一次网络抖动就不再中断整条链。日志里频繁出现的 could not read Username 其实就是 DNS 没解析出来,不是账号问题。

**4. 云对象存储的鉴权悄悄换了玩法。**2026 年我再接 Cloudflare R2,发现老一套"R2 管理令牌"端点已经下线。新姿势是:用 Cloudflare API 建一个带 R2 权限组的 API Token,然后 Access Key ID = Token 的 ID,Secret Access Key = Token 明文做一次 SHA-256 后的十六进制。另外 rclone 访问 R2 必须加 --s3-no-check-bucket,否则它建桶校验会收到 403。这两样官方文档写得散,足足卡了我半个钟头。

**5. 正在运行的备份脚本,绝不"就地改"。**bash 是一边读文件一边执行的,你在它跑的时候直接覆写文件,它会从原来的字节偏移继续往下读——读到一半的新文件、一半的旧内容,然后喊出一句 l/bin/rclone: No such file or directory 就自杀了。用原子替换:写好新文件再 mv 过去,或者干脆等这轮跑完。这个坑我一晚上踩了两回。

验收:数字对上才算完成

备份做完不算完,能对上数字才算完。我的验收是三方对账,照着抄就行:

终端截图验收清单:Gitea 仓库数与 GitHub、本地一致;密文 7.4GB 顶层零明文;三端对象数一致、rclone 零报错
验收清单:三组数字全部对上

  • 数量对齐:自建 Gitea 的仓库数 = GitHub 上的仓库数 = 本地裸仓库数(148 / 148 / 148)。
  • 密文抽查:异地两个顶层目录占 7.4GB,顶层零明文可见。
  • 规模对齐:三方对象数一致(镜像侧 2198 个对象、元数据侧 3.3 万个对象),同步工具全程 0 报错。

最后挂进 cron 每 6 小时自动跑一轮,加互斥锁防止两轮重叠,失败全落日志。从此新仓库自动纳入,不用任何人工操作。

恢复,才是备份的后半篇

备份的价值只在恢复那一刻兑现。有几件事我现在就能回答:

  • 钥匙(一个 49 字节的文件)单独存进密码管理器,和密文物理分离;
  • 恢复演练命令就一句话:用 rclone 把密文考出来,然后 git log -1 对比远端的 HEAD commit——做一次真实演练,胜过十份备份的自我安慰;
  • 恢复时不要求任何一台特定服务器活着:密文在异地机器和自管网盘里各有一份。

结语

成本:两台轻量 VPS、一个免费额度内的对象存储、一个通宵。收获:封号、宕机、误删,三件事都打不穿这个结构。

如果你只有今晚十分钟,先做这三步:把仓库清单拉出来数一遍;把你最大的那个仓库测试 git clone --mirror 能不能跑完;给你的对象存储开个桶试着传一个文件再删掉。这三件事做完,你就已经比 90% 的开发者更接近"不可能全丢"了。