docker-compose 编排实战

docker-compose 编排实战

写在前面

单容器跑跑 nginx 不难,生产环境真正难的是多容器编排:Web + DB + Cache + Queue + Worker,这些服务之间有依赖、有顺序、有共享网络和卷。docker run 一个一个起,既容易出错又难以复用。

docker-compose 解决的就是这个问题——用一份 YAML 描述整套应用,然后一条命令启动/停止/扩缩容。本篇会从核心语法入手,然后拿公司一站式测试平台的 compose 文件做完整拆解。

一、为什么需要 compose

1.1 单 docker run 的痛苦

假设要起一个 WordPress:

docker run -d --name db \
  -e MYSQL_ROOT_PASSWORD=secret \
  -e MYSQL_DATABASE=wordpress \
  -v mysql-data:/var/lib/mysql \
  --restart unless-stopped \
  mysql:8.0

docker run -d --name wp \
  -p 8080:80 \
  -e WORDPRESS_DB_HOST=db \
  -e WORDPRESS_DB_PASSWORD=secret \
  --link db:mysql \
  --restart unless-stopped \
  wordpress:latest

问题:

  1. 命令长,容易敲错
  2. 启动顺序——必须先起 db 再起 wp
  3. 网络、卷、环境变量分散,新人看不懂
  4. 没法版本化(只能写在 Confluence 或者 README)

1.2 compose 的解法

docker-compose.yml:

version: '3.8'

services:
  db:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: wordpress
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  wp:
    image: wordpress:latest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "8080:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_PASSWORD: secret

volumes:
  mysql-data:

启动:

docker compose up -d

启动顺序:自动等待 db 健康后再启动 wp。

二、核心语法

2.1 文件版本选择

version 字段在 v2 格式里已经被废弃,但很多老教程还在写。Docker Compose v2(V2 工具,即 docker compose 命令)已经不需要这个字段了。

如果你的 compose 文件兼容 v1(docker-compose 命令带横杠的旧版本),可以保留 version: '3.8'。新项目建议不写。

2.2 services:服务定义

每个 service 对应一个容器。

image / build

services:
  # 直接用现成镜像
  nginx:
    image: nginx:1.25-alpine

  # 从 Dockerfile 构建
  api:
    build:
      context: ./api          # 构建上下文路径
      dockerfile: Dockerfile  # Dockerfile 路径(相对于 context)
      args:                   # 构建参数
        - NODE_ENV=production
      target: production      # 多阶段构建的 target 阶段
      cache_from:             # 缓存来源
        - myregistry/api:cache

ports / expose

services:
  web:
    ports:
      - "8080:80"           # 宿主:容器
      - "127.0.0.1:8443:443" # 绑定特定 IP
      - "3000-3005:3000-3005" # 端口范围

  internal:
    expose:
      - "3000"  # 只暴露给同网络的其他容器,不映射到宿主机

ports 真的把端口暴露到宿主机;expose 只是声明"我用到这个端口",不实际映射。

environment / env_file

services:
  api:
    environment:
      NODE_ENV: production
      DB_HOST: db
      DEBUG: "false"  # 字符串值要加引号,避免被解析成布尔

    env_file:
      - .env.production
      - .env.secrets

优先用 env_file,把所有环境变量集中到一个文件,既方便管理又方便不同环境切换。

volumes

services:
  db:
    volumes:
      # 命名卷 - 由 docker 管理
      - mysql-data:/var/lib/mysql

      # 绑定挂载 - 直接挂载宿主机目录
      - ./init-scripts:/docker-entrypoint-initdb.d:ro

      # 匿名卷 - 不推荐,难以管理
      - /var/lib/mysql

      # tmpfs - 内存盘
      - type: tmpfs
        target: /tmp
        tmpfs:
          size: 100m

volumes:
  mysql-data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /data/mysql  # 实际存储路径

networks

services:
  api:
    networks:
      - frontend
      - backend

  db:
    networks:
      - backend

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true  # 不暴露到外网

internal: true 的网络没有网关,容器只能内部通信,不能访问外网——非常适合 DB、Cache 这种只给内网用的服务。

depends_on

services:
  api:
    depends_on:
      db:
        condition: service_healthy  # 等 db 健康再启动
      redis:
        condition: service_started  # 等 redis 启动就启动(不等健康)

⚠️ depends_on 只控制启动顺序,不能等应用就绪。这就是为什么一定要配合 healthcheck——只有 healthcheck 通过后,后续服务才会启动。

healthcheck

services:
  api:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s  # 启动后 60s 内失败不算

start_period 很关键——容器刚启动时应用还没准备好,这个时间内 healthcheck 失败会被忽略。

restart

services:
  api:
    restart: unless-stopped  # 推荐:除非手动 stop,否则总是重启
    # 其他选项:
    # no - 不自动重启
    # always - 总是重启(连手动 stop 也会重启)
    # on-failure - 异常退出时重启

2.3 volumes 顶级定义

volumes:
  # 命名卷
  mysql-data:
    driver: local

  # 带配置的命名卷
  redis-data:
    driver: local
    driver_opts:
      type: nfs
      o: addr=10.0.0.1,rw
      device: ":/path/to/dir"

2.4 networks 顶级定义

networks:
  default:
    driver: bridge
    name: my-project-net  # 自定义网络名,默认是 项目名_default

2.5 configs 和 secrets(Swarm 模式)

services:
  api:
    configs:
      - source: app-config
        target: /etc/app/config.yml
    secrets:
      - source: db-password
        target: /run/secrets/db_password

configs:
  app-config:
    file: ./config.yml

secrets:
  db-password:
    file: ./secrets/db_password.txt

secrets 会以 tmpfs 形式挂载到 /run/secrets/,不会留在镜像 layer 里,比 environment 更安全。

三、实战:公司测试平台 compose

公司内部一个测试平台,技术栈:

3.1 完整 compose 文件

# compose.yaml

services:
  # ====== 前端 ======
  web:
    build:
      context: ./web
      target: runner
    restart: unless-stopped
    networks:
      - frontend
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
      interval: 30s
      timeout: 5s
      retries: 3

  # ====== API 服务 ======
  api:
    build:
      context: ./api
      target: runner
    restart: unless-stopped
    environment:
      NODE_ENV: production
      DB_HOST: mysql
      DB_PORT: "3306"
      DB_USER: api_user
      DB_PASSWORD_FILE: /run/secrets/mysql_password
      REDIS_HOST: redis
      MINIO_ENDPOINT: minio
      MINIO_PORT: "9000"
    secrets:
      - mysql_password
      - minio_password
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
      minio:
        condition: service_started
    networks:
      - frontend
      - backend
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 1G
        reservations:
          cpus: '0.5'
          memory: 256M
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "5"

  # ====== Worker 节点 ======
  worker:
    build:
      context: ./worker
    restart: unless-stopped
    environment:
      PYTHONUNBUFFERED: "1"
      DB_HOST: mysql
      REDIS_HOST: redis
    secrets:
      - mysql_password
    depends_on:
      api:
        condition: service_healthy
    networks:
      - backend
    deploy:
      replicas: 2  # 起两个 worker 实例
    healthcheck:
      test: ["CMD", "python", "-c", "import redis; redis.Redis(host='redis').ping()"]
      interval: 30s
      timeout: 5s
      retries: 3

  # ====== MySQL ======
  mysql:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
      MYSQL_DATABASE: testplatform
      MYSQL_USER: api_user
      MYSQL_PASSWORD_FILE: /run/secrets/mysql_password
    secrets:
      - mysql_root_password
      - mysql_password
    volumes:
      - mysql-data:/var/lib/mysql
      - ./mysql/init:/docker-entrypoint-initdb.d:ro
    networks:
      - backend
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 60s
    deploy:
      resources:
        limits:
          memory: 2G

  # ====== Redis ======
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

  # ====== MinIO ======
  minio:
    image: minio/minio:latest
    restart: unless-stopped
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minio_admin
      MINIO_ROOT_PASSWORD_FILE: /run/secrets/minio_password
    secrets:
      - minio_password
    volumes:
      - minio-data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 10s
      retries: 3

  # ====== Nginx 反代 ======
  nginx:
    image: nginx:1.25-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    depends_on:
      web:
        condition: service_healthy
      api:
        condition: service_healthy
    networks:
      - frontend

volumes:
  mysql-data:
  redis-data:
  minio-data:

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

secrets:
  mysql_password:
    file: ./secrets/mysql_password.txt
  mysql_root_password:
    file: ./secrets/mysql_root_password.txt
  minio_password:
    file: ./secrets/minio_password.txt

3.2 关键设计点解释

网络隔离

frontend 网络 (暴露 80/443)
├── nginx
├── web
└── api
          ↓
backend 网络 (internal,不连外网)
├── api
├── mysql
├── redis
├── minio
└── worker

api 同时加入两个网络,作为唯一桥梁。mysql/redis/minio/worker 都在 internal: true 的 backend 网络,完全不能访问外网——就算被入侵也无法下载恶意代码。

密钥管理

所有密码通过 secrets 挂载,不进环境变量、不进镜像历史:

# secrets 目录
secrets/
├── mysql_password.txt        # api 用的密码
├── mysql_root_password.txt   # root 密码
└── minio_password.txt

⚠️ 生产环境应该用 Docker Swarm 或外部密钥管理(Vault/AWS Secrets Manager),而不是 file: 方式。file: 适合本地开发。

资源限制

deploy:
  resources:
    limits:
      cpus: '2'
      memory: 1G
    reservations:
      cpus: '0.5'
      memory: 256M

⚠️ 只有 docker swarm 模式才会生效。docker compose 单机模式下不生效,要用 --compatibility 或者改用 mem_limit 等旧字段。我会在生产环境用 Swarm 或者 K8s。

日志管理

logging:
  driver: json-file
  options:
    max-size: "50m"
    max-file: "5"

不限制的话容器日志会一直增长,最终把磁盘占满。json-file 驱动 + size/file 限制是最常见的配置。

健康检查串联启动

启动顺序:

mysql/redis/minio 健康 → api 健康 → web 健康 → nginx 启动

如果某个服务健康检查失败,docker compose up 会卡住,而不是把不健康的容器暴露给上游。这避免了"服务起是起来了,但数据库连不上"这种隐蔽问题。

3.3 日常运维命令

# 启动所有服务
docker compose up -d

# 查看状态
docker compose ps

# 查看日志(-f 实时跟踪, --tail 显示最近 N 行)
docker compose logs -f --tail=100 api

# 只看某个服务的日志
docker compose logs -f worker

# 进入容器
docker compose exec api sh

# 滚动重启某个服务(不中断)
docker compose up -d --no-deps api

# 扩缩容(仅 Swarm 模式)
docker compose up -d --scale worker=4

# 停止并删除容器
docker compose down

# 停止并删除容器、网络,保留卷
docker compose down --volumes

# 验证 compose 文件
docker compose config

3.4 .env 文件与多环境

.env.production:

COMPOSE_PROJECT_NAME=testplatform
TAG=v1.2.3
NGINX_HOST=testplatform.company.com

.env.staging:

COMPOSE_PROJECT_NAME=testplatform-staging
TAG=develop
NGINX_HOST=staging.testplatform.company.com

compose 文件里可以用 ${TAG},.env 里的值会自动注入:

services:
  api:
    image: myregistry/api:${TAG}

启动时指定环境:

docker compose --env-file .env.production up -d

四、compose 的局限

compose 不是万能的,有这些场景它搞不定:

场景 推荐方案
多机集群 Docker Swarm / Kubernetes
自动扩缩容 Kubernetes HPA
滚动更新零中断 Kubernetes Deployment
服务发现 + 负载均衡 Kubernetes Service + Ingress
配置中心 Consul / etcd

一句话:compose 是单机多容器编排;生产多机用 K8s。本系列最后一篇会讲从 compose 到 K8s 的迁移。

五、本篇小结

关键点 说明
services 定义 每个服务是一个容器
depends_on + healthcheck 控制启动顺序,等健康再启
networks 分层 frontend / backend 隔离
secrets 管密码 不进环境变量、不进镜像
volumes 持久化 命名卷优于匿名卷
deploy 资源限制 limits/reservations(Swarm 模式)

下一篇讲 Docker 网络模式详解,实战一个排查案例:容器连不上宿主 Redis 的来龙去脉。

参考资料