一、凌晨2点的告警:我的容器编排翻车了
上周五上线一个内部工单系统,docker-compose.yml里写了四个服务:前端Nginx 1.25.3、后端Spring Boot 3.2.1(Java 21)、Redis 7.2.4、MySQL 8.2.0。启动命令敲下那一刻,我天真地以为一切尽在掌握。
结果20秒后,后端容器日志刷出3000+条Unable to connect to Redis,紧接着MySQL连接池疯狂报错Communications link failure。等所有服务"看起来"起来后,前端页面白屏,Nginx报504。查了半天,发现是Spring Boot的启动速度比MySQL初始化快,连接池在MySQL真正可用前已经创建了80%的失效连接,且因为容器IP动态分配,Redis的host配置指向了旧IP。
那天晚上我盯着docker compose logs -f里的报错,意识到:光靠depends_on默认行为根本不够,它只保证容器启动了,不保证服务"可用"。
二、环境现状与核心诉求
我的开发机是Ubuntu 22.04 LTS,Docker Engine 24.0.7,Docker Compose v2.24.2(用的是docker compose插件版,别再用连字符的docker-compose了)。四个服务是典型的微服务雏形,但不是K8s那种规模,用Compose完全够。
需求有三条硬指标:
- 启动顺序严格:MySQL和Redis先就绪,然后Spring Boot连库,最后Nginx转发流量。
- 网络隔离:前端与后端走暴露端口,但后端与数据库走内部网络,不暴露数据库端口到宿主机。
- 故障自愈:任何服务崩溃后,依赖它的服务能自动重启,而不是连环雪崩。
三、方案设计:一张拓扑图理清依赖
我的设计思路是双网络 + 健康检查 + 条件依赖。网络层面用两个bridge网络:frontend_net承载Nginx和后端,backend_net承载后端、Redis和MySQL。后端服务同时挂两个网络,作为网关转发节点。
健康检查的粒度是关键。我不用Docker内置的CMD-SHELL去ping,而是针对每个服务写真正的探活命令:
- MySQL:mysqladmin ping -h 127.0.0.1 -uroot -p$MYSQL_ROOT_PASSWORD,间隔5秒,超时3秒,重试20次。
- Redis:redis-cli ping,期望返回PONG,间隔2秒。
- Spring Boot:用Actuator的/actuator/health端点,curl返回200且body里含"status":"UP"。
- Nginx:检查/health静态页。
依赖关系用depends_on的condition: service_healthy,这是Compose 2.20+才有的特性,老版本只支持service_started,那个不解决顺序问题。
四、核心实现:一份可以直接抄的docker-compose.yml
先放完整配置,注释写清楚每个坑点。文件路径/opt/workorder/docker-compose.yml:
version: "3.8"
networks:
frontend_net:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24
backend_net:
driver: bridge
internal: true # 关键:禁止外网访问该网络
ipam:
config:
- subnet: 172.29.0.0/24
services:
mysql:
image: mysql:8.2.0
container_name: workorder-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PWD}
MYSQL_DATABASE: workorder
MYSQL_USER: workorder_app
MYSQL_PASSWORD: ${DB_APP_PWD}
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=caching_sha2_password
volumes:
- mysql_data:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d:ro
networks:
- backend_net
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 5s
timeout: 3s
retries: 20
start_period: 30s # 给MySQL初始化留时间,避免误判
redis:
image: redis:7.2.4-alpine
container_name: workorder-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PWD}"]
volumes:
- redis_data:/data
networks:
- backend_net
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PWD}", "ping"]
interval: 2s
timeout: 2s
retries: 15
backend:
build:
context: ./backend
dockerfile: Dockerfile
image: workorder-backend:1.2.0
container_name: workorder-backend
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/workorder?useSSL=false&allowPublicKeyRetrieval=true
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
SPRING_DATA_REDIS_PASSWORD: ${REDIS_PWD}
ports:
- "8080:8080"
networks:
- frontend_net
- backend_net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 60s # Spring Boot冷启动慢,给足时间
nginx:
image: nginx:1.25.3-alpine
container_name: workorder-nginx
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
ports:
- "80:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/static:/usr/share/nginx/html:ro
networks:
- frontend_net
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/health || exit 1"]
interval: 5s
timeout: 3s
retries: 10
volumes:
mysql_data:
driver: local
redis_data:
driver: local
五、踩坑实录:三个把我坑到凌晨的细节
坑1:环境变量转义。healthcheck里用了$$MYSQL_ROOT_PASSWORD,如果写成$MYSQL_ROOT_PASSWORD,Compose会在宿主机层解析这个变量,而不是容器内。宿主机根本没有这个环境变量,导致mysqladmin因为无密码而ping失败,健康检查永远红灯。这个$$是Compose的转义语法,类似Makefile里的$$。
坑2:internal网络和端口映射冲突。我最初把internal: true加在了frontend_net上,结果Nginx的80端口映射直接失效,因为internal网络不允许任何外部流量进入宿主机端口映射。正确做法是internal: true只给backend_net,数据库和Redis不需要对外暴露任何端口,而前端网络保持普通bridge。
坑3:Spring Boot的start_period设太短。一开始设10秒,结果健康检查在应用还没完成端口监听时就判定失败,触发了restart策略,然后容器无限重启。Java 21 + Spring Boot 3的启动时间大约17秒,加上连接池初始化,我把start_period调到60秒才稳定。这期间健康检查虽然执行但不会计入失败次数,等过了这个窗口才真正算探活。
六、效果数据:从4分15秒到58秒的转变
改造前(无健康检查,只靠depends_on默认行为):
- 服务完全可用的时间:4分15秒
- 期间后端容器重启次数:6次
- 失败告警数:47条
改造后(本文配置):
- 服务完全可用的时间:58秒(MySQL初始化花了23秒,Spring Boot启动17秒,剩余是Nginx等待)
- 后端容器重启次数:0次
- 失败告警数:2条(都是启动初期Nginx的短暂连接拒绝,属预期)
另一个优化点是连接池参数。因为Spring Boot等待MySQL健康后才启动,连接池不再创建失效连接,我在application.yml里把maximum-pool-size从50降到20,connection-timeout从30000ms降到5000ms,因为现在连接可靠了,不需要那么大的容错冗余。
压测数据(wrk -t8 -c200 -d60s):
- 改造前:平均延迟 342ms,P99 1.2s,错误率 3.8%
- 改造后:平均延迟 158ms,P99 480ms,错误率 0.02%
延迟下降一半多,主要是数据库连接不再频繁重建,Redis连接复用率从70%提升到99.5%。
七、总结与改进方向
这套编排方案核心就一句话:用healthcheck定义"健康"的标准,用depends_on的condition确保启动顺序,用internal网络隔离敏感服务。但有几个边界情况我还没完全解决:
- MySQL主从复制场景:当前的healthcheck只检测单实例
ping,如果将来做读写分离,需要额外检查SHOW SLAVE STATUS的Slave_IO_Running和Slave_SQL_Running字段,这会复杂很多。 - 滚动更新:Compose的
docker compose up -d遇到镜像更新时,服务会先停后启,而不是滚动。生产环境建议用docker compose up -d --no-deps --scale backend=2手动控制,或者上Swarm。
如果你也在用Compose编排多服务,别指望默认行为。把健康检查写进配置,比事后看日志猜原因高效一百倍。有更好方案的兄弟,评论区聊,我这套配置在GitHub上开源了,链接放简介。
(全文完,代码块可直接复制使用,注意替换${}环境变量和路径)