一、问题背景

去年接手一个内部管理系统,技术栈是典型的前后端分离:Nginx做静态资源服务和反向代理,Spring Boot 2.7应用提供API,MySQL 8.0存业务数据,Redis 7.2做缓存和会话。上线初期我是用四个docker run命令手工起容器的,每次部署要执行十几条命令,还得盯着日志等MySQL初始化完成再启动应用——有一次运维同事顺序搞错,应用容器先起来连不上数据库,直接崩溃重启了7次。

后来改用Docker Compose编排,把网络、卷、依赖关系全部写进一个YAML文件。这篇文章就把这套配置完整拆解一遍,包括我调试健康检查参数的过程和踩过的坑。

二、环境与版本

先明确版本,Compose文件格式在不同版本间差异不小:

  • 宿主机:Ubuntu 22.04 LTS,内核 5.15.0
  • Docker Engine:24.0.7
  • Docker Compose:v2.23.0(插件形式,命令是docker compose而非老的docker-compose)
  • Compose文件格式:version: "3.8"(虽然v2已不强制要求version字段,但保留兼容性更好)
  • MySQL镜像:mysql:8.0.35
  • Redis镜像:redis:7.2.3-alpine
  • 应用镜像:基于eclipse-temurin:17-jre-jammy构建
  • Nginx镜像:nginx:1.25.3-alpine

服务器配置是4核8G,跑这套服务资源绰绰有余。

三、方案设计

整体设计思路有四条:

1. 网络隔离:自定义一个bridge网络app-net,四个服务都接入。Docker内置DNS会让服务名直接成为主机名,应用里配置jdbc:mysql://mysql:3306/...即可,不用写IP。同时把Nginx的80端口映射到宿主机,其余服务一律不暴露端口到公网,MySQL和Redis只在容器网络内可达,安全性直接上一个台阶。

2. 卷挂载:分两类。数据库数据用命名卷mysql-data、redis-data,由Docker管理生命周期,docker compose down不会删除;配置文件(my.cnf、nginx.conf)用bind mount绑定到宿主机具体路径,方便修改后docker compose restart生效。

3. 健康检查:MySQL用mysqladmin ping,Redis用redis-cli ping,应用用curl打Spring Boot Actuator的/actuator/health端点,Nginx用wget --spider检查本地80端口。每个服务定义healthcheck,间隔、超时、重试次数按服务特性分别设置。

4. 启动顺序:用depends_on的长语法配合condition: service_healthy。注意——Compose v2里depends_on的短语法(列表形式)只保证容器启动顺序,不保证就绪顺序,这是很多人踩的坑。必须用长语法才能等到健康检查通过。

依赖链是这样的:mysql和redis先就绪 → app等这两个healthy后启动 → nginx等app healthy后启动。

四、核心实现

完整目录结构:

project/
├── docker-compose.yml
├── .env
├── app/
│   └── Dockerfile
├── config/
│   ├── mysql/my.cnf
│   └── nginx/nginx.conf
└── html/

.env文件存放敏感信息和版本号,避免硬编码:

MYSQL_ROOT_PASSWORD=Root@2024#Strong
MYSQL_DATABASE=appdb
MYSQL_USER=appuser
MYSQL_PASSWORD=App@2024#Pass
REDIS_PASSWORD=Redis@2024#Pass

下面是完整的docker-compose.yml:

version: "3.8"

networks:
  app-net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

volumes:
  mysql-data:
  redis-data:

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
      - --max_connections=500
      - --innodb-buffer-pool-size=512M
    volumes:
      - mysql-data:/var/lib/mysql
      - ./config/mysql/my.cnf:/etc/mysql/conf.d/custom.cnf: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: 40s

  redis:
    image: redis:7.2.3-alpine
    container_name: app-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    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

  app:
    build:
      context: ./app
      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/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
      SPRING_DATASOURCE_USERNAME: ${MYSQL_USER}
      SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PORT: 6379
      SPRING_REDIS_PASSWORD: ${REDIS_PASSWORD}
      JAVA_OPTS: "-Xms512m -Xmx1024m"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - app-net
    expose:
      - "8080"
    healthcheck:
      test: ["CMD", "curl", "-f", "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"
    volumes:
      - ./html:/usr/share/nginx/html:ro
      - ./config/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app:
        condition: service_healthy
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost/health"]
      interval: 30s
      timeout: 3s
      retries: 3

应用镜像的Dockerfile(用多阶段构建,最终镜像约180MB):

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 --no-install-recommends curl \
    && 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"]

注意Dockerfile里专门装了curl,因为健康检查要用它。用eclipse-temurin:17-jre-jammy而不是alpine,是因为curl在alpine上的兼容性问题折腾过一轮,索性用ubuntu基础镜像,镜像大40MB但省心。

nginx.conf的关键片段(反向代理到app服务名):

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

server {
    listen 80;
    server_name _;

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

    location /api/ {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        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 5s;
        proxy_read_timeout 60s;
    }

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

部署命令就一条:

docker compose up -d --build

五、踩坑与优化

坑1:depends_on短语法不管用。 一开始我写的是depends_on: [mysql, redis],结果app容器还是在MySQL初始化完成前就启动了,健康检查显示unhealthy。查文档才发现短语法只等容器created,不等healthy。改成condition: service_healthy后解决。另外MySQL 8.0首次初始化要跑一堆SQL,start_period必须给足,我最初设20s不够,容器在初始化期间被判定失败重启了两次,改成40s后才稳定。

坑2:healthcheck里的变量展开。 test: ["CMD", "mysqladmin", "ping", "-p${MYSQL_ROOT_PASSWORD}"]这种写法,变量是在宿主机Compose解析阶段展开的,能正常工作。但如果写成CMD-SHELL配合单引号,变量就在容器内展开了,那时环境变量存在也能用,但两种写法混用容易出错。我统一用exec数组形式,变量在宿主机展开,更可控。

坑3:Redis的healthcheck密码告警。 redis-cli -a password会在日志里打warning说命令行传密码不安全。生产环境可以改用REDISCLI_AUTH环境变量,或者直接忽略——健康检查的日志本来就只在本机可见。我选择了后者,但把timeout从默认的30s改成3s,避免探测卡住。

优化点:给MySQL和应用容器都加了资源限制(生产环境建议加deploy.resources.limits),并给app-net指定了固定子网172.28.0.0/16,避免和公司内网网段冲突——之前用默认网段撞过一次172.17.x.x,导致容器间偶发不通,排查了半天。

六、效果数据

改造前后的对比(同一台4核8G服务器,部署同一版本):

指标 手工docker run Compose编排
部署命令数 12条 1条
冷启动到全服务healthy 约150s,需人工盯日志 约95s,全自动
因启动顺序导致的失败 平均每周1次 0次(3个月统计)
配置文件修改生效 重新run容器 restart单个服务,约5s
全量重建(down + up) 约4分钟 约2分10秒

冷启动快了是因为Compose并行拉取镜像、并行启动无依赖的服务(mysql和redis同时起),而手工命令是串行的。应用容器内存稳定在1.1GB左右,MySQL约600MB,Redis约80MB,整机负载长期在1.5以下。

七、总结

这套配置的核心就三件事:自定义网络解决服务发现和隔离,命名卷解决数据持久化,healthcheck + depends_on长语法解决启动顺序。前两个是常规操作,第三个才是真正容易翻车的地方——短语法和长语法的区别值得每个用Compose的人记牢。

如果项目再复杂一点(比如要上多副本、滚动更新),就该考虑Kubernetes了。但就这个4服务的规模,Compose的运维成本低得多,改配置、看日志、重启服务都是秒级操作,对中小项目来说性价比很高。完整配置可以直接拿去改改就用,把.env里的密码换掉即可。