自托管系统的断层带:一次跨越 5 个大版本的迁移工程

cover
compose 里的 latest 不是版本号,是一张空白授权书。它让升级变成一件在你不知情时发生的事,而代价会在两年后一次性结清。
latest 漂移:自托管系统特有的腐烂方式
自托管圈有一个几乎人人都在犯的错误:docker-compose.yml 里写一个 :latest,升级全靠某天心血来潮的 docker compose pull。这套做法平时工作得很好,问题在于它悄悄改变了系统的一个基本属性:生产环境的版本不再由任何人的决策决定,而由"上次 pull 发生在什么时候"决定。
一个真实样本可以说明这种漂移能走多远。一套 fork 自 mx-space 的博客系统(后端 core、前台 Shiro、后台 mx-admin 三个组件),fork 后两年间的实际状态是:本地 fork 停在 7.1.8,生产环境因为 innei/mx-server:latest 跑到了 8.4.1,前台却已经在跑自建镜像。三个组件三种状态,没有一个是被明确决策出来的。
这类漂移的直接代价还不是版本混乱,而是它掩盖了真正的账单。上游这两年发生了什么,只有在决定"把系统迁回自己手里"时才会被完整清算:
| 断层 | 版本 | 影响 | 可逆性 |
|---|---|---|---|
| 认证重建 | v10 | 换 Better Auth,旧 JWT/token 全部作废,启动时自动迁移并删除旧 users 集合 | 不可逆 |
| 数据库换血 | v12 | MongoDB 硬切换 PostgreSQL 16,官方 CLI 一次性搬数据,无双写 | 不可逆 |
| API 契约 | v13 | /api/v3,响应变 {data, meta} + snake_case,前台 Shiro 官宣日落、只兼容 10.x | 破坏兼容 |
从 8.4.1 到 13.12.0,中间隔着 1592 个提交和上面三条断层。这就是 latest 漂移的完整账单:平时省下的每一次小升级,最后都会以"断层"的形式集中收款。

latest 漂移:仓库停在原地,生产环境沿版本阶梯持续爬升,两者之间的红色区域就是无人决策的漂移带
迁移前先建立三个不变量
跨断层迁移的方案可以千变万化,但有三件事应该在动任何生产配置之前完成。它们本质上是三个不变量:无论中间哪一步失败,系统都存在一条回到已知状态的路径。
第一,验证过的备份。 关键词是"验证过"。mongodump 打包出一个 688KB 的 archive 并不构成备份,把它恢复到一个临时库、逐个集合数过行数之后才算:
# 备份
docker exec mongo mongodump --archive --gzip > backup.archive.gz
# 恢复演练:恢复到临时命名空间,对数后丢弃
docker exec mongo mongorestore --archive=/tmp/bk.archive.gz --gzip \
--nsFrom "mx-space.*" --nsTo "mxverify.*"
# posts: 73, notes: 25, comments: 4 → 与线上一致,演练通过
没有恢复演练的备份只是一个心理安慰文件。这次迁移中它的成本是三分钟,而它把后面所有"不可逆"操作都变成了"可逆但昂贵"。
第二,钉死镜像 digest。 把 image: innei/mx-server:latest 改成 image: innei/mx-server@sha256:bbba21...。这一步经常被忽略,但它防御的是迁移期间最危险的事故:一次无意识的 pull 把生产环境直接推过断层。v10 之后的镜像对着旧数据启动会自动执行删除 users 集合的迁移,一个手滑的 docker compose pull && up -d 就足以造成不可逆变更。可变 tag 在迁移窗口期间等价于一颗地雷。
第三,每一步的回滚预案先于操作存在。 具体到这次迁移:数据搬迁工具只读源库,所以 PG 阶段失败的回滚是"compose 指回旧镜像";而 v10 认证迁移会改写 Mongo 数据,所以跨过它之后想回 8.x 只能 mongorestore。识别出哪一步是单向门,决定了检查点应该设在哪里。
解耦:换镜像和升级是两个问题
"从官方镜像迁到自建镜像"听起来是一件事,工程上是两件:部署问题(镜像由谁构建、推到哪、怎么触发)和数据问题(版本升级伴随的 schema 迁移)。两者的风险模型完全不同,混在一起做会让回滚边界变得模糊。
部署问题的解法是纯 CI 工程。fork 上游的 release 工作流,做三处修改:镜像名指向自己的 registry(GHCR 配 GITHUB_TOKEN 即可,不需要额外 secret)、删掉上游作者硬编码的私有基础设施(这次的样本里是他个人的 R2 桶和 Dokploy webhook,fork 方照抄会直接把产物发布到别人的基础设施上)、保持其余部分与上游一致以降低未来 merge 成本。同步策略上有一个反直觉的判断:落后上游一千多个提交时,merge 优于 rebase。 fork 里那些零散的 CI 修补在上游的大重构面前大多已经过时,rebase 会让每个过时提交都产生一轮冲突,而一次 merge 只需要处理最终状态的冲突(这次实测:1592 个提交,只有 2 个文件冲突)。
数据问题则必须尊重上游的迁移设计。mx-space 的迁移机制分两代:Mongo 时代(v8–v11)的数据迁移在应用启动时自动执行、幂等;PG 时代(v12+)用独立的 mongo-pg-cli 做一次性搬迁,之后由 Drizzle migrations 接管 schema 演进。这个机制决定了迁移路径没有"一步到位"的选项。
路径设计:踩着哪些石头过河
最终路径是四段跳,每一段都有明确的存在理由:
8.4.1 ──启动──▶ 11.5.1 ──建库──▶ 12.10.0 ──CLI 搬数据──▶ 12.10.0(带数据) ──换镜像──▶ 13.12.0
Mongo 自动迁移 PG schema 就位 mongo-pg-cli Drizzle 迁移到最新
(跨 v10 单向门) (官方镜像即可) (dry-run 先行) (自建镜像入场)

四段跳迁移路径:从 Mongo 时代的绿色起点,经过两块官方镜像的过渡踏脚石,抵达自建镜像的蓝色终点,头顶是全程的回滚安全绳
几个设计决策值得展开。
中间站直接用官方镜像。 11.5.1 和 12.10.0 只是路过,为它们构建自建镜像毫无意义。自建镜像只需要存在于终点版本,这样 CI 工程和数据迁移彻底解耦。
11.5.1 是必经之路,因为它是 Mongo 时代的最后一站。 mongo-pg-cli 的官方定位是"v11 到 v12 的一次性迁移",意味着源数据必须先被 v9/v10/v11 的自动迁移整形到 v11 的 schema。跳过这一站直接对 8.x 数据跑 CLI,行为未定义。
先让 12.10.0 对着空 PG 启动建 schema,再停掉它搬数据。 CLI 假设目标库 schema 已就位,而 schema 由应用的 migration 机制创建。这个顺序(先建结构、再灌数据)是绝大多数"换库"迁移的标准形,但它埋着一个陷阱,见下一节。
dry-run 不是可选项。 mongo-pg-cli --mode dry-run 会完整跑一遍映射并输出每张表的行数对照,apply 前人工核对这份清单,是唯一一次能在写入前发现数据问题的机会。
三类几乎必然踩中的陷阱

三类结构性陷阱:种子数据碰撞、正则误路由、静默降级的鉴权盾牌
复盘这次迁移,真正消耗时间的问题没有一个出自官方文档覆盖的主路径,全部来自三类"结构性陷阱"。它们的共性是:不专属于 mx-space,任何做类似迁移的系统都会遇到同构的问题。
陷阱一:种子数据与迁移数据的约束冲突。 "先空库启动建 schema、再灌历史数据"的顺序有一个副作用:应用首次启动时往往不只建表,还会种子化默认数据。这次的表现是 12.10.0 首启种子化了 3 个内置 snippet,CLI 随后从 Mongo 搬来 3 个同名的,v13.11 的一个 migration 要求该表路径唯一,直接拒绝启动。修复很简单(删掉种子行,保留迁移数据),但这类问题必然发生在"官方文档之外",因为种子化和搬数据的先后顺序是每个部署者自己选的。通用教训:灌历史数据之前,先清点应用自己种了什么。
陷阱二:反向代理配置跟不上 API 演进。 代理层一条服役多年的正则 location ~* /(feed|sitemap|atom.xml),在 v2 时代把 RSS 请求转给后端,工作无可挑剔。v13 的新接口 /api/v3/aggregate/feed 恰好包含子串 /feed,nginx 的 regex location 又优先于 prefix location,于是新 API 被劫持去了 XML 页面,前台拿着 XML 当 JSON 解析当场崩溃。一个 ^ 锚定解决。通用教训:升级后端 API 版本时,把反向代理里所有正则 location 过一遍,未锚定的正则是 API 演进的定时炸弹。
陷阱三:静默降级的鉴权。 v13 的 API key 必须放在 x-api-key 请求头,沿用旧习惯放 Authorization: Bearer 不会返回 401,只会把请求静默降级为游客身份,公开接口照样 200。如果鉴权检查只断言"状态码不是 401/403",会得出 token 有效的错误结论。正确的探针是断言正身份:
# 错误做法:200 ≠ 已认证(公开接口对游客也返回 200)
curl -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $KEY" $API/categories
# 正确做法:断言返回体中的身份字段
curl -H "x-api-key: $KEY" $API/owner/check_logged # → {"data":{"ok":1}}
通用教训:鉴权探活必须验证"我是谁",而非"有没有报错"。 fail-silent 的认证降级比 fail-loud 的 401 危险得多。
核心要点
这次迁移全程约两小时停机,73 篇文章、25 篇手记、4 条评论零丢失。方法论浓缩成六条:
:latest是负债。自托管系统的镜像要么钉 digest,要么钉版本 tag,升级必须是显式决策- 备份的定义是"完成过恢复演练",演练成本几分钟,买到的是所有单向门操作的可逆性
- 跨大版本迁移前,先画出断层表:每个大版本的破坏性变更是什么、是否可逆、回滚手段是什么
- 部署问题(自建镜像/CI)和数据问题(schema 迁移)解耦处理,中间版本直接用官方镜像过渡
- 分段路径的分段依据是上游的迁移机制边界(自动迁移的代际、一次性工具的适用区间),而非版本号本身
- 三类结构性陷阱(种子数据冲突、代理正则腐蚀、鉴权静默降级)不在任何官方文档里,但几乎每个自托管系统都会遇到
最后一条建议给所有 fork 过基础设施软件的人:现在就去确认生产环境跑的是谁构建的什么版本。这个问题的答案越晚知道,修正它的代价越高。