一、问题背景

前段时间接手一个内部管理系统,结构不复杂但服务不少:前端静态资源由Nginx托管,后端是Spring Boot 2.7应用,数据层用MySQL 8.0,缓存用Redis 7.2。之前部署靠一套shell脚本,先起MySQL,sleep 30,再起Redis,再sleep 5,最后起应用和Nginx。问题很明显:

  1. sleep时间全靠猜,机器慢的时候MySQL没起来,应用直接连不上库启动失败;
  2. 服务之间用宿主机端口通信,端口冲突频繁;
  3. 数据目录直接挂宿主机路径,换台机器就要重新改配置;
  4. 重启顺序靠人工记忆,运维交接成本高。

后来改用Docker Compose编排,把网络、卷、健康检查、启动依赖全部声明式写进一个YAML文件。这篇文章就把这套配置和踩过的坑完整记录下来。

二、环境与版本

先明确一下环境,避免版本差异导致行为不一致:

  • 操作系统:Ubuntu 22.04 LTS
  • Docker Engine:24.0.7
  • Docker Compose:v2.23.3(注意是Compose V2插件,命令是docker compose而不是docker-compose
  • MySQL镜像:mysql:8.0.36
  • Redis镜像:redis:7.2.4-alpine
  • 后端基础镜像:eclipse-temurin:17-jre-jammy
  • Nginx镜像:nginx:1.25.3-alpine

这里特别提醒:depends_oncondition语法在Compose V2里是原生支持的,但如果你还在用V1的docker-compose二进制,部分condition行为不一致,建议直接升到V2。

三、方案设计

整体设计思路如下:

  1. 网络:自定义一个bridge网络app-net,所有服务接入同一网络,服务之间用服务名做DNS解析,不暴露内部端口到宿主机。只有Nginx对外映射80端口。
  2. :MySQL数据、Redis数据、Nginx日志、应用日志分别用命名卷(named volume)持久化,避免宿主机路径耦合。
  3. 健康检查:MySQL用mysqladmin ping,Redis用redis-cli ping,后端用/actuator/health接口,Nginx用wget探测本地。
  4. 启动顺序:用depends_on + condition: service_healthy,让后端必须等MySQL和Redis健康后再启动,Nginx等后端健康后再启动。
  5. 重启策略:统一用restart: unless-stopped,避免宿主机重启后服务不自启。

四、核心实现

完整的docker-compose.yml如下,可以直接跑:

version: "3.9"

networks:
  app-net:
    driver: bridge

volumes:
  mysql-data:
  redis-data:
  nginx-logs:
  app-logs:

services:
  mysql:
    image: mysql:8.0.36
    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
      - --max_connections=500
      - --innodb-buffer-pool-size=512M
    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: 40s

  redis:
    image: redis:7.2.4-alpine
    container_name: app-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redis-data:/data
    networks:
      - app-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&allowPublicKeyRetrieval=true
      SPRING_DATASOURCE_USERNAME: appuser
      SPRING_DATASOURCE_PASSWORD: app_pwd_2024
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PORT: 6379
      JAVA_OPTS: "-Xms256m -Xmx512m"
    volumes:
      - app-logs:/app/logs
    networks:
      - app-net
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'"]
      interval: 15s
      timeout: 5s
      retries: 10
      start_period: 60s

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

Nginx的upstream配置(nginx/conf.d/app.conf)指向服务名backend

upstream backend_api {
    server backend:8080 max_fails=3 fail_timeout=10s;
    keepalive 32;
}

server {
    listen 80;
    server_name _;

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

    location /api/ {
        proxy_pass http://backend_api/;
        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 3s;
        proxy_read_timeout 30s;
    }

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

启动命令很简单:

docker compose up -d --build
docker compose ps

docker compose ps会显示每个服务的健康状态,STATUS列看到healthy就说明整条链路都通了。

五、踩坑与优化

坑1:MySQL健康检查一直unhealthy。 最初写的是mysqladmin ping -h localhost,结果容器内localhost走socket,而初始化阶段socket还没就绪,导致误判。改成-h 127.0.0.1强制走TCP,并加start_period: 40s给初始化留足时间,问题解决。另外密码变量在YAML里要用$$转义,否则会被Compose提前解析。

坑2:backend启动太快,MySQL还没建完表。 即使service_healthy了,MySQL初始化脚本(/docker-entrypoint-initdb.d)可能还在跑。解决办法是在initdb脚本最后加一行SELECT 1;并确保脚本执行完MySQL才返回健康。更稳妥的做法是后端加Flyway/Liquibase迁移,让应用自己保证schema。

坑3:卷权限。 后端镜像里用的是非root用户appuser(uid 1000),挂载app-logs命名卷后写日志报Permission denied。解决方案是在Dockerfile里mkdir -p /app/logs && chown -R 1000:1000 /app/logs,或者用user: "1000:1000"显式指定。

坑4:健康检查间隔太密。 一开始interval设成5s,docker compose ps刷屏不说,MySQL在高负载下偶尔超时被判定unhealthy,触发restart。调到10-15s,retries给到10,稳定很多。

优化点: 后端healthcheck用了wget而不是curl,因为eclipse-temurin:17-jre-jammy基础镜像不带curl,装curl会多30MB。alpine系的Nginx和Redis自带wget/redis-cli,直接复用。

六、效果数据

在同一台4C8G的机器上对比:

  • 手动脚本部署:冷启动约180秒,其中sleep占掉约90秒,且偶发失败需要重跑;
  • Compose编排部署:冷启动约40秒,docker compose up -d返回后约35秒内四个服务全部healthy;
  • 服务重启(只重启backend):约12秒恢复到healthy;
  • 全量重建(downup,保留卷):约55秒。

另外,因为内部端口不暴露到宿主机,端口冲突问题彻底消失,同一台机器可以并行跑多套环境(改一下container_name和网络名即可)。

七、总结

Docker Compose做多服务编排,核心就三件事:网络让服务能互相找到,卷让数据不丢,健康检查加depends_on让启动顺序可控。把这三件事写清楚,部署脚本基本可以退休了。几个关键参数值得记住:start_period给慢启动服务留缓冲,condition: service_healthy替代盲目sleep,命名卷替代宿主机路径绑定。如果你的服务数量还在个位数,Compose的性价比远高于K8s,别为了技术栈好看把简单问题复杂化。