jimyag's Blog

Kubernetes Watch 的一致性:resourceVersion、410 Expired 与重新 List

Informer 的本地缓存会断线重连,但重连不意味着可以凭上次看到的事件继续往前推。API Server 可能已经压缩了旧历史,也可能通过 List 或 watch 返回的对象告诉你当前能保证的版本。客户端如果忽略这个边界,就会把“没有收到事件”误当作“对象没有变化”。

本文在 Kubernetes v1.35.1 上直接请求 List/Watch API,验证三件事:List 响应的 metadata.resourceVersion 可以作为后续 watch 的起点;压缩 etcd 并清掉旧 watch cache 后,从旧版本开始 watch 会收到 410 Expired;客户端从最后观察到的版本重连时,仍能收到后续删除事件。实验在 zj 的三节点 kind v0.33.0 集群 article-next 执行,客户端和服务端均为 v1.35.1(提交 8fea90b45245ef5c8ba54e7ae044d3e777c22500),请求经 kubectl proxy 直接打到 API Server。

resourceVersion 表示什么

resourceVersion 不是业务对象的版本号,也不是一个可以跨对象永久保存的时间戳。它是 API Server 返回的、用于读取和 watch 的一致性标记。List 响应和每个对象都有各自的 resourceVersion。用这个版本做 watch,表达的是“告诉我这个版本之后的变化”。

在 etcd 后端中,版本与 etcd revision 相关。etcd 会压缩旧 revision,API Server 的 watch cache 也只在有限历史内保留事件。客户端拿到的版本太旧,服务端就不能再保证补齐中间变化。

这里必须区分两个动作:

  • List 返回当前对象集合和一个版本。
  • Watch 从指定版本之后持续发送变化。

List 与 Watch 之间可能有新事件。正确做法是以 List 返回的版本作为 watch 起点;另外挑一个近似时间会漏掉这之间的变化。

resourceVersion 的两个特殊取值会改变整条语义,混用会得出相反结论。空字符串表示“Get State and Start at Most Recent”:先补一份当前状态(以合成 ADDED 事件形式),再从那里继续;传 0 表示“Start at Any”,允许从一个更老的版本起步,用于容忍版本回退的场景。只有传具体版本号才是 “Start at Exact”,也才可能因为历史被压缩而收到 410。这张对照表在官方 API concepts 的 watch 一节。

服务端侧的分支在 v1.35.1 的 cacher.go:1256-1266:请求带了具体版本就原样使用,没带才去 etcd 取当前版本。这解释了一个常见误判——把 URL 里的 resourceVersion 删掉后“不再报 410”,代价是客户端失去了“我读到哪个版本”的锚点,之后只能从服务端补发的初始状态重新对齐。

对象多到需要分页时,这个值仍然只有一个。带 limit 的 List 会返回 continue,后续分页请求续用这个 token,而 metadata.resourceVersion 在所有分页中保持一致(ListMeta 的 ResourceVersion 与 Continue 字段注释,types.go:66-92)。所以拿第一页的版本去 watch 是正确的,不需要等最后一页。反过来说,如果分页中途失败重来,必须从第一页重新开始,用中间某一页的 continue 拼不出一致快照。

continue 本身有寿命。那段注释写明:服务端配置变化或者过了几分钟之后,续接这个 token 可能失败。Reflector 对分页有明确的取舍,同一段代码里能看到三档(reflector.go:606-641):调用方指定了 WatchListPageSize 就用它;上一次拿到过分页结果就保持默认分页;带具体 ResourceVersion、又没显式要求分页时,把 PageSize 设为 0 关掉分页,强制走 watch cache。注释给出的理由是避免大量客户端在集群升级时同时打到 etcd——带版本的 List 语义是“至少一样新”,所以从 watch cache 拿全量不会回退到更旧的快照。

分页真失败时也不是让它冒到上层。pager.ListWithAlloc 在续接分页遇到 Expired 时会自己退回全量 List(pager.New 把 FullListIfExpired 默认设为 true,pager.go:58-68);Reflector 再兜一层:isExpiredError 或 isTooLargeResourceVersionError 命中就置位 isLastSyncResourceVersionUnavailable,立刻用空版本重试一次。

这两个错误不是一回事。isExpiredError 判的是 410 Gone / ResourceExpired,意思是版本太旧、历史被压缩;isTooLargeResourceVersionError 判的是请求的最小版本大于服务端当前最大版本,通常出现在客户端缓存的版本来自另一个 API Server、或 watch cache 还没跟上时。两者都要退回空版本重试,但根因一个在过去、一个在未来,排障时看到的现象也相反。所以这里一共有三层恢复,“收到 410 就重新 List”这个说法会漏掉两次。

  sequenceDiagram
    participant C as 客户端
    participant A as API Server
    participant E as etcd
    C->>A: List pods
    A->>E: 读取当前集合
    E-->>A: 对象和 revision
    A-->>C: items + metadata.resourceVersion=rv1
    Note over C,A: List 与 watch 之间可能已经发生对象变化
    C->>A: Watch pods?resourceVersion=rv1
    A-->>C: rv1 之后的 ADDED / MODIFIED / DELETED
    alt rv1 仍可从 watch cache 或底层历史补出
        A-->>C: 连续事件
    else 版本已过期
        A-->>C: ERROR Status,code=410,reason=Expired
        C->>A: 重新 List,取得新的 rv
    end

图中的 410 可能以 HTTP 错误返回,也可能在 watch 流中表现为一个 ERROR 事件。客户端库需要同时处理这两种形式。

一次 List 和连续 Watch

实验先创建 before Pod,再通过 kubectl proxy 请求 namespace 的 Pod 列表。记录到的版本是 1273。随后打开:

1
2
/api/v1/namespaces/article-watch-consistency/pods
  ?watch=1&resourceVersion=1273&timeoutSeconds=30

再创建 after Pod,watch 流收到的第一个对象事件是:

1
ADDED after resourceVersion=1274

List 版本既不是 Pod 的版本,也不是服务端“下一个事件”的编号,作用只是作为 watch 的起点。after 创建后先得到 1274,之后 kubelet 更新状态,又可能出现新的 MODIFIED 事件。

持久化事件的顺序由服务端保证,但客户端不能把“收到 ADDED”当作容器已经 Ready。对象创建、调度、kubelet 状态更新和 Ready 是不同层次的事件。

从最后版本重连仍然能收到删除

第一次 watch 收到 ADDED 后,客户端记录最后观察到的对象版本 1274。断线后,它用 1274 重新发起 watch,然后删除 after。重连的流里先收到了后续 MODIFIED,最后收到 DELETED。

这次能重连成功,是因为服务端仍保留 1274 之后的完整历史;重连成功不能证明这个版本会一直可用。可用边界由服务端历史决定,客户端保存的数字本身没有保证。

1
2
3
4
startResourceVersion: 1274
MODIFIED after 1275
...
DELETED  after 1286

实验中还记录了多个状态更新。不能只挑最后的 DELETED 写成“删除准时到达”;真正能确认的是事件类型和对象的 UID。对于一个会被控制器替换的工作负载,删除旧 Pod 后还可能创建同名或不同名的新 Pod,名称本身不足以区分对象。

什么情况下会得到 410 Expired

最初的实验直接把 resourceVersion=1 传给 watch,结果并没有立刻得到 410。这是实验设计的问题:一个单节点新集群的 watch cache 仍覆盖那个版本,或者 API Server 可以从更底层补出请求,不能把“很小的数字”直接等同于“历史已过期”。

第二轮实验先读取当前 revision,执行 etcd compaction,再重启 kube-apiserver 让旧 watch cache 失效,随后从压缩前的 revision 发起 watch。响应中的第一个事件是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "type": "ERROR",
  "object": {
    "kind": "Status",
    "status": "Failure",
    "message": "too old resource version: 1162 (1163)",
    "reason": "Expired",
    "code": 410
  }
}

这是服务端给出的完整判断条件,客户端无需自行推断。etcd 压缩、watch cache 被清掉以及请求版本之间形成了一个可复现的“已过期”场景。要让“很小的版本号”变成“历史已过期”,必须真的让旧历史不可用:只改请求参数、不动服务端状态,复现不出 410。

压缩 etcd 并重启 API Server 只用于隔离实验。生产环境不应把这种操作当成常规排障手段。真实集群中,API Server 的 watch cache、etcd 压缩策略、请求范围和对象数量共同决定可保留的历史窗口。

client-go Reflector 为什么要重新 List

client-go 的 Reflector 把 List 和 Watch 封装成 ListAndWatch。v1.35.1 的 reflector.go 在 List 成功后调用 syncWith 把结果写入 store,再用 setLastSyncResourceVersion 记下这次版本,随后以该版本发起 watch。退避和重试分成两层:watch 流里收到 Expired 只是让 handleWatch 返回错误,watch 里的 case isExpiredError 只记一条 V(4) 日志(reflector.go:561-565),真正的循环在 RunWithContext 里由 wait.BackoffUntil 驱动(reflector.go:365-373)。

重连时不会立刻丢弃上次版本。下一次 List 仍带 lastSyncResourceVersion,因为带版本的 List 语义是“至少和数据一样新”,从 watch cache 拿到的全量结果不会比客户端已有的更旧。只有这次 List 也失败了,才轮到上面那层空版本兜底(relistResourceVersion 的取值规则在 reflector.go:1014-1030)。所以“重新 List”通常仍以旧版本为起点,退回空版本是最后手段,不是每次 Expired 都发生。

这带来两个结果:

  1. Informer 最终会恢复,但恢复可能需要重新 List 全部对象,不能承诺零延迟和无额外 API 请求。
  2. 重新 List 后,缓存中不再出现的对象会产生删除语义;这与收到一个正常 DELETED 事件并不完全一样。v1.35.1 的 delta_fifo.go 在 Replace 时给新列表中已不存在的 key 排入 DeletedFinalStateUnknown{k, deletedObj},消费者要先从 key 取出对象标识,再决定清除哪个缓存或触发哪个 reconcile。controller.go 里的 DeletionHandlingMetaNamespaceKeyFunc 就是为这类对象解包 key 用的。

共享 Informer 会让多个控制器复用同一条 List/Watch 和本地缓存。一个控制器处理慢,不应改变其他消费者看到的事件基础;但每个控制器自己的 workqueue 仍需要保证 key 的串行处理和失败重试。

Watch 只能保证什么

观察能说明什么不能说明什么
List 返回 rv1当前集合和读取版本之后所有对象都不会变化
Watch 从 rv1 开始服务端可以从此版本追加事件本地处理一定及时完成
收到 ADDED服务端观察到一个新建对象容器已启动或 Ready
收到 DELETED服务端发布了删除事件节点上的进程已经结束
收到 410请求版本超出可补历史etcd 或其他资源一定损坏
重新 List缓存重新对齐到新集合所有控制器业务逻辑已经重放完

控制器的职责不是把事件当作最终状态,而是用事件触发一次 reconcile,在 reconcile 中读取当前对象和依赖对象,做出可重复的收敛动作。Controller 调谐文章的调谐实验已经说明,队列事件只是“要检查谁”,不是“最终状态已经到齐”。

用这些信号排查缓存问题

  1. 读取 HTTP List 响应中的 metadata.resourceVersion;kubectl get -o json 默认可能不显示这个字段。
  2. 检查 watch URL 是否使用了这个版本,而不是硬编码 1 或上次的猜测值。
  3. 读到 Expired 或 too old resource version 时,确认客户端是否重新 List,而不是继续无限重连。
  4. 重新 List 后检查缓存对象的 UID,不要只看 name。
  5. 把 API 事件、控制器队列和业务处理结果分开记录;收到 DELETED 不代表外部系统已经完成清理。

resourceVersion 的读取语义(ResourceVersionMatch、书签、resourceVersion=0 等)以 API concepts 为准;kubectl get -o json 隐藏字段这一点与响应体本身无关。Informer、Reflector、Indexer 和 Workqueue 的分工见已有的 client-go 文章。

实验目录和运行方法见 k8sdev/article-watch-consistency。本次验证了压缩后的 410、从 List 版本 watch 和断线后重连收到删除;没有验证大规模对象量、分页 List、watch bookmarks 或 API Server 长时间中断时的全部重试行为。

#Kubernetes #源码分析 #Client-Go #Informer