一、问题背景:四个容器,每次重建都是一场赌博

我手头有一个典型的Java微服务项目,包含四个组件:Nginx作为反向代理和静态资源服务、Spring Boot业务服务、MySQL 8.0数据库、Redis 7缓存。项目初期为了方便,每个人本地都用docker run手动启动容器,命令大概长这样:

docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=xxx -p 3306:3306 mysql:8.0
docker run -d --name redis -p 6379:6379 redis:7-alpine
docker run -d --name app --link mysql --link redis -p 8080:8080 myapp:1.0
docker run -d --name nginx -p 80:80 -v ./nginx.conf:/etc/nginx/nginx.conf nginx:1.25

问题很快就暴露了:

  1. 启动顺序不可控。Spring Boot启动时MySQL可能还没初始化完,直接抛Communications link failure,服务退出。只能手动重启app容器。
  2. 网络配置混乱。用--link是过时做法,容器间DNS解析经常出问题。
  3. 数据卷管理靠记忆。不同开发者挂载路径不一致,数据库数据丢失过两次。
  4. 环境重建慢。新人入职要照着文档敲十几条命令,平均耗时25分钟,还经常漏参数。

我们需要一套声明式的编排方案,把网络、卷、依赖关系、健康检查全部固化下来。Docker Compose正好解决这个问题。

二、环境与版本

  • 操作系统:Ubuntu 22.04 LTS
  • Docker Engine:24.0.7
  • Docker Compose:v2.24.0(注意是带docker compose子命令的插件版,不是老的docker-compose Python版)
  • 镜像版本:
  • nginx:1.25.3-alpine
  • mysql:8.0.35
  • redis:7.2.3-alpine
  • 业务服务基于 eclipse-temurin:17-jre 构建

三、方案设计

整体思路:

  1. 自定义bridge网络:创建app-net,四个服务都接入,容器间通过服务名互相访问,避免--link。
  2. 命名卷:MySQL数据和Redis持久化用命名卷(named volume),Nginx配置和日志用bind mount,便于本地修改。
  3. 健康检查:MySQL用mysqladmin ping,Redis用redis-cli ping,业务服务暴露/actuator/health,Nginx用wget探测。
  4. 启动顺序:用depends_on的condition: service_healthy,确保被依赖服务真正就绪后再启动下游。
  5. 重启策略:统一restart: unless-stopped,避免宿主机重启后服务不恢复。

四、核心实现

先看完整的docker-compose.yml:

version: "3.9"

networks:
  app-net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

volumes:
  mysql-data:
  redis-data:

services:
  mysql:
    image: mysql:8.0.35
    container_name: mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: root_pwd_2024
      MYSQL_DATABASE: appdb
      MYSQL_USER: appuser
      MYSQL_PASSWORD: app_pwd_2024
      TZ: Asia/Shanghai
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --default-authentication-plugin=mysql_native_password
      - --max_connections=500
    volumes:
      - mysql-data:/var/lib/mysql
      - ./initdb:/docker-entrypoint-initdb.d:ro
    networks:
      - app-net
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s
    ports:
      - "3306:3306"

  redis:
    image: redis:7.2.3-alpine
    container_name: redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "redis_pwd_2024"]
    volumes:
      - redis-data:/data
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "redis_pwd_2024", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s
    ports:
      - "6379:6379"

  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    image: myapp:1.0
    container_name: app
    restart: unless-stopped
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/appdb?useSSL=false&serverTimezone=Asia/Shanghai
      SPRING_DATASOURCE_USERNAME: appuser
      SPRING_DATASOURCE_PASSWORD: app_pwd_2024
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PORT: 6379
      SPRING_REDIS_PASSWORD: redis_pwd_2024
      JAVA_OPTS: "-Xms512m -Xmx1024m"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 60s
    ports:
      - "8080:8080"

  nginx:
    image: nginx:1.25.3-alpine
    container_name: nginx
    restart: unless-stopped
    depends_on:
      app:
        condition: service_healthy
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/logs:/var/log/nginx
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/health"]
      interval: 15s
      timeout: 3s
      retries: 3
    ports:
      - "80:80"

配套的nginx/conf.d/app.conf:

upstream app_backend {
    server app:8080;
    keepalive 32;
}

server {
    listen 80;
    server_name _;

    location /health {
        access_log off;
        return 200 "ok\n";
    }

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
    }
}

启动命令就一句:

docker compose up -d --build

查看状态:

docker compose ps

五、踩坑与优化

坑1:depends_on不等于等待就绪。 早期只写depends_on: [mysql],Compose只保证容器进程启动,不保证MySQL初始化完成。Spring Boot照样报连接失败。必须配condition: service_healthy,且被依赖服务要有healthcheck。

坑2:MySQL健康检查的密码变量转义。 在healthcheck.test里用$MYSQL_ROOT_PASSWORD会被Compose当成变量替换,必须写成$$MYSQL_ROOT_PASSWORD,让shell在容器内解析。这个小坑我查了半小时文档。

坑3:start_period很关键。 MySQL 8.0首次初始化数据目录大概需要20-30秒,如果start_period太短,健康检查会连续失败,容器被标记unhealthy,导致app永远不启动。我把MySQL的start_period设成30s,app设成60s(JVM启动+Spring上下文加载)。

坑4:自定义subnet避免冲突。 默认bridge网段可能和公司内网冲突,显式指定172.28.0.0/16避免踩雷。

优化点: 用build和image同时指定,本地构建出来的镜像会打上myapp:1.0标签,方便回滚和复用。另外Nginx的keepalive 32配合proxy_http_version 1.1和清空Connection头,能显著降低上游连接开销。

六、效果数据

对比改造前后(同一台开发机,8C16G):

指标 docker run 手动 Docker Compose
环境重建耗时 约25分钟 约3分钟
首次启动成功率 约70% 接近100%
服务启动顺序错误 每周2-3次 0次
新人上手时间 半天 15分钟

docker compose up -d后,docker compose ps显示四个服务全部healthy的平均时间约95秒,其中MySQL初始化占了大头。之后重启环境(数据卷保留)只需约35秒。

七、总结

Docker Compose的价值不只是"少敲几条命令",而是把网络拓扑、数据持久化、依赖顺序、健康状态这些运维知识用声明式配置固化下来,变成可版本控制的代码。核心三点:自定义网络解决服务发现,命名卷解决数据持久化,healthcheck加depends_on.condition解决启动顺序。把这三个用好,多服务本地和测试环境编排基本就不会翻车了。