mx-space 博客迁移实录:从官方镜像 8.4.1 到自建镜像 13.12.0 + Yohaku

cover
迁移前的状态
这套博客由三个组件构成,都是两年前从 mx-space 上游 fork 的。迁移前逐个盘点,状态如下:
| 组件 | fork 本地版本 | 生产实际状态 | 差距 |
|---|---|---|---|
| mx-server(后端) | 7.1.8,2024-10 后未更新 | 官方镜像 innei/mx-server:latest,实际 8.4.1 | 落后上游 1592 个提交 |
| Shiro(前台) | 有 58 个自定义提交 | 自建镜像 ghcr.io/dante-dan/shiro | 落后 249 个提交,跨一次 monorepo 重构 |
| mx-admin(后台) | 4.7.3,零自定义提交 | 由 core 镜像内置,无独立部署 | 落后 428 个提交 |
生产环境跑 8.4.1 而本地 fork 停在 7.1.8,原因是 compose 里写的是 :latest,某次 docker compose pull 把版本悄悄推了上去。数据层是 mongo:7 + redis,数据量不大:73 篇文章、25 篇手记、4 条评论,MongoDB 数据目录 513MB。
上游这两年的三个破坏性变更决定了迁移的复杂度:
- v10:认证系统换成 Better Auth,旧 JWT/API token 全部作废。数据迁移在应用启动时自动执行,最后会删除旧的 users 集合,不可逆
- v12:数据库从 MongoDB 硬切换到 PostgreSQL 16,官方提供一次性迁移工具
@mx-space/mongo-pg-cli,没有双写 - v13:API 换
/api/v3契约({data, meta}信封 + snake_case),admin 并入 core monorepo。前台 Shiro 进入日落维护,只兼容 core 10.x
方案
目标:所有组件跑自己构建的镜像(获得定制能力),版本升到上游最新,内容零丢失。
组件决策:
| 组件 | 决策 | 理由 |
|---|---|---|
| mx-server | merge 上游 v13.12.0,CI 改推 ghcr.io/dante-dan/mx-server | 19 个自定义提交全是过时的 CI 修补,merge 只有 2 个文件冲突 |
| Shiro | 冻结,不再同步 | 上游日落且不兼容 core 13,重铺没有意义 |
| 前台 | 换 Yohaku(sponsor 版,私有仓库 + 私有镜像) | 官方对 core 13 的唯一适配前台,监听同样的 2323 端口,可原位替换 |
| mx-admin | fast-forward 后存档 | v13 起 admin 从 monorepo 的 apps/admin 构建进 core 镜像,独立仓库已归档 |
迁移路径:8.4.1 无法直接跳 13.12.0(v13 镜像不再支持 MongoDB),必须分段。分段依据是上游迁移机制的边界:
8.4.1 ──启动──▶ 11.5.1 ──建 schema──▶ 12.10.0 ──CLI 搬数据──▶ 12.10.0 ──换镜像──▶ 13.12.0
Mongo 自动迁移 空 PG 首启 mongo-pg-cli Drizzle 自动迁移

迁移路径示意
- 11.5.1 是 Mongo 时代最后一个版本。
mongo-pg-cli的定位是"v11 到 v12 迁移",所以 8.4.1 的数据要先靠 v9–v11 的启动自动迁移整形到 v11 schema - 12.10.0 的作用是对空 PostgreSQL 首次启动、由它的 migration 机制建好 schema,然后停掉它跑 CLI 搬数据
- 13.12.0(自建镜像)启动时由
migrate.mjs自动把 schema 升到最新
安全措施(动手前完成):
- mongodump 全量备份,并且做恢复演练:恢复到临时命名空间,逐集合对行数,通过后丢弃临时库
- compose 里的
innei/mx-server:latest改成当前镜像的 digest(@sha256:...),防止迁移期间误 pull 直接跨过 v10 的不可逆迁移 - 备份三件套拷贝到服务器之外:mongodump、core 内置备份 zip、
static/上传文件
实施
仓库与 CI
mx-server fork 的同步用 merge 而非 rebase。落后 1592 个提交时,rebase 会让每个过时的本地提交都产生一轮冲突;merge 只需处理最终状态,实测只有 .gitignore 和 release.yml 两个文件冲突:
git remote add upstream https://github.com/mx-space/core.git
git fetch upstream --tags
git checkout -b sync-upstream master
git merge v13.12.0 # 解决 2 个冲突后提交
release.yml 以上游版本为基础改三处:镜像名改为 ghcr.io/dante-dan/mx-server;Docker Hub 登录换成 ghcr(username: ${{ github.actor }} + GITHUB_TOKEN,不需要额外 secret);删掉上游作者硬编码的 R2 发布段和 Dokploy webhook(那是他个人的基础设施,fork 照抄会把产物发到别人家)。push master、打 tag v13.12.0-dante.1,CI 构建出镜像。
Yohaku 的处理:源码放私有仓库(sponsorware 不能公开),构建 workflow 只有一个点需要注意,它的 @yohaku/design-system 来自指向公开仓库的 git submodule,checkout 必须带 submodules: recursive,否则 pnpm install 报 workspace 包缺失。BASE_URL 在构建期注入(NEXT_PUBLIC_API_URL 等由它派生),运行期还要通过 .env 再提供一遍,因为 SSR 在运行时读环境变量,只烘焙不注入会报 Failed to parse URL from /api/v3/...。
切换窗口
按顺序执行,每步验证通过再进行下一步。总耗时约两小时。
1. 冻结与终备份。停掉前台挡住写入,做最后一次 mongodump 并拷出服务器。
2. 升 11.5.1。compose 镜像改 innei/mx-server:11.5.1,up -d 后观察日志:23 个自动迁移依次执行(v9.7.x 的 Better Auth 迁移链、v10.x、v11.4.0),结束后 /api/v2/ping 正常、admin 可登录。此步之后回退 8.x 只能靠 mongorestore。
3. 建 PG,搬数据。compose 加 postgres:16-alpine 服务,镜像改 12.10.0,env 从 DB_HOST 换成 PG_HOST/PG_USER/PG_PASSWORD/PG_DATABASE 并加 SNOWFLAKE_WORKER_ID=1。首启建好 schema 后停掉 app,在 docker 网络内跑迁移(mongo 容器没有映射宿主端口):
docker run --rm --network core_mx-space \
-e SNOWFLAKE_WORKER_ID=900 \
-e MONGO_URI="mongodb://mongo:27017/mx-space" \
-e PG_URL="postgres://mx:***@postgres:5432/mx_core" \
node:22-alpine npx -y @mx-space/mongo-pg-cli@latest --mode dry-run
# dry-run 输出逐表行数对照,人工核对后同命令 --mode apply
apply 完成后起 app,用 psql 对行数(posts 73 / notes 25 / comments 4),再抽查 API 返回。
4. 换自建 13.12.0。compose 镜像改 ghcr.io/dante-dan/mx-server:13.12.0-dante.1,healthcheck 路径改 /api/v3/ping。启动时 Drizzle migration 自动升级 schema(这里踩了坑一,见下节)。成功后 redis-cli FLUSHALL 清缓存(v13.2 的 thumbhash 变更要求清 enrichment 缓存)。
5. 前台换 Yohaku。docker stop shiro,新目录里放 compose 和 .env(BASE_URL、NEXT_PUBLIC_API_URL=https://api.dhpie.com/api/v3、NEXT_PUBLIC_GATEWAY_URL),同样绑 127.0.0.1:2323,反向代理不用动。
6. 验收。首页、文章页、RSS、sitemap、admin 登录、发测试评论、admin 里点一次 Backup Now 确认产出 pg_dump 版备份。
踩到的四个坑

三类结构性陷阱示意
坑一:种子数据与迁移数据冲突。 13.12.0 启动时 migration 报 path collision detected: 3 duplicate keys。原因:12.10.0 对空库首启时种子化了 3 个内置 snippet(ip、geocodelocation、geocodesearch),CLI 随后又从 Mongo 搬来同名的 3 条,而 v13.11 的 snippet 迁移要求路径唯一。处理:删掉首启种子的 3 行(保留 Mongo 迁移来的),重启即过。做"先建 schema 再搬数据"的迁移时,搬数之前先清点应用首启种了什么。
坑二:nginx 正则劫持新 API。 Yohaku 的 feed/sitemap 一直 500,日志显示拿到 XML 却按 JSON 解析。定位到反向代理里一条老规则 location ~* /(feed|sitemap|atom.xml),正则没有锚定开头,而 nginx 的 regex location 优先于 prefix location,v13 新接口 /api/v3/aggregate/feed 包含子串 /feed,被整个劫持到 core 的 RSS 页面。改成 location ~* ^/(feed|sitemap|atom.xml) 解决。升级 API 版本时值得把代理配置里所有正则 location 过一遍。
坑三:旧主题配置缺字段。 从 Mongo 迁来的 theme/shiro 主题 snippet 是 2024 年的 schema,没有 config.module.rss 节点,Yohaku 的 feed 路由读取时直接崩。在 PG 里给该 snippet 合并 {"rss":{"enable":true,"custom_elements":[]}} 解决。跨大版本迁移后,主题/配置类 JSON 的 schema 也要对齐新前台的预期。
坑四:鉴权静默降级。 v13 的 API key 必须放 x-api-key 请求头(Better Auth 的 apiKey 插件只配置了这个 header)。沿用旧习惯放 Authorization: Bearer 不会报 401,只会被当成游客,公开接口照样返回 200。鉴权探活要断言身份而非状态码:
curl -H "x-api-key: $KEY" https://api.dhpie.com/api/v3/owner/check_logged
# 有效 → {"data":{"ok":1,"is_guest":false}}
结果与收尾
迁移后的生产栈:ghcr.io/dante-dan/mx-server:13.12.0-dante.1 + postgres:16-alpine + redis + ghcr.io/dante-dan/yohaku:latest。数据零丢失(73/25/4 全程对数一致),停机约两小时。
观察一天确认稳定后收尾:从 compose 移除 mongo 服务并删除 513MB 数据目录(终版 mongodump 保留在服务器外)、删除 shiro 容器和部署目录、清理 788 个 Mongo 时代的自动备份(894MB)和不再使用的镜像。
迁移后的日常升级流程收敛为:git fetch upstream && git merge <新 tag> → push → 打 v*-dante.N tag → CI 出镜像 → 服务器 docker compose pull app && docker compose up -d app。升级从此是显式决策,:latest 不再出现在任何 compose 里。