容器基础之障眼法

上一篇文章讲到,“容器编排”是容器世界的“顶上战争”,是最有价值的部分。不过,在了解Kubernetes的容器编排之前,我们要先打好容器的基础。

容器其实是一种沙盒技术,它把你的应用装起来,应用和应用之间是独立的,不会相互干扰。而被装进集装箱的应用也可以方便地搬来搬去。想象一种情况,当前的应用因为本地的内存不够挂了,然后容器就会在另一个合适的地方重启启动一个应用提供服务,保证服务的持续和稳定。

这种能力,究竟是怎么实现的呢?
答案就是今天的主题——Linux提供的障眼法工具:Namespace和Cgroups。

从进程开始讲起

我们编写的程序最终都要编译为机器认识的二进制,才能在机器上运行。在程序启动之前,它是静态的,安安静静的呆在磁盘中,但是当程序被执行起来了,它就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件、以及各种设备的状态信息的集合。像这样一个程序运行起来后的计算机执行环境的综合,就是我们这一小节的主角:进程。

而容器的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。

容器的隔离之术:Namespace蒙蔽进程的双眼

Namespace技术是Linux用来修改进程的视图的工具,简单来说,就是蒙蔽进程的双眼,让进程以为自己看到的就是所有,让进程生活在自己的世界中。

举个例子,我们用大名鼎鼎的docker run创建一个容器:

1
$ docker run -it ubuntu /bin/sh

-it告诉容器项目,在启动容器的时候,需要为我们分配一个文本输入输出环境,也就是TTY。这条指令的意思:请帮我启动一个容器,在容器里执行/bin/sh,并且为我分配一个命令行终端跟我这个容器交互。

进入容器后,你执行以下ps命令,会发现:

1
2
3
PID  USER   TIME COMMAND
1 root 0:00 /bin/sh
10 root 0:00 ps

也就是说,在容器里面,它以为它是老大。而事实上,这个容器在宿主机上只是一个最普通的进程。也就是说,它被“隔离”了

这种技术,就是Linux里面的Namespace机制。而Namespace它其实只是Linux创建新进程的一个可选参数。

在linux系统中创建线程/进程的系统调用是clone(),例如,我们在创建进程的时候,传入CLONE_NEWPID的参数:

1
int pid = clone(main_funtion,stack_size,CLONE_NEWPID | SIGCHLD,NULL);

这时,新创建的进程将会看到一个全新的空间,在这个进程空间里,它的PID是1,而在宿主机,这个进程的PID还是真实的数值,例如100。

除了PID命名空间障眼法,Linux还提供了其他各种进程上下文的障眼法。如Mount、UTS、IPC、Network、User。
比如:Mount Namespace,用于让被隔离的进程,只看到当前空间的挂载信息。Network Namespace 用户让进程只看到当前的网络设备和配置。

所以,Docker容器这个听起来玄而又玄的概念,实际上就是在创建容器进程时,指定了这个进程需要启用的一组Namespace参数。让进程只看到当前命名空间所限定的资源、文件、设备、状态、配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。

容器的限制之术:Cgroups困住进程的手脚

使用Namespace技术蒙蔽进程了双眼,但是对于宿主机来说,这些被隔离的进程跟其他进程没有太大的区别。这就意味着,虽然进程表面被隔离了,但是它所能够使用到的资源(比如CPU、内存)却是可以随时被宿主机上的其他进程或者容器占用的。当然,这个被隔离的进程也可以把宿主机的资源全部吃光。

于是,我们希望能够对这个进程使用的资源进行限制。

Cgroups在系统内核的作用是用来统一将进程进行分组,并在分组的基础上对进程进行监控和资源控制管理等。Cgroups的全称是Linux Control Group。它能够限制一个进程组能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等等。

在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。不同的操作系统的展现形式和存储的位置可能不一样的,所以,docker 提供了统一的配置参数入口。具体可以参考官网:https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources。

例如:

1
$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

这条命令意味着,启动的这个容器进程组,在每100000us(100ms)的时间里,只能使用20000us(20ms)的CPU时间。也就是说,改进程组只能使用20%的CPU带宽。

在容器的使用过程中,如果能及时的掌握容器使用的系统资源,无论对开发还是运维工作都是非常有益的。幸运的是 docker 自己就提供了这样的命令:docker stats。

1
$ docker stats

默认情况下,stats 命令会每隔 1 秒钟刷新一次输出的内容直到你按下 ctrl + c。下面是输出的主要内容:
[CONTAINER]:以短格式显示容器的 ID。
[CPU %]:CPU 的使用情况。
[MEM USAGE / LIMIT]:当前使用的内存和最大可以使用的内存。
[MEM %]:以百分比的形式显示内存使用情况。
[NET I/O]:网络 I/O 数据。
[BLOCK I/O]:磁盘 I/O 数据。
[PIDS]:PID 号。

容器技术的优缺点

docker容器和虚拟机技术相比,有一定的优势和缺点,下面我们来盘点一下。

这幅图的左边,画出了虚拟机的工作原理。其中,名为 Hypervisor 的软件是虚拟机最主要的部分。它通过硬件虚拟化功能,模拟出了运行一个操作系统需要的各种硬件,比如 CPU、内存、I/O 设备等等。然后,它在这些虚拟的硬件上安装了一个新的操作系统,即 Guest OS。而APP就在这些Guest OS上面运行。

而右图,APP直接运行在宿主机的操作系统上,而docker容器更多的是旁路式的辅助和管理功能。

根据实验,一个运行着 CentOS 的 KVM 虚拟机启动后,在不做优化的情况下,虚拟机自己就需要占用 100~200 MB 内存。此外,用户应用运行在虚拟机里面,它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗,尤其对计算资源、网络和磁盘 I/O 的损耗非常大。

而相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用 Namespace 作为隔离手段的容器并不需要单独的 Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。

所以说,“敏捷”和“高性能”是容器相较于虚拟机最大的优势,也是它能够在 PaaS 这种更细粒度的资源管理平台上大行其道的重要原因。

不过,有利就有弊,基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是:隔离得不够彻底。

  • 首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。这意味着,如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。
  • 其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。
  • 此外,由于上述问题,尤其是共享宿主机内核的事实,容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度自然也比虚拟机低得多。

总结:
容器的底层实现,是使用了Linux提供的隔离和限制的技术(Namespace和Cgroup)。APP直接运行在宿主机的操作系统上,docker容器更多的是旁路式的辅助和管理功能。这种设计使得应用的运行变得异常的高效和敏捷。当然,也会引入隔离不充分的一些问题。

当大家都以为应用是运行在容器引擎之上,容器之间有着独立的运行空间,相互之间不会干扰的时候,你可以会心一笑:这些都只不过是障眼法罢了。