一、问题背景

去年团队接手一个前后端分离项目,技术栈是Vue 3 + Spring Boot 3.2 + MySQL 8.0 + Redis 7。开发环境大家各跑各的,勉强能凑合。但一到测试环境部署就头疼:

  • 手动装MySQL、Redis,配置参数靠口口相传,新同事经常配错端口
  • 后端启动时MySQL还没初始化完,直接报连接超时,得重启好几次
  • 每次部署要25分钟以上,还容易漏步骤
  • 服务器上残留的容器、网络、卷越来越多,清理全靠docker rm -f $(docker ps -aq)这种暴力操作

痛定思痛,决定用Docker Compose把整套编排固化下来。目标很明确:一条docker compose up -d搞定所有服务,启动顺序自动控制,数据持久化不丢,健康状态可观测。

二、环境与版本

先交代下环境,避免版本差异导致踩坑:

组件 版本 说明
操作系统 Ubuntu 22.04 LTS 内核5.15
Docker Engine 24.0.7 支持Compose V2
Docker Compose v2.23.0 docker compose而非docker-compose
MySQL 8.0.35 官方镜像
Redis 7.2.3-alpine 轻量版
OpenJDK 17 (eclipse-temurin:17-jre) Spring Boot 3.2要求
Nginx 1.25.3-alpine 静态资源+反向代理

注意:Compose V2和V1在语法上有差异,比如version字段在V2里已经废弃,depends_oncondition写法也有变化。本文全部基于V2。

三、方案设计

整体架构分四层:

[Nginx] → [Spring Boot App] → [MySQL]
                    ↓
                 [Redis]

设计要点:

  1. 网络隔离:自定义bridge网络app-net,四个服务都接入,通过服务名互相访问(Docker内置DNS)。不暴露MySQL和Redis端口到宿主机,只在内网通信。
  2. 数据持久化:MySQL数据、Redis数据、Nginx日志分别用命名卷挂载,容器删了数据还在。
  3. 健康检查:MySQL用mysqladmin ping,Redis用redis-cli ping,App用/actuator/health端点,Nginx用wget探测。
  4. 启动顺序:用depends_oncondition: service_healthy,确保MySQL和Redis完全就绪后App才启动,App健康后Nginx才启动。
  5. 配置外置:敏感信息(数据库密码等)走.env文件,不硬编码在compose里。

四、核心实现

先看目录结构:

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

4.1 完整的 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
      - --innodb_buffer_pool_size=512M
    volumes:
      - mysql-data:/var/lib/mysql
      - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql: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.3-alpine
    container_name: app-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
    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: myapp-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}?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 -XX:+UseG1GC"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 60s

  nginx:
    image: nginx:1.25.3-alpine
    container_name: app-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./dist:/usr/share/nginx/html:ro
      - nginx-logs:/var/log/nginx
    depends_on:
      app:
        condition: service_healthy
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/healthz"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 10s

networks:
  app-net:
    driver: bridge
    name: app-net

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

4.2 .env 文件

MYSQL_ROOT_PASSWORD=Root@2024!Secure
MYSQL_DATABASE=myapp
MYSQL_USER=appuser
MYSQL_PASSWORD=App@2024!Pass
REDIS_PASSWORD=Redis@2024!Pass

注意.env要加进.gitignore,生产环境用CI/CD的secret注入。

4.3 后端 Dockerfile

FROM eclipse-temurin:17-jre-jammy

RUN apt-get update && apt-get install -y --no-install-recommends wget \
    && rm -rf /var/lib/apt/lists/* \
    && useradd -r -u 1001 appuser

WORKDIR /app
COPY target/myapp-1.0.0.jar app.jar
RUN chown -R appuser:appuser /app
USER appuser

EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

这里装wget是为了健康检查用(eclipse-temurin基础镜像不带)。用非root用户跑是安全最佳实践。

五、踩坑与优化

坑1:MySQL健康检查一直unhealthy

一开始写的是:

test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]

问题在于localhost在容器里可能走socket连接,MySQL初始化阶段socket还没就绪。改成127.0.0.1强制走TCP,并加上start_period: 40s(MySQL首次初始化要建表、建用户,40秒是保守值,实测在2C4G机器上约25秒)。

坑2:depends_on不等待健康

Compose V2里如果只写:

depends_on:
  - mysql

那只是保证启动顺序,不保证MySQL已就绪。必须用map形式加condition: service_healthy。这是新手最容易踩的坑。

坑3:Redis密码在健康检查里不生效

redis-cli ping如果Redis设了密码会返回NOAUTH Authentication required。必须加-a ${REDIS_PASSWORD}。但这样密码会出现在docker inspect里,介意的话可以用REDISCLI_AUTH环境变量替代。

坑4:Nginx启动后502

原因是App虽然healthy了,但Spring Boot的/actuator/health返回UP时,Tomcat可能还没完全接受连接。解决方案是在Nginx配置里加proxy_next_upstream重试,或者给App的start_period留足余量(我们给了60秒)。实际项目里,Spring Boot冷启动约18秒,60秒足够。

优化:日志卷挂载

Nginx日志用命名卷nginx-logs,避免容器删了日志也没了。查看日志:

docker run --rm -v app-nginx-logs:/logs alpine cat /logs/access.log

或者直接docker logs app-nginx

优化:资源限制

生产环境建议加deploy.resources

deploy:
  resources:
    limits:
      cpus: '2.0'
      memory: 1.5G
    reservations:
      memory: 512M

注意在非Swarm模式下,deploy.resources.limits在Compose V2.23+才完全支持,早期版本要用mem_limitcpus

六、效果数据

部署前后对比(同一台4C8G阿里云ECS,Ubuntu 22.04):

指标 手动部署 Compose编排
首次部署耗时 25分钟 6分12秒(含镜像拉取)
增量部署耗时 8分钟 47秒
启动失败率 约15% <1%
服务启动顺序错误 经常发生 0
数据丢失风险 无(命名卷)
新同事上手时间 半天 10分钟

健康检查参数实测数据:
- MySQL start_period: 40s,实测首次启动28秒,留了12秒余量
- App start_period: 60s,实测18秒,余量充足
- 全链路从docker compose up -d到Nginx可访问:约75秒

七、总结

Docker Compose编排多服务,核心就三件事:

  1. 网络:自定义bridge,服务名即主机名,不暴露内部端口到宿主机
  2. :命名卷持久化数据,绑定挂载放配置和静态资源
  3. 启动顺序depends_on + condition: service_healthy + 合理的start_period

健康检查的start_period宁可给大点,不要给小。大点只是多等几秒,小点会导致服务反复重启,反而更慢。

最后提醒一句:Compose适合单机编排,如果要多机集群,还是得上K8s。但对中小项目来说,Compose的性价比极高——配置文件不到150行,解决了80%的部署痛点。

完整配置已上传到GitHub(示例项目地址),有需要的同学自取。有问题欢迎评论区交流。