Docker Compose

很多 Docker Compose 项目都有一段相似的生命周期:

第一天,只有一个应用和一个数据库,docker compose up -d 之后一切正常;一个月后,Redis、对象存储、反向代理、消息队列、监控面板陆续加入;半年后,compose.yaml 已经超过数百行,变量散落在多个 .env 文件中,谁也不敢轻易升级镜像,更没人能准确说明删掉某个卷会发生什么。

问题往往不在 Docker Compose 本身,而在于我们把它当成了“容器启动命令的存档”,而不是一份应用运行模型

Docker 官方对 Compose 的定义很直接:用一个 Compose 文件描述构成应用的服务、网络、卷、配置和密钥,再由 Compose 创建并管理这些资源。换句话说,Compose 文件不仅要让项目启动,还应当回答下面这些问题:

  • 系统由哪些组件组成?
  • 每个组件依赖谁?
  • 哪些接口允许对外暴露?
  • 哪些数据需要持久化?
  • 服务如何判断自己已经可以工作?
  • 密钥存放在哪里?
  • 失败后如何恢复?

如果这些问题没有答案,即使项目现在能跑,也只是把复杂性暂时藏了起来。

本文默认使用现代的 docker compose 命令,而不是已经进入历史阶段的 docker-compose 独立命令。刚开始配置 Windows、WSL2 与 Docker Desktop,可以先阅读站内的《Windows 11 + WSL2 开发环境搭建全教程》。


一、先理解:Compose 管理的不是容器,而是应用

Docker 架构示意图

单独看一个容器时,我们关心镜像、端口和启动命令;看一个真实应用时,还必须关心服务关系、网络边界、数据生命周期和故障传播。

例如一个常见 Web 系统可能包含:

用户
  │
  ▼
反向代理 ──► Web/API ──► PostgreSQL
                 │
                 ├────► Redis
                 └────► 对象存储

这张图真正重要的地方,不是“有五个容器”,而是其中隐含的约束:

  • 只有反向代理应直接对外开放端口;
  • API 可以访问数据库,但用户不能直接访问数据库;
  • Redis 丢失缓存可以重建,数据库丢失数据则不可接受;
  • API 必须等待数据库真正可用,而不只是等待数据库容器进程启动;
  • 对象存储、数据库和应用日志需要不同的备份策略。

当 Compose 文件能够准确表达这些关系时,它才开始具备工程价值。


二、不要把所有内容塞进一个文件

小项目只有一个 compose.yaml 没有问题,但当开发、测试、生产的差异越来越多时,继续把条件判断和临时配置堆进同一个文件,维护成本会快速上升。

一种比较清晰的结构是:

project/
├─ compose.yaml
├─ compose.dev.yaml
├─ compose.prod.yaml
├─ .env.example
├─ env/
│  ├─ dev.env
│  └─ prod.env.example
├─ secrets/
│  └─ README.md
├─ deploy/
│  ├─ nginx/
│  └─ scripts/
└─ data/
   └─ .gitkeep

其中:

  • compose.yaml 保存所有环境共有的基础结构;
  • compose.dev.yaml 添加源码挂载、调试端口和开发工具;
  • compose.prod.yaml 添加资源限制、只读文件系统、日志轮转和生产镜像;
  • .env.example 只保存变量名称与安全的示例值;
  • 真正的密钥不进入 Git。

启动时明确指定组合:

docker compose -f compose.yaml -f compose.dev.yaml up -d

现代 Compose 也支持 include,适合把相对独立的子系统拆分为各自维护的 Compose 文件。但不要为了“看起来高级”而过度拆分。判断标准只有一个:拆分之后,边界是否更清楚,修改是否更安全。


三、尽量不要写 container_name

很多人习惯给每个服务写固定容器名:

services:
  api:
    container_name: my-api

这看起来更直观,但会带来三个问题:

  1. 同一台机器上难以并行启动多个相同项目;
  2. 固定名称容易与其他环境冲突;
  3. 指定 container_name 后,该服务无法正常扩展为多个容器实例。

Compose 已经会根据项目名、服务名和序号生成容器名称。服务之间通信时,直接使用服务名即可:

postgres://db:5432/app
redis://redis:6379

真正需要稳定的是服务发现名称,而不是宿主机上看起来整齐的容器名。

项目名可以通过目录名、顶层 name-p 参数或 COMPOSE_PROJECT_NAME 控制,比硬编码每一个容器名更合理。


四、不要把 latest 当成版本策略

下面这种配置短期很方便:

services:
  db:
    image: postgres:latest

但它无法回答一个关键问题:下次部署时,拉到的还是不是同一个镜像?

更合理的做法至少固定到明确的大版本或小版本:

services:
  db:
    image: postgres:18.1-alpine

对稳定性要求更高时,可以进一步固定镜像摘要:

image: postgres:18.1-alpine@sha256:...

版本固定并不意味着永远不升级,而是把升级从“无意中发生”变成“经过审查后主动发生”。一个成熟的升级流程通常包括:

查看更新说明 → 备份数据 → 测试环境升级 → 执行迁移 → 验证回滚 → 生产升级

真正危险的不是旧版本,而是你不知道版本何时发生了变化。


五、区分三种完全不同的“变量”

Compose 项目中最容易混淆的是环境变量。至少要区分三类:

1. Compose 插值变量

用于生成 Compose 配置:

image: myapp:${APP_VERSION}
ports:
  - "${APP_PORT:-8080}:8080"

这些值通常来自项目目录下的 .env 或命令行指定的 --env-file

2. 容器运行时环境变量

真正传给容器内进程:

environment:
  LOG_LEVEL: ${LOG_LEVEL:-info}

3. 应用密钥

例如数据库密码、JWT 私钥、云服务密钥。这些内容不应因为“环境变量方便”就全部塞进 .env

建议为每个变量写清楚:

变量 用途 是否敏感 默认值 必填
APP_VERSION 选择应用镜像版本
LOG_LEVEL 日志级别 info
DB_PASSWORD 数据库密码

同时让必填变量在缺失时立即失败:

environment:
  DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}

与其让应用启动五分钟后才报一个难以理解的连接错误,不如在生成配置时就明确拒绝启动。


六、密钥不要和普通配置混在一起

Docker 官方明确建议,对密码、证书和 API Key 等敏感信息使用 Secrets,而不是简单地作为普通环境变量注入。Compose Secret 会按服务授权,并以文件形式挂载到容器内的 /run/secrets/

services:
  db:
    image: postgres:18.1-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

这样做并不会自动解决所有安全问题:宿主机上的 Secret 文件仍然需要正确设置权限,备份中也不能随意包含密钥。但它至少建立了一个重要边界:

普通配置可以进入版本库,敏感信息必须通过独立通道注入。

生产环境还可以进一步接入 OpenBao、Vault、云厂商密钥管理服务或部署平台自身的 Secret 系统。


七、depends_on 不等于“依赖已经可用”

很多 Compose 文件都写过:

services:
  api:
    depends_on:
      - db

这种短写法只保证数据库服务先被启动,不保证数据库已经完成初始化并能接受连接。数据库进程可能还在恢复日志、执行迁移或创建用户,此时 API 依然可能启动失败。

更可靠的方式是为依赖服务定义健康检查:

services:
  db:
    image: postgres:18.1-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s

  api:
    image: example/api:1.4.2
    depends_on:
      db:
        condition: service_healthy

还要注意:健康检查不是“进程还活着”检查,而应尽量验证服务是否真的具备工作能力。Web 服务可以检查 /health/ready,数据库可以执行原生命令,消息队列可以验证管理接口或协议握手。

不过健康检查也不能替代应用自身的重试机制。真实系统中,依赖可能在运行期间短暂失效,因此应用仍应具备超时、退避重试和熔断能力。


八、只暴露真正需要暴露的端口

下面的写法会把数据库端口映射到宿主机:

ports:
  - "5432:5432"

开发环境为了本地调试可以这样做,但生产环境通常没有必要。Compose 网络内的 API 可以直接通过 db:5432 访问数据库,不需要把数据库端口开放给宿主机,更不需要开放给公网。

一个更清晰的网络结构是:

services:
  proxy:
    image: nginx:1.29-alpine
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend

  api:
    image: example/api:1.4.2
    networks:
      - frontend
      - backend

  db:
    image: postgres:18.1-alpine
    networks:
      - backend

networks:
  frontend:
  backend:
    internal: true

此时:

  • 反向代理可以接收外部请求;
  • API 同时连接前端网络与后端网络;
  • 数据库只存在于内部网络;
  • 数据库不会因为一个随手写下的端口映射而暴露出去。

安全并不总是来自复杂的防火墙规则,很多时候只是来自“根本不开放”。


九、不要只考虑持久化,还要考虑恢复

给数据库挂一个卷,只能说明“容器删除后数据还在”,不能说明数据是安全的。

volumes:
  db_data:

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

真正完整的数据策略需要回答:

  • 卷位于哪里?
  • 哪些数据必须备份?
  • 多久备份一次?
  • 备份保留多久?
  • 备份是否加密?
  • 能否在另一台机器恢复?
  • 最近一次实际恢复测试是什么时候?

可以把数据分成三类:

数据类型 示例 丢失后处理
权威数据 数据库、用户上传文件 必须备份并验证恢复
可重建数据 缓存、搜索索引 保留重建流程即可
临时数据 构建缓存、临时文件 通常不需要备份

最容易制造虚假安全感的一句话是:“我们每天都在备份。”

真正重要的问题应该是:“我们最近一次成功恢复是什么时候?”


十、为资源、日志和停止过程设定边界

没有资源边界的容器,在异常时可能拖垮整台服务器;没有日志轮转的容器,可能在几周后写满磁盘;没有优雅停止时间的服务,可能在更新时中断正在执行的任务。

services:
  api:
    image: example/api:1.4.2
    cpus: 1.5
    mem_limit: 1g
    pids_limit: 256
    init: true
    stop_grace_period: 30s
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"

这些配置的意义分别是:

  • cpusmem_limit:防止单个服务无限占用资源;
  • pids_limit:限制异常进程扩散;
  • init: true:帮助正确转发信号并回收僵尸进程;
  • stop_grace_period:给应用处理未完成请求和关闭连接的时间;
  • 日志轮转:避免默认 JSON 日志无限增长;
  • restart:处理进程异常退出,但不能掩盖持续崩溃。

restart: always 不是可靠性的代名词。一个配置错误的服务每十秒重启一次,只是在持续制造日志和压力。


十一、用 Profiles 管理“偶尔才需要”的服务

数据库管理界面、调试代理、性能分析器、邮件测试服务,通常只在特定场景使用。不要让它们默认随着核心服务一起启动。

services:
  api:
    image: example/api:1.4.2

  adminer:
    image: adminer:5
    profiles: ["debug"]
    ports:
      - "127.0.0.1:8081:8080"

默认启动核心系统:

docker compose up -d

需要调试工具时:

docker compose --profile debug up -d

Profiles 的价值不是少启动一个容器,而是让 Compose 文件显式表达:哪些是系统核心组成,哪些只是特定场景下的辅助能力。


十二、把“能启动”升级为“可验证”

提交 Compose 文件之前,至少运行:

docker compose config --quiet
docker compose config

第一条检查配置是否合法,第二条展示变量插值、文件合并后的最终结果。它经常能提前发现:

  • 变量没有设置;
  • 缩进或字段拼写错误;
  • 多个 Compose 文件合并结果与预期不同;
  • 镜像标签或端口被意外覆盖;
  • Secret、网络或卷引用不存在。

运行后还可以使用:

docker compose ps
docker compose logs --tail=200
docker compose events
docker compose top

进一步检查服务状态、日志、生命周期事件与进程。

更成熟的项目可以在 CI 中执行:

语法检查
  ↓
生成最终配置
  ↓
拉取或构建镜像
  ↓
启动测试环境
  ↓
等待健康检查
  ↓
执行接口与迁移测试
  ↓
销毁环境并检查残留

这也是 AI 编程时代尤其值得坚持的一点:AI 可以迅速生成一份结构漂亮的 Compose 文件,但它不能仅凭文本保证镜像存在、健康检查正确、前后端已经连通,更不能保证数据真的可以恢复。

使用 Codex 等工具进行工程开发时,可以参考站内的《OpenAI Codex 安装与使用全指南》,但无论代码由谁生成,最终都应该由可重复的验证流程给出结论。


一个相对完整的基础示例

下面这个示例没有试图覆盖所有场景,但已经表达了镜像版本、健康检查、密钥、网络隔离、日志与资源边界:

name: example-app

services:
  proxy:
    image: nginx:1.29-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./deploy/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      api:
        condition: service_healthy
    networks:
      - frontend
    restart: unless-stopped
    logging: &default_logging
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"

  api:
    image: example/api:${APP_VERSION:?APP_VERSION must be set}
    environment:
      APP_ENV: ${APP_ENV:-production}
      DATABASE_HOST: db
      DATABASE_PORT: "5432"
      DATABASE_NAME: app
      DATABASE_USER: app
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health/ready"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s
    networks:
      - frontend
      - backend
    cpus: 1.5
    mem_limit: 1g
    pids_limit: 256
    init: true
    stop_grace_period: 30s
    restart: unless-stopped
    logging: *default_logging

  db:
    image: postgres:18.1-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s
    networks:
      - backend
    restart: unless-stopped
    logging: *default_logging

  adminer:
    image: adminer:5
    profiles: ["debug"]
    ports:
      - "127.0.0.1:8081:8080"
    networks:
      - backend

networks:
  frontend:
  backend:
    internal: true

volumes:
  db_data:

secrets:
  db_password:
    file: ./secrets/db_password.txt

使用前仍要根据镜像实际包含的工具调整健康检查。例如有些精简镜像没有 wgetcurl,这不是 Compose 能替你猜测的事情。


结语:可维护性来自明确,而不是来自 YAML 技巧

Docker Compose 项目失控,通常不是因为文件太长,而是因为其中存在太多没有被说清楚的事情:

  • 谁依赖谁没有说清楚;
  • 什么能对外访问没有说清楚;
  • 哪些变量是密钥没有说清楚;
  • 数据如何恢复没有说清楚;
  • 服务何时算健康没有说清楚;
  • 哪些配置只用于开发没有说清楚;
  • 修改之后如何验证没有说清楚。

所谓工程化,本质上就是不断消除这些模糊地带。

一份优秀的 Compose 文件不一定短,也不一定使用了所有新特性。它只需要让下一位维护者在打开文件后,能够准确理解系统如何启动、如何连接、如何失败,以及失败之后如何恢复。

当一个项目不仅“今天能跑”,而且“半年后仍然有人敢改”,它才真正越过了演示代码与工程系统之间的边界。


延伸阅读

配图来源:Docker 官方网站、Docker 官方文档与 Docker Compose 开源仓库。为兼顾中国大陆访问,Compose 标志使用 jsDelivr 对开源仓库资源进行分发。