**
一、问题背景:6个服务,谁先启动?
上个月接手一个内部管理系统的部署改造。项目结构不算复杂,但服务数量不少:
web:Nginx 托管的前端静态资源api:Spring Boot 3.2 后端,2 个实例mysql:MySQL 8.0redis:Redis 7.2rabbitmq:RabbitMQ 3.12worker:消费消息的异步任务进程
改造前用的是"写一堆 docker run 脚本"的土办法,问题很典型:后端容器比 MySQL 先起来,连接池初始化直接抛 Communications link failure,然后容器退出,运维得手动重启。统计了一下,冷启动失败率大约 30%,每次部署都要人盯着。
这篇文章就聊聊最后落地的 Compose 方案,重点讲三件事:网络怎么划分、卷怎么挂、启动顺序怎么控。
二、环境与版本
先把版本钉死,Compose 文件格式在不同版本间行为差异不小:
| 组件 | 版本 |
|---|---|
| Docker Engine | 24.0.7 |
| Docker Compose | v2.23.0(插件形式,docker compose 而非 docker-compose) |
| Compose 文件格式 | 不使用顶层 version 字段(v2 已废弃,写了会有 warning) |
| MySQL | 8.0.35 |
| Redis | 7.2.3-alpine |
| RabbitMQ | 3.12.10-management |
| Spring Boot | 3.2.1(基础镜像 eclipse-temurin:17-jre-jammy) |
服务器是 4C8G 的云主机,Ubuntu 22.04,单机部署。别问为什么不上 K8s——六个服务的内部系统,上 K8s 属于给自己找活干。
三、方案设计:网络分层 + 命名卷 + 健康检查
设计上有三个决策点,逐个说。
网络划分:不用默认网络,建两个自定义 bridge 网络。frontend-net 只放 web 和 api,backend-net 放 api、worker 和三个中间件。这样 web 根本解析不到 mysql 的 DNS,即使前端被攻破也摸不到数据库。自定义 bridge 还自带 DNS 解析,服务名直接当主机名用,比 links 那套老机制干净得多。
卷挂载:MySQL 数据、Redis AOF、RabbitMQ 数据全部用命名卷(named volume),不用 bind mount。原因很实际——bind mount 在宿主机上容易踩权限坑(MySQL 容器内 uid 999 对宿主机目录没写权限),命名卷由 Docker 管理,省心。配置文件则用只读 bind mount,方便改完 docker compose restart 生效。
启动顺序:这是重点。depends_on 的短语法只保证"容器启动",不保证"服务可用",MySQL 容器 Up 了但 mysqld 还在初始化,后端照样连不上。正确姿势是给中间件配 healthcheck,依赖方用长语法 condition: service_healthy。
四、核心实现
完整配置如下,关键处我都加了注释:
# docker-compose.yml
name: admin-system
x-common-env: &common-env
TZ: Asia/Shanghai
LANG: C.UTF-8
services:
mysql:
image: mysql:8.0.35
environment:
MYSQL_PASSWORD=
MQ_USER=admin
MQ_PASS=
几个值得单独说的点:
健康检查命令的选择。MySQL 一开始我用的是 mysqladmin ping 不带参数,结果发现即使密码错它也返回成功——因为没认证时 mysqld 也会回 "mysqld is alive"。加上 -u root -p 才是真正确认可用。Redis 的 redis-cli ping 返回 PONG 才算过,别用 nc -z,端口通了不代表 Redis 加载完 AOF。
start_period 别省。MySQL 首次初始化(跑 initdb 脚本建表)在 4C8G 机器上要 30 秒以上,不给 start_period 的话,前几次失败会计入 retries,容器会被误判 unhealthy。我给到 40s,实测初始化完成后 8s 内就转 healthy。
api 的 readiness 而非 liveness。Spring Boot Actuator 的 /health/readiness 会检查 DB 和 Redis 连接,比单纯的 /health 严格。如果用 liveness,DB 断了它也报 UP,Nginx 就会把流量打到半死的实例上。
worker 复用 api 镜像但换 command。同一个 jar,--spring.profiles.active=worker 走不同启动路径,省一次构建,镜像层也共享。
启动命令就一句:
docker compose up -d --wait
--wait 是 v2.17 之后加的,会阻塞到所有带 healthcheck 的服务变 healthy 才返回,CI 脚本里判断部署成功特别方便。
五、踩坑与优化
坑一:deploy.replicas 在非 Swarm 模式下不生效? 这个说法流传很广,其实 Compose v2 已经支持了,docker compose up 会老老实实起 2 个 api 容器。但注意容器名会带序号(admin-system-api-1、admin-system-api-2),日志排查时别找错。另外 container_name 字段和 replicas 互斥,写了会报错,我没写。
坑二:Nginx 的 depends_on 其实意义有限。我配了 web 依赖 api healthy,但 Nginx 本身不解析后端地址——它只是把请求 proxy_pass 到 api:8080,DNS 在请求时才解析。所以就算 api 没起来,Nginx 也能正常启动,只是 502。真正起作用的是 Nginx 配置里的 proxy_next_upstream 和 max_fails,让它在某个 api 实例挂掉时自动切换。这部分配置我放在 nginx.conf 的 upstream 块里,max_fails=3 fail_timeout=10s。
坑三:卷的备份。命名卷好管理但不好备份,直接 cp 宿主机路径不直观。我的做法是加一个一次性容器:
docker run --rm -v admin-system_mysql-data:/src -v $(pwd)/backup:/dst alpine \
tar czf /dst/mysql-$(date +%F).tar.gz -C /src .
注意卷名前缀是 Compose 项目名(name: admin-system 定义的),不是服务名,第一次找路径时我在这上面浪费了十分钟。
优化:日志驱动。默认 json-file 驱动不加限制,跑一个月能把磁盘写满。统一在 compose 里加:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
每个服务限 150MB 日志,够用了。
六、效果数据
改造前后对比(同一台 4C8G 机器,冷启动 5 次取平均):
| 指标 | 改造前(run 脚本) | 改造后(Compose) |
|---|---|---|
| 冷启动失败率 | 30%(3/10 次需手动重启) | 0%(10/10 一次成功) |
| 全栈就绪耗时 | 约 65s(含人工干预) | 42s(--wait 返回) |
| 部署命令 | 6 条 docker run + 手工顺序 |
1 条 docker compose up -d --wait |
| 服务间连通排查 | 靠 docker inspect 翻 IP |
服务名直连,docker compose exec api ping mysql |
另外健康检查带来的一个隐性收益:docker compose ps 能直接看到每个服务的 healthy 状态,出问题时不用一个个 docker logs 翻,先看谁 unhealthy 就有方向了。
七、总结
这套配置上线两周,部署从"需要人盯"变成"CI 里跑一条命令看退出码"。回头看,核心就三点:网络按信任域分层、数据用命名卷、启动顺序靠 healthcheck 而非 depends_on 短语法。Compose 的能力边界其实比很多人以为的宽——replicas、资源限制、--wait 这些配上,单机编排六个服务绰绰有余。真到了需要跨机调度、滚动更新的规模,再考虑 K8s 不迟。
如果你的项目也是这种"服务不少但都在一台机器上"的状态,建议先把 healthcheck 补全,光这一项就能干掉大部分启动时序问题。