一、问题背景:docker run撑不住多服务了
上个月接了个活,要给一个内部系统做容器化改造。架构不复杂:Nginx做反向代理,后端是Spring Boot(Java 17),数据层用MySQL 8.0,缓存用Redis 7。本地开发时,我习惯开四个终端窗口,每个窗口敲一个docker run命令。但到了测试环境,问题来了:
- 每次重启机器,要按顺序手动启动MySQL → Redis → 后端 → Nginx,顺序错了后端就报连不上数据库
- 容器IP是动态的,后端配置文件里的redis-host写死localhost,在容器里根本不通
- 数据存不住,MySQL容器一删,库表全没了
- 最恶心的是,有时候后端启动比MySQL快,Spring Boot重试机制没配好,直接启动失败
我意识到,必须引入Docker Compose做编排。这玩意儿不是简单的“批量docker run”,它内置了DNS解析、网络隔离、依赖控制,是单机多容器部署的最优解。
二、环境与版本:先对齐再干活
写这篇文章时,我的环境是:
- 操作系统:Ubuntu 22.04.3 LTS(内核5.15.0)
- Docker Engine:24.0.7
- Docker Compose:v2.24.0(注意,新版Compose是Docker CLI插件,不是独立的docker-compose命令)
- 镜像版本:mysql:8.0.35、redis:7.2.3、openjdk:17-jdk-slim、nginx:1.25.3
验证版本命令:
docker --version # Docker version 24.0.7
docker compose version # Docker Compose version v2.24.0
强烈建议升级到Compose v2,v1的yaml格式和依赖处理逻辑有不少坑,v2对depends_on的conditions支持更完善。
三、方案设计:三个网络隔离,两个卷持久化
先画个架构草图(脑补一下):
┌──────────────┐
│ Nginx │
│ :80/443 │
└──────┬───────┘
│ 反向代理
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌──▼────────┐ ┌─▼──────────┐
│ 前端静态 │ │ SpringBoot │ │ 预留Admin │
│ 资源文件 │ └─────┬──────┘ └────────────┘
└───────────┘ │
┌─────────┼─────────┐
│ │ │
┌────▼───┐ ┌──▼─────┐
│ MySQL │ │ Redis │
└────────┘ └────────┘
设计决策:
- 三个自定义网络:
frontend_net:Nginx和前端资源容器backend_net:Spring Boot和Nginx(Nginx要转发API请求)data_net:后端和MySQL、Redis
为什么不用一个扁平网络?安全隔离。Nginx容器理论上不需要直接访问MySQL,如果被攻破,攻击面被限制在frontend网络内。但后端和Nginx需要通信,所以Nginx同时挂了frontend和backend两个网卡。
- 卷挂载策略:
- MySQL数据目录挂到宿主机命名卷
mysql_data,防止容器删除丢数据 - 后端日志目录挂到
./logs/backend(bind mount),方便排查问题 -
Nginx静态资源目录挂到
./html,改前端代码不用重新build镜像 -
启动顺序控制:
MySQL和Redis先起,后端必须等MySQL健康检查通过后才启动,Nginx最后。这里不是用简单的depends_on: - mysql,因为那只能保证“容器创建顺序”,不能保证“服务可用”。必须配合healthcheck。
四、核心实现:docker-compose.yml逐行拆解
直接上完整配置(这是生产环境精简版,去掉了敏感信息):
version: "3.8"
services:
mysql:
image: mysql:8.0.35
container_name: myapp-mysql
restart: always
networks:
- data_net
volumes:
- mysql_data:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d:ro
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root123}
MYSQL_DATABASE: myapp_db
MYSQL_USER: myapp_user
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-user123}
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-root123}"]
interval: 5s
timeout: 3s
retries: 12
start_period: 30s
redis:
image: redis:7.2.3-alpine
container_name: myapp-redis
restart: always
networks:
- data_net
volumes:
- redis_data:/data
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-redis123}"]
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis123}", "ping"]
interval: 5s
timeout: 3s
retries: 5
backend:
build: ./backend
image: myapp-backend:1.0.0
container_name: myapp-backend
restart: always
networks:
- backend_net
- data_net
volumes:
- ./logs/backend:/app/logs
environment:
SPRING_PROFILES_ACTIVE: docker
DB_HOST: mysql
DB_PORT: 3306
DB_NAME: myapp_db
DB_USER: myapp_user
DB_PASSWORD: ${MYSQL_PASSWORD:-user123}
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
ports:
- "8080:8080"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
nginx:
image: nginx:1.25.3-alpine
container_name: myapp-nginx
restart: always
networks:
- frontend_net
- backend_net
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./html:/usr/share/nginx/html:ro
ports:
- "80:80"
- "443:443"
depends_on:
backend:
condition: service_started
networks:
frontend_net:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24
backend_net:
driver: bridge
ipam:
config:
- subnet: 172.29.0.0/24
data_net:
driver: bridge
ipam:
config:
- subnet: 172.30.0.0/24
volumes:
mysql_data:
driver: local
redis_data:
driver: local
关键点说明:
- depends_on条件:Compose v2支持
condition: service_healthy,意思不是“等容器启动”,而是“等健康检查通过”。如果不写condition,默认只等容器运行,MySQL可能还在初始化,后端就启动,必然报错。 - healthcheck的start_period:MySQL初始化需要时间(尤其是首次挂载空卷时),
start_period: 30s告诉Docker这30秒内的失败不计入重试次数,避免误杀。 - 跨网络通信:backend容器同时挂backend_net和data_net,所以它能访问MySQL(通过data_net),也能被Nginx访问(通过backend_net)。Nginx只在backend_net里,访问不到MySQL,符合最小权限原则。
- 环境变量默认值:
${MYSQL_ROOT_PASSWORD:-root123}意思是从宿主机环境变量读取,如果没有就默认root123。别在yaml里写死生产密码,用.env文件管理。
五、踩坑记录:三个真实教训
坑1:服务名解析不通,原来是网络没挂全
第一次写compose时,backend的application.yml里配的是redis:6379,但容器启动后报UnknownHostException: redis。排查半天,发现我忘了让backend挂到data_net上。它默认只在默认网络里,而redis在data_net里,DNS解析当然失败。Compose的默认网络是项目名_default,如果你自定义了网络,必须显式声明服务挂哪些网络。
坑2:MySQL健康检查的密码泄露
最开始healthcheck写的是:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot123"]
密码直接明文。后来发现docker inspect能看到命令参数,虽然只是内部网络,但总觉得不踏实。改成从环境变量读取后,至少不是硬编码在yaml里。
坑3:depends_on只等启动,不等就绪
我最初的版本:
depends_on:
- mysql
- redis
结果后端启动后,Spring Boot尝试连接数据库,连接池重试6次,每次间隔2秒,第6次崩溃。日志显示Communications link failure。加了healthcheck和condition后,后端容器会等MySQL真正接受连接后才启动,整个启动流程从失败重试的90秒压缩到40秒(MySQL初始化约25秒,Redis约3秒,后端约12秒)。
六、效果数据与运维命令
改造后,我在测试环境压了一轮:
- 冷启动(全部容器删除后重启):从手动操作约3分钟(含人工判断顺序)缩短到
docker compose up -d一条命令,耗时约45秒 - 服务可用性:连续重启20次,后端启动失败次数从改造前的8次降为0次
- 网络延迟:容器间通过Compose网络通信比通过宿主机端口转发快约0.3ms(本地回环测试数据,仅供参考)
日常运维命令:
# 查看所有服务状态(含健康检查结果)
docker compose ps
# 查看某个服务日志,-f 是跟随输出
docker compose logs -f backend
# 只重启后端,不影响其他服务
docker compose restart backend
# 停掉所有服务并清理未使用的网络(保留卷)
docker compose down
# 停掉所有服务并删除卷(数据全没,慎用!)
docker compose down -v
# 进入容器排查网络连通性
docker exec -it myapp-backend bash
ping mysql # 应该能通
curl redis:6379 # 应该能通
七、总结
Docker Compose不是银弹,但它解决了单机多容器编排90%的痛点。核心就三点:
- 网络规划:按安全边界划分网络,服务挂多个网络实现跨域通信,别图省事全塞一个扁平网络
- 健康检查:这是启动顺序控制的根基,没有healthcheck,depends_on就是个摆设
- 卷挂载:容器是无状态的,数据必须落到卷里,bind mount适合开发,named volume适合生产
最后留个思考题:如果你有多个Spring Boot实例做负载均衡,Compose怎么配?我只能说,单机Compose的scale能力有限,真要水平扩展,得上Swarm或K8s了。下篇文章我打算写写从Compose到Swarm的迁移踩坑,有兴趣的评论区扣1。