《AI Agent 工程实战》16.2 本地编排:compose 起 API/worker/postgres/redis

上一节做出了 ResearchCopilot 的镜像,但一个镜像撑不起一个服务——它还要数据库、缓存和后台 worker。本节写 docker-compose.yml 编排这四个服务,讲清 depends_on 不等于「依赖就绪」(须配 healthcheck)、同一镜像靠 command 扮演 api/worker 两种角色。真跑 config 与 ps 起栈验证。

16.2 本地编排:compose 起 API/worker/postgres/redis

16.1 把 ResearchCopilot 打成了一个 246 MB 的镜像,docker run 一下 API 就起来了。但一个 API 容器不是一个服务——它还要连 Postgres 存会话、连 Redis 做缓存与队列、还要有一个后台 worker 跑周期 digest。这些邻居如果一个个手动 docker run,端口、网络、启动顺序全得自己记,稍不留神就「API 比数据库先起来,一启动就崩」。

本节把 ResearchCopilot 推进到「一条命令起全栈」:写一份 docker-compose.yml 编排 api / worker / postgres / redis 四个服务,重点讲清 depends_on 与「依赖就绪」的区别,并在本机真起栈验证。

16.2.1 compose 解决什么:把「一串 docker run」变成一份声明

四个服务手动起大概是这个样子:先 docker network create、再按顺序 docker run 起 postgres 和 redis、等它们 ready、再起 api 和 worker、还要挂 volume、传一堆 -e 环境变量。这套命令写进 README 就会腐坏——谁改了端口、谁忘了加 -e,没人知道。

compose 的模型很简单:你在 YAML 里声明「我要哪些服务、它们长什么样、谁依赖谁」,剩下的交给工具。它负责建网络、建卷、按依赖顺序启动、给服务起可解析的 DNS 名(服务名即主机名)。同一份文件,开发机、CI、单机 staging 都能用。

16.2.2 一份 compose 文件:四个服务

ResearchCopilot 的 docker-compose.yml:

name: researchcopilot

services:
  api:
    image: researchcopilot:0.1.0
    build: .
    command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
    ports:
      - "18000:8000"
    environment:
      DATABASE_URL: postgresql://postgres:postgres@postgres:5432/research
      REDIS_URL: redis://redis:6379/0
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')"
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  worker:
    image: researchcopilot:0.1.0
    command: ["python", "-m", "app.worker"]
    environment:
      DATABASE_URL: postgresql://postgres:postgres@postgres:5432/research
      REDIS_URL: redis://redis:6379/0
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: research
    ports:
      - "15436:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d research"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7-alpine
    ports:
      - "16381:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:

四个服务各有分工:api 对外暴露 18000、worker 只对内、postgres 与 redis 是有状态的基础设施。注意 DATABASE_URL 里的主机名是 postgres、REDIS_URL 里是 redis——这正是 compose 建的默认网络里,服务名被自动解析成容器 IP。宿主机的端口映射(15436/16381)只是为了让你能从本机 psql/redis-cli 连进去,容器之间走的是内部网络、用默认端口。

16.2.3 depends_on 不等于「依赖就绪」

这是 compose 里最大的一个坑,值得单独一节。很多人以为写了 depends_on: [postgres] 就万事大吉,其实默认的 depends_on 只管「启动顺序」,不管「能不能用」:它让 postgres 容器开始启动,然后立刻启动 api——可 postgres 从「容器起来」到「能接受连接」之间隔着初始化数据目录、启动进程、监听端口好几秒。API 在这个窗口里连数据库,直接 connection refused,然后崩溃退出。

正确做法是给基础设施加 healthcheck,再用 condition: service_healthy 让依赖方等到健康才启动:

写法语义后果
depends_on: [postgres]等 postgres 容器创建可能连不上,随机崩
depends_on: {postgres: {condition: service_started}}等容器进程启动仍可能连不上
depends_on: {postgres: {condition: service_healthy}}等 healthcheck 通过连得上,稳

healthcheck 本身是个「怎么算健康」的定义:test 是探测命令、interval 是探测间隔、timeout 是单次超时、retries 是连续失败几次算不健康。postgres 的 pg_isready 会在数据库真正能接受连接时才返回 0;redis 的 redis-cli ping 返回 PONG 才算通。把「就绪」这件事交给一个明确的探测命令,而不是靠 sleep 10 拍脑袋,是这套机制的价值。

16.2.4 同一镜像两种角色:api 与 worker 靠 command 区分

细看 api 和 worker,你会发现它们用同一个镜像 researchcopilot:0.1.0,只有 command 不同——一个跑 uvicorn,一个跑 python -m app.worker。这是有意的设计:

  • 代码完全共享。两者都要连 Postgres、都要调 minagent 引擎、都要用同一套配置解析。共镜像意味着依赖只有一份、构建只做一次,不会出现「api 的依赖装了、worker 忘了装」的漂移。
  • 差别只有入口。API 是「请求-响应」的长驻 HTTP 进程;worker 是「消费队列」的常驻循环。它们本就是同一份代码的两个入口,用 command 表达最自然。
  • 升级原子。改一行共享代码,两个服务同时换到新镜像,不存在版本错配。

反面做法是给 worker 单独写一个 Dockerfile——它会把同一份依赖再装一遍,两个镜像各自演化,很快就开始不一致。「一个镜像、多个入口」是十二要素应用里「进程」这一条的直接体现。

16.2.5 volume 与网络:有状态与无状态分开

两件基础设施相关的事必须讲清:

volume 管持久化。 postgres 挂了 pgdata:/var/lib/postgresql/data,容器删了数据还在卷里。不挂卷的数据库容器是一次性的——docker compose down 一执行,所有会话、消息、记忆全没了。这也是为什么本节清理时用的是 down -v(连卷一起删),演示完就该干净收场,而生产里 -v 要慎用。redis 这里没挂卷,因为它的定位是缓存/队列,本来就允许丢。

网络管隔离。 compose 默认给项目建一个 bridge 网络(名字是 researchcopilot_default),四个服务都接进去。只有显式写了 ports 的服务才暴露到宿主机——api 暴露 8000、postgres 暴露 5432、redis 暴露 6379,而 worker 一个端口都不暴露,它只主动去连别人。「需要被谁访问,才暴露什么」,默认封闭、按需开口。

16.2.6 真跑:config 校验、起栈与健康检查

本机的 compose 是独立的 docker-compose 可执行文件(v5.5.0),docker compose 子命令没接上插件(会报 unknown command),所以下面一律用 docker-compose。先做纯静态校验——config 会解析 YAML、展开变量、算出最终生效的配置,不起任何容器:

$ docker-compose config
name: researchcopilot
services:
  api:
    ...
    depends_on:
      postgres:
        condition: service_healthy
        required: true
      redis:
        condition: service_healthy
        required: true
    image: researchcopilot:0.1.0
    ports:
      - mode: ingress
        target: 8000
        published: "18000"
        protocol: tcp
  postgres:
    healthcheck:
      test:
        - CMD-SHELL
        - pg_isready -U postgres -d research
      timeout: 3s
      interval: 5s
      retries: 10
    image: postgres:17-alpine
    ports:
      - mode: ingress
        target: 5432
        published: "15436"
        protocol: tcp
    volumes:
      - type: volume
        source: pgdata
        target: /var/lib/postgresql/data
        volume: {}

校验通过(无报错、退出码 0),config 把 depends_on 展开成了带 condition: service_healthy 的完整形式——这就是最终会被执行的配置。

接着真起栈。用非默认端口(15436/16381),避免和本机已有的容器撞车。先只起两个基础设施:

$ docker-compose up -d postgres redis
 Container researchcopilot-redis-1 Creating
 Container researchcopilot-postgres-1 Creating
 Container researchcopilot-postgres-1 Started
 Container researchcopilot-redis-1 Started

$ docker-compose ps
NAME                         IMAGE                SERVICE    STATUS
researchcopilot-postgres-1   postgres:17-alpine   postgres   Up 12 seconds (healthy)
researchcopilot-redis-1      redis:7-alpine       redis      Up 12 seconds (healthy)

两个服务都到了 (healthy)——healthcheck 确实在跑。现在再起全部服务,观察启动顺序:

$ docker-compose up -d
 Container researchcopilot-redis-1 Healthy
 Container researchcopilot-postgres-1 Healthy
 Container researchcopilot-worker-1 Starting
 Container researchcopilot-api-1 Starting
 Container researchcopilot-worker-1 Started
 Container researchcopilot-api-1 Started

这份日志就是「depends_on + service_healthy 生效」的铁证:compose 先等 postgres 和 redis 报 Healthy,之后才 Starting api 与 worker。如果没有 healthcheck,api 会紧跟 postgres 的 Starting 一起启动,正好落在那个「还没 ready」的窗口里。再 ps 一次,四个服务全绿:

$ docker-compose ps
NAME                         IMAGE                   SERVICE    STATUS
researchcopilot-api-1        researchcopilot:0.1.0   api        Up 12 seconds (healthy)
researchcopilot-postgres-1   postgres:17-alpine      postgres   Up 45 seconds (healthy)
researchcopilot-redis-1      redis:7-alpine          redis      Up 45 seconds (healthy)
researchcopilot-worker-1     researchcopilot:0.1.0   worker     Up 12 seconds

$ curl -s http://127.0.0.1:18000/healthz
{"status":"ok"}

$ docker-compose logs --no-log-prefix worker
worker started

api 也是 (healthy)(它自己的 healthcheck 打 /healthz 通了),worker 打印出 worker started。最后清理,演示不留痕:

$ docker-compose down -v
 Container researchcopilot-postgres-1 Removed
 Container researchcopilot-redis-1 Removed
 Volume researchcopilot_pgdata Removed
 Network researchcopilot_default Removed

16.2.7 验收清单

  • 基础设施服务(postgres / redis)都配了 healthcheck。
  • 依赖方用 condition: service_healthy,不是裸 depends_on。
  • api / worker 共镜像,靠 command 区分角色。
  • 有状态服务(postgres)挂了 volume;只对外暴露必要的端口。
  • 连接串里用服务名(postgres / redis)当主机名,不是 localhost。
  • docker-compose config 通过;起栈后 ps 全 (healthy);演示完 down -v 清理。

真实环境里会怎样:本机的 docker-compose 是独立二进制;很多发行版已经把 compose 做成 docker compose 子命令,两者 YAML 格式一致、命令几乎一样,只是调用名不同。生产里这套 compose 通常只在本地和单机 staging 用,真正的编排交给 Kubernetes(depends_on 会被 readiness probe 取代)。另外 healthcheck 的 interval/retries 要按服务启动耗时调——数据库首次初始化可能十几秒,retries 太小会误判不健康。下一节把「健康检查」这件事从 compose 层面下沉到应用内部:liveness 与 readiness 的区别、配置注入、以及收到 SIGTERM 后怎么优雅退出。

阅读导航:上一节:16.1 TS 落点三:Python + Node 双栈镜像瘦身 · 下一节:16.3 配置注入、健康检查与优雅退出 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距