Kubernetes Kubelet CRI 机制解析

CRI机制简介

CRI机制早在2016年的1.5版本就发布出来了,官方在这篇博文中做了介绍,引入CRI的目的是为了让Kubernetes能够对接多种Container Runtime,而不仅限于Docker这一种。我们知道Docker曾经是容器领域的王者,它的出现开启了容器化时代,紧随其后,又出了很多种其他的容器技术,并且随着容器技术的流行,自然而然产生了对容器编排的需求,于是又涌现了一批容器编排的技术,而Kubernetes就是其中的佼佼者,凭借其强大的社区力量和先进的设计理念,很快独领风骚,在众多竞争者中脱颖而出。

早期的Kubernetes仅仅支持Docker这一种容器化技术,根本没有设计什么插件化的机制去支持其他的容器技术,一来可以快速迭代出原型,二来也没有这个必要,当时容器领域基本是Docker的天下,但是随着Docker的各种骚操作,很快容器领域有了其他竞争者,于是让Kubernetes支持其他容器技术的呼声就越来越高,但是对当时的Kubernetes架构来说,如果要支持其他容器技术,就要对Kubernetes的代码做很多ugly的修改,想想当嵌入的容器技术多了的话,维护起来必然很困难,因此一种优雅的插件机制就呼之欲出,很快,社区就设计出了CRI。其实这也是一个项目在走向成熟的路上,必然要经历的阶段,尤其是像Kubernetes这种偏向底层的资源管理的技术,它要管理很多种不同的资源,一定是向着松耦合,可扩展的方向发展,社区只维护核心的代码逻辑,对应的资源厂商以及社区在核心代码树之外,维护各自的插件。

CRI的设计规范可以在这里查看,它使用grpcprotocol buffers技术,将对container runtime的调用转换为远程过程调用,kubelet作为grpc的客户端,container runtime作为grpc的服务端,他们之间走的数据格式为protocol buffers,CRI规定了远程过程调用的接口,container runtime只要相应的实现了这些接口,Kubernetes就可以利用CRI与其进行对接,其架构图如下:

有的container runtime不支持CRI定义的这些接口,比如Docker,一方面它比Kubernetes出现的早,另一方面它也有跟Kubernetes竞争的产品,叫做Docker Swarm,所以它并不打算实现CRI协议,这种情况就可以引入一个中间层,也就是上图中的CRI shim,作为grpc的服务端来实现CRI定义的接口,然后再将其转换成Docker自己的接口协议,向其发起请求,Docker对应的CRI shim叫做dockershim,目前是内置在Kubernetes的代码中,由Kubernetes社区在维护。但是有的container runtime天生就是支持CRI协议的,比如cri-o,从名字上就可以看出它是专门针对Kubernetes的CRI协议来实现的,是一个非常轻量级的container runtime,甚至单独使用都没有意义,它不像Docker一样,可以独立使用,有各种丰富的功能,但是这些功能可能对Kubernetes来说并没有什么用处,cri-o是红帽主导开发的,目前在红帽的OpenShift 4.0版本中,已经替代Docker成为了默认的container runtime,此外支持CRI的还有rkt, frakti, cri-containerd等容器技术。

这里非常有必要提一下去年,也就是2020年,发布的1.20版本,Kubernetes将Docker标为了deprecated,说要在将来的版本中移除对Docker的支持,一石激起千层浪,瞬间引起了国内外广泛的讨论,甚至引起了恐慌,要知道绝大多数运行Kubernetes的,底层都是使用的Docker,要是移除了对Docker的支持,那这些环境该何去何从,未来该如何技术选型,为此Kubernetes社区还专门出了一篇博文来进行解释,大意就是告诉大家不要慌,目前只是打了一个Warning日志进行提示,但是未来一定会移除的,然后告诉大家为什么这么做,以及除了Docker之外,还有哪些选择,但是如果坚持想用Docker的话,还是可以继续使用的,只是得有人去维护dockershim。这件事其实从2016年引入CRI机制时,就注定会发生的,只是时间问题,它成为了一个标志性事件,一方面标志着Kubernetes成为了事实上容器编排领域的王者,可以对曾经容器领域的王者say no,Docker在Kubernetes中不再是特殊的存在,另一方面其实说明了Docker在容器以及容器编排领域逐渐被竞争对手赶超,风光不再,如果它再不支持CRI协议,或者是维护好dockershim,那用Docker的人会越来越少,或者至少应用场景会很受限,更多的可能是用在开发者的桌面环境中,这对Docker来说,其实是一个生死挑战。好了,相比这些,其实我还是对kubelet在哪打出来的那条warning日志更感兴趣一些,以及将来它要移除对Docker的支持的话,会去改动哪些代码,我们还是先来看看那个Warning日志吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
witch containerRuntime {
case kubetypes.DockerContainerRuntime:
klog.Warningf("Using dockershim is deprecated, please consider using a full-fledged CRI implementation")
if err := runDockershim(
kubeCfg,
kubeDeps,
crOptions,
runtimeCgroups,
remoteRuntimeEndpoint,
remoteImageEndpoint,
nonMasqueradeCIDR,
); err != nil {
return err
}
case kubetypes.RemoteContainerRuntime:
// No-op.
break

这条日志,在kubelet服务启动的时候,如果container runtime是docker的话,就会打出来。container runtime就只有两个选项,一个是docker,一个是remote,如果是remote的话,就直接走的是CRI接口协议,但是为了统一行为,如果选择docker的话,也是走的CRI协议,只不过它不能直接跟Docker交互,而是需要通过dockershim来中转一下,dockershim就相当于是grpc的服务端,只不过它是启动在kubelet的一个线程中的。

CRI机制分析

下面我们来分析下CRI这套插件机制是怎么设计的吧,其实整体上,可以分为三层,我们从底层到上层依次来分析下:

CRI客户端和服务端的接口规范

CRI的接口协议定义在cri-api这个项目中,除了包含一个proto格式的接口协议之外,还包含一个go实现的lib库,这个lib库是使用grpc的工具根据proto文件自动生成的,在这个lib库中定义了CRI的接口,包括客户端和服务端的接口,并且实现了发送protocol buffer格式的grpc请求的客户端逻辑,这样CRI接口的实现者,就可以直接调用这些lib库中的客户端代码,方便高效的发送请求给服务端,我们来看下相关的静态类图:

可以看到,在CRI的接口规范中,定义了两个客户端相关的接口:RuntimeServiceClientImageServiceClient,前者定义了容器相关的行为规范,主要是Container和PodSandbox的增删查改,后者定义了和镜像相关的行为规范,比如拉取镜像,查询镜像列表等,然后又定义了runtimeServiceClientimageServiceClient这两个结构体,分别实现了这两个客户端接口中定义的方法,实现的主要逻辑就是向服务端发送相应的protocol buffer格式的grpc请求,这两个结构体中的grpc.ClientConn变量就是跟服务端的连接,然后在kubelet的CRI客户端代码中,即remoteRuntimeServiceremoteImageService中,直接引用了这两个结构体作为成员变量,用来作为跟remote container runtime交互的桥梁。来看一个runtimeServiceClient实现的发送grpc请求的例子:

1
2
3
4
5
6
7
8
9
func (c *runtimeServiceClient) RunPodSandbox(ctx context.Context, in *RunPodSandboxRequest, opts ...grpc.CallOption) (*RunPodSandboxResponse, error) {
out := new(RunPodSandboxResponse)
err := c.cc.Invoke(ctx, "/runtime.v1alpha2.RuntimeService/RunPodSandbox", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}

除了客户端的接口,CRI中还定义了服务端的两个接口:RuntimeServiceServerImageServiceServer,可以看到跟客户端的接口是一一对应的,只不过输入输出的参数不一样,grpc服务端需要一一实现这些接口,才能够正确接收到客户端发来的CRI协议的请求,比如cri-o中的Server结构体就完全实现了这两个接口中的方法,在这些方法中,不同的container runtime又会有各自的实现逻辑,但是对外的接口是统一的,这样kubelet就可以跟这些container runtime无缝对接了,完全不需要改代码,仅仅是改个配置而已,这种方式简洁优雅,而且代码易维护,这就是标准规范带来的好处。

CRI Services接口规范

除了上面核心的客户端和服务端的接口之外,cri-api还定义了一些Services接口,这些接口其实还是定义了一些容器和镜像相关的操作,只不过这些接口更偏向上层一些,它们由Kubernetes这一侧去实现,在实现的方法中,构造了cri-api中发送grpc请求需要用到的参数,然后对grpc请求的返回值进行处理,主要是错误处理,所以Services这层接口的作用,相当于是规范了Kubernetes在调用CRI接口时的行为规范,其类图如下:

Kubelet中的remoteRuntimeServiceremoteImageService结构体分别实现了这些接口方法,在这些方法中,构造好发送grpc请求用到的参数,比如Context, RunPodSandboxRequest等,然后调用cri-api中定义的runtimeServiceClientimageServiceClient去发送相应的grpc请求,然后再对grpc返回值做进一步处理,比如取出返回值的有用信息,或者是错误处理,来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (r *remoteRuntimeService) ListPodSandbox(filter *runtimeapi.PodSandboxFilter) ([]*runtimeapi.PodSandbox, error) {
klog.V(10).Infof("[RemoteRuntimeService] ListPodSandbox (filter=%v, timeout=%v)", filter, r.timeout)
ctx, cancel := getContextWithTimeout(r.timeout)
defer cancel()

resp, err := r.runtimeClient.ListPodSandbox(ctx, &runtimeapi.ListPodSandboxRequest{
Filter: filter,
})
if err != nil {
klog.Errorf("ListPodSandbox with filter %+v from runtime service failed: %v", filter, err)
return nil, err
}

klog.V(10).Infof("[RemoteRuntimeService] ListPodSandbox Response (filter=%v, items=%v)", filter, resp.Items)

return resp.Items, nil
}

实现了这些接口方法,在Kubelet的管理Runtime的Manager中,即kubeGenericRuntimeManager,就可以直接引用remoteRuntimeServiceremoteImageService这两个service变量,去跟Remote Container Runtime进行交互,相关的参数构造以及错误处理,在Service这个层面就处理好了,Manager这一层就不用再关心这些相对偏底层的信息。

Kubelet Runtime Manager

Kubelet Runtime Manager就是Kubelet中一个非常重要的Manager,关于Manager,在Kubelet机制概述中介绍过,RuntimeManager的作用就是管理本节点上的容器和镜像,将定义在apiserver中的Pod对象声明,在Container Runtime中同步出来,主要是对Pod的增删改这些操作,RuntimeManager在处理Pod同步的SyncLoop中被调用来执行相关的操作,而RuntimeManager又是通过上面介绍到的remoteRuntimeServiceremoteImageService来跟Remote Container Runtime打交道的,来看下其类图:

kubelet/container/runtime.go中定义了RuntimeManager需要实现的接口,比如处理同步Pod操作的SyncPod(),执行垃圾回收的GarbageCollect()等等,kubeGenericRuntimeManager结构体则实现了这些接口,这些实现的方法分布在kuberuntime_xxx.go文件中,然后kubeGenericRuntimeManagerKubelet这个结构体引用,赋值给其中的containerRuntime, streamingRuntime, runner等成员变量,后续Kubelet都是通过它们来跟Remote Container Runtime间接进行交互的。

总结

本文先介绍了下CRI的背景知识,然后重点介绍了CRI的协议规范,以及Kubelet中是如何使用CRI机制的,Kubelet对CRI这套机制的设计和应用,从上到下,可以分为这么几层:Kubelet –> kubeGenericRuntimeManager –> remoteRuntimeService/remoteImageServcie –> runtimeServiceClient/imageServiceClient,每一层都有对应的接口规范,并且依赖下一层提供的功能来实现本层相关的业务逻辑,处于最底层的,就是CRI协议的核心内容,规定了CRI的客户端和服务端需要遵循的接口规范,也是我们本文的重点内容,任何实现了CRI服务端接口的容器运行时,就可以跟Kubelet进行对接,这种设计思路和编码方式,很值得学习。

作者

hackerain

发布于

2021-09-07

更新于

2023-10-26

许可协议