0%

【Kubernetes】Container Runtime Interface

众所周知,在 k8s 集群中 Node 上负责启动和管理容器的最底层软件是 Container Runtime,其中使用最为广泛的容器运行时是 Docker。但是,k8s 支持的容器运行时不止于 Docker,还支持像 rkt、containerd、kata 等容器运行时。为了提高系统设计的可扩展性和代码的可维护性,k8s 把 kubelet 对容器的操作统一地抽象成一个接口,在 1.5 版本 引入了 CRI 。CRI 作为在 Kubelet 和 容器运行时之间的一个中间抽象层,Kubelet 只需要和这个接口打交道,而容器只需要提供一个该接口的实现,给 Kubelet 暴露出 gRPC 服务即可。

CRI 架构

CRI Shim

CRI 机制要求 Container Runtime 实现一个 CRI gRPC Server, 该 gRPC Server 需要监听本地的 Unix socket,而 Kubelet 则作为 gRPC Client 运行。这即是 CRI Shim,扮演的是kubelet和容器项目之间的垫片,它的作用是实现CRI规定的每个接口,然后把具体的CRI请求翻译成对后端容器项目的请求或操作 。在当前 k8s 1.20 版本,除了 dockershim 还包含在 kubelet 代码中之外,其他容器运行时的 CRI shim 都需要自己实现并部署在宿主机上。在不久的将来,dockershim 也会被移出

以 Containerd 为例,在 1.0 及以前版本将 dockershim 和 docker daemon 替换为 cri-containerd + containerd,而在 1.1 版本直接将 cri-containerd 内置在 Containerd 中,简化为一个 CRI 插件。

Containerd 内置的 CRI 插件实现了 Kubelet CRI 接口中的 Image ServiceRuntime Service,通过内部接口管理容器和镜像,并通过 CNI 插件给 Pod 配置网络。

根据下面这张图我们再来回顾下 Pod 创建的过程,在调度器将 Pod 调度到某个具体的 Node 之后:

  • kubelet 通过 SyncLoop 来判断需要执行的具体操作,如创建一个Pod,那么kubelet调用 GenericRuntime 的通用组件来发起创建 Pod 的CRI请求
  • 如果使用的是Docker项目,负责响应这个请求的是 dockershim 组件,它把CRI请求里的内容拿出来,组装成 Dcoker API 请求发送给Docker Daemon

基于以上设计,SyncLoop 本身就要求这个控制循环是绝对不可以被阻塞的,所以凡是在 kubelet 里有可能会消耗大量时间的操作,如准备 Pod 的Volume,拉取镜像等,SyncLoop 都会开启单独的 Goroutine 来进行操作。

如前所述,CRI接口可以分为两组,下面将分别介绍这两部分的接口:

  • ImageService:主要是容器镜像相关的操作,比如拉取镜像、删除镜像等。
  • RuntimeService:主要是跟容器相关的操作,比如创建、启动、删除Container、Exec等。

RuntimeService

CRI设计的一个重要原则,就是确保这个接口只注容器不关注 Pod,原因是:

  1. Pod 是 kubernetes 的编排概念,而不是容器运行时的概念,所以不能假设所有下层容器项目,都能够暴露出可以直接映射为 Pod 的API。
  2. 如果 CRI 中引入了关于 Pod 的概念,那么接下来只要 Pod API 对象的字段发生编号,那么 CRI 就可能需要跟着变更。早期 k8s 开发中,Pod对象的变化比较频繁,对于CRI这样的标准接口来说,这样的变更率有点麻烦。
1
2
3
4
5
6
7
8
9
10
11
type RuntimeService interface {
RuntimeVersioner
ContainerManager
PodSandboxManager
ContainerStatsManager

// UpdateRuntimeConfig updates runtime configuration if specified
UpdateRuntimeConfig(runtimeConfig *runtimeapi.RuntimeConfig) error
// Status returns the status of the runtime.
Status() (*runtimeapi.RuntimeStatus, error)
}

容器生命周期

在CRI中有 RunPodSandbox 的接口,其中的 PodSandbox 它并不是 k8s 里 Pod 的API对象,只是抽取了Pod中一部分与容器运行时有关的字段,如HostNameDnsConfigCgroupParent等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type PodSandboxManager interface {
// RunPodSandbox creates and starts a pod-level sandbox. Runtimes should ensure
// the sandbox is in ready state.
RunPodSandbox(config *runtimeapi.PodSandboxConfig, runtimeHandler string) (string, error)
// StopPodSandbox stops the sandbox. If there are any running containers in the
// sandbox, they should be force terminated.
StopPodSandbox(podSandboxID string) error
// RemovePodSandbox removes the sandbox. If there are running containers in the
// sandbox, they should be forcibly removed.
RemovePodSandbox(podSandboxID string) error
// PodSandboxStatus returns the Status of the PodSandbox.
PodSandboxStatus(podSandboxID string) (*runtimeapi.PodSandboxStatus, error)
// ListPodSandbox returns a list of Sandbox.
ListPodSandbox(filter *runtimeapi.PodSandboxFilter) ([]*runtimeapi.PodSandbox, error)
// PortForward prepares a streaming endpoint to forward ports from a PodSandbox, and returns the address.
PortForward(*runtimeapi.PortForwardRequest) (*runtimeapi.PortForwardResponse, error)
}

PodSandbox 这个接口其实是kubernetes将Pod这个概念映射到容器运行时层面所需要的字段,是一个Pod对象的子集。作为容器项目,需要自己决定如何使用这些字段来实现 k8s 期望的Pod模型,它的原理如下图所示:

  • 比如执行kubectl run创建一个包含 A 和 B 两个容器的叫作 foo 的Pod之后,这个Pod的信息最后来到 kubelet,kubelet会按照图中所示的顺序调用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容器加入到网络中的操作。

  • kubelet继续调用 CreateContainerStartContainer 接口来创建和启动容器A、B
    • 对应到dockershim里,就直接启动A、B两个Docker容器。最后宿主机上出现三个Docker容器组成的这个Pod
    • 如果是 Kata Container,CreateContainerStartContainer 接口的实现就之后在创建的轻量级虚拟机中创建A、B容器对应的Mount Namespace,最后在宿主机上,只会用一个叫作foo的轻量级虚拟机在运行。

Streaming API

除了上述对容器生命周期的实现之外,CRI shim的另一个重要工作就是实现 execlogs 等接口,这些接口与前面的操作有一个很大的不同,这些gRPC接口调用期间,kubelet 需要跟容器项目维护一个长连接来传输数据,这种API称为Streaming API。

1
2
3
4
5
6
7
8
9
10
11
12
13
type ContainerManager interface {
CreateContainer(podSandboxID string, config *runtimeapi.ContainerConfig, sandboxConfig *runtimeapi.PodSandboxConfig) (string, error)
StartContainer(containerID string) error
StopContainer(containerID string, timeout int64) error
RemoveContainer(containerID string) error
ListContainers(filter *runtimeapi.ContainerFilter) ([]*runtimeapi.Container, error)
ContainerStatus(containerID string) (*runtimeapi.ContainerStatus, error)
UpdateContainerResources(containerID string, resources *runtimeapi.LinuxContainerResources) error
ExecSync(containerID string, cmd []string, timeout time.Duration) (stdout []byte, stderr []byte, err error)
Exec(*runtimeapi.ExecRequest) (*runtimeapi.ExecResponse, error)
Attach(req *runtimeapi.AttachRequest) (*runtimeapi.AttachResponse, error)
ReopenContainerLog(ContainerID string) error
}

CRI shim 中对 Streaming API的实现,依赖于一套独立的 Streaming Server 机制,如下图所示:

对一个容器执行kubectl exec 命令的时候:

  1. 这个请求首先被交给 APIServer
  2. APIServer 会调用 kubelet 的Exec API
  3. kubelet 调用 CRI 的Exec接口
  4. CRI shim 负责响应 kubelet 的这个调用请求,它不会直接去调用后端的容器项目来进行处理,只会返回一个URL(这个URL是该CRI shim对应的Streaming Server的地址和端口)给 kubelet
  5. kubelet拿到这个URL后,以 Redirect 的方式返回给 APIServer
  6. APIServer通过重定向来向 Streaming Server 发起真正的/exec请求,与它建立长连接

此处的 Streaming Server只需要通过使用 SIG-Node 维护的 Streaming API库来实现,Streaming Server会在 CRI shim 启动时一起启动,一起启动的这一部分如何实现,由 CRI shim 自行决定,如 Docker 的dockershim就直接调用Docker的Exec API来作为实现。

ImageService

1
2
3
4
5
6
7
type ImageManagerService interface {
ListImages(filter *runtimeapi.ImageFilter) ([]*runtimeapi.Image, error)
ImageStatus(image *runtimeapi.ImageSpec) (*runtimeapi.Image, error)
PullImage(image *runtimeapi.ImageSpec, auth *runtimeapi.AuthConfig, podSandboxConfig *runtimeapi.PodSandboxConfig) (string, error)
RemoveImage(image *runtimeapi.ImageSpec) error
ImageFsInfo() ([]*runtimeapi.FilesystemUsage, error)
}

容器运行时

CRI 容器运行时 维护者 主要特性 容器引擎
Dockershim Kubernetes 内置实现、特性最新 docker
cri-o Kubernetes OCI标准不需要Docker OCI(runc、kata、gVisor…)
cri-containerd Containerd 基于 containerd 不需要Docker OCI(runc、kata、gVisor…)
Frakti Kubernetes 虚拟化容器 hyperd、docker
rktlet Kubernetes 支持rkt rkt
PouchContainer Alibaba 富容器 OCI(runc、kata…)
Virtlet Mirantis 虚拟机和QCOW2镜像 Libvirt(KVM)

目前基于 CRI 容器引擎已经比较丰富了,包括

  • Docker: 核心代码依然保留在 kubelet 内部(pkg/kubelet/dockershim),是最稳定和特性支持最好的运行时
  • OCI 容器运行时:
    • 社区有两个实现
    • 支持的 OCI 容器引擎包括
      • runc:OCI 标准容器引擎
      • gVisor:谷歌开源的基于用户空间内核的沙箱容器引擎
      • Clear Containers:Intel 开源的基于虚拟化的容器引擎
      • Kata Containers:基于虚拟化的容器引擎,由 Clear Containers 和 runV 合并而来
  • PouchContainer:阿里巴巴开源的胖容器引擎
  • Frakti:支持 Kubernetes v1.6+,提供基于 hypervisor 和 docker 的混合运行时,适用于运行非可信应用,如多租户和 NFV 等场景
  • Rktlet:支持 rkt 容器引擎(rknetes 代码已在 v1.10 中弃用)
  • Virtlet:Mirantis 开源的虚拟机容器引擎,直接管理 libvirt 虚拟机,镜像须是 qcow2 格式
  • Infranetes:直接管理 IaaS 平台虚拟机,如 GCE、AWS 等

RuntimeClass

RuntimeClass 是 v1.12 引入的新 API 对象,用来支持多容器运行时,比如

  • Kata Containers/gVisor + runc
  • Windows Process isolation + Hyper-V isolation containers

RuntimeClass 表示一个运行时对象,在使用前需要开启特性开关 RuntimeClass,并创建 RuntimeClass CRD:

1
kubectl apply -f https://github.com/kubernetes/kubernetes/tree/master/cluster/addons/runtimeclass/runtimeclass_crd.yaml

然后就可以定义 RuntimeClass 对象

1
2
3
4
5
6
7
apiVersion: node.k8s.io/v1alpha1  # RuntimeClass is defined in the node.k8s.io API group
kind: RuntimeClass
metadata:
name: myclass # The name the RuntimeClass will be referenced by
# RuntimeClass is a non-namespaced resource
spec:
runtimeHandler: myconfiguration # The name of the corresponding CRI configuration

而在 Pod 中定义使用哪个 RuntimeClass:

1
2
3
4
5
6
7
apiVersion: v1
kind: Pod
metadata:
name: mypod
spec:
runtimeClassName: myclass
# ...

参考资料