1. 写在前面
kubernetes (k8s) 是一个典型的分布式系统,基于声明式API设计。声明式API使得用户可以不关心集群的当前状态,只需要告诉k8s我们想要什么,k8s会自动进行调整,逐渐向期望状态逼近,直到能够实现期望状态。状态变更往往意味着数据变更,数据同步在分布式系统中至关重要。k8s支持pull和push两种数据同步模型,push是指主动对外推送增量数据,用于保证数据变更的实时性,pull实际上是一种补偿机制,用于保证数据的完整性和一致性。k8s使用list & watch实现了pull和push模型,list & watch动作离不开resourceVersion,本文重点介绍resourceVersion在k8s中扮演的角色和起到的作用。
2. resourceVersion介绍
resourceVersion是标识服务器内部对象版本的字符串。 客户端可以使用resourceVersion来确定对象何时更改, 或者在get、list和watch资源时表达数据一致性要求。k8s不允许客户端在本地对resourceVersion进行排序和修改,resourceVersion必须被客户端视为不透明的,并且未经修改地传回kube-apiserver。本文以1.20版本k8s中resourceVersion的设计进行分析,其他版本略有差异。
下面是官方给出的k8s集群架构图,可以看出kube-apiserver(下简称为apiserver)是整个k8s集群与etcd交互的唯一组件,常规使用场景下,对集群任何资源的增删改都需要通过apiserver来访问etcd,完成数据的持久化。
客户端通过list & watch apiserver来获取实时数据变更,下面根据数据流的方向分别介绍各个组件的resourceVersion相关的字段。
etcd
etcd共有四种resourceVersion相关的字段:
| 字段 | 作用域 | 介绍 |
|---|---|---|
| Revision | cluster | etcd的逻辑时间戳,全局单调递增,任何key的增删改都会使其自增。 |
| CreateRevision | key | 创建这个 key 时etcd的 Revision, 直到删除前都保持不变。 |
| ModRevision | key | 修改这个 key 时etcd的 Revision, 只要这个 key 更新都会自增。 |
| Version | key | key刚创建时Version为1,之后每次更新都会自增,即这个key从创建以来更新的总次数。 |
以一个具体的pod为例,如下图所示:
- 左侧pod yaml中的resourceVersion是右侧查询结果的mod_revision(1352009442)
- version为6代表这个pod被创建后又发生了5次变更,因此create_revision(1197481290)小于mod_revision(1352009442)
- header中的revision代表查询的这一时刻etcd节点的全局revision(1646139684),三者关系为:revision >= mod_revision >= create_revision。通常情况下,在同一个k8s集群中,etcd的revision没有经过人为修改的情况下,所有资源都会遵守上述大小关系。
apiserver
apiserver为每一类资源(pod,node等)都初始化了一份cacher,cacher的核心数据结构是watchCache,watchCache中cache和store字段分别用于存储事件和真实的资源对象:
- cache的大小可以在一定范围内自适应调节,为了避免历史事件无限堆叠,存储的事件有过期的概念,只保存75s内的历史事件。cache是滑动窗口机制,startIndex、endIndex就是用来实现cyclic buffer的。滑动窗口中的第一个元素就是cache[startIndex%capacity],最后一个元素则是cache[endIndex%capacity]。
- store无具体大小,是线程安全的存储,存储的是全量的某种资源,例如全量的pod,作为apiserver本地的缓存,可供客户端读取。
watchCache有两个resourceVersion相关的字段:
- resourceVersion代表当前apiserver本地watchCache的版本号,初始值为0,是由apiserver list etcd返回的版本号和接收到的etcd事件中带的版本号进行刷新,即当前resourceVersion是最新事件的版本号,可以通过对比不同apiserver的resourceVersion大小来判断每个apiserver的cache状态
- listResourceVersion代表最近一次list时etcd返回的resourceVersion,初始值为0,由apiserver list etcd时返回的版本号刷新
客户端
下面这张图来自于官方文档《client-go under the hood》,描述了客户端处理事件的流程,黄色部分是控制器相关基础组件(需要使用方编写代码的地方),蓝色部分是client-go的Informer机制的组件(直接引用)。客户端为需要关注的每一类资源都初始化一份informer或者reflector,并使用indexer构建了一份全量资源的缓存。事件被处理后不会保留,因此需要客户端处理时有失败重试的逻辑,否则可能会出现“丢事件”的错觉(注意,deleteHandler不要忽略DeletedFinalStateUnknown类型的obj,在网络波动时易出现,否则可能会导致丢了某些删除事件)。
- 为了防止客户端处理事件不严谨,informer提供了一种补偿机制--resync,在创建informer的时候可以指定resyncPeriod, 该参数就是用于控制informer多久全量resync一次,resync时会将informer cache中的所有资源调用OnUpdateEventHandler, 不会请求apiserver。当客户端带有严谨的失败重试逻辑时,可以考虑关闭resync,即创建informer时指定resyncPeriod参数为0即可,避免产生较多无效任务。
- 除此之外,由于客户端接收到的所有事件都会在同一个队列内,即add/update/delete事件按到达顺序依次入队,只有前一个事件被处理完成后下一个事件才会出队,不建议在AddFunc/UpdateFunc/DeleteFunc处理handler内有比较重的逻辑,否则某一个handler处理会阻塞其他事件的处理。因此社区一般推荐workqueue+processNextWorkItem的方式,即handler只做简单处理随后入队,随后异步worker消费workqueue的数据,这也比较符合k8s面向终态的设计,具体例子可见sample-controller。
- 接下来看下客户端reflector中的lastSyncResourceVersion字段,根据接收到最新事件的ResourceVersion进行更新,值的大小可以表示当前客户端的本地缓存的新旧程度。对于多副本的客户端,对某一类资源,某个客户端的lastSyncResourceVersion越大,表示该客户端本地cache越接近etcd最新状态。客户端在访问apiserver时,会使用到lastSyncResourceVersion该字段,该字段对客户端透明,不允许修改。
事件流转
介绍完各组件的相关的resourceVersion,下面看下事件如何流转,以及resourceVersion如何更新。先看下apiserver内定义的事件结构watchCacheEvent,每个事件都带有ResourceVersion字段,是事件发生时etcd的revision。etcd通过gRPC数据流向apiserver传输事件,etcd会保证事件的顺序性,即事件的ResourceVersion从小到大增加,详见etcd watch streams。apiserver本地的resourceVersion根据事件的ResourceVersion进行更新。
本节重点介绍事件的流转流程,下面从两个角度描述这个过程:
- 从apiserver的关键的结构介绍,etcd产生的add/modify/delete/bookmark/error事件经过apiserver传输给客户端。apiserver收到etcd的事件首先更新自己本地indexer缓存(store)和保留当前事件(event cache),保留事件时会判断event cache切片是否需要扩容或者缩容,保持apiserver本地事件的时效性和事件cache大小在一个合理的范围内。最后通过http请求将事件发送给客户端。
- 从关键函数角度分析,如下图所示,与客户端类似,apiserver也是使用reflector从etcd获取event来更新自身的缓存和resourceVersion,同时,apiserver向相关的watcher(客户端)分发事件,用于更新客户端的缓存,resourceVersion等。
事件全部类型包括add/modify/delete/bookmark/error,对于add/modify/delete事件比较好理解,对应某个资源的增删改事件,下面分别介绍下bookmark和error事件。
bookmark事件
- 介绍:bookmark事件是一个特殊事件,该事件不包含具体资源,只有resourceVersion字段,用于标记客户端请求的给定 resourceVersion 的所有更改都已发送,即让客户端知道服务器已向客户端发送了 Bookmark 事件中指定的 resourceVersion 之前的所有事件。这样可以减少客户端因为resourceVersion一直不更新导致收到“too old resource version”报错,避免客户端产生较多无效list请求,
- 如何开启:在 watch 请求中设置 allowWatchBookmarks=true 查询参数来请求 bookmark事件,以kubelet发起watch pod的url举例:
GET /api/v1/pods?allowWatchBookmarks=true&fieldSelector=spec.nodeName%3D1.2.3.4&resourceVersion=1&timeoutSeconds=464&watch=true
- 以上面的url继续说明:kubelet发起watch pod请求带了fieldSelector字段,只关注当前kubelet所在node(1.2.3.4)上的pod变更事件
- 如下图所示,假设cache的cyclic buffer的容量为100,RV1是最小的一个Watch事件的Resource Version,RV100是最大的一个Watch事件的Resource Version。当版本号为RV101的Pod事件到达时,RV1就会被淘汰,apiserver维护的Pod最小版本号就变成了RV2。
- kubelet只关注RV1的pod事件,在未实现bookmark特性之前,其他RV2到RV101的事件是不会推送给它的,因此它内存中维护的Resource Version依然是RV1。
- 若此kubelet当前watch达到超时时间(464s)退出(除超时退出外,网络波动也会引起watch重连,这里以超时退出这个常规情况进行说明),它将使用版本号RV1发起watch重连操作。但是apsierver cyclic buffer中的Pod最小版本号已是RV2,因此会返回”too old resource version”错误给client,client只能发起List操作,在获取到最新版本号后,才能重新进入监听逻辑。
- 我们能否定时将最新的版本号推送给该kubelet来解决以上问题呢? bookmark机制通过新增一个bookmark类型的事件来实现的。apiserver会通过定时器将各类型资源最新的resourceVersion推送给kubelet等client,在client当前watch超时退出时,client可以携带bookmark事件中的最新resourceVersion来与apiserver建立watch连接,这样不会产生报错导致client重新发起list请求。
error事件
- 产生原因:通常情况下,event由etcd经apiserver传递给client,这里面有两处可能会产生error事件:
- etcd -> apiserver:etcd 使用 MVCC 来支持多个版本的键值对,当数据被修改时,etcd 会保留历史版本,为了节省存储空间和提升性能,通常会配置压缩策略用于删除历史版本。对于delete类型的事件,PrevObject字段表示被删除的对象,当某个资源被删除还没有发送delete event时,etcd执行压缩并且删除了该历史版本的资源,PrevObject为nil,此时apiserver即使收到事件也无法知道哪一个资源被删除,此时etcd直接返回error事件用于告知apiserver发生了非预期的错误,使其重新发起list请求,全量更新一次本地缓存。
- apiserver-> client:apiserver虽然也有定时清理历史事件的动作,但是不会向client发送error事件。通常由于网络原因导致decode事件失败产生error事件,用于告知client自身网络出现了问题,可能会出现丢事件。
- 影响:client收到error事件后,会触发重新执行reflector的listAndWatch,即重新list一遍apiserver,使用list获取的全量数据来刷新本地的缓存,随后基于新list的数据进行watch apiserver,以此来规避error事件的影响。这也是k8s系统的一个特点,当出现未知异常时,启动自愈机制,基于正确的数据重新开始工作,体现出k8s面向终态的设计理念。
3. resourceVersion在list & watch的作用
文章开头提到k8s使用list & watch实现了pull和push数据同步模型,这样设计避免了客户端反复轮询,降低了apiserver的压力。对于数据同步,etcd-> apiserver-> 客户端都是采取了list & watch方式,只是采用的通信协议不同,etcd-> apiserver使用gRPC协议,apiserver -> 客户端采用http协议。
下面以apiserver -> 客户端这条链路来介绍list & watch,list基于 HTTP 短链接实现,watch则是调用资源的 watch API 监听资源变更事件,基于 HTTP 长链接实现。list & watch机制需要满足以下需求:
- 实时性 (即数据变化时,相关组件越快感知越好)
- 保证消息的顺序性
- 保证消息不丢失或者有可靠的重新获取机制 (比如 kubelet 和 apiserver 间网络闪断,需要保证网络恢复后kubelet可以收到网络闪断期间产生的消息)
在上述需求中,resourceVersion的作用功不可没,get、list 和 watch 操作支持resourceVersion参数,下面介绍不同操作下的resourceVersion的语义(本文重点介绍resourceVersion参数,其他参数情况详见Kubernetes API 概念):
get & list
| 请求参数分类 | 语义 |
|---|---|
| resourceVersion 未设置 | 返回etcd最新版本(请求穿透到etcd,返回etcd的数据) |
| resourceVersion="0" | 任何版本(返回apiserver cache内的数据) |
| resourceVersion="<非零值>" && 不带limit参数 | 不老于给定版本(返回apiserver cache内的数据) |
| resourceVersion="<非零值>" && 带limit参数 | 不老于给定版本(返回etcd内的数据,因为apiserver cache不支持分页) |
下图为常见的list请求示例,以list pods为例进行讨论,client携带了resourceVersion v1和分页l1两个参数,表示client期望的到新鲜度不低于v1的全量数据,用于更新自己的缓存。正常情况下,不会出现client携带的rv大于etcd rv的情况(除非client diy了一个rv值),因为客户端的rv源头都是来自于etcd,etcd的rv是单调递增的。为了保证高可用,apiserver一般是多节点部署,各apiserver的本地rv由于网络和处理速度等可能出现不一致情况,client的rv有可能出现大于本次list请求的apiserver rv的情况。当client表明从apiserver cache获取资源时,为了避免client丢失或者重复处理某些事件,需要保证apiserver当前的pod cache比client的新,即客户端携带的rv小于等于apiserver当前的resourceVersion。
watch
与get & list不同,正常使用情况下,watch请求不会穿透到etcd
| 请求参数分类 | 语义 |
|---|---|
| resourceVersion="0"或者未设置 | 读取apiserver的全部store缓存 |
| resourceVersion="<非零值>" | 从指定版本开始 |
下图以client watch pod为例(url有删减,例如timeout和watch=true等参数),client携带了v2的版本号和允许接受apiserver发送的bookmark事件。
- 当v2为空或者0时,apiserver认为client需要全量store的pod数据,以add事件逐个发送给client。
- 当v2为一个具体的值时,表示client的cache新鲜度已经到达了v2,期望以v2为起点接收新事件,假设v2的值为100,client期望拿到rv为101,102...的event,如果apiserver的watch cache中最老的event为102,由于rv是全局单调递增的,与100中间相差个101,此时client不能直接从该apiserver获取资源的变动情况,因为存在client已经丢失101这个pod event的可能性,所以需要给client返回410 too old报错,使client重新发起一次list请求来刷新一次自身的缓存。因此,当v2为100时,client可以接受apiserver本地最老的event rv为101,即v2不能小于(oldest-1)。
4. resourceVersion在资源更新的作用
前两节主要在介绍resourceVersion在读场景下的作用,现在我们看下写场景。写资源一般有两种方式:update和patch。
- 在 update 请求中,我们需要将整个修改后的对象提交给 k8s;
- 而对于 patch 请求,我们只需要将对象中某些字段的修改提交给 k8s。
二者不同的是,k8s 要求用户 update 请求中提交的对象必须带有 resourceVersion,对于patch请求,apiserver不会校验resourceVersion,只要patch内容合理即将更新提交到etcd内。每个资源上都有一个resourceVersion,下面示例pod的resourceVersion为1661426473,代表该pod最后一次更新时etcd当前的revision的值。
etcd 是一个强一致性的KV存储,底层实现了MVCC机制,采用 optimistic lock(乐观锁)的方式来实现并发控制。基于 etcd,K8s 也沿用了版本和乐观锁的更新逻辑。在进行数据更新时,乐观锁不会立即锁定数据,而是允许多个事务同时读取数据并进行修改。 只有在提交更新时,才会检查数据是否已经被其他事务修改过。当该pod发起更新请求时,apiserver会校验客户端传入的待更新的资源的resourceVersion是否与apiserver本地存储的对应资源的resourceVersion是否相等,如果不等认为客户端没有根据最新的版本进行修改,拒绝此次更新,错误码为409更新冲突。update场景下,更新冲突如下所示,乐观锁机制下,clientB发起更新时没有使用最新版本v2出现更新冲突。
apiserver中对应代码:
针对更新冲突的场景,可以考虑采用RetryOnConflict函数进行重试,Golang retry.RetryOnConflict函数代码示例这篇文章介绍了多种使用场景。在k8s使用update的场景下,可以重点考虑k8s源码内使用次数较多的一种方式,伪代码如下:
func UpdateFunc(c clientset.Interface, nodeName string,...) error {
firstTry := true
return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
var err error
var oldNode *v1.Node
// First we try getting node from the API server cache, as it's cheaper. If it fails
// we get it from etcd to be sure to have fresh data.
if firstTry {
oldNode, err = c.CoreV1().Nodes().Get(context.TODO(), nodeName, metav1.GetOptions{ResourceVersion: "0"})
firstTry = false
} else {
oldNode, err = c.CoreV1().Nodes().Get(context.TODO(), nodeName, metav1.GetOptions{})
}
if err != nil {
return err
}
return DoUpdateLogic(oldNode, ...)
})
}
5. 结论
在同一个k8s集群内,etcd的全局revision在没有人为调整的情况下,由于etcd的revision全局单调递增,resourceVersion来源于revision,resourceVersion可以理解为数据的新鲜程度,对于某一类资源,apiserver的resourceVersion越大认为该apiserver节点的数据越新,即越接近etcd最新存储情况。resourceVersion主要用于确保数据一致性、支持并发控制、以及帮助客户端监控资源的变化。具体来说,resourceVersion 的作用包括以下几个方面:
- 数据一致性保证:客户端在获取、列出和监视资源时使用resourceVersion表达数据一致性要求,apiserver根据请求参数做出对应的响应;
- 获取增量变化:watch 功能允许客户端监控资源的变化。resourceVersion 在此过程中起到了关键作用。客户端可以通过指定 resourceVersion 来请求从某个版本开始的所有变化(包括新增、更新或删除操作);
- 并发控制:k8s使用resourceVersion 实现了乐观锁(Optimistic Locking),当客户端获取某个资源时,它会得到该资源的 resourceVersion。在使用update方式更新该资源时,客户端会把原来的 resourceVersion 一并提交给服务器。如果资源在客户端获取后发生了变化(即 resourceVersion 被修改了),Kubernetes 会拒绝更新请求,从而避免更新旧的数据。这是一种防止数据丢失和数据竞争的机制;