一、为什么需要编排?—— 手动部署的崩溃瞬间

上周五下午,我把一个包含4个微服务的项目部署到测试环境。按照老办法:先装PostgreSQL,再启动Redis,接着跑Spring Boot jar包,最后配Nginx反向代理。结果呢?数据库还没初始化完成,应用已经连不上库;Redis缓存没预热,接口响应超时;Nginx upstream配置写错一个端口,整个网关504。花了40分钟排查,最后发现是启动顺序和网络配置的锅。

这不是偶然。当服务数量超过3个,手动部署的复杂度呈指数级上升。尤其是启动顺序——现代应用启动时依赖数据库schema、缓存预热、配置中心,任何一个环节没就绪,应用就会启动失败或疯狂重试。Docker Compose的depends_on条件、健康检查、自定义网络,正是解决这类问题的标准方案。

二、环境与版本 —— 别用老版本踩坑

先交代我的环境:

  • Docker Engine: 24.0.7(已开启BuildKit)
  • Docker Compose: v2.24.2(独立二进制,非docker-compose v1)
  • 宿主机: Ubuntu 22.04 LTS, 8核16G
  • 项目技术栈: Spring Boot 3.2.1 + PostgreSQL 15.3 + Redis 7.0.12 + Nginx 1.25.3

注意:Docker Compose v2和v1的语法有差异,v2支持depends_on.condition: service_healthy,v1不支持。如果你还在用v1,建议立刻迁移。

三、方案设计 —— 网络分段、卷挂载、健康检查三位一体

我们的业务场景是:一个电商后台系统,包含用户服务(Spring Boot)、订单服务(Spring Boot)、商品服务(Spring Boot),共享一个PostgreSQL和Redis。Nginx作为统一入口做负载均衡。

设计思路:

  1. 网络分段:创建两个bridge网络——frontend_netbackend_net。Nginx只接入frontend_net,业务服务同时接入两个网络,数据库和Redis只接入backend_net。这样前端无法直接访问数据库,安全隔离。

  2. 卷挂载:PostgreSQL数据目录挂载到宿主机/data/postgres,Redis持久化文件挂载到/data/redis。容器删了数据还在,升级不丢数据。

  3. 健康检查:每个服务配置healthcheck。数据库用pg_isready命令,Redis用redis-cli ping,Spring Boot用curl /actuator/health。健康检查间隔30秒,超时10秒,最多重试5次。

  4. 启动顺序:用depends_oncondition: service_healthy确保数据库和Redis先就绪,再启动业务服务。Nginx最后启动,保证upstream后端全部健康。

四、核心实现 —— 完整docker-compose.yml解析

先看完整配置文件(已脱敏),然后逐段拆解:

version: "3.8"

networks:
  frontend_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/24
  backend_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.21.0.0/24

volumes:
  postgres_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /data/postgres
  redis_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /data/redis

services:
  postgres:
    image: postgres:15.3-alpine
    container_name: ecommerce-postgres
    restart: unless-stopped
    networks:
      - backend_net
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: ecom_user
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ecommerce
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ecom_user -d ecommerce"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

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

  user-service:
    build: ./user-service
    image: ecommerce/user-service:1.2.0
    container_name: ecommerce-user
    restart: unless-stopped
    networks:
      - frontend_net
      - backend_net
    environment:
      SPRING_PROFILES_ACTIVE: docker
      DB_HOST: postgres
      DB_PORT: 5432
      DB_NAME: ecommerce
      DB_USER: ecom_user
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: 6379
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 60s

  nginx:
    image: nginx:1.25.3-alpine
    container_name: ecommerce-nginx
    restart: unless-stopped
    networks:
      - frontend_net
    ports:
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    depends_on:
      user-service:
        condition: service_healthy

1. 网络配置详解

frontend_netbackend_net分别占用不同的子网,避免IP冲突。Nginx只挂frontend_net,即使Nginx被攻破,也无法直接连通数据库。业务服务挂双网,通过服务名(postgresredis)而非IP地址访问后端,Compose内置DNS解析。

2. 卷挂载的坑

这里用了bind mount方式绑定宿主机的/data/postgres/data/redis。注意driver_opts里的type: noneo: bind,如果不写,Docker会创建volume而不是直接绑定目录。我用bind mount是因为要配合宿主机的备份任务(cron直接备份目录),如果你用云盘,建议用driver: local默认配置,数据存在/var/lib/docker/volumes/下。

3. 健康检查细节

PostgreSQL的pg_isready命令需要指定用户名和数据库名,否则可能误判。Redis的健康检查要注意密码注入——用$$REDIS_PASSWORD转义,避免被compose解析为环境变量。

Spring Boot的健康检查依赖spring-boot-starter-actuator,需要在application.yml里暴露health端点:

management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: always

4. 启动顺序的完整闭环

depends_onservice_healthy条件确保:只有当PostgreSQL和Redis都健康后,user-service才会启动。start_period参数给了应用60秒的初始化时间,避免刚启动时的健康检查误报。

五、踩坑记录 —— 这三个坑浪费了我一个下午

坑1:depends_on不含restart语义

如果你在服务A中depends_on服务B,B崩溃后重启,A不会自动重启。需要在A上配置restart: unless-stopped,同时确保B的restart策略也是unless-stopped,否则B挂了A就永远等不到健康的B。

坑2:Redis密码在健康检查中的转义

redis-cli -a $$REDIS_PASSWORD ping,如果写成$REDIS_PASSWORD,Compose会把它当作宿主机环境变量展开,导致密码为空。$$才是转义后的字符串$REDIS_PASSWORD,在容器内解析。这个坑我排查了半小时,最后用docker inspect看容器环境变量才定位。

坑3:bind mount目录权限

如果宿主机/data/postgres的属主不是UID 999(PostgreSQL官方镜像的默认用户),容器启动会报权限错误。解决:chown -R 999:999 /data/postgres。Redis同理,UID是999。

六、效果数据 —— 对比手动部署

部署完这套编排后,我做了两组对比测试:

指标 手动部署 Docker Compose
冷启动时间(含镜像拉取) 4分32秒 58秒(镜像已缓存)
服务可用性(7天) 99.2% 99.95%
故障恢复时间(数据库宕机) 8分钟(手动排查) 45秒(自动重启+健康检查)
环境配置一致性 低(容易漏配) 高(声明式配置)

另外,docker compose config可以离线校验配置合法性,docker compose up -d可以一键启动,docker compose logs -f可以聚合查看所有服务日志。这些在手动部署时都需要额外写脚本。

七、总结与建议

Docker Compose不是万能的,它适合单机多容器编排。如果你的服务超过10个,或者需要跨主机部署,建议上Kubernetes。但在单机环境下,Compose配合健康检查和depends_on条件,已经能解决90%的编排痛点。

最后给三个建议:

  1. 所有服务必须配置healthcheck,否则depends_onservice_healthy形同虚设。
  2. 敏感信息用.env文件管理,不要硬编码在docker-compose.yml里。我上面的${DB_PASSWORD}就是读取.env文件。
  3. 生产环境加logging配置,限制日志大小防止磁盘爆掉。比如:
logging:
  driver: json-file
  options:
    max-size: "50m"
    max-file: "3"

好了,这次的编排方案就分享到这里。如果你也遇到过启动顺序的坑,或者有更好的健康检查写法,欢迎在评论区讨论。