部署
生产环境部署
从一套 deploy.sh 起来的栈到生产环境要改什么:占位值、密钥、演示登录、CORS、限流、公网 URL,以及把 worker 单独运行起来。
生产环境运行的还是笔记本上那两个进程:mirobody serve 提供 HTTP,mirobody worker 消费队列,配置也还是那三层。变的是仓库为了方便而预置的一切:占位凭据、三个演示账号、debug 级别的日志,以及一个按单人交互强度设定的限流阈值。本页就是从 ./deploy.sh 那套栈出发的差量,一个键一个键地过。
生产检查清单
Section titled “生产检查清单”密钥与密钥材料
- 替换每一个
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的事件流
数据存储
- 一个能用上
vector、pg_trgm、pgcrypto的托管 PostgreSQL - 自己执行
mirobody/schema/:生产的ENV会跳过内置的初始化 - Redis 要有密码,持久化与淘汰策略要你自己明确选过
- 配置
S3_*,而不是把上传写进容器本地目录 - 备份自动化,并演练一次恢复
运维
-
LOG_LEVEL: INFO或更高:DEBUG会连带打开 FastAPI 的 debug 模式 - 在
LOG_NAME+LOG_DIR与采集控制台之间做个选择 - 存活与就绪探针指向
GET /api/health -
mirobody worker与mirobody serve分开部署、分开扩缩 - 把
DEFAULT_TIMEZONE设成你的用户真正生活的时区
必须替换的占位值
Section titled “必须替换的占位值”凡是没法给出安全默认值的地方,模板都放了一个字面的哨兵字符串。一共六处带着它:
| 配置键 | 所在位置 | 说明 |
|---|---|---|
PG_PASSWORD | config.yaml | 数据库密码。 |
PG_ENCRYPTION_KEY | config.yaml | schema 里那些加密列所用的密钥。 |
REDIS_PASSWORD | config.yaml | 必须和 Redis 服务端启动时用的那个一致。 |
JWT_KEY | config.yaml,另外 deploy.sh 会往 config.{env}.yaml 里预置一个随机值 | 签发 access token 的 HS256 密钥。 |
POSTGRES_PASSWORD | compose.yaml 的 pg 服务 | 首次启动时初始化集群用;必须和 PG_PASSWORD 一致。 |
--requirepass | compose.yaml 里 redis 的 command | Redis 密码;必须和 REDIS_PASSWORD 一致。 |
密钥与加密密钥
Section titled “密钥与加密密钥”配置项只要键名匹配 _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_SERVER 和 CONFIG_TOKEN),然后是你的 config.{env}.yaml。环境变量赢过这三者。.env 是用 setdefault 加载的,所以一个真实的环境变量同样赢过这个文件。同一个镜像因此能够跨环境直接复用。
关闭演示登录
Section titled “关闭演示登录”config.yaml 预置了三个账号和一个固定验证码,好让刚克隆下来的仓库能立刻登进去:
EMAIL_PREDEFINE_CODES: caregiver@mirobody.ai: '111111'后面的层按键覆盖前面的,所以在你的覆盖文件里把这个键声明成空值就能清掉它;当值不是一个字典时,加载器会回退到空映射:
EMAIL_PREDEFINE_CODES:日志与 debug 开关
Section titled “日志与 debug 开关”config.yaml 预置的是 LOG_LEVEL: DEBUG,而这个级别管的不只是日志详细程度。Server.start 是这样构造应用的:FastAPI(debug = config.log.level <= logging.DEBUG, …),所以 DEBUG 级别会顺带把 FastAPI 切进 debug 模式。
日志默认打到控制台,除非你同时设了 LOG_NAME 和 LOG_DIR,那样会在该目录下写出形如 {date}_{name}_{time}.log 的文件。在容器平台上,通常更好的选择是两个都不设、直接采集控制台:compose 文件已经设了 PYTHONUNBUFFERED=1,日志行不会被攒着。
CORS 与限流
Section titled “CORS 与限流”HTTP_HEADERS 这一块在模板里是整段注释掉的,示例里的源还是 http://localhost:18080。如果你不设它,CORS 中间件根本不会被加上:内置 SPA 与引擎同源时这没问题,但独立部署的前端会被拦住。这一块里恰好只有五个键会被读:
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。
公网 URL、TLS 与反向代理
Section titled “公网 URL、TLS 与反向代理”引擎通过 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-transform 和 x-accel-buffering: no。请确认你的反代确实尊重它们,而不是把整条流缓冲成一个响应。
PostgreSQL
Section titled “PostgreSQL”这个 schema 需要三个扩展,由 mirobody/schema/00_init_schema.sql 创建:vector、pg_trgm、pgcrypto。要么给这个角色创建扩展的权限,要么提前把它们装好;其中 vector 是承重的,因为指标相关的表上声明了带 HNSW 索引的 vector(1024) 列。
这些文件是按文件名顺序执行的,所以手工执行就是按序过一遍:
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_SSL、REDIS_SSL_CHECK_HOSTNAME、REDIS_SSL_CERT_REQS 控制;模板里它们是关着的。给同一批键加 _LOG 后缀,可以声明第二条独立连接。
get_storage_client() 会依次尝试各个云端后端,最后回退到本地磁盘。S3 后端需要 S3_KEY、S3_TOKEN、S3_REGION、S3_BUCKET 四个全都有:缺任何一个它就抛异常,工厂随即静默地落到 LocalStorage。S3_PREFIX 和 S3_CDN 是可选的。
这个静默回退正是要留意的地方:S3 那一块填得不全,上传就落在容器内的 ./.theta/mcp/upload/、图表落在 ./.theta/mcp/charts。在多于一个副本的部署里,一个实例写下的文件对其它实例就是不可见的。
DEFAULT_TIMEZONE 是用户没设时区时的回退值;模板和代码里的默认值都是 America/Los_Angeles。请求本身可以带自己的时区,存下来的用户记录也可以,所以这个值只决定的是「两者都没有时怎么办」。对服务端的后台任务、对一个新账号的第一轮对话来说,这种情况相当常见。
单独部署 worker
Section titled “单独部署 worker”mirobody serve 和 mirobody worker 的打包方式一样、配置方式也一样,但它们不该是同一个部署。
- 只有
mirobody serve承载流量。Worker.start不起 uvicorn、也不注册任何路由,所以挂在负载均衡后面的 worker 副本会是个黑洞。 - 它们的扩缩信号不同。 HTTP 容量跟着请求并发走,队列容量跟着摄入量走。一次批量导入可能需要很多 worker 而不需要额外的 HTTP 容量,流量高峰则正好相反。
- 多加副本是分担而不是重复。 消费者按任务类各自对一条共享的 Redis 列表做
BLPOP,所以多出来的 worker 副本是从同一个队列里取,而不是把它运行两遍。任务发现是自动的:每个注册过的任务都会获得自己的消费循环,目前有两个 —— 指标同步与用户画像刷新。 - 给它时间收尾。
SIGINT和SIGTERM会置上停止事件,各个循环把当前这一批做完,所以终止的宽限期应当长于一个典型任务,而不是把它砍断。
关于监控:空闲的消费者每十分钟会打一条心跳日志,并写明自己的队列;任务类还可以声明一个队列长度上限,一旦队列到顶,入队就会抛异常:把这个当作「消费者跟不上了」的信号。
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 里的队列深度来监控它。
生产环境覆盖文件示例
Section titled “生产环境覆盖文件示例”所有非密钥的配置放在一个文件里:
LOG_LEVEL: INFODEFAULT_TIMEZONE: UTC
HTTP_HOST: 0.0.0.0HTTP_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.internalPG_PORT: 5432PG_USER: mirobodyPG_DBNAME: mirobodyPG_SCHEMA: theta_aiPG_MIN_CONNECTION: 5PG_MAX_CONNECTION: 20
REDIS_HOST: redis.internalREDIS_PORT: 6379REDIS_SSL: true
S3_REGION: us-west-2S3_BUCKET: mirobody-prodS3_PREFIX: uploads/
# 清掉模板预置的演示账号。EMAIL_PREDEFINE_CODES:密钥则从环境变量给:
export ENV=prodexport 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_HOST 和 HTTP_PORT 写在文件里,是针对读 YAML 的那种部署。在仓库自带的 compose.yaml 下,这两个是以容器环境变量的形式进来的、优先级更高,所以那种情况下要改的是 compose 文件。
镜像、四个服务,以及它们底下的那些卷
每一组配置键,以及那三层各自的用途
两个进程、中间件栈,以及两类路由
MCP_PUBLIC_URL 一旦可达,能打开什么