一、问题背景:docker run撑不住多服务了

上个月接了个活,要给一个内部系统做容器化改造。架构不复杂:Nginx做反向代理,后端是Spring Boot(Java 17),数据层用MySQL 8.0,缓存用Redis 7。本地开发时,我习惯开四个终端窗口,每个窗口敲一个docker run命令。但到了测试环境,问题来了:

  • 每次重启机器,要按顺序手动启动MySQL → Redis → 后端 → Nginx,顺序错了后端就报连不上数据库
  • 容器IP是动态的,后端配置文件里的redis-host写死localhost,在容器里根本不通
  • 数据存不住,MySQL容器一删,库表全没了
  • 最恶心的是,有时候后端启动比MySQL快,Spring Boot重试机制没配好,直接启动失败

我意识到,必须引入Docker Compose做编排。这玩意儿不是简单的“批量docker run”,它内置了DNS解析、网络隔离、依赖控制,是单机多容器部署的最优解。

二、环境与版本:先对齐再干活

写这篇文章时,我的环境是:

  • 操作系统:Ubuntu 22.04.3 LTS(内核5.15.0)
  • Docker Engine:24.0.7
  • Docker Compose:v2.24.0(注意,新版Compose是Docker CLI插件,不是独立的docker-compose命令)
  • 镜像版本:mysql:8.0.35、redis:7.2.3、openjdk:17-jdk-slim、nginx:1.25.3

验证版本命令:

docker --version          # Docker version 24.0.7
docker compose version    # Docker Compose version v2.24.0

强烈建议升级到Compose v2,v1的yaml格式和依赖处理逻辑有不少坑,v2对depends_on的conditions支持更完善。

三、方案设计:三个网络隔离,两个卷持久化

先画个架构草图(脑补一下):

                    ┌──────────────┐
                    │    Nginx     │
                    │   :80/443    │
                    └──────┬───────┘
                           │ 反向代理
              ┌────────────┼────────────┐
              │            │            │
        ┌─────▼─────┐  ┌──▼────────┐  ┌─▼──────────┐
        │  前端静态  │  │ SpringBoot │  │  预留Admin  │
        │  资源文件  │  └─────┬──────┘  └────────────┘
        └───────────┘        │
                   ┌─────────┼─────────┐
                   │         │         │
              ┌────▼───┐ ┌──▼─────┐
              │ MySQL  │ │ Redis  │
              └────────┘ └────────┘

设计决策:

  1. 三个自定义网络
  2. frontend_net:Nginx和前端资源容器
  3. backend_net:Spring Boot和Nginx(Nginx要转发API请求)
  4. data_net:后端和MySQL、Redis

为什么不用一个扁平网络?安全隔离。Nginx容器理论上不需要直接访问MySQL,如果被攻破,攻击面被限制在frontend网络内。但后端和Nginx需要通信,所以Nginx同时挂了frontend和backend两个网卡。

  1. 卷挂载策略
  2. MySQL数据目录挂到宿主机命名卷 mysql_data,防止容器删除丢数据
  3. 后端日志目录挂到 ./logs/backend(bind mount),方便排查问题
  4. Nginx静态资源目录挂到 ./html,改前端代码不用重新build镜像

  5. 启动顺序控制
    MySQL和Redis先起,后端必须等MySQL健康检查通过后才启动,Nginx最后。这里不是用简单的depends_on: - mysql,因为那只能保证“容器创建顺序”,不能保证“服务可用”。必须配合healthcheck

四、核心实现:docker-compose.yml逐行拆解

直接上完整配置(这是生产环境精简版,去掉了敏感信息):

version: "3.8"

services:
  mysql:
    image: mysql:8.0.35
    container_name: myapp-mysql
    restart: always
    networks:
      - data_net
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init-sql:/docker-entrypoint-initdb.d:ro
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root123}
      MYSQL_DATABASE: myapp_db
      MYSQL_USER: myapp_user
      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-user123}
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --default-authentication-plugin=mysql_native_password
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-root123}"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 30s

  redis:
    image: redis:7.2.3-alpine
    container_name: myapp-redis
    restart: always
    networks:
      - data_net
    volumes:
      - redis_data:/data
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-redis123}"]
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis123}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  backend:
    build: ./backend
    image: myapp-backend:1.0.0
    container_name: myapp-backend
    restart: always
    networks:
      - backend_net
      - data_net
    volumes:
      - ./logs/backend:/app/logs
    environment:
      SPRING_PROFILES_ACTIVE: docker
      DB_HOST: mysql
      DB_PORT: 3306
      DB_NAME: myapp_db
      DB_USER: myapp_user
      DB_PASSWORD: ${MYSQL_PASSWORD:-user123}
      REDIS_HOST: redis
      REDIS_PORT: 6379
      REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
    ports:
      - "8080:8080"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy

  nginx:
    image: nginx:1.25.3-alpine
    container_name: myapp-nginx
    restart: always
    networks:
      - frontend_net
      - backend_net
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./html:/usr/share/nginx/html:ro
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      backend:
        condition: service_started

networks:
  frontend_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/24
  backend_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.29.0.0/24
  data_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.30.0.0/24

volumes:
  mysql_data:
    driver: local
  redis_data:
    driver: local

关键点说明:

  • depends_on条件:Compose v2支持condition: service_healthy,意思不是“等容器启动”,而是“等健康检查通过”。如果不写condition,默认只等容器运行,MySQL可能还在初始化,后端就启动,必然报错。
  • healthcheck的start_period:MySQL初始化需要时间(尤其是首次挂载空卷时),start_period: 30s告诉Docker这30秒内的失败不计入重试次数,避免误杀。
  • 跨网络通信:backend容器同时挂backend_net和data_net,所以它能访问MySQL(通过data_net),也能被Nginx访问(通过backend_net)。Nginx只在backend_net里,访问不到MySQL,符合最小权限原则。
  • 环境变量默认值${MYSQL_ROOT_PASSWORD:-root123} 意思是从宿主机环境变量读取,如果没有就默认root123。别在yaml里写死生产密码,用.env文件管理。

五、踩坑记录:三个真实教训

坑1:服务名解析不通,原来是网络没挂全

第一次写compose时,backend的application.yml里配的是redis:6379,但容器启动后报UnknownHostException: redis。排查半天,发现我忘了让backend挂到data_net上。它默认只在默认网络里,而redis在data_net里,DNS解析当然失败。Compose的默认网络是项目名_default,如果你自定义了网络,必须显式声明服务挂哪些网络。

坑2:MySQL健康检查的密码泄露

最开始healthcheck写的是:

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

密码直接明文。后来发现docker inspect能看到命令参数,虽然只是内部网络,但总觉得不踏实。改成从环境变量读取后,至少不是硬编码在yaml里。

坑3:depends_on只等启动,不等就绪

我最初的版本:

depends_on:
  - mysql
  - redis

结果后端启动后,Spring Boot尝试连接数据库,连接池重试6次,每次间隔2秒,第6次崩溃。日志显示Communications link failure。加了healthcheck和condition后,后端容器会等MySQL真正接受连接后才启动,整个启动流程从失败重试的90秒压缩到40秒(MySQL初始化约25秒,Redis约3秒,后端约12秒)。

六、效果数据与运维命令

改造后,我在测试环境压了一轮:

  • 冷启动(全部容器删除后重启):从手动操作约3分钟(含人工判断顺序)缩短到docker compose up -d一条命令,耗时约45秒
  • 服务可用性:连续重启20次,后端启动失败次数从改造前的8次降为0次
  • 网络延迟:容器间通过Compose网络通信比通过宿主机端口转发快约0.3ms(本地回环测试数据,仅供参考)

日常运维命令:

# 查看所有服务状态(含健康检查结果)
docker compose ps

# 查看某个服务日志,-f 是跟随输出
docker compose logs -f backend

# 只重启后端,不影响其他服务
docker compose restart backend

# 停掉所有服务并清理未使用的网络(保留卷)
docker compose down

# 停掉所有服务并删除卷(数据全没,慎用!)
docker compose down -v

# 进入容器排查网络连通性
docker exec -it myapp-backend bash
ping mysql   # 应该能通
curl redis:6379  # 应该能通

七、总结

Docker Compose不是银弹,但它解决了单机多容器编排90%的痛点。核心就三点:

  1. 网络规划:按安全边界划分网络,服务挂多个网络实现跨域通信,别图省事全塞一个扁平网络
  2. 健康检查:这是启动顺序控制的根基,没有healthcheck,depends_on就是个摆设
  3. 卷挂载:容器是无状态的,数据必须落到卷里,bind mount适合开发,named volume适合生产

最后留个思考题:如果你有多个Spring Boot实例做负载均衡,Compose怎么配?我只能说,单机Compose的scale能力有限,真要水平扩展,得上Swarm或K8s了。下篇文章我打算写写从Compose到Swarm的迁移踩坑,有兴趣的评论区扣1。