容器底层技术

注:本文全部操作在centos8环境下操作!

一.Docker基本架构

1.服务端

DOcker服务端也就是DOcker daemon,一般在宿主机后台运行,接受来自客户的请求,并处理这些请求。在设计上,DOcker服务端是一个模块化架构,通过专门Engine模块分发、管理各个来自客户的任务。

让服务器监听等地的TCP连接1234端口

[root@Docker ~]# sudo dockerd -H 0.0.0.0:1234

2.客户端

用户不能与服务器直接交互,Docker客户端为用户提供一系列可执行的命令,用户通过这些命令与Docker服务端进行交互。

用户使用的Docker可执行命令就是客户端程序。与Docker服务端不同的是,客户端发送命令后,等待服务端返回信息,收到返回信息后,客户端立刻结束任务并退出。用户执行新命令时,需要再次调用客户端程序。

查看Docker信息

[root@Docker ~]# docker version

显示并没有连接到客户端,但Docker客户端仍可以为用户提供服务

通过命令指定正确的地址信息,再次查看DOcker信息

[root@Docker ~]# docker 192.168.88.148 -H 0.0.0.0:1234

因此只有通过-H参数指定正确的地址信息才能连接到服务端

二.Namespace

1.Namespace介绍

Linux操作系统中,容器用来实现“隔离”的技术称为Namespace(命名空间)。 Namespace技术实际上修改了应用进程看待整个计算机的“视图”,即应用进程的“视线”被操作系统做了限制,只能“看到”某些指定的内容,如图所示。

但对于宿主机来说,这些被进行“隔离”的进程跟其他进程并没有太大区别。

运行一个CentOS7容器。

[root@Docker ~]# docker run -it centos /bin/bash

从以上可以看到,bash是这个容器内部的第1号进程,即PID=1,而这个容器里一共只有两个进程在运行,这就意味着,前面执行的/bin/sh,以及刚刚执行的 ps,已经被Docker 隔离在一个与宿主机完全不同的空间当中。 理论上,每当在宿主机上运行一个/bin/sh程序,操作系统都会给它分配一个进程编号,例如,PID=100。这个编号是进程的唯一标识,就像员工的工号一样。所以,PID=100,可以粗略地理解为这个/bin/sh是公司里的第100号员工。 而现在,要通过Docker把/bin/sh运行在一个容器当中。这时,Docker就会在这个第100号员工入职时给他施一个“障眼法”让他永远看不到前面的其他99个员工,这样,他就会以为自己就是公司里的第1号员工。

这种机制其实就是对被隔离应用的进程空间做了手脚,使这些进程只能看到重新计算过的进程号,例如 PID=1。可实际上,它们在宿主机的操作系统里,还是原来的第100号进程,如图所示。

下面通过宿主机查看容器进程号。

[root@Docker ~]# docker ps
[root@Docker ~]# ps aux | grep 27e4e5a0d0ea

在宿主机中通过容器的ID号查看其进程号,可以看出其进程号为2216,这就是Linux里的Namespace机制。 在 Linux 系统中创建线程调用是clone()函数,例如:int pid = clone(main_function, stack_size, SIGCHLD, NULL); 这个调用会创建一个新的进程,并且返回它的进程号。

系统调用clone()创建一个新进程时,可以在参数中指定CLONE_NEWPID ,例如:int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);。这时,新创建的这个进程将会“看到”一个全新的进程空间,在这空间里,它的进程号是1。在宿主机真实的进程空间里,这个进程的真实进程号不变。 当然,可以多次执行上面的clone()调用,这样就会创建多个PID Namespace,而每个Namespace里的应用进程,都会认为自己是当前容器里的第1号进程,它们既看不到宿主机里真正的进程空间,也看不到其他PID Namespace里的具体情况。

2.NAmespace的类型 

命名空间分为多种类型,对应用程序进行不同程度的隔离,下面一一讲解。

(1)Mount namespace

Mount Namespace将一个文件系统的顶层目录与另一个文件系统的子目录关联起来,使其成为一个整体。该子目录称为挂载点,这个动作称为挂载。

(2)UTS namespace

UTS(UNIX Time-sharing System,UNIX分时系统)Namespace提供主机名和域名的隔离,使子进程有独立的主机名和域名,这一特性在Docker容器技术中被运用,使Docker容器在网络上被视作一个独立的节点,而不仅仅是宿主机上的一个进程。

(3)IPC namespace

IPC(Inter-Process Communication,进程间通信)Namespace是UNIX与Linux下进程间通信的一种方式。IPC有共享内存、信号量、消息队列等方式。此外,也需要对IPC进行隔离,如此一来,只有在同一个Namespace下的进程才能相互通信。IPC需要有一个全局的ID,既然是全局的,就意味着Namespace需要对这个ID号进行 隔离,不能让其他Namespace的进程“看到”。

(4)PID namespace

PID Namespace用来隔离进程的ID空间,使不同PID Namespace里的进程ID号可以重复且相互之间不影响。 PID Namespace可以嵌套,也就是说有父子关系。在当前Namespace里面创建的所有新的Namespace都是当前Namespace的子Namespace。在父Namespace里面可以“看到”所有子Namespace里的进程信息,而在子Namespace里看不到父Namespacelode与其他子Namespacelode进程信息,如图所示。

(5)Network Namespace

每个容器拥有独立的网络设备,IP地址,IP路由表,/proc/net目录,端口号等。这也使得一个host上多个容器内的网络设备都是互相隔离的。

(6)User namespace

User Namespace用来隔离User权限相关的Linux资源,包括User IDs和Group IDs。 这是目前实现的Namespace中最复杂的一个,因为User和权限息息相关,而权限又关联着容器的安全问题。 在不同的User Namespace中,同样一个用户的User ID和Group ID可以不一样。也就是说,一个用户可以在父User Namespace中是普通用户,在子User Namespace中是超级用户。

3.深入理解NAmespace

通过一段代码来查看Namespace是如何实现的。

在以上示例中,代码段通过clone()调用,传入各个Namespace对应的clone flag,创建了一个新的子进程,该进程拥有自己的Namespace。根据以上代码可知,该进程拥有自己的PID、Mount、User、Net、IPC以及UTS Namespace。

所以,Docker在创建容器进程时,指定了这个进程所需要启动的一组Namespace参数。这样,容器就只能“看到”当前Namespace所限定的资源、文件、设备、状态、配置信息等。至于宿主机以及其他不相关的程序,它就完全看不到了。容器,其实是Linux系统中一种特殊的进程。

Linux中Docke创建的隔离空间虽然是看不见摸不着,但是一个进程的Namespace信息在宿主机上是真实存在的,并且是以文件的方式存在,因为在Linux操作系统中,一切皆文件。

一个进程可以选择加入到某个进程已有的Namespace当中,从而达到“进入”这个进程所在容器的目的,这正是docker exec的实现原理。

通过示例进行详细讲解,先运行一个CentOS容器。

[root@Docker ~]# docker run -it -d centos 
[root@Docker ~]# docker ps

以上示例中在运行Docker容器的命令中添加了参数-d,表示使容器在后台运行。

查看当前正在运行Docker容器的进程号。

[root@Docker ~]# docker inspect --format '{{ .State.Pid}}' bc83b4d1bea8

查询到的进程号位2558

查看宿主机的/proc文件,可以看到这个2558进程所有Namespace对应的文件

[root@Docker ~]# ls -l /proc/2558/ns/

可以看到,一个进程的每种Namespace都在它对应的/proc/[进程号]/ns下有一个对应的虚拟文件,并且链接到一个真实的Namespace文件。

有了这样的文件,就可以对Namespace做一些实质性的操作。例如,将进程加入到一个已经存在的Namespace当中。

这个操作依赖一个名为setns()的Linux系统调用。

上述代码共接收了两个参数。

(1)arvg[1],即当前进程要加入的Namespace文件的路径,如/proc/8589/ns/net。

(2)用户要在这个Namespace里运行的进程,如/bin/bash。

代码的核心操作则是通过open()打开指定的Namespace文件,并把这个文件的描述符fd交给setns()使用。在setns()执行后,当前进程就加入了这个文件对应的Namespace当中。

4.NAmespace的劣势

强大的Namespace机制可以实现容器间的隔离,是容器底层技术中非常重要的一项,但也有不可否认的不足。下面总结基于Namespace的隔离机制相对于虚拟化技术的不足之处,以便在生产环境中设法克服。

(1)隔离不彻底

容器只是运行在宿主机上的一种特殊的进程,多个容器之间使用的是同一个宿主机的操作系统内核。 尽管可以在容器中通过Mount Namespace单独挂载其他版本的操作系统文件,如CentOS或者Ubuntu,但这并不能改变它们共享宿主机内核的事实。在Windows宿主机上运行Linux容器,或者在低版本的Linux宿主机上运行高版本的Linux 容器,都是行不通的。 相比之下,拥有硬件虚拟化技术和独立Guest OS的虚拟机就要好用得多。最极端的例子是Microsoft的云计算平台Azure,它就是运行在Windows服务器集群上的,但这并不妨碍用户在上面创建各种Linux虚拟机。

(2)有些资源和对象不能被Namespace化

如果容器中的程序调用settimeofday()修改了时间,整个宿主机的时间都会被修改。相较于在虚拟机里面可以任意做修改,在容器里部署应用时,需要用户的操作上更加谨慎。

(3)安全问题

因为共享宿主机内核,容器中的应用暴露出来的攻击面很大。尽管生产实践中可以使用seccomp等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但这类方法因为多了一层对系统调用的过滤,会拖累容器的性能。通常情况下,也不清楚到底该开启哪些系统调用,禁止哪些系统调用。

三.Cgroups

1.Cgroups介绍

在日常工作中,可能需要限制某个或者某些进程的资源分配,于是就出现了Cgroups这个概念。Cgroups全称是Control groups,这是Linux内核提供的一种可以限制单个进程或者多个进程使用资源并进行分组化管理的机制,最初是由Google的工程师提出,后来被整合进Linux内核。Cgroups中有分配好特定比例的CPU时间、IO时间、可用内存大小等。已经通过Linux Namespace创建的容器,Cgroups将对其做进一步“限制”。

另外,Cgroups采用分层结构,每一层分别限制不同的资源,如图所示。

图中,限制层A限制了CPU时间片,cgrp1组中的进程可以使用CPU60%的时间片,cgrp2组中的进程可以使用CPU20%的时间片。限制层B限制了内存的子系统,Cgroups中的重要概念就是“子系统”,也就是资源控制器。

子系统就是一个资源的分配器,例如,CPU子系统是控制CPU时间分配的。首先挂载子系统,然后才有Cgroups。例如,先挂载memory子系统,然后在memory子系统中创建一个Cgroups节点,在这个节点中,将需要控制的进程写入,并且将控制的属性写入,这就完成了内存的资源限制。 在Cgroups中,资源的限制与进程并不是简单的一对一关系,而是多对多的关系,多个限制对应多个进程,如图所示。

在图中,每一个进程的描述符都与辅助数据结构css_set相关联。一个进程只能关联一个css_set,而一个css_set可以关联多个进程。css_set又对应多个资源限制,关联同一css_set的进程对应同一个css_set所关联的资源限制。

Cgroups的实现不允许css_set同时关联同一个Cgroups层级下的多个限制,也就是css_set不会关联同一种资源的多个限制。这是因为为了避免冲突,Cgroups对同一种资源不允许有多个限制配置。

一个css_set关联多个Cgroups资源限制表示将对当前css_set下的所有进程进行多种资源的控制。一个Cgroups资源限制关联多个css_set表明多个css_set下的所有进程都受到同一份资源的相同限制。

Cgroups被Linux内核支持,有得天独厚的性能优势,发展势头迅猛。在很多领域可以取代虚拟化技术分割资源。Cgroups默认有诸多资源组,几乎可以限制所有服务器上的资源。

这里还是以PID Namespace为例,虽然容器内的1号进程在隔离机制的作用下只能看到容器内的情况,但是在宿主机上,它作为第100号进程与其他所有进程之间依然是平等竞争关系。这就意味着,虽然第100号进程表面上被隔离了起来,但其能够使用到的资源(CPU、内存等),却是可以随时被宿主机上的其他进程占用,这就可能把所有资源耗光。Cgroups技术的出现,完美地解决了这一问题,对容器进行了合理的资源限制。

2.Cgroups的限制能力

下面介绍Cgroups的子系统。

blkio

该子系统为块设备设定输入/输出限制,如物理设备(磁盘、固态硬盘、USB等)。

cpu

该子系统使用调度程序提供对CPU的Cgroups任务访问。

cpuacct

该子系统自动生成Cgroups中任务所使用的CPU报告。

cpuset

该子系统为Cgroups中的任务分配独立CPU(在多核系统)和内存节点。

devices

该子系统可允许或者拒绝Cgroups中的任务访问设备。

freezer

该子系统挂起或者恢复Cgroups中的任务。

memory

该子系统设定Cgroups中任务的内存限制,并自动生成由那些任务使用的内存资源报告。

net_cls

该子系统使用等级识别符标记网络数据包,可允许Linux流量控制程序识别从具体Cgroups中生成的数据包。

ns

该子系统提供了一个将进程分组到不同命名空间的方法。

下面重点介绍Cgroups与容器关系最紧密的限制能力。

Linux中,Cgroups对用户暴露出来的操作接口是文件系统,即Cgroups以文件和目录的方式处于操作系统的/sys/fs/cgroup路径下。

下面通过命令查看Cgroups文件路径。

[root@Docker ~]# mount -t cgroup

以上示例中,输出结果是一系列文件系统目录。/sys/fs/cgroup下面有很多类似cpuset、cpu、memory等子目录,也叫子系统。这些都是这台计算机当前可以被Cgroups进行限制的资源种类。而在子系统对应的资源种类下,用户就可以看到该类资源具体的限制方法。

例如,对子系统cpu来说,有如下几个配置文件:

[root@Docker ~]# ls /sys/fs/cgroup/cpu

cfs_period和cfs_quota这两个参数需要组合使用,以限制进程在长度为cfs_period的一段时间内,只能被分配到总量为cfs_quota的CPU时间。

3.实例验证

下面通过一个示例,来深入理解Cgroups。

在子系统下面创建一个目录,这个目录称为一个“控制组”,示例代码如下:

[root@Docker ~]# cd /sys/fs/cgroup/cpu
[root@Docker cpu]# ls
[root@Docker cpu]# mkdir container
[root@Docker cpu]# ls container/

从以上示例中可以看到,操作系统会在新创建的目录下自动生成该子系统的资源限制文件。

下面在后台执行一条脚本,将CPU占满。

[root@Docker cpu]# while : ; do : ; done &

以上示例执行了一个死循环命令,进程把计算机的CPU占到100%,在输出信息中可以看到这个脚本在后台运行的进程号为2104。

下面使用top命令查看一下CPU的使用情况。

[root@Docker cpu]# top

从以上示例的输出结果中可以看到,CPU的使用率已经达到100%。

下面查看container目录下的文件。

[root@Docker cpu]# cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
[root@Docker cpu]# cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us

从以上示例中可以看到,container控制组里的CPU quota还没有任何限制(-1),CPU period则是默认的100ms(100000)。

通过修改这些文件的内容就可以进行资源限制。

例如,向container组里的cfs_quota文件写入20ms(20000us)。

[root@Docker cpu]# echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
[root@Docker cpu]# cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us

这就意味着在每100ms的时间里,被该控制组限制的进程只能使用20ms的CPU时间,也就是说这个进程只能使用到20%的CPU带宽。

另外,还需要把被限制的进程的进程号写入container组里的tasks文件,上面的设置才会对该进程生效。

[root@Docker cpu]# echo 2104 > /sys/fs/cgroup/cpu/container/tasks 

下面再次使用top命令查看,验证效果。

[root@Docker cpu]# top

从以上示例中可以看到,计算机的CPU使用率立刻降到了20.6%。

关于Linux Cgroups的结构,简单理解就是一个子系统目录与一组资源限制文件的集合。而对于类似Docker的Linux容器项目来说,只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),在启动容器进程之后,将该进程进程号填写到对应控制组的tasks文件中即可。

控制组下面资源文件中的值,则需要用户执行docker run命令的参数指定。

[root@Docker cpu]# docker run -it -d --cpu-period=100000 --cpu-quota=20000 centos /bin/bash

启动这个容器后,查看Cgroups 文件系统下CPU子系统中“system.slice”这个控制组里的资源限制文件。Centos8则在CPU子系统中“docker”这个控制组中。

[root@Docker ~]# cat /sys/fs/cgroup/cpu/docker/9bd78613e0db5316e02e1e75518350990c048f230e7ad64d5a46a166bb3804e1/cpu.cfs_quota_us
[root@Docker ~]# cat /sys/fs/cgroup/cpu/docker/9bd78613e0db5316e02e1e75518350990c048f230e7ad64d5a46a166bb3804e1/cpu.cfs_period_us

4.Cgroups的劣势

Cgroups的资源限制能力也有一些不完善的地方,尤其是/proc文件系统的问题。

Linux操作系统中,/proc目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统信息以及当前正在运行的进程信息。如CPU使用情况、内存占用情况等,这些文件也是top命令查看系统信息的主要数据来源。

但用户在容器中执行top命令时就会发现,显示的信息居然是宿主机的CPU和内存数据,而不是当前容器的数据。

造成这个结果的原因是,/proc文件系统并不知道用户通过Cgroups对这个容器进行了资源限制,即/proc文件系统不了解Cgroups限制的存在。

这是企业中容器化应用常见的问题,也是容器相较于虚拟机的劣势。

四.Docker文件系统

1.容器可读可写层的工作原理

Docker镜像采用层级结构,是根据Dockerfile文件中的命令一层一层的通过docker commit堆叠而成的一个只读文件。容器的最上层是有一个可读写层。这个可读写层在容器启动时,为当前容器单独挂载。任何容器在运行时,都会基于当前镜像在其上层挂载一个可读写层,用户针对容器的所有操作都在可读可写层中完成。一旦容器被删除,这个可读可写层也将会随之删除。 而用户针对这个可读可写层的操作,主要基于两种方式:写时复制与用时分配。下面对这两种方式进行详解。

(1)写时复制

写时复制(CoW,Copy-on-Write)是所有驱动都要用到的技术。CoW表示只在需要写时才去复制,针对已有文件的修改场景。例如,基于一个镜像启动多个容器,如果为每个容器都分配一个与镜像一样的文件系统,那么就会占用大量的磁盘空间,如图所示。

而CoW技术可以让所有容器共享镜像的文件系统,所有数据都从镜像中读取,如图所示。

只在要对文件进行写操作时,才从镜像里把要写的文件复制到自己的文件系统进行修改,这样就可以有效地提高磁盘的利用率。

(2)用时分配

用时分配是先前没有分配空间,只有要新写入一个文件时才分配空间,这样可以提高存储资源的利用率。例如,启动一个容器时,并不为这个容器预分配磁盘空间,当有新文件写入时,才按需分配空间。

2.Docker存储驱动

Docker提供了多种存储驱动(Storage Driver)来存储镜像,常用的几种Storage Driver是AUFS、OverlayFS、Device mapper、Btrfs、ZFS。不同的存储驱动需要不同的宿主机文件系统,如表所示:

下面通过docker info命令查看本机Docker使用的Storage Driver。

[root@Docker ~]# docker info

从以上示例中可以看到,此处使用的Storage Driver是Overlay2,Backing Filesystem代表的是本机的文件系统。用户可以通过--storage-driver=<name>参数来指定要使用的存储驱动,或者在配置文件/etc/default/Docker中通过DOCKER_OPTS指定。

下面介绍几种常见的存储驱动。

(1)AUFS

AUFS(Another Union File System)是一种联合文件系统,是文件级的存储驱动。AUFS是一个能透明覆盖一个或多个现有文件系统的层状文件系统,把多层合并成文件系统的单层表示。简单来说,AUFS支持将不同目录挂载到同一个虚拟文件系统下,它可以一层一层地叠加修改文件。下面无论有多少层都是只读的,只有最上层的文件系统是可读可写的。当需要修改一个文件时,AUFS会为该文件创建一个副本,使用CoW将文件从只读层复制到可度可写层进行修改,修改结果也保存在可读可写层。

在Docker中,下面的只读层就是镜像,可读可写层就是容器。AUFS存储驱动结构如图所示。

(2)OverlayFS

OverlayFS是Linux内核3.18版本开始支持的,它也是一种联合文件系统,与AUFS不同的是Overlay只有两层:upper层与lower层,分别代表Docker的镜像层与容器层,如图所示。

当用户需要修改一个文件时,OverlayFS使用CoW将文件从只读的lower层复制到可读可写的upper层进行修改,结果也保存在upper层。

(3)Device mapper

Device mapper是Linux内核2.6.9版本开始支持的,它提供一种从逻辑设备到物理设备的映射框架机制,在该机制下,用户可以很方便地根据自己的需要制定实现存储资源的管理策略。AUFS与OverlayFS都是文件级存储,而Device Mapper是块级存储,所有的操作都是直接对块进行的。

Device Mapper会先在块设备上创建一个资源池,然后在资源池上创建一个带有文件系统的基本设备,所有镜像都是这个基本设备的快照,而容器则是镜像的快照。所以在容器里看到文件系统是资源池上基本设备的文件系统的快照,容器并没有被分配空间,如图所示。

当用户要写入一个新文件时,Device Mapper在容器的镜像内为其分配新的块并写入数据,也就是用时分配。当用户要修改已有文件时,Device Mapper使用CoW为容器快照分配块空间,将要修改的数据复制到在容器快照中新的块里,再进行修改。Device mapper默认会创建一个100GB的文件来包含镜像和容器。每一个容器被限制在10GB大小的卷内,可以自己配置调整。Device Mapper存储驱动读写机制结构如图所示。

Docker容器的存储驱动各有其特点,下面对三种存储驱动进行对比,如表所示。

AUFS与OverlayFS

AUFS和OverlayFS都是联合文件系统,但AUFS有多层,而OverlayFS只有两层,所以在做写时复制操作时,如果文件比较大且存在于比较低的层,则AUFS可能会更慢一点。另外,OverlayFS并入了Linux系统核心主线,而AUFS没有。

OverlayFS与Device mapper

OverlayFS是文件级存储,Device Mapper是块级存储。文件级存储不管修改的内容大小都会复制整个文件,对大文件进行修改显示要比小文件消耗更多的时间,而块级存储无论是大文件还是小文件都只复制需要修改的块,并不是复制整个文件,如此一来Device Mapper速度就要快一些。块级存储直接访问逻辑磁盘,适合IO密集的场景。而对于程序内部复杂,多并发但少IO的场景,OverlayFS的性能相对要强一些。

  • 34
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值