Docker原理解析——沙盒

  上一篇,顺着云计算的发展史,我们看到了兴起于 PaaS 技术普及的容器技术,看到了容器技术通过容器镜像彻底解决了打包问题,按照连续思维,其实接下来应该讲讲 Docker 出现之后有哪些竞争对手,为什么 Docker 可以胜出,连带着让 Docker 几乎等同于容器技术本身,容器编排竞争中 Swarm 为什么输给了 Kubernetes,不过出于个人对整个云原生知识网络的感受,我觉得可以在这次开个分支,先不谈历史,讲一下 Docker 的实现原理。

本文主要是解释 Docker 沙盒的工作原理,回答诸如但不限于以下问题:

  • 为什么容器里只能跑“一个进程”?
  • 容器和虚拟机有什么区别?

隔离魔术:Linux NameSpace

什么是 Linux NameSpace

  Linux Namespace是Linux提供的一种内核级别环境隔离的方法,可以提供多种类别的环境隔离赋能,下面我们以 PID NameSpace 为例,看看容器是怎么样应用 Linux NameSpace 来构建独立的环境的。
  Linux下的 root 进程的PID是 1,NameSpace 可以提供一种机制,让 NameSpace 内部的其他进程认为主进程的 PID 是 1,不同 NameSpace 之间的进程无法看到彼此。
  执行docker run -it busybox /bin/sh,这句命令的具体作用是,启动一个busybox镜像的容器,在容器中执行 /bin/sh,并直接进入容器中。

1
2
3
4
5
6
7
8
[root@master ~]# docker run -it busybox /bin/sh
Unable to find image 'busybox:latest' locally
latest: Pulling from library/busybox
24fb2886d6f6: Pull complete
Digest: sha256:f7ca5a32c10d51aeda3b4d01c61c6061f497893d7f6628b92f822f7117182a57
Status: Downloaded newer image for busybox:latest
/ #

  然后我们在容器中运行 ps 命令查看进程

1
2
3
4
5
/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
8 root 0:00 ps
/ #

  可以清晰地看到,容器中一共就两个进程,我们启动的 /bin/sh 为 1 号进程,ps 的进程为 2 号进程,这两个进程已经被隔绝到了跟宿主机不同的单独空间中。
  而这就是 NameSpace 做的工作。

  除了进程 ID 隔离(PID Namespace)之外,Linux NameSpace 还提供了多种隔离种类:

分类系统调用参数相关内核版本
Mount namespacesCLONE_NEWNSLinux 2.4.19
UTS namespacesCLONE_NEWUTSLinux 2.6.19
IPC namespacesCLONE_NEWIPCLinux 2.6.19
PID namespacesCLONE_NEWPIDLinux 2.6.24
Network namespacesCLONE_NEWNET始于Linux 2.6.24 完成于 Linux 2.6.29
User namespacesCLONE_NEWUSER始于 Linux 2.6.23 完成于 Linux 3.8

  而容器环境,就是对这些隔离种类的复合使用的结果,具体的调用测试可以参照“左耳朵耗子”大佬在多年前的一篇文章《DOCKER基础技术:LINUX NAMESPACE(上)》,调用文档可以参考官方文档

Linux NameSpace 的优势(对比虚拟机)


  上图是网上常见的虚拟机和容器的对比通,把容器管理系统放到了跟虚拟系统一个级别,实际是有问题的,我们对它做一下纠正。

  如上图所示,因为容器的本质其实就是 Linux 系统上的原生进程做了特殊的隔离措施,对容器的操作其实都是原生物理机操作系统的进程操作,没有中间的性能损耗。而虚拟机自身都是完整的操作系统,本身就要占用不小的内存,用户程序运行在虚拟机内部,对宿主机的计算资源、网络和磁盘 I/O 的调用必须要经过虚拟化软件的拦截和处理,性能损耗很大。

  故而容器启动要比虚拟机快很多,性能也是要比虚拟机更高,概括一下,即“敏捷”“高性能”,这是容器技术在 PaaS 时代大行其道的重要原因。
  。

Linux NameSpace 带来的问题(对比虚拟机)

  Linux NameSpace 的隔离并非只有好处,有利就有弊,主要的问题有以下两点。
  1. 隔离的不彻底。
  2. 有一些资源和对象无法被隔离。

隔离的不彻底

  尽管我们可以在容器里通过 Mount Namespace 单独挂载其他不同版本的操作系统的 rootfs 文件,比如 CentOS 或者 Ubuntu 等各种 Linux 发行版,但这并不能改变共享宿主机内核的事实。这意味着,如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本或者低版本的 Linux 容器,都是行不通的。
  而相比之下,拥有硬件虚拟化技术和独立 Guest OS 的虚拟机就要方便得多了。最极端的例子是,Microsoft 的云计算平台 Azure,实际上就是运行在 Windows 服务器集群上的,但这并不妨碍你在它上面创建各种 Linux 虚拟机出来。

有一些资源和对象无法被隔离

  一个很经典的例子就是时间,如果在容器中调用 settimeofday 更改了时间,那么就会对宿主机以及所有宿主机上的容器造成影响,所以在生产环境中,没有人敢把运行在物理机上的容器直接暴露到公网上。
  而虚拟机虚拟化的是完整的操作系统,完全不存在这样的顾虑。

  当然,现在其实出现了一些基于虚拟化或者独立内核技术的容器实现,可以比较好地在隔离与性能之间做出平衡,后续我们也会对这种方案做一些介绍和实践。

限制大法:Linux Cgroups

  在使用了 NameSpace 做隔离之后,“容器”已经被创建出来了,但是当前被隔离的进程是完全没有做任何限制的,能够使用到100% 的物理资源,如内存、CPU等,这显然不符合我们的需求。
  Linux Cgroups 就是我们用来对进程(组)做限制的系统能力。
  Linux Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
  此外,Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。
  
  下面我们做一个试验,亲自操作一下 Cgroups 的限制能力。
  在 Linux 中,Cgroups 暴露给用户的操作接口是文件系统的形式存在的,它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下,使用 mount 命令可以将它展示出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@master ~]# mount -t cgroup 
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,net_cls,net_prio)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,cpu,cpuacct)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,freezer)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,devices)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,memory)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,blkio)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,perf_event)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,hugetlb)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,cpuset)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,pids)
cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,seclabel,rdma)

  这些诸如 cpuset、cpu、 memory 这样的子目录就是这台机器当前可以被 Cgroups 进行限制的资源种类。在对应的资源种类下,可以看到该类资源具体可以被限制的方法。

1
2
3
4
[root@master ~]# ls /sys/fs/cgroup/cpu
cgroup.clone_children cpuacct.stat cpuacct.usage_percpu cpuacct.usage_sys cpu.cfs_quota_us cpu.shares kubepods system.slice
cgroup.procs cpuacct.usage cpuacct.usage_percpu_sys cpuacct.usage_user cpu.rt_period_us cpu.stat notify_on_release tasks
cgroup.sane_behavior cpuacct.usage_all cpuacct.usage_percpu_user cpu.cfs_period_us cpu.rt_runtime_us docker release_agent

下面我们尝试使用 Cgroups 对自己运行的进程做限制。

写一个死循环程序,命名为 test.c,然后用 gcc 编译
程序源码:

1
2
3
4
5
6
7
8
#include <stdio.h>
int main()
{
long i=0;
while(1)
{i++;}
return 0;
}

操作:

1
2
3
[root@master cpu]# cd ~/
[root@master ~]# vi test.c
[root@master ~]# gcc test.c tes

随后运行程序并使用 top 命令查看程序占用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[root@master ~]# nohup ./test &
[1] 136487
[root@master ~]# top
top - 00:30:58 up 3:26, 3 users, load average: 1.16, 0.68, 0.66
Tasks: 287 total, 2 running, 285 sleeping, 0 stopped, 0 zombie
%Cpu(s): 25.7 us, 0.5 sy, 0.0 ni, 71.4 id, 0.0 wa, 2.1 hi, 0.3 si, 0.0 st
MiB Mem : 7742.0 total, 3245.5 free, 2250.1 used, 2246.3 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 5115.1 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
136487 root 20 0 4224 824 756 R 97.0 0.0 1:06.70 test
3175 root 20 0 1243688 528740 75124 S 6.3 6.7 13:34.29 kube-apiserver
1105 root 20 0 2183524 121736 74692 S 1.3 1.5 4:58.10 kubelet
17097 root 20 0 889800 140096 65000 S 1.3 1.8 3:47.23 kube-controller
3288 root 20 0 10.2g 138408 36164 S 1.0 1.7 3:50.64 etcd
742 root 20 0 154412 49324 41928 S 0.3 0.6 0:45.41 systemd-journal
1754 root 20 0 2328956 105312 53004 S 0.3 1.3 1:06.30 dockerd
1 root 20 0 252572 14848 9552 S 0.0 0.2 0:05.89 systemd

  可以看到,我们的测试程序启动后 PID 为 136487 ,它在启动后几乎无节制的占用了 CPU 的 97% 左右。
  
  下面我们使用 Cgroups 对它做一些CPU限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[root@master ~]# cd /sys/fs/cgroup/cpu/new_test/
[root@master new_test]# ls
cgroup.clone_children cpuacct.stat cpuacct.usage_all cpuacct.usage_percpu_sys cpuacct.usage_sys cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
cgroup.procs cpuacct.usage cpuacct.usage_percpu cpuacct.usage_percpu_user cpuacct.usage_user cpu.cfs_quota_us cpu.rt_runtime_us cpu.stat tasks
[root@master new_test]# cat cpu.cfs_quota_us
-1
[root@master new_test]# echo 100000 > cpu.cfs_period_us
[root@master new_test]# echo 20000 > cpu.cfs_quota_us
[root@master new_test]# cat cpu.cfs_quota_us
20000
[root@master new_test]# echo 136487 > tasks
[root@master new_test]# top
top - 00:37:05 up 3:33, 4 users, load average: 1.11, 1.32, 1.00
Tasks: 297 total, 2 running, 295 sleeping, 0 stopped, 0 zombie
%Cpu(s): 5.8 us, 0.5 sy, 0.0 ni, 91.7 id, 0.0 wa, 1.7 hi, 0.3 si, 0.0 st
MiB Mem : 7742.0 total, 3223.5 free, 2256.5 used, 2262.0 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 5100.6 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
136487 root 20 0 4224 824 756 R 19.9 0.0 6:37.88 test
3175 root 20 0 1243688 528740 75124 S 6.0 6.7 13:59.71 kube-apiserver
17097 root 20 0 889800 134080 65000 S 4.3 1.7 3:54.87 kube-controller
3288 root 20 0 10.2g 138408 36164 S 1.7 1.7 3:56.05 etcd
1105 root 20 0 2183780 123964 74692 S 1.0 1.6 5:05.89 kubelet
742 root 20 0 170696 56944 49416 S 0.3 0.7 0:46.87 systemd-journal
953 root 20 0 575876 12372 10484 S 0.3 0.2 0:20.45 vmtoolsd
1057 root 20 0 1647028 60856 24616 S 0.3 0.8 0:05.48 containerd

实验完成。

而 docker 的 Cgroups 组其实就在 /sys/fs/cgroup/…/docker/…/ 下。

总结

  综上所述,一个正在运行的 Docker 容器,其实就是启用了多个 Linux Namespace 的,受 Cgroups 配置的限制的应用进程

  然后我们就能自然而然的得出另一个结论,容器是一个“单进程”模型

欢迎关注我的其它发布渠道