本篇文章将从技术的角度来普及一下,这个包装应用的“集装箱”到底是如何实现的。

很多人接触容器时都看过这张图片,非常自然的接受了容器约等于“集装箱”的概念。:

确实,容器就是把应用打包起来的技术,就像集装箱一样,不同的应用很独立,能够很方便的Ship,这是所有PaaS平台最理想的状态。

容器的本质

问题1: * 操作系统中运行着一个个容器吗?*
答案当然是否定的,因为操作系统并没有容器的概念。那么所谓的“容器”到底是什么东西呢。
首先我们启动一个容器。docker run -it busybox /bin/sh。这样我们就运行了一个执行/bin/sh的容器,并通过-it参数分配了一个命令行终端与容器交互。这时候我们看一下容器内都运行着什么。

/ # ps a
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    8 root      0:00 ps a

可以看到只有两个进程,一个是启动时候的/bin/sh,一个是刚刚执行的ps a。我们知道,每当在操作系统中运行一个进程,操作系统都会分配一个进程编号,即PID,这个也是进程的唯一标识。而/bin/sh的进程PID=1也就意味着这是第一个启动的进程。我们再到宿主机看看这个进程:

PID   USER     TIME  COMMAND
  41239 pts/0    Sl+    0:00 docker run -it busybox /bin/sh
  41274 pts/0    Ss+    0:00 /bin/sh
  41312 pts/1    R+     0:00 ps a

可以看出/bin/sh包括 ps a在宿主机操作系统里的PID还是原来的PID。这就是Docker对进程动的手脚,让他只能看到动过手脚的PID,这种技术就是Linux的Namespace机制。
在Linux中创建进程的系统调用是clone(),而namespace是一个它的可选参数,执行clone会返回新进程的PID。函数签名如下:
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHILD, NULL)
当参数指定了CLONE_NEWPID的时候,新创建的进程会得到一个新的进程空间,在这个空间里它的PID是1,而在宿主机真实的进程空间中,这个进程的PID还是真实的数值。这个clone方法还可以多次调用,这样会创建多个PID Namespace,而每个Namespace里的进程都以为自己是第一号进程,他们看不到宿主机的真正进程空间,也看不到其他PID Namespace的情况。
除了PID Namespace外,Linux还提供了Mount,UTS,IPC,Network和User这些命名空间,用来对进程进行“集装箱”式的包装处理。
到这,就可以回答问题了,操作系统中并没有运行容器,Docker只是在运行进程时制定了这个进程的各类Namespace,确保进程只能看到当前Namespace所限定的资源,文件,设备,状态,网络等信息——容器只是一种特殊的进程。
问题2:容器的隔离看明白了,那怎么限制容器的资源呢
通过Namespace限制的进程已经成为容器了吗,类比一下,我们给货物加了盒子,但是我们并没有指定盒子的大小,也就是这个进程尽管看不到其他Namespace的东西,但是还是和其他普通进程一样平等,共同享用一个CPU,一条内存。其他进程可以占用它的资源,它也可以把所有资源消耗完毕,这显然不应该这样。这里限制进程的资源就要用到Cgroups技术了。它的最主要作用就是限制一个进程组能够使用的资源上限,此外还能对进程进行优先级设置,审计,挂起和恢复等操作。
在Linux中,Cgroups给用户暴露出来的操作接口是文件,可以通过mount -t cgroup查看

[root@10-23-234-84 ~]# mount -t cgroup
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)
cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)

可以看到在/sys/fs/cgroup下有很多诸如cpu,memeory这样的子目录,这些都是当前这台机器可以限制的资源种类。而看具体资源可以被限制的方法,可以用ls查看

[root@10-23-234-84 ~]# ls /sys/fs/cgroup/cpu
cgroup.clone_children     cpuacct.usage_percpu_user  cpu.stat
cgroup.procs              cpuacct.usage_sys          docker
cgroup.sane_behavior      cpuacct.usage_user         init.scope
cpuacct.stat              cpu.cfs_period_us          notify_on_release
cpuacct.usage             cpu.cfs_quota_us           release_agent
cpuacct.usage_all         cpu.rt_period_us           system.slice
cpuacct.usage_percpu      cpu.rt_runtime_us          tasks
cpuacct.usage_percpu_sys  cpu.shares                 user.slice

眼尖的已经发现有cfs_period和cfs_quota之类的关键词,它们组合使用就可以限制进程在长度为cfs_period的时间里只能分配到cfs_quota的CPU时间。让我们来用一下,看一下它们的威力。首先我们写一个死循环把CPU消耗完毕。

[root@10-23-234-84 ~]# while : ; do : ; done &
[1] 41418

这里记录一下该进程的PID:41418,后面对其限制要用到。现在先执行top可以看一下效果:

[root@10-23-234-84 ~]# top

top - 15:01:43 up 21:52,  2 users,  load average: 0.88, 0.27, 0.10
Tasks: 103 total,   2 running, 101 sleeping,   0 stopped,   0 zombie
%Cpu(s):100.0 us,  0.0 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st

可以看到CPU已经打满了。这时候我们进入/sys/fs/cgroup/cpu目录下创建一个目录:

[root@10-23-234-84 ~]# cd /sys/fs/cgroup/cpu
[root@10-23-234-84 cpu]# mkdir container
[root@10-23-234-84 cpu]# ls
cgroup.clone_children     cpuacct.usage_percpu_user  docker
cgroup.procs              cpuacct.usage_sys          init.scope
cgroup.sane_behavior      cpuacct.usage_user         notify_on_release
container                 cpu.cfs_period_us          release_agent
cpuacct.stat              cpu.cfs_quota_us           system.slice
cpuacct.usage             cpu.rt_period_us           tasks
cpuacct.usage_all         cpu.rt_runtime_us          user.slice
cpuacct.usage_percpu      cpu.shares
cpuacct.usage_percpu_sys  cpu.stat

可以看到操作系统会在新创建的目录下自动生成资源限制文件。这时候查看对CPU的限制可以看到还没有任何限制:

[root@10-23-234-84 cpu]# cat cpu.cfs_period_us 
100000
[root@10-23-234-84 cpu]# cat cpu.cfs_quota_us 
-1

我们可以修改其值来设置限制,比如将cfs_quota设置为20ms(20000 us):

echo 20000 > cpu.cfs_quota_us

这意味着100ms的CPU时间里,被该控制组限制的进程只有20ms的CPU时间,也就是只能用20%的CPU,接下来将刚刚死循环进程PID写入tasks文件,[root@10-23-234-84 container]# echo 41418 > tasks这个限制就会生效了,我们再top观察一下可以看到:

[root@10-23-234-84 ~]# top

top - 15:27:52 up 22:19,  2 users,  load average: 0.63, 1.00, 0.94
Tasks: 100 total,   3 running,  97 sleeping,   0 stopped,   0 zombie
%Cpu(s): 20.2 us,  0.0 sy,  0.0 ni, 79.1 id,  0.0 wa,  0.3 hi,  0.3 si,  0.0 st
MiB Mem :   1848.9 total,    599.5 free,    221.9 used,   1027.4 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.   1448.7 avail Mem 

CPU时间只有20%了。除了CPU之外,Cgroup的每一个子系统都有独有的资源限制能力,比如磁盘的I/O,内存使用等。

总结

所以容器的本质,就是通过Namespace限制了进程的视图和通过Cgroup限制进程的资源,给进程装上了“盒子”。用户的应用程序实际上就是容器里PID=1的进程,也是其他后续创建的所有进程的父进程。这也就是为什么Docker建议容器和应用同生命周期,这也是后面编排系统以及容器设计模式的一个重要概念:单进程模型