一、问题背景

去年接手一个内部管理系统,后端是Spring Boot,前端Nginx做静态资源代理,数据层用MySQL 8.0加Redis 7做缓存。开发环境大家各装各的,MySQL版本从5.7到8.0都有,Redis有的用Docker有的用本机,导致"在我机器上能跑"的问题每周至少出现两次。

一开始我尝试用docker run逐个启动容器,写了几个shell脚本。但问题很快暴露:

  1. 容器之间的网络要手动创建、手动连接,脚本越写越长;
  2. MySQL初始化需要时间,Spring Boot启动时连不上数据库直接崩溃退出;
  3. 数据卷挂载路径写死在脚本里,换台机器就要改;
  4. 每次重启要按顺序手动执行四条命令,漏一条就出问题。

后来改用Docker Compose统一编排,把网络、卷、健康检查、依赖顺序全部声明在YAML里,一条docker compose up -d搞定。下面把完整方案拆开讲。

二、环境与版本

先明确版本,Docker Compose的depends_on条件语法在不同版本差异很大,这点后面踩坑会讲。

  • 操作系统:Ubuntu 22.04 LTS
  • Docker Engine:24.0.7
  • Docker Compose:v2.23.0(注意是v2,命令是docker compose而不是docker-compose
  • MySQL镜像:mysql:8.0.35
  • Redis镜像:redis:7.2.3-alpine
  • Nginx镜像:nginx:1.25.3-alpine
  • 后端镜像:基于eclipse-temurin:17-jre-jammy自行构建

三、方案设计

整体思路分四层:

网络层:创建一个自定义bridge网络app-net,四个服务全部接入。自定义网络自带DNS解析,服务之间直接用服务名通信,比默认bridge网络的--link干净得多。同时把MySQL和Redis放在独立的backend-net,只让后端能访问,Nginx不接入,减少暴露面。

存储层:MySQL数据用命名卷mysql-data,Redis用redis-data,Nginx配置和静态资源用绑定挂载,方便开发时热更新。

健康检查层:MySQL用mysqladmin ping,Redis用redis-cli ping,后端暴露/actuator/health,Nginx用wget探测首页。

启动顺序层:用depends_on配合condition: service_healthy,确保后端只在MySQL和Redis都健康后才启动,Nginx只在后端健康后启动。

四、核心实现

下面是完整的docker-compose.yml,可以直接用。

version: "3.9"

networks:
  frontend-net:
    driver: bridge
  backend-net:
    driver: bridge
    internal: true   # 后端网络禁止外部访问

volumes:
  mysql-data:
  redis-data:

services:
  mysql:
    image: mysql:8.0.35
    container_name: app-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
      - --innodb-buffer-pool-size=512M
      - --max-connections=200
    volumes:
      - mysql-data:/var/lib/mysql
      - ./init-sql:/docker-entrypoint-initdb.d:ro
    networks:
      - backend-net
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-proot_pwd_2024"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s

  redis:
    image: redis:7.2.3-alpine
    container_name: app-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redis-data:/data
    networks:
      - backend-net
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: app-backend:1.0.0
    container_name: app-backend
    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
      JAVA_OPTS: "-Xms512m -Xmx1024m"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - backend-net
      - frontend-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 8
      start_period: 60s

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

后端Dockerfile也贴一下,关键是健康检查依赖wget,jammy镜像默认没有,需要装。

FROM eclipse-temurin:17-jre-jammy

RUN apt-get update && apt-get install -y --no-install-recommends wget \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY target/app-backend-1.0.0.jar app.jar

EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

再给一个Nginx的配置片段,重点是/actuator/health要放行,否则后端健康检查会返回401导致容器一直unhealthy。

server {
    listen 80;
    server_name localhost;

    location /health.html {
        return 200 "ok";
        add_header Content-Type text/plain;
    }

    location /api/ {
        proxy_pass http://backend:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
    }

    location / {
        root /usr/share/nginx/html;
        index index.html;
    }
}

五、踩坑与优化

坑1:depends_on不等于等待就绪。 早期我直接写depends_on: [mysql],结果MySQL容器一启动(进程还在初始化)后端就开始连,连不上直接退出。后来必须用condition: service_healthy,这个语法要求Compose v2.1以上,v3的旧语法不支持condition,所以version字段我写的是"3.9"但实际用v2命令行执行,这块官方文档有点绕。

坑2:MySQL健康检查的start_period必须给够。 首次启动要初始化数据目录和跑init-sql脚本,30秒是底线,我设了30s后偶尔还会失败,最终调到30s加retries: 10才稳。如果是导入大SQL,这个值还要往上加。

坑3:internal: true网络导致Nginx连不上后端。 我一开始把backend-net设成internal,结果后端在backend-net里,Nginx在frontend-net里,两个网络隔离,Nginx反代报502。解决办法是让backend同时接入两个网络,Nginx只接frontend-net。internal网络本身没问题,但跨网络通信要规划清楚。

坑4:健康检查命令里的localhost在容器里可能解析异常。 MySQL检查我最初写-h localhost,容器内解析到IPv6导致连接失败,改成127.0.0.1后正常。

优化点:给所有服务加了restart: unless-stopped,宿主机重启后自动拉起;MySQL的innodb-buffer-pool-size从默认128M调到512M,压测时QPS从约800提升到约1100;Redis设了maxmemory-policy allkeys-lru,避免内存打满被OOM Killer干掉。

六、效果数据

改造前后对比,测试机是4核8G的云服务器:

指标 改造前(脚本) 改造后(Compose)
冷启动成功率 约70% 100%(连续50次)
冷启动耗时 手动约90s 自动约45s
服务启动失败排查时间 平均15分钟 平均2分钟(看healthcheck日志)
环境一致性 依赖人工 完全一致

启动命令就一条:

docker compose up -d

查看健康状态:

docker compose ps

四个服务全部显示healthy才算真正就绪。停止并清理:

docker compose down          # 保留数据卷
docker compose down -v       # 连数据卷一起删,慎用

七、总结

Docker Compose编排多服务的核心不在于写多少配置,而在于把"服务就绪"这件事定义清楚。健康检查是基础,depends_on的condition是手段,网络和卷的规划是保障。这套配置上线后,团队新成员从拉代码到本地跑起来只要5分钟,再没出现过"在我机器上能跑"的扯皮。

有一点要提醒:Compose适合单机编排,如果服务要跨多台机器或者需要自动扩缩容,得上Kubernetes。但对中小项目来说,Compose的投入产出比是最高的。