一、问题背景:四个容器,每次重建都是一场赌博
我手头有一个典型的Java微服务项目,包含四个组件:Nginx作为反向代理和静态资源服务、Spring Boot业务服务、MySQL 8.0数据库、Redis 7缓存。项目初期为了方便,每个人本地都用docker run手动启动容器,命令大概长这样:
docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=xxx -p 3306:3306 mysql:8.0
docker run -d --name redis -p 6379:6379 redis:7-alpine
docker run -d --name app --link mysql --link redis -p 8080:8080 myapp:1.0
docker run -d --name nginx -p 80:80 -v ./nginx.conf:/etc/nginx/nginx.conf nginx:1.25
问题很快就暴露了:
- 启动顺序不可控。Spring Boot启动时MySQL可能还没初始化完,直接抛
Communications link failure,服务退出。只能手动重启app容器。 - 网络配置混乱。用
--link是过时做法,容器间DNS解析经常出问题。 - 数据卷管理靠记忆。不同开发者挂载路径不一致,数据库数据丢失过两次。
- 环境重建慢。新人入职要照着文档敲十几条命令,平均耗时25分钟,还经常漏参数。
我们需要一套声明式的编排方案,把网络、卷、依赖关系、健康检查全部固化下来。Docker Compose正好解决这个问题。
二、环境与版本
- 操作系统:Ubuntu 22.04 LTS
- Docker Engine:24.0.7
- Docker Compose:v2.24.0(注意是带
docker compose子命令的插件版,不是老的docker-composePython版) - 镜像版本:
- nginx:1.25.3-alpine
- mysql:8.0.35
- redis:7.2.3-alpine
- 业务服务基于 eclipse-temurin:17-jre 构建
三、方案设计
整体思路:
- 自定义bridge网络:创建
app-net,四个服务都接入,容器间通过服务名互相访问,避免--link。 - 命名卷:MySQL数据和Redis持久化用命名卷(named volume),Nginx配置和日志用bind mount,便于本地修改。
- 健康检查:MySQL用
mysqladmin ping,Redis用redis-cli ping,业务服务暴露/actuator/health,Nginx用wget探测。 - 启动顺序:用
depends_on的condition: service_healthy,确保被依赖服务真正就绪后再启动下游。 - 重启策略:统一
restart: unless-stopped,避免宿主机重启后服务不恢复。
四、核心实现
先看完整的docker-compose.yml:
version: "3.9"
networks:
app-net:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
volumes:
mysql-data:
redis-data:
services:
mysql:
image: mysql:8.0.35
container_name: 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
- --default-authentication-plugin=mysql_native_password
- --max_connections=500
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: 30s
ports:
- "3306:3306"
redis:
image: redis:7.2.3-alpine
container_name: redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "redis_pwd_2024"]
volumes:
- redis-data:/data
networks:
- app-net
healthcheck:
test: ["CMD", "redis-cli", "-a", "redis_pwd_2024", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
ports:
- "6379:6379"
app:
build:
context: ./app
dockerfile: Dockerfile
image: myapp:1.0
container_name: app
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
SPRING_REDIS_PASSWORD: redis_pwd_2024
JAVA_OPTS: "-Xms512m -Xmx1024m"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 60s
ports:
- "8080:8080"
nginx:
image: nginx:1.25.3-alpine
container_name: nginx
restart: unless-stopped
depends_on:
app:
condition: service_healthy
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/logs:/var/log/nginx
networks:
- app-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/health"]
interval: 15s
timeout: 3s
retries: 3
ports:
- "80:80"
配套的nginx/conf.d/app.conf:
upstream app_backend {
server app:8080;
keepalive 32;
}
server {
listen 80;
server_name _;
location /health {
access_log off;
return 200 "ok\n";
}
location / {
proxy_pass http://app_backend;
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 5s;
proxy_read_timeout 30s;
}
}
启动命令就一句:
docker compose up -d --build
查看状态:
docker compose ps
五、踩坑与优化
坑1:depends_on不等于等待就绪。 早期只写depends_on: [mysql],Compose只保证容器进程启动,不保证MySQL初始化完成。Spring Boot照样报连接失败。必须配condition: service_healthy,且被依赖服务要有healthcheck。
坑2:MySQL健康检查的密码变量转义。 在healthcheck.test里用$MYSQL_ROOT_PASSWORD会被Compose当成变量替换,必须写成$$MYSQL_ROOT_PASSWORD,让shell在容器内解析。这个小坑我查了半小时文档。
坑3:start_period很关键。 MySQL 8.0首次初始化数据目录大概需要20-30秒,如果start_period太短,健康检查会连续失败,容器被标记unhealthy,导致app永远不启动。我把MySQL的start_period设成30s,app设成60s(JVM启动+Spring上下文加载)。
坑4:自定义subnet避免冲突。 默认bridge网段可能和公司内网冲突,显式指定172.28.0.0/16避免踩雷。
优化点: 用build和image同时指定,本地构建出来的镜像会打上myapp:1.0标签,方便回滚和复用。另外Nginx的keepalive 32配合proxy_http_version 1.1和清空Connection头,能显著降低上游连接开销。
六、效果数据
对比改造前后(同一台开发机,8C16G):
| 指标 | docker run 手动 | Docker Compose |
|---|---|---|
| 环境重建耗时 | 约25分钟 | 约3分钟 |
| 首次启动成功率 | 约70% | 接近100% |
| 服务启动顺序错误 | 每周2-3次 | 0次 |
| 新人上手时间 | 半天 | 15分钟 |
docker compose up -d后,docker compose ps显示四个服务全部healthy的平均时间约95秒,其中MySQL初始化占了大头。之后重启环境(数据卷保留)只需约35秒。
七、总结
Docker Compose的价值不只是"少敲几条命令",而是把网络拓扑、数据持久化、依赖顺序、健康状态这些运维知识用声明式配置固化下来,变成可版本控制的代码。核心三点:自定义网络解决服务发现,命名卷解决数据持久化,healthcheck加depends_on.condition解决启动顺序。把这三个用好,多服务本地和测试环境编排基本就不会翻车了。