Kubernetes CRI容器引擎

Kubernetes 节点的底层由一个叫做容器运行时的软件进行支撑,它主要负责启停容器。

Docker 是目前最广为人知的容器运行时软件,但是它并非唯一。在这几年中,容器运行时这个领域发展的迅速。为了使得 Kubernetes 的扩展变得更加容易,一直在打磨支持容器运行时的 K8S插件 API,也就是 容器运行时接口 ( Container Runtime Interface, CRI) 。

k8s架构

这里通过分析 k8s 目前默认的一种容器运行时架构,来帮助我们更好的理解 k8s 运行时的背后逻辑,同时引出 CRI 和 OCI 提出的背景。

我们在创建 k8s 集群的时候,首先需要搭建 master 节点,其次需要创建 node 节点,并将 node 节点加入到 k8s 集群中。当我们构建好 k8s 集群后,可以通过 下面命令来创建应用对应的pod

kubectl create -f nginx.yml

这时候,master 组件中的 Controller Manager 会通过控制循环的方式来做编排工作,创建应用所需的Pod。同时 Scheduler 会 watch etcd 中新 pod 的变化

如果 Scheduler 发现有一个新的 pod 出现,它会运行调度算法,然后选择出最佳的 Node 节点,并将这个节点的名字写到 pod 对象的 NodeName 字段上,这一步就是所谓的 Bind Pod to Node,然后把 bind 的结果写到 etcd

其次,当我们在构建 k8s 集群的时候,默认每个节点都会初始化创建一个 kubectl 进程,kubectl 进程会 watch etcd 中 pod 的变化,当 kubectl 进程监听到 pod 的 bind 的更新操作,并且 bind 的节点是本节点时,它会接管接下来的所有事情,如镜像下载,创建容器等。

Kubernetes CRI容器引擎
Kubernetes CRI容器引擎

k8s默认容器运行时架构

接下来将通过 k8s 默认集成的容器运行时架构,来看 kubernetes 如何创建一个容器

Kubernetes CRI容器引擎

kubernetes 通过 CRI (Container Runtime Interface) 接口调用 dockershim,请求创建一个容器。这一步中,Kubectl 可以视作一个简单的 CRI Client,而 docker shim 就是接收的 Server

dockershim 收到请求后,通过适配的方式,适配成 Docker Daemon 的请求格式,发到 Docker Daemon 上请求创建一个容器。在 docker 1.12 后的版本,docker daemon 被拆分成了 dockerd 和 containerd,其中,containerd 负责操作容器。

dockerd 收到请求后,会调用 containerd 进程去创建一个容器

containerd 收到请求后,并不会自己直接去操作容器,而是创建一个叫做 containerd-shim 的进程,让 containerd-shim 去操作容器,创建 containerd-shim 的目的主要有以下几个

  • 让 containerd-shim 做诸如收集状态,维持 stdin 等 fd 打开等工作。
  • 允许容器运行时( runC ) 启动容器后退出,不必为每个容器一直运行一个容器运行时的 runC
  • 即使在 containerd 和 dockerd 都挂掉的情况下,容器的标准 IO 和其它的文件描述符也是可以用的
  • 向 containerd 报告容器的退出状态
  • 在不中断容器运行时的情况下,升级或重启 dockerd

而 containerd-shim 在这一步需要调用 runC 这个命令行工具,来启动容器,runC 是 OCI (Open Container Initiative, 开放标准协议) 的一个参考实现。主要用来设置 namespaces 和 cgroups,挂载 root filesystem等操作。

runC 启动完容器后,本身会直接退出。containerd-shim 则会成为容器进程的父进程,负责收集容器进程的状态,上报给 containerd,并在容器中的 pid 为 1 的进程退出后接管容器中的子进程进行清理,确保不会出现僵尸进程 (关闭进程描述符)。

容器与容器编排背景

从 k8s 的容器运行时可以看出,kubectl 启动容器的过程经过了很长的一段调用链路。这个是由于在容器及编排领域各大厂商与 docker 之间的竞争以及 docker 公司为了抢占 Pass ( Platform-as-a-service,平台服务) 领域市场,对架构做出的一系列调整。

其实 k8s 最开始的运行时架构链路调用没有这么复杂:kubelet想要创建容器直接通过 docker api 调用 Docker Daemon , 然后Docker Daemon 调用 libcontainer 这个库来启动容器。

后面为了防止 docker 垄断以及受控 docker 运行时,各大厂商于是就联合起来,制订出 开放容器标准OCI ( Open Containers Initiative ) 。大家可以基于这个标准开发自己的容器运行时。Docker公司则把 libcontainer 做了一层封装,变成 runC 捐献给 CNCF 作为 OCI 的参考实现。

接下来就是 Docker 要搞 Swarm 进军 PaaS 市场,于是做了个架构切分,把容器操作都移动到一个单独的 Daemon 进程的 containerd 中去,让 Docker Daemon专门负责上层封装编排,但是最终 Swarm 败给了 K8S ,于是Docker公司就把 Containerd 捐给了 CNCF,专注于搞 Docker 企业版了。

与此同时,容器领域 core os 公司推出了 rkt 容器运行时,希望 k8s 原生支持 rkt 作为运行时,由于 core os 与 Google 的关系,最终 rkt 运行时的支持在 2016 年也被合并进 kubelet 主干代码里,这样做反而给 k8s 中负责维护 kubelet 的小组 SIG-Node 带来了更大的负担,每一次 kubectl 的更新都要维护 docker 和 rkt 作为两部分代码。与此同时,随着虚拟化技术强隔离容器技术 runV (Kata Containers 前身, 后与 intel clear container 合并)的逐渐成熟。K8S 上游对虚拟化容器的支持很快被提上日程。为了从集成每一种运行时都要维护的一份代码中解救出来,K8S SIG-Node 工作组决定对容器的操作统一地抽象成一个接口,这样 kubelet 只需要跟这个接口打交道,而具体地容器运行时,他们只需要实现该接口,并对kubelet暴露 gRPC 服务即可。这个统一地抽象的接口就是 k8s 中俗称的 CRI

什么是CRI

CRI是一组接口规范,这一插件规范让Kubernetes无须重新编译就可以使用更多的容器运行时。CRI包含Protocol Buffers、gRPC API及运行库支持,还有尚在开发的标准规范和工具。CRI在Kubernetes 1.5中发布了Alpha版本。

CRI概览

Kubelet通过gRPC框架与CRI shim进行通信,CRI shim通过Unix Socket启动一个gRPC server提供容器运行时服务,Kubelet作为gRPC client,通过Unix Socket与CRI shim通信。gRPC server使用protocol buffers提供两类gRPC service:ImageServiceRuntimeService。ImageService提供从镜像仓库拉取镜像、删除镜像、查询镜像信息的RPC调用功能。RuntimeService提供容器的相关生命周期管理(容器创建、修改、销毁等)及容器的交互操作(exec/attach/port-forward),其结构如图所示。

Kubernetes CRI容器引擎

接口与实现

CRI最核心的两个概念就是PodSandboxcontainerPod由一组应用容器组成,这些应用容器共享相同的环境与资源约束,这个共同的环境与资源约束被称为PodSandbox。由于不同的容器运行时对PodSandbox的实现不一样,因此CRI留有一组接口给不同的容器运行时自主发挥,例如Hypervisor将PodSandbox实现成一个虚拟机,Docker则将PodSandbox实现成一个Linux命名空间

RuntimeService

Kubelet在创建一个Pod前首先需要调用RuntimeService。RuntimeService.RunPodSandbox为Pod创建环境,这个过程包含初始化Pod网络、分配IP、激活沙箱等。PodSandbox被激活之后,Kubelet会调用CreateContainer、StartContainer、StopContainer、RemoveContainer对容器进行创建、启动、停止、删除等操作。当Pod被删除时,会调用StopPodSandbox、RemovePodSandbox 来销毁Pod。Kubelet的职责在于Pod的生命周期的管理,包含健康监测重启策略控制,并且实现容器生命周期管理中的各种钩子

RuntimeService服务包括对Sandbox和Container操作的方法,下面的伪代码展示了主要的RPC方法:

Kubernetes CRI容器引擎

RuntimeService还定义了与Pod中容器进行交互的接口(kubectl exec/attach/port-forward)。目前,Kubelet使用容器本地方法或使用Node上的工具(nsenter/socat)两种方法来与容器命名空间进行交互。因为多数工具假设Pod利用Linux Namespace做了隔离,因此使用Node上的工具并不是一个可移植的方案。在CRI中,显式地定义了这些调用,让运行时可以做特定实现。下面的伪代码显示了Exec、Attach、PortForward这几个调用需要实现的RuntimeService方法:

Kubernetes CRI容器引擎
// 解释下上述代码
service RuntimeService {
    ......
    // ExecSync在容器中同步执行一个命令。
    rpc ExecSync(ExecSyncRequest) returns (ExecSyncResponse) {}
    // Exec在容器中执行命令
    rpc Exec(ExecRequest) returns (ExecResponse) {}
    // Attach附着在容器上
    rpc Attach(AttachRequest) returns (AttachResponse) {}
    // PortForward从Pod沙箱中进行端口转发
    rpc PortForward(PortForwardRequest) returns (PortForwardResponse) {}
    ......
}

ImageService

为了启动一个容器,CRI还需要执行镜像相关的操作,比如镜像的拉取、查看、移除等操作,因此CRI也定义了一组ImageService接口。但是容器的运行不需要镜像构建操作,所以CRI接口并不包含buildImage相关操作,镜像的构建需要使用外部工具如Docker来完成。

Kubernetes CRI容器引擎

LogService

LogService定义了容器的stdout/stderr应该如何被CRI处理的相关规范。非stdout/stderr的日志处理不在CRI处理范围之内。CRI的日志处理需要解决如下3个问题。

  • 为CRI兼容的运行时提供相关方法来支持现存的日志特性,如支持kubectl logs及docker log drivers等。
  • 允许Kubelet管理容器日志的生命周期,从而实现一个更优的磁盘管理策略。这就需要容器的生命周期和容器的日志之间进行解耦。
  • 不论用户使用什么样的容器运行时,允许日志收集器更容易地与Kubelet集成,同时保证存取的高效性。

RI通过如下2个方面来满足这些需求。

强制指定日志应该存放在本地文件系统的某个位置,并且Kubelet和日志收集器能方便地直接访问该日志文件,如/var/log/pods/<podUID>/<containerName>_<instance#>.log,CRI端通过在创建PodSandbox的时候将这个目录指定给容器运行时实现,日志收集器也可以方便地通过Pod的元数据获取该信息。

运行时需要按照Kubelet能够理解的方式输出日志。运行时需要为每条日志添加RFC 3339 Nano时间戳及stream类型,并以一个新行结束。

Kubernetes CRI容器引擎

短期来看,docker-CRI实现只是部分支持以上几个原则,采取的方式一个是为Kubelet创建日志文件的符号链接,另一个是在Kubelet中添加json格式的日志文件处理支持。长期来看,CRI可能会支持一个自定义的日志处理插件或启动一个额外的进程来复制或装饰原始日志。

为什么需要CRI

在CRI存在之前,容器运行时(Docker/rkt)需要通过实现Kubelet里面high-level的接口才能集成进来。这样的集成使得成本很高,需要开发人员了解整个Kubelet的架构,并且会将代码合并到Kubelet主项目里面。更重要的是,这非常不利于Kubelet的扩展性,因为每个新的改动都会不断地增加维护难度。Kubernetes的目标是具有高度的可扩展性,而CRI为方便使用一个可插拔的容器运行时(Docker/rkt)并构建一个健康的Kubernetes生态迈出了一小步。原因总结起来有如下3条。

  • 不是所有的容器运行时都原生地支持Pod概念。为了支持所有这些Pod特性,当这些容器运行时与Kubernetes集成时,需要花费大量的工作时间来实现一个Shim。
  • 高层次的接口让代码共享和重用变得困难。
  • Pod Spec演化速度非常快。Pod经常被添加新的特征,任何Pod级别的特性修改或增加都会导致容器运行时的修改。

为什么CRI是接口且是基于容器的而不是基于Pod的

Kubernetes有Pod级别的资源接口,如果直接对Pod做抽象,CRI就可以自己实现容器的控制逻辑和状态转换,这样就可以极大地简化API,让CRI能适应更多的容器运行时,但是Kubernetes团队最终放弃了这个想法,原因有如下2点。

  • 首先,Kubelet 有很多Pod级别的功能和机制(例如循环崩溃的处理),交给容器运行时实现的话,会造成很重的负担。
  • 其次,更重要的是,Pod 标准还在高速前进。很多新功能(例如容器初始化)是由Kubelet直接管理容器的,而无须容器运行时进行变更。

CRI选择了围绕容器进行实现,这样容器运行时能够共享这些通用特性,获得更好的开发进度。这并不意味着设计哲学的改变——kubelet要负责、保证容器应用的实际状态和声明状态的一致性。

如何使用CRI

可以通过Kubelet参数来开启

Docker CRI实现设置Kubelet flag。--experimental-cri=true--container-runtime=dockerKubelet会启动一个Local Unix Socket的gRPC CRI server进行PodSandBox、Pod、container的生命周期管理。

cri-o CRI实现在节点上启动runtime service,如cri-o,请查看github.com/kubernetes-incubator/cri-o。设置Kubelet flag。

Kubernetes CRI容器引擎
Kubernetes CRI容器引擎

CRI的目标

定义CRI的目的在于以下3点。

  • 提升Kubernetes的可扩展性,让更多的容器运行时更容易集成到Kubernetes中。
  • 提升Pod Feature的更新迭代效率。
  • 构建易于维护的代码体系。

RI不会做以下5件事。

  • 建议如何与新的容器运行时集成,例如决定Container Shim应该在哪里实现。
  • 提供新接口的版本管理。
  • 提供Windows container 支持。本接口不会在Windows container支持方面花费太多,但是CRI会尽量做到更加易于扩展来让Windows container特性更容易被添加进来。
  • 重新定义Kubelet的内部运行时相关接口。尽管会增加Kubelet的可维护性,但这不是CRI的工作。
  • 提升Kubelet的效率和性能。

附:CRI接口定义完整版

CRI (容器运行时接口)基于 gRPC 定义了 RuntimeService 和 ImageService 等两个 gRPC 服务

具体容器运行时则需要实现 CRI 定义的接口(即 gRPC Server,通常称为 CRI shim)。容器运行时在启动 gRPC server 时需要监听在本地的 Unix Socket (Windows 使用 tcp 格式)。

// Runtime service defines the public APIs for remote container runtimes
service RuntimeService {
    // Version returns the runtime name, runtime version, and runtime API version.
    rpc Version(VersionRequest) returns (VersionResponse) {}
 
    // RunPodSandbox creates and starts a pod-level sandbox. Runtimes must ensure
    // the sandbox is in the ready state on success.
    rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse) {}
    // StopPodSandbox stops any running process that is part of the sandbox and
    // reclaims network resources (e.g., IP addresses) allocated to the sandbox.
    // If there are any running containers in the sandbox, they must be forcibly
    // terminated.
    // This call is idempotent, and must not return an error if all relevant
    // resources have already been reclaimed. kubelet will call StopPodSandbox
    // at least once before calling RemovePodSandbox. It will also attempt to
    // reclaim resources eagerly, as soon as a sandbox is not needed. Hence,
    // multiple StopPodSandbox calls are expected.
    rpc StopPodSandbox(StopPodSandboxRequest) returns (StopPodSandboxResponse) {}
    // RemovePodSandbox removes the sandbox. If there are any running containers
    // in the sandbox, they must be forcibly terminated and removed.
    // This call is idempotent, and must not return an error if the sandbox has
    // already been removed.
    rpc RemovePodSandbox(RemovePodSandboxRequest) returns (RemovePodSandboxResponse) {}
    // PodSandboxStatus returns the status of the PodSandbox. If the PodSandbox is not
    // present, returns an error.
    rpc PodSandboxStatus(PodSandboxStatusRequest) returns (PodSandboxStatusResponse) {}
    // ListPodSandbox returns a list of PodSandboxes.
    rpc ListPodSandbox(ListPodSandboxRequest) returns (ListPodSandboxResponse) {}
 
    // CreateContainer creates a new container in specified PodSandbox
    rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse) {}
    // StartContainer starts the container.
    rpc StartContainer(StartContainerRequest) returns (StartContainerResponse) {}
    // StopContainer stops a running container with a grace period (i.e., timeout).
    // This call is idempotent, and must not return an error if the container has
    // already been stopped.
    // TODO: what must the runtime do after the grace period is reached?
    rpc StopContainer(StopContainerRequest) returns (StopContainerResponse) {}
    // RemoveContainer removes the container. If the container is running, the
    // container must be forcibly removed.
    // This call is idempotent, and must not return an error if the container has
    // already been removed.
    rpc RemoveContainer(RemoveContainerRequest) returns (RemoveContainerResponse) {}
    // ListContainers lists all containers by filters.
    rpc ListContainers(ListContainersRequest) returns (ListContainersResponse) {}
    // ContainerStatus returns status of the container. If the container is not
    // present, returns an error.
    rpc ContainerStatus(ContainerStatusRequest) returns (ContainerStatusResponse) {}
    // UpdateContainerResources updates ContainerConfig of the container.
    rpc UpdateContainerResources(UpdateContainerResourcesRequest) returns (UpdateContainerResourcesResponse) {}
    // ReopenContainerLog asks runtime to reopen the stdout/stderr log file
    // for the container. This is often called after the log file has been
    // rotated. If the container is not running, container runtime can choose
    // to either create a new log file and return nil, or return an error.
    // Once it returns error, new container log file MUST NOT be created.
    rpc ReopenContainerLog(ReopenContainerLogRequest) returns (ReopenContainerLogResponse) {}
 
    // ExecSync runs a command in a container synchronously.
    rpc ExecSync(ExecSyncRequest) returns (ExecSyncResponse) {}
    // Exec prepares a streaming endpoint to execute a command in the container.
    rpc Exec(ExecRequest) returns (ExecResponse) {}
    // Attach prepares a streaming endpoint to attach to a running container.
    rpc Attach(AttachRequest) returns (AttachResponse) {}
    // PortForward prepares a streaming endpoint to forward ports from a PodSandbox.
    rpc PortForward(PortForwardRequest) returns (PortForwardResponse) {}
 
    // ContainerStats returns stats of the container. If the container does not
    // exist, the call returns an error.
    rpc ContainerStats(ContainerStatsRequest) returns (ContainerStatsResponse) {}
    // ListContainerStats returns stats of all running containers.
    rpc ListContainerStats(ListContainerStatsRequest) returns (ListContainerStatsResponse) {}
 
    // UpdateRuntimeConfig updates the runtime configuration based on the given request.
    rpc UpdateRuntimeConfig(UpdateRuntimeConfigRequest) returns (UpdateRuntimeConfigResponse) {}
 
    // Status returns the status of the runtime.
    rpc Status(StatusRequest) returns (StatusResponse) {}
}
 
// ImageService defines the public APIs for managing images.
service ImageService {
    // ListImages lists existing images.
    rpc ListImages(ListImagesRequest) returns (ListImagesResponse) {}
    // ImageStatus returns the status of the image. If the image is not
    // present, returns a response with ImageStatusResponse.Image set to
    // nil.
    rpc ImageStatus(ImageStatusRequest) returns (ImageStatusResponse) {}
    // PullImage pulls an image with authentication config.
    rpc PullImage(PullImageRequest) returns (PullImageResponse) {}
    // RemoveImage removes the image.
    // This call is idempotent, and must not return an error if the image has
    // already been removed.
    rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse) {}
    // ImageFSInfo returns information of the filesystem that is used to store images.
    rpc ImageFsInfo(ImageFsInfoRequest) returns (ImageFsInfoResponse) {}
}

尝试使用新的Docker-CRI来创建容器

要尝试新的Kubelet-CRI-Docker集成,只需为kubelet启动参数加上--enable-cri=true开关来启动CRI。这个选项从Kubernetes 1.6开始已经作为kubelet的默认选项了。如果不希望使用CRI,则可以设置--enable-cri=false来关闭这个功能。

查看kubelet的日志,可以看到启用CRI和创建gRPC Server的日志

10603 15:08:28.953332 3442 container_manager_linux.go:250] Creating 
Container Manager object based on Node Config: {RuntimeCgroupsName:SystemCgroupsName: 
KubeletCgroupsName:ContainerRuntime :docker CgroupsPerQOS:true CgroupRoot:/
CgroupDriver:cgroupfs ProtectKernelDefaults:false EnableCRI :true
NodeAllocatableConfig: {KubeReservedCgroupName: SystemReservedCgroupName:
EnforceNodeAllocatable:map[pods:{}] KubeReserved:map[] SystemReserved:map[]
HardEvictionThresholds:[{Signal :memory.available Operator:LessThan
Value:{Quantity:100Mi Percentage:0} GracePeriod:0s MinReclaim:<niI>}]}
ExperimentalQ0SReserved:map[]}
...
10603 15:08:29.060283 3442 kuhelet.go:573] Starting the GRPC server for the docker CRI shim.

创建一个Deployment:

kubectl run nginx --image=nginx

查看Pod的详细信息,可以看到将会创建沙箱(Sandbox)的Event:

Kubernetes CRI容器引擎

CRI的进展

目前已经有多款开源CRI项目可用于Kubernetes:Docker、CRI-O、
Containerd、frakti(基于Hypervisor的容器运行时),各CRI运行时的安
装手册可参考官网https://kubernetes.io/docs/setup/cri/的说明。

后续补充:

kubernetes1.24版本部署+containerd运行时(弃用docker)

kubernetes 1.24版本正式弃用docker,开始使用containerd作为容器运行时。

在早期版本中,Kubernetes 提供的兼容性支持一个容器运行时:Docker。 在 Kubernetes 后来的发展历史中,集群运营人员希望采用别的容器运行时。 于是 CRI 被设计出来满足这类灵活性需求 - 而 kubelet 亦开始支持 CRI。 然而,因为 Docker 在 CRI 规范创建之前就已经存在,Kubernetes 就创建了一个适配器组件 dockershim。 dockershim 适配器允许 kubelet 与 Docker 交互,就好像 Docker 是一个 CRI 兼容的运行时一样。

Kubernetes CRI容器引擎

切换到 Containerd 容器运行时可以消除掉中间环节。 所有相同的容器都可由 Containerd 这类容器运行时来运行。 但是现在,由于直接用容器运行时调度容器,它们对 Docker 是不可见的。 因此,你以前用来检查这些容器的 Docker 工具或漂亮的 UI 都不再可用。

至于为什么会弃用docker,而使用containerd了?

  1. containerd属于CNCF【云原生计算基金会】的项目,这样便于社区的管理和维护
  2. 提升了一定的性能
Kubernetes CRI容器引擎

从图中也能看出使用containerd减少了中间docker的调用

详细的改动和迁移方案,可以查看文档:

Kubernetes 1.24 中的移除和弃用
你的集群准备好使用 v1.24 版本了吗?
从 dockershim 迁移

发布者:LJH,转发请注明出处:https://www.ljh.cool/39329.html

(0)
上一篇 2023年12月31日 下午1:33
下一篇 2024年1月3日 上午1:40

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注