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 配置注入、健康检查与优雅退出 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。