部署——自托管、单租户
上级: architecture
StandMeet 以自托管、单租户、单机的形态部署。这是产品本身要求的形态,不是留待日后修补的局限——代码里没有水平扩展能力,恰恰是架构正确的证据,而不是技术债。
立场
- 每人一个实例。 StandMeet 是你的真相层/你的代言人(introduction);自然的单位是"你,运行属于自己的一份",与个人网站是同一种形态。这里不存在需要扩展的多租户 SaaS。
- 自托管补全了设计里早已存在的主权。 语料库是你的,MCP-push 是信任边界,不会泄露任何你没放进去的东西——自托管把你的身份和声音放在你自己的基础设施上,而不是某个供应商的。这与"个人 + AI 子代理作为所有权单位"的赌注(design-principles)以及"这是一个基座,不是一个 SaaS"的定位(boundaries)相符。
- 读多写少,单一写者。 写操作很少——只有所有者本人在整理内容 / 做 MCP-push。读操作(访客查询)量可能更高,但受限于一个人的受众规模,而且可缓存;真正昂贵的是 LLM 推理,不是应用层。→ 单机上一个 Postgres 主库加 Redis 缓存就绰绰有余——不需要写扩展,不需要分片,不需要只读副本。
- 真正的不变量不是"可扩展",而是"始终能轻松自托管"。 让它在单机上一条
docker compose up就能跑起来;永远不要引入任何假设有集群存在的依赖。
这台盒子(生产环境 compose 拓扑)
注意 compose 里没有 caddy/proxy 服务——这与"一条命令部署"这个特性被砍掉的现状一致:TLS/域名是所有者自己的问题,在这个盒子之外(infra/deploy/docker-compose.yml:11)。
两份 compose,以及真正在跑的那一份
docker-compose.prod.yml 是源码构建的模板(git 检出、make 自己构建镜像)。线上跑的是 infra/deploy/docker-compose.yml —— 镜像式的那份(ghcr 的 standmeet-{app,backend,builder,im-bridge,updater,db} 镜像,STANDMEET_IMAGE_TAG 频道 tag),直接贴进 Coolify,域名和口令来自它的 SERVICE_* magic 变量,TLS 由它的 Traefik 终结。它在 2026-09-04 取代了 infra/coolify/docker-compose.coolify.yml(a5e1cada9)。两份要靠手维持同步,而它们之间的漂移不是理论问题:app 上有一个 networks.default.aliases 块,一份里有、另一份里没有,于是 app 挂在两张网上却没有 traefik.docker.network 标签 —— Traefik 挑了一张它自己不在的网,SYN 发出去没人接,站点返 504(如果是连接被拒,应当是立刻 502;超时才是那条线索)。
同一类漂移造出了检索那个洞:dev 有一个 prod 没有的服务(corpus-retrieval)。check-search-index-shipped 和 check-knobs-reachable(infra/scripts/)现在把两份部署文件一起闸住。
实例没有宿主的控制权 —— 而且它如实这么说
在镜像式的栈里,backend 刻意不挂 docker.sock(infra/deploy/docker-compose.yml:197、:244;源码构建的 docker-compose.prod.yml:208 仍把它挂在 backend 上,给可选的 docker 驱动托管插件用)。MCP 能力沙箱走的是 bubblewrap,它需要 cap_add: [SYS_ADMIN, NET_ADMIN] + security_opt: [seccomp:unconfined, apparmor:unconfined] 才能在容器里建 user/mount namespace —— 少了这三样,每个沙箱插件都 spawn 不了,访客的一轮对话绑到零个工具。
它对升级的后果是:实例拉不动镜像、也重建不了自己的容器。能做这件事的是编排它的那一方。2026-09-04 之前,升级按钮 POST 的是 owner 自己给的一个不透明 URL(STANDMEET_REDEPLOY_HOOK)。这条路已经没了(3c9fb6113、3d1ce1aaf、7a3a749e7):按钮现在往一个信号文件里写一个时间戳(STANDMEET_UPGRADE_SIGNAL,backend/cmd/server/port/upgrade_signal.go:40-69),文件在一个与 updater sidecar(infra/updater/main.go)共享的卷上 —— 那是唯一持有 docker.sock 的容器。sidecar 拉取频道 tag,然后按每个兄弟容器自己 inspect 出来的配置原地重建(infra/updater/docker.go:91-95,Watchtower 模型 —— 不是拿一份取回来的 compose 去 compose up)。backend 永远不知道自己是怎么部署的;换成 Coolify 形态的宿主,就跑另一个读同一份文件的 sidecar。没有 sidecar(源码构建的栈)→ Configured() 为 false,面板如实说升级在实例之外完成、并告诉 owner 该跑什么。提供一个做不到的动作,比不提供更坏。 整套机制——信号、sidecar、启动时迁移、发布闸门、客户端版本偏差提示——见 product-owned-upgrade。
回执是量出来的,不是假定的:应答这次调用的那个进程,正是要被替换掉的那个,它报不了结果。浏览器在之后轮询 /api/v1/instance(app/src/lib/admin/use-upgrade.ts:85-106),按真实发生的事报 —— 包括"重新部署跑了,版本没变",而那正是一份把 tag 钉死的 compose 会产生的结果。
实例报的版本,就是它正在跑的那个 build
port.appVersion 是个 var,就为了让发布时 -ldflags -X 往里盖章(backend/cmd/server/port/sysinfo.go:49、backend/Dockerfile:73)。很长一段时间没有任何地方盖过,于是每台实例对外报的都是源码里那个字面量:线上跑着 v0.1.3 的镜像,答的是 0.1.0。版本号存在的意义是出事时答得出"这是哪个 build";一个跟 build 无关的数把这个意义整个抵消,而且比没有更坏,因为它看起来像知道。发布构建现在会盖章,缺省值是 dev(没盖过章的一眼看得出),standmeet --version 不需要数据库就答得出,并且有一道闸门在构建与推送之间直接问镜像本人(release-assert-version,Makefile:1369)。
发布与 schema 机制(2026-08-31 → 2026-09-07 上线)
- backend 启动时自己跑迁移 ——
backend/cmd/server/main.go:91的pgstore.Migrate(bd45353f4,2026-08-31);backend/db/migrations/*.sql是升级路径,schema.sql是全新安装路径。schema 烤进standmeet-db镜像(infra/db/Dockerfile),因为贴进去的 compose 背后没有仓库可供 bind-mount。 - ghcr 镜像带 OCI source 标签(
org.opencontainers.image.source,backend/Dockerfile:96、app/Dockerfile:14、infra/db/Dockerfile:30;ba219c391,2026-09-01),让 package 链接回仓库。 - 发布闸门(
Makefile):release-push依赖secrets+secrets-image(扫描镜像里的密钥,Next.js 框架构建 key 列入白名单 —— d846337bc,2026-09-04);release-assert-stripped证明发布构建的 SDK widget 里没有残留data-testid(bec5394fb,2026-09-07;Puck 库 chunk 豁免,92be8bed5);release-assert-version;release-assert-multiarch。 - 八个 UI locale 随 app 镜像发布(
app/src/i18n/messages/{de,en,es,fr,hi,ja,ko,zh};a5e1cada9,2026-09-04)—— locale 走 URL 前缀(e2e/test/ui-locale-in-url.spec.ts),并有 key 对等守卫(infra/scripts/check-i18n-keys)。
为什么这里"无法水平扩展"是正确的
一个人的 StandMeet 服务的是他自己的访客——并发量最多几十到几百,远非 web 规模。垂直扩展(换一台更大的机器)就足够应付;水平扩展买不到任何好处,反而会实实在在地损害自托管的便利性。所以下面这些单机耦合是让自托管保持简单的简化,不是技术债:
| 单机耦合(代码中已验证) | 位置 |
|---|---|
到沙箱化内置 MCP 插件(retrieval / booker / summarize / ask-visitor / mail-sender)的本地 unix 套接字 /run/standmeet/*.sock | cmd/server/axiscap/register.go, internal/infra/hostop/op.go, corpus/usecase/corpus_index_socket.go, routes/connector/invoke_socket.go |
| 进程内内置 MCP 能力(内存传输,无网络) | internal/capabilities/mcpclient/inprocess.go |
builder 写入共享本地卷 /srv/microsites(MICROSITES_ROOT) | builder/runner.mjs |
| 升级信号是一个与 updater sidecar 共享卷上的文件 | cmd/server/port/upgrade_signal.go, infra/updater/ |
进程内缓存(sync.Once 工具缓存;marketplace 目录缓存) | routes/capload/capreg_mcp_app.go, marketplace/usecase/github.go |
| 单一 Postgres / MinIO | compose |
在单租户下这一切都没问题——会话由 Redis 支撑,每张表都以 owner_id 限定作用域,所以这是恰如其分的规模,不是马虎。
如果有一天要做成 SaaS(刻意从简)
两条各自独立的轴,规模都不大:
- 水平扩展(同一实例的 N 个副本): 唯一真正需要重构的是 builder(单后端长轮询 + 共享本地卷 → 任务队列 + 对象存储);进程内缓存会出现漂移(要么迁移到 Redis,要么容忍它);sandbox/sockets 只是把副本限制在特权的、自包含的节点上。
- 多租户(一次部署承载多个所有者): 这是一个产品层面的改变,不是副本数量的问题——
instance_settings单例 + 一次性认领要变成按租户的注册 + 认证。数据层已经以owner_id限定作用域,而且已经有一个multi_tenant开关,所以这比看起来要更近一步。
注:detached agent-loop / persist-at-completion(脱离式代理循环 / 完成时持久化)设计(一个 turn 即使客户端断开也能存活,并落库)是有意为之的 UX 一致性,与扩展性正交——它已经能跨副本工作(历史记录在共享数据库里;重新加载就能恢复)。不是一项扩展任务。
一条命令部署 + 自动 Let's Encrypt(2026 年 7 月被砍)
带自动 Let's Encrypt 证书配置的"一条命令部署"愿景已在 2026 年 7 月被砍掉。所有者现在要在自己的服务商那里绑定域名和证书。保留下来的机制(owner profile 的 public_url + allowed_domains)已经存在,用于锁定对外的门面。