一、为什么需要认真设计编排文件?
先交代背景。上个月接手一个老项目,六个微服务靠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/24和172.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_on的condition: service_healthy,整个启动过程是串行等待的,所以总时间是各服务就绪时间之和,没有额外浪费。
另外验证了故障恢复场景:手动docker stop ecommerce-order-service,等10秒后执行docker-compose up -d,服务在5秒内恢复并注册到网关,期间nginx自动摘除不可用节点,没有产生5xx错误——这得益于gateway的负载均衡健康检查机制。
七、总结与建议
Docker Compose编排的核心不是YAML语法,而是依赖建模。depends_on加healthcheck的组合,本质上是把进程间的启动依赖关系显式声明出来,让编排引擎帮你处理时序问题。
对于生产环境,我建议额外关注三点:一是所有中间件服务必须挂载数据卷,否则容器重建就是数据灾难;二是网络按业务域拆分成多个自定义网络,别把所有服务扔进一个扁平网络;三是为每个服务声明日志轮转限制,这是最容易被忽略的稳定性隐患。
最后推荐一个调试技巧:docker-compose config可以校验YAML语法并展开变量;docker-compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Health}}"可以快速查看所有容器的健康状态。用好这两个命令,排查编排问题效率翻倍。
你如果也遇到过启动顺序导致的服务雪崩,或者有更好的健康检查方案,欢迎在评论区交流。这套配置我已经用了一年多,中间升级过两次镜像版本,编排文件本身几乎没有改动过——好的设计应该能经受住时间考验。