一、问题背景
前段时间接手一个内部管理系统,结构不复杂但服务不少:前端静态资源由Nginx托管,后端是Spring Boot 2.7应用,数据层用MySQL 8.0,缓存用Redis 7.2。之前部署靠一套shell脚本,先起MySQL,sleep 30,再起Redis,再sleep 5,最后起应用和Nginx。问题很明显:
- sleep时间全靠猜,机器慢的时候MySQL没起来,应用直接连不上库启动失败;
- 服务之间用宿主机端口通信,端口冲突频繁;
- 数据目录直接挂宿主机路径,换台机器就要重新改配置;
- 重启顺序靠人工记忆,运维交接成本高。
后来改用Docker Compose编排,把网络、卷、健康检查、启动依赖全部声明式写进一个YAML文件。这篇文章就把这套配置和踩过的坑完整记录下来。
二、环境与版本
先明确一下环境,避免版本差异导致行为不一致:
- 操作系统:Ubuntu 22.04 LTS
- Docker Engine:24.0.7
- Docker Compose:v2.23.3(注意是Compose V2插件,命令是
docker compose而不是docker-compose) - MySQL镜像:mysql:8.0.36
- Redis镜像:redis:7.2.4-alpine
- 后端基础镜像:eclipse-temurin:17-jre-jammy
- Nginx镜像:nginx:1.25.3-alpine
这里特别提醒:depends_on的condition语法在Compose V2里是原生支持的,但如果你还在用V1的docker-compose二进制,部分condition行为不一致,建议直接升到V2。
三、方案设计
整体设计思路如下:
- 网络:自定义一个bridge网络
app-net,所有服务接入同一网络,服务之间用服务名做DNS解析,不暴露内部端口到宿主机。只有Nginx对外映射80端口。 - 卷:MySQL数据、Redis数据、Nginx日志、应用日志分别用命名卷(named volume)持久化,避免宿主机路径耦合。
- 健康检查:MySQL用
mysqladmin ping,Redis用redis-cli ping,后端用/actuator/health接口,Nginx用wget探测本地。 - 启动顺序:用
depends_on+condition: service_healthy,让后端必须等MySQL和Redis健康后再启动,Nginx等后端健康后再启动。 - 重启策略:统一用
restart: unless-stopped,避免宿主机重启后服务不自启。
四、核心实现
完整的docker-compose.yml如下,可以直接跑:
version: "3.9"
networks:
app-net:
driver: bridge
volumes:
mysql-data:
redis-data:
nginx-logs:
app-logs:
services:
mysql:
image: mysql:8.0.36
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
- --max_connections=500
- --innodb-buffer-pool-size=512M
volumes:
- mysql-data:/var/lib/mysql
- ./initdb:/docker-entrypoint-initdb.d: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", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redis-data:/data
networks:
- app-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&allowPublicKeyRetrieval=true
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: app_pwd_2024
SPRING_REDIS_HOST: redis
SPRING_REDIS_PORT: 6379
JAVA_OPTS: "-Xms256m -Xmx512m"
volumes:
- app-logs:/app/logs
networks:
- app-net
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- 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.3-alpine
container_name: app-nginx
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/html:/usr/share/nginx/html:ro
- nginx-logs:/var/log/nginx
networks:
- app-net
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz || exit 1"]
interval: 15s
timeout: 3s
retries: 5
start_period: 10s
Nginx的upstream配置(nginx/conf.d/app.conf)指向服务名backend:
upstream backend_api {
server backend:8080 max_fails=3 fail_timeout=10s;
keepalive 32;
}
server {
listen 80;
server_name _;
location /healthz {
access_log off;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://backend_api/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
}
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}
启动命令很简单:
docker compose up -d --build
docker compose ps
docker compose ps会显示每个服务的健康状态,STATUS列看到healthy就说明整条链路都通了。
五、踩坑与优化
坑1:MySQL健康检查一直unhealthy。 最初写的是mysqladmin ping -h localhost,结果容器内localhost走socket,而初始化阶段socket还没就绪,导致误判。改成-h 127.0.0.1强制走TCP,并加start_period: 40s给初始化留足时间,问题解决。另外密码变量在YAML里要用$$转义,否则会被Compose提前解析。
坑2:backend启动太快,MySQL还没建完表。 即使service_healthy了,MySQL初始化脚本(/docker-entrypoint-initdb.d)可能还在跑。解决办法是在initdb脚本最后加一行SELECT 1;并确保脚本执行完MySQL才返回健康。更稳妥的做法是后端加Flyway/Liquibase迁移,让应用自己保证schema。
坑3:卷权限。 后端镜像里用的是非root用户appuser(uid 1000),挂载app-logs命名卷后写日志报Permission denied。解决方案是在Dockerfile里mkdir -p /app/logs && chown -R 1000:1000 /app/logs,或者用user: "1000:1000"显式指定。
坑4:健康检查间隔太密。 一开始interval设成5s,docker compose ps刷屏不说,MySQL在高负载下偶尔超时被判定unhealthy,触发restart。调到10-15s,retries给到10,稳定很多。
优化点: 后端healthcheck用了wget而不是curl,因为eclipse-temurin:17-jre-jammy基础镜像不带curl,装curl会多30MB。alpine系的Nginx和Redis自带wget/redis-cli,直接复用。
六、效果数据
在同一台4C8G的机器上对比:
- 手动脚本部署:冷启动约180秒,其中sleep占掉约90秒,且偶发失败需要重跑;
- Compose编排部署:冷启动约40秒,
docker compose up -d返回后约35秒内四个服务全部healthy; - 服务重启(只重启backend):约12秒恢复到healthy;
- 全量重建(
down后up,保留卷):约55秒。
另外,因为内部端口不暴露到宿主机,端口冲突问题彻底消失,同一台机器可以并行跑多套环境(改一下container_name和网络名即可)。
七、总结
Docker Compose做多服务编排,核心就三件事:网络让服务能互相找到,卷让数据不丢,健康检查加depends_on让启动顺序可控。把这三件事写清楚,部署脚本基本可以退休了。几个关键参数值得记住:start_period给慢启动服务留缓冲,condition: service_healthy替代盲目sleep,命名卷替代宿主机路径绑定。如果你的服务数量还在个位数,Compose的性价比远高于K8s,别为了技术栈好看把简单问题复杂化。