一、问题背景:手工启动服务,我被依赖关系折磨了一下午

接手公司一个电商后台项目,架构不复杂:Spring Boot写的API服务、React构建的Nginx前端、Redis做缓存、PostgreSQL存订单、RabbitMQ处理异步任务。五个服务,听起来不多,但每次环境搭建都要命。

第一次部署,我按文档顺序手工启动:先PostgreSQL,再Redis,再RabbitMQ,然后启动API,最后Nginx。结果API启动时报错——数据库连接池初始化失败,因为PostgreSQL虽然进程起来了,但还没准备好接受连接。重启API,又发现RabbitMQ的队列没声明。反复折腾,一次完整启动花了15分钟,中间各种超时、重试、手动kill进程。

更要命的是,某个同事改了Redis密码配置,我本地没同步,API连不上缓存,整个登录功能挂掉。这时候我意识到,没有统一的编排工具,多服务环境就是一场灾难。

二、环境与版本:我的技术栈基线

先说清楚环境,避免读者踩版本坑:

  • Docker Engine:24.0.7(Linux内核5.15)
  • Docker Compose:v2.24.2(重点:v2和v1的语法有差异,本文全部基于v2)
  • 宿主机:Ubuntu 22.04 LTS,4核8G
  • 项目服务清单:
服务 镜像版本 端口 说明
api openjdk:17-jdk-alpine 8080 Spring Boot 3.1.5
web nginx:1.25.3 80 前端静态资源+反向代理
redis redis:7.2.3 6379 缓存+Session共享
db postgres:16.1 5432 主数据库
mq rabbitmq:3.12-management 5672/15672 消息队列+管理界面

代码仓库里原本只有Dockerfile,没有编排文件。我需要在项目根目录新增docker-compose.yml。

三、方案设计:网络隔离、卷持久化、健康检查、启动顺序四件套

设计思路,我按生产环境标准来,不搞本地开发那种裸奔配置:

1. 自定义网络划分
所有服务放在同一个bridge网络app_network内,但为了安全,把数据库和消息队列隔离在db_network内层网络,只有API能访问。前端Nginx只需要和API通信,Redis只被API使用。网络隔离的好处是:即使某个容器被攻破,攻击面也受限。

2. 命名卷挂载
PostgreSQL的数据目录、RabbitMQ的消息存储、Redis的持久化文件,全部用命名卷(named volume),不搞bind mount。原因:bind mount依赖宿主机目录结构,换机器就挂;命名卷由Docker管理,docker compose down不会删除数据,备份迁移也方便。

3. 健康检查(healthcheck)
每个依赖服务都配healthcheck,不信任“进程起来了就是健康”。PostgreSQL用pg_isready,Redis用redis-cli ping,RabbitMQ用rabbitmq-diagnostics -q ping。API服务本身也配健康检查,这样Nginx才能知道后端是否可用。

4. 启动顺序控制
Compose v2的depends_on不再只是等待容器启动,配合condition: service_healthy可以做到“等待健康检查通过后才启动下游”。这是解决我最初手工启动崩溃问题的关键。

四、核心实现:docker-compose.yml逐段拆解

直接上完整配置,文件放在项目根目录,所有服务共享同一个.env文件管理环境变量。先看网络和卷的定义:

# docker-compose.yml (第一部分:网络与卷)
version: "3.8"

networks:
  app_network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/24
  db_network:
    driver: bridge
    internal: true  # 关键:禁止外部访问,仅容器间通信

volumes:
  postgres_data:
    driver: local
  rabbitmq_data:
    driver: local
  redis_data:
    driver: local

services:
  # ...服务定义见下文

注意db_networkinternal: true,这意味着外部宿主机无法直接访问数据库端口,只能通过API容器中转。这是安全加固的重要一步。

接下来是完整服务定义:

# docker-compose.yml (第二部分:服务编排)
services:
  db:
    image: postgres:16.1
    container_name: ecommerce-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - db_network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

  redis:
    image: redis:7.2.3
    container_name: ecommerce-redis
    restart: unless-stopped
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    networks:
      - app_network
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  mq:
    image: rabbitmq:3.12-management
    container_name: ecommerce-mq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    networks:
      - db_network
      - app_network  # API需要访问,但管理端口不暴露
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  api:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: ecommerce-api
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
      mq:
        condition: service_healthy
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${DB_NAME}
      SPRING_DATASOURCE_USERNAME: ${DB_USER}
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PASSWORD: ${REDIS_PASSWORD}
      SPRING_RABBITMQ_HOST: mq
      SPRING_RABBITMQ_USERNAME: ${RABBITMQ_USER}
      SPRING_RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
    ports:
      - "8080:8080"
    networks:
      - app_network
      - db_network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  web:
    image: nginx:1.25.3
    container_name: ecommerce-web
    restart: unless-stopped
    depends_on:
      api:
        condition: service_healthy
    volumes:
      - ./frontend/dist:/usr/share/nginx/html:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    ports:
      - "80:80"
    networks:
      - app_network

有几个细节必须强调:

第一,API镜像的构建上下文。 build.context指向./backend,Dockerfile里多阶段构建,从maven镜像编译jar再拷贝到jre镜像。但这里有个坑:Compose构建时不会自动读取.dockerignore,如果backend目录里有target/.git,构建上下文会巨大,构建时间从30秒飙到3分钟。我在backend/.dockerignore里加了target.git*.log,构建时间降回35秒。

第二,depends_oncondition语法。 这是Compose v2的核心特性。如果你用的还是depends_on: - db这种列表写法,那只是等待容器启动(不健康也会启动),必须用上面的映射写法才能等到健康检查通过。

第三,.env文件。 所有密码、用户名通过项目根目录.env注入,不硬编码在yml里。.env文件内容示例:

DB_USER=ecommerce
DB_PASSWORD=Str0ngP@ssw0rd!
DB_NAME=order_db
REDIS_PASSWORD=r3d1sSecret
RABBITMQ_USER=admin
RABBITMQ_PASSWORD=rabbitPass

五、踩坑记录:三个教训让我改了三次配置

坑1:健康检查的start_period设太短。
一开始PostgreSQL的start_period设了5秒,interval设3秒。结果本地机器IO慢,PostgreSQL初始化数据目录花了8秒,10次重试全失败,API一直等不到service_healthy,整个堆栈卡在启动中。日志里全是“Container ecommerce-db is unhealthy”。
解决:start_period改成10秒,retries改成20,给足冷启动时间。实际上PostgreSQL首次初始化要跑initdb,耗时6-15秒不等,机械硬盘更慢。

坑2:RabbitMQ的healthcheck用ping命令,但容器里没有bash。
RabbitMQ镜像基于Alpine,默认shell是/bin/sh,不是bash。我最初写test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]就没问题,但有人会写成test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]——这其实是数组形式的exec调用,不经过shell,反而更安全。关键坑点是:rabbitmq-diagnostics命令在容器启动后需要几秒才能就绪,如果start_period设太短,前几次探测会误报失败。我最终设了30秒。

坑3:内部网络internal: true导致API容器无法访问外网。
db_network设了internal: true,这没错。但API容器同时挂了app_networkdb_network两个网络,而app_network没有设internal,所以API可以正常访问外网(比如调用第三方支付接口)。但如果你误把app_network也设成internal,API会连Maven仓库都访问不了,导致启动时拉取依赖失败。
检查方法:docker network inspect ecommerce_app_network查看Internal字段是否为false。

六、效果数据:启动时间从10分钟到45秒

改造完成后,我做了三轮对比测试,同一台机器,冷启动(docker compose down后再up):

场景 手工启动 Docker Compose
首次冷启动(无镜像缓存) 10分25秒 45秒(含镜像拉取)
二次启动(有缓存) 6分30秒 28秒
失败自动恢复 无此功能 健康检查失败后自动重启,平均恢复时间8秒
数据持久化 手动备份 命名卷自动管理,down不丢数据

实际运行中,某次PostgreSQL突然OOM被kill,Compose检测到容器退出,因为restart: unless-stopped自动重启,同时健康检查发现数据目录完好,30秒内服务恢复,API无需重启,用户无感知。

还有一个隐性的收益:新同事加入项目,只需要装Docker和Compose,然后cp .env.example .env && docker compose up -d,5分钟环境就绪。以前要读10页文档配环境,现在一个命令搞定。

七、总结:Compose编排不是写yaml,是设计系统

经过这次重构,我深刻体会到,docker-compose.yml不是简单的服务清单,而是对整个系统依赖关系、容错策略、安全边界的抽象。几个核心心得:

  1. 健康检查是编排的基石——没有它,depends_on只是摆设,容器启动顺序依然会乱。
  2. 网络分区要按信任级别划分——数据库和消息队列放内部网络,API做唯一入口,前端只暴露80端口。
  3. 命名卷是生产环境的底线——bind mount在本地开发可以,但生产环境一定要用命名卷,不然容器重建就是数据灾难。
  4. 版本不要追新,要稳定——Docker Compose v2.24这个版本我用了三个月没出问题,别急着升到v2.26,新特性往往伴随新bug。

最后给个建议:如果你的服务超过3个,别再手工启动了,花半小时写个compose文件,省下的时间够你喝一年咖啡。踩坑的细节我都写在上面了,照着配,基本一次过。