笔者前边介绍过Docker容器技术有三大支柱,他们分别是:命名空间(Namespace),资源约束(CGroups)和文件系统隔离,笔者今天希望通过这篇文章,来和大家一起详细的聊聊这些技术支柱的详细信息。
我们从Namespace开始,Linux操作系统的Namespaces提供了让每个进程都有独立系统视图的能力,这句话不是很好理解。大白话来说,每个容器进程都只能看到部分文件,进程,网络资源以及不同的系统hostname,通过命名空间就如同给每个进程施加了魔法,让每个容器进程都以为自己是这台机器上的唯一运行的集成,也就是员工号是1的创始人。
对于操作系统来说,一开始所有的系统资源,包括文件系统,进程ID,用户ID,网络接口等都在同一个池子里,所有在这台机器上运行的进程都可以看到和使用这些资源。但是内核给我们提供了命名空间这个能力,通过命名空间,我们就可以将前边提到的这些资源切分成更小的集合。对着资源进行重新组合的结果就是,我们可以让一个或者一组进程只能使用切分后的资源集合。
由于有了Namespace这个可以更加细粒度切分资源的机制,我们在创建进程的时候,可以指定具体的命名空间,这样新创建的进程就只能看到给这个命名空间分配的资源。
对于Linux操作系统来说,命名空间的类型有很多,每个命名空间类型都代表了不同类型的资源,因此我们在使用命名空间创建进程的时候,可以指定多个命名空间来划分这台机器的物理资源集。
具体来说,Linux提供了如下的命名空间类型:
- Mount命名空间mnt,用来隔离挂载点(文件系统)。
- Process ID命名空间pid,用来隔离线程的ID编号。
- Network命名空间net,用来隔离网络设备,网站栈和端口等。
- 进程内通信命名空间ipc,用来隔离进程间通信的资源,包括隔离消息队列,共享内存等资源。
- Unix分时系统命名空间UTS,用来隔离系统的hostname,以及NIS用来隔离域名。
- 用户ID命名空间user,用来隔离用户和用户组。
- Cgroup命名空间用来隔离控制组的根目录,我们会在后边详细介绍Cgroups,因为通过这个控制组,可以限制容器实例的资源使用量。
接下来我们来深入的介绍一下几个关键的命名空间类型,为理解容器的三个支柱做好知识储备。
【使用network命名空间来赋予每个进程独占的网络接口】
运行在机器上的容器进程能看到哪些网络接口由网络命名空间决定,每个网络接口只能从属于一个命名空间,但是我们可以将网络接口从一个命名空间移动到另外一个命名空间中。
也就是说如果我们给每个进程都分配一个独享的网络命名空间,那么每个容器进程就会看到自己独享的网络接口,这其实就类似于通过命名空间给每个容器进程做了网络接口层面的隔离。
为了更好的理解我们接受的第一个命名空间,笔者准备如下的这张图,这张图想展示的核心信息是:创建一个容器换的进程,并给这个进程提供1个独享的网络命名空间,这样的话这个进程只能看到属于这个命名空间的网络接口。
如上图所示,刚开始,机器上只有默认网络命名空间,接着我们创建了两个新的网络接口ethAA和loAA,以及一个新的网络命名空间A。然后我们把新创建的两个网络接口移动到新创建的网络命名空间A中,这个时候,我们就可以将这两个网络接口的名字修改为标准的名字eth0和lo。最后,容器进程在这个命名空间中启动,就只会看到网络接口eh0和lo了。
如果我们从容器进程的角度来看,单纯通过网络接口是无法判断这个进程是运行在容器中,虚拟机上还是直接运行在宿主机上。接下来我们来看看如何让每个容器进程有自己专有的hostname,这样才能体现出在这台机器上的“唯我独宗”,请继续阅读。
【通过UTS命名空间来给每个容器进程分配专属Hostname】
Hostname对于一个容器具备“自我意识”来说非常重要,逻辑上讲,一个容器实例运行的机器上的hostname和另外一个容器实例运行的hostname不一样的情况下,我们才会觉得他们运行在不同的机器上,UTS命名空间可以提供这种隔离。
具体来说,UTS命名空间让运行在其中的进程有专属的hostname和domain name,这样不同的容器之间就好像运行在不同的机器上一样,我们可以创建两个不同的UTS命名空间,然后创建两个容器进程,让两个进程分别运行在这两个命名空间中,这样的话, 两个容器进程即便是运行在相同的宿主机上,但是他们会看到不同的系统参数hostname。
好了,我们介绍了两个命名空间,以及通过命名空间我们是如何给容器进程施加魔法,让即便是运行在同一台宿主机上的两个容器进程,可以看到不同的hostname,网络接口等,那么你是不是很好奇,命名空间是如何时间这种魔法的,请继续阅读。
【命名空间是如何让容器进程实现资源隔离】
通过前边列举的两个例子,我们通过创建网络命名空间和UTS命名空间,就可以给运行在其中的容器进程提供资源的隔离,让容器进程以为自己是这台宿主机的唯一进程,本质是通过命名空间给每个进程创造了一个隔离的运行环境。
虽然我们通常情况下需要这个隔离的边界,但是如果我们要把关系亲密的两个组件容器化,比如一个生产网页的进程和Nginx进程,他们在物理机上通过localhost进行通信,在这种情况下,我们就需要这两个进程运行在一个命名空间中,比如说网络命名空间中,这样他们才能通过localhost进行通信。
如下图所示,两个进程共享了网络命名空间,但是有自己的专属的文件系统:
我们聚焦一下,来看看网络接口,两个容器进程由于在同一个网络命名空间中,因此它们看到的是相同的网络设备(eth0和lo),这就意味着这两个容器有相同的IP地址,以及可以通过loopback设备来进行通信,这就解决了笔者在上边提到的问题,如何容器化这种具有亲密性的应用之间的关系。
另外,由于这两个进程的UTS命名空间也一样,他们会看到相同的hostname,与之相反的是,进程1和进程2有自己专属的mount命名空间,因此他们其实看到的是不同的文件系统。通过上边的描述你可以看到,如果我们想让两个进程共享某些资源,我们可以通过将两个容器进程加入到相同的资源组(命名空间)来实现。
介绍到这里为止,我们终于可以回答这个问题:为什么我们需要容器?你可以细细的品味一下这句话:运行在容器中的进程其实和运行在虚拟机中的进程是不对等的,这个进程只是为他设置了上边提到的7个不同的命名空间,有些命名空间在多个进程之间共享,有些没有,这就意味着我们在虚拟机中的进程和容器中的进程其实不对等,因此我们需要容器这个抽象,来提供从虚拟机部署的应用到容器化部署应用的映射。
好了,介绍完7大命名空间后,不知道你是否会想,如果我登陆到这台容器进程“机器”上,我看到的景象应该是什么样子,接下来,我们来一起看看,登陆到容器进程中,能看到什么信息?
【从容器内部分析运行环境】
从容器内部,我们关心这台机器的系统参数hostname具体是什么值?配置的ip地址是多少?文件系统上能看到那些文件和文件夹,或者说有哪些二进制库和依赖可以访问到?等等。在登陆到一个容器之前,笔者问大家一个问题?如果你要登陆到一台远程的虚拟机上,你会怎么做?我们首先需要远程连接到这台机器,然后运行shell的脚本,而登陆容器的方式和登陆虚拟机完全一样。
注意:并不是所有的应用容器是实例都可以远程登陆,笔者要强调的是这里的讨论只限于开发和测试环境,因为生产环境从原则上讲,就不应该安装可以进行远程登陆的shell脚本。
接下来我们看看如何在登陆到一台运行中的容器进程。我们在前一篇文章中构建的yunpan-container提供了bash shell,因此我们可以通过命令来远程登录到这个容器进程中:docker exec -it yunpan-container bash,
➜ sample1 docker exec -it yunpan-container bash
root@e48ca2f1460d:/#
看到这个#是不是很激动,我们已经登陆到这个容器进程中了。我们来稍微解释一下上边的这句命令,通过提供bash参数,我们就可以在yunpan-container中增加一个bash的进程,这个bash进程和我们的Node js进程运行在相同的命名空间中,因此我们可以通过这个bash进程,来分析node js应用运行的环境,而命令中的-it参数的详细介绍如下:
-i 参数告诉Docker要把bash运行在交互模式下.
-t 参数会分配伪终端给用户, 这样我们就会看到这个/#的输出, 等待登陆用户输入后续的命令.
因为我们已经登陆到容器进程中了,接下来我们就可以运行 ps aux命令,来列出在容器中运行的进程信息, 如下图是这个命令的输出:
root@e48ca2f1460d:/# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 1.4 565636 29644 ? Ssl 01:07 0:00 node app.js
root 14 0.0 0.1 18188 3084 pts/0 Ss 10:05 0:00 bash
root 21 0.0 0.1 36640 2644 pts/0 R+ 10:13 0:00 ps aux
root@e48ca2f1460d:/#
从上边的输出可以看到只输出了三个进程,分别是1号进程,14号进程和21号进程, 其中1号进程就是我们前边说的那个唯我独宗的应用进程, 是在容器启动的时候加载并开始运行的, 而进程14和21就是我们的bash和ps命令.
从上图我希望大家也能分析到, 我们看不到这台机器上运行的其他进程, 以及其他容器中运行的进程, 这就证明了我们上边的分析过程是准确的。
笔者前边介绍过,在Mac系统上,容器都是运行在宿主机上创建的一个Linux虚拟机中,因此其实对于刚才看到的容器进程来说,宿主机就是这个Linux虚拟机,那么我们来分析一下,从这个Linux虚拟机宿主机上能看到什么?
我们可以在另外一个终端上,运行这个命令来登陆到这个Linux虚拟机上:docker run --net=host --ipc=host --uts=host --pid=host -it --security-opt=seccomp=unconfined --privileged --rm -v /:/host alpine chroot /Hosted:私有仓库,内部项目的发布仓库,专门用来存储我们自己生成的jar文件
root@docker-desktop:/# ps aux | grep app.js
root 4259 0.0 1.4 565636 29644 ? Ssl 01:07 0:00 node app.js
root 4484 0.0 0.0 3088 880 ? S+ 10:20 0:00 grep app.js
root@docker-desktop:/#
如果你眼神比较锋利,应该很容就看到在宿主机上看到的进程ID和在容器中看到的是不一样的, 比如在宿主机上看到的是4259, 而在容器中是1, 这背后的原理是:容器有自己的进程命名空间,因此容器会有自己的进程树,以及进程的ID序列,如下图所示,容器的进程树隶属于宿主机的进程树,因此运行在容器进程其实有两个进程ID:
除了进程有专属的进程ID序列,每个容器进程还有自己专属的文件系统。如果你在容器的根目录运行ls命令,你能看到的只有这个容器镜像中,以及启动时挂载的文件和文件夹清单。下面是我们在yunpan-container中运行 ls /命令的结果:
root@e48ca2f1460d:/# ls /
app.js bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
root@e48ca2f1460d:/#
从命令的输出中,你看可以找到我们的应用程序app.js,以及node:12基础镜像中包含的文件和文件夹,这里需要注意的是,你无法从容器中看到宿主机操作系统上的文件和文件夹。而这种隔离提供了安全保障,攻击者就无法在攻破了node js容器后,获取到宿主机上的机密信息。好了,我们通过到不同的“机器”上验证了我们前边推论的精确性。接下来我们来看看容器技术的另外一个支柱:Cgroups。
【通过CGroups限制容器进程的资源使用率】
虽然说Linux的命名空间机制可以让容器进程只能访问机器上的部分资源,但是命名空间并没有限制一个进程可以使用多大的资源,举个例子来说明一下。
我们使用网络命名空间限制某个容器进程只能使用固定的网络接口,但是我们无法通过命名空间来限制容器进程能够使用的网络带宽,进一步来说,我们也无法通过命名空间来限制容器的CPU和内存时候用量。如果我们无法对容器的CPU和内存资源使用量做限制,就会出现某个恶意的进程,消耗了大量的资源和CPU,从而导致正常的业务应用容器退出,从而导致业务中断,因此我们必须解决“限制”这个问题。
我们接下来要介绍的Linux内核的另外一个功能叫Linux Control Groups(cgroups)。通过cgroups提供的能力,我们就可以约束容器进程能够使用的资源上线,这样即便是有恶意的进程,由于受到cgoups的限制,业务发使用超过配置的资源量,整个系统的稳定性和安全性就得到额提升。
笔者会在后续的文章中专题介绍Controls Group的运行原理,在简单了解了cgroups之后,我们来看看如何让docker限制应用可以使用的机器资源。Docker提供了丰富的启动选项来让我们可以约束容器使用的资源总量,比如我们希望启动的容器只使用有4颗CPU的机器上的前两个,就可以使用--cpuset-cpus选项:
$ docker run --cpuset-cpus="1,2" ...
除了制定CPU的数量,我们还可以从耕细粒度来控制,比如约束能够使用的CPU时间,以及CPU的总量等,具体的选型可以参考Doker的文档,笔者就不在这里累述了。内存资源和CPU资源类似,如果不加限制,也会出现被恶意使用的情况,因此Docker也给我们提供了内存约束的选项, 比如我们可以使用如下的启动选项来约束容器只能使用100m内存:
$ docker run --memory="100m" ...
本质上来说,这些启动选项都做了相同的工作,就是配置进程的cgroups, 而配置完成之后,内核来负责基于用户提供的配置来限制进程可以使用的CPU,内存资源的总量。
关于cgroups提供的功能和用法我们就介绍到这里了,希望大家能够通过这几篇文章的阅读,对容器的技术支柱有更加深刻和完整的认知。笔者这篇文章一直在聊Linux内容提供的隔离机制,让进程能够进行“某种程度”的隔离,来实现容器中的进程和虚拟机上的进程的对标,而这里其实有个问题,因为所有的容器都只是一个运行在宿主机的特殊进程而已,那么其实从内核的角度,这些进程之间并没有做到彻底的隔离。这就会造成安全问题,如果有一个容器“变节”,嵌入的恶意代码修改了内核的参数,会导致运行在这台机器上的所有容器受到影响,可能有些同学觉得这是危言损听,我们来举个例子。
假设Kubernetes集群的某个工作节点上运行了3个容器应用,每个容器实例都有自己的网络命名空间和文件系统,并且运维人员配置了容器可以使用的资源最大值,从表面看,这个部署案例不会因为一个容器变节,对其他容器的正常运行造成危害。但是事情一般都比看起来要复杂,如果变节的容器修改了这台机器的系统时间呢,而系统时间是内核的参数,因此运行在其上的应用,如果业务和时间强相关,那么就会出现业务异常。
因为我们需要更强的约束手段,比如限制容器能够使用的系统调用,这就引出了我们今天要给大家介绍的最后一个Linux内核提供的能力,如何通过设置提升镜像的安全性。容器和操作系统之间通过系统调用(sys-call)来交互,我们无论是创建进程,操作文件和设备,以及发送网络数据,都需要使用操作系统提供的系统调用。有些系统调用本身是安全的,因此会开发给所有机器上的进程使用,而有些如果误用会造成问题,因此只开发给具备特定权限的进程使用。
我们以刚才的例子来讨论,运行在工作节点上的容器实例可以打开文件,但是无法修改系统时钟,以及内核的某些参数从而导致其他的应用运行故障,这就是安全限制的作用。因此对于开发和运维的同学来说,大部分容易都应该运行的普通权限下,只有特殊需要的进程,才应该给提供更高级别的权限。
注:我们可以在docker中使用--previlege选项来创建有特殊权限的容器实例。
但是依据最小权限的原则,我们不应该给容器他不需要用到的权限,这样可以缩小攻击面,提升系统的安全性,因此我们需要粒度更细的权限控制机制。幸运的是,Linux内核提供了capabilities这种能力,将操作系统提供的功能分成了不同的组,这些组就叫capabilities。比如Linux操作系统有如下的系统功能分组:
_CAP_NET_ADMIN允许所有的进程使用网络相关的能力
_CAP_NET_BIND允许进程可以使用小于1024的端口号(说明是系统进程)
_CAP_SYS_TIME允许进程修改系统时间等
当我们创建容器实例的时候,capabilities是可以被显式的删除,Kubernetes默认将所启动容器的capabiliteis全部删除,只留下那些供应用程序正常运行的,用户也可以在启动容器实例的时候显式增加或者去掉某些capabilities。好了,今天的文章就这么多了,下一遍文章笔者会通过一个Spring Cloud应用程序如何部署到Kubernetes集群,正式拉开一系列关于K8S介绍的文章。