2026-09-23·by Sijie Wang#standmeet#architecture#deployment

deployment

部署——自托管、单租户

上级: 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-shippedcheck-knobs-reachableinfra/scripts/)现在把两份部署文件一起闸住。

实例没有宿主的控制权 —— 而且它如实这么说

在镜像式的栈里,backend 刻意不挂 docker.sockinfra/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_SIGNALbackend/cmd/server/port/upgrade_signal.go:40-69),文件在一个与 updater sidecarinfra/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/instanceapp/src/lib/admin/use-upgrade.ts:85-106),按真实发生的事报 —— 包括"重新部署跑了,版本没变",而那正是一份把 tag 钉死的 compose 会产生的结果。

实例报的版本,就是它正在跑的那个 build

port.appVersion 是个 var,就为了让发布时 -ldflags -X 往里盖章(backend/cmd/server/port/sysinfo.go:49backend/Dockerfile:73)。很长一段时间没有任何地方盖过,于是每台实例对外报的都是源码里那个字面量:线上跑着 v0.1.3 的镜像,答的是 0.1.0。版本号存在的意义是出事时答得出"这是哪个 build";一个跟 build 无关的数把这个意义整个抵消,而且比没有更坏,因为它看起来像知道。发布构建现在会盖章,缺省值是 dev(没盖过章的一眼看得出),standmeet --version 不需要数据库就答得出,并且有一道闸门在构建与推送之间直接问镜像本人release-assert-versionMakefile:1369)。

发布与 schema 机制(2026-08-31 → 2026-09-07 上线)

  • backend 启动时自己跑迁移 —— backend/cmd/server/main.go:91pgstore.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.sourcebackend/Dockerfile:96app/Dockerfile:14infra/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-versionrelease-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/*.sockcmd/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/micrositesMICROSITES_ROOTbuilder/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 / MinIOcompose

在单租户下这一切都没问题——会话由 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)已经存在,用于锁定对外的门面。

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 →