Docker 容器十年纪

Jun 16, 2026 00:00 · 8110 words · 17 minute read Container Docker

原文 A Decade of Docker Containers


技术起源

在 2000 年代早期,将软件部署到新机器上的标准流程是:手动安装有着繁杂依赖链的 Linux 发行版,手工编译并配置一批软件。到了 2010 年,随着云计算的兴起,这一流程变得更加复杂——应用想要在多台虚拟机上运行,这些虚拟机分布在资源需求各异的宿主机之间。开发者面临的核心问题是:如何在异构环境中保证软件的可复现性与一致性。Docker 为开发者解决了这一难题:将应用及其全部依赖打包进一系列文件系统镜像(即"容器镜像"),任何安装了 Docker 的机器都能运行这些镜像。与虚拟机方案(需要安装完整操作系统)不同,容器仅需几条命令即可启动。

典型工作流

开发者编写一个 Dockerfile,以一种类似 shell 的语法描述如何构建应用。以下是一个基于 Python 的 Web 服务的典型示例:

FROM python:3
COPY requirements.txt /app/requirements.txt
WORKDIR /app
RUN pip install -r requirements.txt
COPY . /app
EXPOSE 80
CMD ["python", "app.py"]

开发者执行 docker build 来构建容器镜像,镜像还可以被推送到 Docker Hub——一个作为镜像中央注册表(Registry)的服务:

$ docker build -t avsm/my-python-app .
$ docker push avsm/my-python-app

任何安装了 Docker 的机器都可以拉取并运行该镜像。例如,以本地数据卷挂载和单个网络端口暴露的方式运行:

docker run -v data:/app/data -p 80:80 avsm/my-python-app

应用此时在容器中运行,与主机系统及其他容器隔离。开发者可以迭代应用,准备好发布新版本时重新构建镜像并推送到镜像仓库。用户能够独立更新镜像,无需担心不同版本软件之间的冲突。

Docker 的命令行接口(CLI)多年来不断演化,纳入了更多命令,其后端系统也经历了彻底的重构,但自 2013 年以来,编写 Dockerfile 并使用 docker builddocker run 的核心工作流始终不变。GitHub 上的公开仓库中,根目录下包含 Dockerfile 的仓库超过 340 万个,足见这种分发机制在各类软件项目中的普及程度。

底层原理

操作系统内核将各进程的内存空间相互隔离,但有意共享多种其他类型的系统资源。操作系统内核从单一共享文件系统启动,包含配置文件、动态库和每个应用的状态。虽然这种共享很方便,但当同时安装多个动态链接库冲突的应用程序时就很困难。进程还需要相互通信,例如 Web 前端需要与后端数据库交互。Linux 支持多种进程间通信(IPC)方法,包括网络(Networking)、Unix 信号(Signals)和 Unix Domain Sockets。尽管这些共享通道对进程协同很重要,但也可能导致应用间的相互干扰——例如网络端口冲突。

解决上述冲突的一种方法是将每个应用运行在独立的虚拟机中,使用独立的客户机内核、用户空间和文件系统。Hypervisor 使多个虚拟机复用共享硬件。这种方法有效,但太重了:需要多个内核、重复的文件系统、重复的缓存,以及桥接的网卡;如果用户只是想快速运行几个应用程序,这会引入显著的复杂性。此外,由于每个客户机操作系统独立运行并认为自己是硬件的唯一用户,也很难高效实现存储和内存的去重。

这些挑战引出了一个问题:我们能否使用操作系统原语(OS primitives),而非重量级的虚拟机?Unix v7 在 1978 年就引入了 chroot(),允许进程使用完全独立的根文件系统(rootfs),但它并不支持将来自不同应用的多个文件系统组合到在一起。Nix 与 Guix 之类的系统要求将软件重新打包到各应用独立的目录中,并通过动态链接来解析正确的库版本。这种方式虽然有效,却要求修改所有软件的打包方式,而这对专有软件来说并不总是可行。它也只是一个局限的解决方案,因为它并未解决应用之间的网络端口冲突问题。

Docker 转而采用了 Linux 的一个叫做命名空间(namespaces)的特性,它在 Nix 诞生之初尚不存在。命名空间让每个进程对如何访问文件、目录等共享资源拥有更精细的控制。例如,在图 1 中,一个包含 /alice/etc/passwd/bob/etc/passwd 的根文件系统下,位于不同命名空间中的两个进程对 /etc/passwd 的视图可以各不相同,分别解析(resolve)到 /alice/bob 下的版本。进程本身并不知道自己的请求被重映射(remap)到了更外层的根文件系统中,也永远无法看到其视野之外的文件。关键在于,命名空间的映射只在打开资源时生效,而由此得到的文件描述符在后续的读写等操作中表现为普通的内核资源,不会带来额外开销。这使得 Linux 内核既能高效地管理共享资源,又能为应用提供其相对于底层文件系统所需的隔离级别。文件描述符一旦打开,也可以按常规方式在进程间传递,从而确保与 Unix 编程惯例的兼容性。

Figure 1

图 1:Linux mount namespace

命名空间在 Linux 中并非什么新特性,而是多年来逐步添加的。文件系统(即 mount)命名空间最早于 2001 年加入 Linux 2.5.2 内核,随后是 2006 年 Linux 2.6.19 中的进程间通信(IPC)命名空间,以及 2007 年 Linux 2.6.24 中的网络栈命名空间。多年来,Linux 已累计支持七种不同类型的命名空间,将它们组合使用可以让单个内核在以极小开销为进程分配资源时获得极大的灵活性。然而,由于这些命名空间是零散地逐步引入的,而非像 Plan 9 那样从一开始就基于它们来设计操作系统,因此它们很底层、使用起来也相当困难。FreeBSD、Solaris 等其他操作系统上的类似变体同样始终未能得到广泛应用。Docker 在 2013 年的重大突破,正是利用命名空间在虚拟机所提供的重量级隔离,和操作系统原语所提供的易用性以及与现有软件的兼容性之间找到了平衡。

Docker 如何运行 Linux 容器

Docker 是一个 CS 架构的应用:服务端守护进程(dockerd)运行在宿主机上,CLI 客户端则通过 RESTful 的 Docker API 发送请求。守护进程负责创建和管理所有系统资源,例如容器、镜像、网络和卷。当开发者调用某个 docker CLI 命令时,它会走 Unix domain socket 发送 API 调用。dockerd 在过去是一个单体(monolithic)程序,但在 2015 年前后,我们将它拆分成了图 2 所示的若干专用组件。第一个组件 buildkit 负责构建文件系统镜像,还有 containerd 负责将这些镜像实例化为运行中的容器,并为其关联相应的网络与存储资源。

Figure 2

图 2:Docker 组件架构

容器镜像。 当调用 docker build 时,Docker 会根据输入的 Dockerfile 构建出一个文件系统镜像,用以表示其中的可执行文件和数据。容器镜像以分层文件系统格式存储,每一层都叠加在前一层之上。最底层通常引导自某个操作系统发行版,例如 Debian 或 Alpine Linux,但也可以通过一个简单的 tar 归档手工组装而成。后续各层则对应于执行 Dockerfile 中各条命令所产生的文件系统差异。这是 Docker Hub 能够在互联网上共享镜像的基础。镜像格式本身自 2016 年起已由Open Container Initiative(OCI)社区的用户标准化,目前已有多个相互独立的实现可供使用。

镜像本身存储在一个内容寻址(content-addressable)存储系统中,以文件系统镜像的哈希值作为管理它的键。这既能高效地对存储进行去重,也确保镜像一旦被推送到 Docker Hub 就不可变更。任何用户都可以拉取该镜像并在任意机器上运行,而哈希值可用于校验镜像未被篡改。Docker 利用 overlayfs、btrfs、ZFS 等现代 Linux 文件系统,直接管理写时复制(copy-on-write)的各层,并实现高效的快照(snapshot)与克隆。Docker 还通过 stargz 存储快照器支持镜像的惰性拉取(lazy-pulling)。

容器实例。 对一个 OCI 镜像调用 docker run,会分配系统资源,创建一个由命名空间隔离的进程(即容器),并从文件系统镜像引导启动。containerd 进程负责动态配置每个容器所需的命名空间,执行的任务包括:

  • 定义用于资源隔离和 I/O 限速的进程控制组(cgroups);

  • 将容器内的本地网络端口重映射到宿主机网络接口对外暴露的端口;

  • 从宿主机文件系统挂载可变的存储卷(volume),以保存应用的持久化状态;

  • 用 PID 命名空间隔离容器的进程树;

  • 用用户命名空间(user namespace)将容器内的本地用户 ID 映射为宿主机上不同的 ID。例如,avsm 用户在容器内始终一致地表现为 UID 1000,但在不同宿主机上实际可能被映射为互不冲突的 UID 12345 或 23456。

构建这些命名空间虽然会带来一定开销,但远低于启动一台完整 Linux 虚拟机的成本,大多数情况下都能在不到一秒内完成。Linux 内核本身会像回收普通进程一样,对已退出的容器进行垃圾回收。

超越 Linux 的演进

这种 CS 架构让管理远程 Docker 实例变得十分容易:只需将 CLI 配置为通过安全的网络连接发送命令即可,例如发往运行在云上的 Docker 主机。2015 年,我们利用这种灵活性,解决了随着 Docker 影响力扩大而日益突出的另一个问题。在发布后的两年里,Docker 已确立为被广泛采用的 Linux 开发工具,但它撞上了一堵可用性的墙。大多数开发者仍以 macOS 或 Windows 作为主要开发环境,而 Docker 的文件系统镜像只能运行在 Linux 内核之上。与此同时,公有云的兴起使 Linux 成为部署环境的首选。我们迫切需要找到一种办法,让 Linux 容器能在 macOS 和 Windows 上运行,从而消除开发云服务的一大障碍。

打造无缝的 Docker for Mac 应用

设计 Docker for Mac 和 Windows 时的关键约束在于:对于已经熟悉 Linux 版 Docker 的开发者,它必须无需任何额外配置即可工作,并且还要能运行相同的 Docker 镜像。答案在于将最新的 Hypervisor 虚拟化与最出色的 Linux 命名空间结合起来。我们没有采用让 Linux 与桌面操作系统并行运行的传统做法,而是反转了软件架构:把 Hypervisor 嵌入到运行于 macOS 或 Windows 上的某个用户空间应用之中,再在该应用内部运行 Linux。这一思路的灵感来自我们对 unikernel 的研究,该研究已经表明,可以灵活地将操作系统组件嵌入到一个更大的应用程序之中

将 Linux 嵌入应用程序。 我们首先设计了一个名为 HyperKit 的库式虚拟机监视器(library virtual machine monitor,VMM),它利用 Intel CPU 的硬件虚拟化扩展,在一个普通用户进程中运行 Linux 内核(图 3)。这个内嵌的 Linux 内核上运行 Docker 守护进程,后者再运行各个容器,并充当一个常规的 Docker 服务端端点(图 4)。我们把所有 Linux 管理细节都隐藏在桌面应用内部,使得在桌面端运行的 docker builddocker run 只需将调用转发给内嵌的 Linux 实例,便能“开箱即用”。这一方案非常成功,以至于被 Podman 等其他容器系统所采用,如今已成为在 macOS 和 Windows 上运行容器的标准方式。

Figure 3

图 3:使用传统的 hypervisor vs. Docker 的库式虚拟机监视器

为体现这一点,我们设计了一个名为 LinuxKit 的定制 Linux 发行版——它并非传统意义上独立运行的 Linux 发行版,而是被设计为一个组件,嵌入到更大的应用程序之中。为尽可能缩短应用启动时间,我们构建了一个定制用户空间,只包含运行 Docker 容器所必需的组件,并且把每一个组件本身都运行在容器之中,使得启动时所用的根命名空间里没有任何东西在运行。这让我们得以复用 Docker 容器本身所使用的写时复制文件系统与网络命名空间,并以高度隔离的方式运行整个系统。LinuxKit 与 HyperKit 的结合,几乎能像启动一个原生 macOS 进程一样快速地启动一个 Linux 进程;Docker for Mac 和 Windows 应用就此诞生,并于 2016 年发布。

Figure 4

图 4:Docker for Mac 应用程序架构

网络。 尽管 Linux 容器此时已能在 macOS 和 Windows 上正常运行,但要把网络通到内嵌的 Linux 容器却出人意料地棘手。传统做法是将以太网网络流量从桌面端桥接(bridge)到 Linux 虚拟机,这需要复杂的网络管理。更糟的是,这种桥接方式还会碰上企业版桌面操作系统上的防火墙和病毒检测程序,被它们判定为潜在的恶意流量,结果导致我们的测试用户提交了成千上万份 bug 报告。所幸,一个名为 SLIRP 的古老工具提供了一种可行的解决方案——其采用的方法早在 1990 年代中期就被用来将 Palmpilot PDA 接入互联网!

出向网络流量会在安全扫描器中触发误报,因为这类扫描器通常被配置为拦截一切绕过宿主机操作系统网络栈的、来自未知进程的流量——而当 Linux 虚拟机将其流量直接桥接到桌面操作系统网络栈时,就是如此。作为 workaround,我们转而借助 MirageOS 的 unikernel 库,在 Linux 网络请求与 macOS、Windows 的原生套接字(socket)调用之间进行转换。当某个容器尝试进行 TCP 握手时,一个携带 TCP SYN 的以太网帧会通过 virtio 协议、借助 Mac 上的共享内存发送给宿主机(图 5)。该帧随后被 VMM 库接收,并通过 sendmsg 发向至运行在宿主机操作系统上的用户空间 TCP/IP 栈。这个用户空间网络栈被命名为 vpnkit,以 OCaml 编写,它接着调用 macOS 的 connect() 系统调用,从而完成 TCP 握手或返回错误信号。在这一架构下,Linux 容器的出向流量会被 VPN 策略视为源自 Docker 应用本身,而非一台独立的机器。2016 年我们在测试版中部署 vpnkit 后,来自企业用户的缺陷报告减少了 99% 以上,此后该方案一直是 Docker for Mac 和 Windows 的关键组件。SLIRP 这一方法随后也在无服务器(serverless)云领域的其他场景中得到应用,让一项古老的拨号上网技术重焕生机,用以解决容器管理中的新问题。

Figure 5

图 5:由本地进程产生的和经由 SLIRP 转发的 VM 流量会被放行

入向网络流量同样也是个难题,但原因有所不同。默认情况下,当 Linux 容器在某个端口上监听时,除非在 CLI 中显式请求,否则它不会自动暴露到互联网(例如 docker run -p 80:80 nginx 才会把 nginx 暴露在 80 端口上)。运行容器时理想的用户体验是:容器端口直接出现在桌面操作系统的 IP 地址上,可以通过浏览器经由诸如 http://localhost:8080 这样的 URL 访问。而 VMware Fusion 之类的桌面虚拟化软件采用的传统做法,暴露的是一个临时的中间 IP,而非 localhost。我们的 LinuxKit 内核安装了一个定制的 eBPF 程序,它会触发在桌面宿主机上创建一个相应的监听套接字,并启动一个端口转发器(port forwarder),使容器能够以较小的开销透明地接收连接。这样便实现了完美的开发者体验:在 Mac 上运行一个 Linux 容器,即可立即通过 localhost 访问它,就如同在一台原生 Linux 机器上一样。

存储。 存储方面也存在类似的问题,因为开发者需要在本地编辑代码、访问数据文件,同时仍要能在容器中运行代码并执行测试。这种实时文件访问在 Linux 上通常通过绑定挂载(bind mount)实现,通过 docker run -v /host:/container 表达。绑定挂载是 Linux 内核中一种不可移植的文件系统概念,它将文件系统树的一部分嫁接到另一部分的目录树上。由于 macOS 和 Windows 使用不同的内核,这种方式无法奏效,因此 Docker 采用源自 KVM hypervisor 的 virtio-fs 共享内存协议,将文件系统操作作为 FUSE 请求格式发送到宿主机。宿主机接收这些请求后,调用相应的 openreadwrite 系统调用。这也意味着开发者的代码和数据可以保留在宿主机文件系统中,从而可供 Apple Time Capsule、Spotlight 等备份与搜索工具使用,而不必在 Linux 虚拟机内部集成这些工具。

Windows 为 Linux 改变

到 2017 年,在云上部署 Linux 越来越流行,微软发布了 Windows Subsystem for Linux(WSL)子系统,允许在 Windows 上直接运行 Linux 应用。该子系统的第一版并未使用虚拟化,而是倾向于通过另一个库操作系统,将 Linux 二进制程序发起的系统调用动态转换为相应的 Windows 系统调用。这一方法对许多应用行之有效,但要运行 Docker 容器则超出了其能力范围。Linux 内核拥有大量系统调用,而 WSL 并未支持足够多的系统调用来运行 Docker 容器。

2018 年,微软重构了 WSL,发布第二版,采用与 Docker for Mac 类似的方法,在后台运行一个完整的 Linux 虚拟机。至此,Docker for Windows 的集成已变得无缝衔接;WSL2 版的 Docker 在 LinuxKit WSL 发行版内运行守护进程和用户容器,并负责从 Windows 本体以及其他 Linux 发行版转发 Docker API 与网络端口(图 6)。

Figure 6

图 6:WSL2 上的 Docker for Windows 架构

回顾起来,使 Docker 容器得以跨平台演进的架构思路,正是库操作系统(library OS)的方法:将传统上“仅限内核”的代码改造为可嵌入其他应用的用户空间库。这一架构的成功,体现在它的无感与无处不在——数以百万计的开发者每天都在使用 Docker 及其衍生工具,而无需操心运行在哪种操作系统之上。

新兴的开发者工作流

多 CPU 架构。 在 Docker 早期,云上的绝大多数工作负载都基于 Intel 架构。这一切随着 2018 年面向云工作负载的 Amazon Graviton ARM 处理器、以及 2020 年 Apple M1 系列 ARM CPU 的发布而改变。一时间,在 ARM 上运行工作负载既能节省成本又能提升性能,开发者们都想发挥其优势。如今,有必要在同一个 Docker 镜像中支持多种 CPU 架构,使开发者能够在 Intel、ARM、POWER 乃至新兴的开源 RISC-V CPU 上运行其应用。在服务端,这一能力是通过扩展 OCI 镜像格式、增加对“多架构清单”(multiarch manifests)的支持来加入 Docker 镜像的,该清单记录了某个镜像针对哪些架构构建。

但这仍给我们留下一个问题:如何在单台宿主机上为多种 CPU 架构构建这些镜像,同时又不引入交叉编译(cross compilation)这一复杂难题。我们转而求助于 Linux 中另一个特性 binfmt_misc,它允许通过自定义的用户空间应用来运行可执行文件。由于 QEMU 能够在多种 CPU 架构之间进行转换,我们便将它安装到 Docker for Desktop 内嵌的 LinuxKit 中,以在 ARM 与 Intel 二进制程序之间透明地进行转换。尽管这会带来显著开销,但通常只在构建阶段才需要,因为最终生成的多架构镜像无需修改即可在任意宿主机上原生运行。此后,Apple 在其 CPU 系列中通过 Rosetta 引入了对 CPU 指令集转换的软硬件支持,这一特性很容易就集成进了 Docker 架构。如今,让 Intel 与 ARM 容器并肩运行已成为开发者的一种常见工作流。

借助可信执行环境管理密钥。 对容器化环境而言,管理密码、API 密钥等机密信息(secrets)一直是个难题,因为它们必须动态注入容器,而不能被固化进文件系统镜像。Docker 一直支持套接字转发,因此可以将一个本地域套接字挂载进容器;在 Docker for Mac 或 Windows 的场景下,还会将该套接字一路转发进 Linux 虚拟机。这使得用户能够在容器内使用 ssh-agent 之类的密钥管理系统,而无需直接暴露密钥本身。套接字转发提供了良好的第一层防护,但现代环境还需要更多层防御,以应对潜藏在日益膨胀的软件供应链中的恶意软件。

首先可以做的,是在容器运行时内部直接利用 hypervisor 保护,以提升跨容器的防护级别。在此之上,Docker 一直在集成现代 CPU 中的一项硬件特性,它甚至能够保护机密数据不被宿主机操作系统窥探。Trusted execution environments(TEE)允许创建机密虚拟机,可在应用、内核乃至 hypervisor 边界之间强制实施数据访问限制。然而,配置和使用 TEE 的管理复杂度与操作系统虚拟化差不多,因为本质上是在 TEE 内部引导启动了一个微型的操作系统内核。

Confidential Containers 工作组的用户社区一直在开发可在 TEE 内运行、并通过 Docker 管理的应用。Docker 的 CS 架构与这类应用能够很好的集成:运行在桌面操作系统上的 Docker CLI 可以将来自本地 TEE 的加密消息,经由宿主机上多个转发套接字一路转发,直至云环境中运行的远程 TEE。这使开发者无需亲临现场即可认证到敏感的云环境,并将凭据安全地保存在桌面 enclave 中,同时保留本地开发的便利。

面向 AI 工作负载的 GPGPU 支持。 到目前为止,我们主要关注 Docker 如何演进以运行在不同操作系统和 CPU 之上,但 AI 工作负载的兴起带来了全新的挑战。机器学习工作负载大多运行在 GPU 上,Docker 生态也必须适配以提供支持。核心难点在于:GPU 工作负载需要精确匹配的内核 GPU 驱动与用户空间库,而多个容器却共享同一个内核。这重新引入了 Docker 最初所要解决的根本冲突:如何在同一台机器上运行依赖相互冲突的多个应用。如果两个应用需要同一内核 GPU 驱动的不同版本,该怎么办?

自 2023 年 3 月起,Docker 已支持容器设备接口(CDI),支持在容器启动时对文件系统镜像进行定制,允许绑定挂载 GPU 设备文件与 GPU 专用动态库,并重新生成 ld.so 缓存。尽管这确保了 Docker 镜像在某一类或某一厂商的 GPU 之间具备可移植性,但在不同操作系统和硬件品牌之间仍无法做到完全无缝。CDI 所添加的可用动态库实际上定义了不同的 API,因此并不存在可与 CPU 容器传统上所依赖的稳定 Linux 系统调用 ABI 相媲美的接口。为 Nvidia GPU 设计的应用仍难以在 Apple M 系列 CPU 上运行,因为底层 GPU 虚拟化支持尚不够成熟,无法在这类差异巨大的硬件之间转换向量指令。我们正与更广泛的容器社区和 GPU 厂商合作,探索更灵活、更安全的方式来管理 GPU 相关依赖,并期待面向可移植接口的各项倡议最终能够收敛为共识。

总结

Docker 诞生于 2013 年,初衷是帮助开发者更轻松地构建、分享和运行任意应用。如今,它已深度融入标准的云与桌面开发工作流,全球有数百万开发者每日使用,每月请求量达数十亿次。我们一贯的目标之一,是维持一个活跃而多元的开源社区,共同制定互操作标准,确保用户不被单一厂商绑架。云原生计算基金会(CNCF)是若干核心组件的托管方,Open Container Initiative(OCI)则负责镜像格式的标准化。如今这些元素的多种实现都在蓬勃发展,我们也看到其在云端、桌面以及汽车、移动设备乃至航天器等边缘场景中的部署数量不断增长。

软件开发日新月异,因此我们不断在底层演进 Docker,以跟上最新发展。图 7 展示了一个典型的 2025 年开发者工作流,其中集成了持续测试与部署、IDE 的语言服务器,以及通过智能体 coding 提供的 AI 辅助。从 Docker 的视角看,核心的 build and run 工作流与十年前的用户体验仍非常相似,但背后已有更完善的系统支持,以降低在各类需要可靠的沙箱环境中运行时的摩擦。

Figure 7

图 7:2026 年 Docker 开发者的工作流