Docker 基础体系 · 第 55/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Compose 多环境治理:开发、测试、生产差异、Secret 和不可变制品

Compose 的难点不在于“把多个容器写进一个 YAML 文件”,而在于:同一套服务如何在开发、测试和生产中保持一致的运行语义,同时允许环境拥有合理差异;敏感信息如何进入容器而不进入镜像和普通配置;构建出的制品如何被验证、晋级、回滚,而不是每个环境重新构建一份“看起来相同”的镜像。

本文以现代 Docker Engine、BuildKit 和 Compose Specification 为范围,默认运行边界是 Linux 容器。Compose 可以很好地描述单机或一组 Docker Engine 上的应用,但它本身不是完整的生产调度器,也不自动提供灰度流量治理、跨节点故障转移或高可用存储。


1. 先建立正确模型:Compose 最终运行的不是某个 YAML 文件

一个 Compose 应用可以抽象为三层:

  1. 制品(artifact):镜像及其内容,通常用镜像摘要标识。
  2. 应用模型(application model):服务、网络、卷、Secret、依赖关系、健康检查等。
  3. 环境输入(environment inputs):变量插值、外部 Secret、挂载目录、运行时平台资源。

最终启动结果可以写成:

R=Deploy(A(C,E),P)R = \operatorname{Deploy}(A(C, E), P)

其中:

  • CC 是 Compose 文件集合;
  • EE 是用于变量插值的输入;
  • A(C,E)A(C,E) 是 Compose CLI 解析、合并并插值后得到的应用模型;
  • PP 是 Docker Engine 及其本地资源;
  • RR 是实际创建的容器、网络、卷和 Secret 挂载。

这个模型解释了几个常见现象:

  • 修改 .env 可能改变容器环境变量,即使 YAML 没变;
  • 修改 compose.prod.yml 可能只改变端口和资源限制,但不改变镜像;
  • 镜像摘要不变,不代表运行结果完全不变,因为外部配置、卷内容和 Secret 仍然可能变化;
  • docker compose config 验证的是解析后的应用模型,不是应用是否能正确启动,也不是数据库迁移是否成功。

一个更适合交付的约束是:

Idev=Itest=IprodI_{\text{dev}} = I_{\text{test}} = I_{\text{prod}}

这里 II 表示应用镜像的内容摘要,而不是镜像标签。允许环境不同的部分应被限制在配置、资源和外部依赖上:

Re=Run(I,Ce,Se,De)R_e = \operatorname{Run}(I, C_e, S_e, D_e)

其中 ee 是环境,CeC_e 是非敏感配置,SeS_e 是 Secret,DeD_e 是数据卷或外部数据。

因此,“开发和生产使用同一个镜像”并不意味着两者使用同一个数据库、同一个日志级别或同一个公开端口;它意味着构建步骤只发生一次,环境差异在部署阶段表达


2. 多环境设计:共享基线,环境文件只表达差异

一个可维护的目录可以如下组织:

project/
├── compose.yaml
├── compose.dev.yaml
├── compose.test.yaml
├── compose.prod.yaml
├── .env.example
├── Dockerfile
├── app/
└── ops/
    └── secrets/
        ├── dev/
        └── test/

基础文件描述所有环境都必须具备的服务语义:

# compose.yaml
name: demo

services:
  api:
    build:
      context: .
      target: runtime
    image: registry.example.com/demo/api:${APP_TAG:-dev}
    environment:
      APP_NAME: demo-api
      LOG_LEVEL: ${LOG_LEVEL:-info}
      DATABASE_HOST: db
      DATABASE_PORT: "5432"
      DATABASE_USER: app
      DATABASE_NAME: app
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy
    networks:
      - backend

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 20
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - backend

secrets:
  db_password:
    file: ${DB_PASSWORD_FILE:-./ops/secrets/dev/db_password}

volumes:
  db-data:

networks:
  backend:

这个基础文件包含:

  • apidb 两个服务;
  • 容器之间通过服务名 db 通信,而不是通过宿主机 localhost
  • PostgreSQL 从 /run/secrets/db_password 读取密码;
  • api 等待数据库健康检查通过后再启动;
  • 数据库数据位于命名卷,而不是容器可写层。

需要注意,depends_on.condition: service_healthy 只表达 Compose 启动顺序上的等待。它不能保证数据库已经完成业务初始化、迁移或接受所有类型的请求。应用仍应实现连接重试,迁移也应作为明确的交付步骤处理。

开发环境:增加源码挂载和调试能力

# compose.dev.yaml
services:
  api:
    build:
      target: development
    environment:
      LOG_LEVEL: debug
    ports:
      - "8080:8080"
    volumes:
      - ./app:/workspace/app

启动开发环境:

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

多个 -f 文件从左到右合并,后面的文件通常覆盖或扩展前面的模型。这里的结果是:

  • api 仍然使用基础文件中的 Secret、网络和数据库依赖;
  • targetruntime 改为 development
  • 增加端口映射和源码挂载;
  • LOG_LEVEL 被改为 debug

开发环境可以使用源码挂载,因为开发目标是快速反馈;但不能把源码挂载带进生产,否则容器中的程序内容不再由镜像完全决定,且宿主机文件权限、换行符和文件监听行为都会成为运行时变量。

测试环境:使用构建制品,而不是开发挂载

# compose.test.yaml
services:
  api:
    image: registry.example.com/demo/api:${APP_TAG:?APP_TAG is required}
    build: null
    environment:
      LOG_LEVEL: warn
    ports:
      - "18080:8080"

运行测试:

APP_TAG=git-3f2a1c7 docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  config

这里使用 build: null 的目的,是在合并后的模型中移除基础文件的 build 配置,避免测试人员误以为 up --build 会产生应该被测试的镜像。测试环境应该拉取 CI 已经构建并推送的镜像:

APP_TAG=git-3f2a1c7 docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  pull api

APP_TAG=git-3f2a1c7 docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  up -d

如果测试结果依赖“测试机重新构建的镜像”,那么测试验证的就不是将要晋级到生产的制品。

生产环境:固定摘要,不接受模糊标签

生产文件可以直接固定镜像摘要:

# compose.prod.yaml
services:
  api:
    image: registry.example.com/demo/api@sha256:REPLACE_WITH_REAL_DIGEST
    build: null
    environment:
      LOG_LEVEL: info
    ports:
      - "127.0.0.1:8080:8080"
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp

实际交付时不能保留 REPLACE_WITH_REAL_DIGEST,应由发布系统生成完整文件或使用经过审计的模板渲染。启动前执行:

docker compose -f compose.yaml -f compose.prod.yaml config -q
docker compose -f compose.yaml -f compose.prod.yaml pull
docker compose -f compose.yaml -f compose.prod.yaml up -d

config -q 成功只表示 Compose 配置能够解析,不能证明镜像存在、Secret 可读、端口可绑定或应用健康。随后仍需检查:

docker compose -f compose.yaml -f compose.prod.yaml ps
docker compose -f compose.yaml -f compose.prod.yaml logs --tail=100 api

3. Compose 文件合并:覆盖不是简单的文本替换

Compose 的多个文件合并是在 YAML 解析之后对 Compose 模型进行合并,不是把两个文本文件直接拼接。因此,以下写法并不可靠:

# 错误思路:把完整 YAML 当成字符串替换
services:
  api:
    ports:
      - "8080:8080"

环境文件真正改变的是 api 服务模型中的 ports 字段。合并规则需要按字段类型理解。

3.1 标量字段通常由后者覆盖

例如:

# 基础
services:
  api:
    image: example/api:v1
    restart: unless-stopped
# 覆盖
services:
  api:
    image: example/api:v2

结果近似为:

services:
  api:
    image: example/api:v2
    restart: unless-stopped

image 是标量,后一个值替换前一个值。

3.2 映射字段通常按键合并

# 基础
services:
  api:
    environment:
      LOG_LEVEL: info
      HTTP_PORT: "8080"
# 覆盖
services:
  api:
    environment:
      LOG_LEVEL: debug

结果为:

environment:
  LOG_LEVEL: debug
  HTTP_PORT: "8080"

这很适合按环境修改少量配置,但也有一个风险:删除操作不是自然发生的。如果生产文件不再希望继承某个环境变量,仅仅不写它并不会删除基础文件中的键。

现代 Compose 实现支持特殊 YAML 标签控制合并行为:

services:
  api:
    environment:
      FOO: !reset null

!reset 用于将继承值重置为空或默认值;!override 用于完全替换某个字段。它们属于 Compose 对 YAML 合并的扩展,使用前应确认部署环境中的 Compose 版本支持,并在 CI 中执行 docker compose config 验证,而不能假设所有旧版客户端都能解析。

3.3 列表字段不应假设都是“覆盖”或都是“追加”

portsvolumessecretsconfigs 等具有唯一键语义的资源,在合并时需要按资源身份处理。例如端口的身份通常由目标端口、协议等属性决定,而不是简单按文本行号决定。

基础文件:

services:
  api:
    ports:
      - "8080:8080"

开发覆盖:

services:
  api:
    ports:
      - "18080:8080"

结果通常包含两个不同的端口映射,因为宿主机端口不同。若基础文件和覆盖文件都声明了同一个目标端口,结果则取决于 Compose 的唯一资源合并规则,不能把它当成普通列表追加。

对于命令字段要特别谨慎:

services:
  api:
    command: ["./server", "--port", "8080"]

覆盖文件写:

services:
  api:
    command: ["./server", "--debug"]

工程上应把它理解为替换完整命令,而不是把两个数组拼接成:

./server --port 8080 ./server --debug

每次合并后都应查看真实结果:

docker compose \
  -f compose.yaml \
  -f compose.dev.yaml \
  config

config 输出的是插值和合并之后的模型,是排查“为什么这个变量或端口仍然存在”的首要工具。


4. includeextends:解决不同层次的问题

4.1 include 是跨文件装配

include 用于把另一个 Compose 文件作为一个完整的 Compose 模型引入。例如把监控组件单独维护:

# observability.yaml
services:
  metrics:
    image: prom/prometheus:v2.53.0
    networks:
      - backend

networks:
  backend:
    name: demo_backend

主文件:

# compose.yaml
include:
  - observability.yaml

services:
  api:
    image: registry.example.com/demo/api@sha256:...
    networks:
      - backend

networks:
  backend:
    name: demo_backend

include 适合团队把一组相关资源封装成可复用组件,例如日志、监控或本地依赖。它不是简单的文本宏;被引入文件本身也可能有服务、网络、卷和 Secret,命名冲突和资源边界需要通过 config 检查。

include 属于较新的 Compose Specification 能力,具体可用性依赖 Compose CLI 版本。生产流水线必须固定 CLI 版本,并在目标环境用同一版本验证,而不能只在开发者机器上验证。

4.2 extends 是服务级继承

extends 用于一个服务复用另一个服务的配置:

# compose.yaml
services:
  api-base:
    image: registry.example.com/demo/api@sha256:...
    environment:
      LOG_LEVEL: info
    networks:
      - backend

  api:
    extends:
      service: api-base
    ports:
      - "8080:8080"

networks:
  backend:

它表达的是“api 继承 api-base 的服务定义”,而不是“整个项目引入另一个 Compose 应用”。extends 可能让服务之间的依赖关系变得不明显,因此不应把它当成面向所有环境的默认组织方式。

一个实用区分是:

  • 多个 -f:同一个应用的环境覆盖;
  • include:引入一个较完整的 Compose 组件;
  • extends:复用某个服务的定义;
  • YAML anchor:同一文件内的结构复用,不等价于 Compose 模型继承。

5. 变量插值:配置值的来源必须可追踪

Compose 变量插值发生在模型解析阶段,例如:

services:
  api:
    image: "${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${APP_TAG:-dev}"
    environment:
      LOG_LEVEL: "${LOG_LEVEL:-info}"

表达式含义:

  • ${VAR}:变量存在时使用其值,不存在时通常为空;
  • ${VAR:-default}:变量未设置或为空时使用默认值;
  • ${VAR:?message}:变量未设置或为空时直接报错;
  • ${VAR-default}${VAR?message}:只在变量未设置时处理,变量为空时保留空值。

生产镜像不应使用无声默认值:

image: "${IMAGE_REPOSITORY}:${APP_TAG:-latest}"

因为 APP_TAG 拼写错误或发布系统未注入变量时,可能意外部署 latest。生产更适合:

image: "${IMAGE_REPOSITORY:?required}@${IMAGE_DIGEST:?required}"

Compose 的变量插值输入主要来自:

  1. 执行命令的 shell 环境;
  2. --env-file 指定的文件;
  3. 项目目录中的 .env 等 Compose 环境文件。

精确优先级和选项会随 Compose CLI 能力变化,应通过实际命令确认:

docker compose \
  --env-file .env.test \
  -f compose.yaml \
  -f compose.test.yaml \
  config --environment

env_file 服务字段是另一个概念:

services:
  api:
    env_file:
      - ./app.env

它主要用于把变量注入容器环境,并不等价于用于 Compose 文件插值的全局环境文件。下列两个变量的来源不同:

services:
  api:
    image: "example/api:${TAG:-dev}"  # Compose 解析时插值
    env_file:
      - ./runtime.env                 # 容器启动时注入

其中 runtime.env 中的 TAG 不一定会参与 image 字段的解析。

验证配置时建议使用:

docker compose \
  --env-file .env.test \
  -f compose.yaml \
  -f compose.test.yaml \
  config -q

以及:

docker compose \
  --env-file .env.test \
  -f compose.yaml \
  -f compose.test.yaml \
  config --images

前者检查模型是否能解析,后者帮助确认实际会使用哪些镜像。注意:如果把 Secret 写进变量并通过 config 输出,敏感值可能出现在终端、CI 日志或审计记录中。Secret 不应通过普通插值传递。


6. Secret:文件挂载不是“加密”,但比普通环境变量更适合运行时密码

Compose Secret 的基本结构是:

secrets:
  db_password:
    file: ./ops/secrets/dev/db_password

services:
  db:
    image: postgres:16
    secrets:
      - source: db_password
        target: db_password
        uid: "999"
        gid: "999"
        mode: 0400

容器中默认可见路径是:

/run/secrets/db_password

应用或数据库通过读取文件取得 Secret:

cat /run/secrets/db_password

但生产中不应执行这个命令,也不应把它写入日志。PostgreSQL 官方镜像支持:

environment:
  POSTGRES_PASSWORD_FILE: /run/secrets/db_password

这比:

environment:
  POSTGRES_PASSWORD: "plain-password"

更不容易被 docker inspect、进程环境转储和调试输出直接暴露。

6.1 Compose 文件 Secret 的真实边界

在普通 Docker Compose、非 Swarm 的 Linux Docker Engine 上,下面这种声明:

secrets:
  db_password:
    file: ./ops/secrets/prod/db_password

通常意味着 Compose 从宿主机读取文件,并将其以文件形式提供给容器。它解决的是:

  • 不把密码写进镜像层;
  • 不把密码直接写进容器环境变量;
  • 让应用使用固定文件路径读取凭据;
  • 可以通过文件权限和部署主机权限控制访问。

不自动意味着

  • Secret 在宿主机上被加密保存;
  • Secret 在 Compose YAML 中被加密;
  • Secret 不会被拥有宿主机权限的管理员读取;
  • Secret 会自动轮换;
  • Secret 会自动从云端密钥管理系统获取。

external: true 适合引用平台已存在的 Secret:

secrets:
  db_password:
    external: true
    name: demo_db_password

但外部 Secret 的可用性取决于部署后端。Docker Swarm 的 Secret 管理与普通 Docker Compose 的文件型 Secret 不是同一套运行模型;不能仅因为 YAML 中写了 external: true,就认为独立 Docker Engine 已经具备 Swarm Secret 存储能力。

6.2 Secret 不应进入构建上下文

错误做法:

FROM alpine
COPY ops/secrets/prod/db_password /run/secrets/db_password

即使之后删除文件,它也可能已经存在于某个镜像层中。正确做法是让 Secret 在运行阶段挂载。

如果构建阶段确实需要私有包仓库凭据,应使用 BuildKit 的 secret mount,而不是 ARGENV

# syntax=docker/dockerfile:1

FROM python:3.12-slim AS builder
WORKDIR /build

COPY requirements.txt .

RUN --mount=type=secret,id=pip_token \
    TOKEN="$(cat /run/secrets/pip_token)" && \
    pip install --no-cache-dir \
      --index-url "https://token:${TOKEN}@packages.example.com/simple" \
      -r requirements.txt

Compose 构建配置可以写成:

services:
  api:
    build:
      context: .
      secrets:
        - pip_token

secrets:
  pip_token:
    file: ./ops/secrets/dev/pip_token

该 Secret 是 BuildKit 构建会话中的临时挂载,不应进入最终镜像层。仍需注意:

  • 构建日志不能打印令牌;
  • 构建上下文中不能包含无关敏感文件;
  • CI 运行器本身仍需要受到保护;
  • 使用的 Compose CLI 和 BuildKit 版本必须支持对应的 build.secrets 语法。

6.3 Secret 轮换必须考虑进程生命周期

假设宿主机上的 Secret 文件从旧值替换为新值:

install -m 0400 new_password ./ops/secrets/prod/db_password

容器中的应用是否立即看到新值,不能只靠 Compose 文件推断。不同挂载实现和应用读取方式可能不同:

  • 应用启动时读取一次:文件变了,内存中的密码仍是旧值;
  • 应用每次请求读取:可能读到新值,但通常不推荐这样做;
  • 应用支持信号或管理接口重载:可以设计平滑轮换;
  • 数据库密码轮换:还必须协调数据库端修改、连接池重连和旧密码失效顺序。

因此 Secret 轮换流程应明确为状态转换:

旧凭据有效
  -> 写入新凭据
  -> 修改依赖系统接受新凭据
  -> 让应用重新读取或滚动重建
  -> 验证新连接
  -> 失效旧凭据

若在“应用重建”之前就失效旧密码,连接池可能全部失败;若永不失效旧密码,则轮换只完成了一半。


7. 不可变制品:摘要比标签更接近真实身份

镜像标签是可变指针:

registry.example.com/demo/api:prod

今天它可能指向摘要 A,明天可以被推送到摘要 B。镜像摘要是内容寻址标识:

registry.example.com/demo/api@sha256:abcdef...

发布流程应接近:

源代码提交
  -> CI 构建
  -> 单元测试和安全检查
  -> 推送镜像
  -> 获取 digest
  -> 用 digest 部署测试
  -> 验证
  -> 用同一 digest 部署生产

构建示例:

docker buildx build \
  --platform linux/amd64 \
  --tag registry.example.com/demo/api:git-3f2a1c7 \
  --push \
  .

查询摘要:

docker buildx imagetools inspect \
  registry.example.com/demo/api:git-3f2a1c7

输出中会出现类似:

Name:      registry.example.com/demo/api:git-3f2a1c7
Digest:    sha256:abcdef...

生产 Compose 使用:

services:
  api:
    image: registry.example.com/demo/api@sha256:abcdef...
    build: null

这里的 build: null 防止生产命令意外依据本地上下文重新构建。更严格的发布系统还会在部署前检查:

docker image inspect \
  registry.example.com/demo/api@sha256:abcdef...

并将以下信息作为发布记录:

  • Git 提交;
  • 镜像摘要;
  • Dockerfile 和依赖锁文件摘要;
  • 构建平台;
  • 测试结果;
  • Compose 渲染后的非敏感配置摘要;
  • 部署时间和操作者。

7.1 多平台镜像的边界

如果生产主机是 linux/amd64,而 CI 构建机是 ARM 主机,不能只写:

docker build .

然后假设产物适用于所有平台。应显式构建目标平台,或构建多平台镜像索引:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag registry.example.com/demo/api:git-3f2a1c7 \
  --push \
  .

生产主机最终拉取的是目标平台对应的镜像内容,但发布记录仍应记录索引摘要和实际平台摘要。Linux 容器不能直接解决 Windows 内核容器的兼容问题;本文的网络、权限、挂载和文件路径示例均以 Linux 容器为边界。

7.2 不可变制品不等于不可变运行环境

即使镜像摘要固定,以下对象仍可能变化:

  • 外部 Secret;
  • 命名卷中的数据库数据;
  • 宿主机 bind mount;
  • DNS 和外部 API;
  • Docker Engine、内核和 cgroup 配置;
  • Compose 文件中的环境变量。

所以完整审计身份应至少是:

Release=(ImageDigest,ComposeModel,SecretVersion,Platform)\text{Release} = (\text{ImageDigest}, \text{ComposeModel}, \text{SecretVersion}, \text{Platform})

其中 Secret 的值不应记录到日志,但可以记录其版本号或不可逆摘要。


8. 验证:把“能解析”与“能运行”分成不同层次

一个可靠的验证流程至少分四层。

第一层:Compose 语法和合并模型

docker compose \
  --env-file .env.test \
  -f compose.yaml \
  -f compose.test.yaml \
  config -q

失败示例包括:

  • YAML 缩进错误;
  • 必需变量未设置;
  • include 文件不存在;
  • 合并结果不满足 Compose 模型;
  • Secret 源文件路径无效。

第二层:镜像和平台

docker compose \
  -f compose.yaml \
  -f compose.prod.yaml \
  pull

docker compose \
  -f compose.yaml \
  -f compose.prod.yaml \
  images

此时要验证镜像能否拉取、架构是否匹配、摘要是否符合发布记录。

第三层:容器和依赖健康

docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  up -d

docker compose ps
docker compose logs --tail=200 api

docker compose ps 显示的是容器状态和健康状态;running 不等价于业务可用。健康检查应尽量验证真实依赖,例如 HTTP /health/ready 可以检查数据库连接和必要迁移状态,而不是只检查进程存在。

第四层:业务验证

curl --fail http://127.0.0.1:18080/health/ready
curl --fail http://127.0.0.1:18080/version

/version 最好返回 Git 提交或构建版本,但不应返回 Secret。这样可以确认“正在运行的容器”确实来自预期制品。


9. 故障路径:启动失败时如何判断是配置、Secret 还是制品问题

一个典型启动时序如下:

sequenceDiagram
    participant CI as CI/CD
    participant R as Registry
    participant H as Docker Engine
    participant C as Compose CLI
    participant A as API
    participant DB as PostgreSQL

    CI->>R: 推送镜像并取得 digest
    CI->>C: 提供 Compose 文件和环境变量
    C->>C: 插值、合并、config 校验
    C->>R: 请求指定 digest
    R-->>H: 返回目标平台镜像
    C->>H: 创建网络、卷、容器
    H->>DB: 挂载 Secret 并启动数据库
    DB-->>H: healthcheck 通过
    H->>A: 启动 API 并挂载同一 Secret
    A->>DB: 使用 /run/secrets/db_password 连接
    DB-->>A: 接受连接
    A-->>CI: readiness 验证成功

故障点可以按阶段定位:

阶段 典型表现 首要检查
插值 required variable is missing shell、--env-file、变量名拼写
合并 端口或环境变量不符合预期 docker compose config
拉取 manifest unknown、权限错误 registry、digest、登录凭据、平台
Secret 创建 文件不存在或权限错误 Secret 源路径、宿主机权限、目标用户
数据库启动 容器退出、密码认证失败 docker compose logs db、Secret 内容格式
API 启动 连接拒绝、解析失败 服务名、网络、健康检查、连接重试
业务验证 HTTP 非 2xx 应用日志、迁移、外部依赖

Secret 文件尤其容易出现隐藏换行问题。创建密码文件时应确认是否包含结尾换行;应用通常应读取后去除换行,但不能假设所有程序都会处理:

printf '%s' 'correct-password' > ./ops/secrets/dev/db_password
chmod 0400 ./ops/secrets/dev/db_password

不要用:

echo 'correct-password' > ./ops/secrets/dev/db_password

除非已经确认目标程序允许结尾换行。


10. 生产交付:回滚的是已验证摘要,而不是重新构建

假设当前生产版本为:

api@sha256:AAA

新版本为:

api@sha256:BBB

部署前应先在测试环境使用 BBB 完成验证。生产发布失败时,回滚操作应恢复到已知可用的 Compose 文件和摘要:

services:
  api:
    image: registry.example.com/demo/api@sha256:AAA
    build: null

然后:

docker compose -f compose.yaml -f compose.prod.yaml pull api
docker compose -f compose.yaml -f compose.prod.yaml up -d api
docker compose -f compose.yaml -f compose.prod.yaml ps

这类回滚通常会重建或替换 api 容器,但不会自动回滚数据库卷。数据库迁移因此必须满足至少一个条件:

  • 向后兼容,使旧应用仍能读取新结构;
  • 迁移分成可扩展、可收缩的阶段;
  • 有明确的数据备份和恢复流程。

如果 BBB 执行了不可逆迁移,单纯把镜像改回 AAA 可能导致旧应用无法启动。镜像回滚和数据回滚是两个不同的操作,不能合并成一个 docker compose up 命令。

灰度的真实边界

单个 Compose 项目不会自动完成生产灰度。要进行灰度,至少需要:

  • 两个可独立启动的应用副本或两个 Compose 项目;
  • 反向代理、服务发现或负载均衡;
  • 明确的流量权重切换;
  • 独立的健康指标和停止条件。

例如:

proxy
 ├── api-blue  -> digest AAA
 └── api-green -> digest BBB

先启动 api-green,通过 readiness、错误率和关键业务检查,再让代理把少量流量转给 green。若失败,将代理切回 blue。Compose 负责创建和管理服务容器,但不会替代理解外部流量系统的状态。


11. 容量和运行手册:Compose 能描述什么,不能保证什么

生产配置中的资源限制应与容量假设相匹配:

services:
  api:
    cpus: "2.0"
    mem_limit: 1g
    pids_limit: 512

这些字段约束容器在 Docker Engine 上的资源使用,但不自动完成容量规划。容量判断需要把请求量、并发、单请求资源、数据库连接和宿主机余量放在同一个模型中。

例如,若每个 API 容器最多使用 100 个数据库连接,运行 3 个副本,理论连接上限至少是:

Cdb3×100+Cmigration+CadminC_{\text{db}} \ge 3 \times 100 + C_{\text{migration}} + C_{\text{admin}}

其中:

  • CdbC_{\text{db}} 是数据库允许的总连接数;
  • CmigrationC_{\text{migration}} 是迁移任务占用的连接;
  • CadminC_{\text{admin}} 是运维和监控预留连接。

如果只增加 API 容器数量,却不调整数据库连接池,扩容可能把数据库直接推入连接耗尽,而不是提升吞吐。

一份可执行的 Compose 运行手册至少应包含:

# 查看解析后的最终模型
docker compose -f compose.yaml -f compose.prod.yaml config

# 查看服务状态
docker compose -f compose.yaml -f compose.prod.yaml ps

# 查看最近日志
docker compose -f compose.yaml -f compose.prod.yaml logs --tail=200 api

# 重启单个无状态服务
docker compose -f compose.yaml -f compose.prod.yaml up -d --no-deps api

# 停止项目,但保留命名卷
docker compose -f compose.yaml -f compose.prod.yaml down

# 删除卷:高风险,仅在确认数据已备份且确实要销毁时执行
docker compose -f compose.yaml -f compose.prod.yaml down -v

down -v 会删除项目声明的命名卷,数据库场景下通常意味着数据损失风险。生产手册必须明确哪些命令允许执行、执行前需要什么备份、执行后如何验证恢复,而不能只写“出问题就重启”。


12. 常见误解及其失败表现

误解一:.env 就是 Secret 管理

.env 适合保存本地开发的非敏感默认值,例如:

APP_TAG=dev
LOG_LEVEL=debug

它不适合作为生产密码库,因为文件可能进入 Git、CI 工作目录、备份系统或错误日志。敏感值应通过 Secret 管理系统、受限文件或部署平台凭据机制提供。

误解二:depends_on 能保证应用可用

它最多控制 Compose 创建和启动顺序,并可配合健康检查等待依赖达到健康状态。它不能处理:

  • 数据库迁移;
  • 外部 API 限流;
  • DNS 暂时不可用;
  • 应用启动后连接池初始化失败;
  • 依赖启动后再次故障。

应用必须有超时、重试和失败退出策略,健康检查也应反映实际服务能力。

误解三:使用同一个标签就等于使用同一个制品

api:prod 可能在 registry 中被重新推送。跨环境交付应记录 digest,并在测试和生产使用同一个 digest。

误解四:固定镜像摘要后就可以随意修改卷和配置

摘要只固定镜像内容。数据库卷、Secret、环境变量、网络和宿主机内核仍会改变运行结果。发布记录需要同时管理制品和运行配置。

误解五:Compose 生产环境等价于 Kubernetes

Compose 可以在单机 Docker Engine 上完成相当清晰的服务编排和交付,但它不天然提供:

  • 跨节点调度;
  • 自动副本重调度;
  • 原生滚动升级控制器;
  • 服务级流量权重;
  • 分布式 Secret 管理;
  • 自动故障转移存储。

当这些能力成为硬性要求时,应评估 Swarm、Kubernetes 或云平台托管服务,而不是继续堆叠 Compose 文件。


13. 一套最小但完整的交付检查

发布一个 Compose 应用时,可以把流程固化为以下顺序:

# 1. 在 CI 中构建并推送一次
docker buildx build \
  --platform linux/amd64 \
  -t registry.example.com/demo/api:git-3f2a1c7 \
  --push .

# 2. 获取并记录 digest
docker buildx imagetools inspect \
  registry.example.com/demo/api:git-3f2a1c7

# 3. 用目标测试配置渲染并验证
APP_TAG=git-3f2a1c7 \
docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  config -q

# 4. 测试环境拉取同一版本
APP_TAG=git-3f2a1c7 \
docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  pull api

APP_TAG=git-3f2a1c7 \
docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  up -d

# 5. 执行健康和业务验证
curl --fail http://127.0.0.1:18080/health/ready

# 6. 将已验证的 digest 写入生产模型,再渲染
docker compose \
  -f compose.yaml \
  -f compose.prod.yaml \
  config -q

# 7. 生产部署和验证
docker compose \
  -f compose.yaml \
  -f compose.prod.yaml \
  pull api

docker compose \
  -f compose.yaml \
  -f compose.prod.yaml \
  up -d api

curl --fail http://127.0.0.1:8080/health/ready

这套流程的核心不是命令数量,而是三个不变量:

  1. 环境差异显式存在于 Compose 模型,而不是隐藏在开发者机器状态中;
  2. Secret 通过运行时输入进入容器,不进入镜像和普通配置日志;
  3. 测试、灰度和生产使用同一个已记录的不可变镜像摘要。

Compose Specification 的文件模型、合并、变量插值、Secret、includeextends 规则,应以当前 Compose 文档和规范为准;部署系统还必须锁定 Compose CLI、Docker Engine、BuildKit 和目标平台,因为解析能力、特殊合并标签和构建选项都可能存在版本差异。只有把配置解析、制品身份、Secret 生命周期和运行验证放在同一条交付链中,多环境才不会退化为几份互相漂移的 YAML 文件。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。