一、问题背景:手工docker run的痛

去年接手一个项目,技术栈是 Vue + Nginx 做前端,Spring Boot 做后端,MySQL 8 存数据,Redis 7 做缓存。最初部署方式是写一个 deploy.sh,里面一堆 docker run 命令,网络用默认 bridge,数据卷用匿名卷,服务之间靠 --link 互联。

问题很快暴露出来:

  1. 启动顺序不可控。后端容器比 MySQL 先起来,连接池初始化直接抛 Communications link failure,然后容器退出,得手动重启。
  2. 数据丢失。有一次误删容器,匿名卷没保留,MySQL 数据全没了,恢复花了三个小时。
  3. 网络混乱。默认 bridge 下容器 IP 会变,后端配置里写死的 IP 隔三差五失效。
  4. 健康状态不可见docker ps 只显示 running,但容器里进程可能已经假死,外部根本不知道。

统计了一下,那段时间每次重新部署,四个服务全部正常启动的概率只有 62% 左右,冷启动平均耗时 48 秒,其中大部分时间浪费在反复重启后端容器上。

后来全面改用 Docker Compose 编排,把网络、卷、健康检查、依赖顺序全部声明式管理,问题基本解决。下面把配置和踩坑过程完整记录下来。

二、环境与版本

先说明本文所有配置的验证环境,版本不一致可能导致行为差异:

  • 操作系统:Ubuntu 22.04 LTS
  • Docker Engine:24.0.7
  • Docker Compose:v2.23.0(注意是 Compose V2 插件版,命令是 docker compose 而非 docker-compose
  • Nginx:1.25.3-alpine
  • OpenJDK:17(Spring Boot 3.2.0 要求 JDK 17+)
  • MySQL:8.0.35
  • Redis:7.2.3-alpine

Compose 文件格式使用 version: "3.8",虽然 V2 已经不强制要求 version 字段,但保留它能兼容部分老工具链。

三、方案设计

整体设计思路如下:

网络层:创建一个自定义 bridge 网络 app-net,四个服务全部接入。自定义 bridge 自带 DNS 解析,服务之间可以直接用服务名互访,比如后端连数据库写 mysql:3306 即可,不用关心 IP。

存储层:MySQL 数据、Redis 持久化文件、Nginx 日志、后端上传文件,全部用命名卷(named volume)挂载。命名卷由 Docker 管理,删容器不删卷,数据安全。

健康检查层:每个有状态服务都配 healthcheck。MySQL 用 mysqladmin ping,Redis 用 redis-cli ping,后端用 Spring Boot Actuator 的 /actuator/health,Nginx 用 wget 请求本地首页。

启动顺序层:用 depends_oncondition: service_healthy 语法。注意这是 Compose V2 才支持的写法,V1 只支持 condition: service_started,那个只能保证容器启动,不能保证服务就绪。

四、核心实现

4.1 目录结构

project/
├── docker-compose.yml
├── backend/
│   ├── Dockerfile
│   └── target/app.jar
├── frontend/
│   ├── Dockerfile
│   ├── dist/
│   └── nginx.conf
└── mysql/
    └── init.sql

4.2 完整的 docker-compose.yml

这是核心文件,每一段都加了注释说明:

version: "3.8"

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

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

services:
  mysql:
    image: mysql:8.0.35
    container_name: app-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: Root@123456
      MYSQL_DATABASE: appdb
      MYSQL_USER: appuser
      MYSQL_PASSWORD: App@123456
      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", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-pRoot@123456"]
      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@123456 --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "Redis@123456", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s

  backend:
    build:
      context: ./backend
      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/appdb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
      SPRING_DATASOURCE_USERNAME: appuser
      SPRING_DATASOURCE_PASSWORD: App@123456
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PORT: 6379
      SPRING_REDIS_PASSWORD: Redis@123456
      JAVA_OPTS: "-Xms512m -Xmx1024m -XX:+UseG1GC"
    volumes:
      - upload-data:/app/upload
    networks:
      - app-net
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "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"
      - "443:443"
    volumes:
      - ./frontend/dist:/usr/share/nginx/html:ro
      - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - upload-data:/usr/share/nginx/html/upload:ro
      - nginx-logs:/var/log/nginx
    networks:
      - app-net
    depends_on:
      backend:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/health"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 10s

4.3 后端 Dockerfile

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

FROM eclipse-temurin:17-jre-jammy AS runtime

WORKDIR /app

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

COPY target/app.jar /app/app.jar
RUN mkdir -p /app/upload && chown -R appuser:appuser /app

USER appuser

EXPOSE 8080

ENV JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC"

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

注意这里必须装 wget,因为 healthcheck 要用它请求 actuator 接口。alpine 镜像默认有 wget,但 temurin 的 jammy 基础镜像没有,不装的话健康检查永远失败。

4.4 启动命令

# 首次构建并启动
docker compose up -d --build

# 查看健康状态
docker compose ps

# 实时查看某服务日志
docker compose logs -f backend

五、踩坑与优化

坑1:start_period 设太短导致后端被误判为不健康

最初后端 healthcheck 的 start_period 只给了 20s,结果 Spring Boot 启动要 35 秒左右,健康检查还没等应用就绪就开始计失败次数,连续 8 次失败后容器被标记为 unhealthy,nginx 因为 depends_on 条件一直不启动。

后来把 start_period 调到 60s,retries 调到 8,问题解决。start_period 内的失败不计入 retries,这个参数对有状态服务特别关键。

坑2:MySQL 的 start_period 不够长

MySQL 8 首次启动要初始化数据目录、执行 init.sql,在低配机器上可能超过 30 秒。原来 start_period: 20s 不够,backend 一直等不到 mysql healthy。改成 40s 后稳定。

坑3:redis-cli 带密码时的告警

redis-cli -a password ping 会输出一行警告 "Warning: Using a password with '-a'..." 到 stderr。如果 healthcheck 只判断 exit code 没问题,但如果用输出内容判断就会失败。建议就用 exit code,或者改用 REDISCLI_AUTH 环境变量。

坑4:卷挂载权限问题

backend 容器里用非 root 用户 appuser 运行,挂载 upload-data 卷时,卷目录默认属主是 root,导致上传文件写入失败。解决办法是在 Dockerfile 里提前 mkdirchown,Docker 初始化命名卷时会继承镜像里的属主设置。

优化:健康检查间隔调优

默认 30s 间隔太慢,前端请求会打到还没就绪的后端。改成 15s 后,服务整体就绪时间明显缩短。但也不要低于 5s,否则频繁的健康检查会占用容器资源。

六、效果数据

改造前后对比(同一台 4C8G 机器,冷启动):

指标 改造前 改造后
全部服务启动成功 62% 99%
冷启动耗时 48s 22s
数据丢失事件 3次/年 0
部署命令条数 12条 1条

启动成功率的提升主要来自健康检查加条件依赖,避免了后端在数据库没就绪时启动失败。冷启动时间反而缩短,是因为不再需要人工反复重启后端容器。

七、总结

Docker Compose 编排的核心价值在于把网络、存储、依赖顺序这些"隐式知识"变成声明式配置。几个关键点再强调一遍:

  1. 自定义 bridge 网络是服务发现的基础,别再用默认 bridge 和 --link
  2. 命名卷保证数据安全,删容器不删卷。
  3. healthcheck + depends_on 的 service_healthy 条件是保证启动顺序的正确姿势,service_started 不够用。
  4. start_period 一定要根据服务实际启动时间设置,宁长勿短,它不影响就绪后的检查频率。

这套配置在我们项目跑了半年多,重新部署从未出过启动顺序问题。如果你也在手工 docker run 一堆服务,强烈建议换成 Compose。