Docker Compose 生产部署
本页深入讲解 Aivory 的生产 compose 文件 deploy/docker-compose.prod.yml:每个服务的职责与健康检查、数据卷与备份策略、预构建镜像与本地构建的切换、资源规划、日常运维命令、升级流程,以及常见故障的排查表。如果你只想尽快把服务拉起来,请先看快速部署,本页假设你已经完成了首次部署。
架构总览
整套栈由 5 个服务组成,全部挂在一个私有 bridge 网络 internal 上。只有 app 发布宿主端口(默认 80:8787),其余服务(包括 Qdrant 和沙箱)不对外暴露任何端口,只能在私有网络内被访问。
| 服务 | 镜像 | 宿主端口 | 职责 |
|---|---|---|---|
app | ghcr.io/<IMAGE_OWNER>/aivory-app | 80(可改) | 单容器同源伺服构建后的 SPA 与 /api 后端 |
postgres | postgres:16-alpine | 无 | 关系型存储:用户、对话、知识库、用量等 |
redis | redis:7-alpine | 无 | 缓存、限流计数器、跨进程停止流式输出的 pub/sub |
qdrant | qdrant/qdrant:v1.12.4 | 无 | RAG 向量检索 |
sandbox | ghcr.io/<IMAGE_OWNER>/aivory-sandbox-sidecar | 无 | 内置代码执行沙箱控制面,仅内网可达 |
app 容器内的 Go 进程同时伺服 SPA 静态文件和 /api,前后端天然同源。因此不存在跨域问题,也不需要配置 PUBLIC_ORIGIN、ALLOWED_ORIGINS 或任何域名相关变量:代理把请求转发到容器,哪个域名进来哪个域名就能用,多域名同时指向也没问题。公网部署时只需在前面放一层 TLS 终止(见反向代理与 HTTPS)。
服务详解
app:应用主容器
- 容器内监听
8787(AIVORY_LISTEN: ":8787"),同时伺服 SPA 与/api。SPA 目录在构建镜像时已内置(STATIC_DIR指向镜像内的构建产物),无需单独的 nginx/web 层。 - 端口映射写死在 compose 文件里:
"80:8787"。宿主 80 被占用时,直接改左边的数字(例如"8080:8787"),不需要任何环境变量。 - 启动依赖:
postgres和redis必须通过健康检查(condition: service_healthy),qdrant只要求已启动(condition: service_started,因为该服务没有定义 healthcheck)。 app不依赖sandbox:沙箱是逐请求的软依赖,只有代码执行功能用到它。如果把它做成启动依赖,沙箱镜像拉不下来或不健康时会拖死整个应用,所以 compose 有意不声明这条依赖。- 健康检查由镜像自带(
Dockerfile.app中定义):每 15s 用wget探测http://127.0.0.1:8787/api/health,超时 3s,启动宽限 20s,连续失败 5 次判定 unhealthy。 - 数据目录:
${DATA_DIR:-./data}绑定挂载到容器内/app/data,存放上传文件、生成产物、备份归档(以及 SQLite 文件,如果用到)。绑定挂载到宿主目录而不是 Docker 卷,是为了让这些文件在宿主机上直接可见、可备份。
compose 为 app 注入的关键环境变量(完整清单见核心环境变量):
| 变量 | compose 中的值 | 说明 |
|---|---|---|
AIVORY_ENV | production | 触发部署级安全校验(如 JWT_SECRET 强制) |
DATABASE_URL | postgres://<user>:<password>@postgres:5432/<db>?sslmode=disable | 由 .env 中的 Postgres 变量拼接而成 |
REDIS_URL | redis://:<REDIS_PASSWORD>@redis:6379/0 | 启用 Redis 缓存、队列与流恢复 |
QDRANT_URL | 默认 http://qdrant:6333 | 指向栈内 Qdrant;可覆盖为外部集群 |
QDRANT_API_KEY | 默认 aivory-internal-qdrant | 必须与 qdrant 服务的 key 一致(见下) |
JWT_SECRET | 必须在 .env 设置 | 缺失时 compose 直接报错拒绝启动 |
SANDBOX_BASE_URL | http://sandbox:8000 | 走私有网络访问内置沙箱 |
SANDBOX_API_KEY | 默认 aivory-bundled-sandbox | 与沙箱服务共享的内部默认值 |
ENABLE_MOCK_PROVIDER | 默认 false | 置 true 可启用内置演示模型,无需真实 API key |
Anthropic / OpenAI 等 provider 的 API key 不通过环境变量配置,它们保存在数据库的 channels 表中,部署完成后在管理后台的渠道与模型页面添加。
postgres:关系型数据库
- 镜像
postgres:16-alpine,数据落在命名卷pgdata(容器内/var/lib/postgresql/data)。 POSTGRES_PASSWORD是必填项:compose 使用${POSTGRES_PASSWORD:?...}语法,不设置直接报错。用户名和库名默认都是aivory。- 健康检查:每 10s 执行一次
pg_isready,超时 5s,最多重试 10 次。app会等它变为 healthy 后才启动。 - 数据库 schema 在
app启动时自动创建和迁移,不需要手动执行任何 SQL。
POSTGRES_PASSWORD 只在 pgdata 卷为空、数据库首次初始化时生效。之后修改 .env 里的这个值不会更新数据库内的实际密码,只会导致 app 用新密码连接旧库而认证失败。改密码要么在数据库内用 ALTER USER 执行,要么(仅限可丢弃数据的环境)删掉 pgdata 卷重新初始化。
redis:缓存与消息
- 镜像
redis:7-alpine,以--appendonly yes --requirepass <REDIS_PASSWORD>启动:开启 AOF 持久化并强制密码认证。REDIS_PASSWORD同样是.env必填项。 - 承担缓存、限流计数器,以及跨进程"停止生成"信号的 pub/sub;配置 Redis 后,应用还会启用 Redis 队列与流恢复。
- 数据落在命名卷
redisdata(容器内/data)。 - 健康检查:每 10s 执行
redis-cli -a "$REDIS_PASSWORD" ping并检查返回PONG,超时 5s,重试 10 次。
qdrant:向量数据库
- 镜像
qdrant/qdrant:v1.12.4,数据落在命名卷qdrantdata(容器内/qdrant/storage)。 - Qdrant 对每个请求都要求 API key(
QDRANT__SERVICE__API_KEY)。栈内qdrant与app读取同一个.env变量QDRANT_API_KEY,双方共享默认值aivory-internal-qdrant,所以零配置也能工作。由于 Qdrant 不发布任何宿主端口、只在私有网络内可达,这个共享默认值是可接受的;仍建议在.env里覆盖为强随机值。 - 该服务没有定义 healthcheck,所以
app对它只等service_started。Qdrant 短暂不可用不会导致功能中断:向量检索失败时,RAG 会回退为注入全文上下文。 - Collection 按 embedding 维度命名为
aivory_c<维度>(例如 1536 维模型对应aivory_c1536)。
sandbox:代码执行沙箱控制面
沙箱随栈内置,一条 docker compose up 就带起来,无需单独项目或额外配置密钥。它的完整安全模型与调参说明见代码沙箱部署,这里只讲 compose 层面的行为:
- 该服务是一个 sidecar 控制面:通过挂载的宿主
/var/run/docker.sock在宿主 Docker 守护进程上派生每会话一个的受限容器,会话运行时镜像为ghcr.io/<IMAGE_OWNER>/aivory-sandbox,并在启动时预拉取(SANDBOX_PULL_ON_START: "1"),保证首次调用即可用。 - 不发布任何宿主端口:只有
app通过私有网络的http://sandbox:8000访问它。挂载docker.sock等价于宿主 root 权限,"不对外暴露端口"正是控制这一风险面的关键,请勿给它加ports映射。 - 控制面容器自身有资源上限:
mem_limit: 1g、pids_limit: 512。会话容器的资源由环境变量控制:
| 变量 | 默认值 | 含义 |
|---|---|---|
SANDBOX_NETWORK | none | 会话容器无网络;仅当沙箱内代码需要联网时改为 bridge |
SANDBOX_MEMORY | 2g | 单个会话容器内存上限 |
SANDBOX_CPUS | 1 | 单个会话容器 CPU 配额 |
SANDBOX_MAX_SESSIONS | 16 | 并发会话容器数上限 |
SANDBOX_EXEC_TIMEOUT_CAP_MS | 600000 | 单次执行时长硬上限(10 分钟),管理后台的超时设置会被钳制到此值以内 |
SANDBOX_IDLE_TTL_CAP_SECONDS | 86400 | 空闲回收窗口硬上限(24 小时),同样钳制管理后台设置 |
SANDBOX_WORKSPACE_SIZE | 512m | 会话工作区容量 |
SANDBOX_READ_ONLY_ROOTFS | 1 | 会话容器根文件系统只读 |
- 工作区持久化:会话被回收时,
/workspace会打包归档到命名卷sandbox-archives(按对话 ID 键控),同一对话再次运行代码时自动恢复。这套本地归档零配置可用,但仅限单机;多副本部署必须改用 S3/OSS 类对象存储。 - 健康检查:每 30s 用 Python
urllib探测容器内http://localhost:8000/healthz(镜像里除 Python 外没有其它探测工具),超时 10s,重试 3 次,start_period为 120s。这个宽限期覆盖的是冷启动时拉取会话运行时镜像的耗时,拉取完成前服务不开始受理请求。
数据持久化与备份
数据都在哪里
| 卷 / 挂载 | 容器路径 | 内容 | 丢失后果 |
|---|---|---|---|
pgdata(命名卷) | /var/lib/postgresql/data | 全部关系型数据:用户、对话、知识库、配置 | 灾难性,必须备份 |
qdrantdata(命名卷) | /qdrant/storage | RAG 向量 | 可从数据库中的 chunk 文本重建,但要重新消耗嵌入 API 调用 |
redisdata(命名卷) | /data | 缓存、限流计数器(AOF) | 影响很小,重启后自然重建 |
sandbox-archives(命名卷) | /var/lib/aivory/sandbox-archives | 各对话的沙箱工作区归档 | 对应对话的沙箱文件丢失,不影响对话本身 |
DATA_DIR(宿主目录绑定挂载,默认 ./data) | /app/data | 上传文件、生成产物、备份归档(backups/ 子目录) | 用户上传与产物丢失,必须备份 |
-v 会连同命名卷一起删除,pgdata、qdrantdata 等全部数据当场清空且不可恢复。日常停止服务用 docker compose -f docker-compose.prod.yml down(不带 -v)或 stop。
备份策略
推荐两条路线并行:
- 应用级备份(首选):管理后台的 Backup & Migration 页面可异步生成全量迁移 ZIP,内含引擎中立的数据库逻辑备份(每表一个 JSONL)、可选的 uploads/artifacts 文件,以及可选的 Qdrant 向量数据。生成的归档存放在
BACKUP_DIR(容器内默认/app/data/backups,宿主机上对应DATA_DIR/backups),把它拷走异地保存即可。这种备份可以跨引擎恢复(比如恢复到 SQLite 部署),详见备份与迁移。 - 基础设施级冷备:停止整个栈后,把所有命名卷和
DATA_DIR目录一起快照/拷贝。必须一起备份,才能保证数据库行、向量和磁盘文件三者一致。
备份导入的大小上限由 MAX_BACKUP_BYTES 控制,默认 20 GiB;含向量的全量归档可能很大,必要时上调。
预构建镜像与本地构建
app 和 sandbox 两个服务同时声明了 image: 和 build:。Compose 的行为是:镜像存在(或可拉取)时优先用镜像,加 --build 参数时本地构建。同一份 compose 文件因此服务于两种流程,拓扑不用写两遍。
方式一:拉取预构建镜像(推荐用于生产)
cd deploy
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
方式二:从源码本地构建(用于二次开发或官方镜像未覆盖的架构)
cd deploy
docker compose -f docker-compose.prod.yml up -d --build
镜像来源由 .env 中两个变量控制:
| 变量 | 默认值 | 说明 |
|---|---|---|
IMAGE_OWNER | hjxwz123 | ghcr.io 命名空间;fork 到自己账号后改成自己的用户名 |
IMAGE_TAG | latest | 镜像标签;生产环境建议固定到具体版本标签,升级和回滚都更可控 |
本地构建时,app 的构建上下文是仓库根目录:Dockerfile.app 是一个三阶段构建,先用 Node 20 构建 Vite SPA(在构建机原生架构上执行,产物是架构无关的静态文件),再用 Go 1.24 编译 API 二进制(需要 CGO,因为二进制内嵌了 SQLite 驱动作为开发/回退后端),最后打进 debian:bookworm-slim 运行时镜像。
资源建议
| 场景 | CPU | 内存 | 说明 |
|---|---|---|---|
| 最小可用(试用、个位数用户) | 2 核 | 4 GB | 完整栈可以跑,但沙箱并发要收紧 |
| 常规团队使用 | 4 核 | 8 GB | 覆盖日常对话 + RAG + 少量并发代码执行 |
| 代码执行 / 深度研究重度使用 | 4 核以上 | 16 GB 以上 | 按沙箱并发量向上加 |
估算沙箱的内存开销:每个会话容器上限 SANDBOX_MEMORY(默认 2g),并发上限 SANDBOX_MAX_SESSIONS(默认 16),极端情况下仅沙箱就可能吃掉 32 GB。小内存机器请务必在 .env 里调低这两个值(例如 SANDBOX_MAX_SESSIONS=4),沙箱控制面自身另占最多 1 GB(mem_limit)。
磁盘方面,pgdata、qdrantdata 和 DATA_DIR 随使用量增长,建议放在 SSD 上并预留监控;Qdrant 向量和上传文件通常是增长最快的两块。
日志查看
cd deploy
# 跟踪 app 日志(最常用)
docker compose -f docker-compose.prod.yml logs -f app
# 只看最近 200 行
docker compose -f docker-compose.prod.yml logs --tail=200 app
# 看最近一小时所有服务的日志
docker compose -f docker-compose.prod.yml logs --since=1h
# 单看沙箱(排查代码执行问题时)
docker compose -f docker-compose.prod.yml logs -f sandbox
# 查看某个容器健康检查的探测输出
docker inspect --format='{{json .State.Health}}' aivory-app-1 | jq
常用运维命令
cd deploy
# 查看各服务状态(重点看 STATUS 列的 healthy / unhealthy)
docker compose -f docker-compose.prod.yml ps
# 重启单个服务
docker compose -f docker-compose.prod.yml restart app
# 进入 Postgres 交互式命令行
docker compose -f docker-compose.prod.yml exec postgres psql -U aivory -d aivory
# 验证 Redis 连通性(密码从容器自身的环境变量读取)
docker compose -f docker-compose.prod.yml exec redis sh -c 'redis-cli -a "$REDIS_PASSWORD" ping'
# 打开 app 容器 shell
docker compose -f docker-compose.prod.yml exec app sh
# 从宿主机探测健康端点
curl -fsS http://localhost/api/health
# 查看各命名卷占用的磁盘
docker system df -v | grep aivory
psql 内常用的几条自检查询:
\dt -- 表清单
SELECT count(*) FROM users; -- 用户数
\q -- 退出
升级流程
数据库结构在 app 启动时自动迁移,升级本身就是"拉新镜像 + 重建容器",但升级前请先备份:
cd deploy
# 1. 备份:在管理后台 Backup & Migration 导出全量备份,
# 并确认归档已生成在 ./data/backups/ 下(拷贝一份到异地更稳妥)
# 2. 拉取新镜像(IMAGE_TAG 固定了版本的话,先在 .env 里改成目标版本)
docker compose -f docker-compose.prod.yml pull
# 3. 滚动重建(只重建镜像有变化的容器)
docker compose -f docker-compose.prod.yml up -d
# 4. 验证
docker compose -f docker-compose.prod.yml ps
curl -fsS http://localhost/api/health
docker compose -f docker-compose.prod.yml logs --tail=100 app
IMAGE_TAG=latest 意味着每次 pull 都可能拿到新版本,升级时机不可控。生产环境建议把 IMAGE_TAG 固定到具体版本标签,升级时显式改值;回滚时把 IMAGE_TAG 改回旧版本再 up -d 即可(数据库结构迁移是向前的,回滚跨大版本前请先确认备份可用)。
常见故障排查
| 症状 | 可能原因 | 处理 |
|---|---|---|
docker compose up 直接报错 set JWT_SECRET in .env(或 POSTGRES_PASSWORD / REDIS_PASSWORD 同类报错) | .env 缺少必填变量,compose 的 :? 校验拦截 | cp .env.example .env 后逐项填写;openssl rand -hex 32 生成 JWT_SECRET |
app 启动即退出,日志提示 JWT_SECRET 问题 | AIVORY_ENV=production 下 JWT_SECRET 必须至少 32 字符,占位值或过短会被拒绝启动 | 换成 openssl rand -hex 32 的输出后 up -d |
app 反复重启,日志报数据库认证/连接失败 | POSTGRES_PASSWORD 含特殊字符(@ : / # % & 等)破坏了拼接出的连接 URL;或 $ 被 compose 变量插值吞掉;或改过密码但 pgdata 卷里还是旧密码 | 密码一律用 openssl rand -hex 24(纯十六进制,无特殊字符);已初始化的库改密码需在 psql 内 ALTER USER |
sandbox 显示 unhealthy(尤其是首次启动) | 冷启动要拉取会话运行时镜像 aivory-sandbox,网络慢时超过 120s 的 start_period | 看 logs -f sandbox 确认是否仍在拉取;可预先 docker pull ghcr.io/hjxwz123/aivory-sandbox:latest;app 不受影响,只有代码执行暂不可用 |
app 日志出现 Qdrant 401 / unauthorized | app 的 QDRANT_API_KEY 与 Qdrant 侧的 key 不一致(通常发生在接外部 Qdrant 集群,或只改了一侧的 key) | 两侧设为同一个值;栈内部署时二者读同一个 .env 变量,改完 up -d 重建即可 |
端口 80 被占用,app 起不来 | 宿主 80 已有其它服务 | 修改 compose 文件中的 "80:8787" 左侧端口 |
| 对话正常但代码执行报错 | 沙箱是软依赖,app 不会因它挂掉 | 检查 sandbox 健康状态、/var/run/docker.sock 是否存在且可用 |
| RAG 检索效果突然变差(退化为全文注入) | Qdrant 不可用或为空,应用自动走全文回退 | 检查 qdrant 容器状态与 app 日志,恢复后自动回到向量检索 |
下一步
- 反向代理与 HTTPS:在栈前面加 TLS 终止层。
- Cloudflare 接入:套 CDN 时的注意事项。
- 代码沙箱部署:沙箱的安全模型与深度调参。
- 轻量部署(SQLite):单机小规模场景的极简替代方案。
- 首次运行配置:初始化管理员与添加模型渠道。