一、凌晨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_oncondition: 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网络隔离敏感服务。但有几个边界情况我还没完全解决:

  1. MySQL主从复制场景:当前的healthcheck只检测单实例ping,如果将来做读写分离,需要额外检查SHOW SLAVE STATUSSlave_IO_RunningSlave_SQL_Running字段,这会复杂很多。
  2. 滚动更新:Compose的docker compose up -d遇到镜像更新时,服务会先停后启,而不是滚动。生产环境建议用docker compose up -d --no-deps --scale backend=2手动控制,或者上Swarm。

如果你也在用Compose编排多服务,别指望默认行为。把健康检查写进配置,比事后看日志猜原因高效一百倍。有更好方案的兄弟,评论区聊,我这套配置在GitHub上开源了,链接放简介。


(全文完,代码块可直接复制使用,注意替换${}环境变量和路径)