一、问题背景:五个服务,一条命令起不来

项目是一个典型的中小型后端系统:Nginx 做反向代理,Web API(Node.js)处理业务,PostgreSQL 存主数据,Redis 做缓存和会话,另有一个 Worker 进程消费队列。开发环境原本是每人本地装一套,新人上手平均要折腾大半天——PG 版本不一致、Redis 端口冲突、环境变量漏配,问题五花八门。

迁到 Docker Compose 之后,第一版 docker-compose.yml 写得比较随意,结果 docker compose up 一跑,API 容器秒退,日志里清一色 ECONNREFUSED 5432。原因很简单:depends_on 默认只保证容器启动,不保证容器里的服务就绪。PostgreSQL 容器起来了,但 initdb 还没跑完,API 连上去自然被拒。

除此之外还有几个问题:

  • 所有容器挂在默认 bridge 网络上,Worker 能直接访问 Nginx,隔离性为零;
  • 数据库数据用 bind mount 挂到宿主机,容器内 UID 和宿主机不一致,PG 直接报 could not change permissions of directory;
  • 没有健康检查,docker compose ps 看到的 Up 状态具有欺骗性,出问题时排查全靠猜。

这篇文章就是把这一版配置彻底重写的过程记录,最终配置在团队内跑了三个月,冷启动稳定在 22 秒左右。

二、环境与版本

版本这东西必须写清楚,Compose 的 condition 语法在 v1 和 v2 里行为差异很大,网上很多老教程会把你带沟里。

组件 版本
Docker Engine 25.0.3
Docker Compose v2.24.5(plugin 形式,docker compose 而非 docker-compose)
Node.js 镜像 node:20.11.1-alpine3.19
PostgreSQL postgres:16.2-alpine
Redis redis:7.2.4-alpine
Nginx nginx:1.25.4-alpine

选 alpine 镜像主要是体积考虑,五个服务镜像加起来约 380MB,比 Debian 系省了一半多。注意 postgres:16-alpine 里没有 bash,健康检查的命令要用 sh 兼容写法。

三、方案设计

整体设计围绕四个点:

网络分层:建两个自定义 bridge 网络。frontend 网络只放 Nginx 和 API,backend 网络放 API、Worker、PG、Redis。Nginx 碰不到数据库,Worker 碰不到外网入口。API 同时挂两个网络,作为唯一的跨层通道。

卷策略:数据库和 Redis 的数据用命名卷(named volume),由 Docker 管理生命周期,避免权限问题;Nginx 配置和静态资源用 bind mount,方便开发时热更新;应用代码在开发环境用 bind mount 挂进去配合 nodemon,生产环境则打进镜像。

健康检查:PG 用 pg_isready,Redis 用 redis-cli ping,API 自己暴露一个 /healthz 端点。Nginx 和 Worker 不设健康检查——前者依赖上游,后者没有可探活的 HTTP 接口,用 depends_on 的 service_started 就够。

启动顺序:用 depends_on 的 condition: service_healthy,让 API 等到 PG 和 Redis 都 healthy 再启动,Worker 等 API healthy,Nginx 等 API started。

四、核心实现

先看完整的 docker-compose.yml,后面逐段解释。

name: myapp

x-common-env: &common-env
  NODE_ENV: production
  TZ: Asia/Shanghai

services:
  postgres:
    image: postgres:16.2-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${PG_PASSWORD:?err}
      POSTGRES_DB: appdb
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 15s
    deploy:
      resources:
        limits:
          memory: 512M

  redis:
    image: redis:7.2.4-alpine
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redisdata:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 5s

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    restart: unless-stopped
    environment:
       {
  const checks = { db: 'down', redis: 'down' };
  try {
    const r = await pool.query('SELECT 1');
    if (r.rowCount === 1) checks.db = 'up';
  } catch (e) { /* 保持 down */ }
  try {
    const pong = await redis.ping();
    if (pong === 'PONG') checks.redis = 'up';
  } catch (e) { /* 保持 down */ }

  const healthy = checks.db === 'up' && checks.redis === 'up';
  res.status(healthy ? 200 : 503).json({ status: healthy ? 'ok' : 'degraded', checks });
});

module.exports = router;

这个端点同时探 DB 和 Redis,比单纯返回 200 有意义——如果 DB 挂了,API 虽然进程活着但实际不可用,健康检查应该反映出来,让 depends_on 的下游(worker)别急着起。

五、踩坑与优化

坑一:depends_on 不等待健康状态。这是最常见的误解。只写 depends_on: [postgres] 等价于 condition: service_started,容器一创建就继续。必须显式写长语法加 condition: service_healthy,且被依赖的服务本身要有 healthcheck,否则 Compose 直接报错。

坑二:internal: true 用错了地方。一开始把 backend 网络设成 internal: true,想让数据库彻底断外网。结果 API 自己也在这个网络上,装 npm 包、调外部支付接口全失败。internal 是网络级别的,不是服务级别的。最后 backend 保持普通 bridge,靠"只有 API 同时挂两个网络"来实现隔离,效果够用。

坑三:bind mount 的 :ro 与 nodemon。开发时把 ./api/src 只读挂进去,nodemon 能监听文件变化,但某些工具(比如写 .cache 目录)会因为只读失败。解决办法是把缓存目录指到 /tmp,或者开发环境去掉 :ro。生产环境镜像里代码是 COPY 进去的,这个挂载根本不存在,所以不影响。

坑四:卷权限。早期用 ./data/pg:/var/lib/postgresql/data 这种 bind mount,宿主机目录属主是当前用户(UID 1000),容器内 postgres 用户是 UID 70,initdb 直接拒绝。换命名卷后 Docker 会自动用镜像里定义的属主初始化卷目录,问题消失。这条经验很值钱:有状态服务优先用命名卷,需要宿主机直接访问文件时才用 bind mount。

优化一:健康检查间隔调优。最初 interval: 30s,结果 PG 就绪后最多要等 30 秒 API 才启动,冷启动拖到 48 秒。调到 5s 后,实际等待时间基本等于 PG 真实初始化时间。代价是健康检查本身的开销,pg_isready 很轻,5 秒一次可以忽略。

优化二:start_period 与 retries 配合。API 的 start_period: 20s 覆盖了 Node 冷启动,之后 interval: 10s × retries: 5 意味着真正故障要 50 秒才判 unhealthy,够宽松,避免网络抖动误杀。

优化三:镜像构建缓存。api 和 worker 共用同一个 Dockerfile,Compose 会复用构建缓存,第二次 up --build 只重建变化层。Dockerfile 里把 COPY package*.json 和 npm ci 放在 COPY . . 之前,依赖没变就不重装,构建时间从 90 秒降到 12 秒。

六、效果数据

改造前后对比,测试机是 4C8G 的云主机,冷启动指 docker compose down -v 后重新 up -d 到所有服务 healthy:

指标 改造前 改造后
冷启动到全部 healthy 48s(靠 sleep 轮询) 22s
API 启动失败率(连不上 PG) 约 3 成 0
服务间调用 P99 延迟 11ms 8ms
docker compose ps 状态可信度 低(Up 不代表可用) 高(healthy 才可用)
新人上手时间 半天 10 分钟

P99 延迟的改善来自网络分层——之前所有容器在同一 bridge 上,广播流量多;拆成两个网络后,frontend 网络里只有 Nginx 和 API 的流量,干扰小了很多。这个数字是 500 QPS 压测下 wrk 测出来的,样本不算大,但趋势明确。

七、总结

Docker Compose 编排多服务的核心不在 YAML 写得多花哨,而在三件事:健康检查定义"就绪"、condition 控制"顺序"、网络和卷划清"边界"。depends_on 单独用基本等于没用,必须和 healthcheck 配套;有状态服务优先命名卷;网络隔离按"谁能访问谁"来切,别按服务数量切。

这套配置目前跑在开发、预发两套环境,生产用的是同构的 Compose 文件加 deploy.resources 限制。真要上大规模集群还是得上 K8s,但对十个服务以内的项目,Compose 的性价比没得说——一条 docker compose up -d 搞定全部,排查问题时 docker compose logs -f api 一目了然。配置放仓库里,改了就 review,比口头交接靠谱得多。