一、问题背景:当docker run撑不住的时候
上个月我们团队接手了一个电商后台项目,拆完微服务后一共6个节点:Nginx网关、前端静态资源、后端Java应用(Spring Boot)、MySQL、Redis、以及一个定时任务Python脚本。起初图省事,写了个shell脚本用docker run一个个起容器,结果问题接踵而至:
- 启动顺序全靠sleep硬等,MySQL还没初始化完,后端就已经连不上库了;
- 网络互通靠--link,这玩意官方都标记废弃了,换个IP就全乱套;
- 数据说丢就丢,容器一删,MySQL里的数据全没了;
- 没有健康检查,容器起来了但服务没就绪,负载均衡转发过去直接502。
最要命的是排查问题,6个容器日志混在一起,压根分不清谁是谁。
二、环境与版本
先交代一下环境,这直接影响后续配置写法:
Docker Engine: 26.1.1
Docker Compose: v2.24.2
Linux: Ubuntu 22.04 LTS (内核5.15)
服务器配置: 4核8G (阿里云ECS)
注意:Compose v2已经集成了docker compose命令(带横杠),不需要再装docker-compose(带横杠的Python版老掉牙了)。下面的配置全部基于v2语法,如果你还在用v1,建议尽早迁移。
三、方案设计:一张拓扑图看懂全局
先看整体架构,我画了张简图:
┌─────────────────────────────────────────────────────┐
│ docker_net_bridge (自定义网络) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ nginx │───▶│ frontend │ │ backend │ │
│ │ :80/443 │ │ :3000 │ │ :8080 │ │
│ └─────────┘ └──────────┘ └──────┬──────┘ │
│ │ │ │
│ └────────────┬───────────────────┘ │
│ ▼ │
│ ┌─────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ mysql │ │ redis │ │ scheduler │ │
│ │ :3306 │ │ :6379 │ │ (无端口暴露) │ │
│ └─────────┘ └──────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────┘
设计思路:
- 网络策略:所有服务加入同一个自定义桥接网络,容器间通过服务名互访(内置DNS解析)。不映射到宿主机的服务(如MySQL、Redis)坚决不暴露端口,避免安全隐患。
- 数据持久化:MySQL和Redis使用named volume,宿主机上存放在/var/lib/docker/volumes/下,容器删除不丢数据。
- 健康检查:每个有依赖关系的服务都配了healthcheck,Compose根据健康状态决定后续服务的启动时机。
- 启动顺序:严格为 mysql/redis → backend → nginx,通过depends_on: condition: service_healthy实现,彻底告别sleep硬等。
四、核心实现:完整的docker-compose.yml
这是我们在测试环境跑通的配置(生产环境做了小改动,密钥已脱敏):
version: "3.8"
networks:
app_net:
driver: bridge
ipam:
config:
- subnet: "172.28.0.0/24"
volumes:
mysql_data:
driver: local
redis_data:
driver: local
services:
mysql:
image: mysql:8.0.36
container_name: app-mysql
restart: always
networks:
app_net:
ipv4_address: 172.28.0.10
volumes:
- mysql_data:/var/lib/mysql
- ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: shop
MYSQL_USER: app_user
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --max_connections=500
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
ports:
- "127.0.0.1:3306:3306" # 仅本机可访问,生产环境用堡垒机
redis:
image: redis:7.2.4-alpine
container_name: app-redis
restart: always
networks:
app_net:
ipv4_address: 172.28.0.11
volumes:
- redis_data:/data
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 3
ports:
- "127.0.0.1:6379:6379"
backend:
image: registry.cn-hangzhou.aliyuncs.com/xxx/shop-backend:1.4.2
container_name: app-backend
restart: always
networks:
app_net:
ipv4_address: 172.28.0.20
environment:
SPRING_PROFILES_ACTIVE: dev
DB_HOST: mysql
DB_PORT: 3306
DB_NAME: shop
REDIS_HOST: redis
REDIS_PORT: 6379
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 60s # Spring Boot启动慢,给足时间
frontend:
image: registry.cn-hangzhou.aliyuncs.com/xxx/shop-frontend:1.2.0
container_name: app-frontend
restart: always
networks:
app_net:
ipv4_address: 172.28.0.30
depends_on:
- backend # 不需要等健康,nginx层会做重试
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
interval: 10s
timeout: 3s
retries: 3
nginx:
image: nginx:1.26.0-alpine
container_name: app-nginx
restart: always
networks:
app_net:
ipv4_address: 172.28.0.40
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./logs/nginx:/var/log/nginx
depends_on:
frontend:
condition: service_healthy
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
interval: 10s
timeout: 3s
retries: 3
scheduler:
image: registry.cn-hangzhou.aliyuncs.com/xxx/shop-scheduler:2.0.1
container_name: app-scheduler
restart: always
networks:
app_net:
ipv4_address: 172.28.0.50
environment:
PYTHONUNBUFFERED: "1"
DB_HOST: mysql
REDIS_HOST: redis
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
backend:
condition: service_healthy
# 不暴露任何端口到宿主机
.env文件管理敏感信息:
# .env (置于docker-compose.yml同目录)
MYSQL_ROOT_PASSWORD=MyStr0ng!Root2024
MYSQL_PASSWORD=AppUser#Passw0rd
启动命令:
# 校验配置
docker compose config --quiet
# 构建并后台启动
docker compose up -d --build
# 查看启动状态
docker compose ps
五、踩坑记录:三次重启血泪史
坑1:depends_on默认不等待健康检查
Compose v2的depends_on有一个坑:如果不加condition: service_healthy,它只保证容器创建顺序,不保证服务就绪。我第一次用depends_on: [mysql, redis],结果MySQL容器起来了但还没初始化完,后端就疯狂重试连接。解决办法就是上面写的显式声明condition: service_healthy。
坑2:healthcheck命令里的环境变量
MySQL的healthcheck测试命令里用了-p${MYSQL_ROOT_PASSWORD},在Compose文件里这个变量会被正确替换。但如果写成$$MYSQL_ROOT_PASSWORD(双美元符),它会在容器内部执行时再求值——这在容器里没有该环境变量时会直接失败。正确写法是单美元符,让Compose先求值。
坑3:Spring Boot的健康检查端点默认不开启
我们一开始配了/actuator/health,但后端一直显示unhealthy。排查了半天发现Spring Boot 2.x的actuator默认只暴露/actuator/health,但没引入依赖。需要在pom.xml里加:
org.springframework.boot
spring-boot-starter-actuator
2.7.18
然后配置management.endpoint.health.show-details=always才能看到详细检查项。
坑4:MySQL初始化脚本执行时机
MySQL官方镜像有个机制:首次启动且数据目录为空时,才会执行/docker-entrypoint-initdb.d/下的.sql脚本。如果用了named volume且数据已存在,脚本不会再次执行。这导致我改了初始化脚本后重启容器,表结构没更新。解决:要么docker compose down -v清卷重来(仅限开发环境),要么用专门的迁移工具(如Flyway)。
六、效果数据与优化
优化前后对比:
| 指标 | 优化前(docker run脚本) | 优化后(Docker Compose) |
|---|---|---|
| 全栈启动时间 | 约8分钟(含sleep等待) | 约90秒(健康检查精准等待) |
| 故障容器恢复 | 手动排查依赖关系 | 重启后依赖服务自动拉起 |
| 数据持久化 | 容器删除数据丢失 | named volume保障数据安全 |
| 网络配置 | --link维护困难 | 自定义网络+服务名解析 |
| 日志管理 | 分散查看 | docker compose logs -f统一查看 |
额外优化:
1. 日志限制:每个服务加了logging配置,限制单文件大小与保留数量,防止磁盘爆满。代码里没写,但强烈建议加上:
yaml
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
-
资源限制:给MySQL加了
mem_limit: 2g,防止内存泄漏把整台宿主机拖垮。 -
镜像tag固定:不要用
latest!我们在生产环境吃过亏,某次latest镜像更新导致Redis数据格式不兼容。现在全部锁定具体版本号。
七、总结与反思
Docker Compose这套编排方案,对于中小型项目(10个容器以内)完全够用。再往上走,比如需要跨主机编排、自动扩缩容,那才需要上Kubernetes或Docker Swarm——但那是另一个话题了。
几个核心收获:
- 健康检查是启动顺序的关键,
depends_on+service_healthy是Compose最实用的特性之一; - 网络隔离用自定义bridge网络,比
--link高效且安全,服务名即域名; - 数据卷一定要用named volume,bind mount适合开发环境改代码,生产环境数据用卷管理更可靠。
最后说一句,Compose文件本身也是代码,要进Git仓库、要code review。我们团队现在每次改编排配置都要过MR,这半年再没出过部署事故。
如果你也在用Compose编排,欢迎评论区交流。遇到什么诡异的坑,说不定我也踩过。