一、从裸奔到编排:一个真实的多服务困境
上个月接手一个内部订单系统,架构不算复杂:Nginx做反向代理,Spring Boot提供REST API,Redis缓存热点数据,MySQL存业务表。之前一直用Shell脚本+systemd管理进程,每次发版要手动重启四个服务,顺序错了就等着连不上数据库的报错刷屏。
最头疼的是环境不一致——开发机没问题,测试环境Redis密码忘了改,生产环境MySQL字符集不对。这类问题排查一次至少花两小时。于是决定全面容器化,但问题来了:四个容器怎么协同?要不要管网络?谁先启动?日志去哪看?
答案是Docker Compose。它不是最炫的编排工具(K8s才是),但对于单体应用拆分的微服务,或单机多容器部署,Compose的性价比极高。下面是我最终落地的配置。
二、环境与版本:先对齐再动手
我的实验和生产环境如下,版本差异会导致指令不兼容,建议先核对:
- Docker Engine: 24.0.7(含BuildKit)
- Docker Compose v2: 2.24.0(
docker compose version确认,不是docker-compose) - 宿主机OS: Ubuntu 22.04 LTS,内核5.15
- 镜像:
nginx:1.25-alpine、redis:7.2-alpine、mysql:8.0.35、openjdk:17-jdk-slim(构建产物用Dockerfile打包)
项目结构:
order-system/
├── docker-compose.yml
├── nginx/
│ ├── default.conf
│ └── Dockerfile
├── app/
│ ├── Dockerfile
│ └── target/order-service.jar
└── scripts/
├── wait-for-it.sh
└── healthcheck.sh
三、方案设计:网络隔离 + 卷挂载 + 健康门控
我的核心设计原则有三条:
-
网络隔离:不把服务全丢到默认bridge网络。我用两个自定义网络——
frontend(Nginx可达)和backend(应用、Redis、MySQL互访)。Nginx不直接连MySQL,应用不暴露到外部。这样即使容器被攻破,横向移动范围也被限制。 -
卷挂载分层:MySQL数据用命名卷
mysql-data持久化;应用日志挂载到宿主机./logs便于采集;Nginx的配置文件用bind mount直接挂载宿主机文件,方便改动即时生效。 -
健康检查+启动依赖:Compose的
depends_on默认只控制启动顺序,不保证服务就绪。必须配合healthcheck,用condition: service_healthy让应用容器真正等待MySQL和Redis可用后才启动。
四、核心实现:docker-compose.yml全解
先看完整配置,这是经过生产验证的版本:
version: "3.8"
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/24
backend:
driver: bridge
internal: true
ipam:
config:
- subnet: 172.21.0.0/24
volumes:
mysql-data:
driver: local
redis-data:
driver: local
services:
mysql:
image: mysql:8.0.35
container_name: order-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: order_db
MYSQL_USER: order_app
MYSQL_PASSWORD: ${MYSQL_APP_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
- ./mysql/init/:/docker-entrypoint-initdb.d/:ro
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --max_connections=200
networks:
- backend
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:7.2-alpine
container_name: order-redis
restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
app:
build:
context: ./app
dockerfile: Dockerfile
image: order-service:latest
container_name: order-app
restart: unless-stopped
expose:
- "8080"
environment:
SPRING_PROFILES_ACTIVE: prod
DB_HOST: mysql
DB_PORT: 3306
DB_NAME: order_db
DB_USER: order_app
DB_PASSWORD: ${MYSQL_APP_PASSWORD}
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes:
- ./logs/:/app/logs/
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 40s
nginx:
image: nginx:1.25-alpine
container_name: order-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/ssl/:/etc/nginx/ssl/:ro
depends_on:
app:
condition: service_healthy
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/healthz"]
interval: 10s
timeout: 3s
retries: 3
关键参数解释:
internal: true的backend网络不提供对外网关,容器无法访问外网,适合数据库和缓存这种不需要出网的服务。start_period是给JVM启动预留的宽限期,期间健康检查失败不计入重试次数。我设了40秒,因为Spring Boot冷启动加载Bean需要时间。- MySQL的
docker-entrypoint-initdb.d只会在数据卷为空时执行初始化脚本,所以挂载要注意别覆盖已有数据。
五、踩坑与优化:三个让我熬夜的故障
故障1:depends_on形同虚设,应用比数据库先崩
最早我只写了depends_on: [mysql, redis],结果应用容器启动后连不上数据库,直接抛异常退出。Compose的depends_on默认只等容器进入started状态,不等进程就绪。MySQL容器虽然起来了,但初始化还要5-10秒。
解决:升级到Compose v2后,用condition: service_healthy强制等待健康检查通过。注意这要求被依赖的服务必须定义了healthcheck,否则Compose会报错。
故障2:MySQL健康检查一直失败,卡死启动流程
用mysqladmin ping做健康检查时,如果root密码包含特殊字符(比如!或@),在YAML里转义会出问题。我一开始写的是:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -p$$MYSQL_ROOT_PASSWORD"]
但$$在Compose里会转成$,而容器内环境变量是MYSQL_ROOT_PASSWORD。这里有个坑:mysqladmin ping即使密码错误也会返回成功(它只检测服务是否存活,不验证认证)。所以我的健康检查永远通过,但应用连MySQL时却认证失败。
解决:改用mysqladmin ping -h localhost -p${MYSQL_ROOT_PASSWORD},并确保密码在.env文件中没有特殊YAML字符。同时增加一个实际查询的检查:
test: ["CMD-SHELL", "mysql -uroot -p$$MYSQL_ROOT_PASSWORD -e 'SELECT 1'"]
故障3:Nginx无法解析应用容器名
自定义网络下,Nginx通过app:8080访问后端服务,但有时会出现host not found in upstream错误。原因是Nginx的resolver是系统默认的127.0.0.11(Docker内置DNS),但如果Nginx容器没加入应用所在的网络,就解析不了。
解决:Nginx必须同时加入frontend和backend网络(如配置所示)。另外,Nginx的default.conf里upstream要写容器名而不是IP:
upstream order_backend {
server app:8080 weight=3 max_fails=3 fail_timeout=10s;
keepalive 32;
}
server {
listen 80;
location /healthz {
return 200 "ok";
}
location / {
proxy_pass http://order_backend;
proxy_set_header Host $host;
}
}
六、效果数据:从手忙脚乱到一键部署
改造完成后,我做了两个对比测试:
- 冷启动时间:
docker compose up -d从零启动四个容器,到Nginx健康检查通过,耗时约18秒(之前手动脚本平均35秒,且需要人盯着日志)。 - 滚动发版:
docker compose build app && docker compose up -d app替换应用容器,同时用ab -n 10000 -c 100压测,失败请求数为0。得益于健康检查,新容器未就绪前流量不会切过去。
卷挂载方面,MySQL数据卷大小从1.2GB增长到2.8GB(三天测试数据),宿主机重启后数据完整。日志目录./logs每天约200MB,用logrotate按天切割,不再担心容器日志撑爆磁盘。
七、总结:Compose编排的三个核心认知
- 网络设计是安全基础:多网络隔离比单bridge加防火墙更可控,
internal: true能挡掉大部分出站攻击。 - 健康检查是编排的灵魂:不写healthcheck的Compose文件等于裸奔。
depends_on只是顺序保证,service_healthy才是就绪保证。 - 卷挂载要规划生命周期:数据库用命名卷,配置文件用bind mount,日志挂宿主机——不同类型数据用不同策略,别一锅烩。
这套配置已经跑了三周,零故障。如果你也正被多服务部署折磨,建议直接从这份配置改起。遇到问题欢迎评论区交流——毕竟编排的坑,踩过才知道多深。