一、问题背景
去年接手一个内部管理系统,后端是Spring Boot,前端Nginx做静态资源代理,数据层用MySQL 8.0加Redis 7做缓存。开发环境大家各装各的,MySQL版本从5.7到8.0都有,Redis有的用Docker有的用本机,导致"在我机器上能跑"的问题每周至少出现两次。
一开始我尝试用docker run逐个启动容器,写了几个shell脚本。但问题很快暴露:
- 容器之间的网络要手动创建、手动连接,脚本越写越长;
- MySQL初始化需要时间,Spring Boot启动时连不上数据库直接崩溃退出;
- 数据卷挂载路径写死在脚本里,换台机器就要改;
- 每次重启要按顺序手动执行四条命令,漏一条就出问题。
后来改用Docker Compose统一编排,把网络、卷、健康检查、依赖顺序全部声明在YAML里,一条docker compose up -d搞定。下面把完整方案拆开讲。
二、环境与版本
先明确版本,Docker Compose的depends_on条件语法在不同版本差异很大,这点后面踩坑会讲。
- 操作系统:Ubuntu 22.04 LTS
- Docker Engine:24.0.7
- Docker Compose:v2.23.0(注意是v2,命令是
docker compose而不是docker-compose) - MySQL镜像:mysql:8.0.35
- Redis镜像:redis:7.2.3-alpine
- Nginx镜像:nginx:1.25.3-alpine
- 后端镜像:基于eclipse-temurin:17-jre-jammy自行构建
三、方案设计
整体思路分四层:
网络层:创建一个自定义bridge网络app-net,四个服务全部接入。自定义网络自带DNS解析,服务之间直接用服务名通信,比默认bridge网络的--link干净得多。同时把MySQL和Redis放在独立的backend-net,只让后端能访问,Nginx不接入,减少暴露面。
存储层:MySQL数据用命名卷mysql-data,Redis用redis-data,Nginx配置和静态资源用绑定挂载,方便开发时热更新。
健康检查层:MySQL用mysqladmin ping,Redis用redis-cli ping,后端暴露/actuator/health,Nginx用wget探测首页。
启动顺序层:用depends_on配合condition: service_healthy,确保后端只在MySQL和Redis都健康后才启动,Nginx只在后端健康后启动。
四、核心实现
下面是完整的docker-compose.yml,可以直接用。
version: "3.9"
networks:
frontend-net:
driver: bridge
backend-net:
driver: bridge
internal: true # 后端网络禁止外部访问
volumes:
mysql-data:
redis-data:
services:
mysql:
image: mysql:8.0.35
container_name: app-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root_pwd_2024
MYSQL_DATABASE: appdb
MYSQL_USER: appuser
MYSQL_PASSWORD: app_pwd_2024
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --innodb-buffer-pool-size=512M
- --max-connections=200
volumes:
- mysql-data:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d:ro
networks:
- backend-net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-proot_pwd_2024"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
redis:
image: redis:7.2.3-alpine
container_name: app-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redis-data:/data
networks:
- backend-net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
backend:
build:
context: ./backend
dockerfile: Dockerfile
image: app-backend:1.0.0
container_name: app-backend
restart: unless-stopped
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/appdb?useSSL=false&serverTimezone=Asia/Shanghai
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: app_pwd_2024
SPRING_REDIS_HOST: redis
SPRING_REDIS_PORT: 6379
JAVA_OPTS: "-Xms512m -Xmx1024m"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend-net
- frontend-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 8
start_period: 60s
nginx:
image: nginx:1.25.3-alpine
container_name: app-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/html:/usr/share/nginx/html:ro
- ./nginx/logs:/var/log/nginx
depends_on:
backend:
condition: service_healthy
networks:
- frontend-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/health.html"]
interval: 15s
timeout: 3s
retries: 5
start_period: 10s
后端Dockerfile也贴一下,关键是健康检查依赖wget,jammy镜像默认没有,需要装。
FROM eclipse-temurin:17-jre-jammy
RUN apt-get update && apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY target/app-backend-1.0.0.jar app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
再给一个Nginx的配置片段,重点是/actuator/health要放行,否则后端健康检查会返回401导致容器一直unhealthy。
server {
listen 80;
server_name localhost;
location /health.html {
return 200 "ok";
add_header Content-Type text/plain;
}
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
location / {
root /usr/share/nginx/html;
index index.html;
}
}
五、踩坑与优化
坑1:depends_on不等于等待就绪。 早期我直接写depends_on: [mysql],结果MySQL容器一启动(进程还在初始化)后端就开始连,连不上直接退出。后来必须用condition: service_healthy,这个语法要求Compose v2.1以上,v3的旧语法不支持condition,所以version字段我写的是"3.9"但实际用v2命令行执行,这块官方文档有点绕。
坑2:MySQL健康检查的start_period必须给够。 首次启动要初始化数据目录和跑init-sql脚本,30秒是底线,我设了30s后偶尔还会失败,最终调到30s加retries: 10才稳。如果是导入大SQL,这个值还要往上加。
坑3:internal: true网络导致Nginx连不上后端。 我一开始把backend-net设成internal,结果后端在backend-net里,Nginx在frontend-net里,两个网络隔离,Nginx反代报502。解决办法是让backend同时接入两个网络,Nginx只接frontend-net。internal网络本身没问题,但跨网络通信要规划清楚。
坑4:健康检查命令里的localhost在容器里可能解析异常。 MySQL检查我最初写-h localhost,容器内解析到IPv6导致连接失败,改成127.0.0.1后正常。
优化点:给所有服务加了restart: unless-stopped,宿主机重启后自动拉起;MySQL的innodb-buffer-pool-size从默认128M调到512M,压测时QPS从约800提升到约1100;Redis设了maxmemory-policy allkeys-lru,避免内存打满被OOM Killer干掉。
六、效果数据
改造前后对比,测试机是4核8G的云服务器:
| 指标 | 改造前(脚本) | 改造后(Compose) |
|---|---|---|
| 冷启动成功率 | 约70% | 100%(连续50次) |
| 冷启动耗时 | 手动约90s | 自动约45s |
| 服务启动失败排查时间 | 平均15分钟 | 平均2分钟(看healthcheck日志) |
| 环境一致性 | 依赖人工 | 完全一致 |
启动命令就一条:
docker compose up -d
查看健康状态:
docker compose ps
四个服务全部显示healthy才算真正就绪。停止并清理:
docker compose down # 保留数据卷
docker compose down -v # 连数据卷一起删,慎用
七、总结
Docker Compose编排多服务的核心不在于写多少配置,而在于把"服务就绪"这件事定义清楚。健康检查是基础,depends_on的condition是手段,网络和卷的规划是保障。这套配置上线后,团队新成员从拉代码到本地跑起来只要5分钟,再没出现过"在我机器上能跑"的扯皮。
有一点要提醒:Compose适合单机编排,如果服务要跨多台机器或者需要自动扩缩容,得上Kubernetes。但对中小项目来说,Compose的投入产出比是最高的。