一、问题背景:17条docker run命令的维护噩梦

去年接手一个内部数据平台项目,本地开发环境由5个服务组成:前端静态资源走Nginx、后端是Spring Boot 3.2的API、MySQL 8.0存业务数据、Redis 7.2做缓存、Prometheus 2.50做指标采集。项目交接文档里躺着一份start.sh,里面是17条docker run命令,每条都带着一长串-p、-v、--network、-e参数。

这套东西的问题在第二周就暴露了:

  1. 启动顺序靠sleep。脚本里写着docker run mysql ... && sleep 30 && docker run api ...,但MySQL首次初始化要跑建表和种子数据,30秒根本不够,API启动时连不上数据库直接退出,得手动重跑。
  2. 网络配置散落各处。每条命令都要写--network platform-net,新建一个服务忘了加,容器之间就互相解析不到主机名。
  3. 卷挂载路径硬编码。-v /Users/xxx/project/mysql-data:/var/lib/mysql,换台机器就得改路径,团队里三个人三个版本的脚本。
  4. 没有健康状态感知。容器Up不等于服务可用,MySQL容器启动后还要10-15秒才接受连接,但docker ps看起来一切正常,排查问题时极具迷惑性。

迁移到Docker Compose的动力很直接:把这些散落的参数收敛到一个声明式文件里,用depends_on的condition: service_healthy替代sleep。

二、环境与版本

先交代清楚版本,Compose文件格式在不同版本间有破坏性变更,尤其是version字段在Compose V2里已经废弃。

组件 版本 说明
Docker Engine 25.0.3 Linux内核5.15,WSL2下同样验证通过
Docker Compose v2.24.5 使用docker compose(空格)而非docker-compose
Compose文件格式 不写version字段 V2规范,写了会告警
MySQL 8.0.36 官方镜像,带mysqladmin ping健康检查
Redis 7.2.4-alpine 镜像体积仅41MB
Spring Boot API 3.2.2,JDK 17 自建镜像,基于eclipse-temurin:17-jre-alpine
Nginx 1.25.3-alpine 静态资源+反向代理
Prometheus v2.50.1 抓取API的/actuator/prometheus

宿主机是16GB内存的MacBook Pro(M2),Docker Desktop分配8GB。这套配置在4核8G的Ubuntu 22.04服务器上也跑通了,只是构建镜像慢一些。

三、方案设计:网络、卷、健康检查的分层思路

在写YAML之前先把结构想清楚,比直接堆配置高效得多。

网络层:定义一个自定义bridge网络platform-net,所有服务加入。不用默认网络的原因是默认bridge不支持容器名DNS解析(只有自定义网络支持),而且自定义网络能做子网规划,避免和公司VPN的网段冲突。这里指定172.28.0.0/16,实测和办公网段无重叠。

卷层:分两类。MySQL和Redis的数据用命名卷(named volume),由Docker管理生命周期,docker compose down不会删数据(除非加-v);Nginx配置和Prometheus配置文件用绑定挂载(bind mount),因为需要频繁修改并即时生效。这里有个坑后面会讲——绑定挂载的路径是相对compose文件所在目录的。

启动顺序:分三层。MySQL和Redis是第一层,无依赖;API是第二层,依赖前两者健康;Nginx和Prometheus是第三层,依赖API健康。用depends_on + condition: service_healthy实现,健康检查本身用各服务自带的客户端工具做探针。

健康检查参数:统一用interval: 10s、timeout: 5s、retries: 5、start_period按服务初始化耗时单独调。start_period是关键——它期间的失败不计入retries,专门对付MySQL这种启动慢的服务。

四、核心实现:完整compose.yaml拆解

下面是实际在用的compose.yaml,删掉了业务无关的环境变量,保留了所有结构性配置。

name: platform

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

volumes:
  mysql-data:
  redis-data:
  prometheus-data:

services:
  mysql:
    image: mysql:8.0.36
    container_name: platform-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: platform
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      TZ: Asia/Shanghai
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --innodb-buffer-pool-size=512M
    volumes:
      - mysql-data:/var/lib/mysql
      - ./initdb:/docker-entrypoint-initdb.d:ro
    networks:
      - platform-net
    ports:
      - "127.0.0.1:3306:3306"
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 60s

  redis:
    image: redis:7.2.4-alpine
    container_name: platform-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redis-data:/data
    networks:
      - platform-net
    ports:
      - "127.0.0.1:6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    image: platform/api:1.4.2
    container_name: platform-api
    restart: unless-stopped
    environment:
      SPRING_PROFILES_ACTIVE: docker
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/platform?useSSL=false&serverTimezone=Asia/Shanghai
      SPRING_DATASOURCE_USERNAME: ${MYSQL_USER}
      SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PORT: 6379
      JAVA_OPTS: "-Xms256m -Xmx512m"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - platform-net
    ports:
      - "127.0.0.1:8080:8080"
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 6
      start_period: 45s

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

  prometheus:
    image: prom/prometheus:v2.50.1
    container_name: platform-prometheus
    restart: unless-stopped
    command:
      - --config.file=/etc/prometheus/prometheus.yml
      - --storage.tsdb.retention.time=15d
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    depends_on:
      api:
        condition: service_healthy
    networks:
      - platform-net
    ports:
      - "127.0.0.1:9090:9090"

几个值得单独说的点:

$$MYSQL_ROOT_PASSWORD的双美元符号。Compose会对$做变量插值,$$转义成字面量的$,让shell在容器内展开环境变量。如果写成单个$,Compose会尝试在宿主机环境里找这个变量,找不到就替换成空字符串,健康检查命令变成mysqladmin ping -p,永远失败。

端口绑定127.0.0.1:前缀。除了Nginx的80端口,MySQL、Redis、API、Prometheus全部只绑本地回环,避免在开发机上把数据库暴露到局域网。这个细节很多模板会漏。

initdb目录只读挂载。MySQL官方镜像会执行/docker-entrypoint-initdb.d下的.sql和.sh文件,只在数据目录为空时执行一次。挂ro防止容器内意外改写。

API的start_period: 45s。Spring Boot冷启动在8GB内存的机器上实测38秒左右,45秒留了余量。这个值给太小会导致健康检查在启动期间就耗尽retries,容器被标记unhealthy,进而卡死depends_on链。

再补一个配套的.env文件(和compose.yaml同目录,Compose自动加载):

MYSQL_ROOT_PASSWORD=dev_root_pwd_2024
MYSQL_USER=platform_app
MYSQL_PASSWORD=dev_app_pwd_2024

生产环境这份文件不进Git,用CI的Secret注入。

五、踩坑与优化:3个真实问题

坑1:depends_on的condition在旧版Compose里被忽略。

团队里有位同事装的是docker-compose 1.29(Python版),condition: service_healthy写了也不生效,服务照样乱序启动。原因是从Compose文件格式3.x开始,condition在docker-compose(V1)里被移除,只有Compose V2(Go重写版)恢复支持。解决办法只有升级到V2,用docker compose version确认输出是v2.x。这个坑查了半小时,因为YAML本身没有任何语法错误。

坑2:MySQL健康检查通过≠数据库初始化完成。

mysqladmin ping在MySQL进程起来后立刻返回成功,但此时docker-entrypoint-initdb.d里的建表脚本可能还在跑。API连上去发现表不存在,Flyway迁移直接报错退出。优化方式是在initdb目录放一个99-ready.sql,并在API侧把Flyway的connectRetries设为10、connectRetriesInterval设为6秒,双保险。更彻底的做法是把健康检查换成mysql -e "SELECT 1 FROM platform.schema_version",但耦合了业务表,权衡后没采用。

坑3:Mac上绑定挂载的inode事件延迟。

修改nginx.conf后docker compose restart nginx,配置有时不生效。排查发现是Docker Desktop的gRPC-FUSE文件共享有缓存,配置文件的修改事件没及时传到容器。改用docker compose up -d --force-recreate nginx强制重建容器解决。这个在Linux宿主上没出现过,属于Mac/WSL2特有现象。

性能优化:给API的构建加了BuildKit缓存挂载,Dockerfile里RUN --mount=type=cache,target=/root/.m2 mvn package,二次构建从3分20秒降到48秒。另外Prometheus的--storage.tsdb.retention.time=15d限制了本地磁盘占用,实测15天约1.2GB。

六、效果数据与总结

迁移前后的对比(同一台M2 MacBook,冷启动,镜像已存在):

指标 手工docker run Docker Compose
启动命令数 17条 1条
全部服务就绪耗时 约18分钟(含人工等待和重试) 92秒(首次含MySQL初始化)/ 58秒(数据卷已存在)
启动顺序错误次数 平均每次搭建2-3次 0
新人环境搭建时间 半天(含排错) 15分钟

92秒这个数字是docker compose up -d到5个容器全部healthy的实测值,用watch -n1 'docker compose ps --format "table {{.Name}}\t{{.Status}}"'观察到的。

总结几条经验:Compose的价值不在于少敲几条命令,而在于把服务间的依赖关系和就绪语义显式声明出来。depends_on + healthcheck这套组合能消灭绝大部分启动竞态,但前提是健康检查的探针要选对——mysqladmin ping这种"进程活着"级别的探针不够,要尽量探到"服务能干活"的层面。另外,start_period这个参数值得每个服务单独调,抄模板给统一值是最容易出问题的地方。

最后提一句,这套配置在单机开发和小规模部署够用,但如果服务数量超过10个或者需要多机部署,就该考虑Kubernetes或者至少Docker Swarm了。Compose的定位是单机编排,别硬撑。