跳转到内容
快速开始

部署

生产环境部署

从一套 deploy.sh 起来的栈到生产环境要改什么:占位值、密钥、演示登录、CORS、限流、公网 URL,以及把 worker 单独运行起来。

生产环境运行的还是笔记本上那两个进程:mirobody serve 提供 HTTP,mirobody worker 消费队列,配置也还是那三层。变的是仓库为了方便而预置的一切:占位凭据、三个演示账号、debug 级别的日志,以及一个按单人交互强度设定的限流阈值。本页就是从 ./deploy.sh 那套栈出发的差量,一个键一个键地过。

密钥与密钥材料
  • 替换每一个 REPLACE_THIS_VALUE_IN_PRODUCTION:完整清单在下一节
  • CONFIG_ENCRYPTION_KEY 从环境变量给,而不是落在磁盘上的 .env 文件里
  • JWT_KEY 自己生成;deploy.sh 写下的那个来自 shell 的 $RANDOM
  • 设置 PG_ENCRYPTION_KEY,并且不要写进配置文件
  • 清空 EMAIL_PREDEFINE_CODES
网络
  • 在引擎前面终结 TLS,并把公网的 Host 头透传进来
  • MCP_PUBLIC_URL 设成公网可达的 HTTPS 源
  • HTTP_HEADERS 里的 CORS 项收窄到你自己的域
  • 重新审视 REQUEST_RATE_LIMITER,并在反代上限流匿名流量
  • 确认反代不会缓冲 /api/chat 的事件流
数据存储
  • 一个能用上 vectorpg_trgmpgcrypto 的托管 PostgreSQL
  • 自己执行 mirobody/schema/:生产的 ENV 会跳过内置的初始化
  • Redis 要有密码,持久化与淘汰策略要你自己明确选过
  • 配置 S3_*,而不是把上传写进容器本地目录
  • 备份自动化,并演练一次恢复
运维
  • LOG_LEVEL: INFO 或更高:DEBUG 会连带打开 FastAPI 的 debug 模式
  • LOG_NAME + LOG_DIR 与采集控制台之间做个选择
  • 存活与就绪探针指向 GET /api/health
  • mirobody workermirobody serve 分开部署、分开扩缩
  • DEFAULT_TIMEZONE 设成你的用户真正生活的时区

凡是没法给出安全默认值的地方,模板都放了一个字面的哨兵字符串。一共六处带着它:

配置键所在位置说明
PG_PASSWORDconfig.yaml数据库密码。
PG_ENCRYPTION_KEYconfig.yamlschema 里那些加密列所用的密钥。
REDIS_PASSWORDconfig.yaml必须和 Redis 服务端启动时用的那个一致。
JWT_KEYconfig.yaml,另外 deploy.sh 会往 config.{env}.yaml 里预置一个随机值签发 access token 的 HS256 密钥。
POSTGRES_PASSWORDcompose.yamlpg 服务首次启动时初始化集群用;必须和 PG_PASSWORD 一致。
--requirepasscompose.yamlredis 的 commandRedis 密码;必须和 REDIS_PASSWORD 一致。

配置项只要键名匹配 _KEY_PASSWORD_PASS_PWD_SECRET_SK_TOKEN,且不以 _URL 结尾,就会在文件加载时被加密,这个 YAML 文件也会被就地改写成密文。所以你贴进 config.{env}.yaml 的密码,在第一次启动之后就变成一串 gAAAA…。这是设计如此,也意味着进程需要对这个文件有写权限。

Fernet 密钥只由 CONFIG_ENCRYPTION_KEY 推导而来:取值、去空白、截断到 32 个字符、用 0 右填充到 32 字节,再做 base64-url 编码。由此有两个后果。只有前 32 个字符起作用,所以更长的密钥不会带来任何额外强度;而更短的密钥会被静默填充,所以就照着 32 个字符来。

取值顺序如下,后面的层覆盖前面的:仓库模板 config.yaml,然后是远程配置文档(如果设了 CONFIG_SERVERCONFIG_TOKEN),然后是你的 config.{env}.yaml。环境变量赢过这三者。.env 是用 setdefault 加载的,所以一个真实的环境变量同样赢过这个文件。同一个镜像因此能够跨环境直接复用。

config.yaml 预置了三个账号和一个固定验证码,好让刚克隆下来的仓库能立刻登进去:

config.yaml
EMAIL_PREDEFINE_CODES:
caregiver@mirobody.ai: '111111'

后面的层按键覆盖前面的,所以在你的覆盖文件里把这个键声明成空值就能清掉它;当值不是一个字典时,加载器会回退到空映射:

config.prod.yaml
EMAIL_PREDEFINE_CODES:

config.yaml 预置的是 LOG_LEVEL: DEBUG,而这个级别管的不只是日志详细程度。Server.start 是这样构造应用的:FastAPI(debug = config.log.level <= logging.DEBUG, …),所以 DEBUG 级别会顺带把 FastAPI 切进 debug 模式。

日志默认打到控制台,除非你同时设了 LOG_NAMELOG_DIR,那样会在该目录下写出形如 {date}_{name}_{time}.log 的文件。在容器平台上,通常更好的选择是两个都不设、直接采集控制台:compose 文件已经设了 PYTHONUNBUFFERED=1,日志行不会被攒着。

HTTP_HEADERS 这一块在模板里是整段注释掉的,示例里的源还是 http://localhost:18080。如果你不设它,CORS 中间件根本不会被加上:内置 SPA 与引擎同源时这没问题,但独立部署的前端会被拦住。这一块里恰好只有五个键会被读:

config.prod.yaml
HTTP_HEADERS:
Access-Control-Allow-Origin: 'https://app.example.com'
Access-Control-Allow-Credentials: 'true'
Access-Control-Allow-Methods: 'GET, POST, PUT, DELETE, OPTIONS'
Access-Control-Allow-Headers: 'Authorization, Content-Type'
Access-Control-Max-Age: '600'

Access-Control-Allow-Origin 是当作单个源透传下去的,不是一个列表。把 * 和凭据搭在一起会被浏览器拒绝,你要是这么写,引擎会打一条告警。

REQUEST_RATE_LIMITER 把 URL 路径映射到每分钟的额度;默认是 /api/chat: 6/api/session: 6。实现是对 limit:{user_id}:{path} 做 Redis INCR,首次命中时设 60 秒过期,一旦越过阈值就返回 429,并把 Retry-After 设为该键剩余的 TTL。

引擎通过 uvicorn 在 HTTP_HOST:HTTP_PORT 上提供纯 HTTP;TLS 在它前面终结。

MCP_PUBLIC_URL 是公网可达的 HTTPS 源,它的作用远不止出现在一条启动横幅里。没有配置对象存储时,LocalStorage 会把文件 URL 拼成 {MCP_PUBLIC_URL}/files:这个键不设,这个前缀就是空的。它同时也是远程 MCP 客户端被指向的那个源,所以要设成外界实际使用的地址,而不是容器的地址。

/api/chat 是一条 Server-Sent Events 流。引擎在这个响应上已经发了 cache-control: no-cache, no-transformx-accel-buffering: no。请确认你的反代确实尊重它们,而不是把整条流缓冲成一个响应。

这个 schema 需要三个扩展,由 mirobody/schema/00_init_schema.sql 创建:vectorpg_trgmpgcrypto。要么给这个角色创建扩展的权限,要么提前把它们装好;其中 vector 是承重的,因为指标相关的表上声明了带 HNSW 索引的 vector(1024) 列。

这些文件是按文件名顺序执行的,所以手工执行就是按序过一遍:

Terminal window
for f in mirobody/schema/*.sql; do
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$f"
done

注意引擎自己那套初始化在某个文件失败时是记日志并回滚、然后接着运行下一个,所以在非生产的 ENV 上,一个只应用了一半的 schema 很容易被忽略过去。上面那个 ON_ERROR_STOP=1 就是故意写得更严格。

PG_SCHEMA 默认是 theta_ai;初始化时会把这个值按逗号切开,并把它列出的每个 schema 都建出来。连接池大小是 PG_MIN_CONNECTION(5)和 PG_MAX_CONNECTION(20):这是按进程算的,而进程有两个,所以服务端的连接上限要按两个来预算。

在正常的部署里 Redis 不是可选项。有三处在使用它:限流的计数器、worker 的任务队列,以及 provider 拉取路径上的锁和临时 OAuth 状态。

compose 里那个 Redis 是按笔记本配的(--maxmemory 512mb --maxmemory-policy allkeys-lru),而且没有卷,所以 append-only 持久化写在容器自己的文件系统里。这两点在生产环境都值得单独决策:allkeys-lru 在内存吃紧时会淘汰任意键,包括一个已入队的任务或一次进行中的 OAuth 握手。

对接托管实例时,TLS 那一侧由 REDIS_SSLREDIS_SSL_CHECK_HOSTNAMEREDIS_SSL_CERT_REQS 控制;模板里它们是关着的。给同一批键加 _LOG 后缀,可以声明第二条独立连接。

get_storage_client() 会依次尝试各个云端后端,最后回退到本地磁盘。S3 后端需要 S3_KEYS3_TOKENS3_REGIONS3_BUCKET 四个全都有:缺任何一个它就抛异常,工厂随即静默地落到 LocalStorageS3_PREFIXS3_CDN 是可选的。

这个静默回退正是要留意的地方:S3 那一块填得不全,上传就落在容器内的 ./.theta/mcp/upload/、图表落在 ./.theta/mcp/charts。在多于一个副本的部署里,一个实例写下的文件对其它实例就是不可见的。

DEFAULT_TIMEZONE 是用户没设时区时的回退值;模板和代码里的默认值都是 America/Los_Angeles。请求本身可以带自己的时区,存下来的用户记录也可以,所以这个值只决定的是「两者都没有时怎么办」。对服务端的后台任务、对一个新账号的第一轮对话来说,这种情况相当常见。

mirobody servemirobody worker 的打包方式一样、配置方式也一样,但它们不该是同一个部署。

  • 只有 mirobody serve 承载流量。 Worker.start 不起 uvicorn、也不注册任何路由,所以挂在负载均衡后面的 worker 副本会是个黑洞。
  • 它们的扩缩信号不同。 HTTP 容量跟着请求并发走,队列容量跟着摄入量走。一次批量导入可能需要很多 worker 而不需要额外的 HTTP 容量,流量高峰则正好相反。
  • 多加副本是分担而不是重复。 消费者按任务类各自对一条共享的 Redis 列表做 BLPOP,所以多出来的 worker 副本是从同一个队列里取,而不是把它运行两遍。任务发现是自动的:每个注册过的任务都会获得自己的消费循环,目前有两个 —— 指标同步与用户画像刷新。
  • 给它时间收尾。 SIGINTSIGTERM 会置上停止事件,各个循环把当前这一批做完,所以终止的宽限期应当长于一个典型任务,而不是把它砍断。

关于监控:空闲的消费者每十分钟会打一条心跳日志,并写明自己的队列;任务类还可以声明一个队列长度上限,一旦队列到顶,入队就会抛异常:把这个当作「消费者跟不上了」的信号。

Terminal window
curl -s https://api.example.com/api/health
响应示例
{
"service": "mirobody",
"version": "",
"tools": 12,
"public_tools": 4,
"authenticated_tools": 8,
"resources": 3,
"agents": 3
}

这个端点不需要认证,返回的是启动时统计好的计数,不碰 PostgreSQL 也不碰 Redis。这让它成为一个很好的存活探针,也是一个很差的数据存储就绪探针:数据库连不上的时候它照样返回 200。这些计数本身也有用:如果你配置的扩展目录没被加载上,工具数和 agent 数会立刻告诉你。

worker 完全没有 HTTP 面。请靠进程存活和 Redis 里的队列深度来监控它。

所有非密钥的配置放在一个文件里:

config.prod.yaml
LOG_LEVEL: INFO
DEFAULT_TIMEZONE: UTC
HTTP_HOST: 0.0.0.0
HTTP_PORT: 18080
HTTP_HEADERS:
Access-Control-Allow-Origin: 'https://app.example.com'
Access-Control-Allow-Credentials: 'true'
Access-Control-Allow-Methods: 'GET, POST, PUT, DELETE, OPTIONS'
Access-Control-Allow-Headers: 'Authorization, Content-Type'
Access-Control-Max-Age: '600'
MCP_PUBLIC_URL: 'https://api.example.com'
REQUEST_RATE_LIMITER:
/api/chat: 30
/api/session: 30
PG_HOST: pg.internal
PG_PORT: 5432
PG_USER: mirobody
PG_DBNAME: mirobody
PG_SCHEMA: theta_ai
PG_MIN_CONNECTION: 5
PG_MAX_CONNECTION: 20
REDIS_HOST: redis.internal
REDIS_PORT: 6379
REDIS_SSL: true
S3_REGION: us-west-2
S3_BUCKET: mirobody-prod
S3_PREFIX: uploads/
# 清掉模板预置的演示账号。
EMAIL_PREDEFINE_CODES:

密钥则从环境变量给:

Terminal window
export ENV=prod
export CONFIG_ENCRYPTION_KEY=""
export PG_PASSWORD=""
export PG_ENCRYPTION_KEY=""
export REDIS_PASSWORD=""
export JWT_KEY=""
export S3_KEY=""
export S3_TOKEN=""
export GOOGLE_API_KEY=""

注意这里把 HTTP_HOSTHTTP_PORT 写在文件里,是针对读 YAML 的那种部署。在仓库自带的 compose.yaml 下,这两个是以容器环境变量的形式进来的、优先级更高,所以那种情况下要改的是 compose 文件。