部署
Docker 部署
deploy.sh 如何构建镜像、compose.yaml 到底声明了什么:固定网段上的四个服务、五个具名卷,以及在容器启动时安装的依赖。
标准部署方式就是仓库根目录下的两个文件:deploy.sh 负责准备配置并构建镜像,compose.yaml 负责声明这套服务。安装指南讲的是简版:克隆、运行 ./deploy.sh、四个容器就起来了。本页是把同一条路当作部署来读:镜像里装了什么、为什么它几乎不需要重建,以及当你从笔记本挪到服务器上时 compose.yaml 的哪些地方必须改。
deploy.sh 的四个阶段
Section titled “deploy.sh 的四个阶段”四个阶段,每个都是幂等的:已经存在的文件、没有变过的镜像都不会被动。
- 写
.env,如果它还不存在ENV取自环境变量,缺省为localdb;CONFIG_ENCRYPTION_KEY是用 shell 的$RANDOM从[A-Za-z0-9]里取的 32 个字符。这个文件被两边读:docker compose用它做${ENV}替换,引擎自己也读它。 - 写
config.{env}.yaml,如果它还不存在 一个随机的 32 字符JWT_KEY、三个demo1/2/3@mirobody.ai演示账号(验证码777777),以及 LLM key 和MCP_PUBLIC_URL的注释占位。这是你的覆盖层:脚本从不碰config.yaml。 - 构建镜像,如果 Dockerfile 文本变了 先探测
hub.docker.com是否有响应,据此选定镜像源前缀,然后构建一个内联 Dockerfile,并为它附上自身的校验和的标签。 - 重启整套服务并跟踪日志 先
docker compose down,再停掉任何发布 18080 / 18082 / 18089 端口的容器,然后docker compose up -d --remove-orphans,最后docker compose logs -f。
最后这个阶段有两个后果,在服务器上运行之前需要注意:
- 脚本以
docker compose logs -f收尾,所以它停在前台。Ctrl-C停掉的是日志跟踪,容器仍在运行。 - 那句
docker compose down没带-v,所以具名卷(包括数据库)都活着。重复运行./deploy.sh是安全的。
Dockerfile 是脚本里的一个字符串。按它实际构建的样子照录如下:
FROM ubuntu:24.04RUN apt update && \ apt install -y --no-install-recommends \ ca-certificates curl \ g++ gfortran build-essential \ libfftw3-dev libhdf5-dev libblas-dev liblapack-dev \ python3 python3-venv python3-dev \ fonts-wqy-microhei fonts-wqy-zenhei fontconfig && \ rm -rf /var/lib/apt/lists/* && \ fc-cache -fv && \ mkdir /root/venv && \ python3 -m venv /root/venv && \ mkdir -p /appWORKDIR /app注意里面没有什么:没有 COPY、没有 pip install、没有应用代码。这个镜像是一套环境,不是引擎的一次构建。仓库以 bind mount 的方式出现在 /app,依赖则在容器启动时才安装。
镜像是「Ubuntu 24.04 + 一套编译工具链 + 一个建在 /root/venv 的虚拟环境」。Python 依赖不在镜像构建期安装,而是在容器启动时装进一个卷,所以镜像里必须备着 C / C++ / Fortran 工具链与数值库的开发头文件 —— 任何在当前平台没有预编译 wheel 的包都会现场编译。另外装了 TLS 根证书、curl(健康检查要用)以及中文字形(否则渲染出来的标签是一串方框)。
镜像的重建条件
Section titled “镜像的重建条件”脚本把 Dockerfile 文本的 md5 作为 label 写在镜像上,下次运行时比对:相同就打印 Using existing docker image. 并跳过构建。
# 当前镜像是按哪个校验和构建的docker image inspect --format '{{ index .Config.Labels "dockerfile.md5" }}' mirobody
# 强制重建docker image rm mirobody && ./deploy.sh受限网络下的镜像源
Section titled “受限网络下的镜像源”构建之前,脚本会对 hub.docker.com 做一次可达性探测:有 curl 就用 curl(连接超时 3 秒),否则用 wget --spider,再否则用 ping。HTTP 200、301、302 都算可达。
如果不可达,脚本就遍历它的镜像源列表(只有一项,docker.1ms.run),第一个有响应的成为镜像仓库前缀,于是 FROM ubuntu:24.04 实际是按 FROM docker.1ms.run/ubuntu:24.04 构建的。
这个回退有三个边界,在受限网络里每一条都会撞上:
- 它只重写脚本自己构建的那个镜像。
pgvector/pgvector:pg17-trixie和redis:7.0-alpine是docker compose去拉的,根本看不到这个前缀。这两个要靠 Docker daemon 层面配置的镜像源。 - npm registry 是无条件设置的。 不管探测结果如何,
npm config set registry https://registry.npmmirror.com都被烙进了镜像。想用默认 registry,就在容器里把它改回去。 - PyPI 没有做镜像。 脚本和
compose.yaml都没有设置索引地址,所以pip的镜像要你自己加:比如在mirobody服务的environment里多加一项。
| 服务 | 镜像 | 发布端口 | 在 mirobody_network 上的地址 |
|---|---|---|---|
pg | pgvector/pgvector:pg17-trixie | 18082:5432 | 10.108.0.2 |
redis | redis:7.0-alpine | 18089:6379 | 10.108.0.9 |
mirobody | mirobody,本地构建 | 18080:18080 | 10.108.0.8 |
mirobody_worker | mirobody,同一个镜像 | 无(ports: []) | 10.108.0.11 |
这个网络是一个叫 mirobody_network 的 bridge,带一段显式的 IPAM 配置:子网 10.108.0.0/24,网关 10.108.0.1。地址是钉死的,不是靠服务发现获得的,所以 config.yaml 才能把 PG_HOST: 10.108.0.2、REDIS_HOST: 10.108.0.9 直接写成能用的默认值,完全不用走服务名解析。
pg —— 带 pgvector 的 PostgreSQL
Section titled “pg —— 带 pgvector 的 PostgreSQL”三个环境变量在首次启动时初始化这个数据库集群(POSTGRES_USER: holistic_user、POSTGRES_DB: holistic_db、POSTGRES_PASSWORD: REPLACE_THIS_VALUE_IN_PRODUCTION),它们和 config.yaml 里的 PG_USER、PG_DBNAME 是对齐的。数据目录是 mirobody_postgres 卷。
Redis 并非以默认参数启动;这个服务用 command 覆盖了一整串显式参数:
redis-server--bind 0.0.0.0 --port 6379 --protected-mode no--requirepass REPLACE_THIS_VALUE_IN_PRODUCTION--maxmemory 512mb --maxmemory-policy allkeys-lru--appendonly yes --appendfilename "appendonly.aof" --appendfsync everysec--loglevel notice --timeout 60 --tcp-keepalive 30--io-threads 4 --io-threads-do-reads yes --tcp-backlog 511mirobody
Section titled “mirobody”HTTP 进程。它依赖 pg 和 redis,发布 18080:18080,收六个环境变量:
| 变量 | 值 | 说明 |
|---|---|---|
ENV | ${ENV} | 由 compose 从 .env 替换进来。决定加载哪个 config.{env}.yaml。 |
CONFIG_SERVER · CONFIG_TOKEN | ${…} | 为可选的远程配置服务转发。这两个只从环境变量读:写在 YAML 里没有任何作用。 |
PYTHONUNBUFFERED | 1 | 日志行会立刻出现在 docker compose logs 里。 |
PYTHONPATH | /app | 让 mirobody serve 对着 bind mount 解析。 |
HTTP_HOST · HTTP_PORT | 0.0.0.0 · 18080 | 这两个在 config.yaml 里都是注释掉的,代码里的回退值是 0.0.0.0 和 80 端口,所以少了这两项,容器就会听在错误的端口上。 |
mirobody_worker
Section titled “mirobody_worker”worker 是用 <<: *mirobody_base 声明的,即对 mirobody 服务做一次 YAML 合并,然后覆盖四个键:
| 键 | 覆盖成 | 为什么 |
|---|---|---|
ports | [] | 重置为空。继承 18080:18080 会让两个容器抢同一个宿主端口。 |
command | 激活 venv,mirobody worker | 故意不做 pip install:锚点里的安装步骤已经在 mirobody 里运行过了。 |
networks | ipv4_address: 10.108.0.11 | 钉死的地址不能被继承,那会撞车。 |
depends_on | pg、redis,外加 mirobody | 共享的 site_packages 卷必须先被填好,worker 才能 import 这个包。 |
其余一切(image、volumes、environment)原样继承。
四个卷,每一个都在干一件不同的事:
| 卷 | 挂载点 | 装什么 |
|---|---|---|
mirobody_postgres | /var/lib/postgresql/data | 数据库集群。 |
mirobody_upload | /app/.theta/mcp/upload | 没有配置对象存储时上传的文件:这是 LocalStorage 的默认基础路径。 |
mirobody_charts | /app/.theta/mcp/charts | 渲染好的图表 PNG,再从 /charts 提供出去。 |
mirobody_site_packages | /root/venv/lib/python3.12/site-packages | 已安装的 Python 依赖,以及下面会讲的 .deps_hash 标记。 |
后几个之所以存在,是因为那个 bind mount。.:/app 把你的工作副本映射进容器,所以如果上传目录是普通路径,它们就会在你的检出目录里冒出来。把具名卷挂在 bind mount 的子目录之上,这些内容就留在 Docker 里、不进仓库,同时仍能扛过 docker compose down。
容器启动时的依赖安装
Section titled “容器启动时的依赖安装”两个应用容器的 command 是同一条 shell 流水线:激活镜像里的 venv → 对 pyproject.toml 与 requirements.txt 取一个校验和 → 与 site-packages 卷里的标记比对 → 不一致才 pip install,否则打印 >> deps unchanged, skip install → 最后启动服务或 worker。整条用 && 串起来,所以装依赖失败就不会走到启动。
对日常开发来说,结论只有两条:
- 改
.py文件只需重启。 源码是 bind mount 进来的,安装是可编辑安装,代码不经过任何构建。 - 只有依赖集合变了才会重装。 那个校验和标记就住在 site-packages 卷里面,所以它不可能和它描述的那批包脱节:卷一删,标记跟着走。
docker compose ps # 现在起着什么docker compose logs -f mirobody mirobody_worker # 两个应用的日志docker compose restart mirobody mirobody_worker # 让配置改动生效docker compose exec mirobody bash # 进服务容器开个 shelldocker compose exec pg psql -U holistic_user -d holistic_dbdocker compose down # 停掉,保留卷配置只在启动时读一次,所以改了 config.{env}.yaml 要把两个应用容器都重启:worker 加载的是同一批文件,否则它一样是旧的。
18080、18082 或 18089 端口已被占用
启动之前,deploy.sh 会在 docker ps 里搜 ":<端口>->" 并停掉找到的进程。这只够得着恰好发布那个宿主端口的容器:占着端口的非 Docker 进程它动不了,把同一个服务发布在别的宿主端口上的容器也一样。腾出端口,或者改 compose.yaml 里映射的左半边;如果你挪了 18080,记得把 mirobody 环境变量里的 HTTP_PORT 一起改。
容器起来了又退出
去看 docker compose logs mirobody。两个应用容器运行的都是用 && 串起来的 shell 流水线,所以失败几乎总在它里面:npm install 或 pip install 连不上仓库,或者 import 失败了。另外注意 depends_on 等的是依赖启动,不是就绪,而这个文件没有声明任何 healthcheck,所以在一台冷机器上,引擎可能连到一个还在初始化的 PostgreSQL。这种情况下重启容器就够了。
依赖装不上
安装发生在容器启动时、在容器内部,所以它需要能出网到 npm registry(镜像里被钉在 registry.npmmirror.com)和 PyPI。失败会导致 .deps_hash 没被写下,于是下一次启动从头重试。想交互着排查:
docker compose exec mirobody bashsource /root/venv/bin/activatepip install -r requirements.txt镜像是旧的,或者重建触发不了
只要内联 Dockerfile 文本的 md5 和本地 mirobody 镜像上的 dockerfile.md5 label 相同,构建就被跳过,别的什么都不比。把镜像删除来强制重建:
docker image rm mirobody && ./deploy.sh改了配置却没有生效
三种原因,按可能性排序。引擎只在启动时读一次配置:重启。环境变量赢过两层 YAML,所以任何在 compose 的 environment 块里设置过的项(HTTP_HOST、HTTP_PORT、ENV)都无法从文件里覆盖。以及,首次加载时加载器会就地加密看起来像密钥的值:键名里带 _KEY、_PASSWORD、_PASS、_PWD、_SECRET、_SK、_TOKEN 的,都会以密文形式被改写回你的 config.{env}.yaml。在你填过密码的地方看到 gAAAA…,是预期行为,不是文件坏了。