2026-09-23·by Sijie Wang#software#project#standmeet

product-owned-upgrade

产品自主升级——实例原地重建自己

父节点:key-designs

结论(2026-09-04): owner 在产品里按升级(后台 System 面板,或 MCP instance.upgrade);backend 写一个不认底座的信号——把一个时间戳写进共享卷上的一个文件——然后停手。一个 updater sidecar,唯一持有 docker.sock 的容器,看到变化,拉取频道 tag,按每个兄弟容器自己 inspect 出来的配置原地重建(Watchtower 模型)。schema 能跟上,是因为 backend 启动时自己跑迁移。backend 里没有任何东西知道自己是怎么部署的。

触发问题。 镜像式的栈里 backend 刻意不挂 docker.sockdeployment)——它拉不动镜像,也重建不了自己的容器。2026-09-04 之前,按钮向 owner 自己给的一个不透明 URL 发请求(STANDMEET_REDEPLOY_HOOK),把部署知识推进了产品、也推给了 owner 的纪律。第一版 sidecar(3d1ce1aaf,同日)拿一份取回来的规范 compose 跑 docker compose -p standmeet up——写死了 project 名(部署名不同的实例会得到一个平行的空栈而不是升级),还要从一份它拿不稳的 .env 里以 ${...} 取密钥(infra/updater/main.go:7-11 记着这两个失败)。另一头,2026-08-31 之前没有任何东西跑 backend/db/migrations/*.sql:owner 拉了新镜像,得到的是想要一个卷里没有的列的代码——一个起不来的 backend。schema 住在卷里、不在镜像里;无视这一点的升级不是升级。

设计

  • 一个信号,不分叉。 instance.upgradeSignalRedeployer.Triggerbackend/cmd/server/port/upgrade_signal.go:56-69):把 unix 时间戳写进 <path>.tmp,再 rename 到 STANDMEET_UPGRADE_SIGNAL 上(原子——sidecar 永远读不到写了一半的文件)。载荷不写版本号:compose 的频道 tag 决定拉什么,版本策略只在一处。Configured() 只是"路径设了没有";组合根只构造一个 redeployer(boot_upgrade.go:29-34),从不在适配器之间选——Coolify 形态的宿主会跑另一个读同一份文件的 sidecar。
  • instance.upgrade_checkinternal/stats/ops/upgrade.go:107-123)问镜像库最新的 tag(port.NewReleaseChannel;默认 https://ghcr.iocmd/server/config/config.go:156),返回 current / latest / comparable / available / can_apply。没盖章的 dev 构建 comparable=false——"比不了"绝不能读成"已是最新"。镜像库不通只丢 latest,绝不丢 current
  • 回执是量出来的,不是假定的。 instance.upgrade 只返回 requested:true——应答的进程本身就在被替换之列。浏览器之后轮询 /api/v1/instanceapp/src/lib/admin/use-upgrade.ts),按真实发生的事报,包括"跑了,版本没变"(tag 被钉死)。
  • updater sidecarinfra/updater/,Go,7a3a749e7):每 5 秒轮询信号(main.go:37-40),只在文件内容变化时行动(main.go:64-79——sidecar 重启从不重放旧的一次按下)。它 inspect 自己的容器com.docker.compose.projectdocker.go:44-58)——真实 project 名是它自己学到的,不是被告知的——列出同标签下的兄弟,跳过自己和任何不在 STANDMEET_IMAGE_PREFIX 下的镜像,把每个镜像的 tag 换成 STANDMEET_CHANNELbumpTagmain.go:110-120),然后 recreate:拉取、停止、删除、按旧容器 inspect 出的配置 / host 配置 / 网络只改镜像地创建、启动(docker.go:91-129)。卷、env(密钥!)、名字、标签、重启策略原样带过去。compose 把它接上:infra/deploy/docker-compose.yml:333-345(信号路径、频道、docker.sock)。
  • schema 跟着二进制走。 pgstore.Migratebackend/cmd/server/main.go:91 跑,先于服务。migration 是嵌入的(go:embedbackend/db/embed.go),代码和 schema 是同一个产物internal/infra/pgstore/migrate.go:1-42);schema_migrations 账本记录跑过什么;每条 migration 可重入(IF NOT EXISTS / 带守卫的块),所以没有 baseline 分支——第一版有,在 dev 上把一条真缺的 migration 标成了已应用。失败 = 不服务。由此得出规则:每次 schema 改动都作为 migration 发布,升级路径对着旧卷测,不只测全新安装那条。

发布那一侧

  • package 链接回仓库,靠 OCI (Open Container Initiative) 的 source 标签 org.opencontainers.image.sourcebackend/Dockerfile:96app/Dockerfile:14infra/db/Dockerfile:30ba219c391,2026-09-01)。坑:首次推送的 ghcr package 默认 private——匿名宿主拉不到,整份部署失败、没有容器、没有日志、旧版本继续跑;IMAGES 里每个新名字都要在 ghcr UI 里翻成 public(没有 REST 端点)。
  • 发布闸门Makefile):release-push 依赖 secrets + secrets-image:1444——对每个构建好的镜像跑 gitleaks;gitleaks 缺失时拒绝运行,而不是为没做的活报成功;Next.js 框架构建 key 列入白名单,d846337bc)。推送之后:release-assert-version:1369——跑已推送 backend 镜像的 --version 和 tag 比对;盖章来自 -ldflags -X …port.appVersionbackend/Dockerfile:73)、release-assert-stripped:1400——证明发布构建的 SDK widget 里没有残留 data-testid,Puck chunk 豁免;bec5394fb,2026-09-07)、release-assert-multiarch:1579——docker manifest inspect 必须列出 amd64)。这些是 mechanical-guardrails:每一道问的是产物,不是作者。
  • owner 侧的另一半——MCP 客户端版本偏差提示sdk/packages/core/src/version-skew.ts4442e8fe9,2026-09-06)。classifySkew(client, server, minCompatibleClient)ok / warn(不同但在下限之上:"跑 update_self")/ incompatible(低于服务端公布的下限——传输/签名契约可能已经动了)。解析不了的版本 → ok,缺数据时绝不唠叨。纯函数、无 I/O;接在 sdk/packages/mcp-client/src/bridge.ts。于是实例升级不可能悄悄把 owner 的客户端晾在原地——这是同一步棋在 service-handle 那一侧。

诚实的天花板

  • updater 从不升级自己main.go:91-93)——新的 updater 镜像要等下一次整栈重启。
  • 没有 sidecar → 没有按钮。 源码构建的栈(docker-compose.prod.yml)不带 updater;Configured() 为 false,面板说升级在实例之外完成、并告诉该跑什么。过时字符串: 后台 i18n 的 upgradeManualapp/src/i18n/messages/*/admin-shell.json:284can_apply 为 false 时显示——use-upgrade.ts:192)仍让 owner "设 STANDMEET_REDEPLOY_HOOK"——backend 已经不读这个旋钮(3c9fb6113 删掉了 RedeployHookURL)。那句话其余部分是对的;这一小句是死建议,截至 2026-09-07 未修。
  • 一个频道,只往前。 bumpTag 永远换到频道 tag;产品内没有回滚——回滚是 owner 在宿主上重新钉 STANDMEET_IMAGE_TAG。钉死 tag 的 compose 会让按钮成为空操作,而回执会如实报出来。
  • 逐个重建、短暂中断、没有健康闸。 兄弟容器按容器列表返回的顺序一个个重建;没有谁等数据库先于 backend,步骤之间不查健康。
  • migration 只往前——没有回退脚本。migration 失败就是一个不服务的 backend,设计如此;恢复靠人工。

长期成立的点

  • 产品发出的是一个脉冲,从不是部署命令;底座知识住在适配器里。owner 自己的宿主是唯一有特权的一方——chain-sovereignty
  • 一个不属于这个 build 的版本号比没有更坏(release-assert-versionmonitor 的 System 面板显示它)。
  • 每次 schema 改动 = 一条 migration + 一条对着旧卷的 upgrade-* spec: upgrade-pending-email-columns.spec.ts(旧卷 + 部署会应用 schema;同版本再部署一次是账本上的空操作;没有账本 + 缺一条 migration → 应用,不是假定;重放绝不碰 owner 能设的 hue)、upgrade-embed-schema.spec.tsupgrade-application-code-unique.spec.tsupgrade-code-entropy-compat.spec.ts(已发出的短 code 仍能开会话)。面板:admin-system-upgrade.spec.ts(按钮真的问了镜像库;实例按不动时按钮不许写成"升级")。sidecar 自己那一趟:infra/updater/updater-e2e.sh;全新卷:infra/db/fresh-install-e2e.sh

已实现 2026-08-28 → 2026-09-07。 f7c0b379d(2026-08-28,按钮,并且说清自己能做什么);bd45353f4(2026-08-31,启动时迁移);ba219c391(2026-09-01,OCI 标签);3c9fb6113 + 3d1ce1aaf + 7a3a749e7(2026-09-04:一个信号;sidecar;原地重建);d846337bc(2026-09-04,secrets-image 白名单);4442e8fe9(2026-09-06,偏差提示);bec5394fb(2026-09-07,strip 闸门)。设计种子:docs/design/product-owned-upgrade.mddocs/design/mcp-self-update.md——是种子不是证据;证据是上面那些 file:line。

写于 2026-09-07,对照 standmeet-new main 36789537d(v0.1.31)。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

product-owned-upgrade