一、问题背景:为什么我放弃了 docker run 脚本
去年接手一个内部管理系统,服务不多但依赖关系挺烦人:Nginx 做反向代理,Spring Boot 提供 API,MySQL 存业务数据,Redis 做缓存和 session。一开始我写了个 deploy.sh,里面一堆 docker run,参数长得像火车:
docker run -d --name api --network appnet -p 8080:8080 \
-e SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/app \
-v /data/logs:/app/logs myapi:1.4.2
问题很快就来了:
- 启动顺序靠
sleep。脚本里写sleep 20等 MySQL 起来,结果测试机磁盘慢,20 秒不够,API 启动时连不上数据库直接退出,容器重启 17 次才成功(docker inspect里的RestartCount我记得清清楚楚)。 - 网络要手动建。换了台机器忘了
docker network create appnet,容器之间 DNS 解析不了,排查了半小时。 - 环境变量散落各处。改一个数据库密码要翻三个脚本。
- 没有资源限制。某次 MySQL 跑了个大查询把内存吃满,把宿主机上别的服务一起拖死了。
于是决定用 Docker Compose 重构。目标很明确:一条 docker compose up -d 搞定,启动顺序由健康检查驱动,而不是靠猜时间。
二、环境与版本
先交代清楚环境,避免版本差异导致行为不一致:
| 组件 | 版本 |
|---|---|
| 宿主机 | Ubuntu 22.04 LTS,4C8G |
| Docker Engine | 24.0.7 |
| Docker Compose | v2.23.0(插件版,命令是 docker compose 不是 docker-compose) |
| MySQL | 8.0.35 |
| Redis | 7.2.3 |
| Spring Boot | 3.2.1(JDK 17) |
| Nginx | 1.25.3-alpine |
注意 Compose v2 和 v1 有个关键区别:v2 才完整支持 depends_on 的 condition: service_healthy 长语法(其实 v1 的 2.1+ 文件格式也支持,但很多人用的 v1 二进制版本太老)。如果你还在用 docker-compose(带横杠),建议先升级。
三、方案设计
整体思路:
- 网络:自定义 bridge 网络
appnet,服务之间用服务名互相访问,只有 Nginx 暴露 80/443 到宿主机,MySQL、Redis、API 全部只在内部网络。 - 卷:MySQL 数据、Redis 数据用命名卷(named volume),日志和 Nginx 配置用 bind mount,方便在宿主机直接看。
- 健康检查:MySQL 用
mysqladmin ping,Redis 用redis-cli ping,API 用 Spring Boot Actuator 的/actuator/health。 - 启动顺序:API 依赖 MySQL 和 Redis 健康,Nginx 依赖 API 健康。
- 资源限制:用
deploy.resources限制内存,防止单个服务拖垮宿主机。
依赖关系图:
nginx ──depends_on(healthy)──> api ──depends_on(healthy)──> mysql
└──depends_on(healthy)──> redis
四、核心实现
目录结构:
project/
├── docker-compose.yml
├── .env
├── nginx/
│ └── conf.d/
│ └── app.conf
└── mysql/
└── init/
└── 01-schema.sql
.env 文件放敏感配置,不提交到 Git:
MYSQL_ROOT_PASSWORD=RootP@ss2024
MYSQL_DATABASE=app
MYSQL_USER=appuser
MYSQL_PASSWORD=AppP@ss2024
REDIS_PASSWORD=RedisP@ss2024
API_IMAGE=myapi:1.4.2
完整的 docker-compose.yml:
services:
mysql:
image: mysql:8.0.35
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=300
- --innodb-buffer-pool-size=1G
volumes:
- mysql-data:/var/lib/mysql
- ./mysql/init:/docker-entrypoint-initdb.d:ro
networks:
- appnet
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 40s
deploy:
resources:
limits:
memory: 2G
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
redis:
image: redis:7.2.3-alpine
container_name: app-redis
restart: unless-stopped
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redis-data:/data
networks:
- appnet
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
deploy:
resources:
limits:
memory: 768M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
api:
image: ${API_IMAGE}
container_name: app-api
restart: unless-stopped
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
SPRING_DATASOURCE_USERNAME: ${MYSQL_USER}
SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD}
JAVA_OPTS: "-Xms512m -Xmx1024m -XX:+UseG1GC"
volumes:
- ./logs/api:/app/logs
networks:
- appnet
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 60s
deploy:
resources:
limits:
memory: 1536M
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
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
- ./logs/nginx:/var/log/nginx
networks:
- appnet
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
interval: 15s
timeout: 3s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
appnet:
driver: bridge
name: appnet
volumes:
mysql-data:
name: app-mysql-data
redis-data:
name: app-redis-data
Nginx 配置里加一个 /healthz 用于健康检查:
upstream api_backend {
server api:8080;
keepalive 32;
}
server {
listen 80;
server_name _;
location /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
location / {
proxy_pass http://api_backend;
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 60s;
}
}
API 需要引入 Actuator 依赖,否则 /actuator/health 是 404:
org.springframework.boot
spring-boot-starter-actuator
application-prod.yml 里放开健康端点:
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: never
启动命令就一句:
docker compose up -d
想确认健康状态:
docker compose ps
输出里 STATUS 列会显示 Up 2 minutes (healthy),这个 healthy 就是启动顺序的开关。
五、踩坑与优化
坑 1:MySQL 健康检查一直 unhealthy。
最初 test 写的是 mysqladmin ping -h localhost,容器内 localhost 走 socket,而 MySQL 初始化阶段 socket 和 TCP 端口状态不同步,导致检查误判。改成 -h 127.0.0.1 强制走 TCP,并且加 start_period: 40s 给初始化留时间。MySQL 首次启动要跑初始化脚本,40 秒是实测值,SSD 上大概 25 秒,机械盘要 50 秒以上,按最慢的来。
坑 2:密码里有特殊字符,健康检查命令解析失败。
mysqladmin -p${MYSQL_ROOT_PASSWORD} 这种写法,如果密码里有 $ 或 !,YAML 解析会出问题。解决办法是把密码放进 .env,Compose 会做变量替换,但要注意 .env 里的值不要加引号,加了引号引号本身会变成密码的一部分。
坑 3:depends_on 短语法不生效。
很多人写:
depends_on:
- mysql
这只是保证启动顺序,不保证 mysql 真的 ready。必须用长语法 condition: service_healthy。而且被依赖的服务必须定义了 healthcheck,否则 Compose 会报错 service "api" depends on service "mysql" which has no healthcheck configured。
坑 4:API 的 start_period 太短导致误杀。
Spring Boot 3.2 冷启动大概 25 秒,加上连接池初始化和 Flyway 迁移,实测要 45 秒左右。start_period: 60s 是留了余量的。start_period 期间的失败不计入 retries,这个参数很关键。
优化 1:日志轮转。 没配 logging 之前,某个容器日志涨到 12GB 把磁盘写满了。现在统一 max-size: 20m + max-file: 3,单个服务日志最多 60MB。
优化 2:资源限制。 deploy.resources.limits.memory 在非 Swarm 模式下 Compose v2 也生效(v1 不生效)。MySQL 给 2G,API 给 1.5G,Redis 给 768M,加起来 4.3G,宿主机 8G 还有余量。
优化 3:时区统一。 所有服务加 TZ: Asia/Shanghai,JDBC URL 里带 serverTimezone,避免日志时间对不上。
优化 4:健康检查间隔。 interval 别设太短,10-15 秒够了,太短会给 MySQL 增加无谓的连接压力。timeout 要小于 interval。
六、效果数据
迁移前后对比(同一台 4C8G 机器,冷启动,清空所有容器和卷):
| 指标 | docker run 脚本 | Docker Compose |
|---|---|---|
| 一键启动成功率 | 约 60%(靠 sleep 赌) | 100%(连续 30 次测试) |
| 冷启动到全部 healthy | 90s(含固定 sleep 60s) | 35s |
| API 容器重启次数 | 平均 3-17 次 | 0 次 |
| 部署脚本行数 | 87 行 bash | 1 行命令 |
| 改配置耗时 | 改 3 个文件 | 改 1 个 .env |
docker compose ps 的典型输出:
NAME IMAGE STATUS PORTS
app-mysql mysql:8.0.35 Up 2 minutes (healthy) 3306/tcp
app-redis redis:7.2.3-alpine Up 2 minutes (healthy) 6379/tcp
app-api myapi:1.4.2 Up 1 minute (healthy) 8080/tcp
app-nginx nginx:1.25.3-alpine Up 50 seconds (healthy) 0.0.0.0:80->80/tcp
注意启动时间是有梯度的:mysql/redis 先 healthy,然后 api 才开始,最后 nginx。这就是 depends_on + healthcheck 的效果。
七、总结
Docker Compose 编排的核心价值不是"少敲几个命令",而是把服务之间的依赖关系用声明式的方式表达出来。healthcheck 定义"什么叫就绪",depends_on: condition: service_healthy 定义"谁等谁",这两者配合才能真正解决启动顺序问题。
几个建议:
- 别用
sleep,任何sleep都是技术债。 - 每个有状态服务都配
healthcheck,start_period给足。 - 敏感配置走
.env,.env进.gitignore。 - 日志轮转和资源限制是生产环境必修课,别等磁盘满了才想起来。
- Compose v2 的长语法
depends_on是刚需,尽早升级。
如果你也在维护一堆 docker run 脚本,真的可以花半天时间迁到 Compose,收益比想象中大。