目录

Docker Compose 最佳实践

Docker Compose 最擅长的事情其实很明确:把一组容器怎么一起跑描述清楚,然后让开发环境、测试环境、临时环境尽量一致。

它非常适合本地开发、集成测试、一次性任务,也适合在单机上快速复现某个问题。但很多团队的问题恰恰出在这里:明明 Compose 只是环境编排工具,却硬把它往生产编排平台上用,最后 YAML 越堆越多,边界越来越模糊。

这篇文章就讲一件事:把 Compose 放回它擅长的位置。

先把定位摆正:Compose 是环境定义,不是生产调度器

Compose 很适合这些场景:

  • 本地开发的应用 + 数据库 + 缓存
  • 集成测试依赖环境
  • 单机上的预览环境
  • migration、seed、admin task 这类一次性任务
  • 带真实依赖的本地问题复现

不适合这些场景:

  • 多节点生产编排
  • 自动扩缩容
  • 复杂发布策略
  • 跨主机调度
  • 长期严肃的 secret 管理

如果你的生产环境需要滚动发布、实例调度、故障迁移、自动扩容,那 Compose 不是“还差一点”,而是工具选错了。

用 v2 插件,命令统一成 docker compose

现在的主流用法是 Docker Compose v2 插件:

docker compose version

优先使用:

docker compose ...

不要再把 docker-compose ... 当主要写法。

另外,新的 Compose 文件也不要再写顶层 version: 字段。现代 Compose 已经不需要它,继续写只会让人误以为它还参与版本选择。

目录结构越普通越好

建议保持简单:

.
├── compose.yaml
├── compose.override.yaml
├── compose.ci.yaml
├── .env.example
├── app/
├── infra/
│   ├── postgres/
│   └── nginx/
└── scripts/

比较实用的约定是:

  • compose.yaml:公共基础配置
  • compose.override.yaml:本地开发覆盖,自动加载
  • compose.ci.yaml:CI 场景覆盖
  • .env.example:变量说明,不放真凭据

不要动不动拆七八个 Compose 文件。文件层次一多,最后 docker compose config 展开出来谁都不敢看。

从一个小而稳的基础文件开始

例如:

services:
  api:
    build:
      context: .
      dockerfile: app/Dockerfile
    command: ["./bin/api"]
    environment:
      APP_ENV: development
      DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable
      REDIS_URL: redis://redis:6379/0
    ports:
      - "8080:8080"
    volumes:
      - .:/workspace
    working_dir: /workspace
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7
    command: ["redis-server", "--save", "", "--appendonly", "no"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  postgres-data:

没有 version:,这就是现在推荐的写法。

override 文件是为了开发便利,不是为了藏核心差异

compose.override.yaml 很适合放本地开发专用配置,比如:

  • bind mount
  • 调试端口
  • 热更新命令
  • 更详细的日志级别

例如:

services:
  api:
    command: ["air", "-c", ".air.toml"]
    environment:
      LOG_LEVEL: debug
    ports:
      - "2345:2345"

开发同学直接跑:

docker compose up

CI 则显式指定:

docker compose -f compose.yaml -f compose.ci.yaml up --abort-on-container-exit --exit-code-from test

override 的用途应该是“覆盖环境细节”,不是“同一个服务在不同环境下其实是两套完全不同的契约”。

.env 要用明白,不然很容易自找麻烦

Compose 的环境变量处理经常让人混乱,核心要分清三件事:

  1. .env 主要是给 Compose 文件里的 ${...} 做变量替换
  2. env_file: 是把变量注入到容器环境
  3. .env.example 可以提交,真实 .env 不要提交

例如:

services:
  api:
    env_file:
      - .env.local
    environment:
      APP_ENV: development
      HTTP_PORT: ${HTTP_PORT:-8080}

几个容易遇到问题的点:

  • .env 会影响 Compose 渲染
  • env_file 会进入容器内部环境
  • shell 里的环境变量可能覆盖 Compose 替换结果
  • 引号、空格、多余换行都可能出问题

怀疑变量没生效时,先跑:

docker compose config

先看最终展开结果,再去怀疑 Docker。

即使是本地环境,也别把 secrets 当儿戏

很多泄漏事故都始于一句“这只是开发环境”。

本地开发至少做到:

  • 用低权限或假凭据
  • 不把真实密钥写进仓库
  • 能走环境变量就别硬编码
  • 配置文件挂载尽量只读

例如:

services:
  api:
    environment:
      STRIPE_API_BASE: https://api.stripe.com
      STRIPE_API_KEY: ${STRIPE_API_KEY}
    volumes:
      - ./infra/api/config.dev.yaml:/workspace/config.yaml:ro

如果到了生产级 secret 管理,Compose 本身不是答案。该用 secret manager、平台能力,就老老实实用。

healthcheck 很值,不要嫌麻烦

很多服务“容器启动了”不等于“服务可用了”。

典型例子:

  • 数据库进程起来了,但还没 ready
  • 应用还在跑 migration
  • HTTP 端口监听了,但依赖还没连上

所以重要服务建议都加 healthcheck

例如:

services:
  api:
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
      interval: 10s
      timeout: 3s
      retries: 10
      start_period: 15s

健康检查应当满足:

  • 开销低
  • 在容器内可执行
  • 能代表真实可用性
  • 不依赖外部不稳定网络

不要把健康检查写成访问第三方公网接口,那测出来的是外网状况,不是你的服务。

depends_on 只能管启动顺序,管不了应用健壮性

这个点需要说得直接一点:depends_on 不是应用重试机制的替代品。

即使写了:

depends_on:
  db:
    condition: service_healthy

也只能改善启动阶段顺序,解决不了这些问题:

  • 数据库后续又变得不健康
  • migration 还没跑完
  • 初始化数据没准备好
  • 权限或租户数据还没建完

Compose 只能帮你编排“先后”,不能保证你的应用永远“已经准备好”。

所以应用本身该做的重连、退避、超时处理,还是要做。

持久化数据优先用 named volume

数据库这类容器管理的状态数据,优先用 named volume:

services:
  db:
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:

源码、配置、脚本这类主机上要编辑的内容,才适合 bind mount:

services:
  api:
    volumes:
      - .:/workspace

经验上可以这么分:

  • named volume:服务数据
  • bind mount:开发工作区

尽量别把数据库数据目录直接 bind mount 到宿主机本地路径,权限、性能、文件系统差异都可能让你遇到问题。

默认网络已经够用,别没事乱建

Compose 会自动给项目创建默认网络,服务之间可以直接用服务名互联:

  • db:5432
  • redis:6379
  • api:8080

很多本地环境其实完全够用了。

只有在你明确需要隔离拓扑时,再建自定义网络。

例如:

services:
  nginx:
    image: nginx:1.27
    networks:
      - edge

  api:
    build: .
    networks:
      - edge
      - backend

  db:
    image: postgres:16
    networks:
      - backend

networks:
  edge:
  backend:
    internal: true

internal: true 对后端专用网络比较实用,能避免不必要的外部连通性。

profile 比“注释 YAML”靠谱得多

有些服务不是每次都需要,比如:

  • MailHog
  • Jaeger
  • Prometheus
  • 本地对象存储模拟器

这时候用 profile 很合适:

services:
  api:
    build: .

  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]

  jaeger:
    image: jaegertracing/all-in-one:1.57
    profiles: ["observability"]

默认跑基础栈:

docker compose up

带上 MailHog:

docker compose --profile dev up

带上观测工具:

docker compose --profile observability up

这比让大家去手工注释服务块靠谱太多。

一次性任务要显式建服务

migration、seed、admin job 这类操作很适合通过 Compose 统一入口。

例如:

services:
  migrate:
    build:
      context: .
      dockerfile: app/Dockerfile
    command: ["./bin/migrate", "up"]
    environment:
      DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable
    depends_on:
      db:
        condition: service_healthy
    profiles: ["ops"]

执行时:

docker compose --profile ops run --rm migrate

比起把一长串 docker run ... 命令丢在 wiki 里,这种方式更稳,也更容易复用。

日志要容易看,出了问题能马上抓

本地和 CI 场景下,容器标准输出通常已经够用。

常用命令基本就这些:

docker compose logs -f api
docker compose logs --tail=100 db
docker compose ps

如果某些服务日志太多,可以做简单限制:

services:
  api:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

但也别过度设计。真到了需要集中采集、长期保留、跨主机检索的程度,你已经不在 Compose 的舒适区了。

restart 策略有用,但别把它当稳定性方案

本地依赖服务可以适度用:

  • 一次性 job 通常不需要 restart
  • 长驻依赖服务可以考虑 unless-stopped
  • 不要让 restart 掩盖 crash loop

例如:

services:
  db:
    image: postgres:16
    restart: unless-stopped

要注意的是:过度自动重启会掩盖真实问题。表面上看“容器都在”,实际上服务可能一直在反复崩。

CI 里通常更不建议乱加 restart,因为测试失败就应该立刻暴露。

Compose 很适合 CI 和集成测试

Compose 在 CI 里最大的价值,就是把依赖环境拉起来,然后跑完就扔。

例如:

services:
  test:
    build:
      context: .
      dockerfile: app/Dockerfile
    command: ["go", "test", "./...", "-count=1"]
    environment:
      DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

CI 里可以这样跑:

docker compose -f compose.yaml -f compose.ci.yaml up --build --abort-on-container-exit --exit-code-from test

结束后记得清理:

docker compose down -v

几个比较实用的习惯:

  • -p 指定独立 project name,避免冲突
  • 任务结束后清理网络和卷
  • 测试数据尽量确定性
  • 不依赖上一次 job 留下来的状态

这些命令最常用,别记花的

日常真正常用的也就这些:

docker compose up -d --build
docker compose down
docker compose down -v
docker compose ps
docker compose logs -f api
docker compose exec api sh
docker compose run --rm migrate
docker compose config

尤其是 docker compose config,非常值得常用。文件 merge、变量替换、profile 展开之后到底长什么样,看它比猜靠谱。

几个常见反模式

1. 把 Compose 当长期生产编排平台

极小规模、单机场景也许还能凑合,但只要开始追求可靠发布和弹性调度,就不该继续硬撑。

2. 把真实 secrets 写进 Compose 文件

如果 compose.yaml 里放着生产密码,问题不在 YAML 写法,而在流程本身。

3. 指望 depends_on 解决一切 readiness 问题

它只能帮启动顺序,应用该做的容错还是得做。

4. 什么都 bind mount

源码挂载可以,数据库目录、运行时目录、缓存目录全从宿主机挂进去,通常会把问题越挂越多。

5. 文件和 profile 过多

如果没人说得清“本地启动到底该用哪几个文件、开哪几个 profile”,说明设计已经过度了。

6. 过度依赖 container_name

Compose 本身已经提供基于 service name 的服务发现。强行写死容器名,常常只会带来命名冲突和灵活性下降。

一份够用的检查清单

一个状态比较好的 Compose 项目,通常具备这些特点:

  • 使用 docker compose,不是旧版 v1 命令
  • 没有过时的 version: 字段
  • 一个基础文件,少量覆盖文件,职责清楚
  • 关键依赖有 healthcheck
  • 有状态服务用 named volume
  • bind mount 只用在确实影响开发效率的地方
  • 可选服务通过 profile 控制
  • 仓库里没有真实 secrets
  • CI 环境可创建、可销毁、可清理
  • 团队没人假装它是生产调度平台

Compose 不需要“高级”,它需要的是稳定、可猜、低摩擦。只要把边界守住,它会是非常顺手的工具。