一、问题背景:手工部署五个服务到底有多痛

去年接手一个中等规模的项目,服务拆分后一共五个组件:Vue打包出来的前端静态资源、Spring Boot后端API、MySQL 8.0、Redis 7.2,外加一个Nginx做反向代理和静态资源托管。一开始团队是写shell脚本部署的,docker run一条条敲,参数全靠记忆。问题很快就来了:

第一,服务之间互相找不到。后端容器里写localhost:3306连MySQL,当然连不上——容器有各自的网络命名空间。第二,数据丢失。有次测试环境重建容器,MySQL数据卷忘了挂,两天的测试数据直接没了。第三,启动顺序混乱。后端比MySQL先起来,连接池初始化失败直接退出,然后就得手动docker start重来一遍,脚本里加sleep 30这种土办法,机器慢的时候30秒也不够。

这些问题本质上都是编排层面的事,用Docker Compose一个文件就能解决。下面把我们最终定稿的配置完整分享出来。

二、环境与版本

先明确版本,避免读者照着抄出现兼容问题:

  • Docker Engine:24.0.7
  • Docker Compose:v2.23.0(注意是Compose V2插件版,命令是docker compose而不是docker-compose)
  • MySQL:8.0.35
  • Redis:7.2.3-alpine
  • OpenJDK:17(后端基础镜像用eclipse-temurin:17-jre-alpine)
  • Nginx:1.25.3-alpine

Compose文件不写version字段——V2版本已经废弃该字段,写了会警告。

三、方案设计:网络、卷、健康检查三件套

整体设计思路:

网络:创建一个自定义bridge网络app-net,五个服务全部接入。自定义bridge自带DNS解析,服务之间直接用服务名互访,比如后端连数据库写mysql:3306即可。不用默认网络的原因是默认网络里所有容器平级,隔离性差,而且默认bridge不支持服务名解析(这是老知识点,但很多人还在踩)。

卷:MySQL数据用命名卷mysql-data,Redis用redis-data开启AOF持久化。Nginx配置文件和前端静态资源用bind mount挂载,方便改完直接reload。

健康检查:MySQL用mysqladmin ping,Redis用redis-cli ping,后端用wget请求/actuator/health。关键是depends_on要配合condition: service_healthy,这才是真正等健康检查通过,而不是只等容器启动。

启动顺序:mysql、redis(并行)→ backend(等前两者healthy)→ nginx(等backend healthy)。

四、核心实现:完整docker-compose.yml

直接上配置,每一处关键参数都加了注释:

# docker-compose.yml
# Compose V2 不再需要 version 字段
services:
  mysql:
    image: mysql:8.0.35
    container_name: app-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: appdb
      MYSQL_USER: appuser
      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
      - ./initdb:/docker-entrypoint-initdb.d: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: 30s

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

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: app-backend:1.4.2
    container_name: app-backend
    restart: unless-stopped
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/appdb?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
      SPRING_DATASOURCE_USERNAME: appuser
      SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PORT: 6379
      JAVA_OPTS: "-Xms256m -Xmx768m"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 8
      start_period: 60s

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      args:
        NODE_ENV: production
    image: app-frontend:1.4.2
    container_name: app-frontend
    restart: "no"          # 一次性构建产物,不需要常驻
    networks:
      - app-net

  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
      - ./nginx/certs:/etc/nginx/certs:ro
      - frontend-dist:/usr/share/nginx/html:ro
    depends_on:
      backend:
        condition: service_healthy
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
      interval: 15s
      timeout: 3s
      retries: 3

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

volumes:
  mysql-data:
  redis-data:
  frontend-dist:

配套的.env文件(放同目录,Compose自动加载):

MYSQL_ROOT_PASSWORD=Str0ngR00tPwd!
MYSQL_PASSWORD=AppUserPwd2024

这里有几个设计点值得展开:

frontend服务用restart: "no"是对的。它只负责构建产物,把dist目录写进frontend-dist命名卷。但这里有个细节——普通容器无法直接把构建产物写入另一个命名卷再退出,实际做法是在frontend的Dockerfile里把产物COPY到/app/dist,然后用一个一次性容器或者init container模式同步到卷里。我们的做法更简单:frontend的Dockerfile基于nginx镜像,直接构建成完整前端镜像,Nginx网关只做反向代理,静态资源由frontend容器自己serve。上面的卷方案适合CI里用docker compose run --rm frontend cp同步,读者按需选择。为了配置的可运行性,我把frontend改成常驻的nginx服务更稳妥:

  frontend:
    image: app-frontend:1.4.2
    container_name: app-frontend
    restart: unless-stopped
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
      interval: 15s
      timeout: 3s
      retries: 3

healthcheck的start_period很关键。MySQL首次初始化要跑initdb脚本、建表,30秒起步;Spring Boot冷启动在我们的机器上实测42秒左右,所以start_period: 60s留足余量。这个参数的含义是:启动窗口期内的失败不计入retries,避免服务还没起来就被判定unhealthy。

MySQL健康检查里用127.0.0.1而不是localhost。官方镜像里localhost可能走socket,而mysqladmin的TCP检查更贴近应用真实连接路径。

启动命令:

docker compose up -d --build
docker compose ps        # 观察 STATUS 列,healthy 才算真正就绪

五、踩坑与优化记录

坑一:depends_on不写condition等于没写。 早期配置只写了depends_on: [mysql],结果只是保证启动顺序,MySQL进程起来了但还没ready,后端照样连不上。加上condition: service_healthy之后才解决。注意这个写法在Compose V2里是原生支持的,V1时代需要version: "2.4",现在不用管。

坑二:MySQL 8.0的认证插件。 后端用mysql-connector-j 8.0.33,默认caching_sha2_password没问题,但如果项目里还有老版本客户端(比如某些Python脚本用pymysql 0.9),会报Authentication plugin 'caching_sha2_password' cannot be loaded。临时方案是在initdb脚本里对该用户执行ALTER USER ... IDENTIFIED WITH mysql_native_password BY ...,长期方案是升级客户端。

坑三:健康检查的wget在alpine镜像里行为差异。 eclipse-temurin:17-jre-alpine里wget是busybox版本,-qO-参数没问题,但如果你用curl就得确认镜像里装了。我们一开始healthcheck写curl,镜像里根本没有,容器一直unhealthy,排查了半小时。教训:healthcheck用的命令必须在镜像里实测存在。

优化点:后端JVM参数通过JAVA_OPTS注入,配合-Xmx768m和容器mem_limit: 1g(未在上面展示,生产环境建议加),避免容器被OOM kill。另外给所有服务加了logging驱动限制,json-file配max-size: 10m、max-file: 3,防止日志把磁盘写满——这个我们真遇到过,一个死循环打日志,两天写了40GB。

六、效果数据

改造前后的对比,数据来自我们测试环境(4核8G虚拟机)连续10次冷启动统计:

指标 shell脚本部署 Compose编排
冷启动耗时(中位数) 5分48秒 1分26秒
启动失败需人工介入次数 10次中3次 10次中0次
环境重建(含数据恢复) 约12分钟 约2分钟
配置变更部署 手动改多处 改yml后up -d

启动失败率从30%降到0,主要归功于健康检查和启动顺序控制。冷启动时间下降主要是因为并行拉取镜像和构建缓存,以及不再需要人工sleep等待。

七、总结

Docker Compose编排的价值不在“能跑起来”,而在“可重复地跑起来”。自定义网络解决服务发现,命名卷解决数据持久化,healthcheck加depends_on条件解决启动依赖——这三件事配置写对了,环境一致性问题基本就消停了。建议把compose文件纳入版本控制,.env里的密码用CI的secret注入,别学我们早期把密码明文提交上去(后来乖乖改了git历史)。

最后提醒一句:Compose适合单机编排,服务再往上长、需要多节点调度的时候,就该考虑Kubernetes了。但在那之前,把compose文件写扎实,能省掉很多半夜爬起来重启容器的觉。