我们在讨论容器提供的隔离性时,经常会拿虚拟机来进行对比,特别是评估运行在容器中的应用安全风险,深刻理解虚拟机的运行机制和容器之间的差异,是我们判断沿用哪些传统的安全手段,来保护容器化部署的关键。
笔者在很多场合会听到大家说容器部署方式好,虚拟机这种方式很差,或者说虚拟机好,容器部署方式隔离的不彻底等等,其实虚拟机和容器并不是非黑即白的关系。笔者会在后续的文章中介绍集中增强容器隔离性的手段,本质上就是让容器从隔离性的角度,更加趋近于虚拟机。
虚拟机和容器进程最大的差异在于前者通常在其中运行了一套完整的操作系统(包括内核),而后者容器模式在内核上和宿主机共用,并不隔离。为了让后续的讨论更加完整,咱们来稍微探讨一下虚拟机的创建和管理机制,也叫Virtual Machine Monitor(VMM)。
当我们给机器加电并按了启动按钮后(物理机),初始化程序(也叫BIOS)会对系统配置的硬件资源进行扫描,比如检查内存是否可用,网络是否可用,以及显示器,键盘,硬盘等等。当初始化程序完成系统硬件检查后,会启动一个叫bootloader的组件,来加载和运行操作系统内核代码。
操作系统有很多不同的类型,大家熟知的包括Linux,华为的欧拉,macOS和Windows等等。但是无论是什么类型的操作系统,内核代码在硬件上运行的权限要高于咱们编写的应用程序。具体来说,内核代码所持有的额外的权限主要包括内存读写,网络接口读写和管理,磁盘读写等,反过来从应用程序的角度看,如果要读写磁盘上的文件数据,无法直接访问磁盘,需要调用操作系统内核提供的系统调用来完成,笔者在前边的文章中有详细的介绍,对这块不了解的同学可以回去查阅。
我们以X86架构机器为例,权限被划分为不同的ring,如下图所示。其中最中间的ring 0拥有最高的操作权限,而ring 3的权限最低。如你所愿的是,大部分操作系统的内核(VM虚拟机除外)代码都运行在ring 0,而我们编写的应用程序运行在ring 3。
内核代码在CPU上以机器码的形式被运行(当然这句话是废话,任何变成语言编写的代码最后都是以0和1这样的机器码在CPU上运行),但是对于内核代码来说,可以使用访问内存,启动CPU线程这样的指令,通常这些操作硬件的指令需要代码有相应的权限。
操作系统初始化并加载是一个非常复杂的话题,但幸运的是描述这个话题的专著可以说汗牛充栋,咱在这篇文章就不累述了,关键是通过区区几千字是无法说清楚的。不过不难想象,操作系统初始化和加载肯定会包含挂载root文件系统(根文件系统),设置网络设备(比如加载驱动程序,设置IP地址,配置路由表等等),最后操作系统会被启动,并在显示器上显示登陆窗口。
从应用程序的角度看,操作系统的内核完成初始化并启动对外正常提供服务后,我们就可以在用户空间中运行自己编写的应用程序了。具体来说,操作系统内核负责用户空间中启动的应用程序对硬件资源需求的管理工作,比如内核负责启动,管理和调度CPU线程来运行应用程序,内核负责维护这些应用进程运行中所需的数据结构,以及非常重要的内存管理功能。内核给每个进程分配独享的内存块,并需要保证进程的内存无法相互访问。
从上边的描述中大家可以看出来,通常情况下内核会直接管理机器上的硬件资源,但是在虚拟机的场景中,VMM充当了第一层硬件资源管理器的职责,将系统中可用的硬件资源进行切分,并分配给机器上的多个虚拟机,每个虚拟机中都运行了完整的操作系统内核。
我们说VMM管理虚拟机,具体体现在VMM会分配CPU和内存资源给每个管理的虚拟机实例,并且VMM会设置虚拟网络接口等虚拟设备,来让运行在虚拟机中的guset kernel正常运行起来,并可以访问到系统的硬件资源。咱们在文章开头部分提到的BIOS,会检查所有的硬件,并且BIOS会把机器上可用的硬件资源信息同步给操作系统内核。而VMM要做的就是把系统上可用的硬件资源切分开来分配给每个虚拟机实例,并且控制每个虚拟机实例只能访问分配的资源子集,让运行在虚拟机中的kernel绝的自己看到的就是实际的物理硬件资源,屏蔽了本质上访问的是VMM这层抽象代理层的事实。
从前边的描述可以看到,VMM是虚拟机这种模式的核心,业界目前有两种类型的VMM,我们通常称作是类型1和类型2(Type 1 and Type 2)。
Type1类型的VMM用大白话说就是直接在硬件上运行虚拟化管理程序Hypervisor,大家熟知的Hyper-v,Xen以及ESX/ESXi都属于类型1。类型1最大的特征就是直接运行在硬件上,hypervisor下没有操作系统,如下图所示:
对于Type1类型的VMM,其实不难理解虚拟化管理程序运行(VMM)就充当了我们在图1.1中的操作系统内核,运行在ring 0,而虚拟中运行的操作系统内核运行在ring1,相应的虚拟机中操作系统内核的权限要低于VMM中的代码,如下图所示:
接着我们来聊聊Type2,笔者在前边多篇文章中使用的vagrant环境,就是典型的Type2类型的VMM。比如在笔者的mac机器上,操作系统运行的是macos,因此内核就是macOS kernel,我在机器上安装了VirtualBox之后,VirtualBox作为操作系统上一个应用,来管理我们通过VirtualBox创建的虚拟机。
我们在VirtualBox可以运行Linux和Windows操作系统,虚拟机中的内核通过VMM(VirutualBox)来访问底层的宿主机操作系统(Host OS)并最终访问机器上的硬件资源,整个过程说起来很绕,读者可以参考下图。
读者可以把上边这张图和图1.1进行对比,仔细体会一下这两种类型的差异。使用macOS的同学应该会经常需要在Mac上运行一个Linux操作系统,从操作系统的角度来看,本质上的需求是我们需要在macOS内核上运行一个Linux内核的机器。VMM其实充当了在macOS上运行虚拟机的职责,因此在macOS上安装VirtualBox的时候,除了安排运行在运行空间的操作界面等组件外,其实还安装了很多有直接使用操作系统硬件的组件,来提供这层虚拟化机制。当然Type2类型VMM除了VirtualBox之外,读者经常听到的QEMU也属于类型2。
中国有句古话叫合久必分,分久必合,古人的智慧也在潜移默化的影响着VMM的发展。随着技术的进步,Type1和Type2之间的边界越来越变得模糊,而KVM(基于内核的VM机制)技术就是这种趋势的具体落地实践,KVM本质上就是在Linux内核中运行了一个VMM模块,如下图所示:
如果单看定义,KVM妥妥的Type1啊,因为虚拟机内核的系统调用直接到硬件,不需要“穿越”宿主机操作系统,但是事情往往没有那么简单。笔者介绍Type2类型VMM的时候提到了QEMU(Quick Emulation)技术,而QEMU会动态的将虚拟机内核的系统调用翻译成宿主机内核的系统调用(注意这里笔者想强调的是:QEMU不是直接访问硬件这个事实),但是由于QEMU需要提供较好的性能,因此QEMU充分利用了KVM的硬件加速机制。
基于上边内容的铺垫,读者应该能理解到运行在Hypervisor之上的虚拟机操作系统是作为用户级的进程运行的,因此这些虚拟机的内核并没有和宿主机操作系统内核同等的权限。但是运行在VMM中的虚拟机并不知道自己看到的其实是虚拟的系统硬件资源,因此当虚拟中的操作系统内核需要运行一些特权指令,就需要持有运行在ring 0这些代码的硬件访问权限。其实这个问题就是虚拟化机制的核心,而业界把这种机制叫做“trap and emulate”翻译成中文感觉会失去这三个单词的灵魂,因此咱就不画蛇添足了。
由于运行在虚拟机中的操作系统内核一般是完整的操作系统和内核,因此当操作系统运行起来后,会觉得自己直接运行在系统硬件上,这就意味着运行在虚拟机中的内核也会试图执行特权指令,考虑到虚拟机内核其实运行在hyperviosr之上,以用户进程运行,那么显然运行只能在ring 0执行的特权指令会出现问题。
而虚拟化软件解决这个问题的办法就是当虚拟机内核视图执行特权指令的时候,会导致一个trap。有过Java或者Dotnet开发经验的同学可以把trap类比为代码的异常处理机制,trap就是一个异常,而异常需要进行适当的处理,特别是业务异常。trap的结果就是CPU会执行ring 0级别的“错误”处理代码。
对于虚拟机来说,如果VMM运行在ring 0,而虚拟机运行在权限稍低的级别,虚拟机内核的特权指令就可以触发VMM的hanlder(异常处理代码)来模拟指令的执行,通过这种方式,VMM提供的隔离性就会更好,因为VMM可以保证任何虚拟机内核不会通过特权指令来影响别人(硬件级别的noisy neihbor问题)。
但不幸的是,在X86架构下的操作系统,并不是所有操作硬件的指令都是特权指令(通常情况下我们把操作硬件资源的指令也叫sensitive指令),因此VMM需要对这种非特权的sensitive指令做特殊处理,我们通常把这种指令称作non-virtualizable指令。
业界目前处理non-virtualizable指令主要有两种方法:
- 第一种方法叫binary translation,大白话说就是将所有虚拟机内核发出的非特权sensitive指令进行捕捉和实时翻译,如果要做到性能无巨大损失,可以说非常的难,并且也很复杂。最新的X86处理器通常会提供硬件级别的binary translation支持。
- 第二种方法叫paravirtualization,大白话说就是在虚拟机中尽量不使用non-virtualizable指令,Xen使用的就是这种方法。
为了确保内容的完整性,笔者必须提Intel的VT-x,也叫硬件虚拟化,本质上就是让hypervisor运行在一个新创建的特权级别下,也叫VMX root模式。在这种模式下,虚拟机的内核直接运行ring 0,就如同宿主机一样。
有了对虚拟机的工作原理有深入的了解之后,我们来重新审视一下”隔离“这个词的含义,特别是进程之间的隔离具体代表的是什么意思,特别是从安全的角度,这是安全边界的关键。简单朴素的道理,应用程序之间必须相互隔离,要不然我们的应用程序可以读取部署在相同机器上你的应用进程的内存数据,那么这就会有严重的数据安全问题。
从这个角度看,物理隔离的安全性最高,比如我们为不同的应用程序配置不同的物理机器,两台机器上的进程无论如何也不会出现相互内存访问的风险。
我们前边说内核负责管理运行在用户空间的应用程序进程,比如分配内存给每个应用程序,因此运行在同一台机器上的两个应用程序是不是能够相互访问内存完全取决于内核的内存隔离保障机制。虽说大部分服务器上运行的操作系统内核都是经过多年实战验证过的,安全有绝对的保障,但是由于内核的版本也在不断地发展,我们不能完全的信任操作系统内核完全没有缺陷。因为随着硬件的发展,以及使用场景越来越多,内核级别新的权限也会时不时的被发现,比如Sepctre和Meltdown就利用了CPU的预测分支执行特性,关于这个缺陷的详细信息,笔者可以参考相关的资料。
有了对虚拟机的全面理解之后,我们最后来对比一下虚拟机和容器进程这两种虚拟化方式的优劣。虽然我们说虚拟机能够提供更加绝对的隔离性,但是业界还是抛弃了虚拟机这种不是模式,全面拥抱了容器化,原因如下:
- 虚拟机的启动速度和容器进程比起来会更慢,特别是在Kuberntes这种容器编排平台上,应用程序部署的POD天生具备临时性,也就是说会被是不是的kill掉然后重新创建,如果启动一个虚拟化应用程序需要的时间会非常长,你可以想想应用的容错性,自动伸缩会是个什么场景!
- 容器技术完成了虚拟机未尽的目标”一次编译,到处运行“的目标(隐含着速度和效率的诉求),虽然说虚拟机也可以做到一次编译,导出运行,但是虚拟机会更慢,你可以考虑一下build一个虚拟机镜像所需要花费的时间,以及虚拟机镜像的文件大小,就知道业界为什么最后拥抱了容器机制。
- 虚拟机技术资源的利用率不高,我们部署应用是基于配置了确定的CPU和内存机器来进行,而大部分情况下我们的预估都不准确,导致机器上的大量资源闲置还不能用作他用。而容器模式下,我们在一台性能强劲的机器上可以运行大量的容器进程,资源使用率非常高(比如来的神龙服务器)。
- 由于每台虚拟机都会运行完整的内核,因此大量的虚拟机会耗费可观的资源给操作系统内核运行。但是在容器模式下,由于运行的多个虚拟化应用共享内核,因此内核需要占用的资源很低,并且应用程序的性能会更好。
当然容器也不是银弹,我们在选择具体的部署方案的时候,一般需要考虑多个因素,比如性能,价格,可运维性,安全风险,安全边界等来权衡。由于容器进程在Linux操作系统上就是一个特殊的进程,并且容器进程通过命名空间以及chroot来进行了隔离,但是本质上容器进程的隔离性要弱于虚拟机。但是这并不是说我们就应该在安全隔离性要求高的场景下直接使用虚拟机,笔者要说的是,业界已经发展处了很多安全机制来增强这种进程间隔离的机制,我们会在后续的文章详细介绍。
好了,今天的内容就这么多了,下篇文章我们来聊聊容器的另外一个概念:镜像,特别是从安全的视角,我们如何确保应用的安全性,敬请期待!