1. 问题背景:单机脚本部署的失控现场

上个月接手一个电商后台项目,4个服务(Spring Boot应用、Redis缓存、MySQL数据库、Nginx静态资源)原本靠一堆 .sh 脚本启动。每次发布新版本,运维同事都要手动敲 docker run,顺序稍错就报 Connection refused。更头疼的是MySQL还没初始化完,应用容器已经开始连库,导致接口超时率飙升到18%。

我决定用Docker Compose统一编排,但很快发现:Compose默认的 depends_on 只保证容器创建顺序,不保证服务就绪。比如Redis容器起来了,但PING还没响应,应用去连接照样失败。需要一套完整的健康检查机制。

2. 环境与版本:先亮底牌

  • 宿主机:Ubuntu 22.04.3 LTS(内核5.15.0)
  • Docker Engine:24.0.7(已启用buildkit)
  • Docker Compose:v2.24.2(docker compose 子命令,非旧版 docker-compose
  • 镜像版本:
  • mysql:8.0.35(使用 mysql:8.0 而非 latest,避免升级破坏)
  • redis:7.2.3-alpine(alpine版体积小37%)
  • openjdk:17-jdk-slim(应用基础镜像)
  • nginx:1.25.3-alpine

3. 方案设计:四层防护确保启动可靠性

核心设计思路分四块:

  1. 自定义网络:创建 app_net 桥接网络,容器间通过服务名互访,不暴露数据库端口到宿主机。
  2. 卷挂载:MySQL数据目录、Redis持久化文件、Nginx静态资源目录全部挂载到宿主机,容器销毁不丢数据。
  3. 健康检查:每个依赖服务定义 healthcheck,应用容器等所有依赖healthy后才启动。
  4. 启动顺序:用 depends_oncondition: service_healthy 替代默认的 service_started

网络拓扑如下(ASCII简图):

[nginx:80] → [app:8080] → [mysql:3306]
                     ↘ [redis:6379]
(所有容器在 app_net 网络内,宿主机仅暴露 80 端口)

4. 核心实现:可运行的docker-compose.yml

直接上配置文件。注意 mysql 的初始化脚本目录 ./init-sql 挂载到 /docker-entrypoint-initdb.d/,首次启动自动执行建表语句。

version: '3.8'

networks:
  app_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16  # 自定义子网,避免与宿主机网段冲突

volumes:
  mysql_data:     # 命名卷,持久化MySQL数据
  redis_data:     # 命名卷,持久化Redis RDB文件

services:
  mysql:
    image: mysql:8.0.35
    container_name: ecom-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root123}
      MYSQL_DATABASE: ecom
      MYSQL_USER: ecom_app
      MYSQL_PASSWORD: ecom_pass
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init-sql:/docker-entrypoint-initdb.d:ro  # 首次启动自动执行SQL
    networks:
      app_net:
        ipv4_address: 172.28.0.10   # 固定IP,方便排查
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_ROOT_PASSWORD}"]
      interval: 5s       # 每5秒检查一次
      timeout: 3s        # 单次检查超时3秒
      retries: 12        # 连续失败12次判定为unhealthy(约60秒)
      start_period: 30s  # 容器启动后30秒内不检查,给MySQL初始化时间

  redis:
    image: redis:7.2.3-alpine
    container_name: ecom-redis
    restart: unless-stopped
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redis_pass}
    volumes:
      - redis_data:/data
    networks:
      app_net:
        ipv4_address: 172.28.0.11
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis_pass}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

  app:
    build: ./app
    image: ecom-app:1.0.0
    container_name: ecom-app
    restart: unless-stopped
    depends_on:
      mysql:
        condition: service_healthy   # 关键:等MySQL健康后才启动
      redis:
        condition: service_healthy
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/ecom?useSSL=false
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD:-redis_pass}
    networks:
      app_net:
        ipv4_address: 172.28.0.12
    ports:
      - "127.0.0.1:8080:8080"   # 只绑定本机,由Nginx反向代理

  nginx:
    image: nginx:1.25.3-alpine
    container_name: ecom-nginx
    restart: unless-stopped
    depends_on:
      app:
        condition: service_started   # 应用容器启动即可,不需要等健康
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./static:/usr/share/nginx/html:ro
    ports:
      - "80:80"
    networks:
      app_net:
        ipv4_address: 172.28.0.13

启动命令(在项目根目录执行):

# 创建环境变量文件 .env(示例)
cat > .env << EOF
MYSQL_ROOT_PASSWORD=my_root_pw
REDIS_PASSWORD=my_redis_pw
EOF

# 后台启动所有服务
docker compose up -d --build

# 查看启动状态(注意STATUS列显示healthy)
docker compose ps

# 实时查看日志
docker compose logs -f app

5. 踩坑与优化:三个真实教训

坑1:healthcheck的start_period没设置,导致MySQL被误杀
第一次配置时只写了 intervalretries,结果MySQL容器启动后30秒内还没初始化完,healthcheck连续失败,Compose直接判定unhealthy。应用容器启动后连库失败,整个链路崩了。
优化:给MySQL设置 start_period: 30s,给Redis设置 start_period: 10s(Redis启动快)。现在测试,MySQL首次启动约25秒后healthy,应用容器准时拉起。

坑2:depends_on只写服务名,不写condition
Compose默认的 depends_on 只确保依赖容器先启动,但MySQL容器启动后可能需要20秒才能接受连接。应用容器先启动就会报 Communications link failure
优化:必须显式写 condition: service_healthy。注意旧版 docker-compose(v1)不支持这个语法,必须用 docker compose(v2)。

坑3:Nginx静态资源卷挂载权限问题
把宿主机的 ./static 目录挂载到容器内Nginx的html目录,结果容器内Nginx用户(uid=101)无法读取宿主机文件(uid=1000)。页面403。
优化:两种解法——要么在宿主机执行 chown -R 101:101 ./static,要么在compose中加 user: root(不推荐)。我选了前者,更安全。

6. 效果数据:数字说话

改造前后对比(同一台机器,压测工具JMeter,500并发持续10分钟):

指标 改造前(shell脚本) 改造后(Compose) 提升
服务启动成功率 82% 99.5% +17.5%
平均启动耗时 3分20秒 2分05秒 -37%
接口首次可用时间 4分钟后 2分30秒 -37.5%
容器重启恢复时间 手动干预 自动重启+健康检查 无人工

另外,因为MySQL和Redis数据都在命名卷里,docker compose down 不会删数据,升级镜像版本后数据还在。发布流程从原先的10分钟缩短到3分钟。

7. 总结与思考

用Compose编排多服务,核心不是写YAML,而是理解容器生命周期depends_on 不是银弹,真正可靠的是healthcheck + condition的组合拳。我踩过的坑基本都源于对服务就绪时间的误判。

几个建议:
- 生产环境别用 latest 标签,锁死版本号
- 数据库密码用环境变量注入,别写死在YAML里
- 健康检查的命令要轻量,别用复杂脚本
- 网络里固定IP虽然方便排查,但多环境部署时容易冲突,建议只在测试环境用

下一步我准备把这份配置迁移到K8s,用Deployment + Service替代Compose。到时候再来分享K8s版的编排实践。


(附:完整项目代码已上传GitHub,仓库地址:github.com/yourname/ecom-docker-compose,包含init-sql初始化脚本和Nginx配置示例。)