一、问题背景

手上这个项目是个典型的中小型微服务后端:前端静态资源由 Nginx 托管并反代 API,后端是 Spring Boot 单体拆出来的两个服务(api 和 job),存储层用 MySQL 8 存业务数据、Redis 7 做缓存、RabbitMQ 3.12 做异步消息。

最开始部署方式是写了一个 deploy.sh,里面一串 docker run,参数全靠手拼。问题很快就来了:

  1. 启动顺序不可控。脚本里 docker run mysql 之后直接 sleep 30,再起后端。开发机快,没问题;测试服务器磁盘慢,MySQL 初始化要 50 多秒,后端连不上直接退出,容器变成 Exited 状态,得手动重来。
  2. 网络配置散落。每个 run 都要 --network 指定,漏一个就连不通,排查半天。
  3. 数据卷管理混乱。MySQL 数据卷名是随机生成的,换台机器迁移数据得先 docker inspect 找卷名。
  4. 环境变量重复。数据库密码在三个服务的 run 命令里各写一遍,改一次要改三处。

后来统一迁到 Docker Compose,配置文件进 Git,一条 docker compose up -d 搞定。下面讲具体怎么配的。

二、环境与版本

先把版本交代清楚,Compose 的 depends_on 条件语法在不同版本差异很大,这个必须说明:

  • Docker Engine:24.0.7
  • Docker Compose:v2.24.5(插件版,docker compose 而非 docker-compose)
  • 宿主机:Ubuntu 22.04,4C8G
  • 镜像版本:mysql:8.0.36、redis:7.2.4-alpine、rabbitmq:3.12.12-management、nginx:1.25.4-alpine、后端自建镜像基于 eclipse-temurin:17-jre-jammy

注意:depends_on 的 condition: service_healthy 是 Compose 规范 2.1+ 才支持的,Compose v2 全系都支持,但如果你还在用老的 docker-compose 1.x(Python 版),部分版本会忽略 condition 直接按启动顺序来,这点后面踩坑一节会展开。

三、方案设计

整体思路四件事:

网络:建一个自定义 bridge 网络 app-net,所有服务接入。自定义 bridge 自带 DNS 解析,服务之间直接用服务名互访(比如后端连 mysql:3306),不暴露任何端口到宿主机,只有 Nginx 映射 80/443。这样内部服务完全不对外,安全性好很多。

卷:MySQL 数据、Redis 持久化、RabbitMQ 数据、Nginx 日志、后端上传文件,全部用命名卷(named volume),名字固定,方便备份迁移。配置文件(nginx.conf、my.cnf)用 bind mount 挂进去,改完重启容器即生效。

健康检查:MySQL 用 mysqladmin ping,Redis 用 redis-cli ping,RabbitMQ 用 rabbitmq-diagnostics -q ping,后端用 curl 打 actuator 的 /actuator/health。每个服务定义合理的 interval、timeout、retries、start_period。

启动顺序:后端 depends_on MySQL / Redis / RabbitMQ 且要求 service_healthy;Nginx depends_on 后端且要求 service_healthy。这样形成 mysql/redis/rabbitmq → api → nginx 的依赖链,Compose 会自动等待健康状态再启下一层。

四、核心实现

目录结构:

project/
├── docker-compose.yml
├── .env
├── conf/
│   ├── nginx.conf
│   └── my.cnf
└── backend/
    └── Dockerfile

.env 放敏感变量和版本号,compose 文件里用 ${VAR} 引用,避免密码硬编码进 Git:

# .env
MYSQL_ROOT_PASSWORD=Root@2024#prod
MYSQL_DATABASE=appdb
MYSQL_USER=appuser
MYSQL_PASSWORD=App@2024#prod
REDIS_PASSWORD=Redis@2024#prod
RABBITMQ_USER=appmq
RABBITMQ_PASSWORD=Mq@2024#prod

核心的 docker-compose.yml(这是第一个可运行代码块,字段都带注释):

services:
  mysql:
    image: mysql:8.0.36
    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
      - --max_connections=500
      - --innodb_buffer_pool_size=512M
    volumes:
      - mysql-data:/var/lib/mysql
      - ./conf/my.cnf:/etc/mysql/conf.d/custom.cnf: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 --requirepass ${REDIS_PASSWORD} --appendonly yes
    volumes:
      - redis-data:/data
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s

  rabbitmq:
    image: rabbitmq:3.12.12-management
    container_name: app-rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
    volumes:
      - rabbitmq-data:/var/lib/rabbitmq
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 15s
      timeout: 10s
      retries: 8
      start_period: 30s

  api:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: app-api:1.4.2
    container_name: app-api
    restart: unless-stopped
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DB_HOST: mysql
      DB_PORT: 3306
      DB_NAME: ${MYSQL_DATABASE}
      DB_USER: ${MYSQL_USER}
      DB_PASSWORD: ${MYSQL_PASSWORD}
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      MQ_HOST: rabbitmq
      MQ_USER: ${RABBITMQ_USER}
      MQ_PASSWORD: ${RABBITMQ_PASSWORD}
    volumes:
      - upload-data:/app/uploads
    networks:
      - app-net
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS 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.4-alpine
    container_name: app-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./conf/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./static:/usr/share/nginx/html:ro
      - nginx-logs:/var/log/nginx
    networks:
      - app-net
    depends_on:
      api:
        condition: service_healthy

networks:
  app-net:
    driver: bridge
    driver_opts:
      com.docker.network.bridge.name: br-app

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

几个关键点说明:

  • healthcheck 里 $$MYSQL_ROOT_PASSWORD 的双美元是必须的。Compose 在解析 yaml 时会把 $ 当变量插值,写单 $ 会被提前替换成宿主机的空值,导致健康检查命令变成 -p 后面空着,永远失败。这个坑我踩过一次。
  • start_period 很重要。MySQL 首次初始化(建库建用户)比正常启动慢得多,不给 start_period,前几次 retries 会白白失败,容器可能还没初始化完就被标记 unhealthy。我给 MySQL 设了 40s,后端设了 60s(JVM 冷启动 + 建连接池)。
  • depends_on 的 condition 只能保证启动顺序,不保证服务内部就绪。所以后端自己的重试逻辑还是要有,健康检查是兜底不是替代。

第二个代码块是后端 Dockerfile 和 nginx.conf 的关键片段,这块直接影响健康检查能否通过:

# backend/Dockerfile
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
# 时区 + curl(healthcheck 依赖 curl,jre 镜像默认没装)
RUN apt-get update && apt-get install -y --no-install-recommends curl tzdata \
    && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
    && rm -rf /var/lib/apt/lists/*
COPY target/app-api-1.4.2.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-Xms256m", "-Xmx768m", "-jar", "/app/app.jar"]
# conf/nginx.conf(server 段节选)
server {
    listen 80;
    server_name _;

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

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

proxy_pass http://api:8080/ 里的 api 就是 compose 服务名,靠 app-net 的 DNS 解析,不需要写 IP。

启动命令:

docker compose up -d --build
docker compose ps          # 看 STATUS 里的 (healthy) 标记
docker compose logs -f api

五、踩坑与优化

坑 1:healthcheck 里的 $ 转义。上面说过,MySQL 健康检查必须写 $$MYSQL_ROOT_PASSWORD。同理任何命令里带 shell 变量的都要双写。

坑 2:Redis 健康检查带密码。redis-cli ping 在设置了 requirepass 后会返回 NOAUTH Authentication required,健康检查直接失败。必须带 -a ${REDIS_PASSWORD}。另外注意 -a 会让密码出现在进程列表里,生产环境可以考虑用 REDISCLI_AUTH 环境变量,但 compose healthcheck 里传环境变量比较绕,我们权衡后接受了这个小风险(容器内部进程,外部不可见)。

坑 3:depends_on 不等于等待就绪。早期以为写了 condition: service_healthy 就万事大吉,结果 MySQL 健康了但后端连接池初始化时偶发 Communications link failure。原因是 MySQL 的 mysqladmin ping 返回 OK 时,用户权限表可能还在加载。解决办法是给后端加连接重试(Spring 的 spring.datasource.hikari.initialization-fail-timeout 设 60000),双保险。

坑 4:卷权限。后端容器用非 root 用户跑(Dockerfile 里 USER 1000),挂载 upload-data 命名卷后第一次启动写文件报 Permission denied。命名卷默认属主是 root。解决办法是在 Dockerfile 里提前 RUN mkdir -p /app/uploads && chown -R 1000:1000 /app/uploads,让镜像层的目录属主正确,卷初始化时会继承镜像里目录的属主。

优化 1:日志驱动限制。默认 json-file 日志无限增长,跑了两周磁盘告警。给每个服务加:

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

单容器日志上限 150MB,5 个服务最多 750MB,可控。

优化 2:资源限制。8G 机器上 MySQL 的 buffer pool 和 JVM 堆要协调。给 api 加了 deploy.resources.limits(compose v2 非 swarm 模式也生效):cpus 2.0、memory 1G;MySQL memory 1.5G。避免某个服务 OOM 拖垮整机。

优化 3:健康检查间隔调优。最初 interval 都是 5s,5 个服务每秒都在跑检查命令,docker stats 能看到明显的 CPU 抖动(约 3%)。调到 10-15s 后抖动消失,故障发现延迟从 5s 变成 15s,对我们这种内部系统完全可接受。

六、效果数据

迁移前后对比(测试服务器 4C8G,冷启动,清空所有卷):

指标 docker run 脚本 Docker Compose 编排
全栈冷启动耗时 约 90s(含固定 sleep 30s×2) 42s
冷启动成功率(20 次) 14/20 = 70% 20/20 = 100%
配置文件变更后重启 手动改 3 处 run 命令 改 .env 后 docker compose up -d
新机器部署步骤 12 步 3 步(clone、配 .env、up)
数据备份 手动 inspect 找卷名 docker run --rm -v project_mysql-data:/data ... 卷名固定

42s 的构成大致是:MySQL 首次初始化 38s(healthcheck 通过)→ 后端启动 22s → 但后端和 MySQL 有重叠(后端在 MySQL healthy 后才开始,nginx 在后端 healthy 后才开始),实际墙钟时间 42s。瓶颈在 MySQL 初始化,这个换机器配置能改善,编排本身已经压不出更多了。

七、总结

Docker Compose 编排这件事,核心不在 yaml 写得多花哨,而在三件事做对:自定义网络让服务名互通、命名卷让数据可迁移、healthcheck + depends_on condition 让启动顺序可控。三个都到位,多服务项目的部署体验会有质的变化。

几个建议给同样在做的同学:healthcheck 的 start_period 一定要按服务实际初始化时间给足,宁长勿短;depends_on 是启动顺序不是就绪保证,业务层重试逻辑不能省;.env 进 .gitignore,配一份 .env.example 进仓库。这套配置我们跑了三个月,中间扩过一次服务(加了个 job 服务),改 compose 文件加十几行就完事,比当年改脚本舒服太多。