大多数人把「代码在 GitHub 上」当成「代码有备份」。其实那只是「代码有一个远程副本」,而这个副本和你的账号、平台政策、计费状态绑在一起。
一、仓库消失的那天
这是为解释机制构造的假设案例,人物、引语、收入和耗时均为教学设定,不是对真实事件的调查记录。
一位开发者的产品代码放在一个私有仓库里,仓库属于他个人账号。某天他收到平台通知:账号因「异常活动」被临时限制。限制期间,他无法登录、无法克隆、无法推送,也无法导出仓库。
他有本地工作副本,所以代码本身没丢。但接下来他发现:
- CI 配置里的部署密钥只在平台 secrets 里,本地没有记录。
- 发布用的签名证书密码,存在平台的环境变量里。
- 上一个版本的构建产物只存在 Releases 页面。
- 问题追踪、讨论、发布说明,全在平台上。
账号在第九天恢复。代码没丢,但九天里他无法发布任何修复。他后来把这次经历总结为一句话:
「我有代码的副本,但我没有产品的副本。」
二、代码资产的四层
| 层 | 内容 | 常见唯一副本 | 失效后的表现 |
|---|---|---|---|
| 工作副本 | 你电脑上的 .git 目录 | 通常有,但可能只有一台机器 | 电脑损坏或未推送,丢失最近改动 |
| 远程仓库 | GitHub / GitLab / Gitee 等 | 往往只有一个 | 账号受限或仓库误删,无法协作与发布 |
| CI 与配置 | 流水线、环境变量、部署密钥 | 只在平台里 | 无法构建、无法部署 |
| 发布产物 | 二进制、安装包、校验和、签名 | 只在 Releases 或 CDN | 无法回滚、无法给用户重装 |
四层里,远程仓库最容易被当成「已经备份」,发布产物最容易被完全忽略。
三、多远程与镜像
Git 本身支持多个远程。最轻量的韧性方案,是让同一次推送落到两个地方。
3.1 手动双远程
# 查看现有远程
git remote -v
# 添加一个镜像远程
git remote add mirror git@backup.example.com:you/project.git
# 推到两个远程
git push origin main
git push mirror main
3.2 一次推送两个远程
# 给 origin 增加第二个 push URL
git remote set-url --add --push origin git@primary.example.com:you/project.git
git remote set-url --add --push origin git@backup.example.com:you/project.git
之后 git push origin main 会依次尝试推送到配置的地址,不是跨平台原子事务;一个成功另一个失败时需分别核对。fetch URL 不因增加 push URL 自动改变,备用远程仍需验证实际 refs。
3.3 平台侧镜像
主流代码托管平台大多支持「推送镜像」或「导入仓库」:把仓库定时同步到另一个平台。优点是无需改本地配置,缺点是依赖平台功能,且同步可能有延迟。
3.4 镜像不是备份的全部
镜像能防「平台账号受限」,不能防「误删分支后镜像也同步删除」。所以还需要一份不会被同步删除的归档。
四、git bundle:离线归档的最小单位
git bundle 可以把一个仓库(包括所有分支和标签)打包成单个文件。它不依赖任何平台,能在任何有 Git 的机器上恢复。
# 打包所有分支和标签
git bundle create project-2026-10.bundle --all
# 校验包是否完整
git bundle verify project-2026-10.bundle
# 计算校验和
shasum -a 256 project-2026-10.bundle
恢复时,先区分两种目标。只想查看默认分支可普通 git clone,但它不等于完整恢复所有分支。要完整恢复 Git 分支和标签,在隔离目录使用镜像克隆;目标必须是已确认的空仓库:
# backup.bundle 已校验,以下在新的隔离目录执行
# 镜像克隆保留 bundle 中的引用;不使用 push --mirror 覆盖已有远端
git clone --mirror backup.bundle restored.git
git -C restored.git for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags
# 将 origin 改为自己控制且已确认为空的新仓库,再分别推分支和标签
git -C restored.git remote set-url origin git@example.com:you/empty-restored.git
git -C restored.git config remote.origin.mirror false
git -C restored.git push origin --all
git -C restored.git push origin --tags
普通 clone 只把默认分支建成本地分支;其他分支常在 origin/*。这时删除 origin 会移除其远程跟踪引用,push --all 也不会推回它们。镜像 clone 默认设置镜像推送,所以上面在分别推分支和标签前显式关闭 remote.origin.mirror。恢复验收应比较原始 refs/heads、refs/tags 的名字及对象 ID,而不是只看 clone 退出码。
--all 打包的是当前仓库已有引用能到达的 Git 对象。未提交与未跟踪文件、Git LFS 实体、子模块各自的仓库、平台 Issues/PR、CI secrets、外部包和发布产物需要独立清单与备份;浅克隆或漏 fetch 的分支不能凭 bundle 补出来。
4.1 归档节奏
| 项目类型 | 建议频率 | 保留 |
|---|---|---|
| 有付费用户的商业项目 | 每月一次 | 最近 12 个月 + 每年一份 |
| 活跃开发的开源项目 | 每季度一次 | 最近 4 个季度 |
| 维护模式项目 | 每半年一次 | 最近 2 份 |
| 已归档项目 | 关停时一次 | 永久 |
4.2 归档存放
- 一份放在与代码平台无关的云存储(不同供应商)。
- 一份放在离线硬盘或 NAS。
- 记录 SHA-256 校验和,恢复前先校验。
- 归档文件加密,密钥与文件分开存放。
五、组织所有权:别把产品绑在个人账号上
个人账号适合起步,但不适合承载有付费用户的产品。原因不是「个人账号不安全」,而是个人账号的恢复路径和所有权转移路径更窄。
| 结构 | 优点 | 风险 | 适用阶段 |
|---|---|---|---|
| 个人账号下的私有仓库 | 简单、免费 | 账号受限即全部不可用;难以转让 | 原型、实验 |
| 组织/团队账号 | 可设多个所有者;可转移;权限清晰 | 配置略复杂 | 有付费用户 |
| 组织 + 服务账号 | 自动化不依赖个人 | 需要管理密钥 | 有 CI/CD |
| 自建托管 | 完全可控 | 运维成本高 | 有明确合规需求 |
5.1 组织的最小配置
- 至少两个 owner:你 + 一个可信任的人(或一个长期保留的应急账号)。
- 开启组织级 2FA 要求。
- 关键仓库开启分支保护:禁止直接 push 到主分支,要求 PR。
- 部署密钥、CI token 使用最小权限,不用个人长期 token。
- 记录「谁是 owner、谁有 admin 权限」,写进资产清单。
5.2 服务账号与机器用户
如果 CI 需要一个长期身份,不要用你的个人账号 token。优先使用平台支持的应用身份、OIDC 或服务账号;确需机器用户时先核对平台条款与席位成本,再授予最小权限,并把它的凭据存进密码管理器与离线恢复包。
六、CI 配置与密钥
CI 是最容易被忽略的一层,因为它「看不见」。但它决定了你能不能发布。
6.1 需要备份的内容
| 内容 | 存在哪 | 备份方式 |
|---|---|---|
| 流水线定义 | 仓库内的 YAML 文件 | 随代码一起归档 |
| 环境变量与 secrets | 平台设置页 | 加密清单,不含明文 |
| 部署密钥 | 平台或云厂商 | 密码管理器 + 离线恢复包 |
| 签名证书与口令 | 本地或平台 | 加密存储,双份异地 |
| 构建脚本 | 仓库内 | 随代码归档 |
6.2 原则
- secrets 的「清单」要备份,「值」要单独加密存放。 出事时至少知道缺什么。
- 不要把唯一一份签名证书放在 CI 平台。 平台账号受限时,你连重签都做不到。
- 部署流程要有一份可手动执行的脚本。 CI 挂了不等于发布流程必须停。
七、发布产物的保留
用户下载的是发布产物,不是源码。发布产物丢了,用户无法重装,你也无法回滚。
| 产物 | 保留策略 | 校验 |
|---|---|---|
| 安装包 / 二进制 | 每个公开版本永久保留 | SHA-256 + 代码签名 |
| 容器镜像 | 至少保留最近 10 个版本 | digest |
| 前端静态资源 | 保留最近 3 个版本 | 构建哈希 |
| 数据库迁移脚本 | 永久保留 | 随代码归档 |
| 发布说明 | 永久保留 | 与版本号对应 |
每个版本发布时,把产物、校验和、签名、发布说明一起复制到独立存储。 不要只依赖平台的 Releases 页面。
八、一次从零恢复演练
目标:在没有原平台账号的情况下,用归档恢复出一个能构建的仓库。
8.1 准备
- 选一个非核心仓库,打一个
git bundle。 - 导出 CI 变量清单(只记名字,不记值)。
- 下载最近一个发布产物与校验和。
8.2 恢复
- 在一台干净机器上
git clonebundle。 - 校验 bundle 的 SHA-256。
- 在新的代码托管平台创建空仓库,推送所有分支与标签。
- 按清单重建 CI 变量(值从密码管理器取)。
- 跑一次构建,确认产物可生成。
- 对比新产物与旧产物的校验和(如果构建可复现)。
8.3 记录
- 记录恢复耗时。
- 记录缺失的配置、密钥、依赖。
- 把缺失项补进资产清单。
- 更新归档频率或存放位置。
演练的价值不在于「恢复成功」,而在于发现你以为备份了、其实没备份的东西。
九、常见错误
| 错误 | 后果 | 更好的做法 |
|---|---|---|
| 只有一个远程仓库 | 账号受限即无法发布 | 加镜像远程或平台镜像 |
| 把 secrets 只存在平台 | 平台不可用时无法构建 | 备份加密的 secrets 清单与值 |
| 发布产物只留在 Releases | 平台清理或账号受限后无法回滚 | 每个版本复制到独立存储 |
| 组织只有一个 owner | 本人无法操作时无人能接管 | 至少两个 owner |
| 用个人 token 做 CI | 权限过大,离职或轮换麻烦 | 服务账号 + 最小权限 |
| 归档从不校验 | 真恢复时发现包损坏 | 每次归档计算并记录 SHA-256 |
十、行动清单
- 列出所有代码仓库,标出「只有一个远程」的。
- 为商业项目添加镜像远程或平台镜像。
- 做一次
git bundle归档,存到独立存储。 - 评估商业仓库是否需要组织所有权与第二位真实授权 owner;组织设置不代替独立备份。
- 组织至少设两个 owner,开启 2FA 要求。
- 导出 CI 变量清单,把值存进密码管理器。
- 每个发布版本保留产物、校验和、签名与说明。
- 做一次从 bundle 恢复的演练,记录耗时与缺失项。
使用边界与实操验收
本节恢复验证的是 Git 引用与对象完整性,不认证所有 CI、LFS、依赖和签名资产已恢复。下面夹具仅创建临时本地仓库,无远端网络。
将下面保存为 verify_bundle_restore.py,在有 Git 和 Python 3 的机器执行。它创建 main、feature 与标签,恢复后比较全部分支和标签,还检查 feature 的独有内容:
import os
import subprocess
import tempfile
from pathlib import Path
env = os.environ.copy()
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
def git(cwd, *args):
return subprocess.check_output(
["git", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null",
"-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", *args],
cwd=cwd, env=env, text=True, stderr=subprocess.STDOUT
)
def refs_at(cwd):
return git(cwd, "for-each-ref", "--format=%(refname) %(objectname)",
"refs/heads", "refs/tags").splitlines()
with tempfile.TemporaryDirectory() as d:
d = Path(d)
git(d, "init", "-b", "main", "src")
src = d / "src"
(src / "main.txt").write_text("main\n")
git(src, "add", ".")
git(src, "commit", "-m", "main")
git(src, "switch", "-c", "feature")
(src / "feature.txt").write_text("feature\n")
git(src, "add", ".")
git(src, "commit", "-m", "feature")
git(src, "tag", "v1")
git(src, "switch", "main")
expected = refs_at(src)
git(src, "bundle", "create", str(d / "backup.bundle"), "--all")
git(d, "clone", "--mirror", str(d / "backup.bundle"), "restored.git")
restored = d / "restored.git"
git(d, "init", "--bare", "destination.git")
git(restored, "remote", "set-url", "origin", str(d / "destination.git"))
git(restored, "config", "remote.origin.mirror", "false")
git(restored, "push", "origin", "--all")
git(restored, "push", "origin", "--tags")
assert refs_at(restored) == expected == refs_at(d / "destination.git")
assert git(d / "destination.git", "show", "feature:feature.txt") == "feature\n"
print("PASS: main, feature, v1 and feature content restored")
关键参考与核验范围
以下链接支持对应的机制或规则;本文的案例、成本和经营评分是说明方法的假设,不能当作平台统计或收益承诺。核验日期:2026-10-22。
- Git bundle :离线传输 Git 对象和引用,不含完整项目的所有外部资产。
系列导航
- 上一篇:身份入口:邮箱、密码管理器、2FA 与恢复码
- 下一篇:数据与备份:3-2-1、加密、离线副本与恢复演练
- 回到目录:独立开发者的资产韧性
- 相关阅读:密钥与环境配置 、平台风险与迁移
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。