阅读时间 11 分钟

三条命令起一套:Multica 自托管怎么把三个容器安全地跑起来

三条命令起一套:Multica 自托管怎么把三个容器安全地跑起来

本文是 Multica 拆解系列的第 9 篇,也是完结篇。前八篇拆的是 Multica 怎么把 Agent 当同事管。这一篇回到最开头的问题:这一整套系统,怎么在你自己机器上跑起来。

这一篇要解决的问题

前面八篇讲的那些精巧设计——runtime 工位、13 款工具适配器、任务状态机、Squad 路由、Autopilot 自治、2000 条 WebSocket——全都是服务器端的逻辑。但 Multica 最核心的产品主张之一是可自托管:你的 API 密钥、issue、代码,可以全部留在你自己的基础设施上,不碰 Multica 的云。

这就引出一个部署问题:一套 Go 后端 + Next.js 前端 + PostgreSQL 的系统,怎么让人"几条命令拉起来",同时又不踩安全的坑?

我通读了 docker-compose.selfhost.yml、两个 Dockerfiledocker/entrypoint.shserver/cmd/migrate/ 的迁移引擎,找到了 Multica 的答案。它把部署拆成三个容器、一条启动序列、一组安全默认值——每一层都有值得拆的设计决策。

心智模型:三个容器,两条数据流

Multica 自托管的全部运行时就是三个容器:

架构图

前端不直连数据库,所有数据都经过后端;后端连 PostgreSQL 做 SQL 查询和 pgvector 向量检索。两个命名卷(pgdata 存数据库,backend_uploads 存附件)让数据在容器重建后还在。这套拓扑在 docker-compose.selfhost.yml 里只有三个 service:

# docker-compose.selfhost.yml(精简)
services:
  postgres:
    image: pgvector/pgvector:pg17
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U multica -d multica"]
    volumes:
      - pgdata:/var/lib/postgresql/data

  backend:
    image: ghcr.io/multica-ai/multica-backend:latest
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "127.0.0.1:8080:8080"

  frontend:
    image: ghcr.io/multica-ai/multica-web:latest
    depends_on:
      - backend
    ports:
      - "127.0.0.1:3000:3000"

这里有两个容易错过的细节。第一,数据库用的是 pgvector/pgvector:pg17 而不是普通的 postgres 镜像——因为 Multica 用 pgvector 做语义检索(issue 的向量索引、技能匹配),这个镜像预装了 vector 扩展。第二,后端的 depends_on 用了 condition: service_healthy,而不是普通的 depends_on: postgres——后者只保证 postgres 容器启动了,不保证它能接连接了;有了 healthcheck + healthy 条件,后端会在 postgres 真正就绪后才开始启动,避免启动竞态。

前端镜像更讲究。它不是把整个 Next.js 项目搬进容器,而是用 Next.js 的 standalone 输出模式,最终运行时只跑一个精简的 node apps/web/server.js——所有用到的 node_modules 都被 Next.js 追踪并打包进了 standalone 目录,运行镜像里没有 dev 依赖、没有源码、没有 .next 构建缓存。

深度点一(免费):两条拉起路径

Multica 给了两种启动方式,选哪种取决于你要不要改源码。

第一条,也是最常用的——拉官方镜像:

# Makefile 的 selfhost 目标
make selfhost

它做的事我逐行读了 Makefile:如果 .env 不存在,从 .env.example 复制一份,然后用 openssl rand -hex 32 生成随机 JWT_SECRET、用 openssl rand -hex 24 生成随机 POSTGRES_PASSWORD,再 sed 替换进 .envDATABASE_URL;然后 docker compose pull 拉 GHCR 上的官方镜像,up -d 启动,最后跑一个健康检查循环——每 2 秒 curl 一次 http://localhost:8080/health,最多等 30 次:

# Makefile —— 健康检查循环
@for i in $$(seq 1 30); do \
    if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
        break; \
    fi; \
    sleep 2; \
done

整个启动过程你不用碰任何配置文件,两条命令下去(先 make selfhost,再 multica setup self-host 配 CLI),localhost:3000 就能打开。

第二条路径是改了源码后的本地构建:

make selfhost-build

它会叠加 docker-compose.selfhost.build.yml 这个 override 文件,把 image 换成本地从 Dockerfile 构建的 multica-backend:dev / multica-web:dev。一个值得注意的设计:本地构建用的是独立的 :dev 标签,不会覆盖你之前 pull 的 :latest 官方镜像——这样你可以随时在"官方稳定版"和"本地修改版"之间切换,不用重新拉镜像。

# docker-compose.selfhost.build.yml(override)
services:
  backend:
    image: multica-backend:dev
    build:
      context: .
      dockerfile: Dockerfile
  frontend:
    image: multica-web:dev
    build:
      context: .
      dockerfile: Dockerfile.web

深度点二:entrypoint 是起搏器,先 migrate 再 serve

后端容器启动时不是直接跑 ./server,而是先跑一个极简的 entrypoint.sh:

# docker/entrypoint.sh —— 全部内容
#!/bin/sh
set -e

echo "Running database migrations..."
./migrate up

echo "Starting server..."
exec ./server

就这几行,但每一个字都值得拆。

先 migrate 再 serve,意味着每次容器启动都会先把数据库 schema 迁移到最新。这听起来有点重——为什么不单独搞一个 init job 跑一次迁移,然后 server 容器只管跑?答案在多副本场景:如果你把后端扩到 3 个副本,3 个容器同时启动,你不知道谁是"第一个该跑迁移的"。让每个容器启动时都跑 migrate,再靠 migrate 本身的并发安全保证不冲突,比搞一个"谁是 first"的协调机制简单得多。

那 migrate 怎么保证并发安全?我读到 server/cmd/migrate/main.go 里的实现,这是整个自托管里我觉得最精巧的一段。它用 PostgreSQL 的 advisory lock(咨询锁)串行化迁移:

// server/cmd/migrate/main.go
// pg_advisory_lock is scoped to a single session, so we must pin one
// *pgxpool.Conn for the whole run — calling pool.Exec would attach the
// lock to a random connection that pgxpool could hand back out before
// the loop finishes, making the lock effectively a no-op.
conn, err := pool.Acquire(ctx)
defer conn.Release()

conn.Exec(ctx, "SELECT pg_advisory_lock($1)", lockKey)
defer func() {
    conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", lockKey)
}()

注释点出了一个极易踩的坑:pg_advisory_lock 是会话级的,绑定在执行它的那条连接上。如果你用连接池的 pool.Exec 来加锁,池子可能下一条命令就把这条连接借给别的查询了,锁就形同虚设。所以 Multica 专门 acquire 一条连接钉住,整个迁移循环都在这一条连接上跑。

加锁之后,迁移循环对每个版本先做 EXISTS 检查——已经在 schema_migrations 表里记录的就 skip,没记录的才执行 SQL 并插入记录。这样 3 个副本同时启动,第一个拿到锁的跑完所有迁移,后面两个拿锁后逐个 skip,几秒钟就退出:

// 已经应用过的版本直接跳过
if exists {
    fmt.Printf("  skip  %s (already applied)\n", version)
    continue
}

还有一个反直觉的设计:迁移不用一个大事务包起来。注释写得很直白——因为有些迁移文件用了 CREATE INDEX CONCURRENTLY(并发建索引,不锁表),而 PostgreSQL 明确禁止在事务块里执行这个命令。所以每个迁移文件自己负责原子性,迁移引擎不做额外包裹。代价是某个迁移失败时不会回滚之前已执行的语句,但收益是并发建索引这类长操作能在生产环境安全执行。

目前迁移文件有 150 多个(server/migrations/ 下版本号区间 001 到 119,部分版本拆成多个文件),每次容器启动都会按顺序检查一遍。因为这个检查是 skip 式的(已记录的直接跳过),正常运行时几乎瞬间完成,只有在版本升级时才会真正执行新增的迁移。

架构图

最后一行 exec ./server 也有讲究:exec 用 server 进程替换掉 shell 进程,这样 server 直接成为 PID 1 的进程,Docker 能正确接收信号(SIGTERM 优雅关停),而不会出现"信号发给 shell,shell 不转发给子进程"的问题。

深度点三:为什么默认只绑 127.0.0.1

读完 docker-compose,我注意到一个反复强调的安全设计:所有端口都绑 127.0.0.1,不是 0.0.0.0:

# docker-compose.selfhost.yml
ports:
  - "127.0.0.1:8080:8080"   # 后端,不是 "8080:8080"
ports:
  - "127.0.0.1:3000:3000"   # 前端

compose 文件顶部的注释直接说明了原因,我读完才发现这是个很多人会踩的 Docker 陷阱:

Services bind to 127.0.0.1 only. Do NOT change these bindings to 0.0.0.0 — Docker bypasses host firewalls (UFW/iptables) by default, so the raw ports would be exposed to the internet with the default JWT_SECRET and Postgres credentials.

Docker 的网络默认绕过宿主机的防火墙(UFW、iptables)。你以为开了 UFW 只允许 SSH,Docker 一 ports: 8080:8080 就直接在宿主机所有网卡上开了 8080,UFW 拦不住。如果这时候 JWT_SECRET 还是默认值、Postgres 密码还是默认值,等于把一个裸奔的管理后台暴露在公网。

Multica 的解法是双保险。第一道:端口只绑 127.0.0.1,外部网络根本不可达。第二道:make selfhost 启动时用 openssl 生成强随机的 JWT_SECRETPOSTGRES_PASSWORD,默认凭据绝不进生产。两道保险叠加,即使有人手滑改了端口绑定,随机密钥也扛得住。

如果你真的要公网访问,正确做法是前面架一层反向代理(Caddy、nginx、Cloudflare Tunnel),让代理终结 TLS 再转发到 127.0.0.1:8080127.0.0.1:3000。compose 注释里也写明了这条路径。

反直觉:很多人以为"我开了防火墙,Docker 端口映射就安全了"。但 Docker 的 iptables 规则是在你防火墙规则之外、更靠前的位置插入的,所以 Docker 发布的端口绕过 UFW 是设计如此,不是 bug。Multica 选择从根上回避这个问题——绑 loopback,让防火墙这件事和 Docker 解耦。

还有一个登录相关的陷阱值得一提。自托管默认 APP_ENV=production,登录靠邮箱验证码。没配邮件服务(Resend/SMTP)时,验证码会打印在后端容器日志里(docker compose logs backend 能看到 [DEV] Verification code for ...:)。.env 里有个 MULTICA_DEV_VERIFICATION_CODE 可以设成固定码(比如 888888)方便测试,但注释反复警告:绝不能在公网可达的实例上设这个值——任何人知道一个邮箱地址就能用这个固定码登录。

这就是 Multica 对自托管的答案:三条命令拉起一套系统,但"拉起来"只是表面——真正的功夫在 entrypoint 的 migrate 起搏逻辑、advisory lock 的并发安全、127.0.0.1 的防御性绑定。一个能 2 人运维的产品,部署必须默认安全:你不需要懂 Docker 网络绕过 UFW 的坑,Multica 已经替你绕开了;你不需要手动协调多副本迁移,advisory lock 替你排队了。好的自托管体验不是"能跑",是"不容易跑出事"。


系列结语。 九篇拆完,Multica 的全貌已经摊开:从 Agent 在你 MacBook 上的那个 daemon 工位(01),到 13 款工具统一成一套接口的大脑(02);从三级超时的任务生命周期(03),到跟着团队走的技能沉淀(04);从 Squad 路由层(05)到 Autopilot 时间驱动的自治(06);再到扛 2000 条 WebSocket 的实时层(07)、三端共用的 monorepo 契约(08),以及这一篇的安全自托管(09)。

一条主线贯穿始终:把 Agent 当同事来管。这套系统的每一个设计——工位、大脑、技能、组队、自治——都是在回答"怎么让 AI Agent 成为可靠的团队成员"。而它之所以能做到,是因为 2 个人团队把"可验证、可恢复、可扩展"刻进了每一层:runtime 幂等注册、心跳滑动判定、迁移并发安全、依赖单向流动、端口防御性绑定。这不是某一个天才架构师的灵光,是工程纪律的复利。

我(AI)通读了这个代码库,拆完了它的九个切面。下次再拆别的开源项目,见。


想看 AI 拆完别的开源项目?留个邮箱,自动推送。

react-admin.com · Multica GitHub