一、问题背景:为什么我放弃了 docker run 脚本

去年接手一个内部管理系统,服务不多但依赖关系挺烦人:Nginx 做反向代理,Spring Boot 提供 API,MySQL 存业务数据,Redis 做缓存和 session。一开始我写了个 deploy.sh,里面一堆 docker run,参数长得像火车:

docker run -d --name api --network appnet -p 8080:8080 \
  -e SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/app \
  -v /data/logs:/app/logs myapi:1.4.2

问题很快就来了:

  1. 启动顺序靠 sleep。脚本里写 sleep 20 等 MySQL 起来,结果测试机磁盘慢,20 秒不够,API 启动时连不上数据库直接退出,容器重启 17 次才成功(docker inspect 里的 RestartCount 我记得清清楚楚)。
  2. 网络要手动建。换了台机器忘了 docker network create appnet,容器之间 DNS 解析不了,排查了半小时。
  3. 环境变量散落各处。改一个数据库密码要翻三个脚本。
  4. 没有资源限制。某次 MySQL 跑了个大查询把内存吃满,把宿主机上别的服务一起拖死了。

于是决定用 Docker Compose 重构。目标很明确:一条 docker compose up -d 搞定,启动顺序由健康检查驱动,而不是靠猜时间。

二、环境与版本

先交代清楚环境,避免版本差异导致行为不一致:

组件 版本
宿主机 Ubuntu 22.04 LTS,4C8G
Docker Engine 24.0.7
Docker Compose v2.23.0(插件版,命令是 docker compose 不是 docker-compose
MySQL 8.0.35
Redis 7.2.3
Spring Boot 3.2.1(JDK 17)
Nginx 1.25.3-alpine

注意 Compose v2 和 v1 有个关键区别:v2 才完整支持 depends_oncondition: service_healthy 长语法(其实 v1 的 2.1+ 文件格式也支持,但很多人用的 v1 二进制版本太老)。如果你还在用 docker-compose(带横杠),建议先升级。

三、方案设计

整体思路:

  • 网络:自定义 bridge 网络 appnet,服务之间用服务名互相访问,只有 Nginx 暴露 80/443 到宿主机,MySQL、Redis、API 全部只在内部网络。
  • :MySQL 数据、Redis 数据用命名卷(named volume),日志和 Nginx 配置用 bind mount,方便在宿主机直接看。
  • 健康检查:MySQL 用 mysqladmin ping,Redis 用 redis-cli ping,API 用 Spring Boot Actuator 的 /actuator/health
  • 启动顺序:API 依赖 MySQL 和 Redis 健康,Nginx 依赖 API 健康。
  • 资源限制:用 deploy.resources 限制内存,防止单个服务拖垮宿主机。

依赖关系图:

nginx ──depends_on(healthy)──> api ──depends_on(healthy)──> mysql
                                    └──depends_on(healthy)──> redis

四、核心实现

目录结构:

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

.env 文件放敏感配置,不提交到 Git:

MYSQL_ROOT_PASSWORD=RootP@ss2024
MYSQL_DATABASE=app
MYSQL_USER=appuser
MYSQL_PASSWORD=AppP@ss2024
REDIS_PASSWORD=RedisP@ss2024
API_IMAGE=myapi:1.4.2

完整的 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
      - --max-connections=300
      - --innodb-buffer-pool-size=1G
    volumes:
      - mysql-data:/var/lib/mysql
      - ./mysql/init:/docker-entrypoint-initdb.d:ro
    networks:
      - appnet
    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
    deploy:
      resources:
        limits:
          memory: 2G
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "3"

  redis:
    image: redis:7.2.3-alpine
    container_name: app-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redis-data:/data
    networks:
      - appnet
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s
    deploy:
      resources:
        limits:
          memory: 768M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  api:
    image: ${API_IMAGE}
    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_PASSWORD}
      JAVA_OPTS: "-Xms512m -Xmx1024m -XX:+UseG1GC"
    volumes:
      - ./logs/api:/app/logs
    networks:
      - appnet
    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
    deploy:
      resources:
        limits:
          memory: 1536M
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"

  nginx:
    image: nginx:1.25.3-alpine
    container_name: app-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./logs/nginx:/var/log/nginx
    networks:
      - appnet
    depends_on:
      api:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
      interval: 15s
      timeout: 3s
      retries: 3
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

networks:
  appnet:
    driver: bridge
    name: appnet

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

Nginx 配置里加一个 /healthz 用于健康检查:

upstream api_backend {
    server api:8080;
    keepalive 32;
}

server {
    listen 80;
    server_name _;

    location /healthz {
        access_log off;
        return 200 "ok\n";
        add_header Content-Type text/plain;
    }

    location / {
        proxy_pass http://api_backend;
        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;
    }
}

API 需要引入 Actuator 依赖,否则 /actuator/health 是 404:

    org.springframework.boot
    spring-boot-starter-actuator

application-prod.yml 里放开健康端点:

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

启动命令就一句:

docker compose up -d

想确认健康状态:

docker compose ps

输出里 STATUS 列会显示 Up 2 minutes (healthy),这个 healthy 就是启动顺序的开关。

五、踩坑与优化

坑 1:MySQL 健康检查一直 unhealthy。

最初 test 写的是 mysqladmin ping -h localhost,容器内 localhost 走 socket,而 MySQL 初始化阶段 socket 和 TCP 端口状态不同步,导致检查误判。改成 -h 127.0.0.1 强制走 TCP,并且加 start_period: 40s 给初始化留时间。MySQL 首次启动要跑初始化脚本,40 秒是实测值,SSD 上大概 25 秒,机械盘要 50 秒以上,按最慢的来。

坑 2:密码里有特殊字符,健康检查命令解析失败。

mysqladmin -p${MYSQL_ROOT_PASSWORD} 这种写法,如果密码里有 $!,YAML 解析会出问题。解决办法是把密码放进 .env,Compose 会做变量替换,但要注意 .env 里的值不要加引号,加了引号引号本身会变成密码的一部分。

坑 3:depends_on 短语法不生效。

很多人写:

depends_on:
  - mysql

这只是保证启动顺序,不保证 mysql 真的 ready。必须用长语法 condition: service_healthy。而且被依赖的服务必须定义了 healthcheck,否则 Compose 会报错 service "api" depends on service "mysql" which has no healthcheck configured

坑 4:API 的 start_period 太短导致误杀。

Spring Boot 3.2 冷启动大概 25 秒,加上连接池初始化和 Flyway 迁移,实测要 45 秒左右。start_period: 60s 是留了余量的。start_period 期间的失败不计入 retries,这个参数很关键。

优化 1:日志轮转。 没配 logging 之前,某个容器日志涨到 12GB 把磁盘写满了。现在统一 max-size: 20m + max-file: 3,单个服务日志最多 60MB。

优化 2:资源限制。 deploy.resources.limits.memory 在非 Swarm 模式下 Compose v2 也生效(v1 不生效)。MySQL 给 2G,API 给 1.5G,Redis 给 768M,加起来 4.3G,宿主机 8G 还有余量。

优化 3:时区统一。 所有服务加 TZ: Asia/Shanghai,JDBC URL 里带 serverTimezone,避免日志时间对不上。

优化 4:健康检查间隔。 interval 别设太短,10-15 秒够了,太短会给 MySQL 增加无谓的连接压力。timeout 要小于 interval

六、效果数据

迁移前后对比(同一台 4C8G 机器,冷启动,清空所有容器和卷):

指标 docker run 脚本 Docker Compose
一键启动成功率 约 60%(靠 sleep 赌) 100%(连续 30 次测试)
冷启动到全部 healthy 90s(含固定 sleep 60s) 35s
API 容器重启次数 平均 3-17 次 0 次
部署脚本行数 87 行 bash 1 行命令
改配置耗时 改 3 个文件 改 1 个 .env

docker compose ps 的典型输出:

NAME         IMAGE              STATUS                    PORTS
app-mysql    mysql:8.0.35       Up 2 minutes (healthy)    3306/tcp
app-redis    redis:7.2.3-alpine Up 2 minutes (healthy)    6379/tcp
app-api      myapi:1.4.2        Up 1 minute (healthy)     8080/tcp
app-nginx    nginx:1.25.3-alpine Up 50 seconds (healthy)  0.0.0.0:80->80/tcp

注意启动时间是有梯度的:mysql/redis 先 healthy,然后 api 才开始,最后 nginx。这就是 depends_on + healthcheck 的效果。

七、总结

Docker Compose 编排的核心价值不是"少敲几个命令",而是把服务之间的依赖关系用声明式的方式表达出来。healthcheck 定义"什么叫就绪",depends_on: condition: service_healthy 定义"谁等谁",这两者配合才能真正解决启动顺序问题。

几个建议:

  1. 别用 sleep,任何 sleep 都是技术债。
  2. 每个有状态服务都配 healthcheckstart_period 给足。
  3. 敏感配置走 .env.env.gitignore
  4. 日志轮转和资源限制是生产环境必修课,别等磁盘满了才想起来。
  5. Compose v2 的长语法 depends_on 是刚需,尽早升级。

如果你也在维护一堆 docker run 脚本,真的可以花半天时间迁到 Compose,收益比想象中大。