先来看一个小例子

一个 Kubernetes 的集群的架构通常如下图所示:

当一个用户通过kubectl命令行想要创建一个 Pod 数量为3的 Deployment 的资源对象的时候。此请求会先通过 HTTP 协议发送到 API Server。随后,API Server 会将此资源对象的相关信息持久化至 etcd 中。紧接着,ControllerManager 中负责管理 Deployment 的 Controller 将会通过List-Watch 机制感知到目前有一个 Deployment 的资源需要被创建,它会通过一段逻辑来处理这个「新增」的资源:创建一个 ReplicaSet。ReplicaSet 资源的创建请求在 Kubernetes 中被处理的方式和 Deployment 是一样的。最终,在 ReplicaSet Controller 中将会创建符合用户预期数量的 Pod。而 Pod 被创建的行为将会被 Scheduler 通过List-Watch 感知到。在通过一定的调度策略选取好 Pod 要调度的 Node 之后,Kubelet 也将通过 ListWatch机制感知到这一结果。最终,Kubelet 将会在相应的 Node 上为 Pod 创建符合预期数量的容器,并且把 Pod 最新的状态通过 API Server 写回至 etcd。

在了解了一个 Deployment 资源被创建的过程之后,再对比上面 k8s 的架构图。我们可以知道,粉色线标识的,基本就是List-Watch 机制传递消息的过程。

对上述例子的思考

  1. 细心观察一下就会发现,整个 Kubernetes 集群中各个组件执行一些逻辑的依据都来源于List-Watch机制传递过来的资源对象的信息。而这些信息基本上都存放在 etcd 中,依靠 API Server 所暴露的 HTTP 接口对外提供。
  2. 整个 Kubernetes 集群中的组件除了 etcd 之外均为无状态服务。这就预示着除了 etcd 之外,其他服务如果因为故障或者升级等出现短暂断开是完全不影响整个集群的正常工作的。
  3. Kubernetes 的设计理念决定了它的各种行为依赖的是具体资源对象的状态信息,而不是某个用户传递至 API Server 的命令。
  4. Kubernetes 作为一个由多个组件联合构成的一个分布式系统,对一致性的要求是「最终一致」。因为和资源对象相关的信息在不断被获取的同时也在不断被修改。整个过程中可能要有一些非预期的行为,但是只要某个操作的结果最终是正确的,对于 Kubernetes 来说就是可以接受的。

Kubernetes 的架构以及核心组件的职责

  • etcd:集群中唯一有状态的服务,负责存储资源对象的信息
  • API Server:集群资源的访问入口。任何对资源对象的操作(内置,自定义)都需要通过它暴露出来的HTTP 接口
  • Scheduler:集群的调度器。通过一定的调度策略,为 Pod 选取符合条件的调度机器并将其调度到相应的机器上
  • Controller Manager: 集群资源控制器的集合。其内部包含了多个处理不同类型资源的 Controller。它们负责通过一定的逻辑将各个资源对象维护至用户期望的状态(desire state)。
  • Kubelet:集群中各节点的守护进程。负责管理其 Node 上对应的 Container 的生命周期
  • Container Runtime:集群底层依赖的容器运行时。负责管理容器镜像以及容器的运行
  • Kube-proxy: 负责管理集群内部的服务发现和负载均衡,给各个服务提供网络通信层面的支持

什么是 List-Watch?

List-Watch 准确的说是 Kubernetes 集群内部的一种「异步消息传递机制」。任何集群内部资源对象的状态变化,都将通过这种机制传递给关心它的组件。如上面描述的关于创建 Deployment 资源对象的过程,scheduler,kubelet,controller 等组件都会通过 List-Watch 机制来监听整个创建过程中的变化。

一个好的 List-Watch 机制需要解决哪些问题?

一个好的「异步消息传递机制」,应该保证如下几个关键的性质:

  1. 实时性:尤其是对于 Kubernetes 来说,集群中的各项操作都依赖于「资源对象的消息」。所以消息传递的实时性就显得尤为重要
  2. 顺序性:同一个资源对象的操作,很多时候都是互斥的且要遵守一定的顺序执行的。如 Pod 的删除和创建操作,明显不能先响应删除,再响应创建
  3. 可靠性:一旦某个组件在接收到「消息 」之后发生故障进行重启,那么这个消息需要按照一定的机制重传

如何保证高实时性?

既然信息是通过 API Server 向外输出的,那么想更加实时的获取这部分信息就得减少除了真正通讯之外的操作的消耗。对于网络通信而言,我们很容易就想到一个办法:尽量减少链接的创建和销毁操作,保持一个长连接,一直等待着服务端数据。所以,在 Kubernetes 1.7 版本之后,使用 HTTP Streaming 协议与 API Server 建立链接,不断等待从 API Server 到来的数据。

如何保证顺序性?

在分布式系统中,若想保证某一个操作或者资源的顺序性,最常见的办法就是加入「版本号」机制。即给那些需要保证顺序的资源添加一个 Version 字段。Version 从小到大依次递增,每当这个资源的信息被更新时,它的 Version 也就相应的+1。通常,一个系统中对于同一个资源可能有多个不同的版本。当目的组件想要 Watch 一个资源对象的信息的时候,可以带上之前本地缓存的资源对象的版本号。API Server 会按照递增的顺序进行传递。默认这个版本号从0开始递增。

在 Kubernetes 中,这个 Version 字段加在了资源对象的 meta 信息部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	// An opaque value that represents the internal version of this object that can
	// be used by clients to determine when objects have changed. May be used for optimistic
	// concurrency, change detection, and the watch operation on a resource or set of resources.
	// Clients must treat these values as opaque and passed unmodified back to the server.
	// They may only be valid for a particular resource or set of resources.
	//
	// Populated by the system.
	// Read-only.
	// Value must be treated as opaque by clients and .
	// More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#concurrency-control-and-consistency
	// +optional
	ResourceVersion string `json:"resourceVersion,omitempty" protobuf:"bytes,6,opt,name=resourceVersion"`

这种「版本号机制」的正常运行,依赖于更新版本号的操作必须是原子的。而这部分操作应该由 etcd 来保证。

关于顺序性的一点小思考

通过对「实时性」的了解,我们知道,在目的组件和 api server 之间是有一条长链接用于传输资源对象信息的。虽然我们通过 ResourceVersion 可以保证 API Server 向目的组件发送的消息是按照递增顺序的。但其实对于目的组件而言,我们貌似是无法保证消息的到达顺序的。并且,在我观察了 client-go 中关于List-Watch 机制的代码逻辑之后,我发现 client-go 对于向缓存中更新这个最新的 ResourceVersion 之前并没有将新旧 Version 进行对比。那么,这里就有一个隐含的风险:长链接中有 1,2两个资源按照递增的顺序发出。但是当到达目的组件的时候,2先到达,1后到达。当这种情况发生的时候,ResourceVersion 是不是就有可能被错误的更新呢?

逆向的想一下这个问题, k8s 的实现者肯定是考虑到了并且解决了这个问题。那我们也来思考一下:

目前看来,API Server 只能保证发送消息的顺序,而目的组件在处理消息的时候又没有对新旧 ResourceVersion 做对比。那么处理上述所说风险的唯一可行的位置就是中间的通信链路。由于 HTTP Streaming 在传输层依赖的是 TCP 协议。而 TCP 协议本身就保证的报文传递的顺序性:发送方会按照报文的顺序等待相应的 ACK 报文,否则会启动超时重传机制。而接收方也会将接收到的报文先存于缓冲区中,按顺序进行消费。如果发现接下来要消费的报文没有按顺序到达,可能会启用快重传机制,不断的给发送方发送上一个顺序到达报文的 ACK 报文。所以,我们之前假想的问题是不存在的。

如何保证可靠性?

Kubernetes 不但提供了持续 Watch 资源对象信息这种增量获取的机制,还提供了一种 List 操作,即一次性获取集群中所有的符合条件的资源对象信息。这样一来,当目的组件第一次启动或者重启,甚至是 Watch 操作出错的时候,可以通过 List 操作统一的获取集群中相关的资源对象,刷新掉可能已经被污染的数据。

现有的 List-Watch 机制有哪些弊端?

通过对 List-Watch 机制实现「消息传递的可靠性」的思路可知,当发生异常情况或客户端重启的时候,会调用 List 接口对 Kubernetes 集群内部所有的资源对象都扫描一次。这个操作是比较昂贵的,尤其是当集群内资源对象的数量达到一定量级的时候。所以,我们应当在日常的开发中尽量减少触发 List 接口。

关于 List-Watch 机制中长连接的一点思考

在 HTTP 1.1 版本中,每一个长连接都会占用一个单独的 TCP 链接。所以,当这个长连接断掉的时候,客户端和服务端是感知不到的。仅仅只是发现长时间没有数据。如果此时 TCP 打开了 keep-alive 功能,那么客户端或者服务端会间隔一定的时间向对方发送 keep-alive 报文来检测链接是否正常。如果对方多次都没有返回一个 ACK 报文的话,发送端会主动断开这个链接,并且重新建立一个新的链接。

在 HTTP2.0版本中,多个 HTTP 链接会共用一个 TCP 链接。这个链接上可能无时不刻都有数据在传递。此时,keep-alive 功能就不起作用了。但是,在连接异常的情况下,发送端(可能是客户端也可能是服务端)发送的报文可能一直得不到接收端的 ACK 报文。这就会持续引发发送端的「超时重传」机制。由于链接断开,即使发生重传也依然会失败。最终,发送端会因为达到最大的超时重传次数而重置掉当前的 TCP 链接。

List-Watch 机制为了减少链接创建和销毁的开销,使用了 HTTP Streaming 协议。客户端是不能确定一个准确的关闭连接的时间点的,因为它也不知道什么时候服务端会有数据过来。所以,关闭连接的任务一般都由服务端来发起。