作者|唐华敏(HuaMin)阿里云容器平台技术专家本文整理自《CNCF x Alibaba 云原生技术公开课》第15讲。关注“阿里云原生”公众号,回复关键词“入门”,即可下载K8s从零入门PPT系列文章。简介:Linux容器是一种轻量级的虚拟化技术。在共享内核的基础上,基于命名空间和cgroup技术实现进程资源隔离和限制。本文将以docker为例,介绍容器镜像和容器引擎的基础知识。Container容器是一种轻量级的虚拟化技术,因为它比虚拟机少了一个管理程序层。先看下图,简单描述了一个容器的启动过程。最下面是一个磁盘,磁盘上存放着容器的镜像。上层是容器引擎,可以是docker或者其他容器引擎。引擎向下发出一个请求,比如创建一个容器,此时它把容器镜像作为宿主机上的一个进程运行在磁盘上。对于容器来说,最重要的是如何保证这个进程所使用的资源是隔离和有限的,这在Linux内核上有cgroup和namespace这两个技术来保证。下面以docker为例,详细介绍资源隔离和容器镜像两部分。1、资源隔离和限制namespaceNamespace用于资源隔离。Linux内核中有七个命名空间,前六个在docker中使用。第七个cgroup命名空间在docker本身并没有使用,而是在runC实现中实现了cgroup命名空间。让我们从头开始:第一个是mout命名空间。mout命名空间是为了保证容器看到的文件系统的视图。它是容器镜像提供的文件系统,也就是说看不到宿主机上的其他文件。除了-v参数绑定的模式,宿主机可以让宿主机上的一些目录和文件在容器中可见;二是uts命名空间,主要隔离hostname和domain;第三个是pid命名空间,保证容器的init进程是由1号进程启动的;第四个是网络命名空间,除了容器使用主机网络模式外,其他所有网络模式都有自己的网络命名空间文件;第五个是用户命名空间,这个命名空间控制着容器内部和宿主机上用户UID和GID的映射,但是这个命名空间很少被使用;第六个是IPC命名空间,控制进程和通信,比如信号量;七个是cgroup命名空间。上图右边有两个示意图,分别表示cgroup命名空间的开启和关闭。使用cgroup命名空间的一个好处是,在容器中看到的cgroup视图是以根的形式呈现的,因此与进程在宿主机上看到的cgroup命名空间视图是一样的;另一个优点是在容器内部使用cgroups更安全。这里我们简单的以unshare为例来说明命名空间的创建过程。容器中命名空间的创建实际上是用unshare系统调用创建的。上图上半部分是unshare的用法示例,下半部分是我用unshare命令实际创建的一个pid命名空间。可以看到bash进程已经在一个新的pid命名空间,然后ps看到这个bash的pid现在是1,说明是一个新的pid命名空间。cgroup驱动有两种类型:cgroup主要用于资源限制。docker容器的cgroup驱动有两种:一种是systemd,另一种是cgroupfs。cgroupfs更容易理解。比如应该限制多少内存,应该使用多少CPU份额?其实就是直接把pid写入对应的cgroup文件,然后把对应需要限制的资源写入对应的内存cgroup文件和CPUcgroup文件即可;另一个是systemd的cgroup驱动。这个驱动是因为systemd本身可以提供cgroup的管理方式。所以如果使用systemd作为cgroup驱动,所有的cgroup写操作都必须通过systemd接口来完成,不能手动更改cgroup文件。容器中常用的cgroups接下来我们看一下容器中常用的cgroups。Linux内核本身提供了很多种cgroup,但是docker容器只使用了以下六种:第一是CPU,CPU一般会设置cpushare和cupset来控制CPU的使用;二是内存,就是控制进程内存的使用;第三个设备,device控制容器中可以看到的设备device;第四个冰柜。它和第三个cgroup(设备)是为了安全。当你停止容器时,freezer会将当前所有进程写入cgroup,然后冻结所有进程。这样做的目的是为了防止一些进程在你停止的时候做fork。这样的话,就相当于阻止了进程逃逸到宿主机,这是出于安全考虑;第五个是blkio,blkio主要是限制容器使用的磁盘的一些IOPS和bps速率限制。因为cgroup不唯一,blkio只能限制同步io,dockerio不行;第六个是pidcgroup,它限制了容器中可以使用的最大进程数。一些不常用的cgroups也是docker容器不使用的cgroups。容器中常用和不常用,这个区别是针对docker的,因为对于runC来说,除了最底层的rdma,其实runC中所有的cgroups都是支持的,但是docker并没有开启这部分支持,所以docker容器是不支持的cgroups如下图所示。2、容器镜像dockerimages接下来说说容器镜像,以docker镜像为例来谈谈容器镜像的组成。Docker镜像基于联合文件系统。简单描述一下联合文件系统,大概意思是:它允许文件分层次存储,但最终你可以通过一个统一的视图看到这些层次上的所有文件。如上图所示,右边是从docker官网上截取的容器存储结构图。这张图非常形象地展示了docker的存储。docker的存储基于联合文件系统,是分层的。每一层都是一个Layer,这些Layer由不同的文件组成,可以被其他图片复用。可以看到当镜像作为容器运行时,最上层会是容器的读写层。这个容器的读写层也可以通过commit变成镜像顶层的latest层。docker镜像存储的底层是基于不同的文件系统,所以它的存储驱动也是针对不同的文件系统定制的,比如AUFS、btrfs、devicemapper、overlay等。Docker为这些文件系统做了一些相应的graphdriver驱动,通过这些驱动将镜像存储到磁盘上。以overlay为例存储过程下面我们以overlay文件系统为例,看看docker镜像是如何存储在磁盘上的。先看下图,简单描述了overlay文件系统的工作原理。最底层为下层,即镜像层,为只读层;右上层是一个upperlayer,是容器的读写层,upperlayer采用了现实的复制机制,也就是说只有某些文件需要修改的时候,才会从下层,所有修改操作都会修改上层的副本;upper并排有一个workdir,充当中间层。也就是说,当上层的副本被修改时,会先放到workdir中,然后再从workdir移动到upper。这就是overlay的工作机制;最上面是mergedir,这是一个统一的视图层。从mergedir可以看到upper和lower中所有数据的整合。然后我们dockerexec进入容器,看到一个文件系统其实就是mergedir的统一视图层。文件操作下面说一下如何基于overlay存储对容器中的文件进行操作。我们先看读操作。刚创建容器时,上半部分实际上是空的。如果此时读取,所有的数据都是从下层读取的。写操作刚才说了,overlay的上层有写真实数据的机制。当需要对某些文件进行操作时,overlay会做一个copyup的动作,然后从lowerlayer复制文件,然后对这部分进行一些写修改操作。再看删除操作,overlay里面其实并没有真正的删除操作。它所谓的删除其实就是通过标记文件,然后从最上面的统一视图层来看。如果看到文件被标记,就会显示该文件,这时就认为该文件被删除了。这种标记有两种方法:一种是涂白法;二是通过设置目录的扩展权限和设置扩展参数来删除目录。操作步骤下面我们来看看实际使用dockerrun启动busybox容器。它的覆盖挂载点是什么样子的?第二张图是坐骑。可以看到这个容器的rootfs挂载了一个,挂载是overlay类型的。它包括三个级别:upper、lower和workdir。然后查看容器中新文件的写入。dockerexec新建一个文件,从上面的diff可以看出,是它的一个upperdir。查看upperdir中的文件,文件中的内容也是dockerexec写入的。最后看一下最下面的mergedir,mergedir中集成了upperdir和lowerdir的内容,也可以看到我们写的数据。3.容器引擎containerd的容器架构详解接下来,我们将基于CNCF的一个容器引擎containerd,说说容器引擎的大致组成。下图是从containerd官网截取的架构图。基于这张架构图,先简单介绍一下containerd的架构。如果把上图分成左右两边,可以认为containerd提供了两大功能。第一个是针对运行时的,即容器生命周期的管理。左边的存储部分其实是一个镜像存储的管理。containerd将负责拉取和存储镜像。从水平层面来说:第一层是GRPC,containerd以GRPCserveforupperlayer的形式向上层提供服务。Metrics部分主要提供cgroupMetrics的一些内容;下层左侧是容器镜像存储,中间一行镜像和容器在Metadata下面。这部分Matadata是通过bootfs存储在磁盘上的。右侧的任务是管理容器的容器结构。Events是指对容器的一些操作都会有一个Event发送给上层,然后上层可以订阅这个Event,从而知道容器状态发生了哪些变化;最底层是Runtimes层,Runtimes可以从类型上区分,比如runC或者安全容器。什么是shimv1/v2接下来说一下containerd在runtime端的大致结构。下图摘自kata官网。上半部分为原图,下半部分增加了一些扩展示例。基于这张图,我们来看看containerd在runtime层的架构。如图:一个进程按照从左到右的顺序从上层运行到最终运行时。我们先来看最左边的,这是一个CRIClient。一般情况下,kubelet通过CRI请求向containerd发送请求。containerd收到容器请求后,它将通过containerdshim。containerdshim负责管理容器的生命周期。它主要负责两个方面:第一是转发io;二是它传递信号。图中上半部分是securitycontainer,是一个kata的进程,这里就不详细展开了。在下部,您可以看到有各种垫片。下面介绍一下containerdshim的架构。一开始,containerd中只有一个shim,就是containerd-shim,四周是蓝色框。这个过程意味着无论是kata容器,runc容器,还是gvisor容器,上面使用的shim都是containerd。后来,containerd针对不同类型的运行时做了扩展。这个扩展是通过shim-v2接口来完成的,也就是说只要实现了shim-v2接口,不同的运行时就可以自定义不同的shim。例如:runC可以自己做一个shim,叫做shim-runc;gvisor可以自己做一个shim,叫做shim-gvisor;和上面的kata一样,它也可以自己做一个shim-kata的shim。这些shim可以替换上面蓝色框中的containerd-shim。这样做的好处有很多,我举个更形象的例子。你可以看看kata的图片。如果在上面使用shim-v1,其实就是三个组件。之所以有三个组成部分,是因为型本身的局限性。但是在使用了shim-v2架构之后,这三个组件可以做成一个binary,也就是原来的三个组件现在可以变成一个shim-kata组件,可以体现出shim-v2的一个好处。containerd容器架构详解——容器进程示例接下来我们将通过两个例子来详细讲解容器进程是如何工作的。下面两张图是基于containerd架构绘制的一个容器的工作流程。启动流程先看一下容器启动的流程:这张图由三部分组成:第一部分是容器引擎部分,容器引擎可以是docker,也可以是其他;containerd和containerd-Shim,都属于containerd架构;最下面是container部分,由runtime拉起,可以认为是shim创建的容器,用来运行runC命令。让我们来看看这个过程是如何工作的。图中也标出了1、2、3、4。1、2、3、4是containerd创建容器的过程。首先,它会创建一个元数据,然后向任务服务发送请求以创建容器。通过中间的一系列组件,最终将请求发送到一个shim。containerd和shim的交互其实是通过GRPC来完成的。containerd向shim发送创建请求后,shim会调用runtime创建容器。以上是容器启动的例子。Execprocess接下来我们看下图是如何执行一个容器的。和start流程很相似,结构也大致相同。不同的部分实际上是containerd如何处理这部分过程。和上图一样,我也在图中标出了1、2、3、4。这些步骤代表containerd执行exec的顺序。从上图可以看出:exec操作还是发送给了containerd-shim。对于容器来说,启动容器和执行容器其实没有本质区别。最后的区别无非就是:是否为运行在容器中的进程创建命名空间。exec时,需要将进程添加到一个已有的命名空间中;启动时需要专门创建容器进程的命名空间。在本文的最后,希望各位同学在看完这篇文章后,能够对Linux容器有更深入的了解。这里简单总结一下本文的内容:容器如何使用命名空间进行资源隔离,使用cgroups进行资源限制;基于overlay文件系统的容器镜像存储简介;以docker+containerd为例介绍容器引擎的工作原理。》阿里云原生微信公众号(ID:Alicloudnative)专注于微服务、Serverless、容器、ServiceMesh等技术领域,关注云原生流行技术趋势、云原生大规模实施实践,是最懂云的原生开发者公众号。”更多相关内容,请关注“阿里云原生”。
