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的设计规范可以在这里查看,它使用grpc和protocol 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 | witch containerRuntime { |
这条日志,在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的接口规范中,定义了两个客户端相关的接口:RuntimeServiceClient
和ImageServiceClient
,前者定义了容器相关的行为规范,主要是Container和PodSandbox的增删查改,后者定义了和镜像相关的行为规范,比如拉取镜像,查询镜像列表等,然后又定义了runtimeServiceClient
和imageServiceClient
这两个结构体,分别实现了这两个客户端接口中定义的方法,实现的主要逻辑就是向服务端发送相应的protocol buffer格式的grpc请求,这两个结构体中的grpc.ClientConn变量就是跟服务端的连接,然后在kubelet的CRI客户端代码中,即remoteRuntimeService
和remoteImageService
中,直接引用了这两个结构体作为成员变量,用来作为跟remote container runtime交互的桥梁。来看一个runtimeServiceClient实现的发送grpc请求的例子:
1 | func (c *runtimeServiceClient) RunPodSandbox(ctx context.Context, in *RunPodSandboxRequest, opts ...grpc.CallOption) (*RunPodSandboxResponse, error) { |
除了客户端的接口,CRI中还定义了服务端的两个接口:RuntimeServiceServer
和ImageServiceServer
,可以看到跟客户端的接口是一一对应的,只不过输入输出的参数不一样,grpc服务端需要一一实现这些接口,才能够正确接收到客户端发来的CRI协议的请求,比如cri-o中的Server结构体就完全实现了这两个接口中的方法,在这些方法中,不同的container runtime又会有各自的实现逻辑,但是对外的接口是统一的,这样kubelet就可以跟这些container runtime无缝对接了,完全不需要改代码,仅仅是改个配置而已,这种方式简洁优雅,而且代码易维护,这就是标准规范带来的好处。
CRI Services接口规范
除了上面核心的客户端和服务端的接口之外,cri-api还定义了一些Services接口,这些接口其实还是定义了一些容器和镜像相关的操作,只不过这些接口更偏向上层一些,它们由Kubernetes这一侧去实现,在实现的方法中,构造了cri-api中发送grpc请求需要用到的参数,然后对grpc请求的返回值进行处理,主要是错误处理,所以Services这层接口的作用,相当于是规范了Kubernetes在调用CRI接口时的行为规范,其类图如下:
Kubelet中的remoteRuntimeService
和remoteImageService
结构体分别实现了这些接口方法,在这些方法中,构造好发送grpc请求用到的参数,比如Context, RunPodSandboxRequest等,然后调用cri-api中定义的runtimeServiceClient
和imageServiceClient
去发送相应的grpc请求,然后再对grpc返回值做进一步处理,比如取出返回值的有用信息,或者是错误处理,来看一个例子:
1 | func (r *remoteRuntimeService) ListPodSandbox(filter *runtimeapi.PodSandboxFilter) ([]*runtimeapi.PodSandbox, error) { |
实现了这些接口方法,在Kubelet的管理Runtime的Manager中,即kubeGenericRuntimeManager,就可以直接引用remoteRuntimeService
和remoteImageService
这两个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又是通过上面介绍到的remoteRuntimeService
和remoteImageService
来跟Remote Container Runtime打交道的,来看下其类图:
在kubelet/container/runtime.go
中定义了RuntimeManager需要实现的接口,比如处理同步Pod操作的SyncPod(),执行垃圾回收的GarbageCollect()等等,kubeGenericRuntimeManager
结构体则实现了这些接口,这些实现的方法分布在kuberuntime_xxx.go文件中,然后kubeGenericRuntimeManager
被Kubelet
这个结构体引用,赋值给其中的containerRuntime, streamingRuntime, runner等成员变量,后续Kubelet都是通过它们来跟Remote Container Runtime间接进行交互的。
总结
本文先介绍了下CRI的背景知识,然后重点介绍了CRI的协议规范,以及Kubelet中是如何使用CRI机制的,Kubelet对CRI这套机制的设计和应用,从上到下,可以分为这么几层:Kubelet –> kubeGenericRuntimeManager –> remoteRuntimeService/remoteImageServcie –> runtimeServiceClient/imageServiceClient,每一层都有对应的接口规范,并且依赖下一层提供的功能来实现本层相关的业务逻辑,处于最底层的,就是CRI协议的核心内容,规定了CRI的客户端和服务端需要遵循的接口规范,也是我们本文的重点内容,任何实现了CRI服务端接口的容器运行时,就可以跟Kubelet进行对接,这种设计思路和编码方式,很值得学习。
Kubernetes Kubelet CRI 机制解析
https://hackerain.me/2021/09/07/kubernetes/kube-kubelet-cri.html