SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-28

Docker Compose 多文件合并与 override 配置

了解 compose.override.yaml 如何自动加载、-f 文件顺序如何合并、ports 列表为何无法关闭端口,以及如何用 include 拆分开发与生产配置。

多个文件时 Compose 的处理方式

Docker Compose 可以根据多个文件构建一个项目。它会按照接收文件的顺序读取这些文件,并将其合并为一个模型,因此后面的文件会覆盖冲突的值。从命令行可以通过两种机制实现这一点:Compose 自动加载的 override 文件,以及手动传入的 -f 标志。第三种机制位于文件内部,即 include 元素,其工作方式与前两种不同。

合并并不是简单覆盖。映射会逐个键合并,序列会追加,少数字段则会整体替换。差异会带来各种意外,其中最容易出问题的是 ports 列表。

以下内容均假设使用 Compose v2,即 docker compose 插件,而不是旧版 docker-compose 脚本。运行 docker compose version 进行检查。如果您还没有编写 Compose 文件,请先阅读Docker Compose 基础指南,然后再回来。

Compose 会自动加载的 override 文件

不带 -f 标志运行 docker compose up 时,Compose 会先在工作目录中查找 compose.yamldocker-compose.yaml,然后继续检查其父目录。如果 override 文件与基础文件位于同一目录,Compose 会自动加载该文件。

ls compose.yaml compose.override.yaml
docker compose up -d

两个文件同时存在时,效果等同于手动输入这两个文件。

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

Compose 能识别的文件名包括 compose.override.yamlcompose.override.yml,以及较旧的 docker-compose.override.ymldocker-compose.override.yaml。其他名称(例如 compose.dev.yaml)只有在通过 -f 指定时才会加载。

一旦传入一个 -f,自动加载就会停止。docker compose -f compose.yaml up 只读取该文件,并忽略 override 文件。后文介绍的 dev 和 prod 模式正是基于这一特性。

这在服务器上可能产生相反的结果。部署目录中遗留的 override 文件会被从该目录运行的每条裸 docker compose 命令加载,包括 cron 任务执行的那条命令。这样,生产环境中的服务栈就可能绑定挂载一个原本没人打算发布的源代码目录。每次部署后都运行 docker compose config,并查看命令输出。无人值守部署时,只有在有机制通知您检查失败,检查才有意义。可使用推送通道,例如自托管的 ntfy 服务器;cron 任务或 systemd OnFailure 单元可以向该通道发送通知。

使用 -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 报告某个服务不存在。对于升级通过一次性命令执行的堆栈,风险更高。例如,在 自行托管的 Chatwoot 支持服务 中执行数据库迁移时,如果 docker compose run 使用了错误的文件列表,就会悄然针对不同于现有服务所使用的模型执行。改为通过 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 根据值的类型进行合并,而不是根据字段名称进行合并。

  • 单值字段会被替换。 imagecommandentrypointmem_limit 会直接采用后一个值。您无法向 command 追加一个参数,因为覆盖配置会重写整行。
  • 映射会按键合并。 environmentlabelsvolumesdevices 会保留两个文件中的所有键;如果同一个键同时存在于两个文件中,则以后一个文件中的值为准。对于 environmentlabels,键是变量名或标签名。对于 volumesdevices,键是容器路径。
  • 序列会追加。 dnsdns_searchexposetmpfsexternal_links 会连接起来。基础配置包含 expose: ["3000"]、覆盖配置包含 ["4000", "5000"] 时,合并结果为 ["3000", "4000", "5000"]

有 4 个序列带有标识键,因此键匹配的条目会合并,而不是追加。volumessecretsconfigs 根据 target 进行匹配。ports 根据 iptargetpublishedprotocol 的组合进行匹配。

请仔细阅读两遍 ports 规则,因为这里最容易出错。只有在这 4 个部分全部一致时,两个端口条目才会被视为同一个条目。只要其中任意一个部分不同,Compose 就会将其视为第二个无关端口,因此会保留两者。

覆盖文件后端口为何仍然处于发布状态

基础文件会将服务发布到所有网络接口:

services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"

覆盖文件尝试将其绑定到 localhost,因为前面会运行反向代理:

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 或更高版本。当基础文件不是由您维护时使用它,例如引入供应商提供的片段。已发布的上游堆栈正属于这种情况:自托管 AFFiNE 工作区背后的 Compose 文件声明了 4 个并非由您编写的容器,而 !reset 可以清除其中一个容器的某个属性,无需分叉该文件,也无需承担持续跟踪其变更的工作。

include,用于由多个部分组装的堆栈

include 会将另一个 Compose 应用引入当前模型。它是顶层元素,不是标志。

include:
  - path: ../commons/compose.yaml

include 中的每个路径都会作为独立的 Compose 应用模型加载,并使用自己的项目目录。因此,该文件中的相对路径会根据文件自身所在的目录解析。这正是它与 -f 的实际区别,也是当片段位于其他文件夹或其他仓库时,应使用 include 的原因。供应商提供的堆栈通常就是这种形式,因为这类堆栈不是由您编写的:自托管 Authentik SSO 安装使用的多服务 Compose 文件可以放在自己的目录中,并保留自身的相对路径;而您的文件仍只管理自己的服务。

长格式支持子选项。

include:
  - path:
      - ../monitoring/compose.yaml
      - ../monitoring/compose.vps.yaml
    project_directory: ../monitoring
    env_file: ../monitoring/.env

path 接受列表。Compose 会先按照常规规则合并这些文件,再将结果加入您的模型。project_directory 设置用于解析被包含文件中相对路径的基础路径。env_file 为被包含文件提供独立的变量,用于插值。这样可以避免共享片段无意中读取您项目的 .envinclude 需要 Compose v2.20.0 或更高版本。对于已在运行的堆栈,单容器附加组件也适用相同的选项,例如 Halcyon,它会将 Jellyfin 媒体库重新设计为 90 年代录像租赁店:它的文件保留自身的镜像标签和 env_file,因此升级该组件时不必修改媒体堆栈所使用的文件。

如果您的文件与被包含文件中存在重复的资源名称,Compose 会报告错误,而不会静默合并。这是有意设计的。要修改被包含文件声明的内容,请将修改写入 compose.override.yaml:覆盖配置会应用于组装后的模型,因此可以修改被包含的资源,而不会与其发生冲突。对于上游文件每次发布都会重写的堆栈,这种做法尤其有用。例如,在 PhotoPrism 与 Immich 对比中评估的多容器照片服务器,localhost 绑定或额外挂载卷应放在您的覆盖文件中,而不是放在下一次升级会替换的文件中。

简而言之: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 上显式指定这两个文件。指定文件的操作正好会排除覆盖文件。

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 ps

ps 应显示两个服务都在运行,并且 db 显示 (healthy)。由于传入了 -f,Compose 不会读取 compose.override.yaml。因此,即使该文件位于同一目录中,开发命令、源代码绑定挂载和公网端口 3000 也不会进入生产环境。端口 8000 仅监听 localhost,可供代理使用。添加第二个服务时,请参见通过 Traefik 运行多个应用

在服务器的 .env 中设置 COMPOSE_FILE=compose.yaml:compose.prod.yaml,之后其余命令就可以继续使用普通的 docker compose logs -f app

单服务堆栈也应采用相同结构,因为自行托管的 openGym 锻炼记录器必须先通过代理使用 TLS 提供服务,才能注册第一个 passkey。基础文件中不包含 ports,可防止意外的公网绑定抢在代理之前接管流量。

部署前读取合并后的模型

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.yamldocker-compose.yaml。如果覆盖文件与其位于同一目录,Compose 会在第二步加载该文件。支持的名称包括 compose.override.yamlcompose.override.ymldocker-compose.override.ymldocker-compose.override.yaml。传入任何 -f 都会禁用此行为,因此 docker compose -f compose.yaml up 只读取一个文件。

多个 -f 文件按什么顺序合并?

从左到右。Compose 按您提供文件的顺序构建配置。每个文件都会覆盖并补充前面的文件,因此命令行中最后一个文件会在冲突时生效。项目中的每条命令都必须使用相同的文件列表,这正是 COMPOSE_FILE=compose.yaml:compose.prod.yaml 的用途。

为什么覆盖后端口仍然发布?

因为 ports 条目由 iptargetpublishedprotocol 的完整组合标识。以 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;对于由其他位置维护的片段,请使用 includeinclude 要求 Compose v2.20.0 或更高版本。

如何删除基础文件设置的值?

在 Compose v2.24 或更高版本中,对该属性使用 !reset 标签。在覆盖文件中写入 ports: !reset []MY_VAR: !reset null,该属性就会恢复为默认值或 null。传给该标签的值是必需的,但会被忽略。如果要替换属性而不是清除属性,请使用 !override;该功能要求 v2.24.4 或更高版本。