一、问题背景:为什么单靠docker run会让人崩溃

项目是一个典型的后台管理系统,前端Vue打包成静态资源交给Nginx,后端Spring Boot提供API,数据层是MySQL和Redis。早期我图省事,写了个shell脚本按顺序docker run,结果每次重启机器都是一场灾难:

  • MySQL容器虽然起来了,但初始化数据库要十几秒,后端连上去直接报Connection refused然后退出;
  • Nginx因为找不到后端upstream,502一片;
  • 容器之间只能用宿主机IP互访,换个环境IP全乱;
  • 数据没做卷挂载,一次误删容器,测试数据全没了。

后来彻底转向Docker Compose。它用一份YAML描述所有服务、网络、卷和依赖关系,一条docker compose up -d就能把整套环境拉起来。下面把完整方案拆开讲。

二、环境与版本

先说清楚版本,避免你照着抄却因为版本差异踩坑:

  • Docker Engine 24.0.7
  • Docker Compose v2.23.0(注意是v2,命令是docker compose而不是docker-compose
  • MySQL 8.0.35
  • Redis 7.2.3
  • OpenJDK 17 + Spring Boot 3.2.0
  • Nginx 1.25.3

这里强调一点:depends_on配合condition: service_healthy这个能力,在Compose文件格式3.x的早期版本里是被移除过的,v2重新支持并推荐使用。所以务必确认你的Compose是v2版本。

三、方案设计

整体拓扑很简单:

        ┌─────────┐
用户 →  │  Nginx  │  (80端口对外)
        └────┬────┘
             │ backend:8080
        ┌────▼────┐
        │ backend │  (Spring Boot)
        └──┬───┬──┘
     mysql │   │ redis
      ┌────▼┐ ┌▼─────┐
      │MySQL│ │Redis │
      └─────┘ └──────┘

设计要点:

  1. 自定义bridge网络:所有服务加入同一个network,容器间直接用服务名当主机名解析,不依赖宿主机IP。
  2. 命名卷:MySQL数据、Redis持久化、Nginx日志分别挂载,容器删了数据还在。
  3. 健康检查:MySQL用mysqladmin ping,Redis用redis-cli ping,backend用/actuator/health,全部配healthcheck。
  4. 启动顺序:backend依赖mysql和redis的service_healthy,nginx依赖backend的service_healthy,形成严格的启动链。

四、核心实现:完整docker-compose.yml

先看目录结构:

project/
├── docker-compose.yml
├── .env
├── backend/
│   └── Dockerfile
├── nginx/
│   ├── Dockerfile
│   └── nginx.conf
└── mysql/
    └── init.sql

.env文件放敏感配置,不提交到git:

MYSQL_ROOT_PASSWORD=Root@123456
MYSQL_DATABASE=admin_db
MYSQL_USER=app_user
MYSQL_PASSWORD=App@123456
REDIS_PASSWORD=Redis@123456

下面是完整的docker-compose.yml

version: "3.9"

services:
  mysql:
    image: mysql:8.0.35
    container_name: admin-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      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
      - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    networks:
      - admin-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

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

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: admin-backend:1.0.0
    container_name: admin-backend
    restart: unless-stopped
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
      SPRING_DATASOURCE_USERNAME: ${MYSQL_USER}
      SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PORT: 6379
      SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD}
      JAVA_OPTS: "-Xms512m -Xmx1024m"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - admin-net
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q '\"status\":\"UP\"'"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 60s

  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile
    image: admin-nginx:1.0.0
    container_name: admin-nginx
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - nginx-logs:/var/log/nginx
    depends_on:
      backend:
        condition: service_healthy
    networks:
      - admin-net

volumes:
  mysql-data:
    driver: local
  redis-data:
    driver: local
  nginx-logs:
    driver: local

networks:
  admin-net:
    driver: bridge

后端Dockerfile用多阶段构建,减小镜像体积:

FROM maven:3.9.5-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests -B

FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

注意backend的healthcheck里用到了wget,所以我在运行阶段显式装了它。这是很多人会忽略的坑:基础镜像里没wget,健康检查永远失败,backend永远不healthy,nginx就永远起不来。

五、踩坑与优化

坑1:depends_on不等待健康状态

最初我只写了:

depends_on:
  - mysql
  - redis

结果Compose只保证容器"启动",不保证"就绪"。backend启动时MySQL还在初始化,直接连接失败退出。加上condition: service_healthy后才真正解决问题。

坑2:MySQL健康检查的密码变量转义

healthcheck里如果写-p$MYSQL_ROOT_PASSWORD$会被Compose当成变量插值处理,导致命令变成空密码。必须写成$$MYSQL_ROOT_PASSWORD,用双美元符转义,让它在容器内被shell解析。

坑3:start_period设置太短

MySQL 8.0首次初始化数据目录(执行init.sql)在我的机器上要25秒左右。一开始start_period只给了10秒,健康检查还没等到就判定失败。后来调到30秒,配合retries: 10才稳定。这个值要按你init.sql的复杂度调整。

坑4:Nginx的upstream要用服务名

nginx.conf里后端地址必须写backend:8080,而不是localhost:8080或某个IP。因为Nginx容器和backend容器是平级的,靠Docker内置DNS解析服务名。

upstream backend_server {
    server backend:8080;
    keepalive 32;
}

server {
    listen 80;
    location /api/ {
        proxy_pass http://backend_server;
        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;
        try_files $uri $uri/ /index.html;
    }
}

优化点:

  • JVM参数显式限制堆内存-Xms512m -Xmx1024m,避免容器里JVM按宿主机内存分配导致OOM。
  • MySQL连接池HikariCP的maximum-pool-size设为20,配合max_connections=500绰绰有余。
  • 所有服务加restart: unless-stopped,机器重启后自动拉起。

六、效果数据

在4核8G的测试机上实测:

指标 优化前 优化后
冷启动到全部healthy 经常失败 约42秒
数据库连接失败次数 每次重启必现 0
一键部署命令 多步脚本 docker compose up -d
镜像总体积 约1.8GB 约1.1GB(多阶段构建)

查看服务状态:

$ docker compose ps
NAME            STATUS                   PORTS
admin-mysql     Up (healthy)             3306/tcp
admin-redis     Up (healthy)             6379/tcp
admin-backend   Up (healthy)             8080/tcp
admin-nginx     Up (healthy)             0.0.0.0:80->80/tcp

四个服务全是healthy,心里踏实多了。日志排查用docker compose logs -f backend,重建单个服务用docker compose up -d --build backend,不影响其他容器。

七、总结

Docker Compose编排的核心,说到底就是把"人肉维护的启动顺序"变成"声明式的依赖关系"。几个关键经验:

  1. 只要服务之间有依赖,就用condition: service_healthy,别偷懒只写服务名。
  2. healthcheck的start_period一定要给足,尤其是MySQL这种初始化慢的。
  3. 健康检查命令依赖的工具(wget/curl)必须确保镜像里有。
  4. 数据一律走命名卷,别用匿名卷,否则清理时容易误删。
  5. Compose用v2,命令是docker compose,别再用老的docker-compose

这套配置我现在用在三个项目里,换台机器只要装好Docker,git clonedocker compose up -d就能跑起来,省下的时间够多写不少业务代码了。