↓ 跳过正文

升级 Misskey 系实例前,先让候选镜像自己交代要跑什么迁移

·3228 字·7 分钟
目录

自托管 Misskey / Sharkey 的升级动作只有两条命令:docker compose pull 和 up -d。迁移在容器启动时自动跑,而你对"它要改什么"一无所知——出问题的那一刻,服务已经停了。这篇讲一个只读预检工具:在切换之前,让候选镜像自己报告待执行的迁移、每条迁移的 SQL、以及它能不能连上现网的库。全程不启动服务、不执行迁移、不写业务表。已开源:github.com/LenKiMo/misskey-upgrade-preflight(MIT)。

一、官方路径缺的那一段
#

typeorm migration:show 能告诉你有没有待执行迁移,但它给不了三样东西:每条迁移到底跑什么 SQL、镜像能不能用现网配置连上库、以及这些 SQL 属于"秒级 DDL"还是"扫全表的回填"。而这三点恰好决定了一件最实际的事——这次升级的停机是几十秒还是几十分钟,以及出事了能不能靠"改回旧镜像"解决。

官方文档把升级写成"pull 然后 up",因为它假设迁移总是安全、总是快。现实里不是:两个版本之间可能夹着一条 UPDATE 全表,也可能夹着一条 DROP COLUMN——后者意味着你的"回滚方案"从一开始就不成立。

所以工具的定位不是"又一篇升级指南",而是把决策所需的事实提前摆出来。

二、怎么做到"不连库也能拿到 SQL"
#

迁移是手写的 up(queryRunner),TypeORM 没有 dry-run。想在不执行、不连库的前提下知道它要跑什么,只能换个角度:把 up() 对着一个假的 queryRunner 跑一遍,把它调用的每条 SQL 记下来。

const calls = [];
const rec = new Proxy({}, {
  get(_t, prop) {
    if (prop === 'then' || typeof prop === 'symbol') return undefined;
    return async (...args) => { calls.push([String(prop), args]); };
  },
});
await migration.up(rec);   // 不碰数据库:query() 变成了记录器

query() 的参数就是原始 SQL,其它方法(createTable 之类 Schema API)只能记下方法名——那种情况下工具会说明"无法静态还原 SQL",并把该条标成 unknown,让你去人工看文件。

待执行迁移的列表则来自 TypeORM 自己的判定:

const pending = await new MigrationExecutor(ds).getPendingMigrations();

这一步是整个工具的地基。不要用"迁移文件数减已应用条数"去推断,也不要按文件名或时间戳比对——TypeORM 是按迁移类的 name 判断是否已应用的,而类名、文件名、时间戳三者可能不一致。实测中就有一条迁移的类名比文件名多打了一位数字,按时间戳比对会把它误判成"待执行",真跑起来就是重复执行 DDL。

顺带记一个版本差异的坑:以为 getPendingMigrations() 返回迁移实例,结果在 TypeORM 0.3.27 上它返回的是包装对象 { id, timestamp, name, instance, transaction }——真正的实例在 .instance 里。第一版探针因此报 m.up is not a function,所有 SQL 都提取失败。现在代码对三种形态(实例、类、包装对象)都做了兼容。

三、输出长什么样
#

真实输出(库已应用 417 条迁移,候选镜像自带 420 条):

== 候选镜像 misskey:2025.5.2-dev-2ca6365c1c
   项目     /srv/misskey(服务 web,网络 misskey_default)
   应用根   /misskey   配置源 /srv/misskey/.config
   已应用   417   待执行 3
   连通性   OK(PG + Redis 均已连上)

-- 待执行迁移(候选镜像启动时会按此顺序跑)
   1. FixProfileImageUrls1770023088000
      ALTER TABLE "user" ALTER COLUMN "avatarUrl" TYPE character varying(1024)
      ALTER TABLE "user" ALTER COLUMN "bannerUrl" TYPE character varying(1024)
      ALTER TABLE "user" ALTER COLUMN "backgroundUrl" TYPE character varying(1024)
   2. AddUserDeletedAt1774625336796
      ALTER TABLE "user" ADD "deletedAt" TIMESTAMP WITH TIME ZONE
      COMMENT ON COLUMN "user"."deletedAt" IS 'When the account was deleted.'
   3. KeepPageWhenDeletingThumbnail1774696127765
      ALTER TABLE "page" DROP CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa"
      ALTER TABLE "page" ADD CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa" FOREIGN KEY ("eyeCatchingImageId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION

-- 归类(启发式,仅供参考)
   [ddl] FixProfileImageUrls1770023088000: DDL(多数情况下秒级;加宽 varchar 只改元数据,不重写表)
   [ddl] AddUserDeletedAt1774625336796: DDL(多数情况下秒级;加宽 varchar 只改元数据,不重写表)
   [ddl] KeepPageWhenDeletingThumbnail1774696127765: DDL(多数情况下秒级;加宽 varchar 只改元数据,不重写表)

   判定:均为 DDL/索引类,备份后可直接切;停机 ≈ 容器重建 + 迁移 + 启动。

于是"这次升级能不能接受"变成一句可读的结论:3 条 DDL、没有数据回填、连通性 OK。

工具自己不做判断之外的承诺,归类是启发式的、按语句首关键字判断。这一条是踩出来的:最初在整个字符串里搜 update,结果 ON UPDATE NO ACTION 这种外键子句把一条纯 DDL 误判成"数据回填"。SQL 里同一个词出现在不同位置,含义完全不同——所以现在只看每条语句的开头。

四、把"能不能无缝"变成算术
#

拿到迁移清单后,回滚方案可以分三档,这是它最实际的用处:

归类 含义 回滚
ddl / index 加列、加宽类型、建索引、改约束 改回旧镜像就够:旧版本会忽略新列、更宽的类型、多出来的 migrations 行
backfill UPDATE / INSERT INTO … SELECT / DELETE 改回旧镜像通常也行,但数据已被改写,看你能不能接受
destructive DROP TABLE / DROP COLUMN / TRUNCATE 改回镜像救不回来,唯一退路是 pg_dump 还原

配套的 probe.sh 量停机。它有两个刻意的设计:

  1. 每轮重新取容器 IP——容器一重建就换 IP,用服务名或旧 IP 会得到一串假失败;
  2. 把两类失败分开统计——000 是容器不在(删旧建新的窗口),5xx 是容器在但应用没就绪(Misskey 启动期 /healthz 会返 503)。这两段的成因和优化手段完全不同,混在一起看就是一个没信息量的总数。

实测一个中等规模实例(约 60 万行 note、库 9.6 GB、3 条 DDL 迁移):compose up 本身 12 秒,总不可用 39 秒(容器空缺 32 秒 + 应用未就绪 7 秒)。切之前先让候选镜像跑一次 one-off 迁移(旧版本继续服务),停机还能再压到只剩一次重启——前提正是 preflight 显示这些迁移向后兼容。

五、适用范围:先把"还活着"核实清楚
#

写这份 README 时特意去核对了生态现状,因为网上流传的清单多半是过期的:

项目 状态 本工具
Misskey 活跃 ✅ 原生目标
Sharkey 活跃 ✅ 已实测(含它的中文 fork)
Firefish ❌ 已停止开发:维护者 2024-09-05 公告进入维护模式、当年底 EOL 技术上能跑(同样是 TypeORM),但不建议再用它开局
Iceshrimp.NET 活跃(v2026.1-beta,2026-04) ❌ 不适用:.NET / EF Core 重写,没有 ormconfig.js

前置条件只有一条:TypeORM 后端 + Misskey 的目录约定(<app>/packages/backend/ormconfig.js、<app>/.config/default.yml)。满足这条的 fork 都能用。

六、几条工程上的小账
#

  • “只读"要说得准确。它不执行迁移、不写业务表,但 TypeORM 的 getPendingMigrations() 在 migrations 表不存在时会建一张空表(typeorm#10858,2026-01 已由 #11672 修复)。徽章写"read-only"很容易,说清边界才难。
  • 配置目录的权限会咬人。容器内是非 root 用户(UID 991 之类),宿主上把 default.yml 设成 600 且属主是 root,探针就报 EACCES。这个错误看起来像工具 bug,其实是部署习惯。
  • Windows 上写的脚本进仓库会变 CRLF,到 Linux 上直接 bad interpreter。.gitattributes 里写 * text=auto eol=lf 加 git add --renormalize .,再用 git show HEAD:probe.sh | grep -c $'\r' 验一遍,比事后到服务器上 dos2unix 便宜。
  • 别在别人的生产库上做实验。验证"待执行 3 条"这个功能时,我用一套一次性 pgroonga 容器 + 临时网络起了一个空库,用旧镜像把 417 条迁移跑完,再拿新镜像去预检——验证完整个环境删除。真实实例全程只被只读查询过。

七、为什么开源
#

这套东西原本只是为了给一个自托管实例换镜像而写的。写的过程中发现两件事值得留下来:一是"让候选镜像自报迁移"这个视角本身很省事,二是我踩的坑(TypeORM 的返回类型、ON UPDATE NO ACTION 的分类误判、uid 权限、CRLF)别人大概率也会踩一遍。

工具很小,三个文件、不到 700 行,其中最难的部分(TypeORM 怎么判定已应用)本来是上游的实现。所以它更适合被当成一份"可执行的检查清单”,而不是一个框架。

  • 仓库:https://github.com/LenKiMo/misskey-upgrade-preflight(MIT)
  • 三个命令:preflight.py(事前:要跑什么)、probe.sh(事中:停了多久)、verify.sh(事后:真的成了)
  • 手册:仓库里的 docs/cutover.md,含回滚三分档与停机数字的读法

如果你的实例是那种"不敢升、一升就怕"的状态,先跑一次预检:多数时候它会告诉你其实只有两三条 DDL——恐惧比迁移本身更耗时。