[ECUG专题回顾]《深入理解容器技术》-田琪(京东资深架构师)

田琪:我今天主要会讲一些容器的底层技术,我讲之前可能想先问一下,各位做系统相关或者是可能会接触到一些系统层面的东西的东西有多少?能举一下手吗?



今天的议题是这样,首先还是会介绍容器整体架构,后面会讲几个比较重要的技术,就是和容器底层相关的一些技术,比如说像Namespace、CGroup和Device Mapper等,其中最重要的是像Cgroup提供资源配额,Namespace是资源隔离,后面详细讲这两个技术是如何实现的。另外会涉及到一些文件系统,比如像Aufs和Btrfs,另外还有其他周围的服务,比如说权限的、容器本身隔离性不好,有些东西需要靠额外的权限机制。另外比如说有些内核与用户层通讯的机制需要Netlink来完成,最早有了Namespace之后其实有一些容器的方案就已经做了,后来又出了一个叫LXC的东西,本身其实DOCKER出来最开始的时候如何去驱动下面这些Cgroup,Namespace呢?他也是用LXC来完成的,用的时候你会非常明显的感觉到瑕疵,写的比较粗糙。Docker于是就自己和LXC做了一个差不多的项目,叫Libcontainer,实际上也就是操作下面Cgroup,Namespace的事情。旁边是其他厂商提供的解决方案,比如像Etcd、Fleet、Libchan、Flannel和Weave,因为Docker只解决了网络连通的问题,你一堆容器转起来之后组合一个网络的话,可能需要把容器这层是要做隔离的,需要其他的支持。最上面是类似于像Kubernetes来帮你组建一个平台。



我最近看的项目像Kuberntes这个项目还是挺好的,然后我们简单的来进入主题,我们介绍一下内核的Namespace。容器本身不是VM,首先大家肯定要清楚容器和VM的本质区别,通过内核提供的Namespace这个东西,能够让你完成进程级别的隔离的效果。实际上它提供的是进程级别的资源隔离,给不同进程能够提供不同的命名空间的试图,让你看的东西是不一样的。两个进程在跑的时候就像VM一样,但实际上跟VM是有本质区别的,这也是我想说的第三点,容器本身是没有hypervisor的,它实际上和KVM和XEN有非常本质的区别,本质上的差别实际上是这样的,这个容器无论怎么隔它实际上是一个操作系统,然后给你隔离出来不同的视图让你看起来像是不同的系统一样,但实际上是同一个内核和系统。KVM本身虚拟化的技术本身是有芯片厂商支持的,比如像因特尔会有vt,vt-d的技术来支持虚拟化的技术。上面还会有Hypervisor层,KVM就是做这个事情用的,其实和容器的差别是非常大的。另外想说的是,我们Namespace实际上它是从2.4的时候就开始有引入,当时引入第一个层就是MNT, 陆续持续往里面加了很多的Namespace,直到3.8最后加的是USER Namespace,总共到现在是6个Namespace远远还不够,所谓容器的隔离性差是有关系的,因为Namespace不够。举个简单的例子,两个容器看起来像是操作系统也好你登进去之后,你在一个容器里改一下时间,那你整个系统的时间都会改过来,因为实际上是缺少了Time Namespace。你在容器里面看一个系统的日志,这个跟所有的容器都是一模一样你都能看的。



简单的介绍一下6个容器大体都有那些,现在这6个Namespace都有哪些?实际上这6个Namespace分别是MNT,解决的是你看起来的目录不一样。PID就是你的进程,你进去之后不同的容器看到的进程不一样。我这张图里画的就是一个图式,可以简单的看一下,我这个是命名空间,上面跑了有三个进程,后来我又加了6个进程,这6个进程我通过Namespace接口让这6个进程每3个为一对放在不同的Namespace里,结果就像这张图一样实际上是有父子关系的。我在命名空间内能够看到9个进程,但如果我在这个Namespace里我只看到属于他的3个进程,这隔离的过程就是通过PID Namespace做的。其他的像NET Namespace是做网络空间隔离的,IPC是进程间通信,UTS是你的主机名,USER是解决用户隔离的问题,这是6个命名空间的介绍。



我们介绍一下Namespace具体的接口,我怎么用这个Namespace?就是说这个的用法包括像DOCKER里面的LXC也好都是这么用的,你所有的容器、所有的隔离性、所有的事情从接口上只有这3个系统端,其中有两个是老早就存在的,。实际上针对Namespace的事情,只是新加了一个Setns,分别解释一下这几个接口都是怎么用的。首先讲一下clone,它是这样的,就是说在没有Namespace之前clone也是非常重要的一个系统调用,这个系统调用是用来创建你的进程或者是轻量级进程,都是通过这个来做的。实际上你进程和线程都可以通过这个来生成,区别在于Flags这个参数,比如如果你创建一个进程你只要添加一个child就可以,如果你是创建一个线程里面会再加上CLONE_FS等一大堆,这是之前创建用的系统调用。后来加上Namespace之后,我这个接口在这里新加了一些新的参数,这个参数实际上是我后面要讲的,这些参数可以让我创建一个新的进程,同时为它制订一个新的命名空间。我创建一个新的进程,新的进程在跑起来的时候是在一个新的Namespace里跑起来的,这是提供的第一个接口。后面我们再加的接口是unshare接口,比如说你的Clone做的事情是创建新的进程,并且把新的进程加到新的Flags里,而它做的是你当前这个进程跑着跑着希望把它放到一个新的地方里。同样flags参数和这个的参数是一样的,你添的值决定了你把它添加到哪个Namespace里。最后新加的一个叫setns,这个也是比较重要的,它可以改变当前的进程Namespace。比如说我当前的进程在一个Namespace下或者是在一个命名空间视图里,我希望它到另外一个视图里,实际上我是通过这个来做的。使用方式是通过一个FD,这个怎么用呢?实际上是这样的,在你的系统下有一个目录叫PROC,里面有一个进程的PID/NS,有一个叫NS的目录,里面分别放了一些文件,这个文件实际上是抽象出来的。你只要打开这个文件,之后通过系统调用打开之后把文件传进来,你就代表要把当前进程加到那个里,是这样的,三个系统调用,其实基本上就全了。分别可以创建一个新的Namespace加进去,把当前的进程加到某个Namespace里和改变进程的Namespace。



我们看一下Namespace到底是一个什么东西,刚才讲了半天感觉很抽象,它到底是个什么东西呢?我现在给大家讲一下Namespace是如何实现的,其实里面涉及到的都是一些数据结构,我把它用图式的方式画出来,其实思路是非常简单的。我们先看一下Namespace的初始状态是什么样的,你系统出来之后默认会有一个视图,这个视图是初始的Namespace,一上来都是在一个Namespace里。我说过了Namespace解决的是进程的隔离和试图的问题,那么进程的内核是通过 task_struct来表示的,也就是说新加了这么一个字段,这个叫nsproxy,这实际上有一个叫struct nsporxy的结构,里面包含了uts、net等,也就是说新加了这么一个字段里面又包含了这6种Namespace结构,这样我很方便的就知道了我一个进程所属于哪些Namespace,其实这是很简单的想法。我这张图初始状态是说,我是以主机名做例子来讲的,因为这个最简单。看到它uts Namespace里指向的是这么一个结构,它里面还是就是name域,这些就是一些字符串,就是你查看系统主机信息,你写一个命令叫Uname里面包含的就是这些东西,这是我初始的状态,有一个进程有一个Namespace,一上来是一个默认的状态。当我创建了一个新的进程后我说的是普通的创建新进程,实际上它做的事情是比如说原来一个task_struct,复制了一下变成了两份,所以两个进程会指向同一个nspoxy的结构,这也就是为什么我们在一个容器里面无论起多少个命令都还在这个容器内不会跑出去的原因,因为他创建出来新的进程默认会指向同一个,就是你原来父进程的Namespace,这样就可以保证我在一个Namespace下创建的所有进程都是在这个下的,其实是有一些非常琐碎的工作要做的,这是Namespace相关的东西。接下来我们看一下Cgroups,我们要解决的就是容器之间怎么做资源的配额。也就是说我要限制一个容器只能用多少CPU、多少内存,这些事情实际上是通过Cgroups来完成的,它主要会涉及一些内存、CPU、IO。本身跟Namespace是完全独立的两个模块没有任何关系,它是可以单独用的,而事实上可能有一些企业内部,它更多的可能会用到Cgroups而不是Namespace,因为它可能不需要隔离东西,但是只是为了限制一些进程的配额,可能会用到这个东西。Cgroups实际上要的是一个通用框架,各个子系统负责实现,Cgroups这个东西框架还是挺简单的现在有1万多行代码,但是子系统要遍布到整个内核的各个子系统,这个做起来其实是很复杂的,因为各个模块的维护根据这个框架来改自己之前的代码,因为之前是没有Cgroups这个东西的,其实改动还是相当大的。



容器技术在底层方面就是这两样东西,这两样东西的不足又决定了我这个容器技术不能像VM那种隔离性那么好,感觉用起来真的像一台机器一样,但是这个推起来很难,原因是因为需要各个模块、各个子系统,工作量是相当大的。简单的说一下Cgroups大概的模型是什么样的,实际上它是借助目录这个文件系统,我讲到这我问一下大家用过Cgroups的举一下手。我先大概说一下Cgroups怎么用,Cgroups是这样的,其实就是提供了一些文件系统的接口,Cgroups是通过VFS来暴露出来的接口,操作一些文件就可以完成资源管理工作,用法就是你专门有一个文件系统叫Cgroup,它之后会生成一个目录结构,里面你可以创建目录,每个目录里面都会生成一些文件,通过目录的形式能给你组织出来一个层次的关系,这个层次关系就是资源管理的关系,就是我这张图。一个Cgroups的文件系统,实际上就是生成了一个Cgroups的目录,在里面愿意创建。每一个目录都代表一个资源组,要做的事情就是把你的进程加入到资源组里面,加入进去之后进程就会受资源组配成的限制,你可以控制这个资源组。可能我这张图里写的是可能用50%的CPU、60%的内存,我这个资源组可以用50%的CPU、40%的磁盘IO、40%的内存,但是你配置的方式就是通过Cgroups暴露出来的文件,这个大家不清楚晚上搜一下文章非常多而且非常简单,包你5分钟搞清楚怎么用。



你进程就加入到这些资源组里就会受到你这些东西的限制,这是Cgroups完成的工作。我后面还是会介绍一下他都有哪些文件,你回去如果没用过的话你可以看一下,它在文件系统里面就会产生这么多文件,这是内核帮你生成的,是以文件为接口的形式,每个文件代表着不同含义。我简单介绍一下,蓝色的是只读文件,会输出一些统计的信息。灰色的是已经要废弃掉的,将来会删掉的,然后白色的是可写的文件,就是你要配置的文件。简单介绍一下,比如说最常用的文件我先介绍这个,这个是Cgroups框架的东西。最常用的文件是tasks和procs,我把一个进程的PID写入到这个文件里,你就依靠一个值和大于号就把ID写进去了,写进去之后你对应的进程就会受到资源组的限制,实际上是这样的。其实这两个文件差不多,但是两个文件放的值不一样,简单的说是一个pid,一个是tgid



我再简单的介绍一下具体是怎么用的,比如说我要控制一个资源组,这个资源组只能用50兆的内存,我就直接写50来弄,写入到我里面有一个memory这种文件,我写过去之后又把进程ID写到tasks的文件里,这个目录对应资源组里面你加进去的ID就会受到这个值的限制,他的值就不会超过,他被卡死也是有可能的。



我讲一下分类吧,首先框架就是你怎么控制进程、怎么管理这是生成一堆文件,内存说完了。CPU可能是多核的,内存实际上也是多个节点,这个东西就来控制你的进程,可以使用几号CPU或者是几号内存节点是做这个事情用的。相关的子系统这是一个设备的黑白名单它比较简单,比如像CPU一开始控制我资源组能用百分之多少的CPU,以前IO的策略在这,一个是基于CFQ的带宽控制,一个叫限流,这两个东西我后面会详细讲,这是Cgroups提供一些文件的接口。简单看一下框架是怎么实现的,也是看里面怎么实现的Cgroups。我简单介绍一下就不讲太详细了,



我们可以先看右边这一部分,右边这一部分实际上是静态的。比如说像Cgroups subsys提供的是不同的行为,比如说像内存子系统、CPU实际上都是这个数据结构来表示的,然后你有多少个Cgroups的子系统就有多少Cgroups的实例。比如说你有7个Cgroups,那么只有7个这个的实例。如果你选择所谓会把所有都加到这个结构里通过这个表,如果你选择一个内存把内存加进去是这样来管理的。我就知道我有一个根目录对应是管哪个子系统的。我通常的做法是这样的,我们马上要做的一件事是分配资源组,比如说我想创建3个资源组后面做的事情是在文件系统里做3个目录,3个目录就代表3个文件组,每个目录里又生成新的文件,你就配这些文件就相当于配资源分配组的权重了。



大家可能不太了解这里面的数据结构,像这三个东西你一看就知道最终组成是什么东西,实际上就是层次关系。你对应Cgroups在用的时候是一个目录数,实际上你在访问目录数的时候,在内核里面对应的是层级数据结构的关系。每个Cgroups里面最重要的东西叫cgroup_subsys_state,比如说你操作CPU相关的文件你是给CPU配了一些值,那么这个值存在哪儿呢?实际上是存在了你这个,这实际上是相当于用C玩出来多态的东西,这个东西对应最终会有一些实现。这些东西实际上都是挂在这个上面,实际上都是它的实例,C里面玩多态玩法的时候是比如说我在这里面定义了一个类型分配了一个数据结构,这个数据结构第一个成员是这个类型的,我将来可以根据第一个成员直接把数据类型给它计算出来,通过内核里面的一个东西来做这个事,这个实际上是C来完成多态的东西。我将来配置所有的配置,实际上最终都是放在这些数据结构里的,都是一些配置的值。



整个这一部分都是静态的,都是你在创建完Cgroups之后你添完资料值之后来生成的东西。实际上左边这一部分是感觉到的呢?左边这一部分解决的就是我配置完了之后,我最终要工作就是让我进程能生效,我进程加入到某一个Cgroups里,这个事情如何来做呢?就是左边的过来完成,我刚刚说你用的时候要把你的进程ID加进去,进程的ID加进去要做的就是把task_struct和Cgroups之间建立关系,其实做的就是这个事情,就是这样的。他们如何建立关系或者是他们是怎样的关系呢?大家考虑我一个进程和资源组到底是什么关系,这个能回答一下吗?其实在内核里面的实现是多对多的关系,首先你想非常好理解的是你肯定一个资源组里放多个进程,很多进程都满足这个条件,我有10个进程都需要控制内存是多少CPU、多少内存是吧?这些你都可以满足的,肯定你是一个资源组里可以由多个进程,那么为什么会出现我一个进程会对应到多个资源组呢?这个其实很好理解,第一个系统可能放的是CPU的配置第二个放的是进程的配置,这个时候我就要分别加到这两个文件系统的某个Cgroups目录里,于是它就是多对多的关系。那么内核在表示多对多关系的时候是一个非常通用的,你现在要写个程序你要做2个实体之间完成一个多对多的映射,实际上你能做的事情是什么呢?你想要多对多,其实你唯一的办法就是生成一个中间的数据结构,其实这个数据结构就是这个。后面会简单介绍一下Device Mapper,你系统里面非常常用的,你用什么都是这个模块,而且这个模块非常有用,包括像脸书的Flashcache也是基于Device Mapper写的,它做的事情实际上是做了虚拟设备硬设的工作,在操作系统上有一块盘就是写入,但是举个简单的例子,两块盘做一个镜像行不行?怎么做呢?就抽出来一块盘做一个假的,写这到个盘子四的时候写一点代码,这个代码就是把写入的请求分成两份,一份写A一份写B,这个事情就可以通过Device Mapper来做,它是一个通用的框架可以写自己的代码。它提供的机制就是抽象出来一个虚拟的设备,通过你这一层抽象变成其他的方式。这个东西实际上是一个框架,它只是一个框架是通用的,当然也提供了一些实现。



我们要关注的是Thin provision,这个东西是帮助你做镜像用到的东西,实际上是其中的一个thin provision,我再多说两句,你存储端的选择其中之一是这个,我简单介绍一下它是干嘛的,它是通过dmsetup,你可以映射一个范围,然后这就是它的名字是写死的。首先你要创建一个Thin-pool,写两个设备,后面填的值是大小的参数,我就不详细解释了。创建出来这么一个设备之后,这个设备是管理工作,这个设备不能直接当盘来有,后面要做的事情还要再加一个命令要给它发条消息,Dev mapper这个是你刚才创建的,0这个是没有用的,你在上面这个设备里创建出来一个虚拟的设备或者是一个虚拟的盘,这个0实际上是设备号你自己随便定,不要冲突了就行。然后再输入你的设备大小,这个是你的thin_pool的名字,这是你发消息起的设备号,这样之后在你下面就出来了两个文件,一个叫Mythin一个叫Mythinpool,后者是作为管理用的,另外一个是你那块盘,实际上你最终写数据的时候用到的是这个东西,你得把这个东西直接放到一个目录上直接用了,那么你用这个设备的时候,你就通过这么一系列的操作,你得到了两个好处。第一个好处叫Thinpool,实际上就是按需分配的概念。你可以把这个设备放上去之后,比如说你这个设备在创建的时候写了65536个扇区,但是实际上并不产生任何的数据也不占用任何资本空间,在你写入的时候会按一个块的分配给你,块大小是你创建的时候指定。另外一个好处,你用这个设备的时候这个设备提供了一个快照的功能,它正是用了这个功能来提供你上层镜像的生成这些工作。



大家知道这个东西怎么用了,我讲一下这个原理。你这看个地方是指定了两个设备,Metadata和Data组成的是两个不同的结构,这然后我下面创建的虚盘叫做Thin Dev,首先你指定了一个设备,这个设备里放的是一个数据结构,这个数据结构是落到磁盘上持久化的。这个里面放的是什么呢?实际上它放的是一个映射关系,这个映射关系叫做Thin-devid LBA,放的是逻辑值,最终查找到的东西是物理的PBA,我们Metadata里放的是一个查找功能,这个简单说是一个映射。那么它到底是啥呢?实际上就是你这一个一个的块,实际上我们最终用这个设备的时候,我所有的写入都是写到data里,那我实际上用的是这个不是用的这个(如图),这个实际上是一个虚盘,那么做的是什么事情呢?做的时候写的叫这个东西,我可能告诉这个设备我要写这个设备哪个扇区,对应的是这个设备的设备号和逻辑快递纸。然后拿这个设备的设备号和纸到Metadata里去找,找到的就是一个PBA和时间,对应的就是这个设备里某一个块的块号。那么你现在应该明白了这个管理的是什么?管理的是所有虚拟设备的所有的块,那么所有的物理实验都是在这的,这个真正写入的物理设备在这。所有创建的盘都是假的,这个盘最终都是由它来找到这个物理的块,就是这个东西。你写一个块申请一个,写一个块申请一个,是这样的方式。写入的时候映射就是通过这个来完成,现在讲一下快照,快照是干什么用的呢?比如说我创建了10个容器,10个容器里的文件系统肯定是要共享的,不能每一个都创建那就浪费太严重了,那怎么共享呢?就是通过快照这种方式,我下载一个镜像的时候我要跑起来,这个镜像可能是创建一个之前可能存在的镜像,创建一个快照,创建完之后只有在生成改变的时候才需要存,其他的时候是共享的,这就是快照要做的事情。那么它是怎么做的呢?这是通过时间来搞定的,为什么要存每个块里呢?放在每一个块里都会有一个时间,同时如果我对一个设备做了快照,那么我的设备会维护一个时间,于是我在取出这个块的时候只要比对我这个块当初分配给虚拟设备时候的时间和整个虚拟设备上次快照的时候,我就知道这个块是不是共享的,能理解吧?如果设备发生过一次快照,那么我之前分配所有的块都不能再写了,对不对?它都得变成只读的,要写的话就要用一个新的块写,这个时候就是共享的。比如说我现在写这个块,我查到了物理块是这个,比如说是1,然后发现我的设备上一次快照时间是2那我就不能再写了,我要想写新的改变我要申请一个新的块再写进去,就是这样。这也是快照的原理,也是它提供的功能。这个东西我就简单的做一下总结。



刚才说到Docker里面支持的几个存储端,其中一个是我刚才讲的Device Mapper。Device Mapper是通用框架,我刚才讲的只是Docker存储端一个选择,还有其他的选择,比如说Aufs,她的问题是没有进入主线内核,他写的代码我也看过,算法和思想都很好,但代码看起来比较吃力,风格不太好。当时出现的情况是功能确实很好,他想推到主线内核里,然后得到了强烈的反对,他就一边改一边反对,最后这个作者在尝试了N久之后终于放弃了。这里面有一个问题,如果他进不了主线内核的话你选择他是相对比较麻烦的,生产系统还没有用BtrfS。



后面简单介绍以下我们做的镜像存储系统,也是典型的三层结构,分为存储节点,路由管理节点,和前端接入节点三个主要模块,提供了大文件拆分,断电续传,多副本容灾,无缝在线迁移等常用功能,时间关系不展开介绍了。



PPT:http://qiniuppt.qiniudn.com/tianqi.pdf


视频(田琪&孙宏亮): http://qiniu-opensource.qiniudn.com/ecug-2014-tianqi.mp4