一、问题背景
手上这个项目是个典型的中小型微服务后端:前端静态资源由 Nginx 托管并反代 API,后端是 Spring Boot 单体拆出来的两个服务(api 和 job),存储层用 MySQL 8 存业务数据、Redis 7 做缓存、RabbitMQ 3.12 做异步消息。
最开始部署方式是写了一个 deploy.sh,里面一串 docker run,参数全靠手拼。问题很快就来了:
- 启动顺序不可控。脚本里
docker run mysql之后直接sleep 30,再起后端。开发机快,没问题;测试服务器磁盘慢,MySQL 初始化要 50 多秒,后端连不上直接退出,容器变成 Exited 状态,得手动重来。 - 网络配置散落。每个 run 都要
--network指定,漏一个就连不通,排查半天。 - 数据卷管理混乱。MySQL 数据卷名是随机生成的,换台机器迁移数据得先
docker inspect找卷名。 - 环境变量重复。数据库密码在三个服务的 run 命令里各写一遍,改一次要改三处。
后来统一迁到 Docker Compose,配置文件进 Git,一条 docker compose up -d 搞定。下面讲具体怎么配的。
二、环境与版本
先把版本交代清楚,Compose 的 depends_on 条件语法在不同版本差异很大,这个必须说明:
- Docker Engine:24.0.7
- Docker Compose:v2.24.5(插件版,
docker compose而非docker-compose) - 宿主机:Ubuntu 22.04,4C8G
- 镜像版本:
mysql:8.0.36、redis:7.2.4-alpine、rabbitmq:3.12.12-management、nginx:1.25.4-alpine、后端自建镜像基于eclipse-temurin:17-jre-jammy
注意:depends_on 的 condition: service_healthy 是 Compose 规范 2.1+ 才支持的,Compose v2 全系都支持,但如果你还在用老的 docker-compose 1.x(Python 版),部分版本会忽略 condition 直接按启动顺序来,这点后面踩坑一节会展开。
三、方案设计
整体思路四件事:
网络:建一个自定义 bridge 网络 app-net,所有服务接入。自定义 bridge 自带 DNS 解析,服务之间直接用服务名互访(比如后端连 mysql:3306),不暴露任何端口到宿主机,只有 Nginx 映射 80/443。这样内部服务完全不对外,安全性好很多。
卷:MySQL 数据、Redis 持久化、RabbitMQ 数据、Nginx 日志、后端上传文件,全部用命名卷(named volume),名字固定,方便备份迁移。配置文件(nginx.conf、my.cnf)用 bind mount 挂进去,改完重启容器即生效。
健康检查:MySQL 用 mysqladmin ping,Redis 用 redis-cli ping,RabbitMQ 用 rabbitmq-diagnostics -q ping,后端用 curl 打 actuator 的 /actuator/health。每个服务定义合理的 interval、timeout、retries、start_period。
启动顺序:后端 depends_on MySQL / Redis / RabbitMQ 且要求 service_healthy;Nginx depends_on 后端且要求 service_healthy。这样形成 mysql/redis/rabbitmq → api → nginx 的依赖链,Compose 会自动等待健康状态再启下一层。
四、核心实现
目录结构:
project/
├── docker-compose.yml
├── .env
├── conf/
│ ├── nginx.conf
│ └── my.cnf
└── backend/
└── Dockerfile
.env 放敏感变量和版本号,compose 文件里用 ${VAR} 引用,避免密码硬编码进 Git:
# .env
MYSQL_ROOT_PASSWORD=Root@2024#prod
MYSQL_DATABASE=appdb
MYSQL_USER=appuser
MYSQL_PASSWORD=App@2024#prod
REDIS_PASSWORD=Redis@2024#prod
RABBITMQ_USER=appmq
RABBITMQ_PASSWORD=Mq@2024#prod
核心的 docker-compose.yml(这是第一个可运行代码块,字段都带注释):
services:
mysql:
image: mysql:8.0.36
container_name: app-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --max_connections=500
- --innodb_buffer_pool_size=512M
volumes:
- mysql-data:/var/lib/mysql
- ./conf/my.cnf:/etc/mysql/conf.d/custom.cnf:ro
networks:
- app-net
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 40s
redis:
image: redis:7.2.4-alpine
container_name: app-redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes:
- redis-data:/data
networks:
- app-net
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
rabbitmq:
image: rabbitmq:3.12.12-management
container_name: app-rabbitmq
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
volumes:
- rabbitmq-data:/var/lib/rabbitmq
networks:
- app-net
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 15s
timeout: 10s
retries: 8
start_period: 30s
api:
build:
context: ./backend
dockerfile: Dockerfile
image: app-api:1.4.2
container_name: app-api
restart: unless-stopped
environment:
SPRING_PROFILES_ACTIVE: prod
DB_HOST: mysql
DB_PORT: 3306
DB_NAME: ${MYSQL_DATABASE}
DB_USER: ${MYSQL_USER}
DB_PASSWORD: ${MYSQL_PASSWORD}
REDIS_HOST: redis
REDIS_PASSWORD: ${REDIS_PASSWORD}
MQ_HOST: rabbitmq
MQ_USER: ${RABBITMQ_USER}
MQ_PASSWORD: ${RABBITMQ_PASSWORD}
volumes:
- upload-data:/app/uploads
networks:
- app-net
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'"]
interval: 15s
timeout: 5s
retries: 10
start_period: 60s
nginx:
image: nginx:1.25.4-alpine
container_name: app-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf:ro
- ./static:/usr/share/nginx/html:ro
- nginx-logs:/var/log/nginx
networks:
- app-net
depends_on:
api:
condition: service_healthy
networks:
app-net:
driver: bridge
driver_opts:
com.docker.network.bridge.name: br-app
volumes:
mysql-data:
redis-data:
rabbitmq-data:
upload-data:
nginx-logs:
几个关键点说明:
- healthcheck 里
$$MYSQL_ROOT_PASSWORD的双美元是必须的。Compose 在解析 yaml 时会把$当变量插值,写单$会被提前替换成宿主机的空值,导致健康检查命令变成-p后面空着,永远失败。这个坑我踩过一次。 start_period很重要。MySQL 首次初始化(建库建用户)比正常启动慢得多,不给 start_period,前几次 retries 会白白失败,容器可能还没初始化完就被标记 unhealthy。我给 MySQL 设了 40s,后端设了 60s(JVM 冷启动 + 建连接池)。depends_on的condition只能保证启动顺序,不保证服务内部就绪。所以后端自己的重试逻辑还是要有,健康检查是兜底不是替代。
第二个代码块是后端 Dockerfile 和 nginx.conf 的关键片段,这块直接影响健康检查能否通过:
# backend/Dockerfile
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
# 时区 + curl(healthcheck 依赖 curl,jre 镜像默认没装)
RUN apt-get update && apt-get install -y --no-install-recommends curl tzdata \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& rm -rf /var/lib/apt/lists/*
COPY target/app-api-1.4.2.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-Xms256m", "-Xmx768m", "-jar", "/app/app.jar"]
# conf/nginx.conf(server 段节选)
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://api:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}
proxy_pass http://api:8080/ 里的 api 就是 compose 服务名,靠 app-net 的 DNS 解析,不需要写 IP。
启动命令:
docker compose up -d --build
docker compose ps # 看 STATUS 里的 (healthy) 标记
docker compose logs -f api
五、踩坑与优化
坑 1:healthcheck 里的 $ 转义。上面说过,MySQL 健康检查必须写 $$MYSQL_ROOT_PASSWORD。同理任何命令里带 shell 变量的都要双写。
坑 2:Redis 健康检查带密码。redis-cli ping 在设置了 requirepass 后会返回 NOAUTH Authentication required,健康检查直接失败。必须带 -a ${REDIS_PASSWORD}。另外注意 -a 会让密码出现在进程列表里,生产环境可以考虑用 REDISCLI_AUTH 环境变量,但 compose healthcheck 里传环境变量比较绕,我们权衡后接受了这个小风险(容器内部进程,外部不可见)。
坑 3:depends_on 不等于等待就绪。早期以为写了 condition: service_healthy 就万事大吉,结果 MySQL 健康了但后端连接池初始化时偶发 Communications link failure。原因是 MySQL 的 mysqladmin ping 返回 OK 时,用户权限表可能还在加载。解决办法是给后端加连接重试(Spring 的 spring.datasource.hikari.initialization-fail-timeout 设 60000),双保险。
坑 4:卷权限。后端容器用非 root 用户跑(Dockerfile 里 USER 1000),挂载 upload-data 命名卷后第一次启动写文件报 Permission denied。命名卷默认属主是 root。解决办法是在 Dockerfile 里提前 RUN mkdir -p /app/uploads && chown -R 1000:1000 /app/uploads,让镜像层的目录属主正确,卷初始化时会继承镜像里目录的属主。
优化 1:日志驱动限制。默认 json-file 日志无限增长,跑了两周磁盘告警。给每个服务加:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
单容器日志上限 150MB,5 个服务最多 750MB,可控。
优化 2:资源限制。8G 机器上 MySQL 的 buffer pool 和 JVM 堆要协调。给 api 加了 deploy.resources.limits(compose v2 非 swarm 模式也生效):cpus 2.0、memory 1G;MySQL memory 1.5G。避免某个服务 OOM 拖垮整机。
优化 3:健康检查间隔调优。最初 interval 都是 5s,5 个服务每秒都在跑检查命令,docker stats 能看到明显的 CPU 抖动(约 3%)。调到 10-15s 后抖动消失,故障发现延迟从 5s 变成 15s,对我们这种内部系统完全可接受。
六、效果数据
迁移前后对比(测试服务器 4C8G,冷启动,清空所有卷):
| 指标 | docker run 脚本 | Docker Compose 编排 |
|---|---|---|
| 全栈冷启动耗时 | 约 90s(含固定 sleep 30s×2) | 42s |
| 冷启动成功率(20 次) | 14/20 = 70% | 20/20 = 100% |
| 配置文件变更后重启 | 手动改 3 处 run 命令 | 改 .env 后 docker compose up -d |
| 新机器部署步骤 | 12 步 | 3 步(clone、配 .env、up) |
| 数据备份 | 手动 inspect 找卷名 | docker run --rm -v project_mysql-data:/data ... 卷名固定 |
42s 的构成大致是:MySQL 首次初始化 38s(healthcheck 通过)→ 后端启动 22s → 但后端和 MySQL 有重叠(后端在 MySQL healthy 后才开始,nginx 在后端 healthy 后才开始),实际墙钟时间 42s。瓶颈在 MySQL 初始化,这个换机器配置能改善,编排本身已经压不出更多了。
七、总结
Docker Compose 编排这件事,核心不在 yaml 写得多花哨,而在三件事做对:自定义网络让服务名互通、命名卷让数据可迁移、healthcheck + depends_on condition 让启动顺序可控。三个都到位,多服务项目的部署体验会有质的变化。
几个建议给同样在做的同学:healthcheck 的 start_period 一定要按服务实际初始化时间给足,宁长勿短;depends_on 是启动顺序不是就绪保证,业务层重试逻辑不能省;.env 进 .gitignore,配一份 .env.example 进仓库。这套配置我们跑了三个月,中间扩过一次服务(加了个 job 服务),改 compose 文件加十几行就完事,比当年改脚本舒服太多。