跳到主要内容

Docker Compose 生产部署

本页深入讲解 Aivory 的生产 compose 文件 deploy/docker-compose.prod.yml:每个服务的职责与健康检查、数据卷与备份策略、预构建镜像与本地构建的切换、资源规划、日常运维命令、升级流程,以及常见故障的排查表。如果你只想尽快把服务拉起来,请先看快速部署,本页假设你已经完成了首次部署。

架构总览

整套栈由 5 个服务组成,全部挂在一个私有 bridge 网络 internal 上。只有 app 发布宿主端口(默认 80:8787),其余服务(包括 Qdrant 和沙箱)不对外暴露任何端口,只能在私有网络内被访问。

服务镜像宿主端口职责
appghcr.io/<IMAGE_OWNER>/aivory-app80(可改)单容器同源伺服构建后的 SPA 与 /api 后端
postgrespostgres:16-alpine关系型存储:用户、对话、知识库、用量等
redisredis:7-alpine缓存、限流计数器、跨进程停止流式输出的 pub/sub
qdrantqdrant/qdrant:v1.12.4RAG 向量检索
sandboxghcr.io/<IMAGE_OWNER>/aivory-sandbox-sidecar内置代码执行沙箱控制面,仅内网可达
同源架构,零域名配置

app 容器内的 Go 进程同时伺服 SPA 静态文件和 /api,前后端天然同源。因此不存在跨域问题,也不需要配置 PUBLIC_ORIGINALLOWED_ORIGINS 或任何域名相关变量:代理把请求转发到容器,哪个域名进来哪个域名就能用,多域名同时指向也没问题。公网部署时只需在前面放一层 TLS 终止(见反向代理与 HTTPS)。

服务详解

app:应用主容器

  • 容器内监听 8787(AIVORY_LISTEN: ":8787"),同时伺服 SPA 与 /api。SPA 目录在构建镜像时已内置(STATIC_DIR 指向镜像内的构建产物),无需单独的 nginx/web 层。
  • 端口映射写死在 compose 文件里:"80:8787"。宿主 80 被占用时,直接改左边的数字(例如 "8080:8787"),不需要任何环境变量。
  • 启动依赖:postgresredis 必须通过健康检查(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_ENVproduction触发部署级安全校验(如 JWT_SECRET 强制)
DATABASE_URLpostgres://<user>:<password>@postgres:5432/<db>?sslmode=disable.env 中的 Postgres 变量拼接而成
REDIS_URLredis://:<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_URLhttp://sandbox:8000走私有网络访问内置沙箱
SANDBOX_API_KEY默认 aivory-bundled-sandbox与沙箱服务共享的内部默认值
ENABLE_MOCK_PROVIDER默认 falsetrue 可启用内置演示模型,无需真实 API key
模型 API key 不在 .env 里

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 官方镜像只在首次初始化时应用密码

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)。栈内 qdrantapp 读取同一个 .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: 1gpids_limit: 512。会话容器的资源由环境变量控制:
变量默认值含义
SANDBOX_NETWORKnone会话容器无网络;仅当沙箱内代码需要联网时改为 bridge
SANDBOX_MEMORY2g单个会话容器内存上限
SANDBOX_CPUS1单个会话容器 CPU 配额
SANDBOX_MAX_SESSIONS16并发会话容器数上限
SANDBOX_EXEC_TIMEOUT_CAP_MS600000单次执行时长硬上限(10 分钟),管理后台的超时设置会被钳制到此值以内
SANDBOX_IDLE_TTL_CAP_SECONDS86400空闲回收窗口硬上限(24 小时),同样钳制管理后台设置
SANDBOX_WORKSPACE_SIZE512m会话工作区容量
SANDBOX_READ_ONLY_ROOTFS1会话容器根文件系统只读
  • 工作区持久化:会话被回收时,/workspace 会打包归档到命名卷 sandbox-archives(按对话 ID 键控),同一对话再次运行代码时自动恢复。这套本地归档零配置可用,但仅限单机;多副本部署必须改用 S3/OSS 类对象存储。
  • 健康检查:每 30s 用 Python urllib 探测容器内 http://localhost:8000/healthz(镜像里除 Python 外没有其它探测工具),超时 10s,重试 3 次,start_period120s。这个宽限期覆盖的是冷启动时拉取会话运行时镜像的耗时,拉取完成前服务不开始受理请求。

数据持久化与备份

数据都在哪里

卷 / 挂载容器路径内容丢失后果
pgdata(命名卷)/var/lib/postgresql/data全部关系型数据:用户、对话、知识库、配置灾难性,必须备份
qdrantdata(命名卷)/qdrant/storageRAG 向量可从数据库中的 chunk 文本重建,但要重新消耗嵌入 API 调用
redisdata(命名卷)/data缓存、限流计数器(AOF)影响很小,重启后自然重建
sandbox-archives(命名卷)/var/lib/aivory/sandbox-archives各对话的沙箱工作区归档对应对话的沙箱文件丢失,不影响对话本身
DATA_DIR(宿主目录绑定挂载,默认 ./data)/app/data上传文件、生成产物、备份归档(backups/ 子目录)用户上传与产物丢失,必须备份
不要执行 docker compose down -v

-v 会连同命名卷一起删除,pgdataqdrantdata 等全部数据当场清空且不可恢复。日常停止服务用 docker compose -f docker-compose.prod.yml down(不带 -v)或 stop

备份策略

推荐两条路线并行:

  1. 应用级备份(首选):管理后台的 Backup & Migration 页面可异步生成全量迁移 ZIP,内含引擎中立的数据库逻辑备份(每表一个 JSONL)、可选的 uploads/artifacts 文件,以及可选的 Qdrant 向量数据。生成的归档存放在 BACKUP_DIR(容器内默认 /app/data/backups,宿主机上对应 DATA_DIR/backups),把它拷走异地保存即可。这种备份可以跨引擎恢复(比如恢复到 SQLite 部署),详见备份与迁移
  2. 基础设施级冷备:停止整个栈后,把所有命名卷和 DATA_DIR 目录一起快照/拷贝。必须一起备份,才能保证数据库行、向量和磁盘文件三者一致。

备份导入的大小上限由 MAX_BACKUP_BYTES 控制,默认 20 GiB;含向量的全量归档可能很大,必要时上调。

预构建镜像与本地构建

appsandbox 两个服务同时声明了 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_OWNERhjxwz123ghcr.io 命名空间;fork 到自己账号后改成自己的用户名
IMAGE_TAGlatest镜像标签;生产环境建议固定到具体版本标签,升级和回滚都更可控

本地构建时,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)。

磁盘方面,pgdataqdrantdataDATA_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=productionJWT_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_periodlogs -f sandbox 确认是否仍在拉取;可预先 docker pull ghcr.io/hjxwz123/aivory-sandbox:latest;app 不受影响,只有代码执行暂不可用
app 日志出现 Qdrant 401 / unauthorizedappQDRANT_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 日志,恢复后自动回到向量检索

下一步