Docker 工作原理解析

一、Docker 的定义与核心概念

1.1 什么是 Docker

Docker 是一套开放平台,用于开发、分发和运行应用程序。它通过操作系统级虚拟化(operating-system-level virtualization)技术,将应用及其全部依赖打包为一个标准化的轻量隔离单元,称为容器。同一个容器镜像可以在任何支持 OCI 标准的 Linux 主机上一致地运行,无需关注底层宿主机的具体配置差异。

与虚拟机不同,Docker 容器共享宿主操作系统的内核,无需每个实例携带独立的完整 Guest OS。这意味着容器启动时间可以从分钟级压缩到毫秒至秒级,内存开销从数百 MB 降至几 MB。

Docker 架构已经完成了重要的解耦与标准化。Docker 不再是单一的 dockerd 守护进程大包大揽,而是演进为一条清晰的职责分离管道:

docker CLI  →  dockerd  →  containerd  →  containerd-shim-runc-v2  →  runc
  (客户端)     (API网关)    (容器生命周期)      (进程存根)             (OCI运行时)

其中每一层都有明确的边界:

  • docker CLI:用户交互接口,向后兼容传统的 docker build/run/push 命令。
  • dockerd:REST API 网关,处理镜像构建(委托给 BuildKit)、镜像分发、网络与卷编排。不再直接管理容器进程。
  • containerd:行业标准的容器运行时守护进程,负责镜像传输与存储、容器生命周期监督、快照管理。Docker 自 2017 年将 containerd 捐赠给 CNCF 后,containerd 已独立发展为云原生生态中的核心基础设施。
  • runc:OCI 运行时规范的参考实现,通过 Linux Namespace + Cgroups 创建并启动容器进程,启动完成后立即退出,将进程托管给 containerd-shim。

这一架构分化不仅是工程美学的追求——它使得 Docker 的每个组件都可以被独立替换。Kubernetes 在 2020 年移除 dockershim 后直接对接 containerd,生产环境中 dockerd 这个中间层已经成为可选组件,但开发者仍然使用完整的 Docker 工具链来构建和测试镜像。

1.2 核心概念图谱

理解 Docker 需要掌握以下关键概念的精确含义及其相互关系:

概念说明类比
镜像(Image)一个只读模板,遵循 OCI Image Spec,包含运行应用所需的完整文件系统与元数据。镜像由多层只读层叠加构成。程序的可执行文件
容器(Container)镜像的运行实例,在镜像层之上附加一个可写层,形成完整的运行时视图。运行中的进程
仓库(Registry)镜像的存储与分发中心,遵循 OCI Distribution Spec。Docker Hub 是最大的公共仓库。Git 远程仓库(如 GitHub)
Dockerfile声明式脚本,定义镜像的构建步骤。BuildKit 是默认构建引擎,支持并行构建与高级缓存挂载。Makefile / 构建配方
dockerdDocker 守护进程,提供 REST API 网关,负责镜像构建调度、镜像分发、网络与卷编排。API 服务器
containerdCNCF 毕业项目,行业标准容器运行时,管理镜像传输、容器生命周期与快照存储。容器运行时引擎
runcOCI Runtime Spec 参考实现,通过 Namespace + Cgroups 创建容器进程,创建后退出的轻量级工具。进程启动器
BuildKit新一代镜像构建引擎(Docker 23.x+ 默认),支持并行构建、远程缓存、密钥注入、多平台镜像。增量编译器

二、Docker 的底层实现原理

Docker 的形象常被描述为”集装箱”,但技术层面更准确的类比是一组 Linux 内核特性的有机组合。Docker 本身并未发明任何底层技术——它的核心价值在于将 Linux 已有的三项能力(Namespace、Cgroups、UnionFS)组合成一套流畅的开发者体验。这一节深入每一个组件的技术细节。

2.1 Namespace——资源隔离

2.1.1 一句话理解 Namespace

想象一栋公寓楼:每个住户在自己的房间里,只能看到自己的家具、自己的窗户、自己的门牌号。他不知道隔壁住了谁、房间里有什么——虽然他和其他住户共享同一栋楼的钢筋水泥。

容器里的进程就住在这样的”公寓”里。这个公寓在 Linux 里叫做 Namespace——它让一个进程只能看到属于自己的那部分系统资源,对其他 Namespace 里的东西一无所知。一台宿主机上可以同时运行几百个容器,每个容器都以为自己是唯一的使用者,互不干扰。

Linux 内核提供了 8 种 Namespace,每种隔开一类系统资源。下面逐个来看。

2.1.2 PID Namespace——进程号隔离

每启动一个程序,系统都会分配一个数字编号,这就是 PID。正常情况下整个系统里 PID 不能重复,容器 A 的进程 PID=1,容器 B 就不能再有 PID=1 的进程。

PID Namespace 打破了这个限制。它让每个容器拥有自己独立的 PID 编号空间:

  • 容器里,你的主进程看到自己是 PID 1
  • 宿主机上,同一个进程看到的是另一个编号,比如 PID 48921
  • 同一个进程拥有两套编号:一套是对容器内部的,一套是对宿主机的

这就是为什么你在容器里执行 ps aux 只能看到容器自己的几个进程,而在宿主机上执行则能看到所有容器的几百个进程——两种视角各看各的。

2.1.3 Mount Namespace——文件系统隔离

Mount Namespace 控制的是”能看到哪些文件和目录”。

每个容器有自己的挂载点视图。容器里的 /proc、/tmp 与宿主机完全独立:

  • 容器内 top 命令读的是容器自己的 /proc,只能看到容器内的进程
  • 在容器里挂载一个外接磁盘,宿主机和其他容器都看不见
  • 容器启动时,它的整个”根目录”被切换到镜像构成的文件系统上——容器以为自己在操作一台完整机器的根目录,实际上那个根目录是镜像层叠加出来的
2.1.4 Network Namespace——网络隔离

Network Namespace 让每个容器拥有独立完整的网络栈:自己的网卡、自己的 IP、自己的端口空间、自己的防火墙规则。

这意味着容器 A 和容器 B 都可以监听 80 端口——不会冲突,因为它们在各自的 Network Namespace 里。

现实中的实现方式:Docker 创建一对虚拟网线(veth pair),一头插在容器里,一头插在宿主机的虚拟交换机(docker0 网桥)上。容器访问外网时,流量从容器网卡出发,经过宿主机的网桥,再通过宿主机的物理网卡出去——就像公寓里每家有自己的路由器,但最终共享一根光纤。

2.1.5 其他 Namespace
Namespace隔离什么为什么需要
UTS主机名每个容器可以有自己的 hostname,不会改了容器名把宿主机名也改了
IPC进程间通信(共享内存、信号量等)防止容器 A 的进程不小心读到容器 B 的共享内存
User用户 ID 映射容器里的 root 可以映射成宿主机上的普通用户。这样就算容器被攻破,攻击者拿到的也不是真正的 root
Cgroupcgroup 信息视图容器里只能看到自己的资源限制,看不到其他容器用了多少资源

Namespace 解决了”能看见什么”的问题。但没有解决”能用多少”的问题——这个由下一节的 Cgroups 来接管。

2.2 Cgroups——资源限制与计量

如果说 Namespace 解决了能看到什么的问题,那么 Cgroups(Control Groups)解决的就是能用多少的问题。Cgroups 将进程组织为层级分组,对每组进程施加 CPU、内存、磁盘 I/O、网络带宽等方面的硬性限制。

2.2.1 Cgroups v2:统一层级模型

到 2026 年,所有主流 Linux 发行版均已默认使用 cgroups v2(内核 5.2+ 即完全就绪),Docker 自然也全面运行在 cgroups v2 之上。

Cgroups v2 的核心特性:

  • 统一层级:所有控制器挂载在单一的层级树下(/sys/fs/cgroup/),管理模型清晰一致。
  • 无内部进程约束:只有叶子节点可以包含进程,确保资源控制不存在歧义。
  • Pressure Stall Information(PSI):内核暴露 /proc/pressure/cpu、/proc/pressure/memory、/proc/pressure/io 接口,精确量化资源拥塞程度。这是现代容器监控与弹性伸缩的核心信号。

Cgroups v2 的数据流:

用户态进程 → cgroupfs(/sys/fs/cgroup/)→ 内核 cgroup 统一层级 → 资源调度器

Docker(通过 containerd)每次创建容器,都会在 cgroupfs 中创建对应的子分组,并将容器进程 PID 写入 cgroup.procs。下面是 cgroups v2 中各资源控制器的现代接口。

2.2.2 CPU 限制

Docker 通过 Cgroups v2 的 CPU 控制器对容器进行两种维度的 CPU 管控:

(1)CPU 权重(cpu.weight)

cgroups v2 使用 cpu.weight 进行权重控制。默认权重为 100,范围 1~10000。Docker 的 --cpu-shares 参数自动映射到此接口。当系统 CPU 资源紧张时,调度器按权重比例分配 CPU 时间。

例如:容器 A 权重 200,容器 B 权重 100,当两者竞争 CPU 时,A 获得约 66.7% 的 CPU 时间,B 获得约 33.3%。权重只在资源竞争时生效,空闲资源仍可被任意容器使用。

(2)CPU 带宽限制(cpu.max)

cgroups v2 通过统一文件 cpu.max 控制 CPU 带宽,格式为 $MAX $PERIOD:

  • Docker 参数 --cpus=1.5 → 写入 cpu.max 为 150000 100000,即每 100ms 周期最多使用 150ms CPU 时间,等价于 1.5 核。
  • Docker 参数 --cpus=0.5 → cpu.max = 50000 100000,等价于 0.5 核。
  • 无限制时 cpu.max = max 100000。
  • Docker 参数 --cpuset-cpus="0,2" → 通过 cpuset.cpus 绑定进程到指定核心。
2.2.3 内存限制

cgroups v2 统一了内存控制接口:

cgroups v2 文件Docker 参数含义
memory.max--memory物理内存硬限制。超出时触发 OOM Killer
memory.swap.max--memory-swap物理内存 + swap 总量限制
memory.high--memory-reservation软限制,超过后内核会节制分配但不直接 OOM。配合 PSI 可实现优雅的内存压力感知
memory.low—尽力保护的最低内存阈值,在全局内存压力下尽量不被回收

关键机制:当容器内存使用量超过 memory.max 时,内核 OOM Killer 介入,杀死容器中内存占用最大的进程。Docker 守护进程可按需重启该容器。

2.2.4 块设备 I/O 限制

cgroups v2 通过 IO 控制器(统一接口文件 io.max)限制块设备的读写速率,格式为 设备号 rbps=X wbps=X riops=X wiops=X:

  • Docker 的 --device-read-bps / --device-write-bps → 写入 io.max 的 rbps / wbps 字段
  • Docker 的 --device-read-iops / --device-write-iops → 写入 io.max 的 riops / wiops 字段

此外,cgroups v2 的 IO 控制器还支持基于权重的比例分配(io.weight)和 IO 延迟目标控制(io.latency),为不同工作负载提供更精细的磁盘 QoS。

2.3 OverlayFS——镜像分层与存储原理

要理解 Docker 镜像为什么又快又省空间,你需要理解两个概念:镜像是怎么存下来的,以及容器运行时怎么读写文件。两者的底层都依赖于 Linux 内核的 OverlayFS——一种”联合文件系统”。

2.3.1 从一个 Dockerfile 看”层”

先看一段最简单的 Dockerfile,然后用它来串联整个存储原理:

FROM ubuntu:24.04                        # 第1层:拿一个现成的 Ubuntu
RUN apt-get install -y python3           # 第2层:装 Python
COPY mycode.py /app/                     # 第3层:放入自己的代码

每一条指令构建完成后,都会形成一个只读的静态快照,这就是一个”层”(layer)。三条指令 = 三个层,像三张叠在一起的透明胶片:

第3层  [ mycode.py ]          ← 你的应用代码
第2层  [ /usr/bin/python3 ]    ← apt 新装的文件
第1层  [ /bin, /usr, /lib... ] ← Ubuntu 完整系统

最终的镜像就是这三层的叠加:它看起来是一个完整的文件系统(有系统文件 + Python + 你的代码),但实际上是三个独立的、只读的层摞在一起。

2.3.2 容器怎么读写这些只读层

上面说的是镜像——是静止的、只读的。现在来看容器——运行中的镜像,它需要能写文件。

OverlayFS 的做法是:在这摞只读层的最上面,额外加一个可写层。结构如下:

┌─────────────────────────────┐
│  可写层(容器专属)           │ ← 所有修改都写到这里
├─────────────────────────────┤
│  第3层  mycode.py  [只读]   │
├─────────────────────────────┤
│  第2层  python3    [只读]   │
├─────────────────────────────┤
│  第1层  ubuntu     [只读]   │
└─────────────────────────────┘

容器里运行的进程看到的,就是这四层叠加后的完整画面。它不需要知道底层是分层的——就像一个文件系统一样自然。

下面分别讲容器里的”读文件”、“改文件”、“删文件”具体是怎么做的。

(1)读文件

规则很简单:从上面往下找,找到就用。

如果容器要读 /app/mycode.py,先看可写层有没有。有就直接用。没有?继续往下层找,在第3层找到了,就用第3层的。容器进程完全不知道文件实际来自哪一层。

(2)改文件——写时复制(Copy-on-Write)

下层的文件是只读的,不能直接改。那容器要修改 /app/mycode.py 怎么办?

OverlayFS 的答案:如果文件来自下层,先把整个文件拷贝一份到可写层,然后在拷贝上修改。

这个过程叫”写时复制”:你不动它,它就安静地待在下层;你一动它,就触发一次拷贝。对于小文件(大多数应用场景),拷贝几乎无感知。如果文件很大(比如数据库的数据文件),首次写入会有一次性的 I/O 开销——这种情况建议使用 Docker Volume,数据直接写入宿主机目录,不走 OverlayFS 层。

(3)删文件——用”白障”标记

下层文件是只读的,物理上删不掉。但容器进程又必须”看到”文件消失了。怎么办?

OverlayFS 用了一个巧妙的方法:在可写层创建一个同名标记文件(叫 whiteout,白障),这个标记表示:“如果下面有同名文件,假装它不存在”。

打个比方:这就像你在透明胶片的某一层上用涂改液盖住下面的内容——下面的字还在,但你看到的画面里它已经消失了。删除目录同理,创建一个叫 opaque(不透明)的标记,表示这个目录的下层内容全部隐藏。

2.3.3 镜像的分发:pull 一个镜像发生了什么

当你执行 docker pull ubuntu:24.04 时,实际发生的是:

  1. 下载层:Registry 把镜像的每个层(压缩包)发送过来,存到宿主机的 /var/lib/docker/overlay2/ 目录下。
  2. 层是独立的目录:每层解压后是一个独立目录,里面就是这一层包含的所有文件。
  3. 容器启动时叠加:docker run 时,Docker 创建一个空的可写层目录,然后调一条 Linux 命令把这几个目录”叠”在一起:
mount -t overlay overlay \
  -o lowerdir=第1层:第2层:第3层,upperdir=可写层,workdir=工作目录 \
  合并视图
  1. 容器看到合并视图:容器进程看到的”根目录”就是这个合并视图。进程以为自己在操作一个普通目录,实际上底下是多层叠加的结果。

关键点:多个容器可以同时共享同一套只读层,各自的可写层互不影响。启动一个新容器只需要创建一个空的可写层目录 + 一条 mount 命令——几乎瞬时完成。

2.3.4 层缓存与构建加速

上面的原理也解释了为什么 docker build 可以很快:

  • Dockerfile 的每条指令对应一层,每一层构建完成后都会打上唯一标识。
  • 下次构建时,如果某个指令的输入(指令文本 + 上下文文件)和上次一模一样,直接复用已有层,跳过执行。
  • 一旦某一层缓存失效,这一层及之后所有层都必须重建。

所以 Dockerfile 的最佳实践是:把不常变的东西写在前面,常变的东西写在最后。

FROM ubuntu:24.04                    # 几乎不变 → 永远命中缓存
RUN apt-get install -y python3       # 很少变 → 基本命中缓存
COPY requirements.txt /app/          # 依赖声明偶尔变
RUN pip install -r /app/requirements.txt  # 声明变了才重装
COPY . /app/                         # 代码天天变 → 只有这层常重建

日常开发中最常见的场景:只改了代码没改依赖 → 只有最后两层重建,前面全部命中缓存,构建几秒完成。

三、总结

Docker 的工作原理可以概括为一条清晰的标准化工具链和一组不变的内核基础能力:

标准化工具链:

docker CLI  →  dockerd(API网关)  →  containerd(生命周期)  →  runc(OCI运行时)
BuildKit(并行构建)     Distribution(OCI分发)     Snapshotter(快照存储)

Docker 已经从单一的”万能守护进程”演进为一组职责分明的、可替换的标准组件。containerd 独立于 Docker 成为容器运行时的行业标准,runc 严格遵循 OCI Runtime Spec,镜像分发遵循 OCI Distribution Spec。开发者依然使用熟悉的 docker build/run/push 命令,但底层已经是一个开放、可组合的生态。

内核基础能力(不受架构演进影响):

  1. Namespace:界定资源视图边界,回答”我是谁、我能看到什么”。8 种 Namespace 各司其职,共同构建出每个容器的独立世界。
  2. Cgroups v2:界定资源用量边界,回答”我能用多少”。统一层级模型配合 PSI 压力感知,提供精确的资源控制与监控信号。
  3. OverlayFS:界定文件系统边界,回答”我的文件在哪、如何变化”。写时复制 + 分层共享 + 快照器插件化,支撑了现代容器镜像的极速构建与分发。

参考资料:

  • Linux Kernel Documentation: Control Groups v2 (Documentation/admin-guide/cgroup-v2.rst)
  • Linux Kernel Documentation: Overlay Filesystem (Documentation/filesystems/overlayfs.rst)
  • Linux Kernel Documentation: Namespaces (man 7 namespaces)
  • OCI Specifications: Runtime Spec, Image Spec, Distribution Spec (github.com/opencontainers)
  • containerd Documentation: Architecture & Snapshotters (github.com/containerd/containerd)
  • BuildKit Documentation (github.com/moby/buildkit)