一、为什么需要精细编排?一个生产事故引发的思考

上个月我们团队在测试环境部署一套新开发的订单服务,docker-compose.yml 写得极其简单——只定义了服务、端口和镜像,没有任何健康检查和依赖控制。结果呢?Spring Boot 容器启动时 MySQL 还没就绪,Hibernate 建表语句全部执行失败,应用直接退出。我们用 restart: always 强行拉起来,但连接池已经缓存了错误的连接状态,导致后续请求大量超时。

更离谱的是,某次服务器重启后,MySQL 容器里的数据全丢了——因为当时用的匿名卷,容器重建后数据跟着没了。这两个事故直接促使我重新设计了整套编排方案。如果你也在用 Docker Compose 跑多服务项目,下面的配置建议直接抄作业。

二、环境与版本说明

先交代一下我的实际环境,避免版本差异导致配置不生效:

  • Docker Engine:24.0.7(Linux 内核 5.15.0)
  • Docker Compose:v2.23.3(注意:v1 的 version: 字段已废弃,本文不写)
  • 宿主机:Ubuntu 22.04 LTS,4核8G
  • 镜像版本:mysql:8.0.35、redis:7.2.3、openjdk:17-jdk-alpine、nginx:1.24.0

服务拓扑:Nginx 作为反向代理 → Spring Boot 应用(2个副本)→ MySQL + Redis。用 Nginx 做负载均衡和静态资源服务,应用容器通过内部网络访问数据库。

三、方案设计:网络、卷、健康检查三位一体

核心设计思路有四点:

  1. 自定义 bridge 网络:创建 app-network,让服务间通过容器名互访,避免暴露不必要的端口到宿主机。只有 Nginx 映射 80 端口。
  2. 命名卷持久化:MySQL 数据存 mysql-data 卷,Redis 的 AOF 文件存 redis-data 卷,应用日志挂载宿主机目录。这样容器 downup 数据不丢。
  3. healthcheck 健康检查:每个依赖服务都定义 healthcheck,Spring Boot 用 depends_oncondition: service_healthy 确保数据库和缓存就绪后才启动。
  4. 启动顺序:MySQL/Redis → Spring Boot → Nginx(Nginx 依赖应用健康)。

四、核心实现:完整的 docker-compose.yml

下面这个文件是真实项目的简化版,但关键配置全部保留。直接存为 docker-compose.yml

services:
  mysql:
    image: mysql:8.0.35
    container_name: order-mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: root#2024
      MYSQL_DATABASE: order_db
      MYSQL_USER: order_app
      MYSQL_PASSWORD: order_app#2024
      TZ: Asia/Shanghai
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --default-authentication-plugin=mysql_native_password
    volumes:
      - mysql-data:/var/lib/mysql
      - ./init-sql:/docker-entrypoint-initdb.d:ro    # 首次启动自动执行建表SQL
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot#2024"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 30s

  redis:
    image: redis:7.2.3-alpine
    container_name: order-redis
    restart: always
    command: redis-server --appendonly yes --requirepass redis#2024
    volumes:
      - redis-data:/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "redis#2024", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  app:
    image: openjdk:17-jdk-alpine
    container_name: order-app
    restart: always
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ./app.jar:/app/app.jar:ro
      - ./logs:/app/logs
    working_dir: /app
    entrypoint: ["java", "-Xms512m", "-Xmx512m", "-jar", "app.jar", "--spring.profiles.active=prod"]
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 40s

  nginx:
    image: nginx:1.24.0-alpine
    container_name: order-nginx
    restart: always
    depends_on:
      app:
        condition: service_healthy
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./static:/usr/share/nginx/html:ro
    networks:
      - app-network

volumes:
  mysql-data:
    name: order-mysql-data
  redis-data:
    name: order-redis-data

networks:
  app-network:
    driver: bridge
    name: order-network
    ipam:
      config:
        - subnet: 172.28.0.0/16

配套的 nginx.conf 关键片段(负载均衡 + 反向代理):

upstream order_backend {
    server app:8080 max_fails=3 fail_timeout=10s;
    keepalive 32;
}

server {
    listen 80;
    location /api/ {
        proxy_pass http://order_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
    }
    location /static/ {
        alias /usr/share/nginx/html/;
        expires 7d;
    }
}

注意几个细节:
- depends_on 必须配合 condition: service_healthy 才能实现真正等待,否则它只控制启动顺序不控制就绪状态(这是 Compose v2 的重要改进)。
- MySQL 初始化脚本放在 ./init-sql 目录,只有数据卷为空时才会执行,重复 up 不会重复导入。
- 给 Spring Boot 分配了固定 512M 堆内存,避免容器内存溢出被 OOM Killer 杀掉。

五、踩坑与优化:四个血泪教训

坑1:healthcheck 命令必须用容器内的工具。 我最初想用 curl 检查应用健康,但 alpine 镜像里没有 curl,导致 healthcheck 永远失败。后来换成 wget(busybox 自带)才解决。同理,MySQL 的 healthcheck 用了 mysqladmin,Redis 用 redis-cli,这些都是镜像内自带的。

坑2:MySQL 初始化脚本执行超时。 我的 init-sql 里有 20 张表的建表语句,首次启动时 MySQL 容器需要 30-50 秒完成初始化,此时 healthcheck 的 start_period 必须给足,否则会被误判为 unhealthy。实测设置 start_period: 30s 比较安全。

坑3:日志文件无限增长。 应用日志直接挂载宿主机目录,跑了两周发现磁盘爆了。解决方案是在 docker-compose 里加日志轮转(虽然本文没写,但强烈建议加):

logging:
  driver: "json-file"
  options:
    max-size: "100m"
    max-file: "3"

坑4:自定义网络子网冲突。 公司内网有个 VPN 网段恰好也是 172.28.0.0/16,导致容器无法访问外部网络。后来改成 172.28.0.0/16 不行就换 172.29.0.0/16,或者干脆不写 ipam 让 Docker 自动分配。建议除非有特殊需求,否则去掉 ipam 配置。

六、效果数据:从混乱到可控

改造后的效果非常直观:

  • 服务启动时间:手动逐个启动需要 3 分 20 秒(含人工等待 MySQL 就绪),现在 docker compose up -d 一键完成,全程 45 秒。其中 MySQL 初始化占 30 秒,应用启动占 15 秒。
  • 数据持久化:执行 docker compose down 后重新 up,MySQL 数据完好,Redis 的 AOF 文件正常加载,缓存命中率 100% 无冷启动。
  • 故障恢复:模拟杀掉 MySQL 容器,restart: always 自动拉起,应用连接池自动重连,业务无感知。Nginx 的 max_fails=3 确保单个应用实例挂掉后自动摘除,请求不中断。
  • 资源占用:四个容器稳定运行 7 天,内存占用约 1.8G(MySQL 600M + Redis 200M + 应用 512M*2 + Nginx 20M),CPU 空闲时低于 5%。

七、总结与推荐实践

Docker Compose 编排多服务项目,核心就三件事:网络隔离、数据持久化、就绪检查。如果你正在维护一个超过 3 个服务的 Compose 项目,我强烈建议:

  1. 每个有状态服务(数据库、缓存)必须用命名卷,别偷懒用匿名卷。
  2. 所有依赖服务的 depends_on 都写上 condition: service_healthy,别只用裸的 depends_on
  3. 健康检查命令优先用镜像自带工具,别赌基础镜像里有 curl。
  4. 给所有服务加日志轮转,否则半年后你的磁盘会教你做人。

这套配置已经在我们的测试环境稳定运行 3 个月,最近刚推广到预发布环境。如果你有更好的实践,欢迎评论区交流——毕竟踩坑的路上,大家都不孤单。