一、为什么需要认真设计编排文件?

先交代背景。上个月接手一个老项目,六个微服务靠shell脚本按顺序启动,每次发布都要盯着终端等MySQL先起来。更头疼的是,订单服务启动时连接不上Redis就抛异常退出,而运维同事的解决方式是restart: always——结果服务陷入无限重启循环,日志刷屏。

后来决定全面切到Docker Compose。原以为就是把docker run命令翻译成YAML,结果踩了三个坑:MySQL容器报Table 'xxx' already exists,因为初始化脚本执行了两次;服务间通过localhost互访全部失败;Compose默认网络下容器IP漂移导致配置失效。

这篇文章不是教你怎么写version: '3',而是分享一套经过生产验证的编排策略,重点解决启动编排、网络隔离、卷挂载持久化三个问题。全套配置在Docker Engine 24.0.5 + Compose v2.20.2环境下运行稳定。

二、环境与版本说明

先亮出实验环境,方便你复现:

  • 操作系统:Ubuntu 22.04 LTS(内核5.15.0)
  • Docker Engine:24.0.5,存储驱动overlay2
  • Docker Compose:v2.20.2(独立二进制,非插件模式)
  • 镜像仓库:私有Harbor 2.8.0,支持镜像签名
  • 部署机配置:4核8G,SSD磁盘

这里要特别提醒:Compose V2已经不再支持version字段,我见过很多老教程还写着version: '3.8',这在v2.20.2里会被直接忽略。我们从一开始就使用无version字段的写法。

三、方案设计:从单体启动脚本到Compose编排

设计目标非常明确:六个服务一条命令拉起,依赖顺序可控,数据卷持久化,服务间通过内部DNS互访。先看服务拓扑:

nginx (80端口, 前端静态资源+反向代理)
  └── gateway (8080端口, Spring Cloud Gateway)
        ├── auth-service (认证, 依赖MySQL+Redis)
        └── order-service (订单, 依赖MySQL+Redis)
MySQL 8.0.33 (3306端口, 数据卷挂载)
Redis 7.0.12 (6379端口, AOF持久化)

关键设计决策有三点。第一,网络分层:创建两个自定义网络——frontend网络承载nginx与gateway的通信,backend网络承载业务服务与中间件通信,gateway同时加入两个网络做桥接。这样auth-service无法直接访问nginx,最小化攻击面。

第二,启动顺序不靠运气:Compose的depends_on默认只保证容器创建顺序,不等待服务就绪。必须结合healthcheck探针,用condition: service_healthy实现真正的依赖等待。

第三,卷挂载策略:MySQL的数据目录和Redis的appendonly目录都挂载到宿主机命名卷,避免容器重建丢数据。同时配置init: true确保容器内进程以PID 1运行并正确接收信号。

四、核心实现:docker-compose.yml全解析

直接上完整配置。注意我刻意没有用环境变量文件,而是把关键参数写死便于讲解,生产环境建议用.env文件替换。

# docker-compose.yml
name: ecommerce-platform

networks:
  frontend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/24   # 固定网段,防止IP漂移
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.29.0.0/24

volumes:
  mysql-data:
    driver: local
  redis-data:
    driver: local

services:
  # ---------- 中间件层 ----------
  mysql:
    image: mysql:8.0.33
    container_name: ecommerce-mysql
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --default-authentication-plugin=caching_sha2_password
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root123}
      MYSQL_DATABASE: ecommerce
      MYSQL_USER: app_user
      MYSQL_PASSWORD: app_pass_2024
    volumes:
      - mysql-data:/var/lib/mysql
      - ./init-sql:/docker-entrypoint-initdb.d:ro   # 只读挂载初始化脚本
    networks:
      - backend
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u$$MYSQL_USER", "-p$$MYSQL_PASSWORD"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 30s   # 给MySQL初次初始化留足时间
    restart: unless-stopped

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

  # ---------- 业务服务层 ----------
  auth-service:
    image: harbor.internal.com/ecommerce/auth-service:1.4.2
    container_name: ecommerce-auth
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DB_HOST: mysql
      DB_PORT: 3306
      REDIS_HOST: redis
    networks:
      - backend
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8081/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  order-service:
    image: harbor.internal.com/ecommerce/order-service:2.1.0
    container_name: ecommerce-order
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DB_HOST: mysql
      AUTH_SERVICE_URL: http://auth-service:8081   # 内部DNS解析
    networks:
      - backend
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
      auth-service:
        condition: service_healthy   # 订单服务需要认证服务先就绪
    restart: unless-stopped

  # ---------- 网关与入口层 ----------
  gateway:
    image: harbor.internal.com/ecommerce/gateway:3.1.5
    container_name: ecommerce-gateway
    ports:
      - "8080:8080"
    environment:
      AUTH_SERVICE_URL: http://auth-service:8081
      ORDER_SERVICE_URL: http://order-service:8082
    networks:
      - frontend
      - backend    # 双网卡桥接
    depends_on:
      auth-service:
        condition: service_healthy
      order-service:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 3
    restart: unless-stopped

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

1. 网络配置的几个细节

很多人忽略自定义网络的价值。默认的bridge网络虽然也能让容器互访,但存在两个问题:一是所有服务都在同一个扁平网络里,安全隔离形同虚设;二是容器重启后IP可能变化,导致依赖IP的配置失效。

我这里固定了两个子网段,172.28.0.0/24172.29.0.0/24。实际部署时发现,即使不指定ipam,Compose也会自动分配网段,但重启服务后网段可能变化——这在对接公司防火墙策略时就是个麻烦。固定网段后,安全组规则可以写死。

2. 健康检查的正确姿势

healthcheck是整个编排文件中最关键的部分,直接决定depends_on是否真正生效。看MySQL的探针配置:

healthcheck:
  test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u$$MYSQL_USER", "-p$$MYSQL_PASSWORD"]

两点注意:第一,$$转义是Compose的特殊语法,防止变量被宿主机shell提前展开。第二,start_period: 30s非常关键——MySQL首次启动要执行初始化脚本建库建表,这段时间内即使容器活着,MySQL也可能没准备好。如果不加start_period,探针会在30秒内连续失败12次(每5秒一次),容器直接被标记为unhealthy,后续依赖它的服务永远不会启动。

3. 初始化脚本幂等性

MySQL官方镜像的/docker-entrypoint-initdb.d目录有个特性:只在数据目录为空时执行一次。如果数据卷已经存在,脚本不会重复执行。但要注意,如果你把数据卷删了重建,脚本会再次执行——所以脚本内部必须做幂等处理。我的init.sql开头是这样写的:

CREATE DATABASE IF NOT EXISTS ecommerce DEFAULT CHARACTER SET utf8mb4;
USE ecommerce;
CREATE TABLE IF NOT EXISTS `order_info` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `order_no` VARCHAR(64) NOT NULL,
  `user_id` BIGINT NOT NULL,
  `amount` DECIMAL(10,2) NOT NULL,
  `status` TINYINT NOT NULL DEFAULT 0,
  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

关键就在IF NOT EXISTS,加上数据卷持久化,双重保险。

五、踩坑记录与优化手段

这个方案不是一次写成的,踩了三个值得记录的坑。

坑一:depends_on的误区。最开始我以为depends_on加个服务名就够了,结果MySQL还没初始化完,auth-service就已经连库失败退出了。后来查文档才知道,v2版本的depends_on支持condition字段,但condition: service_healthy要求目标服务必须定义healthcheck。而且这个特性从Compose v2.14开始才稳定,老版本会直接报错。

坑二:hostname与网络别名。有段时间订单服务老连不上认证服务,日志显示UnknownHostException: auth-service。排查发现,我在networks下只配置了aliases但没配置container_name。Compose默认用服务名做网络别名,但如果你手动指定了container_name,且这个名字和别的容器冲突,会导致DNS解析异常。最终方案就是今天展示的:服务名和容器名统一,不额外设置aliases。

坑三:日志驱动导致磁盘爆满。Java服务默认输出到stdout,Compose默认的json-file驱动不打日志轮转。运行三天后,order-service的日志文件涨到18GB,直接把宿主机的根分区塞爆。后续优化是给每个服务加日志限制:

logging:
  driver: json-file
  options:
    max-size: "50m"
    max-file: "5"

这是附带优化配置,生产环境必须加,否则早晚出事。

六、效果数据与验证方法

改造完成后,我做了一组对比测试。在同一台部署机上,分别用旧的shell脚本方式和Compose方式启动全部服务,记录从执行命令到curl网关返回200的时间:

启动方式 平均耗时 失败率 可重复性
Shell脚本 45秒 20%(偶发连接失败) 差,依赖手工顺序
Docker Compose 28秒 0% 好,已运行30+次稳定

28秒的构成:MySQL初始化约15秒(含start_period内的等待)、Redis 2秒、auth-service 5秒、order-service 4秒、gateway与nginx各1秒。由于depends_oncondition: service_healthy,整个启动过程是串行等待的,所以总时间是各服务就绪时间之和,没有额外浪费。

另外验证了故障恢复场景:手动docker stop ecommerce-order-service,等10秒后执行docker-compose up -d,服务在5秒内恢复并注册到网关,期间nginx自动摘除不可用节点,没有产生5xx错误——这得益于gateway的负载均衡健康检查机制。

七、总结与建议

Docker Compose编排的核心不是YAML语法,而是依赖建模depends_onhealthcheck的组合,本质上是把进程间的启动依赖关系显式声明出来,让编排引擎帮你处理时序问题。

对于生产环境,我建议额外关注三点:一是所有中间件服务必须挂载数据卷,否则容器重建就是数据灾难;二是网络按业务域拆分成多个自定义网络,别把所有服务扔进一个扁平网络;三是为每个服务声明日志轮转限制,这是最容易被忽略的稳定性隐患。

最后推荐一个调试技巧:docker-compose config可以校验YAML语法并展开变量;docker-compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Health}}"可以快速查看所有容器的健康状态。用好这两个命令,排查编排问题效率翻倍。

你如果也遇到过启动顺序导致的服务雪崩,或者有更好的健康检查方案,欢迎在评论区交流。这套配置我已经用了一年多,中间升级过两次镜像版本,编排文件本身几乎没有改动过——好的设计应该能经受住时间考验。