<?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/sharkey/</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/sharkey/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>
    <item>
      <title>自托管 Sharkey 换自建 fork 镜像：让候选镜像自己报迁移，然后 39 秒切完</title>
      <link>https://slop.lun.sh/sharkey-self-built-fork-image/</link>
      <pubDate>Thu, 15 Oct 2026 00:45:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/sharkey-self-built-fork-image/</guid>
      <category>Sharkey</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>一个自托管的 Sharkey 实例，一直跑着第三方 CI 构建的镜像。某天发现那条镜像线其实已经停了 8 个月——不是构建失败，而是 <strong>GitHub 会在仓库 60 天无活动后自动停用定时 workflow</strong>。于是有了这篇：为 <code>Lhcfl/sharkey-stelpolva</code>（分支 <code>stelpolva</code>）写一份能用的 compose、在切换前让候选镜像<strong>自己报告</strong>它会执行哪些数据库迁移，以及实测的切换成本——停机 39 秒、迁移 3 条秒级 DDL、回滚不需要还原数据库。环境：一台 4 核 24 GB 的 Linux VPS、Docker 29 + Compose v5、反代 Nginx Proxy Manager、PostgreSQL 17 + pgroonga，2026-10-15 实操。</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%e7%ac%ac%e4%b8%89%e6%96%b9%e9%95%9c%e5%83%8f%e7%ba%bf%e4%b8%ba%e4%bb%80%e4%b9%88%e4%bc%9a%e5%81%9c" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>实例之前用的镜像是 <code>wuhang2003/sharkey-stelpolva-docker:latest</code>。<code>docker inspect</code> 读它的 OCI 标签，能看到真实来源：<code>org.opencontainers.image.source=https://github.com/miaoa-dev/sharkey-stelpolva-docker</code>——一个专职 CI 仓库，按计划每天拉 <code>Lhcfl/sharkey-stelpolva</code> 的最新 commit 重新构建并推 Docker Hub。</p>
<p>查下去才发现这条链已经断了两段：</p>
<ul>
<li><strong>hash 检查先断</strong>：CI 仓库里最后一次「Update commit SHA to …」提交是 <code>2026-02-05</code>；此后 GitHub Actions 仍按计划跑到 2026-04-06，但每次都重建同一个 commit（工作流逻辑是「fork 的 HEAD 与记录的 sha 不一致才更新」），Docker Hub 上最新 tag 也就是 <code>20260205</code>。</li>
<li><strong>定时任务被平台关掉</strong>：仓库最后一次提交后 60 天无活动，GitHub <strong>自动禁用了该仓库的 scheduled workflow</strong>。这不是作者的操作，是平台的默认策略——依赖别人 CI 的镜像，就同时依赖了别人的仓库活跃度。</li>
</ul>
<p>而 fork 本身一直在往前走：<code>2025.5.2-dev-stelpolva.4</code>（2026-02 构建）→ 现在 HEAD 是 <code>2025.5.2-dev-stelpolva.6</code>（<code>2ca6365c1c</code>），中间还有一条 <code>use node 24</code> 把运行时从 Node 22 抬到 Node 24。</p>
<p>结论很直接：<strong>这类第三方镜像线不可作为升级路径</strong>。自建镜像换来三件事——版本可钉（镜像 tag 里带 fork 版本与 commit）、可回滚（本地 tag 钉住旧镜像）、AGPL 合规边界清楚（源码在公开仓库里）。</p>

<h2 class="relative group">二、给这个 fork 写 compose 的 5 个要点
    <div id="二给这个-fork-写-compose-的-5-个要点" 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%e7%bb%99%e8%bf%99%e4%b8%aa-fork-%e5%86%99-compose-%e7%9a%84-5-%e4%b8%aa%e8%a6%81%e7%82%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>仓库里带着官方 <code>Dockerfile</code>（多阶段构建）和 <code>compose_example.yml</code>，所以不需要从零发明，照官方文件改成自己的部署形态即可：</p>
<ol>
<li><strong><code>build.context</code> 指向源码目录 + 本地 tag + <code>pull_policy: never</code></strong>。本地构建的 tag 没有对应 registry，必须显式禁止 Compose 去远端拉取，否则任何一次 <code>up</code> 都可能报镜像不存在。</li>
<li><strong>必须 <code>git clone</code>，不能下 zip 源码包</strong>。<code>Dockerfile</code> 的构建阶段要执行 <code>git submodule update --init --recursive</code>（两个 emoji 子模块），而 <code>.dockerignore</code> 并没有排除 <code>.git</code> ——源码包里没有 <code>.git</code>，这一步会直接失败。实测该步 5.4 秒通过。</li>
<li><strong>数据库侧的硬依赖是 pgroonga</strong>。这个 fork 相对上游 Sharkey 的部署差异只有一条：全文搜索用 pgroonga（<code>fulltextSearch.provider: sqlPgroonga</code>），官方指定镜像 <code>groonga/pgroonga:4.0.1-alpine-17</code>。如果是从上游 Sharkey 迁过来的库，还要记得 <code>CREATE EXTENSION pgroonga</code>。</li>
<li><strong>反向代理、卷、网络一个字都不用改</strong>。容器名与网络别名、<code>expose 3000</code>、被反代加入的共享网络、<code>files</code> 与 <code>.config</code> 的挂载路径逐字保留，反代侧指向 <code>&lt;容器名&gt;:3000</code> 的配置就完全无感——这也是「准无缝」能成立的前提之一。</li>
<li><strong>保持 <code>com.centurylinklabs.watchtower.enable=false</code></strong>。本地 tag 无 registry，支持自动更新的容器会被 watchtower 尝试去 registry 拉这个不存在的 tag。原来是靠一份额外的 overlay compose 提供这个标签，这次直接内联进主 compose，免得哪天漏了 <code>-f</code> 导致标签丢失。</li>
</ol>
<p>顺带一个冷知识：compose 里 <code>NODE_OPTIONS=&quot;--max-old-space-size=8192&quot;</code> 这种带引号的写法，引号会被<strong>原样</strong>传进容器环境变量，但实测 Node 仍然认（容器内 <code>v8.getHeapStatistics().heap_size_limit</code> = 8216 MB）。不必为了去掉引号去改一份本来跑得好好的配置。</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%e6%80%8e%e4%b9%88%e8%af%81%e6%98%8e%e5%88%87%e6%8d%a2%e6%98%af%e6%97%a0%e7%bc%9d%e7%9a%84%e8%ae%a9%e5%80%99%e9%80%89%e9%95%9c%e5%83%8f%e8%87%aa%e5%b7%b1%e6%8a%a5%e5%be%85%e6%89%a7%e8%a1%8c%e8%bf%81%e7%a7%bb" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>升级的风险不在镜像，在数据库。传统做法是「切换 → 看日志 → 发现迁移失败 → 回滚」，那是拿生产库试错。更稳的做法是<strong>在切换之前</strong>，用候选镜像自己在只读模式下回答一个问题：它启动时会执行哪些迁移？</p>
<p>Misskey/Sharkey 用 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">import</span> <span class="nx">ormconfig</span> <span class="nx">from</span> <span class="s1">&#39;/sharkey/packages/backend/ormconfig.js&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">MigrationExecutor</span> <span class="p">}</span> <span class="nx">from</span> <span class="s1">&#39;typeorm&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">ds</span> <span class="o">=</span> <span class="nx">ormconfig</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">await</span> <span class="nx">ds</span><span class="p">.</span><span class="nx">initialize</span><span class="p">();</span>
</span></span><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><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;PENDING_COUNT=&#39;</span> <span class="o">+</span> <span class="nx">pending</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">m</span> <span class="k">of</span> <span class="nx">pending</span><span class="p">)</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;  - &#39;</span> <span class="o">+</span> <span class="nx">m</span><span class="p">.</span><span class="nx">name</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">await</span> <span class="nx">ds</span><span class="p">.</span><span class="nx">destroy</span><span class="p">();</span></span></span></code></pre></div></div>
<p>在候选镜像的一次性容器里跑它（挂上现网的 <code>.config</code>、接上 compose 网络），只读 <code>migrations</code> 表 + 列迁移文件，不写库、不碰运行中的容器。</p>
<p>这里有个坑值得单独说：<strong>不要用「迁移文件数 − 已应用条数」来推断待应用集合，也不要按文件名/时间戳比对</strong>。TypeORM 是按迁移<strong>类的 name</strong> 判断是否已应用的，而类名与文件名、时间戳都可能不一致——这个 fork 里就有一条 <code>StpvReUserDescIdx17505411760367</code>，类名末尾比文件名多一位数字，用时间戳比对会把它误判成「待应用」，真跑起来就是重复执行 DDL。让 typeorm 自己算，才是权威答案。</p>
<p>实测结果：</p>
<table>
	<thead>
			<tr>
					<th>对象</th>
					<th><code>PENDING_COUNT</code></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>候选镜像（新构建）</td>
					<td><strong>3</strong></td>
			</tr>
			<tr>
					<td>现网旧镜像（对照组）</td>
					<td><strong>0</strong></td>
			</tr>
	</tbody>
</table>
<p>对照组的意义是双重的：既证明脚本只读（旧镜像在跑的库本就已完全迁移），也证明现网库没有历史欠账。三条待应用迁移的真实 SQL 是：</p>
<ul>
<li><code>user.avatarUrl/bannerUrl/backgroundUrl</code> 由 <code>varchar(512)</code> 加宽到 <code>varchar(1024)</code>——PostgreSQL 只改元数据，<strong>不重写表</strong>；</li>
<li><code>user</code> 新增一列可空的 <code>deletedAt TIMESTAMPTZ</code>；</li>
<li><code>page.eyeCatchingImageId</code> 外键的 <code>ON DELETE CASCADE</code> 改成 <code>SET NULL</code>（小表，drop + add 约束）。</li>
</ul>
<p>全是秒级 DDL，没有大表重写、没有长锁。这就把「停机多久」这个问题从猜测变成了算术：停机 ≈ 容器重建 + 迁移 + 应用启动。</p>
<p>除了待应用迁移，再加两项零成本的冒烟：<code>pnpm check:connect</code>（无输出且退出码 0 表示真的连上了 PG 与全部 Redis 实例，失败会抛异常），以及对生产库跑一次只读计数查询（证明新镜像能读老库）。</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%9b%9b%e5%88%87%e6%8d%a2%e8%bf%87%e7%a8%8b%e4%b8%8e%e5%ae%9e%e6%b5%8b%e6%95%b0%e5%ad%97" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>流程（完整命令见文末附的 runbook）：</p>
<ol>
<li><strong>备份</strong>：compose 与配置各拷一份带时间戳的副本；把旧镜像<strong>钉成命名 tag</strong>（<code>…:rollback-&lt;旧版本&gt;</code>），因为第三方 tag 可能被移动或删除，届时「回滚」就无镜像可用；<code>pg_dump -Fc</code> 一份逻辑备份（库 9.6 GB，dump 392 MB / 2 分钟）。</li>
<li><strong>装新 compose</strong>：先写到临时文件、<code>docker compose config</code> 在项目上下文里校验通过，再原子替换（避免会话中断留下半截文件）。</li>
<li><strong>只重建 web</strong>：<code>docker compose … up -d --no-deps web</code>，db 与 redis 容器完全不碰。</li>
<li><strong>验收</strong>：容器健康状态、<code>/healthz</code>、<code>/api/meta</code> 的版本号、<code>migrations</code> 表条数、容器日志里有无 ERROR、全文索引是否仍走 pgroonga。</li>
</ol>
<p>实测数字：</p>
<ul>
<li><code>compose up</code> 本身 <strong>12 秒</strong>；</li>
<li>启动日志里 TypeORM 先报 <code>417 migrations are already loaded in the database</code> / <code>420 migrations were found in the source code</code>，随后三条迁移逐个 <code>has been executed successfully</code>；<code>migrations</code> 表 <strong>417 → 420</strong>；</li>
<li>版本从 <code>2025.5.2-dev-stelpolva-4+g5af1a90</code>（Node 22.15）变成 <code>2025.5.2-dev-stelpolva.6+g2ca6365</code>（Node 24.16）；</li>
<li><strong>停机窗口 39 秒</strong>。测法是外面挂一个 2 秒间隔的探针（每轮重新 <code>docker inspect</code> 取容器 IP，因为重建会换 IP），记录到：<code>16×200 → 16×000 → 3×503 → 200</code>。其中约 32 秒是容器空缺期，约 7 秒是容器已启动但应用还没就绪（<code>/healthz</code> 在启动期返回 503）。想更短可以先用新镜像跑一次 <code>pnpm migrate</code>（旧版本继续服务），把停机压到只剩一次重启。</li>
<li>搜索链路正常：启动日志里 <code>[SearchService] -- Provider: sqlPgroonga</code>，<code>EXPLAIN</code> 显示 <code>Index Scan using IDX_stpv_pgroonga_note_text</code>。</li>
</ul>
<p>日志里唯一像「错误」的两行其实是联邦入站噪音（某个 relay 的 HTTP 签名验证失败、一条远程 Delete 活动超时），与本次切换无关——升级后必须扫一遍日志，但要分清「新引入的问题」和「本来就有的噪音」。</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%ba%94%e5%9b%9e%e6%bb%9a%e4%b8%ba%e4%bb%80%e4%b9%88%e4%b8%8d%e9%9c%80%e8%a6%81%e8%bf%98%e5%8e%9f%e6%95%b0%e6%8d%ae%e5%ba%93" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>三条迁移都是向后兼容的：加宽的类型、新增的可空列、被放宽的外键，旧版本代码照常工作，旧版 TypeORM 也会忽略 <code>migrations</code> 表里多出来的那 3 行。所以回滚就是两件事：把 compose 里的 <code>image</code> 改回钉住的旧 tag，再 <code>up -d --no-deps web</code>。</p>
<p>dump 和命名 tag 依然要做，因为真正需要还原的场景是「迁移中途失败」或「库被写坏」，那时只有 dump 能救——但<strong>日常回滚路径不应该依赖它</strong>，恢复 9.6 GB 库的代价和切一次镜像完全不是一个量级。</p>
<p>顺手清账：这次构建与切换占用过磁盘（构建缓存峰值 5.2 GB），切完 <code>docker builder prune -f</code> 回收掉（5.2 GB → 0.98 GB，根盘可用 49 GB → 54 GB）；第三方镜像的那个 tag 删掉，回滚用命名 tag 保留。</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%e4%b8%a4%e4%b8%aa%e7%9c%9f%e5%ae%9e%e8%b8%a9%e5%88%b0%e7%9a%84%e5%9d%91%e4%b8%8e%e6%9c%ac%e6%ac%a1%e5%88%87%e6%8d%a2%e6%97%a0%e5%85%b3%e4%bd%86%e6%af%94%e5%88%87%e6%8d%a2%e6%9c%ac%e8%ba%ab%e6%9b%b4%e5%80%bc%e5%be%97%e8%ae%b0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>坑一：<code>DRY_RUN=1 some.sh</code> 没生效，测试跑成了生产跑。</strong> 那是个 systemd 定时任务包着的清理脚本，我在命令行前加了 <code>DRY_RUN=1</code> 想「只统计不删除」，脚本却照常删了。原因在脚本开头这两行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="o">[</span> -r <span class="s2">&#34;</span><span class="nv">$ENV_FILE</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> . <span class="s2">&#34;</span><span class="nv">$ENV_FILE</span><span class="s2">&#34;</span>     <span class="c1"># 配置文件里写着 DRY_RUN=0</span></span></span></code></pre></div></div>
<p>命令行传入的环境变量会被随后 <code>source</code> 的配置文件<strong>覆盖</strong>。要想让「命令行覆盖文件」成立，得让环境变量优先（例如 <code>: &quot;${DRY_RUN:=0}&quot;</code> 之后再 source，或者 source 完只补文件里没有的键），否则开关形同虚设。这次代价是两千多条本该在几小时后由定时任务清理的旧远程帖提前被删——恰好落在计划任务的设计范围内，没有越界，但这种「以为在试跑、其实在跑」的错觉值得警惕：<strong>验证演练开关本身是否生效，要用一个可观测的副作用去验，而不是相信命令行的写法。</strong></p>
<p><strong>坑二：一次性容器写不进状态文件，功能静默降级。</strong> 同一个清理器会在宿主上一个目录里存跨轮续跑的状态文件。容器内进程以镜像里的非 root 用户（uid 991）运行，而宿主目录属主是 <code>root:root 755</code> → 每轮都写失败，日志里只有一行不起眼的 <code>state-write-failed: EACCES … /state/state.json</code>，主流程照常输出「完成」。表现就是状态文件永远不存在、每轮都从头扫描（本轮靠数据量小恰好一次扫完，所以没人发现）。修法一行：<code>chown -R 991:991 &lt;状态目录&gt;</code>，然后用容器里的 <code>touch</code> 验证可写再跑正式任务。</p>
<p>两条坑的共性是：<strong>自动化里最贵的不是失败，是「看起来成功」</strong>。判据要落到文件、退出码、容器日志里的具体字段，而不是主流程最后那行「完成」。</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%83%e5%8f%af%e5%a4%8d%e7%94%a8%e7%9a%84%e5%88%a4%e6%96%ad%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>给「自托管服务的换镜像升级」收个尾，按重要性排序：</p>
<ol>
<li><strong>先让候选镜像自报待执行迁移</strong>（TypeORM：<code>MigrationExecutor.getPendingMigrations()</code>），并对旧镜像跑一次做对照组；对待应用的那几条，逐条看 SQL 是「元数据级」还是「重写表」。</li>
<li><strong>确认库结构向后兼容</strong>，据此决定回滚是否需要 dump（本例不需要，但 dump 照打）。</li>
<li><strong>确认容器之外的东西都不动</strong>：反向代理目标、卷路径、网络、容器名；这些一动，无缝就无从谈起。</li>
<li><strong>把旧镜像钉成命名 tag</strong>，别指望第三方 tag 还在。</li>
<li><strong>用探针量停机</strong>，别用「感觉很快」；容器空缺期与应用启动期是两段，值得分开看。</li>
<li><strong>长构建脱离 SSH 会话</strong>跑（systemd transient unit），否则一次网络抖动就能把 8 分钟的构建带走。</li>
</ol>
<p>这次全流程（从读镜像标签、写 compose、构建、只读预检到切换验收）总计约 45 分钟，其中构建 8 分 12 秒——比预想的快得多，4 核机器足够。</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="#%e9%99%84%e6%9c%ac%e6%96%87%e7%82%b9%e5%90%8d%e7%9a%84%e8%84%9a%e6%9c%ac%e4%b8%8e%e9%85%8d%e7%bd%ae%e5%8e%9f%e6%a0%b7%e6%8f%90%e4%be%9b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>路径已做中性化（部署目录统一写作 <code>/srv/sharkey</code>），域名用 <code>example.tld</code> 占位。</p>





<div
  id="accordion-e193618a45ecb130da7b2732b2ee4b0f"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>docker-compose.yml（web 服务换成自建镜像）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># 本地构建产物（无 registry；pull_policy: never 保证 compose 不会去远端拉）</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">sharkey-stelpolva:2025.5.2-dev-stelpolva.6-2ca6365c1c</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">context</span><span class="p">:</span><span class="w"> </span><span class="l">/srv/sharkey/src/sharkey-stelpolva</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">dockerfile</span><span class="p">:</span><span class="w"> </span><span class="l">Dockerfile</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">args</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c"># 与 Dockerfile 默认一致；锁定 Node 大版本，避免上游改默认值时静默跳版</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">NODE_VERSION</span><span class="p">:</span><span class="w"> </span><span class="m">24</span>-<span class="l">alpine3.22</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">pull_policy</span><span class="p">:</span><span class="w"> </span><span class="l">never</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">sharkey-web</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mem_limit</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;2048m&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">memswap_limit</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;2048m&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># 本地 tag 不能被 watchtower「更新」（否则会因无 registry 报错/误动）</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;com.centurylinklabs.watchtower.enable=false&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">links</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">redis</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">redis</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">expose</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;3000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">npminone</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.config/docker.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">NODE_OPTIONS=&#34;--max-old-space-size=8192&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./files:/sharkey/files</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./.config:/sharkey/.config:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># 镜像内未装 curl，故用 node 自带 fetch 做健康检查（node 24 / 22 均可用）</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;node&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-e&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;fetch(&#39;http://127.0.0.1:3000/healthz&#39;).then(r=&gt;process.exit(r.ok?0:1)).catch(()=&gt;process.exit(1))&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">300s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">redis</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">redis:7-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mem_limit</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;256m&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">memswap_limit</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;256m&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">sharkey_redis</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./redis:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;redis-cli ping&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># pgroonga 是 Stelpolva 的硬依赖（fulltextSearch.provider: sqlPgroonga）</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">groonga/pgroonga:4.0.1-alpine-17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mem_limit</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;4096m&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">memswap_limit</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;1536m&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">sharkey_db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;com.centurylinklabs.watchtower.enable=false&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env_file</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.config/docker.env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./db:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">expose</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;5432&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">default</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">driver</span><span class="p">:</span><span class="w"> </span><span class="l">bridge</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">npminone</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">npminone</span></span></span></code></pre></div></div>

  </div>
</details>




  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>build.sh（在服务器上从源码构建，脱离 SSH 会话）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># 在服务器上从源码构建 Sharkey Stelpolva 镜像（非侵入：不触碰运行中的容器）</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 部署到服务器后，脱离 SSH 会话跑：</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   systemd-run --unit=sharkey-build --collect --property=Nice=10 \</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     --property=StandardOutput=file:/var/log/sharkey-build.log \</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     --property=StandardError=append:/var/log/sharkey-build.log /srv/sharkey/bin/sharkey-build.sh</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   # 进度： tail -f /var/log/sharkey-build.log</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 用法： bash sharkey-build.sh [分支]      # 默认 stelpolva</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">SRC</span><span class="o">=</span>/srv/sharkey/src/sharkey-stelpolva
</span></span><span class="line"><span class="cl"><span class="nv">BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="nv">stelpolva</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">REPO</span><span class="o">=</span>https://github.com/Lhcfl/sharkey-stelpolva.git
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> ! -d <span class="s2">&#34;</span><span class="nv">$SRC</span><span class="s2">/.git&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  mkdir -p /srv/sharkey/src
</span></span><span class="line"><span class="cl">  <span class="c1"># 必须 git clone：Dockerfile 构建阶段要跑 git submodule update（两个 emoji 子模块）</span>
</span></span><span class="line"><span class="cl">  git clone --branch <span class="s2">&#34;</span><span class="nv">$BRANCH</span><span class="s2">&#34;</span> --depth <span class="m">1</span> <span class="s2">&#34;</span><span class="nv">$REPO</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$SRC</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$SRC</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">git fetch --depth <span class="m">1</span> origin <span class="s2">&#34;</span><span class="nv">$BRANCH</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">git checkout -q FETCH_HEAD
</span></span><span class="line"><span class="cl">git submodule update --init --recursive   <span class="c1"># 供本地参考；构建容器内会自行再拉一次</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">SHA</span><span class="o">=</span><span class="k">$(</span>git rev-parse --short<span class="o">=</span><span class="m">10</span> HEAD<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">VER</span><span class="o">=</span><span class="k">$(</span>grep -m1 <span class="s1">&#39;&#34;version&#34;&#39;</span> package.json <span class="p">|</span> sed -E <span class="s1">&#39;s/.*: *&#34;([^&#34;]+)&#34;.*/\1/&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">TAG</span><span class="o">=</span><span class="s2">&#34;sharkey-stelpolva:</span><span class="si">${</span><span class="nv">VER</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">SHA</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;=== build </span><span class="nv">$TAG</span><span class="s2">  (commit </span><span class="k">$(</span>git log -1 --format<span class="o">=</span><span class="s1">&#39;%h %cd&#39;</span><span class="k">)</span><span class="s2">)  start </span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> ===&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">DOCKER_BUILDKIT</span><span class="o">=</span><span class="m">1</span> docker build --progress<span class="o">=</span>plain -t <span class="s2">&#34;</span><span class="nv">$TAG</span><span class="s2">&#34;</span> .
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;=== build ok </span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> ===&#34;</span>
</span></span><span class="line"><span class="cl">docker images --format <span class="s1">&#39;{{.Repository}}:{{.Tag}} | {{.ID}} | {{.Size}} | {{.CreatedSince}}&#39;</span> <span class="p">|</span> grep <span class="s1">&#39;^sharkey-stelpolva&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;TAG=</span><span class="nv">$TAG</span><span class="s2">&#34;</span></span></span></code></pre></div></div>

  </div>
</details>




  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>preflight.sh（切换前的只读预检：候选镜像自报迁移 &#43; 连通性）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># 切换前只读预检：让候选镜像自己回答</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   ① 它启动时会执行哪些迁移   ② 能不能用现网配置连上 PG/Redis   ③ 它是什么版本</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 全部只读、不写库、不碰运行中的容器。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 用法： bash preflight.sh sharkey-stelpolva:&lt;版本&gt;-&lt;commit&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -uo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">TAG</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="p">:?用法: bash preflight.sh &lt;镜像tag&gt;</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> /srv/sharkey <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">cat &gt; /tmp/pending.mjs <span class="s">&lt;&lt;&#39;JS&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">import ormconfig from &#39;/sharkey/packages/backend/ormconfig.js&#39;;
</span></span></span><span class="line"><span class="cl"><span class="s">import { MigrationExecutor } from &#39;typeorm&#39;;
</span></span></span><span class="line"><span class="cl"><span class="s">const ds = ormconfig;
</span></span></span><span class="line"><span class="cl"><span class="s">await ds.initialize();
</span></span></span><span class="line"><span class="cl"><span class="s">const exec = new MigrationExecutor(ds);
</span></span></span><span class="line"><span class="cl"><span class="s">const pending = await exec.getPendingMigrations();
</span></span></span><span class="line"><span class="cl"><span class="s">console.log(&#39;PENDING_COUNT=&#39; + pending.length);
</span></span></span><span class="line"><span class="cl"><span class="s">for (const m of pending) console.log(&#39;  - &#39; + (m.name ?? m.constructor?.name));
</span></span></span><span class="line"><span class="cl"><span class="s">await ds.destroy();
</span></span></span><span class="line"><span class="cl"><span class="s">JS</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">cat &gt; /tmp/roquery.mjs <span class="s">&lt;&lt;&#39;JS&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">import ormconfig from &#39;/sharkey/packages/backend/ormconfig.js&#39;;
</span></span></span><span class="line"><span class="cl"><span class="s">const ds = ormconfig;
</span></span></span><span class="line"><span class="cl"><span class="s">await ds.initialize();
</span></span></span><span class="line"><span class="cl"><span class="s">const r = await ds.query(&#39;select count(*)::int as notes, (select count(*)::int from &#34;user&#34;) as users from note&#39;);
</span></span></span><span class="line"><span class="cl"><span class="s">console.log(&#39;DB_OK notes=%d users=%d&#39;, r[0].notes, r[0].users);
</span></span></span><span class="line"><span class="cl"><span class="s">const m = await ds.query(&#39;select count(*)::int as n from migrations&#39;);
</span></span></span><span class="line"><span class="cl"><span class="s">console.log(&#39;MIGRATIONS_APPLIED=%d&#39;, m[0].n);
</span></span></span><span class="line"><span class="cl"><span class="s">await ds.destroy();
</span></span></span><span class="line"><span class="cl"><span class="s">JS</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;=== 1) 待应用迁移（只读） ===&#34;</span>
</span></span><span class="line"><span class="cl">docker run --rm --network sharkey_default <span class="se">\
</span></span></span><span class="line"><span class="cl">  -v /tmp/pending.mjs:/sharkey/packages/backend/pending.mjs:ro <span class="se">\
</span></span></span><span class="line"><span class="cl">  -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/.config:/sharkey/.config:ro&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --env-file .config/docker.env <span class="se">\
</span></span></span><span class="line"><span class="cl">  --entrypoint sh <span class="s2">&#34;</span><span class="nv">$TAG</span><span class="s2">&#34;</span> -c <span class="s1">&#39;cd /sharkey/packages/backend &amp;&amp; node pending.mjs&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;=== 2) 现网配置连通性（无输出且退出码 0 = PG + 全部 Redis 均连上） ===&#34;</span>
</span></span><span class="line"><span class="cl">docker run --rm --network sharkey_default --env-file .config/docker.env <span class="se">\
</span></span></span><span class="line"><span class="cl">  -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/.config:/sharkey/.config:ro&#34;</span> <span class="s2">&#34;</span><span class="nv">$TAG</span><span class="s2">&#34;</span> pnpm check:connect<span class="p">;</span> <span class="nb">echo</span> <span class="s2">&#34;check:connect exit=</span><span class="nv">$?</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;=== 3) 对现网库只读查询 ===&#34;</span>
</span></span><span class="line"><span class="cl">docker run --rm --network sharkey_default --env-file .config/docker.env <span class="se">\
</span></span></span><span class="line"><span class="cl">  -v /tmp/roquery.mjs:/sharkey/packages/backend/roquery.mjs:ro <span class="se">\
</span></span></span><span class="line"><span class="cl">  -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/.config:/sharkey/.config:ro&#34;</span> --entrypoint sh <span class="s2">&#34;</span><span class="nv">$TAG</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -c <span class="s1">&#39;cd /sharkey/packages/backend &amp;&amp; node roquery.mjs&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;=== 4) 镜像内版本 ===&#34;</span>
</span></span><span class="line"><span class="cl">docker run --rm <span class="s2">&#34;</span><span class="nv">$TAG</span><span class="s2">&#34;</span> node -v
</span></span><span class="line"><span class="cl">docker run --rm <span class="s2">&#34;</span><span class="nv">$TAG</span><span class="s2">&#34;</span> sh -c <span class="s1">&#39;grep -m1 &#34;\&#34;version\&#34;&#34; /sharkey/package.json; ls /sharkey/packages/backend/migration/*.js | wc -l&#39;</span></span></span></code></pre></div></div>

  </div>
</details>




  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>switch-runbook.sh（备份 → 切换 → 验收 → 回滚）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># 换镜像的完整动作。在部署目录（/srv/sharkey）下执行。</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -uo pipefail
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> /srv/sharkey <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl"><span class="nv">TS</span><span class="o">=</span><span class="k">$(</span>date +%Y%m%d-%H%M%S<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">NEW_TAG</span><span class="o">=</span>sharkey-stelpolva:2025.5.2-dev-stelpolva.6-2ca6365c1c
</span></span><span class="line"><span class="cl"><span class="nv">OLD_TAG</span><span class="o">=</span>sharkey-stelpolva:rollback-stelpolva-4
</span></span><span class="line"><span class="cl"><span class="nv">F</span><span class="o">=</span><span class="s1">&#39;-f docker-compose.yml -f watchtower-exclude.yml&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ── 1) 备份 ──────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">mkdir -p /srv/sharkey/backups
</span></span><span class="line"><span class="cl">cp -a docker-compose.yml <span class="s2">&#34;/srv/sharkey/backups/docker-compose.yml.</span><span class="si">${</span><span class="nv">TS</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">cp -a .config/default.yml <span class="s2">&#34;/srv/sharkey/backups/default.yml.</span><span class="si">${</span><span class="nv">TS</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 旧镜像钉成命名 tag（第三方 tag 可能在 hub 上被移动/删除）</span>
</span></span><span class="line"><span class="cl">docker tag wuhang2003/sharkey-stelpolva-docker:latest <span class="s2">&#34;</span><span class="nv">$OLD_TAG</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 逻辑备份（库越大越慢；本例 9.6 GB 库 → 392 MB dump / 2 分钟）</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> sharkey_db pg_dump -Fc -U sharkey -d sharkey &gt; <span class="s2">&#34;/srv/sharkey/backups/sharkey-</span><span class="si">${</span><span class="nv">TS</span><span class="si">}</span><span class="s2">.dump&#34;</span>
</span></span><span class="line"><span class="cl">sha256sum <span class="s2">&#34;/srv/sharkey/backups/sharkey-</span><span class="si">${</span><span class="nv">TS</span><span class="si">}</span><span class="s2">.dump&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ── 2) 装新 compose（先临时文件校验，再原子替换） ─────────────────────────</span>
</span></span><span class="line"><span class="cl">install -m <span class="m">644</span> /srv/sharkey/backups/docker-compose.yml.new /srv/sharkey/docker-compose.yml
</span></span><span class="line"><span class="cl">docker compose --project-directory . <span class="nv">$F</span> config &gt;/dev/null <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;compose 语法 OK&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ── 3) 只重建 web ───────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">docker compose <span class="nv">$F</span> up -d --no-deps web
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ── 4) 验收 ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">sleep <span class="m">30</span>
</span></span><span class="line"><span class="cl">docker ps --filter <span class="nv">name</span><span class="o">=</span>sharkey-web --format <span class="s1">&#39;{{.Names}} | {{.Image}} | {{.Status}}&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">IP</span><span class="o">=</span><span class="k">$(</span>docker inspect -f <span class="s1">&#39;{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}&#39;</span> sharkey-web <span class="p">|</span> awk <span class="s1">&#39;{print $1}&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">curl -s -o /dev/null -w <span class="s1">&#39;healthz=%{http_code}\n&#39;</span> --max-time <span class="m">10</span> <span class="s2">&#34;http://</span><span class="nv">$IP</span><span class="s2">:3000/healthz&#34;</span>
</span></span><span class="line"><span class="cl">curl -s --max-time <span class="m">20</span> -X POST -H <span class="s1">&#39;Content-Type: application/json&#39;</span> -d <span class="s1">&#39;{}&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="s2">&#34;http://</span><span class="nv">$IP</span><span class="s2">:3000/api/meta&#34;</span> <span class="p">|</span> python3 -c <span class="s2">&#34;import sys,json;d=json.load(sys.stdin);print(&#39;version&#39;,d[&#39;version&#39;])&#34;</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> sharkey_db psql -U sharkey -d sharkey -tAc <span class="s2">&#34;select count(*) from migrations;&#34;</span>
</span></span><span class="line"><span class="cl">docker logs --tail <span class="m">40</span> sharkey-web 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep -iE <span class="s1">&#39;error|executed successfully&#39;</span> <span class="p">|</span> tail -10
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ── 5) 回滚（任一步失败时执行） ─────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   需要先把 docker-compose.yml 里的 image 改回 $OLD_TAG，然后：</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   docker compose $F up -d --no-deps web</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   三条迁移都是向后兼容的，回滚不需要还原数据库；</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   只有「迁移中途失败/库被写坏」才用 dump：</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   docker compose $F stop web</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   docker exec -i sharkey_db pg_restore -c --if-exists -U sharkey -d sharkey &lt; /srv/sharkey/backups/sharkey-&lt;TS&gt;.dump</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   docker compose $F up -d</span></span></span></code></pre></div></div>

  </div>
</details>

</div>

<style>
  #accordion-e193618a45ecb130da7b2732b2ee4b0f > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-e193618a45ecb130da7b2732b2ee4b0f > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-e193618a45ecb130da7b2732b2ee4b0f details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-e193618a45ecb130da7b2732b2ee4b0f details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-e193618a45ecb130da7b2732b2ee4b0f");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>


]]></content:encoded>
    </item>
  </channel>
</rss>
