画地为牢,细谈VM和容器

2021-12-06 15:58:06 浏览数 (1)

接下来的几篇,二哥和大家聊聊容器的一些概念和容器安全。云原生看起来是横空出世,但如果仔细看看在这个概念出来之前的技术迭代和演进,我们会发现这一切冥冥之中早已安排。前人在“虚拟化”、“不可变基础设施”、“微服务”、“容器”、“容器编排”等领域各种各样的尝试使得云原生的诞生成为必然。

如今,大家聊云原生必然会说到一个词:容器。说到容器,大家总自然地想到另外一个词:虚拟机(VM)。初学者会问一个问题:容器和虚拟机的区别是什么?当他们在网上搜索这个问题的答案时,会看到类似的答案:容器是细粒度的虚拟机。它其实是在错误地暗示提问者:容器是虚拟机。

但实际上虚拟机和容器之间有本质的差别:虚拟机运行有完整的OS,而容器仅仅是一个进程而已。

1. 虚拟机

在聊虚拟机之前,我们先来回顾一下一台物理机器包含的主要零部件:CPU、内存、网卡、磁盘和各类外设。

当给它上电启动时,首先运行的是BIOS,它被用来侦测和枚举这些设备,然后所谓的bootloader开始运行并加载OS kernel。保护模式下,kernel运行于Ring 0,而应用程序运行于Ring 3,前者为特权模式,而后者则是非特权模式。OS如同管家,除了为应用程序悉心管理、协调着CPU、内存、磁盘等竞争性资源外,它还有一项非常重要的工作:将不同的应用程序进行隔离,比如确保应用A无法直接访问应用B的内存,应用A也无法访问应用B的CPU寄存器。

所谓虚拟机(Virtual Machine),也即虚拟出来的机器,那自然需要将一台物理机器所具备的基本部件如CPU,内存等都虚拟出来,且让我们熟知的Windows,Linux等操作系统不加修改就可以直接安装上去。

当我们来到虚拟机世界的时候,会遇到有一个叫Virtual Machine Monitor(VMM)的管家。它也在做两件事:管理竞争性资源和隔离资源。但与前面提到的,跑在裸机上的OS所提供的资源管理和隔离服务不同,VMM服务的对象不再是进程,而是一个个的虚拟机,它确保各个虚拟机按各自申请的资源量运行,不会超量使用更不会访问到属于其它虚拟机的数据。

VMM有两种方式:如图1所示的HyperVisor方式和图2所示的用户态模拟方式。

HyperVisor典型的实现有Hyper-V, Xen和ESXi。我记得差不多十年前做项目时,我们总是会购买Dell R720,给它装上ESXi。一台R720可以同时跑一二十台虚拟机,创建和删除都非常的方便。这些HyperVisor的实现有共同特点:短小精悍且相对安全。

与传统的kernel相比,HyperVisor的代码小得多,比如Xen HyperVisor的全部代码只有50000行左右,而Linux Kernel有超过2000万行代码。我们都明白代码量越小意味着约容易维护,出bug的几率越小,与此同时因为攻击面(attack surface)越小,相应地也就越安全。

图 1:基于HyperVisor的VM

相信大家都有过在自己的电脑上安装VirtualBox,然后再在里面安装、运行另一个OS的经历。这其实对应于图2所示的另一种VMM实现方式:此时VMM变成一个用户态进程,但它模拟出CPU、内存、磁盘、网卡等资源,我们可以通过它的管理界面创建虚拟机并安装其它的OS。

图2里面我还额外画出了一个框图:KVM,且它位于kernel中。我们可以将其看成是用户态VMM的加速器。

图 2:基于KVM的VM

无论图1还是图2,具体的实现细节不是这篇文章关注的重点,但我们大体可以看出它们的共同特征:

  • 它们将现有的硬件资源抽象、虚拟、隔离,从而允许我们进行创建、删除、编辑虚拟机等操作。
  • 它们服务的对象是虚拟机。虚拟机里装有完整的OS。OS认为自己运行在普通的物理机器上,且OS上运行有各式各样的应用。
  • 它们会给虚拟机划分资源,按需分配,按量使用。
  • 它们对这些虚拟机之间进行隔离,这些虚拟机互相看不见对方的存在。

2. 容器

聊完虚拟机,我们来看看容器。我们再看下本文开篇提到的虚拟机和容器之间的本质差别:虚拟机运行有完整的OS,而容器仅仅是一个进程。

其实更准确地说:容器是一个无形的壳子,在它里面运行的application才是进程。出于方便,大家一般直接将这样的方式叫做container。严谨地说,应该是containerized application。

图 3:基于KVM的VM和容器全景大图

为了便于对比,我在图3中将基于KVM的VM和容器放在一张图中。图中草绿色的方框表示进程,它或是以native方式运行,或是跑在一台VM的OS上,再或是运行在容器里。

这张图的右半部分画出了与容器相关的几个重要的概念:namespace,cgroup,chroot,Root filesystem。其中粉红色背景代表namespace,可以看到它将草绿色的方框包围了,我想以此形象化的方式传递一个重要的信息:namespace用来将容器进程隔离。而草绿色方框内,又分别画出了两个部分:Workload和Root filesystem。简单来说我们的application叫workload,而它脚下所踩着的文件系统称为Root filesystem。

它只是进程

我们首先来看看容器是进程这个概念。为了强调这一点,在图3中右边的部分,我们看不到任何标有Guest OS字样的方框。

要演示容器只是进程这个概念,非常的简单。比如下面的示例中,用docker运行了busybox的sh,并在里面执行sleep 50,我们可以在另外一个bash里面清楚地看到这里的shsleep,在宿主机看来都是进程。

代码语言:javascript复制
root@bucksware:~# docker run -it busybox sh
/ # sleep 50
代码语言:javascript复制
root@bucksware:/home/lance# ps fa
  PID TTY      STAT   TIME COMMAND
31289 pts/0    Ss     0:00 sh
31585 pts/0    S      0:00  _ sleep 50
15775 pts/1    Ss     0:00 -bash
24038 pts/1    S      0:00  _ su
24173 pts/1    S      0:00      _ bash
32332 pts/1    R      0:00          _ ps fa
12609 pts/0    Ss     0:00 -bash
11584 pts/0    S      0:00  _ su
11629 pts/0    S      0:00      _ bash
31192 pts/0    Sl     0:00          _ docker run -it busybox sh

上文提到containerized application,译作”容器化了的application“。那所谓”容器化“了具体是指什么呢?我们接着往下看。

加上namespace

Linux的namespace机制如同给进程砌了一堵无形的围墙,只允许进程在指定的范围内活动。通过namespace机制,可以让进程只看到属于自己的process id、mount挂载信息、hostname、networking stack、如iptables,routing,IP地址,网卡等信息。

要体验namespace的乐趣,不一定非要通过docker。像Ubuntu这样流行的OS通常会自带一个叫做unshare的轻量级工具,man unshare会提示我们它的作用:“run a program with some namespaces unshared from the parent.” (译:使用与父进程不同的namespace来运行一个程序)。

我们来玩一下。通过unshare 运行sh,把它圈在uts namespace里。可以看到无论给它设置什么hostname,都不会影响宿主机的hostname。类似地,我们可以通过指定--pid--mount等体验不同的namespace的乐趣。

如果使用--pid,除了第一个命令可以正常运行外,你无法再在里面执行其它的命令,且会碰到如”sh: 2: Cannot fork“这样的提示。此时需要额外加一个参数 --fork。

代码语言:javascript复制
lance@bucksware:~$ sudo unshare --uts sh
# hostname
bucksware
# hostname lance
# hostname
lance
# exit
lance@bucksware:~$ hostname
bucksware

让我们再花点时间来仔细品一下unshare man page里的那句话。当我们”run a program“的时候,kernel创建一个新的进程,execve()的系统调用使得我们的program开始真正地被加载到这个新进程空间并运行。于是就出现了所谓父-子进程。这里的"unshare"是指子进程不会和父进程共享参数中所指定的namespace。在我们这个示例里,sh进程就跑在了自己的uts namespace里。

加上cgroup

kernel不但给进程圈定了活动范围,还限制了进程的资源消耗,这是非常有必要的措施。application虽然被困在了围墙里,但不能让它肆意挥霍系统的资源,否则会导致其它的进程被饿死。

这是通过cgroup机制做到的。照例,我们通过下面的示例来体验一下cgroup的使用方法。

/sys/fs/cgroup目录列出了各种不同的cgroup类别,换言之,我们可以控制进程的cpu、memory、blkio、net等资源的使用量。

代码语言:javascript复制
root@bucksware:/sys/fs/cgroup# ls
blkio  cpuacct      cpuset   freezer  memory   net_cls,net_prio  perf_event  rdma     unified
cpu    cpu,cpuacct  devices  hugetlb  net_cls  net_prio          pids        systemd

以memory为例,我们会发现它提供了各种类型的文件,从名字我们大致可以看得出来,有的文件可以让我们修改以控制资源使用量,有的则是由kernel填充的,用以报告状态的。如memory.limit_in_bytes用以控制位于这个cgoup里面所有进程可使用的内存总量,memory.max_usage_in_bytes用以报告cgroup里内存使用的历史最高值。

代码语言:javascript复制
root@bucksware:/sys/fs/cgroup# ls memory/
cgroup.clone_children                  memory.limit_in_bytes
cgroup.event_control                   memory.max_usage_in_bytes
cgroup.procs                           memory.move_charge_at_immigrate
cgroup.sane_behavior                   memory.numa_stat
init.scope                             memory.oom_control
memory.failcnt                         memory.pressure_level
memory.force_empty                     memory.soft_limit_in_bytes
memory.kmem.failcnt                    memory.stat
memory.kmem.limit_in_bytes             memory.swappiness
memory.kmem.max_usage_in_bytes         memory.usage_in_bytes
memory.kmem.slabinfo                   memory.use_hierarchy
memory.kmem.tcp.failcnt                notify_on_release
memory.kmem.tcp.limit_in_bytes         release_agent
memory.kmem.tcp.max_usage_in_bytes     system.slice
memory.kmem.tcp.usage_in_bytes         tasks
memory.kmem.usage_in_bytes user.slice

当我们在memory目录下创建一个目录lance时,kernel会自动生成所有与memory cgroup有关的参数和统计分析文件。

代码语言:javascript复制
root@bucksware:/sys/fs/cgroup# mkdir memory/lance
root@bucksware:/sys/fs/cgroup# ls memory/lance/
cgroup.clone_children                  memory.limit_in_bytes
cgroup.event_control                   memory.max_usage_in_bytes
......
memory.kmem.tcp.usage_in_bytes         tasks
memory.kmem.usage_in_bytes user.slice

而当我们将一个或多个进程号写到 cgroup.procs时也就实现了对这个进程的memory消耗控制。真是简单、优雅的实现。

代码语言:javascript复制
root@bucksware:/sys/fs/cgroup# echo 31585 > memory/lance/cgroup.procs

聪明的你一定会联想到是不是docker以container id为目录名,在/sys/fs/cgroup创建各种类型的子目录,从而达到控制container的资源消耗?恭喜你,答对了,尝试看看cd /sys/fs/cgroup/memory/docker,你会发现秘密都在那里。

加上root fs和chroot

我们可以把Linux OS image粗略地分为两大部分:kernel部分和文件系统。文件系统里面包含整个OS正常运行所需要的各类文件,如依赖库、可执行文件、配置文件、设备文件等等。一般情况下kernel部分会位于/boot目录。

如果我们给每个容器准备一份上文提到的文件系统,且通过chroot方式,改变进程的根目录到这个文件系统,那么每个容器就有一份属于自己的文件系统。这个文件系统有个高端的名字:root fs(根文件系统)。在这个root fs里,每个容器里的可执行文件完全不需要担心它的依赖库以及设置会和别人冲突,更不会出现类似”DLL地狱“这样的让码农掉头发的问题。

namespace和cgroup只是将进程圈住在围墙里,再锁死了它的资源消耗,而通过chroot,我们将进程低头所能看到的地面也封死了。

我们将这个文件系统再做一些裁剪,减去不需要的,只留下必须的,那么就做出来一个高大上的东西:base image。是的,Dockerfile里使用的FROM指令所读取的正式类似这样的base image。

注意区分chroot和mount namespace的区别和联系。Mount namespace所修改的,是容器进程对文件系统“挂载点”的认知,也就是说在一个容器进程里执行mount操作,native进程是看不到的,其它容器进程也看不到。而chroot是修改容器进程的根目录。但同时二者需要相互配合,比如我们需要把按下面的示例代码把proc、sysfs等文件系统mount到root fs,然后通过chroot切换根目录。

代码语言:javascript复制
 if (mount("proc", "rootfs/proc", "proc", 0, NULL) !=0 ) {
       perror("proc");
  }
   if (mount("sysfs", "rootfs/sys", "sysfs", 0, NULL)!=0) {
       perror("sys");
  }
   //篇幅原因,此处故意省略。请至酷壳-CoolShell网查看完整源码。
   if (mount("shm", "rootfs/dev/shm", "tmpfs", 0, NULL)!=0) {
       perror("dev/shm");
  }
   if (mount("tmpfs", "rootfs/run", "tmpfs", 0, NULL)!=0) {
       perror("run");
  }
   //篇幅原因,此处故意省略。请至酷壳-CoolShell网查看完整源码。
   /* chroot 隔离目录 */
   if ( chdir("./rootfs") != 0 || chroot("./") != 0 ){
       perror("chdir/chroot");
  }

注:上述代码摘自酷壳-CoolShell网:https://coolshell.cn/articles/17010.html#Docker的_Mount_Namespace。篇幅原因,省略若干代码。

张磊在《深入剖析Kubernetes》一书中说rootfs只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂“,这个比喻实在是非常的形象。内核才是OS的灵魂,而一台机器上所有的容器都共享着这个灵魂。

行文至此,我们应该可以回答容器和进程的联系与区别了。如果我们将巨石架构(monolithic architecture)时代的native进程比作雄鹰,那么如今以微服务方式运行的进程则演(缩)化(微)成小鸟。容器始于进程,但是在namespace和cgroup的一步步限制下,从一只可以自由飞翔的雄鹰变成了被囚于牢笼里的小鸟,无法飞得高,也无法飞得远,对资源的消耗也相应地变得越来越小。但麻雀虽小,五脏俱全。进程该有的,它都有,只不过身材小了点,食量少了点。

从namespace,到base image,如果我们自己一个一个地去实现的话,会非常的繁琐,尤其是base image部分。所以在2013年3月,当一个叫dotCloud的无名小辈刚刚发布Docker项目后,就以摧腐拉朽之势碾压了当红明星Cloud Foundry、OpenShift和Clodify。哪有什么岁月静好,只不过是有人在替我们负重前行。

0 人点赞