摘要: 关于 Kubernetes 接口化设计、CRI、容器运行时、shimv2、RuntimeClass 等关键技术特性的设计与实现。
作者|张磊
Kubernetes
项目目前的重点发展方向,是为开发者和使用者暴露更多的接口和可扩展机制,将更多的用户需求下放到社区来完成。其中,发展最为成熟也最为重要的一个接口就是
CRI。2018 年,由 containerd 社区主导的 shimv2 API 的出现,在 CRI
的基础上,为用户集成自己的容器运行时带来了更加成熟和方便的实践方法。
本次演讲分享了关于 Kubernetes 接口化设计、CRI、容器运行时、shimv2、RuntimeClass 等关键技术特性的设计与实现,并以 KataContainers 为例,为听众演示上述技术特性的使用方法。 本文整理自张磊在 KubeCon + CloudNativeCon 2018 现场的演讲速记。
今天,我给大家带来的分享是关于 Kubernetes CRI 和
containerd shimv2 的设计,这也是目前社区里比较重要的一个大方向。大家好,我是张磊,现在在阿里巴巴集团工作。既然今天咱们会聊
Kubernetes 这个项目,那么首先我们来简单看一下 Kubernetes 这个项目的工作原理。
Kubernetes 的工作原理
其实大家都知道 Kubernetes 这个项目它最上面是一层 Control Panel ,它也被很多人称之为 Master 节点。当你把 workload 就是你的应用提交给 Kubernetes 之后,首先为你做事情的是 API server,它会把你的 Application 存到 etcd 里,以 API 对象的方式存到 etcd 中去。
而 Kubernetes 中负责编排的是 Controller manager,一堆 controller 通过控制循环在 run。通过这个控制循环来做编排工作,帮你去创建出这些应用所需要的 Pod,注意不是容器,是 Pod。
cdn.nlark.com/lark/0/2018/png/168324/1544673854566-635bc4b2-a618-4d14-9e64-4a8c5207af9f.png">
而一旦一个 Pod 出现之后,Scheduler 会 watch 新 Pod 的变化。如果他发现有一个新的 Pod 出现,Scheduler 会帮你去把所有调度算法都 run 一遍,把 run 到的结果:就是一个 Node 的名字,写在我这个 Pod 对象 NodeName 字段上面,就是一个所谓的 bind 的操作。然后把 bind 的结果写回到 etcd 里去,这就是所谓的 Scheduler 工作过程。所以 Control Panel 它忙活这么一圈下来,最后得到的结果是什么呢?你的一个 Pod 跟一个 Node 绑定(bind)在了一起,就是所谓 Schedule 了。
而 Kubelet 呢?它是运行在所有节点上。Kubelet 会 watch 所有 Pod 对象的变化,当它发现一个 Pod 与一个 Node 绑定在一起的时,并且它又发现这个被绑定的 Node 是它自己,那么 Kubelet 就会帮你去接管接下来的所有事情。
如果你看一下 Kubelet ,看看它在做什么呢?很简单,其实当 Kubelet 拿到这个信息之后,他是去 call 你运行在每个机器上的 Containerd 进程,去 run 这个 Pod 里的每一个容器。
这时候,Containerd 帮你去 call runc 所以最后其实是 runc 帮你去 set up 起来这些 namespace、Cgroup 这些东西,是它去帮你 chroot ,“搭”出来所谓的一个应用和需要的容器。这就是整个 Kubernetes 工作的一个简单原理。
Linux Container
所以这个时候你可能会提出一个问题就是什么是容器?其实容器非常简单,我们平常所说这个容器就是 Linux 容器,你可以把 Linux 容器分为两部分:第一个是 Container Runtime,第二个是 Container Image。
所谓的 Runtime 部分就是你所运行进程的动态视图和资源边界,所以它是由 Namespace 和 Cgroup 为你构建出来的。而对于 Image(镜像),你可以把它理解为是你想要运行的程序的静态视图,所以它其实是你的程序+数据+所有的依赖+所有的目录文件组成一个压缩包而已。
而这些压缩包被以 union mount 的方式 mount 在一起的时候,我们称之为 rootfs 。rootfs 就是你的整个 process 的静态视图,他们看到这个世界就这样子,所以这是 Linux Container。
KataContainer
可今天我们还要聊另外一种 Container,它与前面 Linux Container 截然不同。他的 Container Runtime 是用 hypervisor 实现的,是用 hardware virtualization 实现的,像个虚拟机一样。所以每一个像这样的 KataContainer 的 Pod,都是一个轻量级虚拟机,它是有完整的 Linux 内核。所以我们经常说 KataContainer 与 VM 一样能提供强隔离性,但由于它的优化和性能设计,它拥有与容器项媲美的敏捷性。这个一点稍后会强调,而对于镜像部分, KataContainer 与 Docker 这些项目没有任何不同,它使用的是标准 Linux Continer 容器,支持标准的 OCR Image 所以这一部分是完全一样的。
容器安全
可是你可能会问为什么我们会有 KataContainer 这种项目? 其实很简单,因为我们关心安全这个事,比如很多金融的场景、加密的场景,甚至现在区块链很多场景下,都需要一个安全的 Container Runtime,所以这是我们强调 KataContainer 的一个原因。
如果你现在正在使用 Docker, 我问一个问题就是你怎样才能安全地使用 Docker?你可能会有很多套路去做。比如说你会 drop 掉一些 Linux capibility,你可以去指定 Runtime 可以做什么,不能做什么。第二个你可以去 read-only mount points 。第三,你可以使用 SELinux 或者 AppArmor 这些工具把容器给保护起来。还有一种方式是可以直接拒绝一些 syscalls,可以用到 SECCOMP。
但是我需要强调的是所有这些操作都会在你的 Container 和 Host 之间引入新的 layer,因为它要去做过滤,它要去拦截你的 syscalls,所以这个部分你搭的层越多,你容器性能越差,它一定是有额外的负面性能损耗的。
更重要的是,做这些事情之前你要想清楚到底应该干什么,到底应该 drop 掉哪些 syscalls,这个是需要具体问题具体分析的,那么这时候我应该怎么去跟我的用户去讲如何做这件事情?
所以,这些事情说起来很简单,但实际执行起来很少有人知道到底该怎么去做。所以在 99.99% 的情况下,大多数人都是把容器 run 到虚拟机里去的,尤其在公有云场景下。
而对于 KataContainer 这种项目来说,它由于使用了与虚拟机一样的 hardware virualization,它是有独立内核的,所以这个时候它提供的 isolation 是完全可信任的,就与你信任 VM 是一样的。
更重要的是,由于现在每一个 Pod 里是有一个 Independent Kernel,跟个小虚拟机一样,所以这时候就允许你容器运行的 Kernel 版本跟 Host machine 适应是完全不一样。这是完全 OK 的,就与你在在虚拟机中做这件事一样,所以这就是为什么我会强调 KataContainers 的一个原因,因为它提供了安全和多租户的能力。
Kubernetes + 安全容器
所以也就很自然会与有一个需求,就是我们怎么去把 KataContainer run 在 Kubernetes 里?
那么这个时候我们还是先来看 Kubelet 在做什么事情,所以 Kubelet 要想办法像 call Containerd 一样去 call KataContainer,然后由 KataContainer 负责帮忙把 hypervisor 这些东西 set up 起来,帮我把这个小VM 运行起来。所以这个时候就要需要想怎么让 Kubernetes 能合理的操作 KataContainers。
Container Runtime Interface(CRI)
对于这个诉求,就关系到了我们之前一直在社区推进的 Container Runtime Interface ,我们叫它 CRI。CRI 的作用其实只有一个:就是它描述了,对于 Kubernetes 来说,一个 Container 应该有哪些操作,每个操作有哪些参数,这就是 CRI 的一个设计原理。但需要注意的是,CRI 是一个以容器为核心的 API,它里面没有 Pod 的这个概念。这个要记住。
为什么这么说呢?我们为什么要这么设计呢?很简单,我们不希望像 Docker 这样的项目,必须得懂什么是 Pod,暴露出 Pod 的 API,这是不合理的诉求。Pod 永远都是一个 Kubernetes 的编排概念,这跟容器没有关系,所以这就是为什么我们要把这个 API 做成 Containerd -centric。
另外一个原因出于 maintain 的考虑,因为如果现在, CRI 里有 Pod 这个概念,那么接下来任何一个 Pod feature 的变更都有可能会引起 CRI 的变动,对于一个接口来说,这样的维护代价是比较大的。所以如果你细看一下 CRI,你会发现它其实定了一些非常普遍的操作容器接口。
在这里,我可以把 CRI 大致它分为 Container 和 SandBox。SandBox 用来描述的是我通过什么样的机制来去实现 Pod ,所以它其实就是 Pod这个概念真正跟容器项目相关的字段。对于 Docker 或 Linux 容器来说,它其实 match 到最后 run 起来的是一个叫 infra container 的容器,就是一个极小的容器,这个容器用来 hold 整个 Pod 的 Node 和 Namespace。
不过, Kubernetes 如果用 Linux Container Runtim, 比如 Docker 的话,它不会给你提供 Pod level 的 isolation,除了一层 Pod level cgroups 。这是一个不同点。因为,如果你用 KataContainers 的话,KataContaniners 会在这一步为你创建一个轻量级的虚拟机。
接下来到下一阶段,到 Containers 这个 API 的时候,对于 Docker 来说它就给你起在宿主机上启动用户容器,但对 Kata 来说不是这样的,它会在前面的 Pod 对应的轻量级虚拟机里面,也就在前面创建的 SandBox 里面 set up 这些用户容器所需要 Namespace ,而不会再跟你在一起新的容器。所以有了这样一个机制之后,当上面 Contol Panel 完成它的工作之后,它说我把 Pod 调度好了,这时候 Kubelet 这边启动或创建这个 Pod 的时候一路走下去,最后一步才会去 call 我们这个所谓 CRI。在此之前,在 Kubelet 或者 Kubernetes 这是没有所谓 Containers runtime 这个概念的。
所以走到这一步之后,如果你用 Docker 的话,那么 Kubernetes 里负责响应这个 CRI 请求 是 Dockershim。但如果你用的不是 Docker 的话一律都要去走一个叫 remote 的模式,就是你需要写一个 CRI Shim,去 serve 这个 CRI 请求,这就是我们今天所讨论下一个主题。
CRI Shim 如何工作?
CRI Shim 可以做什么?它可以把 CRI 请求 翻译成 Runtime API。我举个例子,比如说现在有个 Pod 里有一个 A 容器和有个 B 容器,这时候我们把这件事提交给 Kubernetes 之后,在 Kubelet 那一端发起的 CRI code 大概是这样的序列:首先它会 run SandBox foo,如果是 Docker 它会起一个 infra 容器,就是一个很小的容器叫 foo,如果是 Kata 它会给你起一个虚拟机叫 foo,这是不一样的。
所以接下来你 creat start container A 和 B 的时候,在 Docker 里面是起两个容器,但在 Kata 里面是在我这个小虚拟机里面,在这 SandBox 里面起两个小 NameSpace,这是不一样的。所以你把这一切东西总结一下,你会发现 OK,我现在要把 Kata run 在 Kubernetes 里头,所以我要做工作,在这一步要需要去做这个 CRI shim,我就想办法给 Kata 作一个 CRI shim。
而我们能够想到一个方式,我能不能重用现在的这些 CRI shim。重用现在哪些?比如说 CRI containerd 这个项目它就是一个 containerd 的 CRI shim,它可以去响应 CRI 的请求过来,所以接下来我能不能把这些情况翻译成对 Kata 这些操作,所以这个是可以的,这也是我们将用一种方式,就是把 KataContainers 接到我的 Containerd 后面。这时候它的工作原理大概这样这个样子,Containerd 它有一个独特设计,就是他会为每一个 Contaner 起个叫做 Contained shim。你 run 一下之后你会看他那个宿主机里面,会 run 一片这个 Containerd shim 一个一个对上去。
而这时候由于 Kata 是一个有 SandBox 概念的这样一个 container runtime,所以 Kata 需要去 match 这些 Shim 与 Kata 之间的关系,所以 Kata 做一个 Katashim。把这些东西对起来,就把你的 Contained 的处理的方式翻译成对 kata 的 request,这是我们之前的一个方式。
但是你能看到这其实有些问题的,最明显的一个问题在于 对 Kata 或 gvisor 来说,他们都是有实体的 SandBox 概念的,而有了 SandBox 概念后,它就不应该去再去给他的每一个 Container 启动有一个 shim match 起来,因为这给我们带来很大的额外性能损耗。我们不希望每一个容器都去 match 一个 shim,我们希望一个 SandBox match 一个 shim。
另外,就是你会发现 CRI 是服务于 Kubernetes 的,而且它呈现向上汇报的状态,它是帮助 Kubernetes 的,但是它不帮助 Container runtime。所以说当你去做这个集成时候,你会发现尤其对于 VM gvisor\KataContainer 来说,它与 CRI 的很多假设或者是 API 的写法上是不对应的。所以你的集成工作会比较费劲,这是一个不 match 的状态。
最后一个就是我们维护起来非常困难,因为由于有了 CRI 之后,比如 RedHat 拥有自己的 CRI 实现叫 cri-o,他们和 containerd 在本质上没有任何区别,跑到最后都是靠 runc 起容器,为什么要这种东西?
我们不知道,但是我作为 Kata maintainer,我需要给他们两个分别写两部分的 integration 把 Kata 集成进去。这就很麻烦,者就意味着我有 100 种这种 CRI 我就要写 100 个集成,而且他们的功能全部都是重复的。
Containerd ShimV2
所以在今天我给大家 propose 的这个东西叫做 Containerd ShimV2。前面我们说过 CRI,CRI 决定的是 Runtime 和 Kubernetes 之间的关系,那么我们现在能不能再有一层更细致的 API 来决定我的 CRI Shim 跟下面的 Runtime 之间真正的接口是什么样的?
这就是 ShimV2 出现的原因,它是一层 CRI shim 到 Containerd runtime 之间的标准接口,所以前面我直接从 CRI 到 Containerd 到 runc,现在不是。我们是从 CRI 到 Containerd 到 ShimV2,然后 ShimV2 再到 runc 再到 KataContainer。这么做有什么好处?
我们来看一下,最大的区别在于:在这种方式下,你可以为每一个 Pod 指定一个 Shim。因为在最开始的时候,Containerd 是直接启动了一个 Containerd Shim 来去做响应,但我们新的 API 是这样写的,是 Containerd Shim start 或者 stop。所以这个 start 和 stop 操作怎么去实现是你要做的事情。
而现在,我作为一位 KataContainers项目的 maintainer 我就可以这么实现。我在 created SandBox 的时候 call 这个 start 的时候,我启动一个 Containerd Shim。但是当我下一步是 call API 的时候,就前面那个 CRI 里面, Container API 时候,我就不再起了,我是 reuse,我重用为你创建好的这个 SandBox,这就位你的实现提供了很大的自由度。
所以这时候你会发现整个实现的方式变了,这时候 Containerd 用过来之后,它不再去 care 每个容器起 Containerd Shim,而是由你自己去实现。我的实现方式是我只在 SandBox 时候,去创建 containerd-shim-v2,而接下来整个后面的 container level 操作,我会全部走到这个 containerd-shim-v2 里面,我去重用这个 SandBox,所以这个跟前面的时间就出现很大的不同。
所以你现在去总结一下这个图的话,你发现我们实现方式是变成这个样子:
首先,你还是用原来的 CRI Containerd,只不过现在装的是 runc,你现在再装一个 katacontainer 放在那机器上面。接下来我们 Kata 那边会给你写一个实现叫 kata-Containerd-Shimv2。所以前面要写一大坨 CRI 的东西,现在不用了。现在,我们只 focus 在怎么去把 Containerd 对接在 kata container 上面,就是所谓的实现 Shimv2 API,这是我们要做的工作。而具体到我们这要做的事情上,其实它就是这样一系列与 run 一个容器相关的 API。
比如说我可以去 create、start,这些操作全部映射在我 Shimv2 上面去实现,而不是说我现在考虑怎么去映射,去实现 CRI,这个自由度由于之前太大,造成了我们现在的一个局面,就有一堆 CRI Shim 可以用。这其实是一个不好的事情。有很多政治原因,有很多非技术原因,这都不是我们作为技术人员应该关心的事情,你现在只需要想我怎么去跟 Shimv2 对接就好了。
接下来,我为你演示一下通过 CRI + containerd shimv2调用 KataContainers 的一个 Demo(具体内容略)
总结
Kubernetes 现在的核心设计思想,就是通过接口化和插件化,将原本复杂的、对主干代码有侵入性的特性,逐一从核心库中剥离和解耦。而在这个过程中,CRI 就是 Kubernetes 项目中最早完成了插件化的一个调用接口。而这次分享,主要为你介绍了在CRI基础上的另一种集成容器运行时的思路,即:CRI + containerd shimv2 的方式。通过这种方式,你就不需要再为自己的容器运行时专门编写一个 CRI 实现(CRI shim),而是可以直接重用 containerd对 CRI 的支持能力,然后通过 containerd shimv2的方式来对接具体的容器运行时(比如 runc)。目前,这种集成方式已经成为了社区对接下层容器运行时的主流思路,像很多类似于 KataContainers,gvisor,Firecracker 等基于独立内核或者虚拟化的容器项目,也都开始通过 shimv2 ,进而借助 containerd项目无缝接入到 Kubernetes 当中。
而众所周知,在阿里内部,Sigma/Kubernetes 系统使用的容器运行时主要是 PouchContainer。事实上,PouchContainer 本身选择使用 containerd 作为其主要的容器运行时管理引擎,并自我实现了增强版的 CRI 接口,使其满足阿里巴巴强隔离、生产级别的容器需求。所以在 shimv2 API 在 containerd 社区发布之后,PouchContainer 项目就已经率先开始探索和尝试通过 containerd shimv2 来对接下层的容器运行时,进而更高效的完成对其他种类的容器运行时尤其是虚拟化容器的集成工作。我们知道,自从开源以来,PouchContainer 团队一直都在积极地推动 containerd 上游社区的发展和演进工作,而在这次 CRI + containerd shimv2 的变革里, PouchContainer 再一次走到了各个容器项目的最前面。