This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
<audio id="audio" title="45 | 幕后英雄SIG-Node与CRI" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dd/3a/dd28bbef572731c1cc9edaaae166d13a.mp3"></audio>
你好我是张磊。今天我和你分享的主题是幕后英雄之SIG-Node与CRI。
在前面的文章中,我为你详细讲解了关于 Kubernetes 调度和资源管理相关的内容。实际上在调度这一步完成后Kubernetes 就需要负责将这个调度成功的 Pod在宿主机上创建出来并把它所定义的各个容器启动起来。这些都是 kubelet 这个核心组件的主要功能。
在接下来三篇文章中,我就深入到 kubelet 里面,为你详细剖析一下 Kubernetes 对容器运行时的管理能力。
在 Kubernetes 社区里,与 kubelet 以及容器运行时管理相关的内容,都属于 SIG-Node 的范畴。如果你经常参与社区的话,你可能会觉得,相比于其他每天都热闹非凡的 SIG小组SIG-Node 是 Kubernetes 里相对沉寂也不太发声的一个小组,小组里的成员也很少在外面公开宣讲。
不过正如我前面所介绍的SIG-Node以及 kubelet其实是 Kubernetes整套体系里非常核心的一个部分。 毕竟,它们才是 Kubernetes 这样一个容器编排与管理系统,跟容器打交道的主要“场所”。
而 kubelet 这个组件本身,也是 Kubernetes 里面第二个不可被替代的组件(第一个不可被替代的组件当然是 kube-apiserver。也就是说**无论如何,我都不太建议你对 kubelet 的代码进行大量的改动。保持 kubelet 跟上游基本一致的重要性,就跟保持 kube-apiserver 跟上游一致是一个道理。**
当然, kubelet 本身,也是按照“控制器”模式来工作的。它实际的工作原理,可以用如下所示的一幅示意图来表示清楚。
<img src="https://static001.geekbang.org/resource/image/91/03/914e097aed10b9ff39b509759f8b1d03.png" alt=""><br>
可以看到kubelet 的工作核心就是一个控制循环SyncLoop图中的大圆圈。而驱动这个控制循环运行的事件包括四种
<li>
Pod 更新事件;
</li>
<li>
Pod 生命周期变化;
</li>
<li>
kubelet 本身设置的执行周期;
</li>
<li>
定时的清理事件。
</li>
所以跟其他控制器类似kubelet 启动的时候,要做的第一件事情,就是设置 Listers也就是注册它所关心的各种事件的 Informer。这些 Informer就是 SyncLoop 需要处理的数据的来源。
此外kubelet 还负责维护着很多很多其他的子控制循环(也就是图中的小圆圈)。这些控制循环的名字,一般被称作某某 Manager比如 Volume Manager、Image Manager、Node Status Manager等等。
不难想到,这些控制循环的责任,就是通过控制器模式,完成 kubelet 的某项具体职责。比如 Node Status Manager就负责响应 Node 的状态变化,然后将 Node 的状态收集起来,并通过 Heartbeat 的方式上报给 APIServer。再比如 CPU Manager就负责维护该 Node 的 CPU 核的信息,以便在 Pod 通过 cpuset 的方式请求 CPU 核的时候,能够正确地管理 CPU 核的使用量和可用量。
那么这个 **SyncLoop又是如何根据 Pod 对象的变化,来进行容器操作的呢?**
实际上kubelet 也是通过 Watch机制监听了与自己相关的 Pod 对象的变化。当然,这个 Watch 的过滤条件是该 Pod 的 nodeName 字段与自己相同。kubelet 会把这些 Pod 的信息缓存在自己的内存里。
而当一个 Pod 完成调度、与一个 Node 绑定起来之后, 这个 Pod 的变化就会触发 kubelet 在控制循环里注册的 Handler也就是上图中的 HandlePods 部分。此时,通过检查该 Pod 在 kubelet 内存里的状态kubelet 就能够判断出这是一个新调度过来的 Pod从而触发 Handler 里 ADD 事件对应的处理逻辑。
在具体的处理过程当中kubelet 会启动一个名叫 Pod Update Worker的、单独的 Goroutine 来完成对 Pod 的处理工作。
比如,如果是 ADD 事件的话kubelet 就会为这个新的 Pod 生成对应的 Pod Status检查 Pod 所声明使用的 Volume 是不是已经准备好。然后,调用下层的容器运行时(比如 Docker开始创建这个 Pod 所定义的容器。
而如果是 UPDATE 事件的话kubelet 就会根据 Pod 对象具体的变更情况,调用下层容器运行时进行容器的重建工作。
在这里需要注意的是,**kubelet 调用下层容器运行时的执行过程,并不会直接调用 Docker 的 API而是通过一组叫作 CRIContainer Runtime Interface容器运行时接口的 gRPC 接口来间接执行的。**
Kubernetes 项目之所以要在 kubelet 中引入这样一层单独的抽象,当然是为了对 Kubernetes 屏蔽下层容器运行时的差异。实际上,对于 1.6版本之前的 Kubernetes 来说它就是直接调用Docker 的 API 来创建和管理容器的。
但是正如我在本专栏开始介绍容器背景的时候提到过的Docker 项目风靡全球后不久CoreOS 公司就推出了 rkt 项目来与 Docker 正面竞争。在这种背景下Kubernetes 项目的默认容器运行时,自然也就成了两家公司角逐的重要战场。
毋庸置疑Docker 项目必然是 Kubernetes 项目最依赖的容器运行时。但凭借与 Google 公司非同一般的关系CoreOS 公司还是在2016年成功地将对 rkt 容器的支持,直接添加进了 kubelet 的主干代码里。
不过,这个“赶鸭子上架”的举动,并没有为 rkt 项目带来更多的用户,反而给 kubelet 的维护人员,带来了巨大的负担。
不难想象,在这种情况下, **kubelet 任何一次重要功能的更新都不得不考虑Docker 和 rkt 这两种容器运行时的处理场景,然后分别更新 Docker 和 rkt 两部分代码。**
更让人为难的是,由于 rkt 项目实在太小众kubelet 团队所有与 rkt 相关的代码修改,都必须依赖于 CoreOS 的员工才能做到。这不仅拖慢了 kubelet 的开发周期,也给项目的稳定性带来了巨大的隐患。
与此同时在2016年Kata Containers 项目的前身runV项目也开始逐渐成熟这种基于虚拟化技术的强隔离容器与 Kubernetes 和 Linux 容器项目之间具有良好的互补关系。所以,**在 Kubernetes 上游,对虚拟化容器的支持很快就被提上了日程。**
不过,虽然虚拟化容器运行时有各种优点,但它与 Linux 容器截然不同的实现方式,使得它跟 Kubernetes 的集成工作,比 rkt 要复杂得多。如果此时再把对runV支持的代码也一起添加到 kubelet 当中那么接下来kubelet 的维护工作就可以说完全没办法正常进行了。
所以在2016年SIG-Node 决定开始动手解决上述问题。而解决办法也很容易想到,那就是把 kubelet 对容器的操作统一地抽象成一个接口。这样kubelet 就只需要跟这个接口打交道了。而作为具体的容器项目,比如 Docker、 rkt、runV它们就只需要自己提供一个该接口的实现然后对 kubelet 暴露出 gRPC 服务即可。
这一层统一的容器操作接口,就是 CRI了。我会在下一篇文章中为你详细讲解 CRI 的设计与具体的实现原理。
而在有了 CRI 之后Kubernetes 以及 kubelet 本身的架构,就可以用如下所示的一幅示意图来描述。
<img src="https://static001.geekbang.org/resource/image/51/fe/5161bd6201942f7a1ed6d70d7d55acfe.png" alt=""><br>
可以看到,当 Kubernetes 通过编排能力创建了一个 Pod 之后,调度器会为这个 Pod 选择一个具体的节点来运行。这时候kubelet 当然就会通过前面讲解过的 SyncLoop 来判断需要执行的具体操作比如创建一个Pod。那么此时kubelet 实际上就会调用一个叫作 GenericRuntime 的通用组件来发起创建 Pod 的 CRI 请求。
那么,**这个 CRI 请求,又该由谁来响应呢?**
如果你使用的容器项目是 Docker 的话,那么负责响应这个请求的就是一个叫作 dockershim 的组件。它会把 CRI 请求里的内容拿出来,然后组装成 Docker API 请求发给 Docker Daemon。
需要注意的是,在 Kubernetes 目前的实现里dockershim 依然是 kubelet 代码的一部分。当然在将来dockershim 肯定会被从 kubelet 里移出来,甚至直接被废弃掉。
而更普遍的场景,就是你需要在每台宿主机上单独安装一个负责响应 CRI 的组件这个组件一般被称作CRI shim。顾名思义CRI shim 的工作,就是扮演 kubelet 与容器项目之间的“垫片”shim。所以它的作用非常单一那就是实现 CRI 规定的每个接口,然后把具体的 CRI 请求“翻译”成对后端容器项目的请求或者操作。
## 总结
在本篇文章中,我首先为你介绍了 SIG-Node 的职责,以及 kubelet 这个组件的工作原理。
接下来,我为你重点讲解了 kubelet 究竟是如何将 Kubernetes 对应用的定义,一步步转换成最终对 Docker 或者其他容器项目的API 请求的。
不难看到在这个过程中kubelet 的 SyncLoop 和 CRI 的设计是其中最重要的两个关键点。也正是基于以上设计SyncLoop 本身就要求这个控制循环是绝对不可以被阻塞的。所以,凡是在 kubelet 里有可能会耗费大量时间的操作,比如准备 Pod 的 Volume、拉取镜像等SyncLoop 都会开启单独的 Goroutine 来进行操作。
## 思考题
请问,在你的项目中,你是如何部署 kubelet 这个组件的?为什么要这么做呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,95 @@
<audio id="audio" title="46 | 解读 CRI 与 容器运行时" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/61/6b/6151026f3abcb3afbb766c2b38ca086b.mp3"></audio>
你好,我是张磊。今天我和你分享的主题是:解读 CRI 与 容器运行时。
在上一篇文章中,我为你详细讲解了 kubelet 的工作原理和 CRI 的来龙去脉。在今天这篇文章中,我们就来进一步地、更深入地了解一下 CRI 的设计与工作原理。
首先,我们先来简要回顾一下有了 CRI 之后Kubernetes 的架构图,如下所示。
<img src="https://static001.geekbang.org/resource/image/70/38/7016633777ec41da74905bfb91ae7b38.png" alt=""><br>
在上一篇文章中我也提到了CRI 机制能够发挥作用的核心,就在于每一种容器项目现在都可以自己实现一个 CRI shim自行对 CRI 请求进行处理。这样Kubernetes 就有了一个统一的容器抽象层,使得下层容器运行时可以自由地对接进入 Kubernetes 当中。
所以说,这里的 CRI shim就是容器项目的维护者们自由发挥的“场地”了。而除了 dockershim之外其他容器运行时的 CRI shim都是需要额外部署在宿主机上的。
举个例子。CNCF 里的 containerd 项目,就可以提供一个典型的 CRI shim 的能力将Kubernetes 发出的 CRI 请求,转换成对 containerd 的调用,然后创建出 runC 容器。而 runC项目才是负责执行我们前面讲解过的设置容器 Namespace、Cgroups和chroot 等基础操作的组件。所以,这几层的组合关系,可以用如下所示的示意图来描述。
<img src="https://static001.geekbang.org/resource/image/62/3d/62c591c4d832d44fed6f76f60be88e3d.png" alt=""><br>
**而作为一个 CRI shimcontainerd 对 CRI 的具体实现,又是怎样的呢?**
我们先来看一下 CRI 这个接口的定义。下面这幅示意图,就展示了 CRI 里主要的待实现接口。
<img src="https://static001.geekbang.org/resource/image/f7/16/f7e86505c09239b80ad05aecfb032e16.png" alt=""><br>
具体地说,**我们可以把 CRI 分为两组:**
<li>
第一组,是 RuntimeService。它提供的接口主要是跟容器相关的操作。比如创建和启动容器、删除容器、执行 exec 命令等等。
</li>
<li>
而第二组,则是 ImageService。它提供的接口主要是容器镜像相关的操作比如拉取镜像、删除镜像等等。
</li>
关于容器镜像的操作比较简单所以我们就暂且略过。接下来我主要为你讲解一下RuntimeService部分。
**在这一部分CRI 设计的一个重要原则,就是确保这个接口本身,只关注容器,不关注 Pod。**这样做的原因,也很容易理解。
**第一**Pod 是 Kubernetes 的编排概念,而不是容器运行时的概念。所以,我们就不能假设所有下层容器项目,都能够暴露出可以直接映射为 Pod 的 API。
**第二**,如果 CRI 里引入了关于 Pod 的概念,那么接下来只要 Pod API 对象的字段发生变化那么CRI 就很有可能需要变更。而在 Kubernetes 开发的前期Pod 对象的变化还是比较频繁的但对于CRI 这样的标准接口来说,这个变更频率就有点麻烦了。
所以,在 CRI 的设计里,并没有一个直接创建 Pod 或者启动 Pod 的接口。
不过相信你也已经注意到了CRI 里还是有一组叫作RunPodSandbox 的接口的。
这个 PodSandbox对应的并不是 Kubernetes 里的 Pod API 对象,而只是抽取了 Pod 里的一部分与容器运行时相关的字段比如HostName、DnsConfig、CgroupParent 等。所以说PodSandbox 这个接口描述的,其实是 Kubernetes 将 Pod 这个概念映射到容器运行时层面所需要的字段或者说是一个Pod 对象子集。
而作为具体的容器项目,你就需要自己决定如何使用这些字段来实现一个 Kubernetes 期望的 Pod模型。这里的原理可以用如下所示的示意图来表示清楚。
<img src="https://static001.geekbang.org/resource/image/d9/61/d9fb7404c5dc9e0b5c902f74df9d7a61.png" alt=""><br>
比如,当我们执行 kubectl run 创建了一个名叫 foo 的、包括了 A、B 两个容器的 Pod 之后。这个Pod 的信息最后来到 kubeletkubelet 就会按照图中所示的顺序来调用 CRI 接口。
在具体的 CRI shim 中,这些接口的实现是可以完全不同的。比如,如果是 Docker 项目dockershim 就会创建出一个名叫 foo 的 Infra容器pause 容器用来“hold”住整个 Pod 的 Network Namespace。
而如果是基于虚拟化技术的容器,比如 Kata Containers 项目,它的 CRI 实现就会直接创建出一个轻量级虚拟机来充当 Pod。
此外,需要注意的是,在 RunPodSandbox 这个接口的实现中你还需要调用networkPlugin.SetUpPod(…) 来为这个 Sandbox 设置网络。这个 SetUpPod(…) 方法,实际上就在执行 CNI 插件里的add(…) 方法,也就是我在前面为你讲解过的 CNI 插件为 Pod 创建网络,并且把 Infra 容器加入到网络中的操作。
>
备注这里你可以再回顾下第34篇文章[《Kubernetes网络模型与CNI网络插件》](https://time.geekbang.org/column/article/67351)中的相关内容。
接下来kubelet 继续调用 CreateContainer 和 StartContainer 接口来创建和启动容器 A、B。对应到 dockershim里就是直接启动AB两个 Docker 容器。所以最后,宿主机上会出现三个 Docker 容器组成这一个 Pod。
而如果是 Kata Containers 的话CreateContainer和StartContainer接口的实现就只会在前面创建的轻量级虚拟机里创建两个 A、B 容器对应的 Mount Namespace。所以最后在宿主机上只会有一个叫作 foo 的轻量级虚拟机在运行。关于像 Kata Containers 或者 gVisor 这种所谓的安全容器项目,我会在下一篇文章中为你详细介绍。
除了上述对容器生命周期的实现之外CRI shim 还有一个重要的工作,就是如何实现 exec、logs 等接口。这些接口跟前面的操作有一个很大的不同就是这些gRPC 接口调用期间kubelet 需要跟容器项目维护一个长连接来传输数据。这种 API我们就称之为Streaming API。
CRI shim 里对 Streaming API 的实现,依赖于一套独立的 Streaming Server 机制。这一部分原理,可以用如下所示的示意图来为你描述。
<img src="https://static001.geekbang.org/resource/image/a8/ef/a8e7ff6a6b0c9591a0a4f2b8e9e9bdef.png" alt=""><br>
可以看到,当我们对一个容器执行 kubectl exec 命令的时候,这个请求首先交给 API Server然后 API Server 就会调用 kubelet 的 Exec API。
这时kubelet就会调用 CRI 的 Exec 接口,而负责响应这个接口的,自然就是具体的 CRI shim。
但在这一步CRI shim 并不会直接去调用后端的容器项目(比如 Docker )来进行处理,而只会返回一个 URL 给 kubelet。这个 URL就是该 CRI shim 对应的 Streaming Server 的地址和端口。
而 kubelet 在拿到这个 URL 之后,就会把它以 Redirect 的方式返回给 API Server。所以这时候API Server 就会通过重定向来向 Streaming Server 发起真正的 /exec 请求,与它建立长连接。
当然,这个 Streaming Server 本身,是需要通过使用 SIG-Node 为你维护的 Streaming API 库来实现的。并且Streaming Server 会在 CRI shim 启动时就一起启动。此外Stream Server 这一部分具体怎么实现,完全可以由 CRI shim 的维护者自行决定。比如对于Docker 项目来说dockershim 就是直接调用 Docker 的 Exec API 来作为实现的。
以上就是CRI 的设计以及具体的工作原理了。
# 总结
在本篇文章中,我为你详细解读了 CRI 的设计和具体工作原理并为你梳理了实现CRI 接口的核心流程。
从这些讲解中不难看出CRI 这个接口的设计,实际上还是比较宽松的。这就意味着,作为容器项目的维护者,我在实现 CRI 的具体接口时,往往拥有着很高的自由度,这个自由度不仅包括了容器的生命周期管理,也包括了如何将 Pod 映射成为我自己的实现,还包括了如何调用 CNI 插件来为 Pod 设置网络的过程。
所以说,当你对容器这一层有特殊的需求时,我一定优先建议你考虑实现一个自己的 CRI shim ,而不是修改 kubelet 甚至容器项目的代码。这样通过插件的方式定制 Kubernetes 的做法,也是整个 Kubernetes 社区最鼓励和推崇的一个最佳实践。这也正是为什么像 Kata Containers、gVisor 甚至虚拟机这样的“非典型”容器,都可以无缝接入到 Kubernetes 项目里的重要原因。
# 思考题
请你思考一下我前面讲解过的Device Plugin 为容器分配的 GPU 信息,是通过 CRI 的哪个接口传递给 dockershim最后交给 Docker API 的呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,97 @@
<audio id="audio" title="47 | 绝不仅仅是安全Kata Containers 与 gVisor" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/82/24/82703193a84be5389db43f9867223d24.mp3"></audio>
你好我是张磊。今天我和你分享的主题是绝不仅仅是安全之Kata Containers 与 gVisor。
在上一篇文章中,我为你详细地讲解了 kubelet 和 CRI 的设计和具体的工作原理。而在讲解 CRI 的诞生背景时,我也提到过,这其中的一个重要推动力,就是基于虚拟化或者独立内核的安全容器项目的逐渐成熟。
使用虚拟化技术来做一个像 Docker 一样的容器项目,并不是一个新鲜的主意。早在 Docker 项目发布之后Google 公司就开源了一个实验性的项目,叫作 novm。这可以算是试图使用常规的虚拟化技术来运行 Docker 镜像的第一次尝试。不过novm 在开源后不久,就被放弃了,这对于 Google 公司来说或许不算是什么新鲜事,但是 novm 的昙花一现,还是激发出了很多内核开发者的灵感。
所以在2015年几乎在同一个星期Intel OTC Open Source Technology Center 和国内的 HyperHQ 团队同时开源了两个基于虚拟化技术的容器实现,分别叫做 Intel Clear Container 和 runV 项目。
而在2017年借着 Kubernetes 的东风,这两个相似的容器运行时项目在中立基金会的撮合下最终合并,就成了现在大家耳熟能详的 Kata Containers 项目。 由于 Kata Containers 的本质就是一个精简后的轻量级虚拟机,所以它的特点,就是“像虚拟机一样安全,像容器一样敏捷”。
而在2018年Google 公司则发布了一个名叫 gVisor 的项目。gVisor 项目给容器进程配置一个用 Go 语言实现的、运行在用户态的、极小的“独立内核”。这个内核对容器进程暴露 Linux 内核 ABI扮演着“Guest Kernel”的角色从而达到了将容器和宿主机隔离开的目的。
不难看到,无论是 Kata Containers还是 gVisor它们实现安全容器的方法其实是殊途同归的。这两种容器实现的本质都是给进程分配了一个独立的操作系统内核从而避免了让容器共享宿主机的内核。这样容器进程能够看到的攻击面就从整个宿主机内核变成了一个极小的、独立的、以容器为单位的内核从而有效解决了容器进程发生“逃逸”或者夺取整个宿主机的控制权的问题。这个原理可以用如下所示的示意图来表示清楚。
<img src="https://static001.geekbang.org/resource/image/95/1d/959c4c40c767acb6a3ffe6e144202e1d.png" alt="">
而它们的区别在于Kata Containers 使用的是传统的虚拟化技术,通过虚拟硬件模拟出了一台“小虚拟机”,然后在这个小虚拟机里安装了一个裁剪后的 Linux 内核来实现强隔离。
而 gVisor 的做法则更加激进Google 的工程师直接用 Go 语言“模拟”出了一个运行在用户态的操作系统内核,然后通过这个模拟的内核来代替容器进程向宿主机发起有限的、可控的系统调用。
接下来,我就来为你详细解读一下 KataContainers 和 gVisor 具体的设计原理。
**首先,我们来看 KataContainers**。它的工作原理可以用如下所示的示意图来描述。
<img src="https://static001.geekbang.org/resource/image/8d/89/8d7bbc8acaf27adff890f0be637df889.png" alt="">
我们前面说过Kata Containers 的本质,就是一个轻量化虚拟机。所以当你启动一个 Kata Containers 之后你其实就会看到一个正常的虚拟机在运行。这也就意味着一个标准的虚拟机管理程序Virtual Machine Manager, VMM是运行 Kata Containers 必备的一个组件。在我们上面图中,使用的 VMM 就是 Qemu。
而使用了虚拟机作为进程的隔离环境之后Kata Containers 原生就带有了 Pod 的概念。即这个Kata Containers 启动的虚拟机,就是一个 Pod而用户定义的容器就是运行在这个轻量级虚拟机里的进程。在具体实现上Kata Containers 的虚拟机里会有一个特殊的 Init 进程负责管理虚拟机里面的用户容器,并且只为这些容器开启 Mount Namespace。所以这些用户容器之间原生就是共享 Network 以及其他Namespace 的。
此外,为了跟上层编排框架比如 Kubernetes 进行对接Kata Containers 项目会启动一系列跟用户容器对应的 shim 进程,来负责操作这些用户容器的生命周期。当然,这些操作,实际上还是要靠虚拟机里的 Init 进程来帮你做到。
而在具体的架构上Kata Containers的实现方式同一个正常的虚拟机其实也非常类似。这里的原理可以用如下所示的一幅示意图来表示。
<img src="https://static001.geekbang.org/resource/image/16/f3/1684d0d89c170c2f8e6d050919c883f3.jpg" alt="">
可以看到,当 Kata Containers 运行起来之后,虚拟机里的用户进程(容器),实际上只能看到虚拟机里的、被裁减过的 Guest Kernel以及通过 Hypervisor 虚拟出来的硬件设备。
而为了能够对这个虚拟机的 I/O 性能进行优化Kata Containers 也会通过 vhost 技术比如vhost-user来实现 Guest 与 Host 之间的高效的网络通信,并且使用 PCI Passthrough PCI 穿透)技术来让 Guest 里的进程直接访问到宿主机上的物理设备。这些架构设计与实现,其实跟常规虚拟机的优化手段是基本一致的。
相比之下gVisor 的设计其实要更加“激进”一些。它的原理,可以用如下所示的示意图来表示清楚。
<img src="https://static001.geekbang.org/resource/image/2f/7b/2f7903a7c494ddf6989d00c794bd7a7b.png" alt="">
gVisor工作的核心在于它为应用进程、也就是用户容器启动了一个名叫 Sentry 的进程。 而Sentry 进程的主要职责就是提供一个传统的操作系统内核的能力运行用户程序执行系统调用。所以说Sentry 并不是使用 Go 语言重新实现了一个完整的 Linux 内核,而只是一个对应用进程“冒充”内核的系统组件。
在这种设计思想下我们就不难理解Sentry 其实需要自己实现一个完整的 Linux 内核网络栈,以便处理应用进程的通信请求。然后,把封装好的二层帧直接发送给 Kubernetes 设置的 Pod 的Network Namespace 即可。
此外Sentry 对于Volume 的操作,则需要通过 9p 协议交给一个叫做 Gofer 的代理进程来完成。Gofer 会代替应用进程直接操作宿主机上的文件并依靠seccomp机制将自己的能力限制在最小集从而防止恶意应用进程通过 Gofer 来从容器中“逃逸”出去。
而在具体的实现上gVisor 的 Sentry 进程,其实还分为两种不同的实现方式。这里的工作原理,可以用下面的示意图来描述清楚。
<img src="https://static001.geekbang.org/resource/image/5a/b8/5a1d6e0291306417864033b3f40f74b8.png" alt="">
**第一种实现方式**是使用Ptrace机制来拦截用户应用的系统调用System Call然后把这些系统调用交给 Sentry 来进行处理。
这个过程,对于应用进程来说,是完全透明的。而 Sentry 接下来,则会扮演操作系统的角色,在用户态执行用户程序,然后仅在需要的时候,才向宿主机发起 Sentry 自己所需要执行的系统调用。这,就是 gVisor 对用户应用进程进行强隔离的主要手段。不过, Ptrace 进行系统调用拦截的性能实在是太差,仅能供 Demo 时使用。
而**第二种实现方式**,则更加具有普适性。它的工作原理如下图所示。
<img src="https://static001.geekbang.org/resource/image/3f/bf/3faf90550425378be91eb8cd2f0c63bf.png" alt="">
在这种实现里Sentry 会使用 KVM 来进行系统调用的拦截,这个性能比 Ptrace 就要好很多了。
当然为了能够做到这一点Sentry 进程就必须扮演一个 Guest Kernel 的角色,负责执行用户程序,发起系统调用。而这些系统调用被 KVM 拦截下来,还是继续交给 Sentry 进行处理。只不过在这时候Sentry 就切换成了一个普通的宿主机进程的角色,来向宿主机发起它所需要的系统调用。
可以看到,**在这种实现里Sentry 并不会真的像虚拟机那样去虚拟出硬件设备、安装 Guest 操作系统。它只是借助 KVM 进行系统调用的拦截,以及处理地址空间切换等细节。**
值得一提的是,在 Google 内部,他们也是使用的第二种基于 Hypervisor 的gVisor 实现。只不过 Google 内部有自己研发的 Hypervisor所以要比 KVM 实现的性能还要好。
通过以上的讲述,相信你对 Kata Containers 和 gVisor 的实现原理已经有一个感性的认识了。需要指出的是到目前为止gVisor 的实现依然不是非常完善,有很多 Linux系统调用它还不支持有很多应用在 gVisor 里还没办法运行起来。 此外gVisor也暂时没有实现一个 Pod 多个容器的支持。当然,在后面的发展中,这些工程问题一定会逐渐解决掉的。
另外,你可能还听说过 AWS 在2018年末发布的一个叫做 Firecracker 的安全容器项目。这个项目的核心,其实是一个用 Rust 语言重新编写的 VMM虚拟机管理器。这就意味着 Firecracker 和 Kata Containers 的本质原理,其实是一样的。只不过, Kata Containers 默认使用的 VMM 是 Qemu而 Firecracker则使用自己编写的 VMM。所以理论上Kata Containers 也可以使用 Firecracker 运行起来。
## 总结
在本篇文章中,我为你详细地介绍了拥有独立内核的安全容器项目,对比了 KataContainers 和 gVisor 的设计与实现细节。
在性能上KataContainers 和 KVM 实现的 gVisor 基本不分伯仲,在启动速度和占用资源上,基于用户态内核的 gVisor 还略胜一筹。但是,对于系统调用密集的应用,比如重 I/O 或者重网络的应用gVisor 就会因为需要频繁拦截系统调用而出现性能急剧下降的情况。此外gVisor 由于要自己使用 Sentry 去模拟一个Linux 内核,所以它能支持的系统调用是有限的,只是 Linux 系统调用的一个子集。
不过gVisor 虽然现在没有任何优势,但是这种通过在用户态运行一个操作系统内核,来为应用进程提供强隔离的思路,的确是未来安全容器进一步演化的一个非常有前途的方向。
值得一提的是Kata Containers 团队在 gVisor 之前,就已经 Demo 了一个名叫 Linuxd 的项目。这个项目,使用了 User Mode Linux (UML)技术,在用户态运行起了一个真正的 Linux Kernel 来为应用进程提供强隔离,从而避免了重新实现 Linux Kernel 带来的各种麻烦。
有兴趣的话,你可以[在这里查看](https://lc32018.sched.com/event/ER8x/run-linux-kernel-as-a-daemon-lai-jiangshan-hypersh)这个演讲。我相信,这个方向,应该才是安全容器进化的未来。这比 Unikernels 这种根本不适合实际场景中使用的思路,要靠谱得多。
>
本篇图片出处均引自[ Kata Containers 的官方对比资料](https://www.openstack.org/assets/presentation-media/kata-containers-and-gvisor-a-quantitave-comparison.pdf)。
## 思考题
安全容器的意义,绝不仅仅止于安全。你可以想象一下这样一个场景:比如,你的宿主机的 Linux 内核版本是3.6,但是应用却必须要求 Linux 内核版本是4.0。这时候,你就可以把这个应用运行在一个 KataContainers 里。那么请问,你觉得使用 gVisor 是否也能提供这种能力呢?原因是什么呢?
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。