一、问题背景:手工部署五个服务到底有多痛
去年接手一个中等规模的项目,服务拆分后一共五个组件:Vue打包出来的前端静态资源、Spring Boot后端API、MySQL 8.0、Redis 7.2,外加一个Nginx做反向代理和静态资源托管。一开始团队是写shell脚本部署的,docker run一条条敲,参数全靠记忆。问题很快就来了:
第一,服务之间互相找不到。后端容器里写localhost:3306连MySQL,当然连不上——容器有各自的网络命名空间。第二,数据丢失。有次测试环境重建容器,MySQL数据卷忘了挂,两天的测试数据直接没了。第三,启动顺序混乱。后端比MySQL先起来,连接池初始化失败直接退出,然后就得手动docker start重来一遍,脚本里加sleep 30这种土办法,机器慢的时候30秒也不够。
这些问题本质上都是编排层面的事,用Docker Compose一个文件就能解决。下面把我们最终定稿的配置完整分享出来。
二、环境与版本
先明确版本,避免读者照着抄出现兼容问题:
- Docker Engine:24.0.7
- Docker Compose:v2.23.0(注意是Compose V2插件版,命令是
docker compose而不是docker-compose) - MySQL:8.0.35
- Redis:7.2.3-alpine
- OpenJDK:17(后端基础镜像用
eclipse-temurin:17-jre-alpine) - Nginx:1.25.3-alpine
Compose文件不写version字段——V2版本已经废弃该字段,写了会警告。
三、方案设计:网络、卷、健康检查三件套
整体设计思路:
网络:创建一个自定义bridge网络app-net,五个服务全部接入。自定义bridge自带DNS解析,服务之间直接用服务名互访,比如后端连数据库写mysql:3306即可。不用默认网络的原因是默认网络里所有容器平级,隔离性差,而且默认bridge不支持服务名解析(这是老知识点,但很多人还在踩)。
卷:MySQL数据用命名卷mysql-data,Redis用redis-data开启AOF持久化。Nginx配置文件和前端静态资源用bind mount挂载,方便改完直接reload。
健康检查:MySQL用mysqladmin ping,Redis用redis-cli ping,后端用wget请求/actuator/health。关键是depends_on要配合condition: service_healthy,这才是真正等健康检查通过,而不是只等容器启动。
启动顺序:mysql、redis(并行)→ backend(等前两者healthy)→ nginx(等backend healthy)。
四、核心实现:完整docker-compose.yml
直接上配置,每一处关键参数都加了注释:
# docker-compose.yml
# Compose V2 不再需要 version 字段
services:
mysql:
image: mysql:8.0.35
container_name: app-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: appdb
MYSQL_USER: appuser
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
- ./initdb:/docker-entrypoint-initdb.d:ro
networks:
- app-net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
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:
- 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.4.2
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&characterEncoding=utf8
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
JAVA_OPTS: "-Xms256m -Xmx768m"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 8
start_period: 60s
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
NODE_ENV: production
image: app-frontend:1.4.2
container_name: app-frontend
restart: "no" # 一次性构建产物,不需要常驻
networks:
- app-net
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/certs:/etc/nginx/certs:ro
- frontend-dist:/usr/share/nginx/html:ro
depends_on:
backend:
condition: service_healthy
networks:
- app-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
interval: 15s
timeout: 3s
retries: 3
networks:
app-net:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
volumes:
mysql-data:
redis-data:
frontend-dist:
配套的.env文件(放同目录,Compose自动加载):
MYSQL_ROOT_PASSWORD=Str0ngR00tPwd!
MYSQL_PASSWORD=AppUserPwd2024
这里有几个设计点值得展开:
frontend服务用restart: "no"是对的。它只负责构建产物,把dist目录写进frontend-dist命名卷。但这里有个细节——普通容器无法直接把构建产物写入另一个命名卷再退出,实际做法是在frontend的Dockerfile里把产物COPY到/app/dist,然后用一个一次性容器或者init container模式同步到卷里。我们的做法更简单:frontend的Dockerfile基于nginx镜像,直接构建成完整前端镜像,Nginx网关只做反向代理,静态资源由frontend容器自己serve。上面的卷方案适合CI里用docker compose run --rm frontend cp同步,读者按需选择。为了配置的可运行性,我把frontend改成常驻的nginx服务更稳妥:
frontend:
image: app-frontend:1.4.2
container_name: app-frontend
restart: unless-stopped
networks:
- app-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
interval: 15s
timeout: 3s
retries: 3
healthcheck的start_period很关键。MySQL首次初始化要跑initdb脚本、建表,30秒起步;Spring Boot冷启动在我们的机器上实测42秒左右,所以start_period: 60s留足余量。这个参数的含义是:启动窗口期内的失败不计入retries,避免服务还没起来就被判定unhealthy。
MySQL健康检查里用127.0.0.1而不是localhost。官方镜像里localhost可能走socket,而mysqladmin的TCP检查更贴近应用真实连接路径。
启动命令:
docker compose up -d --build
docker compose ps # 观察 STATUS 列,healthy 才算真正就绪
五、踩坑与优化记录
坑一:depends_on不写condition等于没写。 早期配置只写了depends_on: [mysql],结果只是保证启动顺序,MySQL进程起来了但还没ready,后端照样连不上。加上condition: service_healthy之后才解决。注意这个写法在Compose V2里是原生支持的,V1时代需要version: "2.4",现在不用管。
坑二:MySQL 8.0的认证插件。 后端用mysql-connector-j 8.0.33,默认caching_sha2_password没问题,但如果项目里还有老版本客户端(比如某些Python脚本用pymysql 0.9),会报Authentication plugin 'caching_sha2_password' cannot be loaded。临时方案是在initdb脚本里对该用户执行ALTER USER ... IDENTIFIED WITH mysql_native_password BY ...,长期方案是升级客户端。
坑三:健康检查的wget在alpine镜像里行为差异。 eclipse-temurin:17-jre-alpine里wget是busybox版本,-qO-参数没问题,但如果你用curl就得确认镜像里装了。我们一开始healthcheck写curl,镜像里根本没有,容器一直unhealthy,排查了半小时。教训:healthcheck用的命令必须在镜像里实测存在。
优化点:后端JVM参数通过JAVA_OPTS注入,配合-Xmx768m和容器mem_limit: 1g(未在上面展示,生产环境建议加),避免容器被OOM kill。另外给所有服务加了logging驱动限制,json-file配max-size: 10m、max-file: 3,防止日志把磁盘写满——这个我们真遇到过,一个死循环打日志,两天写了40GB。
六、效果数据
改造前后的对比,数据来自我们测试环境(4核8G虚拟机)连续10次冷启动统计:
| 指标 | shell脚本部署 | Compose编排 |
|---|---|---|
| 冷启动耗时(中位数) | 5分48秒 | 1分26秒 |
| 启动失败需人工介入次数 | 10次中3次 | 10次中0次 |
| 环境重建(含数据恢复) | 约12分钟 | 约2分钟 |
| 配置变更部署 | 手动改多处 | 改yml后up -d |
启动失败率从30%降到0,主要归功于健康检查和启动顺序控制。冷启动时间下降主要是因为并行拉取镜像和构建缓存,以及不再需要人工sleep等待。
七、总结
Docker Compose编排的价值不在“能跑起来”,而在“可重复地跑起来”。自定义网络解决服务发现,命名卷解决数据持久化,healthcheck加depends_on条件解决启动依赖——这三件事配置写对了,环境一致性问题基本就消停了。建议把compose文件纳入版本控制,.env里的密码用CI的secret注入,别学我们早期把密码明文提交上去(后来乖乖改了git历史)。
最后提醒一句:Compose适合单机编排,服务再往上长、需要多节点调度的时候,就该考虑Kubernetes了。但在那之前,把compose文件写扎实,能省掉很多半夜爬起来重启容器的觉。