↓ 跳过正文

自托管 Sharkey 换自建 fork 镜像:让候选镜像自己报迁移,然后 39 秒切完

·6318 字·13 分钟
目录

一个自托管的 Sharkey 实例,一直跑着第三方 CI 构建的镜像。某天发现那条镜像线其实已经停了 8 个月——不是构建失败,而是 GitHub 会在仓库 60 天无活动后自动停用定时 workflow。于是有了这篇:为 Lhcfl/sharkey-stelpolva(分支 stelpolva)写一份能用的 compose、在切换前让候选镜像自己报告它会执行哪些数据库迁移,以及实测的切换成本——停机 39 秒、迁移 3 条秒级 DDL、回滚不需要还原数据库。环境:一台 4 核 24 GB 的 Linux VPS、Docker 29 + Compose v5、反代 Nginx Proxy Manager、PostgreSQL 17 + pgroonga,2026-10-15 实操。

一、第三方镜像线为什么会停
#

实例之前用的镜像是 wuhang2003/sharkey-stelpolva-docker:latest。docker inspect 读它的 OCI 标签,能看到真实来源:org.opencontainers.image.source=https://github.com/miaoa-dev/sharkey-stelpolva-docker——一个专职 CI 仓库,按计划每天拉 Lhcfl/sharkey-stelpolva 的最新 commit 重新构建并推 Docker Hub。

查下去才发现这条链已经断了两段:

  • hash 检查先断:CI 仓库里最后一次「Update commit SHA to …」提交是 2026-02-05;此后 GitHub Actions 仍按计划跑到 2026-04-06,但每次都重建同一个 commit(工作流逻辑是「fork 的 HEAD 与记录的 sha 不一致才更新」),Docker Hub 上最新 tag 也就是 20260205。
  • 定时任务被平台关掉:仓库最后一次提交后 60 天无活动,GitHub 自动禁用了该仓库的 scheduled workflow。这不是作者的操作,是平台的默认策略——依赖别人 CI 的镜像,就同时依赖了别人的仓库活跃度。

而 fork 本身一直在往前走:2025.5.2-dev-stelpolva.4(2026-02 构建)→ 现在 HEAD 是 2025.5.2-dev-stelpolva.6(2ca6365c1c),中间还有一条 use node 24 把运行时从 Node 22 抬到 Node 24。

结论很直接:这类第三方镜像线不可作为升级路径。自建镜像换来三件事——版本可钉(镜像 tag 里带 fork 版本与 commit)、可回滚(本地 tag 钉住旧镜像)、AGPL 合规边界清楚(源码在公开仓库里)。

二、给这个 fork 写 compose 的 5 个要点
#

仓库里带着官方 Dockerfile(多阶段构建)和 compose_example.yml,所以不需要从零发明,照官方文件改成自己的部署形态即可:

  1. build.context 指向源码目录 + 本地 tag + pull_policy: never。本地构建的 tag 没有对应 registry,必须显式禁止 Compose 去远端拉取,否则任何一次 up 都可能报镜像不存在。
  2. 必须 git clone,不能下 zip 源码包。Dockerfile 的构建阶段要执行 git submodule update --init --recursive(两个 emoji 子模块),而 .dockerignore 并没有排除 .git ——源码包里没有 .git,这一步会直接失败。实测该步 5.4 秒通过。
  3. 数据库侧的硬依赖是 pgroonga。这个 fork 相对上游 Sharkey 的部署差异只有一条:全文搜索用 pgroonga(fulltextSearch.provider: sqlPgroonga),官方指定镜像 groonga/pgroonga:4.0.1-alpine-17。如果是从上游 Sharkey 迁过来的库,还要记得 CREATE EXTENSION pgroonga。
  4. 反向代理、卷、网络一个字都不用改。容器名与网络别名、expose 3000、被反代加入的共享网络、files 与 .config 的挂载路径逐字保留,反代侧指向 <容器名>:3000 的配置就完全无感——这也是「准无缝」能成立的前提之一。
  5. 保持 com.centurylinklabs.watchtower.enable=false。本地 tag 无 registry,支持自动更新的容器会被 watchtower 尝试去 registry 拉这个不存在的 tag。原来是靠一份额外的 overlay compose 提供这个标签,这次直接内联进主 compose,免得哪天漏了 -f 导致标签丢失。

顺带一个冷知识:compose 里 NODE_OPTIONS="--max-old-space-size=8192" 这种带引号的写法,引号会被原样传进容器环境变量,但实测 Node 仍然认(容器内 v8.getHeapStatistics().heap_size_limit = 8216 MB)。不必为了去掉引号去改一份本来跑得好好的配置。

三、怎么证明「切换是无缝的」:让候选镜像自己报待执行迁移
#

升级的风险不在镜像,在数据库。传统做法是「切换 → 看日志 → 发现迁移失败 → 回滚」,那是拿生产库试错。更稳的做法是在切换之前,用候选镜像自己在只读模式下回答一个问题:它启动时会执行哪些迁移?

Misskey/Sharkey 用 TypeORM,迁移器有现成的公开方法:

import ormconfig from '/sharkey/packages/backend/ormconfig.js';
import { MigrationExecutor } from 'typeorm';

const ds = ormconfig;
await ds.initialize();
const pending = await new MigrationExecutor(ds).getPendingMigrations();
console.log('PENDING_COUNT=' + pending.length);
for (const m of pending) console.log('  - ' + m.name);
await ds.destroy();

在候选镜像的一次性容器里跑它(挂上现网的 .config、接上 compose 网络),只读 migrations 表 + 列迁移文件,不写库、不碰运行中的容器。

这里有个坑值得单独说:不要用「迁移文件数 − 已应用条数」来推断待应用集合,也不要按文件名/时间戳比对。TypeORM 是按迁移类的 name 判断是否已应用的,而类名与文件名、时间戳都可能不一致——这个 fork 里就有一条 StpvReUserDescIdx17505411760367,类名末尾比文件名多一位数字,用时间戳比对会把它误判成「待应用」,真跑起来就是重复执行 DDL。让 typeorm 自己算,才是权威答案。

实测结果:

对象 PENDING_COUNT
候选镜像(新构建) 3
现网旧镜像(对照组) 0

对照组的意义是双重的:既证明脚本只读(旧镜像在跑的库本就已完全迁移),也证明现网库没有历史欠账。三条待应用迁移的真实 SQL 是:

  • user.avatarUrl/bannerUrl/backgroundUrl 由 varchar(512) 加宽到 varchar(1024)——PostgreSQL 只改元数据,不重写表;
  • user 新增一列可空的 deletedAt TIMESTAMPTZ;
  • page.eyeCatchingImageId 外键的 ON DELETE CASCADE 改成 SET NULL(小表,drop + add 约束)。

全是秒级 DDL,没有大表重写、没有长锁。这就把「停机多久」这个问题从猜测变成了算术:停机 ≈ 容器重建 + 迁移 + 应用启动。

除了待应用迁移,再加两项零成本的冒烟:pnpm check:connect(无输出且退出码 0 表示真的连上了 PG 与全部 Redis 实例,失败会抛异常),以及对生产库跑一次只读计数查询(证明新镜像能读老库)。

四、切换过程与实测数字
#

流程(完整命令见文末附的 runbook):

  1. 备份:compose 与配置各拷一份带时间戳的副本;把旧镜像钉成命名 tag(…:rollback-<旧版本>),因为第三方 tag 可能被移动或删除,届时「回滚」就无镜像可用;pg_dump -Fc 一份逻辑备份(库 9.6 GB,dump 392 MB / 2 分钟)。
  2. 装新 compose:先写到临时文件、docker compose config 在项目上下文里校验通过,再原子替换(避免会话中断留下半截文件)。
  3. 只重建 web:docker compose … up -d --no-deps web,db 与 redis 容器完全不碰。
  4. 验收:容器健康状态、/healthz、/api/meta 的版本号、migrations 表条数、容器日志里有无 ERROR、全文索引是否仍走 pgroonga。

实测数字:

  • compose up 本身 12 秒;
  • 启动日志里 TypeORM 先报 417 migrations are already loaded in the database / 420 migrations were found in the source code,随后三条迁移逐个 has been executed successfully;migrations 表 417 → 420;
  • 版本从 2025.5.2-dev-stelpolva-4+g5af1a90(Node 22.15)变成 2025.5.2-dev-stelpolva.6+g2ca6365(Node 24.16);
  • 停机窗口 39 秒。测法是外面挂一个 2 秒间隔的探针(每轮重新 docker inspect 取容器 IP,因为重建会换 IP),记录到:16×200 → 16×000 → 3×503 → 200。其中约 32 秒是容器空缺期,约 7 秒是容器已启动但应用还没就绪(/healthz 在启动期返回 503)。想更短可以先用新镜像跑一次 pnpm migrate(旧版本继续服务),把停机压到只剩一次重启。
  • 搜索链路正常:启动日志里 [SearchService] -- Provider: sqlPgroonga,EXPLAIN 显示 Index Scan using IDX_stpv_pgroonga_note_text。

日志里唯一像「错误」的两行其实是联邦入站噪音(某个 relay 的 HTTP 签名验证失败、一条远程 Delete 活动超时),与本次切换无关——升级后必须扫一遍日志,但要分清「新引入的问题」和「本来就有的噪音」。

五、回滚为什么不需要还原数据库
#

三条迁移都是向后兼容的:加宽的类型、新增的可空列、被放宽的外键,旧版本代码照常工作,旧版 TypeORM 也会忽略 migrations 表里多出来的那 3 行。所以回滚就是两件事:把 compose 里的 image 改回钉住的旧 tag,再 up -d --no-deps web。

dump 和命名 tag 依然要做,因为真正需要还原的场景是「迁移中途失败」或「库被写坏」,那时只有 dump 能救——但日常回滚路径不应该依赖它,恢复 9.6 GB 库的代价和切一次镜像完全不是一个量级。

顺手清账:这次构建与切换占用过磁盘(构建缓存峰值 5.2 GB),切完 docker builder prune -f 回收掉(5.2 GB → 0.98 GB,根盘可用 49 GB → 54 GB);第三方镜像的那个 tag 删掉,回滚用命名 tag 保留。

六、两个真实踩到的坑(与本次切换无关,但比切换本身更值得记)
#

坑一:DRY_RUN=1 some.sh 没生效,测试跑成了生产跑。 那是个 systemd 定时任务包着的清理脚本,我在命令行前加了 DRY_RUN=1 想「只统计不删除」,脚本却照常删了。原因在脚本开头这两行:

[ -r "$ENV_FILE" ] && . "$ENV_FILE"     # 配置文件里写着 DRY_RUN=0

命令行传入的环境变量会被随后 source 的配置文件覆盖。要想让「命令行覆盖文件」成立,得让环境变量优先(例如 : "${DRY_RUN:=0}" 之后再 source,或者 source 完只补文件里没有的键),否则开关形同虚设。这次代价是两千多条本该在几小时后由定时任务清理的旧远程帖提前被删——恰好落在计划任务的设计范围内,没有越界,但这种「以为在试跑、其实在跑」的错觉值得警惕:验证演练开关本身是否生效,要用一个可观测的副作用去验,而不是相信命令行的写法。

坑二:一次性容器写不进状态文件,功能静默降级。 同一个清理器会在宿主上一个目录里存跨轮续跑的状态文件。容器内进程以镜像里的非 root 用户(uid 991)运行,而宿主目录属主是 root:root 755 → 每轮都写失败,日志里只有一行不起眼的 state-write-failed: EACCES … /state/state.json,主流程照常输出「完成」。表现就是状态文件永远不存在、每轮都从头扫描(本轮靠数据量小恰好一次扫完,所以没人发现)。修法一行:chown -R 991:991 <状态目录>,然后用容器里的 touch 验证可写再跑正式任务。

两条坑的共性是:自动化里最贵的不是失败,是「看起来成功」。判据要落到文件、退出码、容器日志里的具体字段,而不是主流程最后那行「完成」。

七、可复用的判断清单
#

给「自托管服务的换镜像升级」收个尾,按重要性排序:

  1. 先让候选镜像自报待执行迁移(TypeORM:MigrationExecutor.getPendingMigrations()),并对旧镜像跑一次做对照组;对待应用的那几条,逐条看 SQL 是「元数据级」还是「重写表」。
  2. 确认库结构向后兼容,据此决定回滚是否需要 dump(本例不需要,但 dump 照打)。
  3. 确认容器之外的东西都不动:反向代理目标、卷路径、网络、容器名;这些一动,无缝就无从谈起。
  4. 把旧镜像钉成命名 tag,别指望第三方 tag 还在。
  5. 用探针量停机,别用「感觉很快」;容器空缺期与应用启动期是两段,值得分开看。
  6. 长构建脱离 SSH 会话跑(systemd transient unit),否则一次网络抖动就能把 8 分钟的构建带走。

这次全流程(从读镜像标签、写 compose、构建、只读预检到切换验收)总计约 45 分钟,其中构建 8 分 12 秒——比预想的快得多,4 核机器足够。

附:本文点名的脚本与配置(原样提供)
#

路径已做中性化(部署目录统一写作 /srv/sharkey),域名用 example.tld 占位。

docker-compose.yml(web 服务换成自建镜像)
services:
  web:
    # 本地构建产物(无 registry;pull_policy: never 保证 compose 不会去远端拉)
    image: sharkey-stelpolva:2025.5.2-dev-stelpolva.6-2ca6365c1c
    build:
      context: /srv/sharkey/src/sharkey-stelpolva
      dockerfile: Dockerfile
      args:
        # 与 Dockerfile 默认一致;锁定 Node 大版本,避免上游改默认值时静默跳版
        NODE_VERSION: 24-alpine3.22
    pull_policy: never

    container_name: sharkey-web
    restart: always
    mem_limit: '2048m'
    memswap_limit: '2048m'
    labels:
      # 本地 tag 不能被 watchtower「更新」(否则会因无 registry 报错/误动)
      - "com.centurylinklabs.watchtower.enable=false"

    links:
      - db
      - redis
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

    expose:
      - "3000"

    networks:
      - default
      - npminone

    env_file:
      - .config/docker.env
    environment:
      - NODE_OPTIONS="--max-old-space-size=8192"

    volumes:
      - ./files:/sharkey/files
      - ./.config:/sharkey/.config:ro

    # 镜像内未装 curl,故用 node 自带 fetch 做健康检查(node 24 / 22 均可用)
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 300s

  redis:
    restart: always
    image: redis:7-alpine
    mem_limit: '256m'
    memswap_limit: '256m'
    container_name: sharkey_redis
    networks:
      - default
    volumes:
      - ./redis:/data
    healthcheck:
      test: "redis-cli ping"
      interval: 5s
      retries: 20

  db:
    restart: always
    # pgroonga 是 Stelpolva 的硬依赖(fulltextSearch.provider: sqlPgroonga)
    image: groonga/pgroonga:4.0.1-alpine-17
    mem_limit: '4096m'
    memswap_limit: '1536m'
    container_name: sharkey_db
    labels:
      - "com.centurylinklabs.watchtower.enable=false"
    networks:
      - default
    env_file:
      - .config/docker.env
    volumes:
      - ./db:/var/lib/postgresql/data
    expose:
      - "5432"
    healthcheck:
      test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"
      interval: 5s
      retries: 20

networks:
  default:
    driver: bridge
  npminone:
    external: true
    name: npminone
build.sh(在服务器上从源码构建,脱离 SSH 会话)
#!/bin/bash
# 在服务器上从源码构建 Sharkey Stelpolva 镜像(非侵入:不触碰运行中的容器)
#
# 部署到服务器后,脱离 SSH 会话跑:
#   systemd-run --unit=sharkey-build --collect --property=Nice=10 \
#     --property=StandardOutput=file:/var/log/sharkey-build.log \
#     --property=StandardError=append:/var/log/sharkey-build.log /srv/sharkey/bin/sharkey-build.sh
#   # 进度: tail -f /var/log/sharkey-build.log
# 用法: bash sharkey-build.sh [分支]      # 默认 stelpolva

set -euo pipefail

SRC=/srv/sharkey/src/sharkey-stelpolva
BRANCH="${1:-stelpolva}"
REPO=https://github.com/Lhcfl/sharkey-stelpolva.git

if [ ! -d "$SRC/.git" ]; then
  mkdir -p /srv/sharkey/src
  # 必须 git clone:Dockerfile 构建阶段要跑 git submodule update(两个 emoji 子模块)
  git clone --branch "$BRANCH" --depth 1 "$REPO" "$SRC"
fi

cd "$SRC"
git fetch --depth 1 origin "$BRANCH"
git checkout -q FETCH_HEAD
git submodule update --init --recursive   # 供本地参考;构建容器内会自行再拉一次

SHA=$(git rev-parse --short=10 HEAD)
VER=$(grep -m1 '"version"' package.json | sed -E 's/.*: *"([^"]+)".*/\1/')
TAG="sharkey-stelpolva:${VER}-${SHA}"
echo "=== build $TAG  (commit $(git log -1 --format='%h %cd'))  start $(date -Is) ==="

DOCKER_BUILDKIT=1 docker build --progress=plain -t "$TAG" .

echo "=== build ok $(date -Is) ==="
docker images --format '{{.Repository}}:{{.Tag}} | {{.ID}} | {{.Size}} | {{.CreatedSince}}' | grep '^sharkey-stelpolva'
echo "TAG=$TAG"
preflight.sh(切换前的只读预检:候选镜像自报迁移 + 连通性)
#!/bin/bash
# 切换前只读预检:让候选镜像自己回答
#   ① 它启动时会执行哪些迁移   ② 能不能用现网配置连上 PG/Redis   ③ 它是什么版本
# 全部只读、不写库、不碰运行中的容器。
#
# 用法: bash preflight.sh sharkey-stelpolva:<版本>-<commit>
set -uo pipefail
TAG="${1:?用法: bash preflight.sh <镜像tag>}"
cd /srv/sharkey || exit 1

cat > /tmp/pending.mjs <<'JS'
import ormconfig from '/sharkey/packages/backend/ormconfig.js';
import { MigrationExecutor } from 'typeorm';
const ds = ormconfig;
await ds.initialize();
const exec = new MigrationExecutor(ds);
const pending = await exec.getPendingMigrations();
console.log('PENDING_COUNT=' + pending.length);
for (const m of pending) console.log('  - ' + (m.name ?? m.constructor?.name));
await ds.destroy();
JS

cat > /tmp/roquery.mjs <<'JS'
import ormconfig from '/sharkey/packages/backend/ormconfig.js';
const ds = ormconfig;
await ds.initialize();
const r = await ds.query('select count(*)::int as notes, (select count(*)::int from "user") as users from note');
console.log('DB_OK notes=%d users=%d', r[0].notes, r[0].users);
const m = await ds.query('select count(*)::int as n from migrations');
console.log('MIGRATIONS_APPLIED=%d', m[0].n);
await ds.destroy();
JS

echo "=== 1) 待应用迁移(只读) ==="
docker run --rm --network sharkey_default \
  -v /tmp/pending.mjs:/sharkey/packages/backend/pending.mjs:ro \
  -v "$PWD/.config:/sharkey/.config:ro" \
  --env-file .config/docker.env \
  --entrypoint sh "$TAG" -c 'cd /sharkey/packages/backend && node pending.mjs'

echo "=== 2) 现网配置连通性(无输出且退出码 0 = PG + 全部 Redis 均连上) ==="
docker run --rm --network sharkey_default --env-file .config/docker.env \
  -v "$PWD/.config:/sharkey/.config:ro" "$TAG" pnpm check:connect; echo "check:connect exit=$?"

echo "=== 3) 对现网库只读查询 ==="
docker run --rm --network sharkey_default --env-file .config/docker.env \
  -v /tmp/roquery.mjs:/sharkey/packages/backend/roquery.mjs:ro \
  -v "$PWD/.config:/sharkey/.config:ro" --entrypoint sh "$TAG" \
  -c 'cd /sharkey/packages/backend && node roquery.mjs'

echo "=== 4) 镜像内版本 ==="
docker run --rm "$TAG" node -v
docker run --rm "$TAG" sh -c 'grep -m1 "\"version\"" /sharkey/package.json; ls /sharkey/packages/backend/migration/*.js | wc -l'
switch-runbook.sh(备份 → 切换 → 验收 → 回滚)
#!/bin/bash
# 换镜像的完整动作。在部署目录(/srv/sharkey)下执行。
set -uo pipefail
cd /srv/sharkey || exit 1
TS=$(date +%Y%m%d-%H%M%S)
NEW_TAG=sharkey-stelpolva:2025.5.2-dev-stelpolva.6-2ca6365c1c
OLD_TAG=sharkey-stelpolva:rollback-stelpolva-4
F='-f docker-compose.yml -f watchtower-exclude.yml'

# ── 1) 备份 ──────────────────────────────────────────────────────────────
mkdir -p /srv/sharkey/backups
cp -a docker-compose.yml "/srv/sharkey/backups/docker-compose.yml.${TS}"
cp -a .config/default.yml "/srv/sharkey/backups/default.yml.${TS}"
# 旧镜像钉成命名 tag(第三方 tag 可能在 hub 上被移动/删除)
docker tag wuhang2003/sharkey-stelpolva-docker:latest "$OLD_TAG"
# 逻辑备份(库越大越慢;本例 9.6 GB 库 → 392 MB dump / 2 分钟)
docker exec sharkey_db pg_dump -Fc -U sharkey -d sharkey > "/srv/sharkey/backups/sharkey-${TS}.dump"
sha256sum "/srv/sharkey/backups/sharkey-${TS}.dump"

# ── 2) 装新 compose(先临时文件校验,再原子替换) ─────────────────────────
install -m 644 /srv/sharkey/backups/docker-compose.yml.new /srv/sharkey/docker-compose.yml
docker compose --project-directory . $F config >/dev/null && echo "compose 语法 OK"

# ── 3) 只重建 web ───────────────────────────────────────────────────────
docker compose $F up -d --no-deps web

# ── 4) 验收 ─────────────────────────────────────────────────────────────
sleep 30
docker ps --filter name=sharkey-web --format '{{.Names}} | {{.Image}} | {{.Status}}'
IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' sharkey-web | awk '{print $1}')
curl -s -o /dev/null -w 'healthz=%{http_code}\n' --max-time 10 "http://$IP:3000/healthz"
curl -s --max-time 20 -X POST -H 'Content-Type: application/json' -d '{}' \
  "http://$IP:3000/api/meta" | python3 -c "import sys,json;d=json.load(sys.stdin);print('version',d['version'])"
docker exec sharkey_db psql -U sharkey -d sharkey -tAc "select count(*) from migrations;"
docker logs --tail 40 sharkey-web 2>&1 | grep -iE 'error|executed successfully' | tail -10

# ── 5) 回滚(任一步失败时执行) ─────────────────────────────────────────
#   需要先把 docker-compose.yml 里的 image 改回 $OLD_TAG,然后:
#   docker compose $F up -d --no-deps web
#   三条迁移都是向后兼容的,回滚不需要还原数据库;
#   只有「迁移中途失败/库被写坏」才用 dump:
#   docker compose $F stop web
#   docker exec -i sharkey_db pg_restore -c --if-exists -U sharkey -d sharkey < /srv/sharkey/backups/sharkey-<TS>.dump
#   docker compose $F up -d