一、问题背景

前段时间接手了一个中小型项目,技术栈是典型的前后端分离:Nginx做静态资源与反向代理,Spring Boot提供API,MySQL存业务数据,Redis做缓存和会话。开发阶段大家都是各自在本机装MySQL、Redis,然后IDE里跑Java,本地起个Nginx。看似没问题,但一到部署和联调就出状况。

最典型的三类问题:

  1. 启动顺序靠运气。API容器启动比MySQL快,连不上数据库直接退出;重启一次有时候能好,有时候要重启三四次。
  2. 环境不一致。同事本地MySQL是5.7,测试服务器是8.0,SQL语法和认证插件差异导致一堆莫名其妙的报错。
  3. 数据卷管理混乱。有人把数据挂在宿主机相对路径,换台机器路径就找不到;有人干脆不挂,容器一删数据全没。

后来我决定用Docker Compose把整套环境固化下来,一条命令拉起全部服务。这篇博客就把最终落地的配置和踩过的坑完整写出来。

二、环境与版本

先明确版本,因为Compose文件格式和Docker Engine版本是强相关的:

  • 宿主机:Ubuntu 22.04 LTS
  • Docker Engine:24.0.7
  • Docker Compose:v2.23.0(注意是v2,命令是docker compose而不是docker-compose
  • Nginx:1.25.3-alpine
  • OpenJDK:eclipse-temurin:17-jre-jammy
  • MySQL:8.0.35
  • Redis:7.2.3-alpine

Compose文件使用version字段其实在v2里已经废弃,直接省略即可,Compose v2会自动识别。我下面给的配置就是不带version的写法。

三、方案设计

整体设计思路:

  • 网络:创建一个自定义bridge网络app-net,四个服务都接入。自定义网络的好处是内置DNS,服务之间可以直接用服务名互相访问,比如API连数据库直接用mysql:3306
  • :MySQL数据、Redis数据、Nginx日志都用命名卷(named volume),不用bind mount。命名卷由Docker管理,跨机器迁移更省心。
  • 健康检查:MySQL用mysqladmin ping,Redis用redis-cli ping,API暴露Spring Boot Actuator的/actuator/health,Nginx用wget请求本地。
  • 启动顺序:用depends_on的长语法配合condition: service_healthy,让API等MySQL和Redis都健康后再启动,Nginx等API健康后再启动。

这样整个依赖链就是:MySQL/Redis → API → Nginx。

四、核心实现

先看目录结构:

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

.env文件放敏感信息和版本号,方便统一管理:

MYSQL_ROOT_PASSWORD=Root@2024
MYSQL_DATABASE=appdb
MYSQL_USER=appuser
MYSQL_PASSWORD=App@2024
API_PORT=8080
WEB_PORT=80

下面是完整的docker-compose.yml,这是本文的核心:

services:
  mysql:
    image: mysql:8.0.35
    container_name: app-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:
      - app-net
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
      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", "--requirepass", "Redis@2024"]
    volumes:
      - redis-data:/data
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "Redis@2024", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    image: app-api:1.0.0
    container_name: app-api
    restart: unless-stopped
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
      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@2024
      JAVA_OPTS: "-Xms256m -Xmx512m"
    expose:
      - "8080"
    networks:
      - app-net
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 60s

  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile
    image: app-nginx:1.0.0
    container_name: app-nginx
    restart: unless-stopped
    ports:
      - "${WEB_PORT}:80"
    volumes:
      - nginx-logs:/var/log/nginx
    networks:
      - app-net
    depends_on:
      api:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 10s

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

networks:
  app-net:
    driver: bridge

几个关键点解释一下:

healthcheck的start_period很关键。MySQL首次初始化要建库建表,我给了30秒;Spring Boot应用启动慢,给了60秒。这段时间内的失败不计入retries,避免刚启动就被判定为不健康。

depends_on的condition写法是Compose v2的长语法,短语法(数组形式)只能保证容器启动顺序,不能保证服务就绪。必须用长语法加service_healthy

API的Dockerfile用了多阶段构建,最终镜像只含JRE:

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 useradd -r -u 1001 appuser
COPY --from=builder /build/target/*.jar app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

注意用非root用户运行,安全上更稳妥。

Nginx的healthcheck我在nginx.conf里配了一个/healthz直接返回200:

server {
    listen 80;
    server_name _;

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

    location /api/ {
        proxy_pass http://api:8080/;
        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_read_timeout 60s;
    }

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

启动命令就一条:

docker compose up -d

看启动状态:

docker compose ps

五、踩坑与优化

坑1:MySQL 8.0认证插件。默认是caching_sha2_password,老版本JDBC驱动连不上。我在command里加了--default-authentication-plugin=mysql_native_password强制用旧插件。不过要注意这个参数在MySQL 8.4里已经被移除,如果你用8.4要换成--mysql-native-password=ON

坑2:healthcheck里的密码变量${MYSQL_ROOT_PASSWORD}在test数组里会被Compose解析,但-p和密码之间不能有空格,写成-p${VAR}才行,否则mysqladmin会把密码当成独立参数。

坑3:Redis healthcheck报警告redis-cli -a会打印"Using a password with -a option may not be safe"的警告到stderr,虽然不影响退出码,但日志很吵。可以在healthcheck的test前加sh -c重定向,或者直接忽略,我选择忽略,因为退出码是对的。

坑4:API健康检查依赖Actuator。一定要在pom.xml里引入spring-boot-starter-actuator,并且application-prod.yml里暴露health端点:

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      show-details: never

优化1:资源限制。生产上我给API加了内存和CPU限制:

deploy:
  resources:
    limits:
      cpus: "1.5"
      memory: 768M
    reservations:
      memory: 512M

优化2:日志轮转。默认json-file驱动会无限增长,加上:

logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

优化3:时区统一。所有容器都设TZ=Asia/Shanghai,避免日志时间对不上。

六、效果数据

改造前后对比,数据来自我们测试环境(4核8G云主机):

指标 改造前 改造后
冷启动命令数 5步手动 1条docker compose up -d
平均启动耗时 90秒 35秒
首次启动可用率 70% 100%
环境重建耗时 约30分钟 约2分钟
镜像总大小 - 约1.1GB

其中API镜像从最初的780MB(单阶段构建带Maven)压到210MB(多阶段+JRE)。启动顺序问题彻底消失,连续重启20次没有出现一次API连不上数据库的情况。

总结

Docker Compose做多服务编排,核心其实就三件事:网络让服务能找到彼此,卷让数据能持久化,健康检查加条件依赖让启动顺序可控。这套配置不复杂,但每一个参数背后都对应一个真实踩过的坑。如果你也在维护类似的中小项目,可以直接把这套模板拿去改,把镜像版本、环境变量和init.sql换掉就能跑。

有两点建议:一是别用latest标签,所有镜像版本钉死;二是healthcheck的start_period宁可给大点,尤其是数据库和Java应用,给少了会误判。