WR Blog 加载中...
返回文章
DockerCompose配置管理DevOps

Compose 配置合并:Override、include、extends、变量插值和验证

Compose 配置合并:Override、include、extends、变量插值和验证封面

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

Compose 配置合并:Override、include、extends、变量插值和验证

Compose 配置合并解决的是一个具体问题:同一组服务在不同场景下通常只有少数属性不同。例如开发环境需要源码挂载和调试端口,测试环境需要固定镜像,生产环境需要外部网络和只读配置。与其复制三份完整 YAML,不如将配置拆成基础模型、覆盖文件、可复用片段和环境变量,再由 Compose 生成最终模型。

这里需要先区分四种机制:

  • Override file:通过多个 -f 文件按顺序合并,适合“基础配置 + 环境覆盖”。
  • include:把另一个 Compose 应用整体引入当前模型,适合组合多个独立组件。
  • extends:复用或扩展某个服务的定义,适合服务级继承。
  • 变量插值:在合并前把 ${VAR} 等表达式解析为具体值。
  • 验证:通过 docker compose config 检查解析、插值、合并后的最终模型,而不是只检查某个源文件的 YAML 语法。

这些机制最终都作用于 Compose 的配置模型。Compose 文件只是模型的一种 YAML 表示;真正被创建的是合并、插值和规范化后的服务、网络、卷、配置和密钥集合。


一、先建立 Compose 配置处理的整体顺序

可以把常见的 Compose 配置处理抽象为:

F1,F2,,FnI(F1),I(F2),,I(Fn)MN(M)V(N(M))F_1, F_2, \ldots, F_n \rightarrow I(F_1), I(F_2), \ldots, I(F_n) \rightarrow M \rightarrow N(M) \rightarrow V(N(M))

其中:

  • FiF_i 是输入的 Compose 文件;
  • I(Fi)I(F_i) 是对单个文件执行变量插值后的结果;
  • MM 是按照合并规则得到的模型;
  • N(M)N(M) 是规范化后的模型,例如短语法转成长语法;
  • VV 是模型验证;
  • 最终模型用于创建容器、网络、卷等资源。

在使用多个 -f 文件时,关键顺序是:

  1. Compose 分别读取各文件;
  2. 每个文件中的变量表达式按其上下文进行插值;
  3. 文件按命令行顺序合并,后面的文件覆盖或扩展前面的文件;
  4. Compose 对结果进行规范化和一致性检查;
  5. 运行命令,例如 uprunbuild

因此,变量插值不是简单的“先把所有文件拼在一起,再统一替换”。尤其在不同文件使用不同变量来源时,不能假设所有变量都会在最终合并后才解析。

一个基本命令如下:

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

这里 compose.yaml 是基础文件,compose.dev.yaml 是覆盖文件。config 不会启动容器,而是打印最终的 Compose 模型。


二、Override:多个 Compose 文件的有序合并

2.1 Override 的含义

Compose 中常说的 override file,通常指通过多个 -f 参数传入的后续文件:

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

Compose 把第一个文件作为基础,后面的文件依次作用于前面的结果:

M0=F1M_0 = F_1

Mi=merge(Mi1,Fi+1)M_i = merge(M_{i-1}, F_{i+1})

后面的文件并不是“再启动一套服务”,而是修改已经存在的服务模型。服务名相同,则合并该服务;只在后续文件出现的服务,则会加入最终模型。

2.2 标量字段:后者替换前者

对于通常的标量字段,后一个文件直接替换前一个文件的值。标量包括字符串、数字、布尔值和空值等。

基础文件:

# compose.yaml
services:
  api:
    image: example/api:1.0
    restart: unless-stopped
    environment:
      APP_ENV: production
      LOG_LEVEL: info

覆盖文件:

# compose.dev.yaml
services:
  api:
    image: example/api:dev
    restart: "no"
    environment:
      LOG_LEVEL: debug

执行:

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

最终相关结果等价于:

services:
  api:
    image: example/api:dev
    restart: "no"
    environment:
      APP_ENV: production
      LOG_LEVEL: debug

这里有两个不同层次的合并:

  • image 是标量,直接由 example/api:dev 替换 example/api:1.0
  • environment 是映射,映射中的 LOG_LEVEL 被替换,但未出现的 APP_ENV 保留。

所以“后文件覆盖前文件”并不意味着整个服务对象被无条件替换。必须先判断字段的合并类型。

2.3 映射字段:按键合并

environmentlabelsbuild.args 等映射字段,Compose 按键合并:

merge(A,B)[k]={B[k],kBA[k],kBmerge(A,B)[k] = \begin{cases} B[k], & k \in B \\ A[k], & k \notin B \end{cases}

示例:

# compose.yaml
services:
  api:
    environment:
      APP_ENV: production
      LOG_LEVEL: info
      FEATURE_CACHE: "false"
# compose.test.yaml
services:
  api:
    environment:
      APP_ENV: test
      FEATURE_CACHE: "true"

结果为:

services:
  api:
    environment:
      APP_ENV: test
      LOG_LEVEL: info
      FEATURE_CACHE: "true"

如果需要删除基础文件中的某个映射键,不能仅仅把它从覆盖文件中省略,因为省略的含义是“保留”。可以使用显式的空值,但具体效果取决于字段和 Compose 实现对空值的处理;在需要明确删除时,应优先使用支持的 !reset 标签,或者重新设计基础配置,避免产生难以识别的隐式继承。

2.4 普通序列:通常追加,而不是替换

许多序列字段在合并时会追加:

# compose.yaml
services:
  api:
    tmpfs:
      - /tmp
    dns:
      - 1.1.1.1
# compose.dev.yaml
services:
  api:
    tmpfs:
      - /run
    dns:
      - 8.8.8.8

最终结果通常包含两边的值:

services:
  api:
    tmpfs:
      - /tmp
      - /run
    dns:
      - 1.1.1.1
      - 8.8.8.8

这会导致一个常见误解:开发覆盖文件写了一个新列表,并不一定表示“用这个列表替代旧列表”。

对于某些资源型序列,Compose 会按唯一键去重或合并,而不是简单追加。例如:

  • ports 的唯一性由端口映射的关键属性决定;
  • volumes 的唯一性主要以容器目标路径为依据;
  • secretsconfigs 以目标路径等属性识别;
  • depends_onnetworks 等字段具有专门的映射语义。

端口尤其容易出错:

# compose.yaml
services:
  web:
    ports:
      - "8080:80"
# compose.dev.yaml
services:
  web:
    ports:
      - "8081:80"

这通常不是把 8080 改成 8081,而是得到两个端口发布:

ports:
  - target: 80
    published: "8080"
  - target: 80
    published: "8081"

如果目标是“开发环境只暴露 8081”,仅添加 8081:80 不够。应该使用覆盖整个字段的机制,或者让基础文件不要声明宿主机端口。

2.5 commandentrypoint 和健康检查命令是特殊情况

容器命令不是普通的“命令参数列表追加”。如果基础文件和覆盖文件都声明 command,通常应按替换理解:

# compose.yaml
services:
  api:
    command: ["./api", "--config", "/etc/api/prod.yaml"]
# compose.dev.yaml
services:
  api:
    command: ["./api", "--config", "/etc/api/dev.yaml", "--debug"]

结果是开发命令,而不是把两个命令列表拼接成一个不可执行的参数列表:

command:
  - ./api
  - --config
  - /etc/api/dev.yaml
  - --debug

entrypointhealthcheck.test 也属于需要特别注意的命令字段。不能把所有 YAML 序列都套用“追加”规则。

2.6 用 !reset!override 表达删除与完全替换

现代 Docker Compose 支持用于合并控制的 YAML 标签,但它们属于 Compose 对 YAML 的扩展,不是所有第三方 Compose 实现都保证支持。

!reset 表示将字段重置为空值或空集合。例如:

# compose.prod.yaml
services:
  web:
    ports: !reset []

它的意图是清除基础文件中的 ports,而不是追加一个空列表。

!override 表示跳过默认合并规则,直接使用当前文件中的值:

# compose.dev.yaml
services:
  web:
    ports: !override
      - "8081:80"

如果基础文件有多个端口,!override 使最终结果只保留 8081:80

这两个标签解决的是不同问题:

  • !reset:删除基础值;
  • !override:完全替换基础字段。

它们不能随意替代普通 YAML 的 null、空列表或省略字段。交付流水线中使用前,应在目标 Docker Compose 版本上执行 docker compose config 验证。


三、路径解析:多文件合并时最容易被忽略的边界

Compose 中的相对路径包括:

  • build.context
  • build.dockerfile
  • env_file
  • volumes 中的宿主机路径
  • secrets.file
  • configs.file
  • extends.file

在通过多个 -f 文件合并时,路径通常以项目的基础 Compose 文件或项目目录为参照,而不是简单地以每个覆盖文件自身所在目录为参照。这样做是为了保证不同文件合并后仍然属于同一个 Compose 项目,但也意味着目录结构变化可能导致路径指向错误。

例如:

project/
├── compose.yaml
├── compose.prod.yaml
└── prod/
    └── .env
# compose.prod.yaml
services:
  api:
    volumes:
      - ./prod-config:/etc/api:ro

工程师有时会直觉地认为 ./prod-config 相对于 compose.prod.yaml 所在目录;如果文件实际不在预期目录,Compose 可能报路径不存在,或者更危险地绑定了错误的宿主机目录。

应使用最终模型检查路径:

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

在 Linux 容器环境中,宿主机绑定挂载还受到以下条件约束:

  • 宿主机路径必须存在,或由 Compose 按短语法行为创建;
  • 文件和目录的类型必须匹配;
  • 容器内用户必须有对应读写权限;
  • SELinux、AppArmor 等安全机制可能进一步限制访问;
  • 容器内的 /etc/api 与宿主机目录不是同一个命名空间。

因此,config 能验证路径被解析成什么,不代表容器运行时一定能成功访问它。


四、include:组合独立的 Compose 应用

4.1 include 解决什么问题

include 用于把另一个 Compose 文件或 Compose 应用整体引入当前项目。例如一个项目可能把基础设施拆成:

compose.yaml
compose/
├── observability.yaml
├── postgres.yaml
└── messaging.yaml

主文件可以写:

include:
  - compose/postgres.yaml
  - compose/messaging.yaml
  - path: compose/observability.yaml
    env_file:
      - compose/observability.env

被引入的文件可以包含自己的:

  • services
  • networks
  • volumes
  • configs
  • secrets
  • include

include 不是单纯的 YAML 文本拼接。它的价值在于:被包含的 Compose 文件可以作为相对独立的 Compose 模型维护,其相对路径和变量来源可以围绕自己的目录组织。

4.2 include-f 的核心差异

假设目录为:

project/
├── compose.yaml
└── components/
    └── database/
        ├── compose.yaml
        └── init/

被包含文件:

# components/database/compose.yaml
services:
  db:
    image: postgres:16
    volumes:
      - ./init:/docker-entrypoint-initdb.d:ro

如果主文件使用传统多文件方式:

docker compose \
  -f compose.yaml \
  -f components/database/compose.yaml \
  config

./init 的解析可能受项目基础文件路径规则影响,不能简单理解为“相对于第二个文件”。

如果主文件使用:

include:
  - components/database/compose.yaml

include 的设计目标是让被包含模型中的相对路径相对于被包含文件所在的项目目录解析。因此 ./init 表示 components/database/init,这更适合组件化目录。

两者的适用方向不同:

机制 主要语义 适合场景
多个 -f 对同一个模型进行有序覆盖 开发、测试、生产差异
include 把多个 Compose 应用组合成一个模型 数据库、监控、消息系统等组件拼装
extends 复用单个服务定义 多个服务共享镜像、环境、资源配置

4.3 include 的路径、变量与冲突

可以为被包含文件指定变量文件:

include:
  - path: components/database/compose.yaml
    env_file:
      - components/database/.env

这使组件能够使用自己的变量输入,而不必把所有变量都放到主项目的 .env 中。

但是,组件组合仍然必须处理资源命名冲突。例如两个被包含文件都声明了:

services:
  redis:
    image: redis:7

或者都声明了同名顶层卷:

volumes:
  data:

Compose 对冲突会进行诊断;不同版本对冲突报告和处理细节可能存在差异。不能把“后 include 的文件必然覆盖前一个文件”当作稳定的覆盖机制。需要有意覆盖时,应使用多个 -f 文件;使用 include 时则应通过服务名、网络名和卷名设计清晰的命名空间,避免名称冲突。

include 还可能递归包含其他文件。递归组合提高了复用能力,同时也增加了排查难度。出现服务来源不明时,应查看:

docker compose config

该输出会展示最终模型,通常比逐个阅读源文件更适合确认“某个字段最终来自哪里”。


五、extends:服务级继承与合并

5.1 基本用法

extends 允许一个服务复用另一个服务的定义:

services:
  api-base:
    image: example/api:1.0
    working_dir: /app
    environment:
      APP_ENV: production
      LOG_LEVEL: info
    volumes:
      - ./src:/app:ro

  api:
    extends:
      service: api-base
    environment:
      LOG_LEVEL: debug
    ports:
      - "8080:8080"

最终的 api 服务继承 api-base 的字段,并覆盖 LOG_LEVEL,同时增加端口。

也可以从另一个文件继承:

services:
  api:
    extends:
      file: compose.base.yaml
      service: api-base
    environment:
      APP_ENV: test

这里的 service 指定被继承的服务,file 指定来源文件。文件路径的解析遵循 Compose 的项目路径规则,使用前应通过实际项目目录和 config 命令确认。

5.2 extends 的合并不是面向对象语言的完整继承

extends 只合并服务配置,不意味着 Compose 会自动创建父服务,也不意味着会自动导入整个应用。

例如:

# compose.base.yaml
services:
  api-base:
    image: example/api:1.0
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
# compose.yaml
services:
  api:
    extends:
      file: compose.base.yaml
      service: api-base

不能据此简单断言 db 一定会作为当前项目服务被创建。被继承的是 api-base 的服务定义;关联服务、网络和卷是否存在,还必须在当前最终模型中明确声明并满足依赖关系。

更稳妥的写法是:

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10

  api:
    extends:
      file: compose.base.yaml
      service: api-base
    depends_on:
      db:
        condition: service_healthy

这样 api 的依赖对象在当前文件中明确存在。

5.3 extends 的合并规则

extends 大体沿用 Compose 的服务合并规则:

  • 标量字段:子服务覆盖父服务;
  • 映射字段:按键合并;
  • 普通序列:按规则追加;
  • portsvolumessecretsconfigs 等资源型字段:按唯一属性合并;
  • commandentrypointhealthcheck.test:不能按普通序列追加来理解。

示例:

services:
  worker-base:
    image: example/worker:1.0
    environment:
      QUEUE: default
      LOG_LEVEL: info
    volumes:
      - worker-cache:/var/cache/worker

  worker-test:
    extends:
      service: worker-base
    environment:
      QUEUE: test
    command: ["./worker", "--once"]

结果的逻辑模型为:

services:
  worker-test:
    image: example/worker:1.0
    environment:
      QUEUE: test
      LOG_LEVEL: info
    volumes:
      - worker-cache:/var/cache/worker
    command:
      - ./worker
      - --once

注意服务名发生了变化:父服务是 worker-base,最终服务是 worker-test。如果父服务的卷、网络或命令中含有依赖服务名,还需要检查它们在新服务名下是否仍然语义正确。

5.4 extends 的依赖闭包问题

服务配置中可能引用其他服务:

  • depends_on
  • links
  • volumes_from
  • network_mode: service:xxx

继承服务时,Compose 不应被理解为自动复制整个依赖拓扑。最终模型必须满足所有引用关系,否则会出现模型验证错误或启动错误。

因此,使用 extends 时要区分:

服务配置继承应用拓扑继承服务配置继承 \neq 应用拓扑继承

extends 复制的是服务字段;数据库、缓存、消息队列等依赖服务是否被纳入当前项目,需要由最终 Compose 文件显式表达。


六、变量插值:在 YAML 合并前解析值

6.1 基本语法

Compose 支持常见的 Shell 风格变量插值:

services:
  api:
    image: "${API_IMAGE:-example/api:latest}"
    environment:
      APP_ENV: "${APP_ENV:-development}"
      API_KEY: "${API_KEY:?API_KEY must be set}"

常用形式包括:

表达式 行为
${VAR} 使用 VAR,未设置时通常为空并给出警告
${VAR:-default} VAR 未设置或为空时使用默认值
${VAR-default} 只有 VAR 未设置时使用默认值,空字符串仍保留
${VAR:?message} 未设置或为空时报告错误
${VAR?message} 未设置时报告错误,空字符串可保留
${VAR:+replacement} 已设置且非空时使用替代值
${VAR+replacement} 已设置时使用替代值
$$ 输出字面量 $,避免 Compose 插值

例如:

services:
  api:
    command: ["sh", "-c", "echo $${RUNTIME_VALUE}"]

Compose 不会把 $${RUNTIME_VALUE} 当作 Compose 变量替换,而是向容器传入:

echo ${RUNTIME_VALUE}

之后由容器中的 Shell 或应用自行解释。

6.2 插值只处理值,不默认处理键

下面的写法不会按预期把变量替换到标签键中:

services:
  api:
    labels:
      "${LABEL_KEY}": enabled

Compose 的插值主要应用于 YAML 值。对于标签和环境变量,如果必须动态生成键,应使用列表语法:

services:
  api:
    labels:
      - "${LABEL_KEY}=enabled"
    environment:
      - "${ENV_KEY}=${ENV_VALUE}"

不过列表语法会牺牲一部分结构可读性,变量为空时还可能产生不易发现的配置问题。生产配置通常应让键保持静态,只插值值。

6.3 变量来源与优先级

变量插值使用的变量来源,不等同于容器内部最终拥有的环境变量。

在 Docker Compose CLI 中,常见来源包括:

  1. 命令执行环境中的 Shell 变量;
  2. 当前工作目录中的 .env
  3. --env-file 指定的变量文件;
  4. 项目目录中的其他环境文件,具体行为受命令和 Compose 版本影响。

可以用以下命令检查 Compose 当前用于插值的环境:

docker compose config --environment

可以用 --env-file 明确指定输入:

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

需要区分三种概念:

Compose 插值变量

用于解析 Compose 文件:

services:
  api:
    image: "example/api:${TAG}"

environment 中传给容器的变量

services:
  api:
    environment:
      APP_ENV: "${APP_ENV:-development}"

env_file 传给容器的变量

services:
  api:
    env_file:
      - app.env

env_file 主要描述容器启动时的环境变量来源,并不应被自动理解为所有 Compose 文件插值的通用变量源。需要插值时,应通过 Shell 环境、项目 .env 或显式 --env-file 提供变量,并使用 config --environment 检查结果。

6.4 变量插值与合并的完整算例

基础文件:

# compose.yaml
services:
  api:
    image: "example/api:${API_TAG:-stable}"
    environment:
      APP_ENV: "${APP_ENV:-production}"
      LOG_LEVEL: "${LOG_LEVEL:-info}"

测试覆盖文件:

# compose.test.yaml
services:
  api:
    image: "example/api:${API_TAG:-test}"
    environment:
      APP_ENV: test
      TEST_DATABASE_URL: "${TEST_DATABASE_URL:?TEST_DATABASE_URL is required}"

执行:

API_TAG=2025.03 \
LOG_LEVEL=debug \
TEST_DATABASE_URL='postgres://test-db/app' \
docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  config

处理过程可以写成:

  1. 基础文件中:
    • API_TAG 从 Shell 得到 2025.03
    • APP_ENV 未从 Shell 提供,使用 production
    • LOG_LEVEL 从 Shell 得到 debug
  2. 测试文件中:
    • API_TAG 也得到 2025.03
    • APP_ENV 是字面量 test
    • TEST_DATABASE_URL 已提供。
  3. 两个文件合并:
    • image 是标量,测试文件的 example/api:2025.03 覆盖基础文件同值;
    • environment 按键合并;
    • APP_ENVproduction 覆盖为 test
    • LOG_LEVEL 保留为 debug
    • TEST_DATABASE_URL 加入最终模型。

最终结果的关键部分为:

services:
  api:
    image: example/api:2025.03
    environment:
      APP_ENV: test
      LOG_LEVEL: debug
      TEST_DATABASE_URL: postgres://test-db/app

如果省略 TEST_DATABASE_URL

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

Compose 应在插值或配置处理阶段报告:

TEST_DATABASE_URL is required

这种 ${VAR:?message} 形式适合把“配置缺失”提前变成确定性失败,而不是让容器启动后才因为连接字符串为空而失败。


七、不要把 Secret 当成变量插值的替代品

变量插值适合非敏感配置,例如镜像标签、日志级别和端口。密码、令牌和私钥不应为了插值方便而放进命令行、Git 仓库或最终 config 输出。

不推荐:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"

问题在于:

  • docker compose config 可能把解析后的密码打印出来;
  • Shell 历史、CI 日志和进程环境可能暴露密码;
  • 环境变量通常会出现在容器检查信息中;
  • 变量文件并不天然等同于安全的 Secret 存储。

Linux 容器边界下,可以使用 Compose secrets:

services:
  db:
    image: postgres:16
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

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

这要求镜像或启动脚本支持 _FILE 约定;POSTGRES_PASSWORD_FILE 是 PostgreSQL 官方镜像支持的方式,不是所有镜像都自动支持。Secret 文件的宿主机权限、版本控制排除和部署注入方式仍需单独治理。

还需要注意:docker compose config 在某些情况下会把 Secret 的来源路径打印出来。它避免了直接把 Secret 值作为环境变量注入,但并不意味着所有诊断输出都可以公开。


八、验证:验证的不是“YAML 能解析”这么简单

8.1 验证层次

Compose 配置错误至少有五个层次:

  1. YAML 语法错误
    缩进、引号、冒号和序列结构不合法。

  2. 变量插值错误
    必需变量缺失、表达式格式错误,或变量值为空。

  3. Compose 模型错误
    字段类型错误、未知结构、服务引用不存在、端口和网络配置不一致。

  4. 合并结果错误
    覆盖文件意外追加端口、继承后引用了不存在的服务、路径解析错误。

  5. 运行时错误
    镜像不存在、端口已被占用、宿主机目录权限不足、容器启动后健康检查失败。

docker compose config 主要覆盖前四层,不能替代容器启动和应用级测试。

8.2 查看最终模型

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

它适合检查:

  • 服务最终使用的镜像;
  • 环境变量最终值;
  • 端口是否被意外追加;
  • 卷和网络是否存在;
  • extends 继承后的字段;
  • include 引入的服务;
  • 短语法被解析后的长语法;
  • 相对路径最终解析为什么。

如果只需要判断配置是否合法:

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

成功时通常没有输出,并返回退出码 0;失败时输出错误并返回非零退出码,适合 CI:

set -eu

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

8.3 在验证前关闭插值或查看变量环境

要检查原始表达式是否存在,可以使用:

docker compose config --no-interpolate

这个选项适合诊断“变量到底是没有定义,还是插值后值不正确”。但 --no-interpolate 输出不再代表可直接运行的最终模型,不能用它替代正常验证。

查看 Compose 插值环境:

docker compose config --environment

查看最终服务名和卷名:

docker compose config --services
docker compose config --volumes

这些查询有助于区分“源文件里写了什么”和“当前项目最终识别了什么”。

8.4 config 不验证的内容

以下情况可能在 config 成功后才失败:

docker compose config -q
docker compose up -d

例如:

  • image: example/api:unknown 的镜像标签不存在;
  • ports: ["8080:80"] 中宿主机的 8080 已被其他进程占用;
  • ./config:/etc/api:ro 绑定的是错误类型的路径;
  • Linux 宿主机权限或 SELinux 阻止容器访问挂载目录;
  • 应用启动后无法连接数据库;
  • 健康检查命令在镜像中不存在;
  • deploy 下的某些字段在本地 Compose 中不产生预期效果。

所以一个完整的交付验证通常至少包括:

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

docker compose \
  --env-file .env.ci \
  -f compose.yaml \
  -f compose.ci.yaml \
  pull

docker compose \
  --env-file .env.ci \
  -f compose.yaml \
  -f compose.ci.yaml \
  up -d

docker compose \
  --env-file .env.ci \
  -f compose.yaml \
  -f compose.ci.yaml \
  ps

config -q 验证模型,pull 验证镜像引用和仓库访问,up -d 验证运行时资源,ps 验证容器状态。应用级健康检查还应通过实际 HTTP、数据库迁移或业务测试确认。


九、一个可复用的多环境结构

下面的结构把不同机制放在各自适合的位置:

project/
├── compose.yaml
├── compose.dev.yaml
├── compose.test.yaml
├── compose.prod.yaml
├── compose.env
├── components/
│   ├── postgres/
│   │   ├── compose.yaml
│   │   └── init/
│   └── monitoring/
│       └── compose.yaml
└── secrets/
    └── db_password.txt

基础文件:

# compose.yaml
services:
  api:
    image: "${API_IMAGE:-example/api:stable}"
    environment:
      APP_ENV: "${APP_ENV:-production}"
      LOG_LEVEL: "${LOG_LEVEL:-info}"
    depends_on:
      db:
        condition: service_healthy
    networks:
      - app

  db:
    image: "postgres:${POSTGRES_TAG:-16}"
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: 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: 12
    networks:
      - app

networks:
  app:

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

开发覆盖:

# compose.dev.yaml
services:
  api:
    build:
      context: .
    image: "example/api:dev"
    environment:
      APP_ENV: development
      LOG_LEVEL: debug
    volumes:
      - ./:/app
    ports:
      - "8080:8080"
    command: ["./api", "--config", "/app/config/dev.yaml"]

测试覆盖:

# compose.test.yaml
services:
  api:
    image: "example/api:${CI_COMMIT_SHA:?CI_COMMIT_SHA is required}"
    environment:
      APP_ENV: test
      DATABASE_URL: "postgres://app@db/app"
    command: ["./api", "test"]

生产覆盖:

# compose.prod.yaml
services:
  api:
    image: "registry.example.com/api:${RELEASE_TAG:?RELEASE_TAG is required}"
    environment:
      APP_ENV: production
      LOG_LEVEL: warn
    ports: !override
      - "80:8080"
    volumes: !reset []

  db:
    restart: unless-stopped

测试环境执行:

CI_COMMIT_SHA=9f4c2a1 \
docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  config -q

生产环境执行:

RELEASE_TAG=2025.03.1 \
docker compose \
  -f compose.yaml \
  -f compose.prod.yaml \
  config

这个设计中:

  • 基础文件定义服务拓扑和默认运行模型;
  • 开发文件增加源码绑定挂载和调试端口;
  • 测试文件使用不可变提交哈希作为镜像引用;
  • 生产文件用 !override 只暴露生产端口;
  • 生产文件用 !reset 清除开发或基础配置可能带来的挂载;
  • 数据库密码通过 Secret 文件进入容器,而不是通过镜像标签或普通变量传递。

如果生产覆盖文件没有使用 !reset [] 清除基础卷,而基础文件包含源码挂载,生产容器就可能继续绑定宿主机源码目录。这不是 Compose 的异常行为,而是序列或资源字段合并后的正常结果。


十、常见误解与失败路径

误解一:覆盖文件中的列表一定会替换基础列表

错误理解:

# compose.yaml
services:
  api:
    ports:
      - "8080:8080"
      - "9090:9090"
# compose.dev.yaml
services:
  api:
    ports:
      - "8081:8080"

期望只暴露 8081,但结果可能同时包含基础端口和新端口。诊断方法:

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

修复方式是使用 !override,或从基础文件移除不应跨环境继承的端口。

误解二:include 就是 -f 的另一种写法

-f 的核心语义是有序覆盖;include 的核心语义是组件组合。把多个组件通过 -f 叠加,可能造成相对路径以错误基准解析;把环境覆盖文件写成 include,又可能失去明确的覆盖意图。

判断方法是问:

  • 这是同一个服务模型在不同环境下的差异吗?使用 -f
  • 这是一个相对独立的 Compose 组件吗?考虑 include
  • 只是想复用某一个服务字段集合吗?考虑 extends

误解三:extends 会自动带上父服务的所有依赖

extends 只表达服务定义复用。父服务依赖的数据库、网络和卷必须检查是否出现在最终模型中。否则可能发生:

service "api" depends on undefined service "db"

或者模型验证通过但运行时无法连接预期的外部资源。

误解四:.env 中的变量等于容器里的环境变量

.env 首先是 Compose CLI 的变量输入来源之一。它不会因为存在于项目目录,就自动成为所有容器的环境变量。

若要让容器获得变量,必须显式写入:

environment:
  LOG_LEVEL: "${LOG_LEVEL}"

或者:

env_file:
  - app.env

两者的用途和暴露面不同,不应混用。

误解五:docker compose config 成功就代表系统能运行

config 成功只说明当前 Compose 模型可以被解析和验证。它不保证:

  • 镜像存在;
  • 端口可绑定;
  • 文件权限正确;
  • 服务已经就绪;
  • 应用协议正确;
  • 数据库迁移成功。

应将模型验证、镜像验证、容器启动和应用测试分为不同阶段。


十一、Linux 容器边界下的具体取舍

Compose 的本地执行模型通常是单个 Docker Engine 上的一组容器、网络和卷。它不是跨节点调度器,也不会因为配置中写了某些编排字段,就自动获得 Kubernetes 或 Swarm 的全部能力。

在 Linux 容器环境中,以下边界尤其重要:

Bind mount 是宿主机状态耦合

volumes:
  - ./data:/var/lib/app

这个配置把宿主机目录作为容器数据源。配置合并时只要该条目被保留,容器就会继续依赖当前宿主机目录。它不适合直接表达不可变生产制品的数据来源。

Named volume 的生命周期不同

volumes:
  app-data:
services:
  api:
    volumes:
      - app-data:/var/lib/app

命名卷由 Docker 管理,容器删除通常不会自动删除卷。多环境文件如果复用同一个项目名和卷名,测试和生产可能访问同一份持久化数据。可以通过项目名、显式卷名和部署目录隔离:

docker compose -p app-prod -f compose.yaml -f compose.prod.yaml up -d

Secret 文件仍受宿主机权限影响

Compose Secret 的 file 来源仍然是宿主机文件。Secret 不等于加密存储;如果文件权限过宽、备份系统收集了它,或者 CI 工作区被暴露,敏感信息仍会泄露。

生产覆盖不应依赖“默认追加”

生产环境需要固定镜像、明确端口、只读配置和受控数据卷时,应使用 !override!reset 或独立的生产基础文件,避免依赖复杂的隐式合并。最终必须保存或审查 docker compose config 的输出,确保交付的确实是预期模型。


十二、建议的故障诊断顺序

docker compose up 失败时,不要先猜容器内部问题,而应按配置处理顺序排查:

docker compose version
docker compose config --environment
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml config -q

然后分别检查:

  1. 版本:确认 Docker Compose CLI 支持项目使用的 include!reset!override
  2. 变量环境:确认变量来自预期的 Shell、.env--env-file
  3. 最终模型:确认服务、端口、卷、网络和镜像已经按预期合并;
  4. 退出码:在 CI 中使用 config -q 让配置错误阻断交付;
  5. 运行时:再检查 pullupps、日志和健康状态。

例如:

docker compose \
  --env-file .env.prod \
  -f compose.yaml \
  -f compose.prod.yaml \
  config -q || {
    echo "invalid Compose model" >&2
    exit 1
  }

docker compose \
  --env-file .env.prod \
  -f compose.yaml \
  -f compose.prod.yaml \
  up -d

docker compose \
  --env-file .env.prod \
  -f compose.yaml \
  -f compose.prod.yaml \
  ps

这段流程的因果关系是明确的:先阻止非法模型进入运行阶段,再验证运行时资源,最后观察容器状态。若直接执行 up,配置错误、镜像拉取错误、端口冲突和应用启动错误会混在同一条故障路径中,诊断成本更高。


十三、如何选择机制

可以用下面的判定规则收束设计:

  • 同一项目的开发、测试、生产差异:使用多个 -f override 文件;
  • 多个目录化、相对独立的 Compose 组件组合:使用 include
  • 多个服务共享一组服务字段:使用 extends
  • 镜像标签、端口和非敏感运行参数:使用变量插值;
  • 密码、令牌和私钥:使用 Secret 或外部密钥系统,不把值直接写进插值结果;
  • 需要删除或完全替换继承字段:使用 !reset!override,并确认 Compose 版本;
  • 需要知道实际交付什么:始终检查 docker compose config 的合并结果。

最重要的不是记住某个 YAML 片段,而是明确配置处理中的三个层次:

变量解析模型合并运行时验证变量解析 \rightarrow 模型合并 \rightarrow 运行时验证

include 负责组合,extends 负责服务复用,override 文件负责环境差异;它们都不能替代对最终模型的检查。对于 Linux 容器,路径、权限、端口、卷和 Secret 的宿主机边界还必须在真正启动后验证。只有同时理解源文件、合并规则和运行时状态,Compose 配置才具有可预测的交付行为。


系列导航与关联阅读

官方资料

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

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
WR Blog 加载中...
返回文章
DockerCompose配置管理DevOps

Compose 配置合并:Override、include、extends、变量插值和验证

Compose 配置合并:Override、include、extends、变量插值和验证封面

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

Compose 配置合并:Override、include、extends、变量插值和验证

Compose 配置合并解决的是一个具体问题:同一组服务在不同场景下通常只有少数属性不同。例如开发环境需要源码挂载和调试端口,测试环境需要固定镜像,生产环境需要外部网络和只读配置。与其复制三份完整 YAML,不如将配置拆成基础模型、覆盖文件、可复用片段和环境变量,再由 Compose 生成最终模型。

这里需要先区分四种机制:

  • Override file:通过多个 -f 文件按顺序合并,适合“基础配置 + 环境覆盖”。
  • include:把另一个 Compose 应用整体引入当前模型,适合组合多个独立组件。
  • extends:复用或扩展某个服务的定义,适合服务级继承。
  • 变量插值:在合并前把 ${VAR} 等表达式解析为具体值。
  • 验证:通过 docker compose config 检查解析、插值、合并后的最终模型,而不是只检查某个源文件的 YAML 语法。

这些机制最终都作用于 Compose 的配置模型。Compose 文件只是模型的一种 YAML 表示;真正被创建的是合并、插值和规范化后的服务、网络、卷、配置和密钥集合。


一、先建立 Compose 配置处理的整体顺序

可以把常见的 Compose 配置处理抽象为:

F1,F2,,FnI(F1),I(F2),,I(Fn)MN(M)V(N(M))F_1, F_2, \ldots, F_n \rightarrow I(F_1), I(F_2), \ldots, I(F_n) \rightarrow M \rightarrow N(M) \rightarrow V(N(M))

其中:

  • FiF_i 是输入的 Compose 文件;
  • I(Fi)I(F_i) 是对单个文件执行变量插值后的结果;
  • MM 是按照合并规则得到的模型;
  • N(M)N(M) 是规范化后的模型,例如短语法转成长语法;
  • VV 是模型验证;
  • 最终模型用于创建容器、网络、卷等资源。

在使用多个 -f 文件时,关键顺序是:

  1. Compose 分别读取各文件;
  2. 每个文件中的变量表达式按其上下文进行插值;
  3. 文件按命令行顺序合并,后面的文件覆盖或扩展前面的文件;
  4. Compose 对结果进行规范化和一致性检查;
  5. 运行命令,例如 uprunbuild

因此,变量插值不是简单的“先把所有文件拼在一起,再统一替换”。尤其在不同文件使用不同变量来源时,不能假设所有变量都会在最终合并后才解析。

一个基本命令如下:

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

这里 compose.yaml 是基础文件,compose.dev.yaml 是覆盖文件。config 不会启动容器,而是打印最终的 Compose 模型。


二、Override:多个 Compose 文件的有序合并

2.1 Override 的含义

Compose 中常说的 override file,通常指通过多个 -f 参数传入的后续文件:

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

Compose 把第一个文件作为基础,后面的文件依次作用于前面的结果:

M0=F1M_0 = F_1

Mi=merge(Mi1,Fi+1)M_i = merge(M_{i-1}, F_{i+1})

后面的文件并不是“再启动一套服务”,而是修改已经存在的服务模型。服务名相同,则合并该服务;只在后续文件出现的服务,则会加入最终模型。

2.2 标量字段:后者替换前者

对于通常的标量字段,后一个文件直接替换前一个文件的值。标量包括字符串、数字、布尔值和空值等。

基础文件:

# compose.yaml
services:
  api:
    image: example/api:1.0
    restart: unless-stopped
    environment:
      APP_ENV: production
      LOG_LEVEL: info

覆盖文件:

# compose.dev.yaml
services:
  api:
    image: example/api:dev
    restart: "no"
    environment:
      LOG_LEVEL: debug

执行:

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

最终相关结果等价于:

services:
  api:
    image: example/api:dev
    restart: "no"
    environment:
      APP_ENV: production
      LOG_LEVEL: debug

这里有两个不同层次的合并:

  • image 是标量,直接由 example/api:dev 替换 example/api:1.0
  • environment 是映射,映射中的 LOG_LEVEL 被替换,但未出现的 APP_ENV 保留。

所以“后文件覆盖前文件”并不意味着整个服务对象被无条件替换。必须先判断字段的合并类型。

2.3 映射字段:按键合并

environmentlabelsbuild.args 等映射字段,Compose 按键合并:

merge(A,B)[k]={B[k],kBA[k],kBmerge(A,B)[k] = \begin{cases} B[k], & k \in B \\ A[k], & k \notin B \end{cases}

示例:

# compose.yaml
services:
  api:
    environment:
      APP_ENV: production
      LOG_LEVEL: info
      FEATURE_CACHE: "false"
# compose.test.yaml
services:
  api:
    environment:
      APP_ENV: test
      FEATURE_CACHE: "true"

结果为:

services:
  api:
    environment:
      APP_ENV: test
      LOG_LEVEL: info
      FEATURE_CACHE: "true"

如果需要删除基础文件中的某个映射键,不能仅仅把它从覆盖文件中省略,因为省略的含义是“保留”。可以使用显式的空值,但具体效果取决于字段和 Compose 实现对空值的处理;在需要明确删除时,应优先使用支持的 !reset 标签,或者重新设计基础配置,避免产生难以识别的隐式继承。

2.4 普通序列:通常追加,而不是替换

许多序列字段在合并时会追加:

# compose.yaml
services:
  api:
    tmpfs:
      - /tmp
    dns:
      - 1.1.1.1
# compose.dev.yaml
services:
  api:
    tmpfs:
      - /run
    dns:
      - 8.8.8.8

最终结果通常包含两边的值:

services:
  api:
    tmpfs:
      - /tmp
      - /run
    dns:
      - 1.1.1.1
      - 8.8.8.8

这会导致一个常见误解:开发覆盖文件写了一个新列表,并不一定表示“用这个列表替代旧列表”。

对于某些资源型序列,Compose 会按唯一键去重或合并,而不是简单追加。例如:

  • ports 的唯一性由端口映射的关键属性决定;
  • volumes 的唯一性主要以容器目标路径为依据;
  • secretsconfigs 以目标路径等属性识别;
  • depends_onnetworks 等字段具有专门的映射语义。

端口尤其容易出错:

# compose.yaml
services:
  web:
    ports:
      - "8080:80"
# compose.dev.yaml
services:
  web:
    ports:
      - "8081:80"

这通常不是把 8080 改成 8081,而是得到两个端口发布:

ports:
  - target: 80
    published: "8080"
  - target: 80
    published: "8081"

如果目标是“开发环境只暴露 8081”,仅添加 8081:80 不够。应该使用覆盖整个字段的机制,或者让基础文件不要声明宿主机端口。

2.5 commandentrypoint 和健康检查命令是特殊情况

容器命令不是普通的“命令参数列表追加”。如果基础文件和覆盖文件都声明 command,通常应按替换理解:

# compose.yaml
services:
  api:
    command: ["./api", "--config", "/etc/api/prod.yaml"]
# compose.dev.yaml
services:
  api:
    command: ["./api", "--config", "/etc/api/dev.yaml", "--debug"]

结果是开发命令,而不是把两个命令列表拼接成一个不可执行的参数列表:

command:
  - ./api
  - --config
  - /etc/api/dev.yaml
  - --debug

entrypointhealthcheck.test 也属于需要特别注意的命令字段。不能把所有 YAML 序列都套用“追加”规则。

2.6 用 !reset!override 表达删除与完全替换

现代 Docker Compose 支持用于合并控制的 YAML 标签,但它们属于 Compose 对 YAML 的扩展,不是所有第三方 Compose 实现都保证支持。

!reset 表示将字段重置为空值或空集合。例如:

# compose.prod.yaml
services:
  web:
    ports: !reset []

它的意图是清除基础文件中的 ports,而不是追加一个空列表。

!override 表示跳过默认合并规则,直接使用当前文件中的值:

# compose.dev.yaml
services:
  web:
    ports: !override
      - "8081:80"

如果基础文件有多个端口,!override 使最终结果只保留 8081:80

这两个标签解决的是不同问题:

  • !reset:删除基础值;
  • !override:完全替换基础字段。

它们不能随意替代普通 YAML 的 null、空列表或省略字段。交付流水线中使用前,应在目标 Docker Compose 版本上执行 docker compose config 验证。


三、路径解析:多文件合并时最容易被忽略的边界

Compose 中的相对路径包括:

  • build.context
  • build.dockerfile
  • env_file
  • volumes 中的宿主机路径
  • secrets.file
  • configs.file
  • extends.file

在通过多个 -f 文件合并时,路径通常以项目的基础 Compose 文件或项目目录为参照,而不是简单地以每个覆盖文件自身所在目录为参照。这样做是为了保证不同文件合并后仍然属于同一个 Compose 项目,但也意味着目录结构变化可能导致路径指向错误。

例如:

project/
├── compose.yaml
├── compose.prod.yaml
└── prod/
    └── .env
# compose.prod.yaml
services:
  api:
    volumes:
      - ./prod-config:/etc/api:ro

工程师有时会直觉地认为 ./prod-config 相对于 compose.prod.yaml 所在目录;如果文件实际不在预期目录,Compose 可能报路径不存在,或者更危险地绑定了错误的宿主机目录。

应使用最终模型检查路径:

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

在 Linux 容器环境中,宿主机绑定挂载还受到以下条件约束:

  • 宿主机路径必须存在,或由 Compose 按短语法行为创建;
  • 文件和目录的类型必须匹配;
  • 容器内用户必须有对应读写权限;
  • SELinux、AppArmor 等安全机制可能进一步限制访问;
  • 容器内的 /etc/api 与宿主机目录不是同一个命名空间。

因此,config 能验证路径被解析成什么,不代表容器运行时一定能成功访问它。


四、include:组合独立的 Compose 应用

4.1 include 解决什么问题

include 用于把另一个 Compose 文件或 Compose 应用整体引入当前项目。例如一个项目可能把基础设施拆成:

compose.yaml
compose/
├── observability.yaml
├── postgres.yaml
└── messaging.yaml

主文件可以写:

include:
  - compose/postgres.yaml
  - compose/messaging.yaml
  - path: compose/observability.yaml
    env_file:
      - compose/observability.env

被引入的文件可以包含自己的:

  • services
  • networks
  • volumes
  • configs
  • secrets
  • include

include 不是单纯的 YAML 文本拼接。它的价值在于:被包含的 Compose 文件可以作为相对独立的 Compose 模型维护,其相对路径和变量来源可以围绕自己的目录组织。

4.2 include-f 的核心差异

假设目录为:

project/
├── compose.yaml
└── components/
    └── database/
        ├── compose.yaml
        └── init/

被包含文件:

# components/database/compose.yaml
services:
  db:
    image: postgres:16
    volumes:
      - ./init:/docker-entrypoint-initdb.d:ro

如果主文件使用传统多文件方式:

docker compose \
  -f compose.yaml \
  -f components/database/compose.yaml \
  config

./init 的解析可能受项目基础文件路径规则影响,不能简单理解为“相对于第二个文件”。

如果主文件使用:

include:
  - components/database/compose.yaml

include 的设计目标是让被包含模型中的相对路径相对于被包含文件所在的项目目录解析。因此 ./init 表示 components/database/init,这更适合组件化目录。

两者的适用方向不同:

机制 主要语义 适合场景
多个 -f 对同一个模型进行有序覆盖 开发、测试、生产差异
include 把多个 Compose 应用组合成一个模型 数据库、监控、消息系统等组件拼装
extends 复用单个服务定义 多个服务共享镜像、环境、资源配置

4.3 include 的路径、变量与冲突

可以为被包含文件指定变量文件:

include:
  - path: components/database/compose.yaml
    env_file:
      - components/database/.env

这使组件能够使用自己的变量输入,而不必把所有变量都放到主项目的 .env 中。

但是,组件组合仍然必须处理资源命名冲突。例如两个被包含文件都声明了:

services:
  redis:
    image: redis:7

或者都声明了同名顶层卷:

volumes:
  data:

Compose 对冲突会进行诊断;不同版本对冲突报告和处理细节可能存在差异。不能把“后 include 的文件必然覆盖前一个文件”当作稳定的覆盖机制。需要有意覆盖时,应使用多个 -f 文件;使用 include 时则应通过服务名、网络名和卷名设计清晰的命名空间,避免名称冲突。

include 还可能递归包含其他文件。递归组合提高了复用能力,同时也增加了排查难度。出现服务来源不明时,应查看:

docker compose config

该输出会展示最终模型,通常比逐个阅读源文件更适合确认“某个字段最终来自哪里”。


五、extends:服务级继承与合并

5.1 基本用法

extends 允许一个服务复用另一个服务的定义:

services:
  api-base:
    image: example/api:1.0
    working_dir: /app
    environment:
      APP_ENV: production
      LOG_LEVEL: info
    volumes:
      - ./src:/app:ro

  api:
    extends:
      service: api-base
    environment:
      LOG_LEVEL: debug
    ports:
      - "8080:8080"

最终的 api 服务继承 api-base 的字段,并覆盖 LOG_LEVEL,同时增加端口。

也可以从另一个文件继承:

services:
  api:
    extends:
      file: compose.base.yaml
      service: api-base
    environment:
      APP_ENV: test

这里的 service 指定被继承的服务,file 指定来源文件。文件路径的解析遵循 Compose 的项目路径规则,使用前应通过实际项目目录和 config 命令确认。

5.2 extends 的合并不是面向对象语言的完整继承

extends 只合并服务配置,不意味着 Compose 会自动创建父服务,也不意味着会自动导入整个应用。

例如:

# compose.base.yaml
services:
  api-base:
    image: example/api:1.0
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
# compose.yaml
services:
  api:
    extends:
      file: compose.base.yaml
      service: api-base

不能据此简单断言 db 一定会作为当前项目服务被创建。被继承的是 api-base 的服务定义;关联服务、网络和卷是否存在,还必须在当前最终模型中明确声明并满足依赖关系。

更稳妥的写法是:

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10

  api:
    extends:
      file: compose.base.yaml
      service: api-base
    depends_on:
      db:
        condition: service_healthy

这样 api 的依赖对象在当前文件中明确存在。

5.3 extends 的合并规则

extends 大体沿用 Compose 的服务合并规则:

  • 标量字段:子服务覆盖父服务;
  • 映射字段:按键合并;
  • 普通序列:按规则追加;
  • portsvolumessecretsconfigs 等资源型字段:按唯一属性合并;
  • commandentrypointhealthcheck.test:不能按普通序列追加来理解。

示例:

services:
  worker-base:
    image: example/worker:1.0
    environment:
      QUEUE: default
      LOG_LEVEL: info
    volumes:
      - worker-cache:/var/cache/worker

  worker-test:
    extends:
      service: worker-base
    environment:
      QUEUE: test
    command: ["./worker", "--once"]

结果的逻辑模型为:

services:
  worker-test:
    image: example/worker:1.0
    environment:
      QUEUE: test
      LOG_LEVEL: info
    volumes:
      - worker-cache:/var/cache/worker
    command:
      - ./worker
      - --once

注意服务名发生了变化:父服务是 worker-base,最终服务是 worker-test。如果父服务的卷、网络或命令中含有依赖服务名,还需要检查它们在新服务名下是否仍然语义正确。

5.4 extends 的依赖闭包问题

服务配置中可能引用其他服务:

  • depends_on
  • links
  • volumes_from
  • network_mode: service:xxx

继承服务时,Compose 不应被理解为自动复制整个依赖拓扑。最终模型必须满足所有引用关系,否则会出现模型验证错误或启动错误。

因此,使用 extends 时要区分:

服务配置继承应用拓扑继承服务配置继承 \neq 应用拓扑继承

extends 复制的是服务字段;数据库、缓存、消息队列等依赖服务是否被纳入当前项目,需要由最终 Compose 文件显式表达。


六、变量插值:在 YAML 合并前解析值

6.1 基本语法

Compose 支持常见的 Shell 风格变量插值:

services:
  api:
    image: "${API_IMAGE:-example/api:latest}"
    environment:
      APP_ENV: "${APP_ENV:-development}"
      API_KEY: "${API_KEY:?API_KEY must be set}"

常用形式包括:

表达式 行为
${VAR} 使用 VAR,未设置时通常为空并给出警告
${VAR:-default} VAR 未设置或为空时使用默认值
${VAR-default} 只有 VAR 未设置时使用默认值,空字符串仍保留
${VAR:?message} 未设置或为空时报告错误
${VAR?message} 未设置时报告错误,空字符串可保留
${VAR:+replacement} 已设置且非空时使用替代值
${VAR+replacement} 已设置时使用替代值
$$ 输出字面量 $,避免 Compose 插值

例如:

services:
  api:
    command: ["sh", "-c", "echo $${RUNTIME_VALUE}"]

Compose 不会把 $${RUNTIME_VALUE} 当作 Compose 变量替换,而是向容器传入:

echo ${RUNTIME_VALUE}

之后由容器中的 Shell 或应用自行解释。

6.2 插值只处理值,不默认处理键

下面的写法不会按预期把变量替换到标签键中:

services:
  api:
    labels:
      "${LABEL_KEY}": enabled

Compose 的插值主要应用于 YAML 值。对于标签和环境变量,如果必须动态生成键,应使用列表语法:

services:
  api:
    labels:
      - "${LABEL_KEY}=enabled"
    environment:
      - "${ENV_KEY}=${ENV_VALUE}"

不过列表语法会牺牲一部分结构可读性,变量为空时还可能产生不易发现的配置问题。生产配置通常应让键保持静态,只插值值。

6.3 变量来源与优先级

变量插值使用的变量来源,不等同于容器内部最终拥有的环境变量。

在 Docker Compose CLI 中,常见来源包括:

  1. 命令执行环境中的 Shell 变量;
  2. 当前工作目录中的 .env
  3. --env-file 指定的变量文件;
  4. 项目目录中的其他环境文件,具体行为受命令和 Compose 版本影响。

可以用以下命令检查 Compose 当前用于插值的环境:

docker compose config --environment

可以用 --env-file 明确指定输入:

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

需要区分三种概念:

Compose 插值变量

用于解析 Compose 文件:

services:
  api:
    image: "example/api:${TAG}"

environment 中传给容器的变量

services:
  api:
    environment:
      APP_ENV: "${APP_ENV:-development}"

env_file 传给容器的变量

services:
  api:
    env_file:
      - app.env

env_file 主要描述容器启动时的环境变量来源,并不应被自动理解为所有 Compose 文件插值的通用变量源。需要插值时,应通过 Shell 环境、项目 .env 或显式 --env-file 提供变量,并使用 config --environment 检查结果。

6.4 变量插值与合并的完整算例

基础文件:

# compose.yaml
services:
  api:
    image: "example/api:${API_TAG:-stable}"
    environment:
      APP_ENV: "${APP_ENV:-production}"
      LOG_LEVEL: "${LOG_LEVEL:-info}"

测试覆盖文件:

# compose.test.yaml
services:
  api:
    image: "example/api:${API_TAG:-test}"
    environment:
      APP_ENV: test
      TEST_DATABASE_URL: "${TEST_DATABASE_URL:?TEST_DATABASE_URL is required}"

执行:

API_TAG=2025.03 \
LOG_LEVEL=debug \
TEST_DATABASE_URL='postgres://test-db/app' \
docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  config

处理过程可以写成:

  1. 基础文件中:
    • API_TAG 从 Shell 得到 2025.03
    • APP_ENV 未从 Shell 提供,使用 production
    • LOG_LEVEL 从 Shell 得到 debug
  2. 测试文件中:
    • API_TAG 也得到 2025.03
    • APP_ENV 是字面量 test
    • TEST_DATABASE_URL 已提供。
  3. 两个文件合并:
    • image 是标量,测试文件的 example/api:2025.03 覆盖基础文件同值;
    • environment 按键合并;
    • APP_ENVproduction 覆盖为 test
    • LOG_LEVEL 保留为 debug
    • TEST_DATABASE_URL 加入最终模型。

最终结果的关键部分为:

services:
  api:
    image: example/api:2025.03
    environment:
      APP_ENV: test
      LOG_LEVEL: debug
      TEST_DATABASE_URL: postgres://test-db/app

如果省略 TEST_DATABASE_URL

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

Compose 应在插值或配置处理阶段报告:

TEST_DATABASE_URL is required

这种 ${VAR:?message} 形式适合把“配置缺失”提前变成确定性失败,而不是让容器启动后才因为连接字符串为空而失败。


七、不要把 Secret 当成变量插值的替代品

变量插值适合非敏感配置,例如镜像标签、日志级别和端口。密码、令牌和私钥不应为了插值方便而放进命令行、Git 仓库或最终 config 输出。

不推荐:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"

问题在于:

  • docker compose config 可能把解析后的密码打印出来;
  • Shell 历史、CI 日志和进程环境可能暴露密码;
  • 环境变量通常会出现在容器检查信息中;
  • 变量文件并不天然等同于安全的 Secret 存储。

Linux 容器边界下,可以使用 Compose secrets:

services:
  db:
    image: postgres:16
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

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

这要求镜像或启动脚本支持 _FILE 约定;POSTGRES_PASSWORD_FILE 是 PostgreSQL 官方镜像支持的方式,不是所有镜像都自动支持。Secret 文件的宿主机权限、版本控制排除和部署注入方式仍需单独治理。

还需要注意:docker compose config 在某些情况下会把 Secret 的来源路径打印出来。它避免了直接把 Secret 值作为环境变量注入,但并不意味着所有诊断输出都可以公开。


八、验证:验证的不是“YAML 能解析”这么简单

8.1 验证层次

Compose 配置错误至少有五个层次:

  1. YAML 语法错误
    缩进、引号、冒号和序列结构不合法。

  2. 变量插值错误
    必需变量缺失、表达式格式错误,或变量值为空。

  3. Compose 模型错误
    字段类型错误、未知结构、服务引用不存在、端口和网络配置不一致。

  4. 合并结果错误
    覆盖文件意外追加端口、继承后引用了不存在的服务、路径解析错误。

  5. 运行时错误
    镜像不存在、端口已被占用、宿主机目录权限不足、容器启动后健康检查失败。

docker compose config 主要覆盖前四层,不能替代容器启动和应用级测试。

8.2 查看最终模型

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

它适合检查:

  • 服务最终使用的镜像;
  • 环境变量最终值;
  • 端口是否被意外追加;
  • 卷和网络是否存在;
  • extends 继承后的字段;
  • include 引入的服务;
  • 短语法被解析后的长语法;
  • 相对路径最终解析为什么。

如果只需要判断配置是否合法:

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

成功时通常没有输出,并返回退出码 0;失败时输出错误并返回非零退出码,适合 CI:

set -eu

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

8.3 在验证前关闭插值或查看变量环境

要检查原始表达式是否存在,可以使用:

docker compose config --no-interpolate

这个选项适合诊断“变量到底是没有定义,还是插值后值不正确”。但 --no-interpolate 输出不再代表可直接运行的最终模型,不能用它替代正常验证。

查看 Compose 插值环境:

docker compose config --environment

查看最终服务名和卷名:

docker compose config --services
docker compose config --volumes

这些查询有助于区分“源文件里写了什么”和“当前项目最终识别了什么”。

8.4 config 不验证的内容

以下情况可能在 config 成功后才失败:

docker compose config -q
docker compose up -d

例如:

  • image: example/api:unknown 的镜像标签不存在;
  • ports: ["8080:80"] 中宿主机的 8080 已被其他进程占用;
  • ./config:/etc/api:ro 绑定的是错误类型的路径;
  • Linux 宿主机权限或 SELinux 阻止容器访问挂载目录;
  • 应用启动后无法连接数据库;
  • 健康检查命令在镜像中不存在;
  • deploy 下的某些字段在本地 Compose 中不产生预期效果。

所以一个完整的交付验证通常至少包括:

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

docker compose \
  --env-file .env.ci \
  -f compose.yaml \
  -f compose.ci.yaml \
  pull

docker compose \
  --env-file .env.ci \
  -f compose.yaml \
  -f compose.ci.yaml \
  up -d

docker compose \
  --env-file .env.ci \
  -f compose.yaml \
  -f compose.ci.yaml \
  ps

config -q 验证模型,pull 验证镜像引用和仓库访问,up -d 验证运行时资源,ps 验证容器状态。应用级健康检查还应通过实际 HTTP、数据库迁移或业务测试确认。


九、一个可复用的多环境结构

下面的结构把不同机制放在各自适合的位置:

project/
├── compose.yaml
├── compose.dev.yaml
├── compose.test.yaml
├── compose.prod.yaml
├── compose.env
├── components/
│   ├── postgres/
│   │   ├── compose.yaml
│   │   └── init/
│   └── monitoring/
│       └── compose.yaml
└── secrets/
    └── db_password.txt

基础文件:

# compose.yaml
services:
  api:
    image: "${API_IMAGE:-example/api:stable}"
    environment:
      APP_ENV: "${APP_ENV:-production}"
      LOG_LEVEL: "${LOG_LEVEL:-info}"
    depends_on:
      db:
        condition: service_healthy
    networks:
      - app

  db:
    image: "postgres:${POSTGRES_TAG:-16}"
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: 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: 12
    networks:
      - app

networks:
  app:

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

开发覆盖:

# compose.dev.yaml
services:
  api:
    build:
      context: .
    image: "example/api:dev"
    environment:
      APP_ENV: development
      LOG_LEVEL: debug
    volumes:
      - ./:/app
    ports:
      - "8080:8080"
    command: ["./api", "--config", "/app/config/dev.yaml"]

测试覆盖:

# compose.test.yaml
services:
  api:
    image: "example/api:${CI_COMMIT_SHA:?CI_COMMIT_SHA is required}"
    environment:
      APP_ENV: test
      DATABASE_URL: "postgres://app@db/app"
    command: ["./api", "test"]

生产覆盖:

# compose.prod.yaml
services:
  api:
    image: "registry.example.com/api:${RELEASE_TAG:?RELEASE_TAG is required}"
    environment:
      APP_ENV: production
      LOG_LEVEL: warn
    ports: !override
      - "80:8080"
    volumes: !reset []

  db:
    restart: unless-stopped

测试环境执行:

CI_COMMIT_SHA=9f4c2a1 \
docker compose \
  -f compose.yaml \
  -f compose.test.yaml \
  config -q

生产环境执行:

RELEASE_TAG=2025.03.1 \
docker compose \
  -f compose.yaml \
  -f compose.prod.yaml \
  config

这个设计中:

  • 基础文件定义服务拓扑和默认运行模型;
  • 开发文件增加源码绑定挂载和调试端口;
  • 测试文件使用不可变提交哈希作为镜像引用;
  • 生产文件用 !override 只暴露生产端口;
  • 生产文件用 !reset 清除开发或基础配置可能带来的挂载;
  • 数据库密码通过 Secret 文件进入容器,而不是通过镜像标签或普通变量传递。

如果生产覆盖文件没有使用 !reset [] 清除基础卷,而基础文件包含源码挂载,生产容器就可能继续绑定宿主机源码目录。这不是 Compose 的异常行为,而是序列或资源字段合并后的正常结果。


十、常见误解与失败路径

误解一:覆盖文件中的列表一定会替换基础列表

错误理解:

# compose.yaml
services:
  api:
    ports:
      - "8080:8080"
      - "9090:9090"
# compose.dev.yaml
services:
  api:
    ports:
      - "8081:8080"

期望只暴露 8081,但结果可能同时包含基础端口和新端口。诊断方法:

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

修复方式是使用 !override,或从基础文件移除不应跨环境继承的端口。

误解二:include 就是 -f 的另一种写法

-f 的核心语义是有序覆盖;include 的核心语义是组件组合。把多个组件通过 -f 叠加,可能造成相对路径以错误基准解析;把环境覆盖文件写成 include,又可能失去明确的覆盖意图。

判断方法是问:

  • 这是同一个服务模型在不同环境下的差异吗?使用 -f
  • 这是一个相对独立的 Compose 组件吗?考虑 include
  • 只是想复用某一个服务字段集合吗?考虑 extends

误解三:extends 会自动带上父服务的所有依赖

extends 只表达服务定义复用。父服务依赖的数据库、网络和卷必须检查是否出现在最终模型中。否则可能发生:

service "api" depends on undefined service "db"

或者模型验证通过但运行时无法连接预期的外部资源。

误解四:.env 中的变量等于容器里的环境变量

.env 首先是 Compose CLI 的变量输入来源之一。它不会因为存在于项目目录,就自动成为所有容器的环境变量。

若要让容器获得变量,必须显式写入:

environment:
  LOG_LEVEL: "${LOG_LEVEL}"

或者:

env_file:
  - app.env

两者的用途和暴露面不同,不应混用。

误解五:docker compose config 成功就代表系统能运行

config 成功只说明当前 Compose 模型可以被解析和验证。它不保证:

  • 镜像存在;
  • 端口可绑定;
  • 文件权限正确;
  • 服务已经就绪;
  • 应用协议正确;
  • 数据库迁移成功。

应将模型验证、镜像验证、容器启动和应用测试分为不同阶段。


十一、Linux 容器边界下的具体取舍

Compose 的本地执行模型通常是单个 Docker Engine 上的一组容器、网络和卷。它不是跨节点调度器,也不会因为配置中写了某些编排字段,就自动获得 Kubernetes 或 Swarm 的全部能力。

在 Linux 容器环境中,以下边界尤其重要:

Bind mount 是宿主机状态耦合

volumes:
  - ./data:/var/lib/app

这个配置把宿主机目录作为容器数据源。配置合并时只要该条目被保留,容器就会继续依赖当前宿主机目录。它不适合直接表达不可变生产制品的数据来源。

Named volume 的生命周期不同

volumes:
  app-data:
services:
  api:
    volumes:
      - app-data:/var/lib/app

命名卷由 Docker 管理,容器删除通常不会自动删除卷。多环境文件如果复用同一个项目名和卷名,测试和生产可能访问同一份持久化数据。可以通过项目名、显式卷名和部署目录隔离:

docker compose -p app-prod -f compose.yaml -f compose.prod.yaml up -d

Secret 文件仍受宿主机权限影响

Compose Secret 的 file 来源仍然是宿主机文件。Secret 不等于加密存储;如果文件权限过宽、备份系统收集了它,或者 CI 工作区被暴露,敏感信息仍会泄露。

生产覆盖不应依赖“默认追加”

生产环境需要固定镜像、明确端口、只读配置和受控数据卷时,应使用 !override!reset 或独立的生产基础文件,避免依赖复杂的隐式合并。最终必须保存或审查 docker compose config 的输出,确保交付的确实是预期模型。


十二、建议的故障诊断顺序

docker compose up 失败时,不要先猜容器内部问题,而应按配置处理顺序排查:

docker compose version
docker compose config --environment
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml config -q

然后分别检查:

  1. 版本:确认 Docker Compose CLI 支持项目使用的 include!reset!override
  2. 变量环境:确认变量来自预期的 Shell、.env--env-file
  3. 最终模型:确认服务、端口、卷、网络和镜像已经按预期合并;
  4. 退出码:在 CI 中使用 config -q 让配置错误阻断交付;
  5. 运行时:再检查 pullupps、日志和健康状态。

例如:

docker compose \
  --env-file .env.prod \
  -f compose.yaml \
  -f compose.prod.yaml \
  config -q || {
    echo "invalid Compose model" >&2
    exit 1
  }

docker compose \
  --env-file .env.prod \
  -f compose.yaml \
  -f compose.prod.yaml \
  up -d

docker compose \
  --env-file .env.prod \
  -f compose.yaml \
  -f compose.prod.yaml \
  ps

这段流程的因果关系是明确的:先阻止非法模型进入运行阶段,再验证运行时资源,最后观察容器状态。若直接执行 up,配置错误、镜像拉取错误、端口冲突和应用启动错误会混在同一条故障路径中,诊断成本更高。


十三、如何选择机制

可以用下面的判定规则收束设计:

  • 同一项目的开发、测试、生产差异:使用多个 -f override 文件;
  • 多个目录化、相对独立的 Compose 组件组合:使用 include
  • 多个服务共享一组服务字段:使用 extends
  • 镜像标签、端口和非敏感运行参数:使用变量插值;
  • 密码、令牌和私钥:使用 Secret 或外部密钥系统,不把值直接写进插值结果;
  • 需要删除或完全替换继承字段:使用 !reset!override,并确认 Compose 版本;
  • 需要知道实际交付什么:始终检查 docker compose config 的合并结果。

最重要的不是记住某个 YAML 片段,而是明确配置处理中的三个层次:

变量解析模型合并运行时验证变量解析 \rightarrow 模型合并 \rightarrow 运行时验证

include 负责组合,extends 负责服务复用,override 文件负责环境差异;它们都不能替代对最终模型的检查。对于 Linux 容器,路径、权限、端口、卷和 Secret 的宿主机边界还必须在真正启动后验证。只有同时理解源文件、合并规则和运行时状态,Compose 配置才具有可预测的交付行为。


系列导航与关联阅读

官方资料

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

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
,避免 Compose 插值 |\n\n例如:\n\n```yaml\nservices:\n api:\n command: [\"sh\", \"-c\", \"echo ${RUNTIME_VALUE}\"]\n```\n\nCompose 不会把 `${RUNTIME_VALUE}` 当作 Compose 变量替换,而是向容器传入:\n\n```text\necho ${RUNTIME_VALUE}\n```\n\n之后由容器中的 Shell 或应用自行解释。\n\n### 6.2 插值只处理值,不默认处理键\n\n下面的写法不会按预期把变量替换到标签键中:\n\n```yaml\nservices:\n api:\n labels:\n \"${LABEL_KEY}\": enabled\n```\n\nCompose 的插值主要应用于 YAML 值。对于标签和环境变量,如果必须动态生成键,应使用列表语法:\n\n```yaml\nservices:\n api:\n labels:\n - \"${LABEL_KEY}=enabled\"\n environment:\n - \"${ENV_KEY}=${ENV_VALUE}\"\n```\n\n不过列表语法会牺牲一部分结构可读性,变量为空时还可能产生不易发现的配置问题。生产配置通常应让键保持静态,只插值值。\n\n### 6.3 变量来源与优先级\n\n变量插值使用的变量来源,不等同于容器内部最终拥有的环境变量。\n\n在 Docker Compose CLI 中,常见来源包括:\n\n1. 命令执行环境中的 Shell 变量;\n2. 当前工作目录中的 `.env`;\n3. `--env-file` 指定的变量文件;\n4. 项目目录中的其他环境文件,具体行为受命令和 Compose 版本影响。\n\n可以用以下命令检查 Compose 当前用于插值的环境:\n\n```bash\ndocker compose config --environment\n```\n\n可以用 `--env-file` 明确指定输入:\n\n```bash\ndocker compose \\\n --env-file .env.test \\\n -f compose.yaml \\\n -f compose.test.yaml \\\n config\n```\n\n需要区分三种概念:\n\n#### Compose 插值变量\n\n用于解析 Compose 文件:\n\n```yaml\nservices:\n api:\n image: \"example/api:${TAG}\"\n```\n\n#### `environment` 中传给容器的变量\n\n```yaml\nservices:\n api:\n environment:\n APP_ENV: \"${APP_ENV:-development}\"\n```\n\n#### `env_file` 传给容器的变量\n\n```yaml\nservices:\n api:\n env_file:\n - app.env\n```\n\n`env_file` 主要描述容器启动时的环境变量来源,并不应被自动理解为所有 Compose 文件插值的通用变量源。需要插值时,应通过 Shell 环境、项目 `.env` 或显式 `--env-file` 提供变量,并使用 `config --environment` 检查结果。\n\n### 6.4 变量插值与合并的完整算例\n\n基础文件:\n\n```yaml\n# compose.yaml\nservices:\n api:\n image: \"example/api:${API_TAG:-stable}\"\n environment:\n APP_ENV: \"${APP_ENV:-production}\"\n LOG_LEVEL: \"${LOG_LEVEL:-info}\"\n```\n\n测试覆盖文件:\n\n```yaml\n# compose.test.yaml\nservices:\n api:\n image: \"example/api:${API_TAG:-test}\"\n environment:\n APP_ENV: test\n TEST_DATABASE_URL: \"${TEST_DATABASE_URL:?TEST_DATABASE_URL is required}\"\n```\n\n执行:\n\n```bash\nAPI_TAG=2025.03 \\\nLOG_LEVEL=debug \\\nTEST_DATABASE_URL='postgres://test-db/app' \\\ndocker compose \\\n -f compose.yaml \\\n -f compose.test.yaml \\\n config\n```\n\n处理过程可以写成:\n\n1. 基础文件中:\n - `API_TAG` 从 Shell 得到 `2025.03`;\n - `APP_ENV` 未从 Shell 提供,使用 `production`;\n - `LOG_LEVEL` 从 Shell 得到 `debug`。\n2. 测试文件中:\n - `API_TAG` 也得到 `2025.03`;\n - `APP_ENV` 是字面量 `test`;\n - `TEST_DATABASE_URL` 已提供。\n3. 两个文件合并:\n - `image` 是标量,测试文件的 `example/api:2025.03` 覆盖基础文件同值;\n - `environment` 按键合并;\n - `APP_ENV` 由 `production` 覆盖为 `test`;\n - `LOG_LEVEL` 保留为 `debug`;\n - `TEST_DATABASE_URL` 加入最终模型。\n\n最终结果的关键部分为:\n\n```yaml\nservices:\n api:\n image: example/api:2025.03\n environment:\n APP_ENV: test\n LOG_LEVEL: debug\n TEST_DATABASE_URL: postgres://test-db/app\n```\n\n如果省略 `TEST_DATABASE_URL`:\n\n```bash\ndocker compose \\\n -f compose.yaml \\\n -f compose.test.yaml \\\n config\n```\n\nCompose 应在插值或配置处理阶段报告:\n\n```text\nTEST_DATABASE_URL is required\n```\n\n这种 `${VAR:?message}` 形式适合把“配置缺失”提前变成确定性失败,而不是让容器启动后才因为连接字符串为空而失败。\n\n---\n\n## 七、不要把 Secret 当成变量插值的替代品\n\n变量插值适合非敏感配置,例如镜像标签、日志级别和端口。密码、令牌和私钥不应为了插值方便而放进命令行、Git 仓库或最终 `config` 输出。\n\n不推荐:\n\n```yaml\nservices:\n db:\n image: postgres:16\n environment:\n POSTGRES_PASSWORD: \"${POSTGRES_PASSWORD}\"\n```\n\n问题在于:\n\n- `docker compose config` 可能把解析后的密码打印出来;\n- Shell 历史、CI 日志和进程环境可能暴露密码;\n- 环境变量通常会出现在容器检查信息中;\n- 变量文件并不天然等同于安全的 Secret 存储。\n\nLinux 容器边界下,可以使用 Compose secrets:\n\n```yaml\nservices:\n db:\n image: postgres:16\n secrets:\n - db_password\n environment:\n POSTGRES_PASSWORD_FILE: /run/secrets/db_password\n\nsecrets:\n db_password:\n file: ./secrets/db_password.txt\n```\n\n这要求镜像或启动脚本支持 `_FILE` 约定;`POSTGRES_PASSWORD_FILE` 是 PostgreSQL 官方镜像支持的方式,不是所有镜像都自动支持。Secret 文件的宿主机权限、版本控制排除和部署注入方式仍需单独治理。\n\n还需要注意:`docker compose config` 在某些情况下会把 Secret 的来源路径打印出来。它避免了直接把 Secret 值作为环境变量注入,但并不意味着所有诊断输出都可以公开。\n\n---\n\n## 八、验证:验证的不是“YAML 能解析”这么简单\n\n### 8.1 验证层次\n\nCompose 配置错误至少有五个层次:\n\n1. **YAML 语法错误** \n 缩进、引号、冒号和序列结构不合法。\n\n2. **变量插值错误** \n 必需变量缺失、表达式格式错误,或变量值为空。\n\n3. **Compose 模型错误** \n 字段类型错误、未知结构、服务引用不存在、端口和网络配置不一致。\n\n4. **合并结果错误** \n 覆盖文件意外追加端口、继承后引用了不存在的服务、路径解析错误。\n\n5. **运行时错误** \n 镜像不存在、端口已被占用、宿主机目录权限不足、容器启动后健康检查失败。\n\n`docker compose config` 主要覆盖前四层,不能替代容器启动和应用级测试。\n\n### 8.2 查看最终模型\n\n```bash\ndocker compose \\\n -f compose.yaml \\\n -f compose.dev.yaml \\\n config\n```\n\n它适合检查:\n\n- 服务最终使用的镜像;\n- 环境变量最终值;\n- 端口是否被意外追加;\n- 卷和网络是否存在;\n- `extends` 继承后的字段;\n- `include` 引入的服务;\n- 短语法被解析后的长语法;\n- 相对路径最终解析为什么。\n\n如果只需要判断配置是否合法:\n\n```bash\ndocker compose \\\n -f compose.yaml \\\n -f compose.dev.yaml \\\n config -q\n```\n\n成功时通常没有输出,并返回退出码 `0`;失败时输出错误并返回非零退出码,适合 CI:\n\n```bash\nset -eu\n\ndocker compose \\\n --env-file .env.ci \\\n -f compose.yaml \\\n -f compose.ci.yaml \\\n config -q\n```\n\n### 8.3 在验证前关闭插值或查看变量环境\n\n要检查原始表达式是否存在,可以使用:\n\n```bash\ndocker compose config --no-interpolate\n```\n\n这个选项适合诊断“变量到底是没有定义,还是插值后值不正确”。但 `--no-interpolate` 输出不再代表可直接运行的最终模型,不能用它替代正常验证。\n\n查看 Compose 插值环境:\n\n```bash\ndocker compose config --environment\n```\n\n查看最终服务名和卷名:\n\n```bash\ndocker compose config --services\ndocker compose config --volumes\n```\n\n这些查询有助于区分“源文件里写了什么”和“当前项目最终识别了什么”。\n\n### 8.4 `config` 不验证的内容\n\n以下情况可能在 `config` 成功后才失败:\n\n```bash\ndocker compose config -q\ndocker compose up -d\n```\n\n例如:\n\n- `image: example/api:unknown` 的镜像标签不存在;\n- `ports: [\"8080:80\"]` 中宿主机的 8080 已被其他进程占用;\n- `./config:/etc/api:ro` 绑定的是错误类型的路径;\n- Linux 宿主机权限或 SELinux 阻止容器访问挂载目录;\n- 应用启动后无法连接数据库;\n- 健康检查命令在镜像中不存在;\n- `deploy` 下的某些字段在本地 Compose 中不产生预期效果。\n\n所以一个完整的交付验证通常至少包括:\n\n```bash\ndocker compose \\\n --env-file .env.ci \\\n -f compose.yaml \\\n -f compose.ci.yaml \\\n config -q\n\ndocker compose \\\n --env-file .env.ci \\\n -f compose.yaml \\\n -f compose.ci.yaml \\\n pull\n\ndocker compose \\\n --env-file .env.ci \\\n -f compose.yaml \\\n -f compose.ci.yaml \\\n up -d\n\ndocker compose \\\n --env-file .env.ci \\\n -f compose.yaml \\\n -f compose.ci.yaml \\\n ps\n```\n\n`config -q` 验证模型,`pull` 验证镜像引用和仓库访问,`up -d` 验证运行时资源,`ps` 验证容器状态。应用级健康检查还应通过实际 HTTP、数据库迁移或业务测试确认。\n\n---\n\n## 九、一个可复用的多环境结构\n\n下面的结构把不同机制放在各自适合的位置:\n\n```text\nproject/\n├── compose.yaml\n├── compose.dev.yaml\n├── compose.test.yaml\n├── compose.prod.yaml\n├── compose.env\n├── components/\n│ ├── postgres/\n│ │ ├── compose.yaml\n│ │ └── init/\n│ └── monitoring/\n│ └── compose.yaml\n└── secrets/\n └── db_password.txt\n```\n\n基础文件:\n\n```yaml\n# compose.yaml\nservices:\n api:\n image: \"${API_IMAGE:-example/api:stable}\"\n environment:\n APP_ENV: \"${APP_ENV:-production}\"\n LOG_LEVEL: \"${LOG_LEVEL:-info}\"\n depends_on:\n db:\n condition: service_healthy\n networks:\n - app\n\n db:\n image: \"postgres:${POSTGRES_TAG:-16}\"\n environment:\n POSTGRES_DB: app\n POSTGRES_USER: app\n POSTGRES_PASSWORD_FILE: /run/secrets/db_password\n secrets:\n - db_password\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U app -d app\"]\n interval: 5s\n timeout: 3s\n retries: 12\n networks:\n - app\n\nnetworks:\n app:\n\nsecrets:\n db_password:\n file: ./secrets/db_password.txt\n```\n\n开发覆盖:\n\n```yaml\n# compose.dev.yaml\nservices:\n api:\n build:\n context: .\n image: \"example/api:dev\"\n environment:\n APP_ENV: development\n LOG_LEVEL: debug\n volumes:\n - ./:/app\n ports:\n - \"8080:8080\"\n command: [\"./api\", \"--config\", \"/app/config/dev.yaml\"]\n```\n\n测试覆盖:\n\n```yaml\n# compose.test.yaml\nservices:\n api:\n image: \"example/api:${CI_COMMIT_SHA:?CI_COMMIT_SHA is required}\"\n environment:\n APP_ENV: test\n DATABASE_URL: \"postgres://app@db/app\"\n command: [\"./api\", \"test\"]\n```\n\n生产覆盖:\n\n```yaml\n# compose.prod.yaml\nservices:\n api:\n image: \"registry.example.com/api:${RELEASE_TAG:?RELEASE_TAG is required}\"\n environment:\n APP_ENV: production\n LOG_LEVEL: warn\n ports: !override\n - \"80:8080\"\n volumes: !reset []\n\n db:\n restart: unless-stopped\n```\n\n测试环境执行:\n\n```bash\nCI_COMMIT_SHA=9f4c2a1 \\\ndocker compose \\\n -f compose.yaml \\\n -f compose.test.yaml \\\n config -q\n```\n\n生产环境执行:\n\n```bash\nRELEASE_TAG=2025.03.1 \\\ndocker compose \\\n -f compose.yaml \\\n -f compose.prod.yaml \\\n config\n```\n\n这个设计中:\n\n- 基础文件定义服务拓扑和默认运行模型;\n- 开发文件增加源码绑定挂载和调试端口;\n- 测试文件使用不可变提交哈希作为镜像引用;\n- 生产文件用 `!override` 只暴露生产端口;\n- 生产文件用 `!reset` 清除开发或基础配置可能带来的挂载;\n- 数据库密码通过 Secret 文件进入容器,而不是通过镜像标签或普通变量传递。\n\n如果生产覆盖文件没有使用 `!reset []` 清除基础卷,而基础文件包含源码挂载,生产容器就可能继续绑定宿主机源码目录。这不是 Compose 的异常行为,而是序列或资源字段合并后的正常结果。\n\n---\n\n## 十、常见误解与失败路径\n\n### 误解一:覆盖文件中的列表一定会替换基础列表\n\n错误理解:\n\n```yaml\n# compose.yaml\nservices:\n api:\n ports:\n - \"8080:8080\"\n - \"9090:9090\"\n```\n\n```yaml\n# compose.dev.yaml\nservices:\n api:\n ports:\n - \"8081:8080\"\n```\n\n期望只暴露 `8081`,但结果可能同时包含基础端口和新端口。诊断方法:\n\n```bash\ndocker compose \\\n -f compose.yaml \\\n -f compose.dev.yaml \\\n config\n```\n\n修复方式是使用 `!override`,或从基础文件移除不应跨环境继承的端口。\n\n### 误解二:`include` 就是 `-f` 的另一种写法\n\n`-f` 的核心语义是有序覆盖;`include` 的核心语义是组件组合。把多个组件通过 `-f` 叠加,可能造成相对路径以错误基准解析;把环境覆盖文件写成 `include`,又可能失去明确的覆盖意图。\n\n判断方法是问:\n\n- 这是同一个服务模型在不同环境下的差异吗?使用 `-f`;\n- 这是一个相对独立的 Compose 组件吗?考虑 `include`;\n- 只是想复用某一个服务字段集合吗?考虑 `extends`。\n\n### 误解三:`extends` 会自动带上父服务的所有依赖\n\n`extends` 只表达服务定义复用。父服务依赖的数据库、网络和卷必须检查是否出现在最终模型中。否则可能发生:\n\n```text\nservice \"api\" depends on undefined service \"db\"\n```\n\n或者模型验证通过但运行时无法连接预期的外部资源。\n\n### 误解四:`.env` 中的变量等于容器里的环境变量\n\n`.env` 首先是 Compose CLI 的变量输入来源之一。它不会因为存在于项目目录,就自动成为所有容器的环境变量。\n\n若要让容器获得变量,必须显式写入:\n\n```yaml\nenvironment:\n LOG_LEVEL: \"${LOG_LEVEL}\"\n```\n\n或者:\n\n```yaml\nenv_file:\n - app.env\n```\n\n两者的用途和暴露面不同,不应混用。\n\n### 误解五:`docker compose config` 成功就代表系统能运行\n\n`config` 成功只说明当前 Compose 模型可以被解析和验证。它不保证:\n\n- 镜像存在;\n- 端口可绑定;\n- 文件权限正确;\n- 服务已经就绪;\n- 应用协议正确;\n- 数据库迁移成功。\n\n应将模型验证、镜像验证、容器启动和应用测试分为不同阶段。\n\n---\n\n## 十一、Linux 容器边界下的具体取舍\n\nCompose 的本地执行模型通常是单个 Docker Engine 上的一组容器、网络和卷。它不是跨节点调度器,也不会因为配置中写了某些编排字段,就自动获得 Kubernetes 或 Swarm 的全部能力。\n\n在 Linux 容器环境中,以下边界尤其重要:\n\n### Bind mount 是宿主机状态耦合\n\n```yaml\nvolumes:\n - ./data:/var/lib/app\n```\n\n这个配置把宿主机目录作为容器数据源。配置合并时只要该条目被保留,容器就会继续依赖当前宿主机目录。它不适合直接表达不可变生产制品的数据来源。\n\n### Named volume 的生命周期不同\n\n```yaml\nvolumes:\n app-data:\n```\n\n```yaml\nservices:\n api:\n volumes:\n - app-data:/var/lib/app\n```\n\n命名卷由 Docker 管理,容器删除通常不会自动删除卷。多环境文件如果复用同一个项目名和卷名,测试和生产可能访问同一份持久化数据。可以通过项目名、显式卷名和部署目录隔离:\n\n```bash\ndocker compose -p app-prod -f compose.yaml -f compose.prod.yaml up -d\n```\n\n### Secret 文件仍受宿主机权限影响\n\nCompose Secret 的 `file` 来源仍然是宿主机文件。Secret 不等于加密存储;如果文件权限过宽、备份系统收集了它,或者 CI 工作区被暴露,敏感信息仍会泄露。\n\n### 生产覆盖不应依赖“默认追加”\n\n生产环境需要固定镜像、明确端口、只读配置和受控数据卷时,应使用 `!override`、`!reset` 或独立的生产基础文件,避免依赖复杂的隐式合并。最终必须保存或审查 `docker compose config` 的输出,确保交付的确实是预期模型。\n\n---\n\n## 十二、建议的故障诊断顺序\n\n当 `docker compose up` 失败时,不要先猜容器内部问题,而应按配置处理顺序排查:\n\n```bash\ndocker compose version\ndocker compose config --environment\ndocker compose -f compose.yaml -f compose.prod.yaml config\ndocker compose -f compose.yaml -f compose.prod.yaml config -q\n```\n\n然后分别检查:\n\n1. **版本**:确认 Docker Compose CLI 支持项目使用的 `include`、`!reset` 或 `!override`;\n2. **变量环境**:确认变量来自预期的 Shell、`.env` 或 `--env-file`;\n3. **最终模型**:确认服务、端口、卷、网络和镜像已经按预期合并;\n4. **退出码**:在 CI 中使用 `config -q` 让配置错误阻断交付;\n5. **运行时**:再检查 `pull`、`up`、`ps`、日志和健康状态。\n\n例如:\n\n```bash\ndocker compose \\\n --env-file .env.prod \\\n -f compose.yaml \\\n -f compose.prod.yaml \\\n config -q || {\n echo \"invalid Compose model\" >&2\n exit 1\n }\n\ndocker compose \\\n --env-file .env.prod \\\n -f compose.yaml \\\n -f compose.prod.yaml \\\n up -d\n\ndocker compose \\\n --env-file .env.prod \\\n -f compose.yaml \\\n -f compose.prod.yaml \\\n ps\n```\n\n这段流程的因果关系是明确的:先阻止非法模型进入运行阶段,再验证运行时资源,最后观察容器状态。若直接执行 `up`,配置错误、镜像拉取错误、端口冲突和应用启动错误会混在同一条故障路径中,诊断成本更高。\n\n---\n\n## 十三、如何选择机制\n\n可以用下面的判定规则收束设计:\n\n- 同一项目的开发、测试、生产差异:使用多个 `-f` override 文件;\n- 多个目录化、相对独立的 Compose 组件组合:使用 `include`;\n- 多个服务共享一组服务字段:使用 `extends`;\n- 镜像标签、端口和非敏感运行参数:使用变量插值;\n- 密码、令牌和私钥:使用 Secret 或外部密钥系统,不把值直接写进插值结果;\n- 需要删除或完全替换继承字段:使用 `!reset` 或 `!override`,并确认 Compose 版本;\n- 需要知道实际交付什么:始终检查 `docker compose config` 的合并结果。\n\n最重要的不是记住某个 YAML 片段,而是明确配置处理中的三个层次:\n\n\\[\n变量解析 \\rightarrow 模型合并 \\rightarrow 运行时验证\n\\]\n\n`include` 负责组合,`extends` 负责服务复用,override 文件负责环境差异;它们都不能替代对最终模型的检查。对于 Linux 容器,路径、权限、端口、卷和 Secret 的宿主机边界还必须在真正启动后验证。只有同时理解源文件、合并规则和运行时状态,Compose 配置才具有可预测的交付行为。\n\n---\n\n## 系列导航与关联阅读\n\n- 系列入口:[Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付](https://wrblog.cn/articles/f92a587c-6d26-5bc8-ba2d-981c6631ac7e)\n- 上一篇:[Compose 服务发现与依赖:DNS、Healthcheck、启动顺序和重连](https://wrblog.cn/articles/12404d83-5f21-50e2-bd51-cbe089fa73f4)\n- 下一篇:[Compose 多环境治理:开发、测试、生产差异、Secret 和不可变制品](https://wrblog.cn/articles/dcb6b496-6731-552d-8e4b-bfb499219abf)\n- 延伸:[Docker Compose 完整指南:服务、网络、卷、依赖、Profile 和生产边界](https://wrblog.cn/articles/d72feffe-2fff-504f-918c-a94f90a98e49)\n\n## 官方资料\n\n- [Docker Compose Documentation](https://docs.docker.com/compose/)\n- [Compose Specification](https://compose-spec.io/)\n\n> 本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。\n","tags":["Docker","Compose","配置管理","DevOps"],"likeCount":0,"commentCount":0,"createdByUserId":"10000000000","createdByDisplayName":"小郝","createdByAvatar":"/public/profile/10000000000/avatar/2026/08/04/db02b81c-42f2-441b-8a80-61370cdbb581.webp","publishTime":"2026-09-01 13:45:20","updateTime":"2026-09-01 13:45:20"}},"status":200,"locale":"zh-CN","theme":"light"}