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

Docker 在 Linux、macOS 与 Windows 的差异:VM、路径、网络和性能

Docker 的命令行体验在 Linux、macOS 和 Windows 上很相似,但容器实际运行的位置可能完全不同。理解这种差异的关键,不是记住某个平台要写哪条命令,而是先区分三个角色:

  • Docker CLI:负责发送 API 请求,例如 docker rundocker build
  • Docker daemon:负责创建容器、管理镜像、挂载文件系统、配置网络和执行构建。
  • 容器运行时:通常由 containerd、runc 等组件完成容器进程的创建。

命令最终由 daemon 执行,而不是由当前打开终端的 CLI 进程执行。因此,路径、端口、卷和构建上下文的实际含义,首先取决于 daemon 位于哪里。

在原生 Linux 上,CLI 和 daemon 通常都运行在同一个 Linux 主机;在 macOS 和 Windows 上,Docker Desktop 通常通过一个 Linux 虚拟机运行 Linux 容器。于是,同样的 docker run 命令,在不同平台上可能经过不同的数据路径。


一、先确定 Docker daemon 到底运行在哪里

1. Linux 原生 Engine:容器直接使用 Linux 内核

Linux 容器不是一个完整的虚拟机。容器进程通常由宿主机 Linux 内核直接创建,并通过以下内核机制隔离:

  • namespace:隔离进程、网络、挂载点、用户等视图;
  • cgroup:限制和统计 CPU、内存、进程数等资源;
  • capabilities、seccomp、LSM:限制进程能够执行的特权操作;
  • overlayfs 等存储驱动:组织镜像层和容器可写层。

例如:

docker run --rm alpine uname -a

在 Linux 主机上,容器中的 uname 通常看到的是宿主机 Linux 内核版本:

Linux 8f3c... 6.x.x ... x86_64 Linux

这里的 Linux 不是一个被虚拟出来的独立内核。容器拥有独立的进程和文件系统视图,但不拥有独立内核。

这也决定了 Linux 容器的边界:

Linux 容器需要 Linux 内核提供容器所依赖的内核接口;Docker 本身不会把 Linux 内核翻译成 macOS 或 Windows 内核。

因此,在 macOS 和 Windows 上运行 Linux 容器时,Docker Desktop 必须额外启动一个 Linux VM。

2. macOS:Docker Desktop 中的 Linux VM

macOS 的 Darwin/XNU 内核不是 Linux 内核,不能直接运行普通 Linux 容器。Docker Desktop 会创建一个 Linux 虚拟机,并在其中运行 Docker daemon 和 Linux 容器。

典型路径是:

docker CLI
    │
    │ Docker API
    ▼
Docker Desktop 管理组件
    │
    ▼
Linux VM 中的 dockerd/containerd
    │
    ▼
Linux 容器

macOS 上 VM 的具体虚拟化实现、文件共享组件和网络实现会随 Docker Desktop 版本、硬件架构以及设置变化。可以确定的是:Linux 容器实际使用的是 VM 中的 Linux 内核,而不是 macOS 内核

因此下面的命令:

docker run --rm alpine uname -a

在 macOS 上看到的是 Linux 内核信息,而不是 Darwin:

Linux ...

macOS 主机上的终端只是 Docker CLI 的运行位置。容器进程并不直接作为 macOS 进程运行。

3. Windows:Linux 容器与 Windows 容器是两条不同边界

Windows 上需要区分两种容器:

Linux 容器

Docker Desktop 通常通过以下方式之一提供 Linux 内核:

  • WSL 2 后端;
  • Hyper-V 虚拟机后端;
  • 具体版本支持的其他 Linux VM 集成方式。

在 WSL 2 后端中,Linux 环境由 WSL 2 虚拟化提供;在 Hyper-V 后端中,则由独立的 Linux VM 提供。实现细节和可选项与 Docker Desktop 版本有关,但共同点仍然是:

Windows CLI
    │
    ▼
Docker Desktop / WSL2 或 Hyper-V
    │
    ▼
Linux daemon
    │
    ▼
Linux 容器

Windows 容器

Windows 容器使用 Windows 容器镜像和 Windows 内核能力。它不是“Linux 容器换了一种启动参数”,而是另一套容器平台。

例如,Linux 镜像:

docker run --rm alpine sh

只能在 Linux 容器环境中运行。切换到 Windows containers 后,通常需要使用 Windows 基础镜像,并且命令、文件系统语义、进程模型和镜像兼容性都会变化。

Linux 容器镜像和 Windows 容器镜像不能因为 CPU 架构相同就互相运行。容器镜像至少需要匹配以下条件:

  1. 容器用户态所需的操作系统 ABI;
  2. 容器运行时可用的内核能力;
  3. CPU 架构,例如 amd64arm64

二、使用 Docker context 判断命令实际发往哪里

Docker CLI 可以连接不同的 daemon。不要仅凭“我在 Linux/macOS/Windows 终端中执行命令”判断容器在哪里运行。

查看当前 context:

docker context ls

可能看到类似结果:

NAME              DESCRIPTION                               DOCKER ENDPOINT
default *         Current DOCKER_HOST based configuration   unix:///var/run/docker.sock
desktop-linux     Docker Desktop                           unix:///...

查看 daemon 详情:

docker info

重点观察:

  • Operating System
  • Kernel Version
  • OSType
  • Architecture
  • Docker Root Dir
  • 存储驱动
  • cgroup 版本
  • 当前 daemon 的资源限制

查看客户端和服务器端版本:

docker version

其中 Client 表示 CLI,Server 表示 daemon。两者可以不在同一个操作系统上,甚至不在同一台机器上。

例如,远程 context 的路径语义是:

CLI 所在机器上的命令
        │
        ▼
远程 daemon 所在机器解析路径并创建挂载

因此:

docker -H ssh://server.example.com run \
  --mount type=bind,src="$PWD",dst=/src \
  alpine ls /src

这里的 src 必须在远程 daemon 能访问的位置存在。它不是自动把本地电脑的当前目录上传到远程服务器。


三、路径差异:路径由 daemon 解释,而不是由容器解释

1. Bind mount 的形式化模型

Bind mount 是把 daemon 所在主机上的一个已有路径挂载到容器路径。可以把它表示为:

H: 宿主机文件系统
D: Docker daemon 所在环境
C: 容器文件系统命名空间
S: 宿主机源路径
T: 容器目标路径

绑定挂载成立的条件是:

S ∈ Filesystem(D)

而不是:

S ∈ Filesystem(CLI)

挂载完成后,容器中的路径关系近似为:

C/T  →  D/S

如果容器目标目录原来有内容,这些内容会被挂载遮蔽:

镜像中的 /app
        │
        ├── main.py
        └── config.yaml

挂载主机目录到 /app 后:

容器看到的 /app
        │
        └── 主机目录内容

原有镜像文件没有被删除,但在挂载解除前不可见。

2. Linux 原生路径

Linux 常见路径是:

docker run --rm \
  --mount type=bind,src="$PWD",dst=/workspace \
  alpine ls -la /workspace

前置条件是当前目录存在,并且 daemon 具有访问权限。Linux shell 中,$PWD 由 shell 展开为绝对路径,然后 Docker CLI 将路径交给 daemon。

更明确的写法是:

docker run --rm \
  --mount type=bind,src="$(pwd)",dst=/workspace,readonly \
  alpine find /workspace -maxdepth 1 -type f

readonly 只限制容器通过该挂载写入,不能阻止宿主机上的其他进程修改文件。

--mount-v 有一个容易造成故障的差异:

docker run --rm \
  --mount type=bind,src=/does/not/exist,dst=/data \
  alpine true

通常会因为源路径不存在而报错。

而短语法:

docker run --rm \
  -v /does/not/exist:/data \
  alpine true

在许多 Docker Engine 场景下会自动创建源路径,并且往往创建成目录。这样拼写错误就可能被静默转换为一个空目录,导致容器启动成功但读不到预期文件。

3. macOS 路径:本地路径需要跨 VM 文件共享

macOS 中:

docker run --rm \
  --mount type=bind,src="$PWD",dst=/workspace \
  alpine ls -la /workspace

表面上和 Linux 相同,但实际过程包含额外的一层:

macOS ~/project
    │
    │ Docker Desktop 文件共享机制
    ▼
Linux VM 中可访问的共享路径
    │
    ▼
容器 /workspace

这会带来三个重要后果。

文件共享权限是独立边界

如果 Docker Desktop 没有获得访问某个目录的权限,容器可能启动失败,或者挂载结果不符合预期。macOS 的隐私控制也可能限制终端或 Docker Desktop 访问“桌面”“文稿”等目录。

文件系统事件不是完全等价的

Linux 工具通常依赖 inotify 监听文件变化。macOS 主机文件变更需要通过 Docker Desktop 的共享机制传入 Linux VM,再转发给容器。某些开发服务器可能出现:

  • 文件修改后不能自动重载;
  • 事件延迟;
  • 事件数量较多时丢失或降级为轮询;
  • 大量小文件扫描明显变慢。

这不是容器进程逻辑错误,而是跨文件系统边界转发事件的结果。

UID/GID 语义不同

Linux 容器通常以数字 UID/GID 访问文件。例如容器中的用户 1000:1000,在 Linux bind mount 上会直接与宿主机的数字所有权产生关系。

macOS 主机文件系统并不以 Linux 容器所理解的方式提供完整的 Unix 所有权语义。Docker Desktop 会在共享层处理权限映射,因此容器内的 chown、权限检查和宿主机 Finder 中显示的所有权之间不一定是一一对应关系。

4. Windows 路径:驱动器、反斜杠和 WSL 路径不是一回事

Windows 主机路径常见形式有:

C:\Users\alice\project

PowerShell 中当前目录可以通过:

${PWD}

获取。使用 Docker CLI 时,推荐先验证解析结果:

docker run --rm `
  --mount type=bind,src="${PWD}",dst=/workspace `
  alpine ls -la /workspace

PowerShell 的反引号是续行符;如果复制到 cmd.exe 或 Bash 中,语法需要改变。

在 Windows 上常见的路径错误包括:

  • C:\project 直接写进 Bash 风格脚本;
  • /mnt/c/project 当成所有 Docker 后端都能直接理解的路径;
  • 把 WSL 内部路径、Windows 路径和 Docker Desktop 共享路径混用;
  • 在 Compose YAML 中使用未转义的反斜杠;
  • 主机路径包含冒号时被错误解析为 源:目标 分隔符。

Compose 的长语法可以减少短语法歧义:

services:
  app:
    image: alpine
    command: ["sh", "-c", "ls -la /workspace && sleep 3600"]
    volumes:
      - type: bind
        source: ./project
        target: /workspace
        read_only: true

相对路径通常按 Compose 项目文件所在目录解析,而不是按容器工作目录解析。执行前可以查看 Compose 展开的结果:

docker compose config

这一步能发现环境变量、相对路径和合并文件展开后的实际配置。

5. 大小写敏感性是重要的可移植性边界

Linux 文件系统通常区分:

src/App.js
src/app.js

它们是两个不同路径。Windows 默认 NTFS 配置通常大小写不敏感,macOS 默认文件系统也常见大小写不敏感配置,但两者不等于“永远不区分大小写”。

因此,以下代码可能在 macOS 或 Windows 上正常,在 Linux CI 或生产环境失败:

import App from "./app.js";

实际文件却是:

App.js

当源码通过 bind mount 进入 Linux 容器时,容器侧的 Linux 工具链可能仍然受主机共享层行为影响;构建、Git 检出和运行时解析也可能产生差异。可移植项目应避免仅靠大小写差异区分文件名,并在 Linux CI 上验证。


四、Bind mount 的深层边界:传播、递归、只读和符号链接

1. 只读不是“不可见”,也不是宿主机整体只读

docker run --rm \
  --mount type=bind,src="$PWD",dst=/src,readonly \
  alpine sh -c 'echo test >/src/x'

容器中的写入应失败,例如:

sh: can't create /src/x: Read-only file system

但这不表示:

  • 宿主机上的其他进程不能写入 $PWD
  • 挂载源路径下的所有相关挂载都自动变成只读;
  • 容器不能修改其他未设置只读的挂载;
  • 文件系统本身一定具备所有预期的只读语义。

2. 挂载传播主要是 Linux 原生能力

挂载传播决定一个挂载命名空间中的子挂载变化,是否传播到另一个命名空间。常见模式包括:

  • private
  • rprivate
  • shared
  • rshared
  • slave
  • rslave

其中 r 表示递归地作用于子挂载。

例如,一个容器内启动 systemd、挂载 loop device 或创建嵌套挂载时,是否能让挂载变化传播回宿主机,取决于源挂载的传播属性、内核支持和容器权限。

Linux Engine 上可以显式指定:

docker run --rm \
  --mount type=bind,src=/var/lib/mydata,dst=/data,bind-propagation=rshared \
  alpine mount

这不是普通的“目录共享”开关,而是 Linux mount namespace 的属性。

在 Docker Desktop 上,容器运行在 Linux VM 中,macOS 或 Windows 主机目录先经过文件共享机制进入 VM。宿主机原生挂载命名空间并不直接等同于 VM 内的 Linux mount namespace,因此不能把 Linux 原生的 mount propagation 预期套用到 Docker Desktop 文件共享上。需要传播挂载时,应优先使用 Linux 主机上的 Docker Engine,并在目标内核上验证。

3. 递归只读依赖实现与内核能力

普通:

--mount type=bind,src=/data,dst=/data,readonly

主要限制目标挂载本身。源目录下面如果存在独立子挂载,是否也被递归设置为只读,取决于 Docker Engine、Linux 内核和挂载实现对递归只读的支持。不能把普通 readonly 自动理解成所有嵌套挂载都只读。

4. 符号链接可能越出预期目录

假设宿主机目录中存在:

project/
└── secret-link -> /home/alice/.ssh

project bind mount 到容器后,容器访问:

cat /workspace/secret-link/id_rsa

可能解析到挂载源之外的路径。符号链接不是目录边界。是否能成功访问还取决于挂载、权限和路径在 daemon 环境中的实际存在,但安全审查不能只检查目录名,还必须检查符号链接和实际解析路径。

在 Docker Desktop 中,符号链接还会受到 Windows/macOS 主机文件系统、共享层和 VM 内路径映射的共同影响。一个在 Linux 主机上成立的符号链接,不一定在 Desktop VM 中指向同样的位置。


五、数据卷的位置:named volume 不等于当前目录中的文件夹

Named volume 由 Docker 管理:

docker volume create app-data
docker run --rm \
  --mount type=volume,src=app-data,dst=/var/lib/app \
  alpine sh -c 'echo hello >/var/lib/app/message'

查看它的元数据:

docker volume inspect app-data

Linux 原生 Engine 可能显示类似:

[
  {
    "Name": "app-data",
    "Mountpoint": "/var/lib/docker/volumes/app-data/_data"
  }
]

这个路径位于 Linux daemon 所在主机。

在 Docker Desktop 中,named volume 通常位于 Linux VM 的文件系统中。docker volume inspect 显示的路径是 daemon 视角的路径,不一定能直接在 macOS Finder 或 Windows Explorer 中打开。

这解释了 named volume 与 bind mount 的主要差异:

bind mount:
宿主机明确路径 ──挂载──> 容器路径

named volume:
Docker 管理的数据对象 ──挂载──> 容器路径

数据库、包管理器缓存等大量小文件通常更适合放在 named volume 或 VM 内部文件系统中,而不是直接放在 macOS/Windows 主机目录的 bind mount 中。原因不是 volume “天然更快”,而是它避免了频繁穿越主机文件共享边界。


六、网络差异:Linux bridge 与 Desktop 双层网络

1. Linux 原生网络路径

默认 bridge 网络中,容器通常拥有独立的 network namespace 和虚拟网卡。典型组件关系是:

容器 eth0
   │
   ▼
veth pair
   │
   ▼
docker0 bridge
   │
   ▼
Linux 主机网络栈
   │
   ├── NAT/iptables/nftables
   └── 物理网卡

容器访问外网时,通常经过 bridge、主机路由和地址转换。容器访问同一 user-defined network 中的其他容器时,则通过 Docker 内置 DNS 解析服务名。

例如:

docker network create app-net

docker run -d --name web --network app-net nginx
docker run --rm --network app-net alpine \
  wget -qO- http://web

这里 web 是 Docker 网络中的服务名。容器不应依赖另一个容器的动态 IP;服务名由 Docker 网络的 DNS 机制解析。

2. macOS/Windows Linux 容器的额外一层

Docker Desktop 中,容器网络至少包含 Linux VM 这一层:

容器
  │
  ▼
VM 内 bridge 和 Linux 网络栈
  │
  ▼
Docker Desktop 虚拟网络
  │
  ▼
macOS/Windows 主机网络栈
  │
  ▼
物理网络

因此,Linux 原生下看到的容器网桥、地址和路由,不一定能在主机系统中直接看到。你在 macOS 上运行:

ifconfig

或在 Windows 上运行:

ipconfig

通常不会看到一个等价于 Linux 原生 docker0 的主机网桥。这个网桥位于 Desktop 的 Linux 环境中。

3. 发布端口的真实含义

下面的命令:

docker run -d --name web -p 8080:80 nginx

表示把 daemon 所在环境的 TCP 8080 转发到容器的 TCP 80

客户端访问 daemon-host:8080
        │
        ▼
Docker 端口转发
        │
        ▼
容器 web:80

在 Linux 原生 Engine 上,daemon-host 通常就是 Linux 主机。

在 macOS/Windows Desktop 上,端口还需要由 Docker Desktop 从主机转发到 Linux VM,再由 VM 转发到容器。因此客户端可以访问:

http://localhost:8080

但这里的 localhost 是客户端所在的 macOS/Windows 主机,而不是容器。

端口发布只对发布端口生效:

docker run -d --name web nginx

如果没有 -p,容器内部虽然监听 80,但主机的 localhost:80 不会因此自动可访问。容器之间应使用 Docker 网络和容器端口,例如:

http://web:80

而不是使用主机映射端口。

4. localhost 的三种含义

在以下位置执行请求时,localhost 的含义不同:

执行位置 localhost 指向
容器内 当前容器自身
Linux 主机上 Linux 主机自身
macOS/Windows 主机上 macOS/Windows 主机自身

例如,容器中的应用监听:

127.0.0.1:8080

只能接受当前容器内的连接。即使发布了端口,Docker 也无法把外部请求转发给一个只绑定容器 loopback 的服务。要接受容器外部连接,应用通常需要监听:

0.0.0.0:8080

0.0.0.0 是监听地址,不是客户端访问地址。客户端仍应通过已发布的主机端口访问。

5. 从容器访问宿主机

host.docker.internal 是 Docker Desktop 常用的特殊主机名:

docker run --rm alpine ping -c 1 host.docker.internal

它用于让 Desktop 中的容器访问 macOS 或 Windows 主机上的服务。这个名称不是跨所有 Docker Engine 都由规范保证的通用 DNS 名称。

Linux 原生 Engine 上,如果希望使用相同名称,可以在支持 host-gateway 的现代 Engine 中显式添加:

docker run --rm \
  --add-host host.docker.internal:host-gateway \
  alpine getent hosts host.docker.internal

也可以使用 Docker bridge 的网关地址,但网关地址、主机防火墙和 daemon 配置需要具体验证。生产环境不能假设“容器一定能访问宿主机所有端口”;主机监听地址、防火墙、VPN 和安全策略都可能阻断连接。

6. host network 不是跨平台等价能力

Linux 上:

docker run --rm --network host alpine ip addr

会让容器使用主机网络命名空间,而不是使用普通独立 network namespace。此时 -p 通常没有普通 bridge 模式下的意义。

Docker Desktop 中,Linux 容器位于 Linux VM 内。即使某些较新版本提供了经过配置或需要显式启用的 host networking 能力,它也不应被直接理解为“容器与 macOS/Windows 主机共享同一个网络命名空间”。具体行为必须以当前 Docker Desktop 版本和设置为准。


七、性能差异:先区分计算、存储、网络和构建

性能不能简单概括为“Linux 快、Desktop 慢”。更准确的模型是:

总耗时 =
  CPU 执行时间
+ 内存与调度开销
+ 文件系统访问时间
+ 文件事件同步时间
+ 网络路径开销
+ 镜像构建与缓存开销

不同工作负载中,各项占比不同。

1. CPU 和内存

Linux 原生容器直接使用宿主机 Linux 内核调度,通常没有额外的完整 VM 边界。Docker Desktop 的 Linux 容器需要经过 VM,因此存在:

  • VM 的 CPU 调度层;
  • VM 内存上限或动态回收;
  • 主机与 VM 之间的资源竞争;
  • Docker Desktop 后台组件本身的资源占用。

这不意味着所有 CPU 密集型任务都会明显变慢。对于主要在容器内部执行的 CPU 计算,虚拟化开销可能小于文件系统和构建上下文开销;但当程序频繁访问共享目录、进行大量小文件操作或触发大量文件事件时,差异通常更明显。

需要区分资源限制来源:

docker inspect <container>
docker stats
docker info

docker stats 显示容器视角的运行时统计;Docker Desktop 的 VM 资源设置又决定了容器所在 Linux 环境可用的总体资源。一个容器没有设置 CPU 限额,不代表它可以无限使用 Windows/macOS 主机的全部资源。

2. 文件 I/O:开发工作负载的主要差异来源

假设一次操作访问 NN 个小文件,每个文件需要执行元数据查询、打开、读取和关闭。可以粗略表示为:

TN×(tmetadata+topen+tevent)+TdataT \approx N \times (t_{\text{metadata}} + t_{\text{open}} + t_{\text{event}}) + T_{\text{data}}

其中:

  • tmetadatat_{\text{metadata}}:查询目录项和属性的时间;
  • topent_{\text{open}}:打开文件的时间;
  • teventt_{\text{event}}:文件变化事件跨边界传递的时间;
  • TdataT_{\text{data}}:真正传输文件内容的时间。

在 Linux 原生 Engine 中,bind mount 通常直接访问 Linux 文件系统。macOS/Windows Desktop 的 bind mount 可能多出:

主机文件系统
 → 文件共享协议或集成层
 → Linux VM 文件系统
 → 容器挂载点

NN 很大而单个文件很小时,固定的元数据和事件开销会被重复放大。典型受影响场景包括:

  • node_modules
  • PHP Composer 依赖目录;
  • Python 虚拟环境;
  • JavaScript 文件监听器;
  • 大型单体仓库的全量扫描;
  • Git 状态检查和 IDE 索引。

这就是为什么一种常见的折中方式是:

源码:bind mount,便于主机编辑
依赖和构建缓存:named volume 或 VM 内部路径

示例 Compose 配置:

services:
  app:
    image: node:22
    working_dir: /workspace
    command: ["sh", "-c", "npm install && npm run dev"]
    volumes:
      - type: bind
        source: .
        target: /workspace
      - type: volume
        source: node_modules
        target: /workspace/node_modules

volumes:
  node_modules:

这里源码仍然从主机同步到容器,但 /workspace/node_modules 被 named volume 覆盖,因此依赖文件不必频繁穿越主机文件共享层。

这个配置也有一个边界:如果 package.json、锁文件或 Node 版本变化,必须重新执行安装或清理 volume。named volume 不会自动理解项目依赖是否已经过期。

3. 镜像构建:BuildKit 仍然受构建上下文位置影响

现代 Docker 构建通常使用 BuildKit。执行:

docker build -t demo .

. 是构建上下文。Docker CLI 或相关客户端需要把上下文提供给 daemon;BuildKit 再根据 Dockerfile 使用其中的文件。

上下文过大时,构建开始前就可能产生明显延迟。使用 .dockerignore 降低上下文规模:

.git
node_modules
dist
coverage
*.log

构建过程可以理解为:

构建目录
    │
    ├── 读取 .dockerignore
    ▼
构建上下文
    │
    ▼
BuildKit daemon / builder
    │
    ├── 读取 Dockerfile
    ├── 查询缓存
    ├── 执行 RUN
    └── 生成镜像层

在 Linux 原生 Engine 上,BuildKit 通常直接操作 Linux 文件系统。在 Docker Desktop 上,BuildKit builder 往往位于 Linux VM 中;如果上下文来自 macOS/Windows bind-mounted 目录,读取大量文件的成本会受到共享机制影响。

可以用以下命令确认构建器和 daemon 状态:

docker buildx ls
docker buildx inspect --bootstrap

输出中的 builder 驱动、平台和节点信息比单看 docker build 命令更能说明实际构建位置。

4. 多架构构建与虚拟化

现代 BuildKit 支持多平台构建,例如:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t registry.example.com/demo:latest \
  --push .

这条命令中的两个平台表示镜像目标平台,不表示当前主机一定原生执行了两种架构的构建步骤。

如果目标架构与 builder CPU 架构不同,构建中的 RUN 可能依赖 QEMU 用户态模拟。模拟会影响 CPU 密集型构建步骤,尤其是编译大型项目。需要区分:

  • linux/amd64linux/arm64:镜像目标平台;
  • builder 节点架构:实际执行 BuildKit 步骤的环境;
  • QEMU 模拟:在不同架构上执行二进制的兼容机制。

Windows/macOS 上还要叠加 Linux VM;因此多架构构建失败时,应先查看:

docker buildx inspect --bootstrap
docker version
docker info

不要仅根据宿主机系统名称判断构建能力。


八、端到端示例:用 Compose 同时验证路径、网络和卷

下面的配置可在 Linux、macOS 和 Windows 的 Linux containers 模式下使用:

services:
  api:
    image: python:3.12-alpine
    working_dir: /app
    command:
      - sh
      - -c
      - |
        python -m http.server 8000 --bind 0.0.0.0
    ports:
      - "127.0.0.1:8000:8000"
    volumes:
      - type: bind
        source: ./public
        target: /app
        read_only: true
      - type: volume
        source: api-cache
        target: /cache

volumes:
  api-cache:

准备目录和文件:

mkdir -p public
printf 'hello from container\n' > public/index.html
docker compose up -d

Windows PowerShell 中可以使用:

New-Item -ItemType Directory -Force public
"hello from container" | Set-Content public/index.html
docker compose up -d

验证:

curl http://127.0.0.1:8000

预期输出:

hello from container

每一步成立的原因如下:

  1. source: ./public 按 Compose 项目路径解析为主机目录;
  2. 该目录以只读方式挂载到容器 /app
  3. Python 监听 0.0.0.0:8000,因此能接受容器外请求;
  4. 127.0.0.1:8000:8000 只把主机 loopback 上的端口发布出来;
  5. named volume api-cache 由 Docker 管理,不依赖主机路径;
  6. macOS/Windows 上,主机目录还要通过 Docker Desktop 文件共享进入 Linux VM。

查看最终 Compose 配置:

docker compose config

查看挂载:

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

查看端口:

docker compose port api 8000

可能输出:

127.0.0.1:8000

清理容器和网络:

docker compose down

如果同时删除 named volume:

docker compose down -v

-v 会删除 Compose 管理的 named volume。若该卷中存放数据库数据,删除前必须确认已有备份;bind mount 中的主机文件不会因为 down -v 被删除。


九、常见失败表现与诊断路径

1. 容器中目录为空

先检查挂载是否真的存在:

docker inspect <container> \
  --format '{{range .Mounts}}{{println .Type .Source "->" .Destination}}{{end}}'

如果 Source 不是预期目录,重点检查:

  • 当前 Docker context;
  • Compose 相对路径;
  • Windows shell 的变量展开;
  • -v 是否因源路径拼写错误自动创建了空目录;
  • Docker Desktop 是否允许访问该主机目录。

2. 文件修改后容器不重载

分层诊断:

docker exec <container> sh -c 'ls -l /workspace'

确认容器确实看到新文件,然后检查应用使用的文件监听机制。若目录来自 macOS/Windows bind mount:

  • 尝试应用的 polling 模式;
  • 将依赖和缓存移到 named volume;
  • 减少被监听的文件数量;
  • 在 Linux CI 或 Linux 主机上复现。

不能只通过重启容器判断问题是否解决,因为重启可能暂时重建了应用内部缓存,但没有改变文件事件路径。

3. 容器无法访问宿主机服务

先验证名称解析:

docker exec <container> getent hosts host.docker.internal

再验证端口:

docker exec <container> \
  wget -S -O- http://host.docker.internal:9000

如果解析成功但连接失败,检查:

  • 宿主机服务是否只监听 127.0.0.1
  • 宿主机防火墙;
  • Docker Desktop 或 Linux daemon 的网络模式;
  • VPN 是否改变了路由;
  • Linux 原生环境是否配置了 host-gateway

4. 主机访问不到已发布端口

检查端口发布:

docker ps
docker port <container>

然后检查容器内监听地址:

docker exec <container> sh -c 'cat /proc/net/tcp'

更直接的方式是进入应用容器或使用诊断镜像确认服务是否监听 0.0.0.0,而不是只监听 127.0.0.1

还要区分:

-p 127.0.0.1:8080:80

和:

-p 0.0.0.0:8080:80

前者只允许本机访问,后者可能允许其他网络主机访问,实际还受主机防火墙控制。

5. 权限错误

Linux 原生 bind mount 上,容器进程的 UID/GID 直接影响主机文件权限:

docker run --rm \
  --user "$(id -u):$(id -g)" \
  --mount type=bind,src="$PWD",dst=/workspace \
  alpine sh -c 'touch /workspace/from-container'

这种写法可以减少容器以 root 创建主机文件的问题,但要求镜像内程序能以该 UID 正常运行。

macOS/Windows Desktop 上,权限由主机文件系统、Docker Desktop 共享层和 Linux VM 内映射共同决定。此时简单地在容器中执行:

chown -R app:app /workspace

不一定能得到与 Linux 原生主机相同的结果,而且可能造成大量文件操作和额外同步成本。


十、平台取舍与运行边界

Linux 原生 Engine 适合直接使用 Linux 内核能力

当任务依赖以下能力时,Linux 原生 Engine 通常边界最清晰:

  • mount propagation;
  • 精确的 Linux UID/GID 和文件权限;
  • 宿主机网络命名空间;
  • 内核模块、设备和 cgroup 行为;
  • 大量文件 I/O;
  • 直接访问宿主机 Linux 文件系统;
  • 需要观察和控制 Linux daemon 的底层网络、存储和进程状态。

但 Linux Engine 的 daemon 通常拥有较高权限,尤其是以 root 运行时。bind mount //var/run/docker.sock 等路径可能扩大容器对宿主机的控制范围。容器隔离不是自动形成的强多租户安全边界。

macOS/Windows Desktop 适合本地开发,但边界包含 VM

Docker Desktop 的 VM 带来明确的隔离层,也带来额外的:

  • 文件共享边界;
  • VM 资源配置;
  • 主机到 VM 的网络转发;
  • Desktop 特权组件和企业策略;
  • named volume 的 VM 内存储位置。

向容器开放主机目录,本质上是把主机文件访问能力交给容器。共享范围、访问权限、企业设备管理和 Docker socket 的使用都应纳入安全边界审查。

Windows 容器不能作为 Linux 容器的透明替代品

如果生产环境使用 Linux 容器,Windows 主机上的开发验证应明确处于 Linux containers 模式。切换到 Windows containers 后,以下内容都可能改变:

  • 基础镜像;
  • shell 和路径;
  • 文件权限;
  • 进程和服务管理;
  • 网络与存储实现;
  • Dockerfile 中的命令。

“Docker 命令相同”只说明 CLI API 相似,不说明容器运行时、内核和文件系统语义相同。


十一、一个可靠的判断顺序

遇到平台差异问题时,可以按以下因果顺序排查:

1. 当前 CLI 连接哪个 daemon?
        │
        ▼
2. daemon 是 Linux 主机、Linux VM 还是 Windows daemon?
        │
        ▼
3. 源路径由谁解析?是否存在文件共享层?
        │
        ▼
4. 容器使用哪种网络模式?端口绑定在哪台主机?
        │
        ▼
5. 数据位于 bind mount、named volume 还是镜像层?
        │
        ▼
6. 性能瓶颈来自 CPU、文件 I/O、事件同步还是构建上下文?

对应的最小诊断命令是:

docker context show
docker info
docker version
docker inspect <container>
docker volume inspect <volume>
docker network inspect <network>
docker buildx ls

其中最容易被忽略的是第一步:如果 daemon 实际位于远程主机或 Docker Desktop 的 Linux VM 中,那么后续关于路径、网络和性能的判断都必须从 daemon 的视角出发。

Docker 在三种桌面操作系统上的命令可以保持一致,但容器并不因此拥有相同的内核、路径、网络和 I/O 环境。Linux 原生 Engine 的容器直接受 Linux 内核控制;macOS 和 Windows 的 Linux 容器则经过 Linux VM;Windows containers 又是另一套 Windows 内核边界。只要把 daemon 位置、文件共享路径和网络转发链路明确画出来,绝大多数“同一条 Docker 命令在不同系统上表现不同”的问题,都可以还原为具体的组件和状态差异。


系列导航与关联阅读

官方资料

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