产品自主升级——实例原地重建自己
父节点:key-designs
结论(2026-09-04): owner 在产品里按升级(后台 System 面板,或 MCP
instance.upgrade);backend 写一个不认底座的信号——把一个时间戳写进共享卷上的一个文件——然后停手。一个 updater sidecar,唯一持有docker.sock的容器,看到变化,拉取频道 tag,按每个兄弟容器自己 inspect 出来的配置原地重建(Watchtower 模型)。schema 能跟上,是因为 backend 启动时自己跑迁移。backend 里没有任何东西知道自己是怎么部署的。
触发问题。 镜像式的栈里 backend 刻意不挂 docker.sock(deployment)——它拉不动镜像,也重建不了自己的容器。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.upgrade→SignalRedeployer.Trigger(backend/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_check(internal/stats/ops/upgrade.go:107-123)问镜像库最新的 tag(port.NewReleaseChannel;默认https://ghcr.io,cmd/server/config/config.go:156),返回current / latest / comparable / available / can_apply。没盖章的dev构建comparable=false——"比不了"绝不能读成"已是最新"。镜像库不通只丢latest,绝不丢current。- 回执是量出来的,不是假定的。
instance.upgrade只返回requested:true——应答的进程本身就在被替换之列。浏览器之后轮询/api/v1/instance(app/src/lib/admin/use-upgrade.ts),按真实发生的事报,包括"跑了,版本没变"(tag 被钉死)。 - updater sidecar(
infra/updater/,Go,7a3a749e7):每 5 秒轮询信号(main.go:37-40),只在文件内容变化时行动(main.go:64-79——sidecar 重启从不重放旧的一次按下)。它 inspect 自己的容器读com.docker.compose.project(docker.go:44-58)——真实 project 名是它自己学到的,不是被告知的——列出同标签下的兄弟,跳过自己和任何不在STANDMEET_IMAGE_PREFIX下的镜像,把每个镜像的 tag 换成STANDMEET_CHANNEL(bumpTag,main.go:110-120),然后recreate:拉取、停止、删除、按旧容器 inspect 出的配置 / host 配置 / 网络只改镜像地创建、启动(docker.go:91-129)。卷、env(密钥!)、名字、标签、重启策略原样带过去。compose 把它接上:infra/deploy/docker-compose.yml:333-345(信号路径、频道、docker.sock)。 - schema 跟着二进制走。
pgstore.Migrate在backend/cmd/server/main.go:91跑,先于服务。migration 是嵌入的(go:embed,backend/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.source(backend/Dockerfile:96、app/Dockerfile:14、infra/db/Dockerfile:30;ba219c391,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.appVersion,backend/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.ts,4442e8fe9,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 的upgradeManual(app/src/i18n/messages/*/admin-shell.json:284,can_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-version;monitor 的 System 面板显示它)。 - 每次 schema 改动 = 一条 migration + 一条对着旧卷的
upgrade-*spec:upgrade-pending-email-columns.spec.ts(旧卷 + 部署会应用 schema;同版本再部署一次是账本上的空操作;没有账本 + 缺一条 migration → 应用,不是假定;重放绝不碰 owner 能设的 hue)、upgrade-embed-schema.spec.ts、upgrade-application-code-unique.spec.ts、upgrade-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.md、docs/design/mcp-self-update.md——是种子不是证据;证据是上面那些 file:line。
写于 2026-09-07,对照 standmeet-new main 36789537d(v0.1.31)。