Docker 的实现原理剖析

Docker 的发展历史


Docker 公司前身是 DotCloud,由 Solomon Hykes 在2010年成立,2013年更名 Docker。同年发布了 Docker-compose 组件提供容器的编排工具。2014年 Docker 发布1.0版本,2015年Docker 提供 Docker-machine,支持 windows 平台。


在此期间,Docker 项目在开源社区大受追捧,同时也被业界诟病的是 Docker 公司对于 Docker 发展具有绝对的话语权,比如 Docker 公司推行了 libcontainer  难以被社区接受。于是在一同贡献 Docker 代码的公司诸如 Redhat,谷歌的倡导下,成立了 OCI 开源社区,旨在于将 Docker 的发展权利回归社区,当然反过来讲,Docker 公司也希望更多的厂商安心贡献代码到Docker 项目,促进 Docker 项目的发展。于是通过OCI建立了 runc 项目,替代 libcontainer,这为开发者提供了除 Docker 之外的容器化实现的选择。


至2017年,Docker 项目转移到 Moby 项目,基于 Moby 项目,Docker 提供了两种发行版,Docker CE 和 Docker EE, Docker CE 就是目前大家普遍使用的版本,Docker EE 成为付费版本,提供了容器的编排,Service 等概念。Docker 公司承诺 Docker 的发行版会基于 Moby 项目。这样一来,通过 Moby 项目,你也可以自己打造一个定制化的容器引擎,而不会被 Docker 公司绑定。




Docker 底层的技术



Docker 的误解:Docker 是轻量级的虚拟机。


上图大家看到最多的一张 Docker 和虚拟机对比的图,从图上看来,确实看起来 Docker 实现了类似于虚拟化的技术,能够让应用跑在一些轻量级的容器里。这么理解其实是错误的。实际上 Docker 是使用了很多 Linux 的隔离功能,让容器看起来像一个轻量级虚拟机在独立运行,容器的本质是被限制了的 Namespaces,cgroup,具有逻辑上独立文件系统,网络的一个进程。其底层运用了如下 Linux 的能力:



 Namespaces


这里提出一个问题,在宿主机上启动两个容器,在这两个容器内都各有一个 PID=1的进程,众所周知,Linux 里 PID 是唯一的,既然 Docker 不是跑在宿主机上的两个虚拟机,那么它是如何实现在宿主机上运行两个相同 PID 的进程呢?


这里就用到了 Linux Namespaces,它其实是 Linux 创建新进程时的一个可选参数,在 Linux 系统中创建进程的系统调用是 clone()方法。



通过调用这个方法,这个进程会获得一个独立的进程空间,它的 pid 是1,并且看不到宿主机上的其他进程,这也就是在容器内执行 PS 命令的结果。


不仅仅是 PID,当你启动启动容器之后,Docker 会为这个容器创建一系列其他 namespaces。


这些 namespaces 提供了不同层面的隔离。容器的运行受到各个层面 namespace 的限制。


Docker Engine 使用了以下 Linux 的隔离技术:

  • The pid namespace: 管理 PID 命名空间 (PID: Process ID).

  • The net namespace: 管理网络命名空间(NET: Networking).

  • The ipc namespace: 管理进程间通信命名空间(IPC: InterProcess Communication).

  • The mnt namespace: 管理文件系统挂载点命名空间 (MNT: Mount).

  • The uts namespace: Unix 时间系统隔离. (UTS: Unix Timesharing System).

通过这些技术,运行时的容器得以看到一个和宿主机上其他容器隔离的环境。


Cgroups


在容器环境里,如果不做限制,运行一个 Java 应用,当应用有 Bug 导致 JVM 进行 Full GC 的时候,会占用大量的内存,也可能导致 CPU 飙升到100%,如何避免由单个容器的问题,导致整个集群不可用?Docker 用到了 Cgroups。Cgroups 是 Control Group 的缩写,由2007年谷歌工程师研发,2008年并入 Linux Kernel 2.6.24,由 C 语言编写。


Docker 底层使用 groups 对进程进行 CPU,Mem,网络等资源的使用限制,从而实现在宿主机上的资源分配,不至于出现一个容器占用所有宿主机的 CPU 或者内存。


具体的实现原理如上图所示,通过使用 cgroups,为不同的进程设定不同的配额,上图中 cgroup1 的进程只能使用60%的 CPU,cgroup2 使用20%的 CPU,同样可以为进程设定容器。所以当你在 Kubernetes 里为某个 pod 声明 CPU 限额时,底层就是调用的 cgroup 的设置。具体实现如下:


进入宿主机 cgroup 目录: cd /sys/fs/cgroup/cpu


为进程创建一个目录,例如 my_container, 然后 cgroup 会自动在这个目录下创建多个默认配置:



在cpu.cfs_quota_us里输入对应的数值,即可实现对 CPU 的配额设置:


echo 60000 > /sys/fs/cgroup/cpu/container/cpu.cfs_ q uota_us,这里的单位是毫秒,意思是每一毫秒内,该进程能够使用60%的 CPU 时间。如何将该配置应用到进程里?


echo 22880 > /sys/fs/cgroup/cpu/container/task s,其中22880是宿主机上的 PID,这时候你通过调用 top 命令,能够看到该进程的 CPU 使用率不会超过60%,这样就能实现对进程的限制,避免之前的问题发生。


UnionFS


容器有了进程隔离(视野隔离),CGroup 资源隔离,还缺少隔离的文件系统。试想,容器 A 的应用如果能直接访问到容器 B 的文件,会造成非常混乱的局面。为了解决这个问题,Docker 默认使用了 AuFS(Advanced Union FS) 来支持 Docker 镜像的 Layer,也支持其他 UnionFS 的版本。


UnionFS 的用法

在这个例子中,-o 是传入的目录参数, none 表示不会挂载任何驱动,最后是目标目录。执行的结果,就是 merged-folder 目录下会包含 fd1, fd2 两个目录的内容。在 Docker 的实现中,执行 Docker info 可以看到 aufs 的路径:

那么,如何使用 Aufs 的呢?可以回忆下,当我们用 Docker pull 时候的场景:

我们下载一个 Docker 镜像的时候,为什么会很多层?而且每一层的 id 是对应了什么内容?


Docker的镜像存储即用到了 aufs 技术。Docker 镜像的每一层都是只读的,如果需要对镜像增加内容,Docker 会使用 aufs 挂载一个 branch,指向diff 的目录,同时会给这一层 layer 设定一个唯一 id,这也就是为什么我们 pull 一个镜像的时候,会有多个带 id 的 layer 会被下载。


在 /var/lib/docker/aufs 目录下,可以找到 /diff,/layers/, /mnt 目录, 

/diff 管理 Docker 镜像每一层的内容。

/layer 管理Docker 镜像的元数据,层级关系。

/mnt 管理挂载点,通常对应一个镜像,或者 layer,用于描述一个容器镜像的所有层级内容。


Runc


之前提到,为了防止 Docker 这项开源技术被Docker 公司控制,在几个核心贡献的厂商,社区推动下成立了 OCI 社区。OCI 社区提供了 runc 的维护,而 runc 是基于 OCI 规范的运行容器的工具。换句话说,你可以通过 runc,提供自己的容器实现,而不需要依赖 Docker。当然,Docker 的发行版底层也是用的 runc。在 Docker 宿主机上执行 runc,你会发现它的大多数命令和 Docker 命令类似,感兴趣的读者可以自己实践如何用 runc 启动容器。



总结


至此,本文总结了 Docker 的发展历史,以及底层的实现,希望能够激发大家对 Docker 底层技术了解的兴趣。




参考资料

  • https://docs.docker.com/storage/storagedriver/aufs-driver/#how-the-aufs-storage-driver-works

  • https://github.com/opencontainers/runc

  • http://www.sel.zju.edu.cn/?p=840



文章作者:王青



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章