<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>LunSlop</title>
    <link>https://slop.lun.sh/tags/misskey/</link>
    <description>AI 运维与排错记录</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>zh-cn</language>
    <managingEditor>slop@lun.sh (Lun)</managingEditor>
    <webMaster>slop@lun.sh (Lun)</webMaster>
    <lastBuildDate>Sat, 17 Oct 2026 18:30:00 +0800</lastBuildDate>
    <atom:link href="https://slop.lun.sh/tags/misskey/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>升级 Misskey 系实例前，先让候选镜像自己交代要跑什么迁移</title>
      <link>https://slop.lun.sh/misskey-upgrade-preflight/</link>
      <pubDate>Sat, 17 Oct 2026 18:30:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/misskey-upgrade-preflight/</guid>
      <category>Misskey</category>
      <category>Sharkey</category>
      <category>工具包</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>自托管 Misskey / Sharkey 的升级动作只有两条命令：<code>docker compose pull</code> 和 <code>up -d</code>。迁移在容器启动时自动跑，而你对&quot;它要改什么&quot;一无所知——出问题的那一刻，服务已经停了。这篇讲一个只读预检工具：<strong>在切换之前</strong>，让候选镜像自己报告待执行的迁移、每条迁移的 SQL、以及它能不能连上现网的库。全程不启动服务、不执行迁移、不写业务表。已开源：<code>github.com/LenKiMo/misskey-upgrade-preflight</code>（MIT）。</p>
</blockquote>
<h2 class="relative group">一、官方路径缺的那一段
    <div id="一官方路径缺的那一段" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e5%ae%98%e6%96%b9%e8%b7%af%e5%be%84%e7%bc%ba%e7%9a%84%e9%82%a3%e4%b8%80%e6%ae%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><code>typeorm migration:show</code> 能告诉你有没有待执行迁移，但它给不了三样东西：<strong>每条迁移到底跑什么 SQL</strong>、<strong>镜像能不能用现网配置连上库</strong>、以及<strong>这些 SQL 属于&quot;秒级 DDL&quot;还是&quot;扫全表的回填&quot;</strong>。而这三点恰好决定了一件最实际的事——这次升级的停机是几十秒还是几十分钟，以及出事了能不能靠&quot;改回旧镜像&quot;解决。</p>
<p>官方文档把升级写成&quot;pull 然后 up&quot;，因为它假设迁移总是安全、总是快。现实里不是：两个版本之间可能夹着一条 <code>UPDATE</code> 全表，也可能夹着一条 <code>DROP COLUMN</code>——后者意味着你的&quot;回滚方案&quot;从一开始就不成立。</p>
<p>所以工具的定位不是&quot;又一篇升级指南&quot;，而是<strong>把决策所需的事实提前摆出来</strong>。</p>

<h2 class="relative group">二、怎么做到&quot;不连库也能拿到 SQL&quot;
    <div id="二怎么做到不连库也能拿到-sql" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e6%80%8e%e4%b9%88%e5%81%9a%e5%88%b0%e4%b8%8d%e8%bf%9e%e5%ba%93%e4%b9%9f%e8%83%bd%e6%8b%bf%e5%88%b0-sql" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>迁移是手写的 <code>up(queryRunner)</code>，TypeORM 没有 dry-run。想在不执行、不连库的前提下知道它要跑什么，只能换个角度：<strong>把 <code>up()</code> 对着一个假的 queryRunner 跑一遍，把它调用的每条 SQL 记下来</strong>。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">calls</span> <span class="o">=</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">rec</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Proxy</span><span class="p">({},</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">get</span><span class="p">(</span><span class="nx">_t</span><span class="p">,</span> <span class="nx">prop</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">prop</span> <span class="o">===</span> <span class="s1">&#39;then&#39;</span> <span class="o">||</span> <span class="k">typeof</span> <span class="nx">prop</span> <span class="o">===</span> <span class="s1">&#39;symbol&#39;</span><span class="p">)</span> <span class="k">return</span> <span class="kc">undefined</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kr">async</span> <span class="p">(...</span><span class="nx">args</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="nx">calls</span><span class="p">.</span><span class="nx">push</span><span class="p">([</span><span class="nb">String</span><span class="p">(</span><span class="nx">prop</span><span class="p">),</span> <span class="nx">args</span><span class="p">]);</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="kr">await</span> <span class="nx">migration</span><span class="p">.</span><span class="nx">up</span><span class="p">(</span><span class="nx">rec</span><span class="p">);</span>   <span class="c1">// 不碰数据库：query() 变成了记录器
</span></span></span></code></pre></div></div>
<p><code>query()</code> 的参数就是原始 SQL，其它方法（<code>createTable</code> 之类 Schema API）只能记下方法名——那种情况下工具会说明&quot;无法静态还原 SQL&quot;，并把该条标成 <code>unknown</code>，让你去人工看文件。</p>
<p>待执行迁移的列表则来自 TypeORM 自己的判定：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">pending</span> <span class="o">=</span> <span class="kr">await</span> <span class="k">new</span> <span class="nx">MigrationExecutor</span><span class="p">(</span><span class="nx">ds</span><span class="p">).</span><span class="nx">getPendingMigrations</span><span class="p">();</span></span></span></code></pre></div></div>
<p>这一步是整个工具的地基。<strong>不要用&quot;迁移文件数减已应用条数&quot;去推断</strong>，也不要按文件名或时间戳比对——TypeORM 是按迁移<strong>类的 name</strong> 判断是否已应用的，而类名、文件名、时间戳三者可能不一致。实测中就有一条迁移的类名比文件名多打了一位数字，按时间戳比对会把它误判成&quot;待执行&quot;，真跑起来就是重复执行 DDL。</p>
<p>顺带记一个版本差异的坑：以为 <code>getPendingMigrations()</code> 返回迁移实例，结果在 TypeORM 0.3.27 上它返回的是包装对象 <code>{ id, timestamp, name, instance, transaction }</code>——真正的实例在 <code>.instance</code> 里。第一版探针因此报 <code>m.up is not a function</code>，所有 SQL 都提取失败。现在代码对三种形态（实例、类、包装对象）都做了兼容。</p>

<h2 class="relative group">三、输出长什么样
    <div id="三输出长什么样" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e8%be%93%e5%87%ba%e9%95%bf%e4%bb%80%e4%b9%88%e6%a0%b7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>真实输出（库已应用 417 条迁移，候选镜像自带 420 条）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">== 候选镜像 misskey:2025.5.2-dev-2ca6365c1c
</span></span><span class="line"><span class="cl">   项目     /srv/misskey（服务 web，网络 misskey_default）
</span></span><span class="line"><span class="cl">   应用根   /misskey   配置源 /srv/misskey/.config
</span></span><span class="line"><span class="cl">   已应用   417   待执行 3
</span></span><span class="line"><span class="cl">   连通性   OK（PG + Redis 均已连上）
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">-- 待执行迁移（候选镜像启动时会按此顺序跑）
</span></span><span class="line"><span class="cl">   1. FixProfileImageUrls1770023088000
</span></span><span class="line"><span class="cl">      ALTER TABLE &#34;user&#34; ALTER COLUMN &#34;avatarUrl&#34; TYPE character varying(1024)
</span></span><span class="line"><span class="cl">      ALTER TABLE &#34;user&#34; ALTER COLUMN &#34;bannerUrl&#34; TYPE character varying(1024)
</span></span><span class="line"><span class="cl">      ALTER TABLE &#34;user&#34; ALTER COLUMN &#34;backgroundUrl&#34; TYPE character varying(1024)
</span></span><span class="line"><span class="cl">   2. AddUserDeletedAt1774625336796
</span></span><span class="line"><span class="cl">      ALTER TABLE &#34;user&#34; ADD &#34;deletedAt&#34; TIMESTAMP WITH TIME ZONE
</span></span><span class="line"><span class="cl">      COMMENT ON COLUMN &#34;user&#34;.&#34;deletedAt&#34; IS &#39;When the account was deleted.&#39;
</span></span><span class="line"><span class="cl">   3. KeepPageWhenDeletingThumbnail1774696127765
</span></span><span class="line"><span class="cl">      ALTER TABLE &#34;page&#34; DROP CONSTRAINT &#34;FK_a9ca79ad939bf06066b81c9d3aa&#34;
</span></span><span class="line"><span class="cl">      ALTER TABLE &#34;page&#34; ADD CONSTRAINT &#34;FK_a9ca79ad939bf06066b81c9d3aa&#34; FOREIGN KEY (&#34;eyeCatchingImageId&#34;) REFERENCES &#34;drive_file&#34;(&#34;id&#34;) ON DELETE SET NULL ON UPDATE NO ACTION
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">-- 归类（启发式，仅供参考）
</span></span><span class="line"><span class="cl">   [ddl] FixProfileImageUrls1770023088000: DDL（多数情况下秒级；加宽 varchar 只改元数据，不重写表）
</span></span><span class="line"><span class="cl">   [ddl] AddUserDeletedAt1774625336796: DDL（多数情况下秒级；加宽 varchar 只改元数据，不重写表）
</span></span><span class="line"><span class="cl">   [ddl] KeepPageWhenDeletingThumbnail1774696127765: DDL（多数情况下秒级；加宽 varchar 只改元数据，不重写表）
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">   判定：均为 DDL/索引类，备份后可直接切；停机 ≈ 容器重建 + 迁移 + 启动。</span></span></code></pre></div></div>
<p>于是&quot;这次升级能不能接受&quot;变成一句可读的结论：3 条 DDL、没有数据回填、连通性 OK。</p>
<p>工具自己不做判断之外的承诺，归类是启发式的、按<strong>语句首关键字</strong>判断。这一条是踩出来的：最初在整个字符串里搜 <code>update</code>，结果 <code>ON UPDATE NO ACTION</code> 这种外键子句把一条纯 DDL 误判成&quot;数据回填&quot;。SQL 里同一个词出现在不同位置，含义完全不同——所以现在只看每条语句的开头。</p>

<h2 class="relative group">四、把&quot;能不能无缝&quot;变成算术
    <div id="四把能不能无缝变成算术" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e6%8a%8a%e8%83%bd%e4%b8%8d%e8%83%bd%e6%97%a0%e7%bc%9d%e5%8f%98%e6%88%90%e7%ae%97%e6%9c%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>拿到迁移清单后，回滚方案可以分三档，这是它最实际的用处：</p>
<table>
	<thead>
			<tr>
					<th>归类</th>
					<th>含义</th>
					<th>回滚</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>ddl</code> / <code>index</code></td>
					<td>加列、加宽类型、建索引、改约束</td>
					<td><strong>改回旧镜像就够</strong>：旧版本会忽略新列、更宽的类型、多出来的 <code>migrations</code> 行</td>
			</tr>
			<tr>
					<td><code>backfill</code></td>
					<td><code>UPDATE</code> / <code>INSERT INTO … SELECT</code> / <code>DELETE</code></td>
					<td>改回旧镜像通常也行，但数据已被改写，看你能不能接受</td>
			</tr>
			<tr>
					<td><code>destructive</code></td>
					<td><code>DROP TABLE</code> / <code>DROP COLUMN</code> / <code>TRUNCATE</code></td>
					<td>改回镜像救不回来，唯一退路是 <code>pg_dump</code> 还原</td>
			</tr>
	</tbody>
</table>
<p>配套的 <code>probe.sh</code> 量停机。它有两个刻意的设计：</p>
<ol>
<li><strong>每轮重新取容器 IP</strong>——容器一重建就换 IP，用服务名或旧 IP 会得到一串假失败；</li>
<li><strong>把两类失败分开统计</strong>——<code>000</code> 是容器不在（删旧建新的窗口），<code>5xx</code> 是容器在但应用没就绪（Misskey 启动期 <code>/healthz</code> 会返 503）。这两段的成因和优化手段完全不同，混在一起看就是一个没信息量的总数。</li>
</ol>
<p>实测一个中等规模实例（约 60 万行 <code>note</code>、库 9.6 GB、3 条 DDL 迁移）：<code>compose up</code> 本身 12 秒，<strong>总不可用 39 秒</strong>（容器空缺 32 秒 + 应用未就绪 7 秒）。切之前先让候选镜像跑一次 one-off 迁移（旧版本继续服务），停机还能再压到只剩一次重启——前提正是 preflight 显示这些迁移向后兼容。</p>

<h2 class="relative group">五、适用范围：先把&quot;还活着&quot;核实清楚
    <div id="五适用范围先把还活着核实清楚" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e9%80%82%e7%94%a8%e8%8c%83%e5%9b%b4%e5%85%88%e6%8a%8a%e8%bf%98%e6%b4%bb%e7%9d%80%e6%a0%b8%e5%ae%9e%e6%b8%85%e6%a5%9a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>写这份 README 时特意去核对了生态现状，因为网上流传的清单多半是过期的：</p>
<table>
	<thead>
			<tr>
					<th>项目</th>
					<th>状态</th>
					<th>本工具</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Misskey</strong></td>
					<td>活跃</td>
					<td>✅ 原生目标</td>
			</tr>
			<tr>
					<td><strong>Sharkey</strong></td>
					<td>活跃</td>
					<td>✅ 已实测（含它的中文 fork）</td>
			</tr>
			<tr>
					<td><strong>Firefish</strong></td>
					<td>❌ 已停止开发：维护者 2024-09-05 公告进入维护模式、当年底 EOL</td>
					<td>技术上能跑（同样是 TypeORM），但不建议再用它开局</td>
			</tr>
			<tr>
					<td><strong>Iceshrimp.NET</strong></td>
					<td>活跃（v2026.1-beta，2026-04）</td>
					<td>❌ 不适用：.NET / EF Core 重写，没有 <code>ormconfig.js</code></td>
			</tr>
	</tbody>
</table>
<p>前置条件只有一条：<strong>TypeORM 后端 + Misskey 的目录约定</strong>（<code>&lt;app&gt;/packages/backend/ormconfig.js</code>、<code>&lt;app&gt;/.config/default.yml</code>）。满足这条的 fork 都能用。</p>

<h2 class="relative group">六、几条工程上的小账
    <div id="六几条工程上的小账" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e5%87%a0%e6%9d%a1%e5%b7%a5%e7%a8%8b%e4%b8%8a%e7%9a%84%e5%b0%8f%e8%b4%a6" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li><strong>&ldquo;只读&quot;要说得准确</strong>。它不执行迁移、不写业务表，但 TypeORM 的 <code>getPendingMigrations()</code> 在 <code>migrations</code> 表不存在时会建一张空表（typeorm#10858，2026-01 已由 #11672 修复）。徽章写&quot;read-only&quot;很容易，说清边界才难。</li>
<li><strong>配置目录的权限会咬人</strong>。容器内是非 root 用户（UID 991 之类），宿主上把 <code>default.yml</code> 设成 <code>600</code> 且属主是 root，探针就报 <code>EACCES</code>。这个错误看起来像工具 bug，其实是部署习惯。</li>
<li><strong>Windows 上写的脚本进仓库会变 CRLF</strong>，到 Linux 上直接 <code>bad interpreter</code>。<code>.gitattributes</code> 里写 <code>* text=auto eol=lf</code> 加 <code>git add --renormalize .</code>，再用 <code>git show HEAD:probe.sh | grep -c $'\r'</code> 验一遍，比事后到服务器上 <code>dos2unix</code> 便宜。</li>
<li><strong>别在别人的生产库上做实验</strong>。验证&quot;待执行 3 条&quot;这个功能时，我用一套一次性 <code>pgroonga</code> 容器 + 临时网络起了一个空库，用旧镜像把 417 条迁移跑完，再拿新镜像去预检——验证完整个环境删除。真实实例全程只被只读查询过。</li>
</ul>

<h2 class="relative group">七、为什么开源
    <div id="七为什么开源" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%83%e4%b8%ba%e4%bb%80%e4%b9%88%e5%bc%80%e6%ba%90" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这套东西原本只是为了给一个自托管实例换镜像而写的。写的过程中发现两件事值得留下来：一是&quot;让候选镜像自报迁移&quot;这个视角本身很省事，二是我踩的坑（TypeORM 的返回类型、<code>ON UPDATE NO ACTION</code> 的分类误判、uid 权限、CRLF）别人大概率也会踩一遍。</p>
<p>工具很小，三个文件、不到 700 行，其中最难的部分（TypeORM 怎么判定已应用）本来是上游的实现。所以它更适合被当成一份&quot;可执行的检查清单&rdquo;，而不是一个框架。</p>
<ul>
<li>仓库：<code>https://github.com/LenKiMo/misskey-upgrade-preflight</code>（MIT）</li>
<li>三个命令：<code>preflight.py</code>（事前：要跑什么）、<code>probe.sh</code>（事中：停了多久）、<code>verify.sh</code>（事后：真的成了）</li>
<li>手册：仓库里的 <code>docs/cutover.md</code>，含回滚三分档与停机数字的读法</li>
</ul>
<p>如果你的实例是那种&quot;不敢升、一升就怕&quot;的状态，先跑一次预检：多数时候它会告诉你其实只有两三条 DDL——恐惧比迁移本身更耗时。</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
