一、问题背景:当docker run撑不住的时候

上个月我们团队接手了一个电商后台项目,拆完微服务后一共6个节点:Nginx网关、前端静态资源、后端Java应用(Spring Boot)、MySQL、Redis、以及一个定时任务Python脚本。起初图省事,写了个shell脚本用docker run一个个起容器,结果问题接踵而至:

  1. 启动顺序全靠sleep硬等,MySQL还没初始化完,后端就已经连不上库了;
  2. 网络互通靠--link,这玩意官方都标记废弃了,换个IP就全乱套;
  3. 数据说丢就丢,容器一删,MySQL里的数据全没了;
  4. 没有健康检查,容器起来了但服务没就绪,负载均衡转发过去直接502。

最要命的是排查问题,6个容器日志混在一起,压根分不清谁是谁。

二、环境与版本

先交代一下环境,这直接影响后续配置写法:

Docker Engine: 26.1.1
Docker Compose: v2.24.2
Linux: Ubuntu 22.04 LTS (内核5.15)
服务器配置: 4核8G (阿里云ECS)

注意:Compose v2已经集成了docker compose命令(带横杠),不需要再装docker-compose(带横杠的Python版老掉牙了)。下面的配置全部基于v2语法,如果你还在用v1,建议尽早迁移。

三、方案设计:一张拓扑图看懂全局

先看整体架构,我画了张简图:

┌─────────────────────────────────────────────────────┐
│              docker_net_bridge (自定义网络)           │
│                                                     │
│   ┌─────────┐    ┌──────────┐    ┌─────────────┐   │
│   │  nginx  │───▶│ frontend │    │  backend    │   │
│   │ :80/443 │    │ :3000    │    │ :8080       │   │
│   └─────────┘    └──────────┘    └──────┬──────┘   │
│        │                                │          │
│        └────────────┬───────────────────┘          │
│                     ▼                              │
│   ┌─────────┐    ┌──────────┐    ┌─────────────┐   │
│   │  mysql  │    │  redis   │    │  scheduler  │   │
│   │ :3306   │    │ :6379    │    │ (无端口暴露)  │   │
│   └─────────┘    └──────────┘    └─────────────┘   │
└─────────────────────────────────────────────────────┘

设计思路:
- 网络策略:所有服务加入同一个自定义桥接网络,容器间通过服务名互访(内置DNS解析)。不映射到宿主机的服务(如MySQL、Redis)坚决不暴露端口,避免安全隐患。
- 数据持久化:MySQL和Redis使用named volume,宿主机上存放在/var/lib/docker/volumes/下,容器删除不丢数据。
- 健康检查:每个有依赖关系的服务都配了healthcheck,Compose根据健康状态决定后续服务的启动时机。
- 启动顺序:严格为 mysql/redis → backend → nginx,通过depends_on: condition: service_healthy实现,彻底告别sleep硬等。

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

这是我们在测试环境跑通的配置(生产环境做了小改动,密钥已脱敏):

version: "3.8"

networks:
  app_net:
    driver: bridge
    ipam:
      config:
        - subnet: "172.28.0.0/24"

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

services:
  mysql:
    image: mysql:8.0.36
    container_name: app-mysql
    restart: always
    networks:
      app_net:
        ipv4_address: 172.28.0.10
    volumes:
      - mysql_data:/var/lib/mysql
      - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: shop
      MYSQL_USER: app_user
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --max_connections=500
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    ports:
      - "127.0.0.1:3306:3306"   # 仅本机可访问,生产环境用堡垒机

  redis:
    image: redis:7.2.4-alpine
    container_name: app-redis
    restart: always
    networks:
      app_net:
        ipv4_address: 172.28.0.11
    volumes:
      - redis_data:/data
      - ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3
    ports:
      - "127.0.0.1:6379:6379"

  backend:
    image: registry.cn-hangzhou.aliyuncs.com/xxx/shop-backend:1.4.2
    container_name: app-backend
    restart: always
    networks:
      app_net:
        ipv4_address: 172.28.0.20
    environment:
      SPRING_PROFILES_ACTIVE: dev
      DB_HOST: mysql
      DB_PORT: 3306
      DB_NAME: shop
      REDIS_HOST: redis
      REDIS_PORT: 6379
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 60s   # Spring Boot启动慢,给足时间

  frontend:
    image: registry.cn-hangzhou.aliyuncs.com/xxx/shop-frontend:1.2.0
    container_name: app-frontend
    restart: always
    networks:
      app_net:
        ipv4_address: 172.28.0.30
    depends_on:
      - backend    # 不需要等健康,nginx层会做重试
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
      interval: 10s
      timeout: 3s
      retries: 3

  nginx:
    image: nginx:1.26.0-alpine
    container_name: app-nginx
    restart: always
    networks:
      app_net:
        ipv4_address: 172.28.0.40
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./logs/nginx:/var/log/nginx
    depends_on:
      frontend:
        condition: service_healthy
      backend:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3

  scheduler:
    image: registry.cn-hangzhou.aliyuncs.com/xxx/shop-scheduler:2.0.1
    container_name: app-scheduler
    restart: always
    networks:
      app_net:
        ipv4_address: 172.28.0.50
    environment:
      PYTHONUNBUFFERED: "1"
      DB_HOST: mysql
      REDIS_HOST: redis
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
      backend:
        condition: service_healthy
    # 不暴露任何端口到宿主机

.env文件管理敏感信息:

# .env (置于docker-compose.yml同目录)
MYSQL_ROOT_PASSWORD=MyStr0ng!Root2024
MYSQL_PASSWORD=AppUser#Passw0rd

启动命令:

# 校验配置
docker compose config --quiet

# 构建并后台启动
docker compose up -d --build

# 查看启动状态
docker compose ps

五、踩坑记录:三次重启血泪史

坑1:depends_on默认不等待健康检查

Compose v2的depends_on有一个坑:如果不加condition: service_healthy,它只保证容器创建顺序,不保证服务就绪。我第一次用depends_on: [mysql, redis],结果MySQL容器起来了但还没初始化完,后端就疯狂重试连接。解决办法就是上面写的显式声明condition: service_healthy

坑2:healthcheck命令里的环境变量

MySQL的healthcheck测试命令里用了-p${MYSQL_ROOT_PASSWORD},在Compose文件里这个变量会被正确替换。但如果写成$$MYSQL_ROOT_PASSWORD(双美元符),它会在容器内部执行时再求值——这在容器里没有该环境变量时会直接失败。正确写法是单美元符,让Compose先求值。

坑3:Spring Boot的健康检查端点默认不开启

我们一开始配了/actuator/health,但后端一直显示unhealthy。排查了半天发现Spring Boot 2.x的actuator默认只暴露/actuator/health,但没引入依赖。需要在pom.xml里加:

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

然后配置management.endpoint.health.show-details=always才能看到详细检查项。

坑4:MySQL初始化脚本执行时机

MySQL官方镜像有个机制:首次启动且数据目录为空时,才会执行/docker-entrypoint-initdb.d/下的.sql脚本。如果用了named volume且数据已存在,脚本不会再次执行。这导致我改了初始化脚本后重启容器,表结构没更新。解决:要么docker compose down -v清卷重来(仅限开发环境),要么用专门的迁移工具(如Flyway)。

六、效果数据与优化

优化前后对比:

指标 优化前(docker run脚本) 优化后(Docker Compose)
全栈启动时间 约8分钟(含sleep等待) 约90秒(健康检查精准等待)
故障容器恢复 手动排查依赖关系 重启后依赖服务自动拉起
数据持久化 容器删除数据丢失 named volume保障数据安全
网络配置 --link维护困难 自定义网络+服务名解析
日志管理 分散查看 docker compose logs -f统一查看

额外优化:
1. 日志限制:每个服务加了logging配置,限制单文件大小与保留数量,防止磁盘爆满。代码里没写,但强烈建议加上:
yaml logging: driver: json-file options: max-size: "20m" max-file: "3"

  1. 资源限制:给MySQL加了mem_limit: 2g,防止内存泄漏把整台宿主机拖垮。

  2. 镜像tag固定:不要用latest!我们在生产环境吃过亏,某次latest镜像更新导致Redis数据格式不兼容。现在全部锁定具体版本号。

七、总结与反思

Docker Compose这套编排方案,对于中小型项目(10个容器以内)完全够用。再往上走,比如需要跨主机编排、自动扩缩容,那才需要上Kubernetes或Docker Swarm——但那是另一个话题了。

几个核心收获:

  1. 健康检查是启动顺序的关键depends_on + service_healthy是Compose最实用的特性之一;
  2. 网络隔离用自定义bridge网络,比--link高效且安全,服务名即域名;
  3. 数据卷一定要用named volume,bind mount适合开发环境改代码,生产环境数据用卷管理更可靠。

最后说一句,Compose文件本身也是代码,要进Git仓库、要code review。我们团队现在每次改编排配置都要过MR,这半年再没出过部署事故。

如果你也在用Compose编排,欢迎评论区交流。遇到什么诡异的坑,说不定我也踩过。