解析 Kubernetes 容器运行时
Kubernetes 已经成为容器编排调度领域的事实标准,其优良的架构不仅保证了丰富的容器编排调度功能,同时也提供了各个层次的扩展接口以满足用户的定制化需求。其中,容器运行时作为 Kubernetes 管理和运行容器的关键组件,当然也提供了简便易用的扩展接口,也就是 CRI(Container Runtime Interface)。
本文将介绍 CRI 的由来、演进以及未来展望,主要内容分为四个部分:Kubernetes架构简介、容器运行时接口的基本原理、容器运行时的演进以及未来的展望。
Kubernetes 简介
我们知道,Kubernetes是一个开源的容器集群管理系统,它的发展非常迅速,已经成为最流行和最活跃的容器编排系统。
从架构上来说,Kubernetes 的组件可以分为 Master 和 Node 两部分,其中 Master 是整个集群的大脑,所有的编排、调度、API 访问等都由 Master 来负责。
具体的来说,Master 包括以下几个组件:
- etcd 保存了整个集群的状态。
- kube-apiserver 提供了资源操作的唯一入口,并提供认证、授权、访问控制、API 注册和发现等机制。
- kube-controller-manager 负责维护集群的状态,包括很多资源的控制器,是保证 Kubernetes 声明式 API 工作的大脑。
- kube-scheduler 负责资源的调度,按照预定的调度策略将 Pod 调度到相应的 Node 上;
而 Node 则是负责运行具体的容器,并为容器提供存储、网络等必要的功能:
- kubelet 负责维持容器的生命周期,同时也负责 Volume(CSI)和网络(CNI)的管理;
- Container runtime 负责镜像管理以及 Pod 和容器的真正运行。Kubelet 默认的容器运行时为 Docker;
- kube-proxy 负责为 Service 提供 cluster 内部的服务发现和负载均衡
- Network plugin 也就基于 CNI(Container Network Internface)负责为容器配置网络。
除了这些核心的组件以外,Kubernetes 当然还包含了很多丰富的功能,这些都是通过扩展(Addon)的方式来部署的。比如 kube-dns 和 metrics-server 等,都是以容器的方式部署在集群里面,并提供 API 给其他组件调用。
提示:在 Kubernetes 中,通常你可以听到两种不同类型的扩展 Kubernetes 功能的方式:1) 第一种是扩展(Addon),比如 dashboard、EFK、Prometheus、各种 Operator 等,这些扩展不需要 Kubernetes 提供标准的接口,但是都为 Kubernetes 增加了新的功能特性;2) 还有一种方式则是插件(Plugin),比如 CNI、CRI、CSI、Device Plugin 等,这些都是 Kubernetes 各个核心组件提供了标准的内置接口,而外部插件则是实现这些接口,从而将 Kubernetes 扩展到更多的用例场景中。
Kubelet 架构
刚才提到,Kubelet 负责维持容器的生命周期。除此之外,它也配合 kube-controller-manager 管理容器的存储卷,并配合 CNI 管理容器的网络。下面就是 Kubelet 的简单架构示意图:
你可以发现,Kubelet 也是有很多组件构成,包括
- Kubelet Server 对外提供 API,供 kube-apiserver、metrics-server 等服务调用。比如 kubectl exec 需要通过 Kubelet API /exec/{token} 与容器进行交互。
- Container Manager 管理容器的各种资源,比如 cgroups、QoS、cpuset、device 等。
- Volume Manager 管理容器的存储卷,比如格式化磁盘、挂载到 Node 本地、最后再将挂载路径传给容器。
- Eviction 负责容器的驱逐,比如在资源不足时驱逐优先级低的容器,保证高优先级容器的运行。
- cAdvisor 负责为容器提供 metrics 数据源。
- Metrics 和 stats 提供容器和节点的度量数据,比如 metrics-server 通过 /stats/summary 提取的度量数据是 HPA 自动扩展的依据。
- 再向下就是 Generic Runtime Manager,这是容器运行时的管理者,负责跟 CRI 交互,完成容器和镜像的管理。
- 在 CRI 之下,包括两种容器运行时的实现
- 一个是内置的 dockershim,实现了 docker 容器引擎的支持以及 CNI 网络插件(包括 kubenet)的支持
- 另一个就是外部的容器运行时,用来支持 runc、containerd、gvisor 等外部容器运行时。
Kubelet 通过 CRI 接口跟外部容器运行时交互,而上图右侧就是 CRI 容器运行时的架构。它通常包括以下几个组件:
- CRI Server,这是 CRI gRPC server,监听在 unix socket 上面。在讨论容器运行时的时候,这个 Server 也通常成为 CRI shim(夹在容器引擎和Kubelet之间的一层)。
- Streaming Server,提供 streaming API,用在 Exec、Attach、Port Forward 等流式接口上。
- 容器和镜像的管理,比如拉取镜像、创建和启动容器等。
- CNI 网络插件的支持,用于给容器配置网络。
- 最后是容器引擎(Container Engine)的管理,比如支持 runc 、containerd 或者支持多个容器引擎。
这样,Kubernetes 中的容器运行时按照不同的功能就可以分为三个部分:
- 第一个是 Kubelet 中容器运行时的管理模块 Generic Runtime Manager,它通过 CRI 来管理容器和镜像。
- 第二个是容器运行时接口,是 Kubelet 与外部容器运行时的通信接口。
- 第三个是具体的容器运行时实现,包括 Kubelet 内置的 dockershim 以及外部的容器运行时(如 cri-o、cri-containerd 等)。
容器运行时接口(CRI)
容器运行时接口(CRI),顾名思义,用来在 Kubernetes 扩展容器运行时,从而用户可以选择自己喜欢的容器引擎。
CRI 是基于 gPRC 的,用户不需要关心内部通信逻辑,而只需要实现定义的接口就可以,包括 RuntimeService 和 ImageService: RuntimeService负责管理Pod和容器的生命周期,而ImageService负责镜像的生命周期管理。
除了 gRPC API,CRI 还包括用于实现 streaming server 的库(用于 Exec、Attach、PortForward 等接口)和 CRI Tools。这两个稍后再作详细介绍。
基于 CRI 接口的容器运行时通常称为 CRI shim, 这是一个 gRPC Server,监听在本地的unix socket上;而kubelet作为gRPC的客户端来调用CRI接口。
另外,外部容器运行时需要自己负责管理容器的网络,推荐使用CNI,这样跟Kubernetes的网络模型保持一致。
CRI 的推出为容器社区带来了新的繁荣,cri-o、frakti、cri-containerd 等一些列的容器运行时为不同场景而生:
- cri-containerd——基于 containerd 的容器运行时
- cri-o——基于 OCI 的容器运行时
- frakti——基于虚拟化的容器运行时
而基于这些容器运行时,还可以轻易联结新型的容器引擎,比如可以通过 clear container、gVisor 等新的容器引擎配合 cri-o 或 cri-containerd 等轻易接入 Kubernetes,将 Kubernetes 的应用场景扩展到了传统 IaaS 才能实现的强隔离和多租户场景。
当使用CRI运行时,需要配置kubelet的--container-runtime参数为remote,并设置--container-runtime-endpoint为监听的unix socket位置(Windows上面为 tcp 或 npipe)。
CRI 接口
那么,CRI 接口到底长的什么样呢?
CRI 接口包括 RuntimeService 和 ImageService 两个服务,这两个服务可以在一个 gRPC server 里面实现,当然也可以分开成两个独立服务。目前社区的很多运行时都是将其在一个 gRPC server 里面实现。
管理镜像的 ImageService 提供了 5 个接口,分别是查询镜像列表、拉取镜像到本地、查询镜像状态、删除本地镜像以及查询镜像占用空间等。这些都很容易映射到 docker API 或者 CLI 上面。
而 RuntimeService 则提供了更多的接口,按照功能可以划分为四组:
- PodSandbox 的管理接口:PodSandbox 是对 Kubernete Pod 的抽象,用来给容器提供一个隔离的环境(比如挂载到相同的 cgroup 下面),并提供网络等共享的命名空间。PodSandbox 通常对应到一个 Pause 容器或者一台虚拟机。
- Container 的管理接口:在指定的 PodSandbox 中创建、启动、停止和删除容器。
- Streaming API 接口:包括 Exec、Attach 和 PortForward 等三个和容器进行数据交互的接口,这三个接口返回的是运行时 Streaming Server 的 URL,而不是直接跟容器交互。
- 状态接口,包括查询 API 版本和查询运行时状态。
Streaming API
Streaming API 用于客户端与容器需要交互的场景,所以采用的是流式接口,包括 Exec、PortForward 和 Attach 等。Kubelet 内置的 docker 通过 nsenter、socat 等方法来支持这些特性,但它们不一定适用于其他的运行时。因而,CRI 也显式定义了这些 API,并且要求容器运行时返回一个 streaming server 的 URL 以便 Kubelet 重定向 API Server 发送过来的流式请求。
这样一个完整的 Exec 流程就是
- 客户端 kubectl exec -i -t ...
- kube-apiserver 向 Kubelet 发送流式请求 /exec/
- Kubelet 通过 CRI 接口向 CRI Shim 请求 Exec 的 URL
- CRI Shim 向 Kubelet 返回 Exec URL
- Kubelet 向 kube-apiserver 返回重定向的响应
- kube-apiserver 重定向流式请求到 Exec URL,接着就是 CRI Shim 内部的 Streaming Server 跟 kube-apiserver 进行数据交互,完成 Exec 的请求和响应
在 v1.10 及更早版本中,容器运行时必需返回一个 API Server 可直接访问的 URL(通常跟 Kubelet 使用相同的监听地址);而从 v1.11 开始,Kubelet 新增了 --redirect-container-streaming(默认为 false),默认不再转发而是代理 Streaming 请求,这样运行时可以返回一个 localhost 的 URL。通过 Kubelet 代理的好处是由 Kubelet 处理与 API server 通信之间的请求认证。
实际上,各个运行时 streaming server 的处理框架都是类似的,因而 Kubelet 也提供来一个 steaming server 库,方便容器运行时的开发者引用。
容器运行时演进过程
了解了容器运行时接口的基本原理之后,接下来,我们再来看一下容器运行时的演进过程。
容器运行时的演进可以分为三个阶段:
首先,在 Kubernetes v1.5 之前,Kubelet 内置了 Docker 和 rkt 的支持,并且通过 CNI 网络插件给它们配置容器网络。这个阶段的用户如果需要自定义运行时的功能是比较痛苦的,需要修改 Kubelet 的代码,并且很有可能这些修改无法推到上游社区。这样,还需要维护一个自己的 fork 仓库,维护和升级都非常麻烦。
不同用户实现的容器运行时各有所长,许多用户都希望Kubernetes支持自定义的运行时。于是,从v1.5 开始增加了 CRI 接口,通过容器运行时的抽象层消除了这些障碍,使得无需修改 Kubelet 就可以支持运行多种容器运行时。CRI 接口包括了一组 Protocol Buffer、gRPC API 、用于 streaming 接口的库以及用于调试和验证的一系列工具等。在此阶段,内置的 Docker 实现也逐步迁移到了 CRI 的接口之下。但此时 rkt 还未完全迁移,这是因为 rkt 迁移 CRI 的过程将在独立的 repository 完成,方便其维护和管理。
第三阶段,从 v1.11 开始,Kubelet 内置的 rkt 代码删除,CNI 的实现迁移到 dockershim 之内。这样,除了 docker 之外,其他的容器运行时都通过 CRI 接入。外部的容器运行时除了实现 CRI 接口外,也要负责为容器配置网络。一般推荐使用 CNI,因为这样可以支持社区内的众多网络插件,不过这不是必需的,网络插件只需要满足 Kubernetes 网络的基本假设即可,即 IP-per-Pod、所有 Pod 和 Node 都可以直接通过 IP 相互访问。
CRI 容器运行时
CRI 的推出为容器社区带来了新的繁荣,适用于各种不同场景的运行时也应运而生。比如:
这里也注意区分 CRI 容器运行时与容器引擎的区别:
- CRI 容器运行时是指实现了 Kubelet CRI 接口的运行时,这样就可以无缝集成到 Kubernetes 之中。
- 容器引擎只是负责管理容器镜像和容器运行的一个服务,它也有一个标准是 OCI(Open Container Initiative)。
比如 CNCF 的 Container Runtime Landscape 中包括了一些列的“Container Runtime”,这其中有一些是实现了 CRI 的,比如 cri-o;而更多的则只是一个容器引擎,需要通过 cri-o、cri-containerd 等才可以应用到 Kubernetes 之中。
CRI Tools
CRI Tools 对 Kubernetes 容器运行时和容器应用的排错来说是一个非常有用的工具。它是一个 SIG Node 所属的子项目,可以应用到所有实现了 CRI 接口的容器运行时中。CRI Tools 包括两个组件,crictl 用于排错和调试,而 critest 用于容器运行时实现进行一致性验证。
crictl
先来看 crictl。crictl 提供了一个类似了 docker 命令的命令行工具。在排错或者调试容器应用时,有时候系统管理员需要登录到节点上去查看容器或镜像的状态,以便收集系统和容器应用的信息。这时候,推荐使用 crictl 来完成这些工作,因为 crictl 为所有不同的容器引擎都提供了一个一致的使用体验。
从使用上来说,crictl 的使用非常类似于 docker 命令行工具,比如你可以使用 crictl pods 来查询 PodSandbox 的列表、使用 crictl ps 来查询容器的列表、使用 crictl images 查询镜像列表。
需要注意的是,crictl 的设计目标是排错,而并非 docker 或者 kubectl 等的替代品。比如,由于 CRI 并没有定义镜像构建的接口,crictl 并不提供 docker build 这种构建镜像的功能。但由于 crictl 提供了一个面向 Kubernetes 的接口,相对于 docker 来说,crictl 可以提供一个对容器和 Pod 更清晰的视图。
critest
critest 是一个容器运行时的一致性验证工具,用于验证容器运行时是否符合 Kubelet CRI 的要求。它是 CRI TOOLS 工具的一部分。除了验证测试,critest 还提供了 CRI 接口的性能测试,比如 critest -benchmark。
推荐将 critest 集成到 CRI 容器运行时的 Devops 流程中,保证每个变更都不会破坏 CRI 的基本功能。
另外,还可以选择将 critest 与 Kubernetes Node E2E 的测试结果提交到 Sig-node 的 TestGrid,向社区和用户展示。
未来展望
Docker 运行时拆分
目前 Docker 是内置在 Kubelet 中的一个运行时,也是默认的容器运行时。这样,实际上 Kubelet 就会依赖于 Docker,从而为 Kubelet 本身带来一定的维护负担。
比如,Kubelet 内部有些功能可能只是适用于 Docker 运行时。当 Docker 或者 Docker 依赖的其他组件(比如 containerd、runc)发现严重缺陷时,修复这些缺陷就需要重现编译和发布 Kubelet。
此外,当用户想要在 Docker 运行时中新增功能时,这些新增的功能可能并不容易引入到 Kubelet 中,特别是三个月的发布周期中,新的特性通常不会引入到现有的稳定分支中。从 Docker 运行时的角度来说,新特性的引入通常也会比较缓慢。
所以,拆分 Docker 容器引擎,将其独立出去成为一个 cri-docker 就可以解决上述的所有问题。
由于 Docker 作为默认的容器引擎,在生产环境中已经得到广泛应用,拆分和迁移会应用绝大部分用户,因为具体的迁移方法还需要社区的详细讨论。
强隔离容器引擎
虽然 Kubernetes 提供了基本的多租户功能,可以不同应用放到不同 namespace 中进行隔离,也可以使用 RBAC 控制不同用户对各类资源的访问,但由于 Docker 共享内核的特性,在 Kubernetes 中运行不可信应用时还是有很大的安全隐患。为了消除这个问题,强隔离容器引擎应运而生。
最早的强隔离容器引擎就是 Kata containers 的前身 hyperd 和 clear container,它将 Kubernetes Pod 作为一个虚拟机来运行,这样就可以通过虚拟化的方式对容器应用进行强隔离。虚拟化是整个云计算中 IaaS 的基础,它的安全性已经得到了广泛验证,因而其安全性也就得到了保证。这两个项目目前已经合并成为 Kata containers。
除了 Kata containers 之外,Google 和 AWS 也在推进强隔离的容器引擎,也就是 gVisor 和 Firecraker。
跟 Kata containers 不同,gVisor 并不会去创建一个完整的 VM,而是实现了一个自己的沙箱(文档成为用户态内核),拦截和过滤容器的 syscall,从而达到安全隔离的目的。虽然 gVisor 相对于 VM 来说更轻量化,但拦截过滤也会带来很高的成本,对最终容器应用的性能会造成一定损失。
同样的,Firecraker 基于 KVM 实现了一个轻量级的 VM,称为 microVM。跟 Kata 不同的是,它没有使用 QEMU,而是用 Rust 构建了一套精简的设备模型,从而让每个 microVM 只占用大约 5MB 的内存。
多容器运行时
有了强隔离的容器引擎后,不可避免的就出现了一些新的问题。比如,很多 Kubernetes 自身的服务或者扩展由于需要 HostNetwork 或特权模式,无法运行在强隔离的环境中。所以,多容器运行时也就应运而生了。
这样,就可以使用 runc/docker 等运行特权应用,而使用强隔离容器引擎运行普通应用。比如,典型的组合为:
- runc + kata
- runc + gVisor
- Windows server containers + Hyper-V containers
以前,很多容器运行时都是在 CRI Shim 中支持多个容器引擎,并通过 Annotations 的形式选择。而借助于新的 RuntimeClass 资源,就可以直接通过 Pod Spec 来选择不同的 runtime。