2.3 容器核心技术
容器作为用户应用最终运行的承载体,其依赖的核心技术值得深入研究。事实上,容器所依托的技术并非新技术,而是早已存在多年的两种相对较成熟的技术:Namespace 和CGroups。科技界的技术创新经常出现这种情形:几种成熟技术的组合往往会成就一个巨大创新。容器技术就是通过组合Namespace 和CGroups 这两个耳熟能详的技术,对云计算进行根本性的变革。
在这场大变革中,Namespace 提供了不同类型资源的隔离,CGroups 则针对相应的资源进行了限制分配和统计。
2.3.1 Namespace
Linux 内核从2.4.19 版本开始引入Namespace 技术,译为 “命名空间”,它提供了一种内核级别的系统资源的隔离方式。系统可以为进程分配不同的Namespace,并保证不同的Namespace 资源独立分配、进程彼此隔离,即不同的Namespace 下的进程互不干扰。
如图2-1 所示,Linux 内核支持6 种Namespace。主机有其默认的Namespace,行为与用户的Namespace 完全一致,不同进程可以通过不同的Namespace 进行隔离。当系统启动一个容器时,会为该容器创建相应的不同类型的Namespace,为运行在容器内的进程提供相应的资源隔离。
图2-1 Namespace
Linux 支持的Namespace 类型、隔离资源及Kernel 版本如表2-1 所示。
表2-1 Linux Namespace
在主机的/proc/[pid]/ns 目录中可以查看系统进程归属的Namespace,每个Namespace都附带一个编号,它是该Namespace 在系统中的唯一标识,示例代码如下:
Linux 对Namespace 的主要操作是通过以下三个系统调用实现的:
(1)clone
在创建新进程的系统调用时,可以通过flags 参数指定需要新建的Namespace 类型。与Namespace 相关的flags 及系统调用代码如下:
(2)setns
该系统调用可以让调用进程加入某个已经存在的Namespace 中,相关代码如下:
(3)unshare
该系统调用可以将调用进程移动到新的Namespace 下。同clone 的flags 参数一样,如果带有与Namespace 相同的CLONE_NEW*标志,则相应的Namespace 就会被创建,而调用进程就属于新建的Namespace,示例代码如下:
2.3.1.1 IPC
进程间通信(Interprocess Communication)是Linux 系统中常见的、进程与进程直接通信的手段。
IPC Namespace 用于隔离IPC 资源,包含System V IPC 对象和POSIX 消息队列。其中System V IPC 对象包含信号量、共享内存和消息队列,用于进程间的通信。System V IPC对象具有全局唯一的标识,对在该IPC Namespace 内的进程可见,而对其外的进程不可见。当IPC Namespace 被销毁后,所有的IPC 对象也会被自动销毁。
Kubernetes 允许用户在Pod 中使用hostIPC 进行定义,通过该属性使授权用户容器共享主机IPC Namespace,达到进程间通信的目的。
2.3.1.2 Network
Network Namespace 提供了关于系统上网络资源的隔离,例如网络设备、IPv4 和IPv6协议栈、IP 路由表、防火墙规则、/proc/net 目录(/proc/pid/net 目录的符号链接)、/sys/class/net目录、/proc/sys/net 目录下的很多文件、端口号(socket)等。一个物理的网络设备通常会被放到主机的Network Namespace(就是系统初始的Network Namespace)中。
不同网络的Namespace 由网络虚拟设备(Virtual Ethernet Device,即VETH)连通,再基于网桥或者路由实现与物理网络设备的连通。当网络Namespace 被释放后,对应的VETH Pair 设备也会被自动释放。
在Kubernetes 中,同一Pod 的不同容器共享同一网络的Namespace,没有例外。这使得Kubernetes 能将网络挂载在更轻量、更稳定的sandbox 容器上,而用户定义的容器只需复用已配置好的网络即可。另外,同一Pod 的不同容器中运行的进程可以基于localhost彼此通信,这在多容器进程、彼此需要通信的场景下是非常有效的。
2.3.1.3 PID
PID Namespace 用于进程号隔离,不同PID Namespace 中的进程PID 可以相同。容器启动后,Entrypoint 进程会作为PID 为1 的进程存在,因此是该PID Namespace 的init 进程。它是当前Namespace 所有进程的父进程,如果该进程退出,内核会对该PID Namespace的所有进程发送SIGKILL 信号,以便同时结束它们。init 进程默认屏蔽系统信号,即除非该进程对系统信号做特殊处理,否则发往该进程的系统信号默认都会被忽略。不过SIGKILL 和SIGSTOP 信号比较特殊,init 进程无法捕获这两个信号。
Kubernetes 默认对同一Pod 的不同容器构建独立的PID Namespace,以便将不同容器的进程彼此隔离,同时允许通过ShareProcessNamespace 属性设置不同容器的进程共享PID Namespace。
Kubernetes 支持多重容器进程的重启策略,默认行为是用户进程退出后立即重启。Kubernetes 用户只需中止其容器中的Entrypoint 进程,即可实现容器重启。
2.3.1.4 Mount
Mount Namespace 提供了进程能看到的挂载点的隔离。 在主机上, 通过/proc/[pid]/mounts、/proc/[pid]/mountinfo、/proc/[pid]/mountstats 等文件来查看挂载点。在容器内,可以通过mount 或lsmnt 命令查看Mount Namespace 中的有效挂载点。
不同于其他类型的Namespace 的严格隔离,Linux 内核针对Mount Namespace 隔离性开发了共享子树(Shared Subtree)功能,用于在不同Mount Namespace 之间自动可控地传播mount 和umount 事件。共享子树引入了对等组的概念,对等组是一组挂载点,其成员之间互相传播mount 和umount 事件。此特性使得当主机磁盘发生变更(比如系统导入新磁盘)时,只需在一个Mount Namespace 中进行挂载,该磁盘即可在所有Namespace 中可见。
挂载点可以设置的传播类型如下:
● MS_SHARED:此挂载点与同一对等组里其他挂载点共享mount 和umount 事件。在此挂载点下添加或删除挂载点时,事件会传播到对等组内的其他Namespace 中,事件在Namespace 中也会自动进行相同的mount 或umount 操作。同样地,对等组中其他挂载点上的挂载和卸载事件也会传播到此挂载点。
● MS_PRIVATE:此挂载点是私有点,没有对等组,mount 和umount 事件不会与其他的Mount Namespace 共享。
● MS_SLAVE:该挂载点可以从主对等组接收mount 和umount 事件,但是在本挂载点下的mount 和umount 事件不会传播到任何其他的Mount Namespace 下。
● MS_UNBINDABLE:与MS_PRIVATE 不同的是,该挂载点不可以执行bind mount操作。
在目录/proc/[pid]/mountinfo 下,可以看到该PID 所属的mount Namespace 下的挂载点的传播类型及所属的对等组。
在Kubernetes 中,挂载点通常是private 类型。如果需要设置挂载点的类型,那么可以在Pod 的spec 中填写相应的挂载配置。
2.3.1.5 UTS
UTS(“UNIX Time-Sharing System”)Namespace 允许不同容器拥有独立的hostname和domain name。UTS Namespace 中的一个进程可以看作一个在网络上独立存在的节点。也就是说,除IP 外,还能通过主机名进行访问。
2.3.1.6 USR
User Namespace 主要隔离了安全相关的标识符和属性,比如用户ID、用户组ID、root目录、密钥等。一个进程的用户ID 和组ID 在User Namespace 内外可以有所不同。在该User Namespace 外,它是一个非特权的用户ID;而在User Namespace 内,进程可以使用0(root)作为用户ID,且其具有完全的特权权限。
User Namespace 允许不同容器有独立的user 和group ID,它主要提供两种职能:权限隔离和用户身份标识隔离。我们可以通过在容器镜像中创建和切换用户,来为文件目录设置不同的用户权限,从而实现容器内的权限管理,而无须影响主机配置。
2.3.1.7 CGroup
内核从 4.6 版本开始支持 CGroup Namespace。如果容器启动时没有开启 CGroup Namespace,那么在容器内部查询 CGroup 时,返回整个系统的信息;而开启 CGroup Namespace 后,可以看到当前容器以根形式展示的单独的CGroup 信息:
CGroup 视图的改变使容器更加安全,而且在容器内也可以有自己的CGroup 结构。Docker 目前不支持CGroup Namespace。
2.3.2 CGroups
CGroups(Control Groups)是Linux 下用于对一个或一组进程进行资源控制和监控的机制。利用CGroups 可以对诸如CPU 使用时间、内存、磁盘I/O 等进程所需的资源进行限制。Kubernetes 允许用户为Pod 的容器申请资源,当容器在计算节点上运行起来时,可以通过CGroups 来完成资源的分配和限制。
在CGroups 中,对资源的控制都是以CGroup 为单位的。目前CGroups 可以控制多种资源,不同资源的具体管理工作由相应的CGroup 子系统(Subsystem)来实现。因此,针对不同类型的资源限制,只要将限制策略在不同的的子系统上进行关联即可。CGroups 由谷歌的工程师引入2.6.24 版本的内核中,最初只对CPU 进行了资源限制,然而随着对其他资源控制需求的增多,它们的CGroup 子系统不断地被引入内核。在容器时代,特别是Kubernetes 中,为了提高对节点资源的利用率,一个节点上会运行尽可能多的容器,这就对资源隔离的多样性和精确性提出了越来越高的要求,因此CGroups 也发挥着越来越重要的作用。
对于CGroups 的组织管理,用户可以通过文件操作来实现,对资源的控制可以细化到线程级别。CGroups 在不同的系统资源管理子系统中以层级树(Hierarchy)的方式来组织管理:每个CGroup 都可以包含其他的子CGroup,因此子CGroup 能使用的资源,除了受本CGroup 配置的资源参数限制,还受到父CGroup 设置的资源限制。
由于CGroups 包含众多的子系统,而不同的子系统在开发和管理时并没有进行有效的协调和统一,所以造成不同的子系统之间不协调,层级树管理也变得愈加复杂。于是从3.10版本的内核开始,实现了CGroups 的v2 版本。尽管CGroups v2 是用来替代CGroups v1的,但由于目前实现的控制子系统有限,且目前的容器运行时都基于CGroup v1 来实现,所以CGroup v2 只是在开发完善中,并没有被大规模使用。
Kubernetes 1.18 之前的版本主要使用了CPU、cpuset、memory 子系统,而blkio、PID子系统可以根据需求选择性地开启使用,如图2-2 所示。
2.3.2.1 CPU
CPU 子系统用于限制进程的CPU 使用时间。在CPU 子系统中,对于每个CGroup 下的非实时任务,CPU 使用时间可以通过cpu.shares、cpu.cfs_period_us 和cpu.cfs_quota_us参数来进行控制,而系统的CFS(Completely Fair Scheduler)调度器则根据CGroup 下进程的优先级、权重和cpu.shares 等配置来给该进程分配相应的CPU 时间。
图2-2 CGroups
CPU CGroup 的主要配置参数如下:
1.cpu.shares
cpu.shares 是在该CGroup 能获得CPU 使用时间的相对值,最小值为2。如果两个CGroup 的cpu.shares 都为100,那么他们可以得到相同的CPU 时间。如果另外一个CGroup的cpu.shares 是200,那么他可以得到两倍于cpu.shares=100 的CGroup 获取的CPU 时间。但是如果一个CGroup 中的任务处在空闲状态,不使用任何的CPU 时间,则该CPU 时间就可以被其他的CGroup 所借用。简言之,cpu.shares 主要用于表示当系统CPU 繁忙时,给该CGroup 分配的CPU 时间份额。
2.cpu.cfs_period_us 和cpu.cfs_quota_us
cfs_period_us 用于配置时间周期长度,单位为us(微秒)。cfs_quota_us 用来配置当前CGroup 在cfs_period_us 时间内最多能使用的CPU 时间数,单位为us(微秒)。这两个参数被用来设置该CGroup 能使用的CPU 的时间上限。如果不想对进程使用的CPU 设置限制,可以将cfs.cfs_quota_us 设置为-1。
3.cpu.stat
CGroup 内的进程使用的CPU 时间统计。
4.nr_periods
经过cpu.cfs_period_us 的时间周期数量。
5.nr_throttled
在经过的周期内,有多少次因为进程在指定的时间周期内用光了配额时间而受到限制。
6.throttled_time
CGroup 中的进程被限制使用CPU 的总用时,单位是ns(纳秒)。
2.3.2.2 cpuacct
cpuacct 用于统计CGroup 及其子CGroup 下进程的CPU 的使用情况。
1.cpuacct.usage
包含该CGroup 及其子CGroup 下进程使用CPU 的时间,单位是ns(纳秒)。
2.cpuacct.stat
包含该CGroup 及其子CGroup 下进程使用的CPU 时间,以及用户态和内核态的时间。
2.3.2.3 cpuset
cpuset 为CGroups 的进程分配单独的CPU 和内存节点,将进程固定在某个CPU 或内存节点上,以达到提高性能的目的。该子系统主要包含如下配置参数:
1.cpuset.cpus
设置CGroup 下进程可以使用的CPU 核,在将进程加入CGroup 之前,该参数必须进行设置。
2.cpuset.mems
指定CGroup 下进程可以使用的内存节点,在将进程加入CGroup 之前,该参数必须进行设置。
3.cpuset.memory_migrate
如果cpuset.mems 发生改变,那么该参数用于指示已经申请成功的内存页是否需要迁移到新配置的内存节点上。
4.cpuset.cpu_exclusive
CPU 互斥,默认不开启。该参数用于指示该CGroups 设置的CPU 核是否可以被除父CGroup 和子CGroup 外的其他CGroup 共享。
5.cpuset.mem_exclusive
内存互斥,默认不开启。该参数用于指示该CGroups 设置的内存节点是否可以被除父CGroup 和子CGroup 外的其他CGroup 共享。
2.3.2.4 memory
memory 用于限制CGroup 下进程的内存使用量,亦可获取到内存的详细使用信息。
1.memory.stat
该文件中包含该CGroup 下进程的详细的内存使用信息,如表2-2 所示。
表2-2 CGroup 下进程的内存使用信息
(续表)
该统计包含当前CGroup 下的进程内存的使用情况。在memory.stat 中以total_开头的统计项包含了该CGroup 下所有子CGroup 的内存使用情况。
每个数据统计项之间存在如下的关联关系:
● active_annon + inactive_anon = 匿名内存 + 用户tmpfs 的文件缓存 + 可交换的缓存
● active_annon + inactive_anno = rss + tmpfs
● active_file + inactive_file = cache - tmpfs
2.memory.usage_in_bytes
CGroup 下进程使用的内存,包含CGroup 及其子CGroup 下的进程使用的内存。
3.memory.max_usage_in_bytes
CGroup 下进程使用内存的最大值,包含子CGroup 的内存使用量。
4.memory.limit_in_bytes
设置CGroup 下进程最多能使用的内存。如果设置为-1,则表示对该CGroup 的内存使用不做限制。
5.memory.failcnt
CGroup 下的进程达到内存最大使用限制的次数。
6.memory.force_empty
当没有进程属于该CGroup 后,将该值设置为0,系统会尽可能地将该CGroup 使用的内存释放掉。对于不能释放的内存,则会将其移动到父CGroup 上。在CGroup 销毁之前,将能释放的内存释放掉,可以尽量避免将已经不使用的内存移动到父CGroup 上,从而避免对运行在父CGroup 中的进程造成内存压力。
7.memory.oom_control
设置是否在CGroup 中使用OOM(Out of Memory)Killer,默认为使用。当属于该CGroup 的进程使用的内存超过最大的限定值时,会立刻被OOM Killer 处理。
2.3.2.5 blkio
blkio 子系统用来实现对块设备访问的I/O 控制,按权重分配目前有两种限制方式:一是限制每秒写入的字节数(Bytes Per Second,即BPS),二是限制每秒的读写次数(I/O Per Second,即IOPS)。blkio 子系统按权重分配模式工作于I/O 调度层,依赖于磁盘的CFQ(Completely Fair Queuing,完全公平算法)调度,如果磁盘调度使用deadline 或者none的算法则无法支持。BPS、IOPS 工作于通用设备层,不依赖于磁盘的调度算法,因此有更多的适用场景。blkio 子系统实现如图2-3 所示。
图2-3 blkio 子系统实现
下面介绍一下blkio 子系统的权重方式的配置和I/O 限流方式的配置。
1.权重方式的配置
● blkio.weight
通过权重来配置CGroup 可以访问设备I/O 的默认比例,范围是100~1000。
● blkio.weight_device
通过权重来配置CGroup 可以访问指定设备I/O 的默认比例,范围是100~1000。该值可以改写blkio.weight 的默认值。
2.I/O 限流方式的配置
I/O 限流方式的配置参数如表2-3 所示。
表2-3 I/O 限流方式的配置参数
I/O 操作根据是否经过Cache 可以分为buffer I/O 和direct I/O 两大类,大部分读写都是通过buffer I/O 的方式进行的。用户程序将文件写入Cache,再由内核将Cache 的内容回写到物理磁盘上,但是对回写Page Cache 的inode 来说,它并没有属于哪个CGroup 的信息,因此就不能基于CGroup 的配置来对回写的Page Cache 进行限制。
目前Docker 上使用的CGroup v1 的blkio 子系统并没有对buffer I/O 实行限速,但是对direct I/O 可以做限速。如果想要支持对buffer I/O 的限制,就需要等待Docker 对CGroup V2 的支持,当然CGroup v2 并不支持所有的文件系统,它需要一定的时间来做进一步的发展和完善。
2.3.2.6 PID
PID 子系统用来限制CGroup 能够创建的进程数。
● pids.max:允许创建的最大进程数量。
● pids.current:当前的进程数量。
2.3.2.7 其他
CGroup 还支持如下子系统:
● devices 子系统,控制进程访问某些设备。
● perf_event 子系统,控制perf 监控CGroup 下的进程。
● net_cls 子系统,标记CGroups 中进程的网络数据包,通过TC 模块(Traffic Control)对数据包进行控制。
● net_prio 子系统,针对每个网络设备设置特定的优先级。
● hugetlb 子系统,对hugepage 的使用进行限制。
● freezer 子系统,挂起或者恢复CGroups 中的进程。
● ns 子系统,使不同CGroups 下面的进程使用不同的Namespace。
● rdma 子系统,对RDMA/IB-spe-cific 资源进行限制。
这些子系统与容器技术并非紧密相关,在此不做详述。
2.3.3 容器运行时
提到容器,大家都会想到Docker,Docker 对容器技术的推广起到至关重要的作用。自2013 年发布以来,Docker 在GitHub 上的代码活跃度居高不下,它使得更多人和企业关注并使用容器技术,甚至很多人认为容器就是Docker。
当然,容器技术不只有Docker,还有其他,比如CoreOS 推出的Rocket。Kubernetes社区当然不可能为每个容器技术单独开发一套代码,于是OCI(Open Container Initiative)就在这样一种场景中诞生了。它的核心目标是围绕容器镜像和运行时制定一个开放的工业标准,以此来保持容器技术的开放性和灵活性,使容器能够在任意硬件和系统上运行。
OCI 标准的设计要考虑以下几方面:
● 操作标准化:容器的标准化操作包括使用标准流程创建、启动和停止容器,使用标准文件系统工具复制和创建容器快照,使用标准化网络工具进行下载和上传。
● 内容无关:不关心容器内的具体应用内容是什么,无论是Java 应用还是MySQL数据库服务,都能够通过容器标准操作来运行。
● 基础设施无关:无论是个人的笔记本电脑还是公有云平台,或者其他基础设施,容器都能运行且运行的结果是一致的。
● 工业级交付:制定容器标准的一大目标就是容器操作自动化,使软件分发可以达到工业级交付。
OCI 标准包含两个协议:镜像标准(Image Spec)和运行时标准(Runtime Spec)。如图2-4 所示,这两个标准通过 OCI 运行时文件系统包(OCI runtime filesytem bundle)的标准格式连接在一起,OCI 镜像可以通过工具转换成文件系统包,OCI Runtime 能够识别该文件系统包并运行容器。
镜像标准,顾名思义是镜像的规范,它规范了以层(Layer)保存的文件系统,每个层保存了和上层之间的变化,如何用manifest、config 和index 文件找出镜像的具体信息,比如文件系统的层级信息(每个层级的哈希值及历史信息),以及容器运行时需要的一些信息(比如环境变量、工作目录、命令参数、挂载列表等)。
运行时标准,顾名思义是运行容器的规范,定义了容器的创建、删除、查看等操作,规范了容器的状态描述(比如容器ID、进程号、运行状态等)等。runC 就是OCI 运行时标准的一个参考实现。runC 直接与容器所依赖的CGroup、Linux Kernel 等进行交互,负责为容器配置CGroup、Namespace 等启动容器所需的环境,创建启动容器的相关进程。
图2-4 OCI 标准之间的联系
为了兼容OCI 标准,Docker 也做了架构调整。将容器运行时相关的核心代码从Docker引擎剥离出来,形成Containerd,并将它贡献给CNCF。由Containerd 向Docker 提供管理容器的API,而Docker 引擎则专门负责上层的封装编排,提供对Images、Volumes、Network及Builds 操作的API,如图2-5 所示。
图2-5 Docker 框架
在Kubernetes 早期的1.5 版本以前,kubelet 内置了Docker 和Rocket 两种运行时。随着Kubernetes 的推广,越来越多的用户希望它能支持更多的容器运行时,因为不同的容器运行时各有所长。如果用户想要自定义运行时,就需要修改kubelet 源代码。
另外,kubelet 与Docker 及Rocket 紧密耦合,它们的接口变化会影响Kubernetes 的稳定性。于是从1.5 版本开始,Kubernetes 推出了CRI(Container Runtime Interface)接口,有了CRI 接口无须修改kubelet 源代码就可以支持更多的容器运行时,并逐步将内置的Docker 和rtk 从Kubernetes 源代码中移除,同时将CNI 的实现迁到CRI Runtime 内,如图2-6 所示。也就是说,外部的容器运行时除了实现CRI 接口真正负责管理镜像和容器的生命周期,还需要实现CNI,负责容器配置网络。
图2-6 CRI、CNI、OCI 的关系
CRI 其实就是一组gRPC 接口,包括两类:RuntimeService 和ImageService,如图2-7所示,RuntimeService 包括一组对容器沙箱和容器查询进行操作和管理的接口,一组与容器交互的接口,以及运行时版本和状态查询的接口。ImageService 则提供了对容器镜像的查询和操作的接口。
图2-7 CRI 接口
因此,实现一个适用于Kubernetes 的新的容器运行时,向上需要实现上述CRI 接口,向下须遵循OCI 的标准来操作容器。如图2-8 所示,容器运行时包含如下基本单元:
● CRI gRPC Server:用于接收来自kubelet 的CRI 请求。
● Streaming Server(流服务):允许exec 或者attach 到用户容器。
● CNI:允许调用网络插件完成容器的网络设置。
● Container Service:用于管理所有的容器。
● Image Service:用于管理所有的容器镜像。
● Process Management:用于管理容器的shim 进程,shim 进程用于管理容器内的所有进程。
图2-8 容器运行时的基本单元
当Docker 将Containerd 开源后,Kubernetes 也顺势孵化了Cri-containerd 项目,使Containerd 接入CRI 的标准中,也就是说实现了一个CRI gRPC Server。Kubernetes 还孵化了为其量身定制的Crio,实现了一个最小的CRI 接口。在 2017 年 Kubecon Austin 的一个演讲中,Walsh 解释说,“CRI-O 被设计成比其他的方案都要小,遵从‘UNIX 只做一件事并把它做好’的设计哲学,实现组件重用”。
更多符合CRI 和OCI 标准的容器的出现,让Kubernetes 在使用的兼容性和广泛性上得到进一步的加强。选用何种容器运行时,需要根据用户的应用场景来选择。没有最好的,只有适合的。现在将当前这几种容器运行时套用到前面解释过的CRI 和OCI 的关系中,它们在Kubernetes 中的位置和调用关系是怎样的呢?
如图2-9 所示,相比Docker,独立出来的Containerd 和Crio 的调用层级要简洁很多,能够直接被kubelet 调用。其中Crio 的conmon 就对应containerd-shim,其作用是一样的。kubelet 中如何才能使用Containerd 和Crio 呢?kubelet 有一个参数--container-runtime,默认是“ Docker ”。 通常设置成“ remote ”, 即从外部调用容器运行时, 通过参数--container-runtime-endpoint 来找到容器运行时的服务地址,一般是Linux 本地的Socket地址。contianerd 的默认地址为unix:///run/containerd/containerd.sock,Crio 的默认地址为unix:///run/crio/crio.sock。
图2-9 Docker、Containerd 和Crio 的调用关系
前面(本节)我们提到OCI 运行时标准的参考实现runC,其实除了runC,还有Kata Container 和gVisor 等都符合OCI 规范的运行时,但是它们和runC 的功能有所不同。
Kata Container 是由OpenStack 基金会(OSF)主导的,基于Intel 透明容器(Clear Container)和Hyper 的runV 技术,并进行了扩展。Kata Container 致力于通过轻量级虚拟机来构建安全的容器运行时,这些虚拟机的感觉和性能类似于容器,但是使用硬件虚拟化技术作为安全防御层,可以提供更强的工作负载隔离。它支持多个虚拟机管理程序,包括qemu、nemu 和firecracker,并可与其他容器化项目(例如Docker、Containerd、Crio 及Kubernetes)兼容和集成。
容器之所以流行的主要原因是:轻量、性能好,并且易于集成。但是传统的容器体系结构中所有容器及主机操作系统之间共享内核,如果一个容器藏着什么坏心思,主机和其他容器就变得岌岌可危。这个问题是Kata Container 诞生的主要推力之一。
Kata 的出现不是取代现有的容器解决方案,而是要解决容器的安全性问题。在Kata Container 中,每个容器都有自己的轻量级虚拟机和微型内核,将相互不信任的租户放在同一集群上,能够加强租户之间的隔离。Kata 与Containerd、Kubernetes 的集成示意图如图2-10 所示。
在Kubernetes 集群中,我们创建一个Kata Container 就像创建一个普通的容器,我们只需要在Pod 的spec 中指定runtimeClassName 为Kata,示例代码如下:
图2-10 Kata 与Containerd、Kubernetes 的集成示意图
gVisor 和Kata 一样,是谷歌开发的用以解决容器的安全性问题的方案,但是两者的实现方式不同。它没有给容器提供一个虚拟机环境,而是提供了一个用户空间内核。它拦截所有的应用程序系统调用并充当来宾内核,而无须通过虚拟化硬件进行转换。gVisor 不是简单地一股脑地将应用程序系统调用重定向到主机内核的,它自己实现了大多数内核原语(例如信号、文件系统、管道等),并在这些原语之上构建了完整的系统调用处理程序。图 2-11 是 gVisor 与 Containerd 、 Kubernetes 的集成示意图。 当创建 Pod 时指定runtimeClassName 为runsc,即可创建gVisor 的容器。
图2-11 gVisor 与Containerd、Kubernetes 的集成示意图
2.3.4 容器存储驱动
对于运行时而言,选择何种存储驱动至关重要。
容器镜像以分层的形式组织管理,如图2-12 所示,不同的分层被不同的镜像共享。容器的镜像层都是只读的,在基础镜像上不断叠加。在运行容器的时候,镜像最上层会挂载容器的可写层。容器的可写层一般不用于存储用户的容器数据,以防容器重启后该数据丢失。但由于容器依然需要往容器可写层上读写数据,因此需要由存储驱动来管理容器的文件系统。
图2-12 容器镜像分层
由于镜像具有共享特性,所以对容器可写层的操作需要依赖存储驱动提供的写时复制和用时分配机制,以此来支持对容器可写层的修改,进而提高对存储和内存资源的利用率。
1.写时复制
写时复制,即Copy-on-Write。一个镜像可以被多个容器使用,但是不需要在内存和磁盘上做多个拷贝。在需要对镜像提供的文件进行修改时,该文件会从镜像的文件系统中被复制到容器的可写层的文件系统中进行修改,而镜像中的文件不会改变。不同容器对文件的修改都相互独立、互不影响。
2.用时分配
按需分配空间,而非提前分配,即当一个文件被创建出来后,才会分配空间。
对文件的添加、修改、删除和读写都只发生在容器的可写层,因此不同容器对文件的修改都相互独立、互不影响。
Docker 和Containerd 都支持多种存储驱动,每种驱动都各有所长,没有一种驱动能够满足所有的应用场景。因此,需要根据业务特点进行选择。目前容器存储驱动主要有五种,如表2-4 所示。
表2-4 容器存储驱动
接下来,我们详细介绍一下表2-4 中的五种容器存储驱动。
1.AUFS
AUFS 是一种联合文件系统(Union Filesystem),是文件级的存储驱动,支持将节点上的多个目录挂载到同一挂载点。这里的目录等同于AUFS 中的Branch 或者Docker 中的层的概念,详情如图2-13 所示。
图2-13 基于AUFS 存储驱动的容器层级
2.OverlayFS
OverlayFS 也是一种与AUFS 类似的联合文件系统,同样属于文件级的存储驱动,包含了最初的Overlay 和更新更稳定的overlay2。Overlay 只有两层:upper 层和lower 层。lower层代表镜像层,upper 层代表容器可写层,将upperdir 和lowerdir 统一的挂载点称为merged。基于OverlayFS 的容器层级如图2-14 所示。
图2-14 基于OverlayFS 的容器层级
3.Device Mapper
Device Mapper 属于块设备级别的存储驱动,所有操作都直接对块设备进行操作。Device Mapper 驱动会先在节点块设备上创建一个资源池(Thin Pool),再于其上创建一个带有文件系统的基本设备(Base Device)。每一层镜像都是上一层镜像的快照,最底层镜像是基本设备的快照,而容器则是镜像的快照。这些快照都属于写时复制的快照,内容发生改变时才会有数据存在。基于Device Mapper 存储驱动的容器层级如图2-15 所示。
图2-15 基于Device Mapper 存储驱动的容器层级
4.BtrFS
BtrFS 被称为下一代写时复制文件系统,同OverlayFS 和AUFS 一样,也是文件级存储。特别地,它可以像Device Mapper 一样直接操作底层设备。BtrFS 把文件系统的一部分配置为一个完整的子文件系统,我们称之为 subVolume。对于容器的基础镜像,会以subVolume 的方式存储,而其他的镜像层,会以基于上一层镜像层的快照方式进行存储,同时包含本层的修改。容器的可写层是最后一级镜像层的快照,包含容器的修改。这些修改和容器层的修改一样,都存储于块设备上。基于BtrFS 的容器层级如图2-16 所示。
图2-16 基于BtrFS 的容器层级
5.ZFS
ZFS 将节点设备加入被我们称为 “zpools” 的存储池进行管理。存储驱动从zpool 里分配一个ZFS 文件系统给容器的基础镜像,并且打上该层的快照。其他的镜像层都是上一级镜像层快照的克隆,这个克隆是可写的,并可以按需从zpool 中申请空间。而快照是只读的,用于保证镜像层的只读特性。当容器启动时,在镜像的最顶层创建一个克隆作为可写层,如图2-17 所示。
图2-17 基于ZFS 的驱动的容器层级
下面我们从稳定性、性能、可维护性等多个维度来评估存储驱动方案的优缺点,如表2-5 所示。
表2-5 存储驱动方案的优缺点
针对Kubernetes 的Pod 而言,容器读写数据应该发生在外挂的卷上,容器可写层不应该作为容器读写数据的主要渠道。在存储驱动的选择上,稳定性和可维护性应该是相对性能而言的、优先级更高的考量因素,所以Overlay2 是必然的选择。而对于ZFS 或BtrFS,除非有特殊需求,并不建议将它们作为运行时的默认存储驱动。事实上,现在Docker 和Containerd 都将Overlay2 作为默认或者建议的存储驱动。