Docker - 底层实现详解

2025年04月07日 09:12:38    [原创]


一. Docker总统架构

Docker总统架构图


Docker是一个C/S模式的架构,后端是一个松耦合架构,模块各司其职。

  • 用户是使用Docker Client与Docker Daemon建立通信,并发送请求给后者。
  • Docker Daemon作为Docker架构中的主体部分,首先提供Server的功能使其可以接受Docker Client的请求;
  • Engine执行Docker内部的一系列工作,每一项工作都是以一个Job的形式的存在。
  • Job的运行过程中,当需要容器镜像时,则从Docker Registry中下载镜像,并通过镜像管理驱动graphdriver将下载镜像以Graph的形式存储;
  • 当需要为Docker创建网络环境时,通过网络管理驱动networkdriver创建并配置Docker容器网络环境;
  • 当需要限制Docker容器运行资源或执行用户指令等操作时,则通过execdriver来完成。
  • libcontainer是一项独立的容器管理包,networkdriver以及execdriver都是通过libcontainer来实现具体对容器进行的操作。

二. Docker各模块组件分析

1. Docker Client【发起请求】

  • Docker Client是和Docker Daemon建立通信的客户端。用户使用的可执行文件为docker(类似可执行脚本的命令),docker命令后接参数的形式来实现一个完整的请求命令(例如docker images,docker为命令不可变,images为参数可变)。
  • Docker Client可以通过以下三种方式和Docker Daemon建立通信:tcp://host:port,unix://path_to_socket和fd://socketfd。
  • Docker Client发送容器管理请求后,由Docker Daemon接受并处理请求,当Docker Client接收到返回的请求相应并简单处理后,Docker Client一次完整的生命周期就结束了。一次完整的请求:发送请求→处理请求→返回结果,与传统的C/S架构请求流程并无不同。

2. Docker Daemon【后台守护进程】

Docker Daemon的架构图


2.1 Docker Server【调度分发请求】

Docker Server的架构图

Docker Server相当于C/S架构的服务端。功能为接受并调度分发Docker Client发送的请求。接受请求后,Server通过路由与分发调度,找到相应的Handler来执行请求。

在Docker的启动过程中,通过包gorilla/mux,创建了一个mux.Router,提供请求的路由功能。在Golang中,gorilla/mux是一个强大的URL路由器以及调度分发器。该mux.Router中添加了众多的路由项,每一个路由项由HTTP请求方法(PUT、POST、GET或DELETE)、URL、Handler三部分组成。

创建完mux.Router之后,Docker将Server的监听地址以及mux.Router作为参数,创建一个httpSrv=http.Server{},最终执行httpSrv.Serve()为请求服务。

在Server的服务过程中,Server在listener上接受Docker Client的访问请求,并创建一个全新的goroutine来服务该请求。在goroutine中,首先读取请求内容,然后做解析工作,接着找到相应的路由项,随后调用相应的Handler来处理该请求,最后Handler处理完请求之后回复该请求。

2.2 Engine

Docker Server的架构图

Engine是Docker架构中的运行引擎,同时也Docker运行的核心模块。它扮演Docker container存储仓库的角色,并且通过执行job的方式来操纵管理这些容器。

在Engine数据结构的设计与实现过程中,有一个handler对象。该handler对象存储的都是关于众多特定job的handler处理访问。举例说明,Engine的handler对象中有一项为:{"create": daemon.ContainerCreate,},则说明当名为"create"的job在运行时,执行的是daemon.ContainerCreate的handler。

2.3 job

Docker Server的架构图

一个Job可以认为是Docker架构中Engine内部最基本的工作执行单元。Docker可以做的每一项工作,都可以抽象为一个job。例如:在容器内部运行一个进程,这是一个job;创建一个新的容器,这是一个job。Docker Server的运行过程也是一个job,名为serveapi。

ob的设计者,把Job设计得与Unix进程相仿。比如说:Job有一个名称,有参数,有环境变量,有标准的输入输出,有错误处理,有返回状态等。

3. Docker Registry【镜像注册中心】

Docker Registry是一个存储容器镜像的仓库(注册中心),可理解为云端镜像仓库,按repository来分类,docker pull 按照[repository]:[tag]来精确定义一个image。

在Docker的运行过程中,Docker Daemon会与Docker Registry通信,并实现搜索镜像、下载镜像、上传镜像三个功能,这三个功能对应的job名称分别为"search","pull" 与 "push"。

可分为公有仓库(docker hub)和私有仓库。

4. Graph【docker内部数据库】

Graph的架构图


4.1 Repository

已下载镜像的保管者(包括下载镜像和dockerfile构建的镜像)。

一个repository表示某类镜像的仓库(例如Ubuntu),同一个repository内的镜像用tag来区分(表示同一类镜像的不同标签或版本)。一个registry包含多个repository,一个repository包含同类型的多个image。

镜像的存储类型有aufs,devicemapper,Btrfs,Vfs等。其中centos系统使用devicemapper的存储类型。

同时在Graph的本地目录中,关于每一个的容器镜像,具体存储的信息有:该容器镜像的元数据,容器镜像的大小信息,以及该容器镜像所代表的具体rootfs。

4.2 GraphDB

已下载容器镜像之间关系的记录者。

GraphDB是一个构建在SQLite之上的小型图数据库,实现了节点的命名以及节点之间关联关系的记录

5. Driver【执行部分】

Driver是Docker架构中的驱动模块。通过Driver驱动,Docker可以实现对Docker容器执行环境的定制。即Graph负责镜像的存储,Driver负责容器的执行。


5.1 graphdriver

graphdriver架构图

graphdriver主要用于完成容器镜像的管理,包括存储与获取。

  • 存储:docker pull下载的镜像由graphdriver存储到本地的指定目录(Graph中)。
  • 获取:docker run(create)用镜像来创建容器的时候由graphdriver到本地Graph中获取镜像。

5.2 networkdriver

networkdriver的架构图

networkdriver的用途是完成Docker容器网络环境的配置,其中包括

  • Docker启动时为Docker环境创建网桥;
  • Docker容器创建时为其创建专属虚拟网卡设备;
  • Docker容器分配IP、端口并与宿主机做端口映射,设置容器防火墙策略等。

5.3 execdriver

execdriver的架构图

execdriver作为Docker容器的执行驱动,负责创建容器运行命名空间,负责容器资源使用的统计与限制,负责容器内部进程的真正运行等。

现在execdriver默认使用native驱动,不依赖于LXC。


6. libcontainer【函数库】

libcontainer的架构图

libcontainer是Docker架构中一个使用Go语言设计实现的库,设计初衷是希望该库可以不依靠任何依赖,直接访问内核中与容器相关的API。


Docker可以直接调用libcontainer,而最终操纵容器的namespace、cgroups、apparmor、网络设备以及防火墙规则等。


libcontainer提供了一整套标准的接口来满足上层对容器管理的需求。或者说,libcontainer屏蔽了Docker上层对容器的直接管理。


7. docker container【服务交付的最终形式】

container架构

Docker container(Docker容器)是Docker架构中服务交付的最终体现形式。

Docker按照用户的需求与指令,订制相应的Docker容器:

  • 用户通过指定容器镜像,使得Docker容器可以自定义rootfs等文件系统;
  • 用户通过指定计算资源的配额,使得Docker容器使用指定的计算资源;
  • 用户通过配置网络及其安全策略,使得Docker容器拥有独立且安全的网络环境;
  • 用户通过指定运行的命令,使得Docker容器执行指定的工作。

三. 镜像分层和联合文件系统

1. 镜像分层

从上图中可以看到,新镜像是从 base 镜像一层一层叠加生成的。每安装一个软件,就在现有镜像的基础上增加一层。


镜像分层最大的一个好处就是共享资源。比如说有多个镜像都从相同的 base 镜像构建而来,那么 Docker Host 只需在磁盘上保存一份 base 镜像;同时内存中也只需加载一份 base 镜像,就可以为所有容器服务了。而且镜像的每一层都可以被共享。


如果多个容器共享一份基础镜像,当某个容器修改了基础镜像的内容,比如 /etc 下的文件,这时其他容器的 /etc 是不会被修改的,修改只会被限制在单个容器内。这就是容器 Copy-on-Write 特性。


可写的容器层

当容器启动时,一个新的可写层被加载到镜像的顶部。这一层通常被称作“容器层”,“容器层”之下的都叫“镜像层”。

所有对容器的改动 - 无论添加、删除、还是修改文件都只会发生在容器层中。只有容器层是可写的,容器层下面的所有镜像层都是只读的。


镜像层数量可能会很多,所有镜像层会联合在一起组成一个统一的文件系统。如果不同层中有一个相同路径的文件,比如 /a,上层的 /a 会覆盖下层的 /a,也就是说用户只能访问到上层中的文件 /a。在容器层中,用户看到的是一个叠加之后的文件系统。

文件操作
说明
添加文件
在容器中创建文件时,新文件被添加到容器层中。
读取文件
在容器中读取某个文件时,Docker 会从上往下依次在各镜像层中查找此文件。一旦找到,立即将其复制到容器层,然后打开并读入内存。
修改文件
在容器中修改已存在的文件时,Docker 会从上往下依次在各镜像层中查找此文件。一旦找到,立即将其复制到容器层,然后修改之。
删除文件
在容器中删除文件时,Docker 也是从上往下依次在镜像层中查找此文件。找到后,会在容器层中记录下此删除操作。(只是记录删除操作)

只有当需要修改时才复制一份数据,这种特性被称作 Copy-on-Write。可见,容器层保存的是镜像变化的部分,不会对镜像本身进行任何修改。 总结下来就是:容器层记录对镜像的修改,所有镜像层都是只读的,不会被容器修改,所以镜像可以被多个容器共享。


2. 联合文件系统

Docker 联合文件系统(Union File System,UnionFS)是 Docker 实现镜像分层和容器轻量化的核心技术。它通过层叠挂载多个文件系统,将不同位置的目录内容合并为一个统一的视图。


核心概念:

1. 分层存储(Layering)

  
  镜像:由多个只读层(Read-only Layers)堆叠组成,每层代表 Dockerfile 中的一个指令。

  容器:在镜像层之上添加一个可写层(Writable Layer),所有修改在此层进行。
  

2. 写时复制(Copy-on-Write, CoW)

  
  读操作:从底层只读层读取数据。

  写操作:首次修改文件时,将该文件从只读层复制到可写层,后续修改基于副本进行。

  删文件:在可写层创建 "whiteout" 标记隐藏底层文件。
  


常见 UnionFS 实现:

驱动
说明
overlay2
性能最优,Linux 内核原生支持,需 Linux 4.x+
devicemapper
块设备+快照,支持磁盘配额,配置复杂,性能较差
aufs
历史兼容性好 未进入内核主线,逐步淘汰
zfs
数据压缩/去重,内存占用高
btrfs
支持快照 稳定性风险

3. overlay2 深度解析

1. 目录结构

  
  /var/lib/docker/overlay2/
   ├── l/           # 硬链接缩短路径
   ├── diff/        # 每层实际内容(只读)
   ├── merged/      # 挂载点的统一视图
   └── work/        # OverlayFS 内部工作目录
  

2. 挂载原理

  
  mount -t overlay overlay -o lowerdir=layer1:layer2,upperdir=upper,workdir=work merged

  lowerdir:多个只读层(镜像层)
  upperdir:单个可写层(容器层)
  merged:统一视图目录
  

四. Docker挂载原理示意图

docker可以在执行run命令创建容器的时候用-v参数将宿主机的某个目录挂载到docker容器中指定的目录,从而实现docker容器内目录和宿主机上对应目录之间的映射。


其实现的本质原理其实很简单,就是挂载后修改了原docker容器内路径的dentry对应的inode指向,改成了宿主机对应路径对应的inode,从而后续的文件读写都是在操作宿主机的目录,至于dentry和inode这里不再详述。