Docker Compose 多文件合并与 override 规则
了解 compose.override.yaml 如何自动加载、-f 文件顺序如何合并,以及 ports 列表为何仍会保留端口。附 dev 与 prod 拆分方案和 include 用法。
Compose 如何处理多个文件
Docker Compose 可以使用多个文件构建一个项目。它会按照接收文件的顺序读取这些文件,并将其合并为一个模型。因此,后一个文件会覆盖发生冲突的值。命令行提供两种机制来实现这一点:Compose 自动加载的 override 文件,以及您手动传入的 -f 标志。文件内部还有第三种机制,即 include 元素。它的工作方式与前两种不同。
合并并不是简单覆盖。映射会逐个键合并,序列会追加,少数字段则会整体替换。差异会导致一些意外结果,其中 ports 列表最容易引发问题。
以下内容均假设使用 Compose v2,即 docker compose 插件,而不是旧版 docker-compose 脚本。运行 docker compose version 进行检查。如果您还没有编写 Compose 文件,请先阅读 Docker Compose 基础指南,然后再回来。
Compose 无需显式指定即可加载的 override 文件
运行 docker compose up 时不使用 -f 标志,Compose 会在工作目录及其父目录中搜索 compose.yaml 或 docker-compose.yaml。如果 override 文件与基础文件位于同一目录,Compose 会自动加载该文件。
ls compose.yaml compose.override.yaml
docker compose up -d两个文件同时存在时,效果等同于手动依次指定这两个文件。
docker compose -f compose.yaml -f compose.override.yaml up -dCompose 可识别的文件名包括 compose.override.yaml、compose.override.yml,以及较旧的 docker-compose.override.yml 和 docker-compose.override.yaml。其他名称(例如 compose.dev.yaml)只有在使用 -f 指定时才会加载。
一旦传入 -f,自动加载就会停止。docker compose -f compose.yaml up 只读取该文件,并忽略 override 文件。本文后面的 dev 和 prod 模式正是基于这一特性。
这在服务器上可能产生相反的结果。部署目录中遗留的 override 文件,会被从该目录运行的每个不带参数的 docker compose 命令加载,包括 cron 任务运行的命令。因此,生产环境中的服务栈可能会绑定挂载原本不应发布的源代码目录。每次部署后运行 docker compose config,并检查输出结果。
使用 -f 排序,以及相对路径的解析位置
Compose 按您提供文件的顺序构建配置。后续文件会覆盖前面的配置,并在其基础上添加配置。按从左到右的顺序,最后一个文件优先。
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -d项目中的每个命令都需要使用相同的文件列表。使用两个文件运行 up,再使用一个文件运行 logs,操作的就是不同的合并模型。这很容易导致 Compose 报告服务不存在。请改为使用 COMPOSE_FILE 环境变量一次性设置文件列表。
export COMPOSE_FILE=compose.yaml:compose.prod.yaml
docker compose config
docker compose up -d在 Linux 上,分隔符是 :,而 COMPOSE_PATH_SEPARATOR 会更改该分隔符。您也可以将 COMPOSE_FILE 写入项目的 .env 文件中,这样它就会成为代码检出内容的一部分,而不是 shell 历史记录的一部分。在命令行中显式设置的值优先于环境变量。
接下来是会导致绑定挂载出问题的规则。使用 -f 处理多个文件时,所有文件中的相对路径都相对于第一个文件所在的目录解析,而不是相对于包含这些路径的文件解析。在 deploy/prod/compose.prod.yaml 中写入 ./data:/var/lib/postgresql/data 后,Compose 仍会在基础文件旁查找 ./data。Docker 随后会在错误路径创建一个空目录,容器启动时其中没有任何内容。这看起来像数据丢失,但实际并非如此。请传递 --project-directory 手动设置基础路径,或使用 include,让每个文件都相对于自身所在目录解析。
项目名称也来自同一个基础目录,因此更改第一个文件可能会重命名项目。项目重命名后,容器名称和卷名称都会改变,旧卷仍会以旧名称保留在磁盘上。请在基础文件中使用顶级 name: 固定项目名称。
name: myapp哪些字段会合并,哪些字段会被替换
Compose 根据值的类型进行合并,而不是根据字段名称进行合并。
- 单值字段会被替换。
image、command、entrypoint和mem_limit会直接采用后一个值。您无法向command追加一个参数,因为覆盖操作会重写整行。 - 映射会按键合并。
environment、labels、volumes和devices会保留两个文件中的所有键;如果两个文件包含相同的键,则以后一个文件中的值为准。对于environment和labels,键是变量名或标签名。对于volumes和devices,键是容器路径。 - 序列会追加。
dns、dns_search、expose、tmpfs和external_links会进行串联。将包含expose: ["3000"]的基础配置与包含["4000", "5000"]的覆盖配置合并后,会生成["3000", "4000", "5000"]。
有四种序列带有标识键,因此具有相同标识键的条目会合并,而不是追加。volumes、secrets 和 configs 按 target 匹配。ports 按 ip、target、published 和 protocol 的组合进行匹配。
请仔细理解 ports 规则,因为这里很容易出错。只有四个部分全部相同,两个端口条目才是同一个条目。只要其中任意一个部分不同,Compose 就会将其视为第二个无关端口,因此会保留两个条目。
为什么使用 override 后端口仍然处于发布状态
用于在每个网络接口上发布服务的基础文件:
services:
web:
image: nginx:1.27
ports:
- "8080:80"用于将服务绑定到 localhost 的 override 文件,因为前面会有反向代理:
services:
web:
ports:
- "127.0.0.1:8080:80"先检查结果,再判断配置是否生效。
docker compose -f compose.yaml -f compose.prod.yaml config输出中包含两条配置。ip 部分不同,分别是 0.0.0.0 和 127.0.0.1。因此,合并时它们会被视为两个不同的端口。您尝试移除的公网绑定仍然存在于模型中。与其他环境相比,这在 Docker 中更重要,因为已发布的端口会写入 iptables,并且优先于您的防火墙规则。相关机制请参阅为什么已发布的 Docker 端口会绕过 ufw。
有两种修复方法。显式方法是使用 !override 标签。它会替换整个属性,并跳过合并规则:
services:
web:
ports: !override
- "127.0.0.1:8080:80"!override 需要 Compose v2.24.4 或更高版本。可移植的修复方法完全不需要标签:将 ports 完全排除在基础文件之外,只在特定环境的文件中声明它。无需合并,就没有内容会意外暴露。这就是下面完整示例所使用的模式。
删除基础文件设置的值
!reset 会删除一个属性,将其恢复为默认值或 null。它需要一个值但会忽略该值,因此请填写有效的空值。
services:
web:
ports: !reset []
environment:
DEBUG: !reset null!reset 需要 Compose v2.24 或更高版本。当基础文件无法由您编辑时使用它,例如引入供应商提供的片段时。
include,用于由多个部分组装的堆栈
include 将另一个 Compose 应用引入当前模型。它是顶级元素,不是标志。
include:
- path: ../commons/compose.yamlinclude 中的每个路径都会作为独立的 Compose 应用模型加载,并使用各自的项目目录。因此,该文件中的相对路径会相对于文件自身所在的目录解析。这正是它与 -f 的实际区别,也是当片段位于其他文件夹或其他仓库中时,应使用 include 的原因。
长格式支持子选项。
include:
- path:
- ../monitoring/compose.yaml
- ../monitoring/compose.vps.yaml
project_directory: ../monitoring
env_file: ../monitoring/.envpath 接受列表,这些文件会先按照常规规则合并,然后再将结果加入当前模型。project_directory 设置用于解析被包含文件中相对路径的基准路径。env_file 为被包含文件提供独立的变量用于插值,避免共享片段无意中读取项目的 .env。include 需要 Compose v2.20.0 或更高版本。
如果当前文件与被包含文件之间存在重复的资源名称,系统会报告错误,而不会静默合并。这是有意设计的。如果要修改被包含文件声明的内容,请将修改写入 compose.override.yaml:覆盖配置会应用于组装后的模型,因此可以修改被包含的资源,而不会与其发生冲突。
简而言之:include 用于组合独立的应用,-f 用于在同一个应用上叠加配置。
在一个 VPS 上分离开发环境和生产环境
下面通过 3 个文件展示完整模式。基础文件声明所有环境都成立的配置,并且完全不发布端口。
name: myapp
services:
app:
image: ghcr.io/example/app:1.4.2
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
LOG_LEVEL: info
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:depends_on 条件会让应用等待能够响应的数据库,而不是仅等待容器存在。详见健康检查和 depends_on 条件。POSTGRES_PASSWORD 从项目的 .env 文件中插入。该文件绝不能提交到 git。更安全的方案请参阅环境文件和 Compose secrets。
接下来是 compose.override.yaml,Compose 会自动加载该文件。这是开发者文件。
services:
app:
build: .
command: npm run dev
environment:
LOG_LEVEL: debug
ports:
- "3000:3000"
volumes:
- ./src:/app/src
db:
ports:
- "127.0.0.1:5432:5432"在笔记本电脑上,直接运行 docker compose up 会合并这两个文件。command 会替换镜像默认值,因为它是单值配置。LOG_LEVEL 会替换 info,因为 environment 会按键合并。绑定挂载和两个发布端口只是新增配置。数据库端口绑定到 localhost,因此在共享网络中的其他设备无法访问这台笔记本上的 PostgreSQL。
最后是 compose.prod.yaml。Compose 不会查找这个文件名,因此不会意外加载它。
services:
app:
ports:
- "127.0.0.1:8000:3000"
deploy:
resources:
limits:
memory: 512M在 VPS 上显式指定这两个文件。指定文件本身就会排除 override 文件。
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml psps 应显示两个服务正在运行,其中 db 显示 (healthy)。由于传入了 -f,因此不会读取 compose.override.yaml。这样,即使该文件位于同一目录中,开发命令、源代码绑定挂载和公共端口 3000 也无法进入生产环境。端口 8000 仅监听 localhost,可供代理使用。添加第二个服务时,请参阅通过 Traefik 运行多个应用。
在服务器的 .env 中设置 COMPOSE_FILE=compose.yaml:compose.prod.yaml,之后其余命令即可恢复为直接使用 docker compose logs -f app。
部署前读取合并后的模型
docker compose config 会打印完整合并且完成插值的模型。它不是预览,而是 Compose 将要处理的确切输入。因此,当输出与您的预期不一致时,应以输出为准。
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml config --no-interpolate
docker compose -f compose.yaml -f compose.prod.yaml config --services--no-interpolate 不会展开 ${VAR}。在将输出粘贴到其他位置前,请使用它,因为普通的 config 会以明文打印所有已解析的密钥。--services 只列出服务名称,可快速确认 include 是否加载了预期内容。
故障模式及其表现
no configuration file provided: not found。 Compose 未找到任何可读取的内容。您不在项目目录中,或者 COMPOSE_FILE 指定的路径不存在。Compose 会在父目录中搜索默认基础文件,但不会在任何位置搜索您自行命名的文件。
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string. 插值会根据项目的 .env 文件和 shell 环境进行解析,此处的项目目录是第一个 -f 文件所在的目录。如果您从不同于 .env 所在目录的位置进行部署,就会看到此警告,随后数据库会拒绝所有连接。
您的覆盖配置修改未显示在 docker compose config 中。 您可能传递了 -f,该选项会关闭自动加载覆盖配置;或者 Compose 在父目录中找到了 compose.yaml,而您的覆盖文件不在该文件旁边。不带其他参数运行 docker compose config,即可查看 Compose 实际构建的模型。
绑定挂载为空,并且 Docker 创建了一个您未要求的目录。 相对路径是根据第一个文件所在的目录解析的。请修正路径,传递 --project-directory,或将该片段移到 include 后面。
容器重新启动后使用了新名称,并且某个卷看起来是空的。 项目名称发生了变化,因为项目名称取决于第一个文件所在的目录。在基础文件中添加顶级 name:,即可固定名称。旧卷仍以旧前缀保留,使用 docker volume ls 可以查看它。
您在覆盖配置中移除的端口仍处于开放状态。 ports 合并时执行了追加,而不是替换。使用 docker compose config 进行确认,然后使用 !override,或将 ports 移出基础文件。
FAQ
Compose 会自动加载 compose.override.yaml 吗?
会。在不使用 -f 标志运行 docker compose 时,Compose 会在工作目录及其父目录中搜索 compose.yaml 或 docker-compose.yaml。如果覆盖文件与其位于同一目录,Compose 会随后加载该文件。支持的文件名为 compose.override.yaml、compose.override.yml、docker-compose.override.yml 和 docker-compose.override.yaml。传入任意 -f 都会禁用此行为,因此 docker compose -f compose.yaml up 只读取一个文件。
多个 -f 文件按什么顺序合并?
从左到右。Compose 按照您提供文件的顺序构建配置。每个文件都会覆盖并追加到前面的文件,因此命令行中最后一个文件会覆盖冲突项。该项目的每条命令都必须使用相同的文件列表,这正是 COMPOSE_FILE=compose.yaml:compose.prod.yaml 的用途。
为什么覆盖后端口仍然处于发布状态?
因为 ports 条目由完整的 ip、target、published 和 protocol 集合标识。以 8080:80 为基础覆盖 127.0.0.1:8080:80 时,ip 部分不同,因此 Compose 会将其视为第二个端口,并保留两者。运行 docker compose config 即可看到这两个条目。在 Compose v2.24.4 或更高版本中使用 ports: !override;或者从基础文件中移除 ports,这样就没有可供合并的内容。
include 和 -f 有什么区别?
-f 会将多个文件叠加到一个应用中,并且每个文件中的相对路径都相对于第一个文件所在的目录解析。include 会引入一个独立的 Compose 应用,并保留每个被引入文件自身的项目目录,因此其中的相对路径会相对于该文件自身解析。对于自有堆栈的环境层,应使用 -f;对于由其他位置维护的片段,应使用 include。include 需要 Compose v2.20.0 或更高版本。
如何移除基础文件设置的值?
在 Compose v2.24 或更高版本中,使用 !reset 标签。在覆盖文件中写入 ports: !reset [] 或 MY_VAR: !reset null,该属性就会恢复为默认值或 null。为该标签提供的值是必需的,但会被忽略。如果要替换属性而不是清除属性,请使用 !override;该功能需要 v2.24.4 或更高版本。