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

Docker Compose 开发工作流:Bind、Watch、Override、Profile 和调试

Docker Compose 的开发工作流,核心不是“把代码挂进容器”这一条命令,而是协调四类状态:

  1. 源代码状态:代码位于宿主机,还是被复制进镜像;
  2. 容器文件系统状态:容器内的路径由镜像层、Bind mount 或 Named volume 提供;
  3. Compose 模型状态:哪些服务、网络、卷和配置被当前命令纳入项目;
  4. 进程运行状态:容器是否运行、主进程是否存活、应用是否已经重新加载。

Bind mount 解决的是宿主机与容器之间的文件映射;Watch 解决的是文件变化后的同步、重建或重启;Override 解决的是多套 Compose 配置的组合;Profile 解决的是可选服务的启停;调试命令则用于观察这些状态是否按照预期变化。

下文以现代 Docker Engine、BuildKit 和 Compose Specification 为基础,并假定应用运行在 Linux 容器 中。Docker Desktop 在 macOS 或 Windows 上通常通过 Linux VM 提供容器运行环境,因此路径共享、文件事件和权限表现还会受到宿主机与 VM 边界影响。


一、先建立 Compose 的运行模型

1. Compose 文件描述的是模型,不是已经运行的容器

一个 Compose 项目通常由以下对象组成:

  • service:容器的声明;
  • image 或 build:容器根文件系统的来源;
  • network:服务之间的通信域;
  • volume:持久化或共享数据;
  • config、secret:配置和敏感数据;
  • profiles:服务是否进入当前模型;
  • develop.watch:开发时如何响应文件变化。

执行:

docker compose up

时,Compose 会经历近似如下过程:

flowchart TD
    A[读取 Compose 文件] --> B[合并多个配置]
    B --> C[解析变量与路径]
    C --> D[应用 Profile 选择]
    D --> E[校验服务依赖与配置]
    E --> F[构建或拉取镜像]
    F --> G[创建网络、卷和容器]
    G --> H[启动容器进程]
    H --> I[观察日志、健康状态和退出状态]

这里有一个容易忽略的因果关系:

  • 文件还没有合并完成时,不能讨论最终的 volumes
  • Profile 尚未筛选时,不能判断某个服务是否存在于本次运行;
  • 容器尚未创建时,docker compose exec 没有目标;
  • 容器已经创建但进程退出时,exec 同样不能使用。

因此调试 Compose 时,应先确认 最终模型,再确认 容器状态,最后确认 进程和网络状态

2. 用 config 查看 Compose 的最终模型

假设目录如下:

demo/
├── compose.yaml
├── compose.override.yaml
├── Dockerfile
└── app.py

使用:

docker compose config

它会输出变量插值、多个文件合并、默认值展开后的配置。这个命令不会启动容器,适合验证:

  • 文件是否被正确读取;
  • portsvolumesenvironment 的最终值;
  • Profile 是否使服务进入当前模型;
  • 相对路径最终指向哪里;
  • YAML 缩进和字段类型是否正确。

只想验证配置而不访问镜像或运行环境时,可以使用:

docker compose config --quiet

成功时通常没有输出,失败时返回非零退出码。


二、Bind mount:把宿主机路径映射到容器

1. Bind mount 的定义

Bind mount 是把宿主机上的一个具体文件或目录,挂载到容器文件系统中的某个目标路径:

services:
  api:
    volumes:
      - type: bind
        source: .
        target: /app

也可以使用短语法:

services:
  api:
    volumes:
      - .:/app

其中:

  • source 是宿主机路径;
  • target 是容器内路径;
  • roread_only: true 可以把挂载设置为只读;
  • bind 的数据不由 Docker 创建和管理,而是直接依赖宿主机路径。

典型开发场景是:

宿主机 ./app.py
        │
        │ Bind mount
        ▼
容器 /app/app.py
        │
        ▼
应用进程读取 /app/app.py

容器内读取到的不是镜像构建时复制进去的那份文件,而是当前宿主机路径上的文件。

2. Bind mount 会遮挡镜像原有内容

这是开发环境中最重要的文件系统规则之一。

假设 Dockerfile 为:

FROM python:3.12-slim

WORKDIR /app
COPY app.py /app/app.py

CMD ["python", "/app/app.py"]

构建镜像后,镜像中已经存在:

/app/app.py

如果运行时使用:

services:
  api:
    volumes:
      - .:/app

那么容器启动后,/app 会被宿主机当前目录遮挡。镜像中的 /app/app.py 并没有被删除,但在这个容器挂载视图中不可见。

因此,如果宿主机目录中没有 app.py,容器内的 /app/app.py 也不能通过普通路径访问。

这也解释了下面的常见故障:

容器镜像构建成功
容器启动失败
错误:/app/app.py 不存在

实际原因可能不是 Dockerfile 的 COPY 失败,而是启动时的 Bind mount 覆盖了镜像中的 /app

3. Bind mount 与 Named volume 的职责不同

下面两个挂载并不等价:

services:
  api:
    volumes:
      - .:/app
      - pycache:/app/__pycache__

volumes:
  pycache:

第一项是 Bind mount,数据来源是宿主机当前目录;第二项是 Named volume,数据由 Docker 管理。

当多个挂载覆盖同一路径的不同层级时,较深路径的挂载可以独立提供内容。因此:

/app                  ← 宿主机目录
/app/__pycache__      ← Named volume

这可以避免 Python 缓存文件写回宿主机。

不过 Named volume 不会自动解决所有依赖问题。例如 Node.js 项目常见:

volumes:
  - .:/app
  - node_modules:/app/node_modules

原因是宿主机的 node_modules 可能:

  • 尚未安装;
  • 是 macOS 或 Windows 上生成的依赖;
  • 包含与 Linux 容器不兼容的原生扩展。

/app/node_modules 单独放到容器侧 Named volume,可以保留代码的 Bind mount,同时让依赖在 Linux 容器中安装。

4. ro 不能阻止应用访问宿主机敏感内容

下面的配置使容器不能写宿主机目录:

services:
  api:
    volumes:
      - type: bind
        source: .
        target: /app
        read_only: true

但只读不等于安全隔离。容器仍然可以读取目录中的源代码、配置文件和其他可读内容。因此不能把宿主机的整个家目录随意挂载进容器,即使使用了 ro

更安全的范围通常是:

services:
  api:
    volumes:
      - type: bind
        source: ./src
        target: /app/src
        read_only: true

5. Linux 下的权限、用户和文件事件

Bind mount 不会把宿主机文件的所有权“转换”为容器用户的所有权。Linux 上,文件的 UID 和 GID 数值直接参与权限判断。

例如容器进程以 UID 1000 运行:

FROM python:3.12-slim

RUN useradd --uid 1000 --create-home appuser
WORKDIR /app
USER appuser

COPY app.py /app/app.py
CMD ["python", "/app/app.py"]

如果宿主机的 ./data 属于一个容器用户没有写权限的 UID,下面的程序可能失败:

services:
  api:
    volumes:
      - ./data:/app/data

错误可能表现为:

PermissionError: [Errno 13] Permission denied

诊断时分别查看两侧:

ls -ln data
docker compose exec api id
docker compose exec api ls -ln /app/data

ls -n 显示数字 UID/GID,避免容器内外用户名不同造成误判。

Linux 上还需要注意:

  • 编辑器写文件时可能采用“写临时文件再 rename”的方式;
  • 应用监听的是 inode 或文件事件,监听行为可能因此变化;
  • 容器内的 inotify 监听限制可能影响大型项目;
  • SELinux 启用时,Bind mount 可能还需要 :z:Z 标签选项,具体取决于共享范围和发行版策略。

zZ 属于宿主机安全标签语义,不应在不了解 SELinux 策略时盲目复制到所有环境。


三、Watch:在文件变化后同步、重启或重建

1. Watch 与 Bind mount 不是同一层机制

Bind mount 让容器直接看到宿主机目录:

宿主机文件改变
      │
      ▼
容器挂载视图立即反映变化

Compose Watch 则是一个由 Compose 观察宿主机文件,并执行动作的机制:

宿主机文件改变
      │
      ▼
Compose Watch 规则匹配
      │
      ├── sync:复制文件到运行中的容器
      ├── sync+restart:复制后重启容器
      ├── rebuild:重新构建镜像并更新容器
      └── restart:重启容器

所以:

  • Bind mount 改变的是容器文件系统的挂载关系;
  • Watch 改变的是开发工作流中的更新动作;
  • Watch 可以在不把整个源码目录暴露为挂载点的情况下,把指定文件同步到容器。

Compose Watch 使用 develop.watch 配置,现代 Compose 版本支持以下常用动作:

  • sync:把变化同步到容器目标路径;
  • restart:重启服务容器;
  • sync+restart:先同步,再重启;
  • rebuild:触发镜像重建并更新服务。

具体动作和可用字段受 Compose 版本影响,应使用本机版本文档和:

docker compose version

进行确认。

2. 一个可运行的 Watch 示例

目录:

watch-demo/
├── compose.yaml
├── Dockerfile
└── app.py

app.py

from http.server import BaseHTTPRequestHandler, HTTPServer


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        body = b"hello from compose watch\n"
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format, *args):
        print(format % args)


HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()

Dockerfile

FROM python:3.12-slim

WORKDIR /app
COPY app.py /app/app.py

CMD ["python", "/app/app.py"]

compose.yaml

services:
  api:
    build:
      context: .
    ports:
      - "8000:8000"
    develop:
      watch:
        - action: sync+restart
          path: ./app.py
          target: /app/app.py
        - action: rebuild
          path: ./Dockerfile

启动 Watch:

docker compose up --watch

或者使用:

docker compose watch

不同 Compose 版本对这两个命令的交互方式可能略有差异,但它们都围绕 develop.watch 规则工作。首次执行时会先构建镜像并启动服务。

验证:

curl http://localhost:8000

预期输出:

hello from compose watch

然后修改 app.py 中的响应内容。Compose 会执行:

  1. 检测到 ./app.py 改变;
  2. 将该文件同步到容器的 /app/app.py
  3. 重启 api 容器;
  4. 新进程读取修改后的文件;
  5. 端口映射继续由更新后的容器提供服务。

修改 Dockerfile 时,匹配的是 rebuild 规则:

  1. 重新执行镜像构建;
  2. 创建基于新镜像的容器;
  3. 替换旧容器;
  4. 重新建立端口、网络和其他 Compose 声明的资源关系。

3. sync 不一定会使程序重新加载代码

如果改为:

- action: sync
  path: ./app.py
  target: /app/app.py

文件会更新,但正在运行的 Python 进程已经加载了旧代码。除非应用本身具备热重载机制,否则 HTTP 响应可能仍然来自旧版本。

因此动作的选择必须与进程生命周期匹配:

文件类型 常见动作 原因
模板、静态文件 sync 应用可能每次请求重新读取
支持热重载的源码 sync 由应用开发服务器负责重载
不支持热重载的源码 sync+restart 同步后需要重新启动进程
依赖清单 rebuild 依赖通常在镜像构建阶段安装
Dockerfile、系统包配置 rebuild 必须生成新镜像层

例如:

develop:
  watch:
    - action: sync
      path: ./templates
      target: /app/templates
    - action: sync+restart
      path: ./src
      target: /app/src
    - action: rebuild
      path: requirements.txt

rebuild 规则不需要像 sync 那样提供有效的容器目标路径,因为其目的不是把文件复制到正在运行的容器,而是以变化后的构建上下文重新构建镜像。

4. Watch 的路径边界

Watch 中的 path 通常是相对于 Compose 项目目录的路径,目标路径必须是容器内路径。一个常见错误是把宿主机绝对路径写成目标:

# 错误思路
target: /home/user/project/src

target 应该是容器内应用实际使用的路径,例如:

target: /app/src

还可以排除不应同步的目录:

develop:
  watch:
    - action: sync
      path: ./src
      target: /app/src
      ignore:
        - __pycache__/
        - "*.pyc"

排除目录很重要,因为构建产物、依赖目录和缓存目录可能引发大量无意义事件,甚至把容器生成的文件反向污染开发源目录。

5. Watch 的边界与故障表现

Watch 不是通用的文件系统同步系统。以下情况要单独处理:

  • 容器必须处于可管理的运行状态,服务已经退出时无法正常同步;
  • 目标路径必须存在或能被 Compose/容器正确创建;
  • 应用若在启动时只读取一次配置,单纯 sync 不会刷新内存状态;
  • 依赖的原生模块、系统包和编译工具通常必须通过 rebuild 更新;
  • 大量文件变化可能触发连续重建或重启;
  • Docker Desktop 下文件事件经过 VM 边界,性能和事件语义可能与原生 Linux 不同。

Watch 与 Bind mount 也不应同时管理同一个目标路径。例如:

services:
  api:
    volumes:
      - .:/app
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src

此时 /app/src 同时处于 Bind mount 的可见范围和 Watch 的同步目标内。即使配置可以被解析,实际更新路径也会变得难以推断,通常应选择一种机制,而不是叠加两种机制。


四、Override:合并基础配置与开发配置

1. Override 的作用

Compose 支持通过多个文件描述同一个项目。默认情况下,Compose 会读取:

compose.yaml
compose.override.yaml

并把后者作为覆盖文件合并到前者。

常见分层方式是:

compose.yaml              # 基础配置
compose.override.yaml     # 本地开发默认配置
compose.prod.yaml         # 生产或预发布差异
compose.debug.yaml        # 调试配置

显式指定文件:

docker compose \
  -f compose.yaml \
  -f compose.debug.yaml \
  up

文件顺序有因果意义:后面的文件覆盖或补充前面的文件。

也可以通过环境变量指定:

export COMPOSE_FILE=compose.yaml:compose.debug.yaml
docker compose config

Linux 使用冒号分隔路径;Windows 的路径分隔规则不同,应由 Compose 环境处理或使用对应平台格式。

2. 一个基础文件和开发 Override

基础文件 compose.yaml

services:
  api:
    build:
      context: .
    command: ["python", "/app/app.py"]
    ports:
      - "8000:8000"
    environment:
      APP_ENV: production

开发覆盖文件 compose.override.yaml

services:
  api:
    environment:
      APP_ENV: development
    volumes:
      - type: bind
        source: .
        target: /app
      - type: volume
        source: pycache
        target: /app/__pycache__

volumes:
  pycache:

执行:

docker compose config

最终的 api 同时具有:

  • 基础文件中的 buildcommandports
  • Override 中的开发环境变量;
  • Override 中的 Bind mount 和 Named volume。

执行:

docker compose up

会自动读取 compose.override.yaml。如果只想使用基础文件:

docker compose -f compose.yaml up

这两个命令的 Compose 模型不同,后者不会自动加入开发覆盖文件。

3. 合并不是简单的文本覆盖

Compose 合并发生在解析后的 YAML 结构上,而不是把第二个文件的文本粘贴到第一个文件末尾。

大体上:

  • 标量字段通常由后一个文件替换,例如 commandimage
  • 映射字段通常按键合并,例如 environmentlabels
  • 某些序列字段按 Compose 规则合并或去重;
  • volumesdevices 这类字段按容器目标路径具有特殊合并语义;
  • ports 具有端口元组语义,不应简单按普通字符串列表理解。

例如:

# compose.yaml
services:
  api:
    environment:
      LOG_LEVEL: info
      APP_ENV: production
    command: ["python", "app.py"]
# compose.override.yaml
services:
  api:
    environment:
      LOG_LEVEL: debug
    command: ["python", "-u", "app.py"]

结果通常是:

environment:
  LOG_LEVEL: debug
  APP_ENV: production
command:
  - python
  - -u
  - app.py

LOG_LEVEL 被替换,APP_ENV 保留,而 command 整体被替换。

4. 路径相对于第一个 Compose 文件解析

使用多个文件时,路径解析容易出错:

docker compose \
  -f deploy/compose.yaml \
  -f dev/compose.override.yaml \
  config

相对路径通常以第一个 Compose 文件所在的项目目录为基准,而不是分别以每个覆盖文件所在目录为基准。这意味着:

volumes:
  - ./src:/app/src

很可能指向:

deploy/../src

而不是 dev/src

正确做法是先使用 docker compose config 检查最终路径,并尽量把相关 Compose 文件放在同一个项目根目录附近,减少跨目录合并。

5. 删除基础配置中的字段

普通覆盖适合“替换或追加”,不适合表达“删除基础文件中的值”。现代 Compose 支持用于控制合并行为的特殊 YAML 标签,例如:

services:
  api:
    ports: !reset []

这表示清空基础文件中的端口列表。还存在更强制的覆盖语义:

services:
  api: !override
    image: my-debug-image
    command: ["python", "-m", "pdb", "app.py"]

这些标签属于 Compose 的扩展合并语义,要求使用支持它们的 Compose 版本。若团队环境版本不一致,先执行:

docker compose version

并在 CI 中固定或验证 Compose 版本,否则同一组文件可能在不同机器上表现不同。

6. Override 不等于继承 Dockerfile

Compose Override 只合并 Compose 模型,不会自动继承 Dockerfile 中的开发命令、依赖或工具。

例如,下面的 Override:

services:
  api:
    command: ["bash"]

只改变容器启动命令。如果镜像中没有 Bash,容器仍会启动失败:

exec: "bash": executable file not found

开发配置需要同时考虑:

  • 镜像是否包含调试器和 Shell;
  • command 的可执行文件是否存在;
  • 挂载是否遮挡了构建阶段复制的文件;
  • 非 root 用户是否有权限访问挂载目录。

五、Profile:让可选服务进入或离开当前模型

1. Profile 的定义

Profile 是 Compose 服务级别的可选激活条件:

services:
  api:
    image: example/api

  adminer:
    image: adminer
    profiles:
      - tools

没有指定 Profile 时:

docker compose up

通常只启动没有 profiles 字段的 apiadminer 不会进入当前默认服务集合。

启用 Profile:

docker compose --profile tools up

也可以使用环境变量:

COMPOSE_PROFILES=tools docker compose up

一次启用多个 Profile:

docker compose --profile tools --profile debug up

或者:

COMPOSE_PROFILES=tools,debug docker compose up

没有 profiles 字段的服务属于始终启用的服务;设置了 profiles 的服务只有在对应 Profile 激活时才可被默认启动。

2. Profile 是模型选择,不只是“容器暂停”

下面的服务:

services:
  mailhog:
    image: mailhog/mailhog
    profiles: ["debug"]

debug 未激活时,不只是容器没有运行,而是它通常不参与这次 Compose 操作的服务集合。因此其他服务不能把它当作始终存在的依赖端点。

例如:

services:
  api:
    image: example/api
    depends_on:
      - mailhog

  mailhog:
    image: mailhog/mailhog
    profiles: ["debug"]

如果 api 默认启动而 mailhog 未激活,这个依赖关系就可能造成模型无效或启动失败。正确的组织方式通常是让依赖方也属于同一个 Profile:

services:
  api:
    image: example/api
    profiles: ["debug"]
    depends_on:
      - mailhog

  mailhog:
    image: mailhog/mailhog
    profiles: ["debug"]

或者让 api 在所有环境都存在,但通过环境变量决定是否连接调试服务,而不是声明一个在默认模型中不存在的硬依赖。

3. 直接指定服务可能临时激活其 Profile

执行:

docker compose run --rm adminer

或:

docker compose up adminer

是在直接请求一个具体服务,而不是请求默认服务集合。Compose 对显式指定服务有特殊处理:目标服务的 Profile 可以被视为被直接选择,但它的依赖服务仍必须满足依赖关系和 Profile 约束。

因此不能把“直接指定服务”理解成“自动让所有相关 Profile 都永久启用”。要观察最终行为,使用:

docker compose --profile debug config --services
docker compose --profile debug ps

前者显示启用后的服务集合,后者显示已创建或运行的容器。

4. Profile 的典型用途

调试依赖服务:

services:
  api:
    build: .
    ports:
      - "8000:8000"

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example

  adminer:
    image: adminer
    profiles: ["tools"]
    ports:
      - "8080:8080"

  mailhog:
    image: mailhog/mailhog
    profiles: ["debug"]
    ports:
      - "8025:8025"

启动 API 和数据库,但不启动工具:

docker compose up

启动数据库、API 和 Adminer:

docker compose --profile tools up

启动所有已定义 Profile:

docker compose --profile "*" up

"*" 的含义是启用所有 Profile,而不是匹配服务名。


六、一个组合示例:基础配置、开发覆盖、Watch 和调试 Profile

下面的示例将几种机制组合起来。

1. 基础 Compose 文件

services:
  api:
    build:
      context: .
    command: ["python", "/app/app.py"]
    ports:
      - "8000:8000"
    environment:
      APP_ENV: production
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000')"]
      interval: 5s
      timeout: 2s
      retries: 5

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: example
      POSTGRES_DB: app
    volumes:
      - db-data:/var/lib/postgresql/data

  adminer:
    image: adminer
    profiles: ["tools"]
    ports:
      - "8080:8080"

volumes:
  db-data:

这里:

  • apidb 默认属于项目;
  • adminer 只有在 tools Profile 激活时才加入;
  • db-data 保存 PostgreSQL 数据;
  • healthcheck 检查的是容器内的 HTTP 服务,而不是宿主机端口。

2. 开发 Override

services:
  api:
    environment:
      APP_ENV: development
    develop:
      watch:
        - action: sync+restart
          path: ./app.py
          target: /app/app.py
        - action: rebuild
          path: Dockerfile

  db:
    environment:
      POSTGRES_PASSWORD: dev-password

默认执行:

docker compose up --watch

会读取:

compose.yaml
compose.override.yaml

并让 api 使用开发环境变量和 Watch 规则。

启用数据库管理工具:

docker compose --profile tools up --watch

如果显式使用 -f,仍需把两个文件都列出:

docker compose \
  -f compose.yaml \
  -f compose.override.yaml \
  --profile tools \
  up --watch

3. 配置验证顺序

先验证默认模型:

docker compose config --services

预期至少包含:

api
db

再验证工具 Profile:

docker compose --profile tools config --services

预期增加:

adminer

随后检查具体服务:

docker compose ps

若容器尚未创建,可能没有输出运行中的服务;这不表示配置为空,只表示尚未执行 up 或服务已经被删除。


七、调试 Compose:从模型到进程的逐层定位

1. 第一层:确认命令实际使用了哪些文件

docker compose config

重点检查:

  • 是否自动读取了 compose.override.yaml
  • 是否错误地使用了 -f 导致默认 Override 被绕过;
  • volumes 的宿主机路径是否正确;
  • command 是否被覆盖;
  • 环境变量是否完成插值;
  • Profile 服务是否出现在模型中。

例如:

docker compose --profile tools config --services

如果 adminer 不在输出中,问题发生在 Profile 选择阶段,还没有必要去看容器日志。

2. 第二层:确认容器状态和退出原因

docker compose ps

查看所有状态:

docker compose ps -a

关注:

  • Up:容器存在且主进程仍在运行;
  • Exited (0):进程正常退出,但服务可能不是长驻进程;
  • Exited (1) 或其他非零状态:应用启动或运行失败;
  • Restarting:重启策略可能形成循环;
  • health: startingunhealthy:容器进程仍在,但健康检查未通过。

日志:

docker compose logs api
docker compose logs -f api
docker compose logs --tail=100 api

logs 读取的是容器标准输出和标准错误。如果应用把错误写入容器内文件,Compose 日志不会自动显示该文件内容。

3. 第三层:进入正在运行的容器

docker compose exec api sh

exec 的前提是目标容器正在运行,并且容器内存在 sh。如果镜像是极简镜像,可能没有 Shell:

exec: "sh": executable file not found

此时可以执行已有程序:

docker compose exec api python -c "import os; print(os.getcwd())"

查看用户和文件:

docker compose exec api id
docker compose exec api pwd
docker compose exec api ls -la /app
docker compose exec api env | sort

如果容器没有运行,使用:

docker compose run --rm api python -c "import sys; print(sys.version)"

run 会为服务创建一个一次性容器,其行为不同于 exec

  • exec 进入现有容器,使用现有进程空间和挂载;
  • run 创建新的服务容器,适合执行迁移、诊断脚本或一次性命令;
  • run 默认不会发布服务的端口,若需要端口映射通常要加 --service-ports
  • run 可能使用不同的容器名称和生命周期,不能把它的结果完全等同于常驻服务。

4. 第四层:检查挂载是否遮挡了文件

docker inspect "$(docker compose ps -q api)" \
  --format '{{json .Mounts}}'

输出中应能看到类似:

[
  {
    "Type": "bind",
    "Source": "/path/to/project",
    "Destination": "/app"
  }
]

若容器中的文件与镜像预期不一致,应优先判断:

  1. /app 是否被 Bind mount 覆盖;
  2. 宿主机源目录中是否真的有该文件;
  3. 是否由子路径 Named volume 覆盖;
  4. 运行的是不是旧容器;
  5. Compose 文件是否使用了错误的 Override。

必要时重新创建:

docker compose up -d --force-recreate api

如果问题来自旧镜像:

docker compose up -d --build --force-recreate api

5. 第五层:检查网络和端口

查看端口映射:

docker compose port api 8000

查看网络:

docker network ls
docker compose ps
docker network inspect <project>_default

容器之间访问服务时,不应使用宿主机发布端口或 localhost

api 容器访问 db:
错误:localhost:5432
正确:db:5432

原因是容器内的 localhost 指向当前容器自身。Compose 默认网络会为服务提供基于服务名的 DNS,因此 db 解析到数据库容器。

api 容器测试数据库名称解析:

docker compose exec api getent hosts db

如果镜像中没有 getent,可以使用应用语言或数据库客户端验证。网络连通不等于应用可用:端口可达、认证成功、数据库已初始化是三个不同条件。

6. 第六层:区分容器存活和应用健康

健康检查示例:

healthcheck:
  test:
    [
      "CMD",
      "python",
      "-c",
      "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000')"
    ]
  interval: 5s
  timeout: 2s
  retries: 5

这里的检查在容器内部执行:

容器内 Python
    │
    └── 请求 127.0.0.1:8000

它不验证宿主机是否能通过 localhost:8000 访问,也不验证外部负载均衡器是否能访问。

查看健康状态:

docker inspect "$(docker compose ps -q api)" \
  --format '{{json .State.Health}}'

健康检查失败但容器仍然 Up,说明:

  • 主进程没有退出;
  • 应用没有满足健康条件。

这与容器直接 Exited 是两条不同的故障路径。


八、常见失败模式及其恢复方法

1. 修改文件后容器没有变化

按以下顺序检查:

docker compose config
docker compose ps
docker compose logs api

然后判断使用的是哪种机制:

  • Bind mount:检查宿主机路径是否挂载到正确目标;
  • Watch:检查是否通过 up --watchwatch 运行;
  • sync:检查应用是否具备热重载;
  • sync+restart:检查容器是否发生重启;
  • rebuild:检查构建上下文和 Dockerfile 是否真的包含变化文件。

如果使用 Bind mount,却把文件复制到了镜像中后又用挂载遮挡,容器看到的将是宿主机版本,不是镜像版本。

2. 修改依赖文件后依赖仍然是旧版本

例如:

develop:
  watch:
    - action: sync
      path: requirements.txt
      target: /app/requirements.txt

这只会更新文件,不会执行 pip install。更合适的是:

develop:
  watch:
    - action: rebuild
      path: requirements.txt

并在 Dockerfile 中安装依赖:

FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .
CMD ["python", "app.py"]

推导过程是:

  1. 依赖安装发生在构建阶段;
  2. sync 只改变运行中容器的文件;
  3. 已安装的软件包不会因为清单文件改变而自动变化;
  4. 因此依赖清单应触发 rebuild,或由应用启动脚本显式安装。

3. docker compose exec 失败

错误:

service "api" is not running

说明容器不存在或主进程已退出。执行:

docker compose ps -a
docker compose logs api

错误:

No such container

可能是:

  • 当前目录不是原来的 Compose 项目目录;
  • COMPOSE_PROJECT_NAME 改变;
  • 使用了不同的 -f 文件组合;
  • 容器已经被 down 删除。

错误:

executable file not found

说明容器内不存在所请求的程序。不要假设所有镜像都带有 bashcurlps

4. 端口绑定失败

错误:

Bind for 0.0.0.0:8000 failed: port is already allocated

含义是宿主机端口 8000 已被占用,而不是容器内应用一定有问题。检查:

docker ps --format 'table {{.Names}}\t{{.Ports}}'

Linux 上也可以检查:

ss -ltnp | grep ':8000'

开发 Override 中可以换宿主机端口:

ports:
  - "18000:8000"

此时:

  • 宿主机访问 localhost:18000
  • 容器内服务仍监听 8000
  • Compose 服务间通信仍使用容器端口和服务名。

5. 数据库看似“丢失”

执行:

docker compose down

默认会删除由该 Compose 项目创建的容器和网络,但通常不会删除 Named volume。若执行:

docker compose down -v

则会删除声明关联的 Named volume,数据库数据可能随之丢失。

恢复策略取决于数据来源:

  • Bind mount:检查宿主机目录是否仍在;
  • Named volume:确认是否执行过 down -v
  • 临时容器可写层:容器删除后数据通常消失。

查看卷:

docker volume ls
docker volume inspect <project>_db-data

开发环境清理前,应明确区分“删除容器”与“删除数据卷”。


九、Bind 与 Watch 的选择逻辑

可以用下面的条件判断:

选择 Bind mount,当:

  • 应用需要直接读取大量源码;
  • IDE、调试器或语言服务器需要看到容器内状态;
  • 你接受宿主机和容器共享文件系统视图;
  • 需要频繁访问生成文件或交互式修改文件。

代价是:

  • 容器内外权限耦合;
  • 宿主机生成的依赖可能与 Linux 容器不兼容;
  • 大型目录的文件事件和 I/O 可能较慢;
  • 挂载会遮挡镜像内容。

选择 Watch,当:

  • 希望只同步源码或特定目录;
  • 希望依赖和系统工具留在镜像中;
  • 希望代码变化触发可控的同步、重启或重建;
  • 不希望整个项目目录成为容器挂载点。

代价是:

  • 需要较新的 Compose 版本;
  • 必须为不同文件类型选择正确动作;
  • 应用是否热重载仍由应用进程决定;
  • 复杂项目需要认真设计忽略规则和重建边界。

二者的关键差异可以形式化为:

Bind:读取路径 = 宿主机挂载视图
Watch:读取路径 = 镜像文件系统 + Compose 复制结果

如果目标是“容器始终直接看到宿主机目录”,Bind 更直接;如果目标是“宿主机变化触发一套明确的更新动作”,Watch 更容易表达生命周期。


十、开发配置与生产边界

开发配置往往包含:

volumes:
  - .:/app

或:

develop:
  watch:
    - action: sync+restart

这些配置适合快速迭代,但不应默认带入生产,原因包括:

  1. 生产容器的代码应来自已审计、可追踪的镜像;
  2. Bind mount 会让容器内容依赖宿主机目录;
  3. Watch 依赖开发机文件事件,不是生产部署控制器;
  4. 开发 Override 可能开放调试端口或挂载敏感源码;
  5. profiles 只是 Compose 模型选择,不是生产权限边界;
  6. Compose 的重建和替换不等价于完整的发布、回滚和流量治理。

可以把生产命令明确写成:

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

而不是依赖当前目录下是否存在自动读取的 compose.override.yaml。这样可以减少开发配置被意外合并的可能性。

同时,生产配置应显式验证:

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

再执行:

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

开发工作流追求短反馈周期;生产工作流追求可复现、可审计和可恢复。Bind、Watch、Override 和 Profile 都是 Compose 的配置与开发能力,不能单独替代镜像发布、密钥管理、备份、滚动更新或故障回滚机制。


系列导航与关联阅读

官方资料

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