提到虚拟化技术,我们首先想到的一定是 Docker,经过四年的快速发展 Docker 已经成为了很多公司的生产环境中大规模使用,也不再是一个只能在开发阶段使用的玩具了。作为在生产环境中广泛应用的产品,Docker 有着非常成熟的社区以及大量的使用者,代码库中的内容也变得非常庞大。
![🐋[Docker] 核心技术与实现原理 - 图1](/uploads/projects/seekerzw@yaaygq/4d52b73f0f22a9bd0e64e7642ba9f6a0.png)
![🐋[Docker] 核心技术与实现原理 - 图2](/uploads/projects/seekerzw@yaaygq/dc286047865c9e029c6f087316f4a02d.png)
Namespaces
命名空间 (namespaces) 是 Linux 为我们提供的用于分离进程树、网络接口、挂载点以及进程间通信等资源的方法。在日常使用 Linux 或者 macOS 时,我们并没有运行多个完全分离的服务器的需要,但是如果我们在服务器上启动了多个服务,这些服务其实会相互影响的,每一个服务都能看到其他服务的进程,也可以访问宿主机器上的任意文件,这是很多时候我们都不愿意看到的,我们更希望运行在同一台机器上的不同服务能做到完全隔离,就像运行在多台不同的机器上一样。![🐋[Docker] 核心技术与实现原理 - 图3](/uploads/projects/seekerzw@yaaygq/4dd12c083e52dfd9ef7d17547b604350.png)
进程
进程是 Linux 以及现在操作系统中非常重要的概念,它表示一个正在执行的程序,也是在现代分时系统中的一个任务单元。在每一个 *nix 的操作系统上,我们都能够通过 ps 命令打印出当前操作系统中正在执行的进程,比如在 Ubuntu 上,使用该命令就能得到以下的结果:当前机器上有很多的进程正在执行,在上述进程中有两个非常特殊,一个是 pid 为 1 的 /sbin/init 进程,另一个是 pid 为 2 的 kthreadd 进程,这两个进程都是被 Linux 中的上帝进程 idle 创建出来的,其中前者负责执行内核的一部分初始化工作和系统配置,也会创建一些类似 getty 的注册进程,而后者负责管理和调度其他的内核进程。
$ ps -efUID PID PPID C STIME TTY TIME CMDroot 1 0 0 Apr08 ? 00:00:09 /sbin/initroot 2 0 0 Apr08 ? 00:00:00 [kthreadd]root 3 2 0 Apr08 ? 00:00:05 [ksoftirqd/0]root 5 2 0 Apr08 ? 00:00:00 [kworker/0:0H]root 7 2 0 Apr08 ? 00:07:10 [rcu_sched]root 39 2 0 Apr08 ? 00:00:00 [migration/0]root 40 2 0 Apr08 ? 00:01:54 [watchdog/0]...
![🐋[Docker] 核心技术与实现原理 - 图4](/uploads/projects/seekerzw@yaaygq/e9ab52811945ccefccabc88166a0a1cd.png)
在新的容器内部执行 ps 命令打印出了非常干净的进程列表,只有包含当前 ps -ef 在内的三个进程,在宿主机器上的几十个进程都已经消失不见了。 当前的 Docker 容器成功将容器内的进程与宿主机器中的进程隔离,如果我们在宿主机器上打印当前的全部进程时,会得到下面三条与 Docker 相关的结果:
root@iZ255w13cy6Z:~# docker run -it -d ubuntub809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79root@iZ255w13cy6Z:~# docker exec -it b809a2eb3630 /bin/bashroot@b809a2eb3630:/# ps -efUID PID PPID C STIME TTY TIME CMDroot 1 0 0 15:42 pts/0 00:00:00 /bin/bashroot 9 0 0 15:42 pts/1 00:00:00 /bin/bashroot 17 9 0 15:43 pts/1 00:00:00 ps -ef
在当前的宿主机器上,可能就存在由上述的不同进程构成的进程树:
UID PID PPID C STIME TTY TIME CMDroot 29407 1 0 Nov16 ? 00:08:38 /usr/bin/dockerd --raw-logsroot 1554 29407 0 Nov19 ? 00:03:28 docker-containerd -l unix:///var/run/docker/libcontainerd/docker-containerd.sock --metrics-interval=0 --start-timeout 2m --state-dir /var/run/docker/libcontainerd/containerd --shim docker-containerd-shim --runtime docker-runcroot 5006 1554 0 08:38 ? 00:00:00 docker-containerd-shim b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79 /var/run/docker/libcontainerd/b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79 docker-runc
![🐋[Docker] 核心技术与实现原理 - 图5](/uploads/projects/seekerzw@yaaygq/0fa031e0e3c9665ccb66a4e2de1f63ad.png)
Docker 的容器就是使用上述技术实现与宿主机器的进程隔离,当我们每次运行 docker run 或者 docker start 时,都会在下面的方法中创建一个用于设置进程间隔离的 Spec:
containerRouter.postContainersStart└── daemon.ContainerStart└── daemon.createSpec└── setNamespaces└── setNamespace
在 setNamespaces 方法中不仅会设置进程相关的命名空间,还会设置与用户、网络、IPC 以及 UTS 相关的命名空间:
func (daemon *Daemon) createSpec(c *container.Container) (*specs.Spec, error) {s := oci.DefaultSpec()// ...if err := setNamespaces(daemon, &s, c); err != nil {return nil, fmt.Errorf("linux spec namespaces: %v", err)}return &s, nil}
所有命名空间相关的设置 Spec 最后都会作为 Create 函数的入参在创建新的容器时进行设置:
func setNamespaces(daemon *Daemon, s *specs.Spec, c *container.Container) error {// user// network// ipc// uts// pidif c.HostConfig.PidMode.IsContainer() {ns := specs.LinuxNamespace{Type: "pid"}pc, err := daemon.getPidContainer(c)if err != nil {return err}ns.Path = fmt.Sprintf("/proc/%d/ns/pid", pc.State.GetPID())setNamespace(s, ns)} else if c.HostConfig.PidMode.IsHost() {oci.RemoveNamespace(s, specs.LinuxNamespaceType("pid"))} else {ns := specs.LinuxNamespace{Type: "pid"}setNamespace(s, ns)}return nil}
所有与命名空间的相关的设置都是在上述的两个函数中完成的,Docker 通过命名空间成功完成了与宿主机进程和网络的隔离。
daemon.containerd.Create(context.Background(), container.ID, spec, createOptions)
网络
如果 Docker 的容器通过 Linux 的命名空间完成了与宿主机进程的网络隔离,但是却有没有办法通过宿主机的网络与整个互联网相连,就会产生很多限制,所以 Docker 虽然可以通过命名空间创建一个隔离的网络环境,但是 Docker 中的服务仍然需要与外界相连才能发挥作用。 每一个使用 docker run 启动的容器其实都具有单独的网络命名空间,Docker 为我们提供了四种不同的网络模式,Host、Container、None 和 Bridge 模式。![🐋[Docker] 核心技术与实现原理 - 图6](/uploads/projects/seekerzw@yaaygq/b72cd5e3304878c08ae25179304aece3.png)
![🐋[Docker] 核心技术与实现原理 - 图7](/uploads/projects/seekerzw@yaaygq/de04848c646bdd3e8c4c20732096f1ba.png)
docker0 会为每一个容器分配一个新的 IP 地址并将 docker0 的 IP 地址设置为默认的网关。网桥 docker0 通过 iptables 中的配置与宿主机器上的网卡相连,所有符合条件的请求都会通过 iptables 转发到 docker0 并由网桥分发给对应的机器。
$ brctl showbridge name bridge id STP enabled interfacesdocker0 8000.0242a6654980 no veth3e84d4fveth9953b75
我们在当前的机器上使用 docker run -d -p 6379:6379 redis 命令启动了一个新的 Redis 容器,在这之后我们再查看当前 iptables 的 NAT 配置就会看到在 DOCKER 的链中出现了一条新的规则:
$ iptables -t nat -LChain PREROUTING (policy ACCEPT)target prot opt source destinationDOCKER all -- anywhere anywhere ADDRTYPE match dst-type LOCALChain DOCKER (2 references)target prot opt source destinationRETURN all -- anywhere anywhere
上述规则会将从任意源发送到当前机器 6379 端口的 TCP 包转发到 192.168.0.4:6379 所在的地址上。 这个地址其实也是 Docker 为 Redis 服务分配的 IP 地址,如果我们在当前机器上直接 ping 这个 IP 地址就会发现它是可以访问到的:
DNAT tcp -- anywhere anywhere tcp dpt:6379 to:192.168.0.4:6379
从上述的一系列现象,我们就可以推测出 Docker 是如何将容器的内部的端口暴露出来并对数据包进行转发的了;当有 Docker 的容器需要将服务暴露给宿主机器,就会为容器分配一个 IP 地址,同时向 iptables 中追加一条新的规则。
$ ping 192.168.0.4PING 192.168.0.4 (192.168.0.4) 56(84) bytes of data.64 bytes from 192.168.0.4: icmp_seq=1 ttl=64 time=0.069 ms64 bytes from 192.168.0.4: icmp_seq=2 ttl=64 time=0.043 ms^C--- 192.168.0.4 ping statistics ---2 packets transmitted, 2 received, 0% packet loss, time 999msrtt min/avg/max/mdev = 0.043/0.056/0.069/0.013 ms
![🐋[Docker] 核心技术与实现原理 - 图8](/uploads/projects/seekerzw@yaaygq/6553bce75bf27c851496452370f37c81.png)
Docker 通过 Linux 的命名空间实现了网络的隔离,又通过 iptables 进行数据包转发,让 Docker 容器能够优雅地为宿主机器或者其他容器提供服务。
$ redis-cli -h 127.0.0.1 -p 6379 pingPONG
libnetwork
整个网络部分的功能都是通过 Docker 拆分出来的 libnetwork 实现的,它提供了一个连接不同容器的实现,同时也能够为应用给出一个能够提供一致的编程接口和网络层抽象的容器网络模型。 The goal of libnetwork is to deliver a robust Container Network Model that provides a consistent programming interface and the required network abstractions for applications. libnetwork 中最重要的概念,容器网络模型由以下的几个主要组件组成,分别是 Sandbox、Endpoint 和 Network:![🐋[Docker] 核心技术与实现原理 - 图9](/uploads/projects/seekerzw@yaaygq/198d6a5a2f27d1caeb08f014c72ed03f.png)
挂载点
虽然我们已经通过 Linux 的命名空间解决了进程和网络隔离的问题,在 Docker 进程中我们已经没有办法访问宿主机器上的其他进程并且限制了网络的访问,但是 Docker 容器中的进程仍然能够访问或者修改宿主机器上的其他目录,这是我们不希望看到的。 在新的进程中创建隔离的挂载点命名空间需要在 clone 函数中传入 CLONE_NEWNS,这样子进程就能得到父进程挂载点的拷贝,如果不传入这个参数子进程对文件系统的读写都会同步回父进程以及整个主机的文件系统。 如果一个容器需要启动,那么它一定需要提供一个根文件系统(rootfs),容器需要使用这个文件系统来创建一个新的进程,所有二进制的执行都必须在这个根文件系统中。![🐋[Docker] 核心技术与实现原理 - 图10](/uploads/projects/seekerzw@yaaygq/26d9aa27b782dc91471cea1955c560cc.png)
![🐋[Docker] 核心技术与实现原理 - 图11](/uploads/projects/seekerzw@yaaygq/96eb337a83a8709eb8a0673aea60fdbf.png)
到这里我们就将容器需要的目录挂载到了容器中,同时也禁止当前的容器进程访问宿主机器上的其他目录,保证了不同文件系统的隔离。 这一部分的内容是作者在 libcontainer 中的 SPEC.md 文件中找到的,其中包含了 Docker 使用的文件系统的说明,对于 Docker 是否真的使用 chroot 来确保当前的进程无法访问宿主机器的目录,作者其实也没有确切的答案,一是 Docker 项目的代码太多庞大,不知道该从何入手,作者尝试通过 Google 查找相关的结果,但是既找到了无人回答的 问题,也得到了与 SPEC 中的描述有冲突的 答案 ,如果各位读者有明确的答案可以在博客下面留言,非常感谢。
// pivor_rootput_old = mkdir(...);pivot_root(rootfs, put_old);chdir("/");unmount(put_old, MS_DETACH);rmdir(put_old);// chrootmount(rootfs, "/", NULL, MS_MOVE, NULL);chroot(".");chdir("/");
chroot
在这里不得不简单介绍一下 chroot(change root),在 Linux 系统中,系统默认的目录就都是以 / 也就是根目录开头的,chroot 的使用能够改变当前的系统根目录结构,通过改变当前系统的根目录,我们能够限制用户的权利,在新的根目录下并不能够访问旧系统根目录的结构个文件,也就建立了一个与原系统完全隔离的目录结构。 与 chroot 的相关内容部分来自 理解 chroot 一文,各位读者可以阅读这篇文章获得更详细的信息。CGroups
我们通过 Linux 的命名空间为新创建的进程隔离了文件系统、网络并与宿主机器之间的进程相互隔离,但是命名空间并不能够为我们提供物理资源上的隔离,比如 CPU 或者内存,如果在同一台机器上运行了多个对彼此以及宿主机器一无所知的『容器』,这些容器却共同占用了宿主机器的物理资源。![🐋[Docker] 核心技术与实现原理 - 图12](/uploads/projects/seekerzw@yaaygq/621166eef76cdf117f77310ef4758034.png)
![🐋[Docker] 核心技术与实现原理 - 图13](/uploads/projects/seekerzw@yaaygq/d7bd0cab8f77190f3e7146e236b9c4ea.png)
![🐋[Docker] 核心技术与实现原理 - 图14](/uploads/projects/seekerzw@yaaygq/747021d3fefe35b94cb9b295d398b17c.png)
大多数 Linux 的发行版都有着非常相似的子系统,而之所以将上面的 cpuset、cpu 等东西称作子系统,是因为它们能够为对应的控制组分配资源并限制资源的使用。 如果我们想要创建一个新的 cgroup 只需要在想要分配或者限制资源的子系统下面创建一个新的文件夹,然后这个文件夹下就会自动出现很多的内容,如果你在 Linux 上安装了 Docker,你就会发现所有子系统的目录下都有一个名为 docker 的文件夹:
$ lssubsys -mcpuset /sys/fs/cgroup/cpusetcpu /sys/fs/cgroup/cpucpuacct /sys/fs/cgroup/cpuacctmemory /sys/fs/cgroup/memorydevices /sys/fs/cgroup/devicesfreezer /sys/fs/cgroup/freezerblkio /sys/fs/cgroup/blkioperf_event /sys/fs/cgroup/perf_eventhugetlb /sys/fs/cgroup/hugetlb
9c3057xxx 其实就是我们运行的一个 Docker 容器,启动这个容器时,Docker 会为这个容器创建一个与容器标识符相同的 CGroup,在当前的主机上 CGroup 就会有以下的层级关系:
$ ls cpucgroup.clone_children...cpu.statdockernotify_on_releaserelease_agenttasks$ ls cpu/docker/9c3057f1291b53fd54a3d12023d2644efe6a7db6ddf330436ae73ac92d401cf1cgroup.clone_children...cpu.statnotify_on_releaserelease_agenttasks
![🐋[Docker] 核心技术与实现原理 - 图15](/uploads/projects/seekerzw@yaaygq/a2b65dbe247fbf6f85a44c685e985ad6.png)
当我们使用 Docker 关闭掉正在运行的容器时,Docker 的子控制组对应的文件夹也会被 Docker 进程移除,Docker 在使用 CGroup 时其实也只是做了一些创建文件夹改变文件内容的文件操作,不过 CGroup 的使用也确实解决了我们限制子容器资源占用的问题,系统管理员能够为多个容器合理的分配资源并且不会出现多个容器互相抢占资源的问题。
$ docker run -it -d --cpu-quota=50000 busybox53861305258ecdd7f5d2a3240af694aec9adb91cd4c7e210b757f71153cdd274$ cd 53861305258ecdd7f5d2a3240af694aec9adb91cd4c7e210b757f71153cdd274/$ lscgroup.clone_children cgroup.event_control cgroup.procs cpu.cfs_period_us cpu.cfs_quota_us cpu.shares cpu.stat notify_on_release tasks$ cat cpu.cfs_quota_us50000
UnionFS
Linux 的命名空间和控制组分别解决了不同资源隔离的问题,前者解决了进程、网络以及文件系统的隔离,后者实现了 CPU、内存等资源的隔离,但是在 Docker 中还有另一个非常重要的问题需要解决 - 也就是镜像。 镜像到底是什么,它又是如何组成和组织的是作者使用 Docker 以来的一段时间内一直比较让作者感到困惑的问题,我们可以使用 docker run 非常轻松地从远程下载 Docker 的镜像并在本地运行。 Docker 镜像其实本质就是一个压缩包,我们可以使用下面的命令将一个 Docker 镜像中的文件导出:你可以看到这个 busybox 镜像中的目录结构与 Linux 操作系统的根目录中的内容并没有太多的区别,可以说 Docker 镜像就是一个文件。
$ docker export $(docker create busybox) | tar -C rootfs -xvf -$ lsbin dev etc home proc root sys tmp usr var
存储驱动
Docker 使用了一系列不同的存储驱动管理镜像内的文件系统并运行容器,这些存储驱动与 Docker 卷(volume)有些不同,存储引擎管理着能够在多个容器之间共享的存储。 想要理解 Docker 使用的存储驱动,我们首先需要理解 Docker 是如何构建并且存储镜像的,也需要明白 Docker 的镜像是如何被每一个容器所使用的;Docker 中的每一个镜像都是由一系列只读的层组成的,Dockerfile 中的每一个命令都会在已有的只读层上创建一个新的层:容器中的每一层都只对当前容器进行了非常小的修改,上述的 Dockerfile 文件会构建一个拥有四层 layer 的镜像:
FROM ubuntu:15.04COPY . /appRUN make /appCMD python /app/app.py
![🐋[Docker] 核心技术与实现原理 - 图16](/uploads/projects/seekerzw@yaaygq/4b510eaee1fdd96f11239e5f696600b5.png)
![🐋[Docker] 核心技术与实现原理 - 图17](/uploads/projects/seekerzw@yaaygq/f148fdd8f19061b927f55b63b2311253.png)
AUFS
UnionFS 其实是一种为 Linux 操作系统设计的用于把多个文件系统『联合』到同一个挂载点的文件系统服务。而 AUFS 即 Advanced UnionFS 其实就是 UnionFS 的升级版,它能够提供更优秀的性能和效率。 AUFS 作为联合文件系统,它能够将不同文件夹中的层联合(Union)到了同一个文件夹中,这些文件夹在 AUFS 中称作分支,整个『联合』的过程被称为联合挂载(Union Mount):![🐋[Docker] 核心技术与实现原理 - 图18](/uploads/projects/seekerzw@yaaygq/4ca37c58725c6d98085f355c7aa2c1fa.jpeg)
而 /var/lib/docker/aufs/layers/ 中存储着镜像层的元数据,每一个文件都保存着镜像层的元数据,最后的 /var/lib/docker/aufs/mnt/ 包含镜像或者容器层的挂载点,最终会被 Docker 通过联合的方式进行组装。
$ ls /var/lib/docker/aufs/diff/00adcccc1a55a36a610a6ebb3e07cc35577f2f5a3b671be3dbc0e74db9ca691c 93604f232a831b22aeb372d5b11af8c8779feb96590a6dc36a80140e38e764d800adcccc1a55a36a610a6ebb3e07cc35577f2f5a3b671be3dbc0e74db9ca691c-init 93604f232a831b22aeb372d5b11af8c8779feb96590a6dc36a80140e38e764d8-init019a8283e2ff6fca8d0a07884c78b41662979f848190f0658813bb6a9a464a90 93b06191602b7934fafc984fbacae02911b579769d0debd89cf2a032e7f35cfa...
![🐋[Docker] 核心技术与实现原理 - 图19](/uploads/projects/seekerzw@yaaygq/6fbb24d93d7980f9da25aa8b314bdbff.jpeg)
其他存储驱动
AUFS 只是 Docker 使用的存储驱动的一种,除了 AUFS 之外,Docker 还支持了不同的存储驱动,包括 aufs、devicemapper、overlay2、zfs 和 vfs 等等,在最新的 Docker 中,overlay2 取代了 aufs 成为了推荐的存储驱动,但是在没有 overlay2 驱动的机器上仍然会使用 aufs 作为 Docker 的默认驱动。![🐋[Docker] 核心技术与实现原理 - 图20](/uploads/projects/seekerzw@yaaygq/a5e7e4fb0101d13106ffcff449f78a0e.png)
作者的这台 Ubuntu 上由于没有 overlay2 存储驱动,所以使用 aufs 作为 Docker 的默认存储驱动。
$ docker info | grep StorageStorage Driver: aufs
总结
Docker 目前已经成为了非常主流的技术,已经在很多成熟公司的生产环境中使用,但是 Docker 的核心技术其实已经有很多年的历史了,Linux 命名空间、控制组和 UnionFS 三大技术支撑了目前 Docker 的实现,也是 Docker 能够出现的最重要原因。 作者在学习 Docker 实现原理的过程中查阅了非常多的资料,从中也学习到了很多与 Linux 操作系统相关的知识,不过由于 Docker 目前的代码库实在是太过庞大,想要从源代码的角度完全理解 Docker 实现的细节已经是非常困难的了,但是如果各位读者真的对其实现细节感兴趣,可以从 Docker CE 的源代码开始了解 Docker 的原理。Reference
- Chapter 4. Docker Fundamentals · Using Docker by Adrian Mount
- TECHNIQUES BEHIND DOCKER
- Docker overview
- Unifying filesystems with union mounts
- DOCKER 基础技术:AUFS
- RESOURCE MANAGEMENT GUIDE
- Kernel Korner - Unionfs: Bringing Filesystems Together
- Union file systems: Implementations, part I
- IMPROVING DOCKER WITH UNIKERNELS: INTRODUCING HYPERKIT, VPNKIT AND DATAKIT
- Separation Anxiety: A Tutorial for Isolating Your System with Linux Namespaces
- 理解 chroot
- Linux Init Process / PC Boot Procedure
- Docker 网络详解及 pipework 源码解读与实践
- Understand container communication
- Docker Bridge Network Driver Architecture
- Linux Firewall Tutorial: IPTables Tables, Chains, Rules Fundamentals
- Traversing of tables and chains
- Docker 网络部分执行流分析(Libnetwork 源码解读)
- Libnetwork Design
- 剖析 Docker 文件系统:Aufs与Devicemapper
- Linux - understanding the mount namespace & clone CLONE_NEWNS flag
- Docker 背后的内核知识 —— Namespace 资源隔离
- Infrastructure for container projects
- Spec · libcontainer
- DOCKER 基础技术:LINUX NAMESPACE(上)
- DOCKER 基础技术:LINUX CGROUP
- 《自己动手写Docker》书摘之三:Linux UnionFS
- Introduction to Docker
- Understand images, containers, and storage drivers
- Use the AUFS storage driver
