K8S Runtime 种类多,使用复杂?那是你没明白其中的门道
技术
作者:吴叶磊
译者:小君君
2019-04-12 12:34

在刚开始接触 Kubernetes 的时候,相信很多人都经常搞不懂 CRI 与 OCI 的联系和区别,也不知道为什么要垫那么多的“shim”(尤其是 containerd-shim 和 dockershim 这两个完全没什么关联的东西都被称为 shim)。这篇文章就和大家一起聊聊 Kubernetes Runtime,把下面这张 Landscape 里的核心项目陈述清楚: 


 通过本文你将了解到:

  • 典型的 Runtime 架构;
  • 大话容器历史;
  • OCI、CRI 与被滥用的名词“Runtime”;
  • containerd 和 CRI-O;
  • 强隔离容器:Kata、gVisor、firecracker;
  • 安全容器与 Serverless。

典型的 Runtime 架构 

首先,本文从最常见的 Runtime 方案 Docker 说起: 

当 Kubelet 想要创建一个容器时,它需要以下几个步骤:

  • Kubelet 通过 CRI 接口(gRPC)调用 dockershim,请求创建一个容器,CRI(容器运行时接口,Container Runtime Interface)。在这一步中 , Kubelet 可以视作一个简单的 CRI Client,而 dockershim 就是接收请求的 Server。目前 dockershim 的代码其实是内嵌在 Kubelet 中的,所以接收调用的就是 Kubelet 进程;
  • dockershim 收到请求后,它会转化成 Docker Daemon 能听懂的请求,发到 Docker Daemon 上,并请求创建一个容器;
  • Docker Daemon 早在 1.12 版本中就已经将针对容器的操作移到另一个守护进程 containerd 中了。因此 Docker Daemon 仍然不能帮人们创建容器,而是需要请求 containerd 创建一个容器;
  • containerd 收到请求后,并不会自己直接去操作容器,而是创建一个叫做 containerd-shim 的进程,让 containerd-shim 去操作容器。这是因为容器进程需要一个父进程来做诸如收集状态、维持 stdin 等 fd 打开工作。假如这个父进程就是 containerd,那每次 containerd 挂掉或升级后,整个宿主机上所有的容器都需要退出,但是引入了 containerd-shim 就规避了这个问题(containerd 和 shim 并不是父子进程关系);
  • 创建容器是需要做一些设置 namespace 和 Cgroups、挂载 root filesystem 的操作。这些事已经有了公开的规范 OCI(Open Container Initiative,开放容器标准)。它的一个参考实现叫做 runc。containerd-shim 在这一步需要调用 runc 这个命令行工具,来启动容器;
  • runc 启动完容器后,它会直接退出,containerd-shim 则会成为容器进程的父进程,负责收集容器进程的状态,上报给 containerd。并在容器中 pid 为 1 的进程退出后接管容器中的子进程,然后进行清理,确保不会出现僵尸进程。

Docker Daemon 和 dockershim 看上去就像是两个不干活的组件,Kubelet 为啥不直接调用 containerd 呢? 

当然是可以的!但是,在了解这个之前,大家不妨先看看为什么现在的架构如此繁冗。 

大话容器历史 

其实 Kubernetes 最开始的 Runtime 架构远没这么复杂:Kubelet 想要创建容器可以直接通知 Docker Daemon,那时也不存在 containerd。Docker Daemon 自行调节libcontainer库就可以把容器跑起来。

而熟悉容器和容器编排历史的读者应该知道,在这之后就是容器圈的一系列政治斗争。先是大佬们认为 Runtime 标准不能被 Docker 一家公司控制,于是推出了开放容器标准 OCI。Docker 则把libcontainer封装起来 , 变成 runc 捐献出来,作为 OCI 的参考实现。 

此时 rkt 也想从 Docker 那边分一杯羹,希望 Kubernetes 原生支持 rkt 作为 Runtime,并且 PR 也成功的合进去了。接触过一块业务同时接两个需求方的读者应该都知道类似这样的事情处理起来很麻烦,Kubernetes 中负责维护 Kubelet 的小组 sig-node 也被这件事狠狠的坑了一把。 

后来,大家认为这样做是不行的。今天能有 rkt,明天就能有其他的什么出来,长此以往,sig-node 小组的工作便无法进行下去(每天都需要处理兼容性的 bug)。于是,Kubernetes v1.5 推出了 CRI 机制(即容器运行时接口,Container Runtime Interface)。Kubernetes 借此告知大家 , 只要能实现这个接口,谁都可以做 Runtime。 

不过 CRI 本身只是 Kubernetes 的一个标准。当时的 Kubernetes 尚未达到如今这般举足轻重的地位,容器运行时也不会与 Kubernetes 绑死,只提供 CRI 接口。于是就有了 shim(垫片),一个 shim 的职责就是作为 Adapter 将各种容器运行时本身的接口适配到 Kubernetes 的 CRI 接口上。 

接下来,Docker 的 Swarm 为进军 PaaS 市场,做了个架构切分,将容器操作都移动到一个单独的 Daemon 进程 containerd 中,让 Docker Daemon 专门负责上层的封装编排。可惜 Swarm 并没有 Kubernetes 那般功能强大。失败之后,Docker 公司就把 containerd 项目捐给 CNCF,专心做 Docker 企业版。 

经过这些事情之后,就是读者们在上一张图中看到的那些东西了。尽管现在已经有 CRI-O、containerd-plugin 这样更精简轻量的 Runtime 架构,但是 dockershim 这一套作为经受了最多生产环境考验的方案,迄今为止仍是 Kubernetes 默认的 Runtime 实现。  

了解这些具体的架构,有时能帮助人们在排除故障时少走弯路,但更重要的是它们能作为一个例子 , 帮助人们更好地理解整个 Kubernetes Runtime 背后的设计逻辑 。

 OCI、CRI 与被滥用的名词“Runtime” 

 OCI,也就是前文提到的“开放容器标准”,在官方文档中主要规定了两点:

  • 容器镜像应该是什么样的,即 ImageSpec。它大致规定的是,你的容器镜像需要是一个压缩了的文件夹,文件夹里以 xxx 结构放入 xxx 文件中;
  • 容器要需要能接收哪些指令,这些指令的行为是什么,即 RuntimeSpec。简单来说,它规定的就是“容器”要能够执行“create”“start”“stop”“delete”这些命令,并且行为要规范。

 runc 为什么叫参考实现?因为它能按照标准将符合标准的容器镜像运行起来。 

标准的好处就是方便搞创新,只要研发的东西符合标准,在生态圈里就能与其它工具一起愉快地工作。那研发人员自行研发的镜像就可以用任意的工具去构建,“容器”也不一定非要用 namespace 和 Cgroups 来做隔离。这就让各种虚拟化容器可以更好地参与到游戏当中。 

而 CRI 更简单,单纯是一组 gRPC 接口,看一眼 kubelet/apis/cri/services.go 就能归纳出几套核心接口:

  • 一套针对容器操作的接口,包括创建、启停容器等;
  • 一套针对镜像操作的接口,包括拉取镜像、删除镜像等;
  • 还有一套针对 PodSandbox(容器沙箱环境)的操作接口。

 现在我们可以找到很多符合 OCI 标准或兼容了 CRI 接口的项目,这些项目大体构成了整个 Kuberentes 的 Runtime 生态:

  • OCI Compatible:runc、Kata(以及它的前身 runV 和 Clear Containers)、gVisor。其它比较偏门的还有 Rust 写的 railcar;
  • CRI Compatible:Docker(借助 dockershim)、containerd(借助 CRI-containerd)、CRI-O、frakti 等。

很多读者可能在最开始学习 Kubernetes 的时候,弄不清 OCI 和 CRI 的区别与联系。其中一大原因就是社区里糟糕的命名:这上面的项目统统可以称为容器运行时(Container Runtime),彼此之间区分的办法就是给“容器运行时”这个词加上各种定语和从句来进行修饰。Go 语言的开源贡献者和项目成员 Dave Cheney 曾说过: 

Good naming is like a good joke. If you have to explain it, it’s not funny. 

显然 Container Runtime 在这里就不是一个好名字了,更准确的说法是:cri-runtime 和 oci-runtime。通过这个粗略的分类,就可以总结出整个 Runtime 架构万变不离其宗的三层抽象: 

1 Orchestration API -> Container API -> Kernel API

这其中 Kubernetes 已经是 Orchestration API 的事实标准。而在 Kubernetes 中,Container API 的接口标准就是 CRI,由 cri-runtime 实现。Kernel API 的规范是 OCI,由 oci-runtime 实现。 

这其中 Kubernetes 已经是 Orchestration API 的事实标准。而在 Kubernetes 中,Container API 的接口标准就是 CRI,由 cri-runtime 实现。Kernel API 的规范是 OCI,由 oci-runtime 实现。
根据这个思路 , 我们就很容易理解下面这两种东西:

  • 各种更为精简的 cri-runtime;
  • 各种“强隔离”容器方案。

containerd 和 CRI-O 

读者们在第一节就看到现在的 Runtime 实在是有点复杂,后来人们就有了直接拿 containerd 做 oci-runtime 的方案。当然,除了 Kubernetes 之外,containerd 还要接诸如 Swarm 等调度系统,因此它不会去直接实现 CRI。这个适配工作就要交给一个 shim 了。 

在 containerd v1.0 中,对 CRI 的适配通过一个单独的进程CRI-containerd来完成: 


containerd v1.1 中做的又更漂亮一点,砍掉 CRI-containerd 进程,直接把适配逻辑作为插件放进 containerd 主进程中: 


但在 containerd 做这些事情之前,社区就已经有了一个更为专注的 cri-runtime: CRI-O。它非常纯粹,可以兼容 CRI 和 OCI,做一个 Kubernetes 专用的运行时:


 其中conmon就对应 containerd-shim,大体意图是一样的。CRI-O 和 containerd(直接调用)的方案比起默认的 dockershim 简洁很多,但没什么生产环境的验证案例。本人所知道的仅仅是 containerd 在 GKE 上是 beta 状态。因此假如你对 Docker 没有特殊的政治恨意,大可不必把 dockershim 这套换掉。 

强隔离容器:Kata、gVisor、firecracker 

一直以来 Kubernetes 都有一个被诟病的点:难以实现真正的多租户。为什么这么说呢?读者们先考虑一下什么样是理想的多租户状态: 

 理想来说,平台的各个租户(tenant)之间应该无法感受到彼此的存在,表现得就像每个租户独占整个平台一样。具体来说就是,我不能看到其它租户的资源,我的资源跑满了,也不能影响其它租户的资源使用。我无法从网络或内核上攻击其它租户。 

 Kubernetes 当然做不到,其中最大的两个原因是:

  • kube-apiserver 是整个集群中的单例,并且没有多租户概念;
  • 默认的 oci-runtime 是 runc,而 runc 启动的容器是共享内核的。

 一个典型的解决方案就是提供一个新的 OCI 实现,用 VM 来跑容器,实现内核上的硬隔离。runV 和 Clear Containers 都是这个思路。因为这两个项目做得事情是很类似,后来就合并成了一个项目 Kata Container。Kata 的一张图很好地解释了基于虚拟机的容器与基于 namespaces 和 Cgroups 的容器间的区别: 


当然,没有系统是完全安全的。假如 hypervisor 存在漏洞,那么用户仍有可能攻破隔离。但所有的事情都要对比而言,在共享内核的情况下,暴露的攻击面是非常大的,做安全隔离的难度就像在美利坚和墨西哥之间修 The Great Wall。而当内核隔离之后,只要守住 hypervisor 这道关子就后顾无虞了。

 一个 VM 中跑一个容器,听上去隔离性很不错,但不是说虚拟机又笨重又不好管理才切换到容器的吗,怎么又要走回去了? 

Kata 告诉你,虚拟机没那么邪恶,只是以前没玩好: 

  • 不好管理是因为没有遵循“不可变基础设施”,以前大家都在虚拟机上疯狂的试探。这台装 Java 8,那台装 Java 6,Admin 是要 angry 的。现在,Kata 则支持 OCI 镜像,完全可以用上 Dockerfile + 镜像,让不好管理成为了过去时;
  • 笨重是因为之前要虚拟化整个系统。现在我们只着眼于虚拟化应用,那就可以裁剪掉很多功能,把 VM 做得很轻量。因此即便用虚拟机来做容器,Kata 还是可以将容器启动时间压缩得非常短,启动后在内存上和 IO 上的 overhead 也尽可能去优化。

不过话说回来,Kubernetes 上的调度单位是 Pod,是容器组,Kata 虚拟机里的一个容器。那同一个 Pod 间的容器应该如何做 namespace 的共享? 

这就要说回前文讲到的 CRI 中,针对 PodSandbox(容器沙箱环境)的操作接口了。本文第一节刻意简化了场景,只考虑创建一个容器,而没有讨论创建一个Pod。大家都知道,真正启动 Pod 里定义的容器之前,Kubelet 会先启动一个 infra 容器,并执行 /pause 让 infra 容器的主进程永远挂起。 

这个容器存在的目的就是维持住整个 Pod 的各种 namespace。真正的业务容器只要加入 infra 容器的 network 等 namespace 就能实现对应 namespace 的共享。而 infra 容器创造的这个共享环境则被抽象为 PodSandbox。每次 Kubelet 在创建 Pod 时,就会先调用 CRI 的RunPodSandbox接口启动一个沙箱环境,再调用CreateContainer在沙箱中创建的容器。 

这里就已经说出答案了,对于 Kata Container 而言,只要在RunPodSandbox调用中创建一个 VM,之后再往 VM 中添加容器就可以了。最后运行 Pod 的样子就是这样的: 

 说完了 Kata,其实 gVisor 和 firecracker 都不言自明了,大体上都是类似的,只是:

  • gVisor 并不会去创建一个完整的 VM,而是实现了一个叫“Sentry”的用户态进程来处理容器的 syscall,而拦截 syscall 并重定向到 Sentry 的过程则由 KVM 或 ptrace 实现;
  • firecracker 称自己为 microVM,即轻量级虚拟机,它本身还是基于 KVM 的。不过 KVM 通常使用 QEMU 来虚拟化除 CPU 和内存外的资源,比如 IO 设备、网络设备。firecracker 则使用 rust 实现了最精简的设备虚拟化,为的就是压榨虚拟化的开销,越轻量越好。

安全容器与 Serverless 

你可能觉得安全容器对自己而言没什么用:大不了在每个产品线上都部署 Kubernetes,机器池都隔离掉,从基础设施的层面就隔离掉。 

这么做当然可以,但同时也要知道,这种做法最终其实是以 IaaS 的方式在卖资源,是做不了真正的 PaaS 乃至 Serverless 的。 

 Serverless 要做到所有的用户容器或函数按需使用计算资源,那必须满足两点:

  • 多租户强隔离:用户的容器或函数都是按需启动按秒计费,我们可不能给每个用户预先分配一坨隔离的资源。因此我们要保证整个 Platform 是多租户强隔离的;
  • 极度轻量:Serverless 的第一个特点是运行时沙箱会更频繁地创建和销毁;第二个特点是切分的粒度会非常非常细,细中细就是 FaaS,一个函数就要一个沙箱。因此就要求两点:
  • 沙箱启动删除必须飞快;
  • 沙箱占用的资源越少越好。

这两点在 long-running,粒度不大的容器运行环境下可能不明显,但在 Serverless 环境下就会急剧被放大。这时候去做 MicroVM 的 ROI 就比以前要高很多。想想 , 用传统的 KVM 去跑 FaaS,那还不得亏到姥姥家了? 

结语

整篇文章的内容非常多,但 rkt、lxc、lxd 都还没涉及。为控制篇幅,这里只提供类比,大家可以自行拓展阅读:rkt 跟 Docker 一样是一个容器引擎,特点是无 daemon,目前这个项目基本不活跃;lxc 是 Docker 最早使用的容器工具集,位置可以类比 runc,提供跟 kernel 打交道的库 & 命令行工具;lxd 则是基于 lxc 的一个容器引擎,只不过大多数容器引擎的目标是容器化应用,lxd 的目标则是容器化操作系统。 



178 comCount 0