这几天在公众号评论区收到一条留言,说k8s的List-Watch 没讲清楚,ETCD返回事件,到底如何监听没讲清楚,今天就来探讨看看吧。

          在 Kubernetes 圈子里待久了,总会遇到些 “天天挂在嘴边,却突然发现没吃透” 的概念。List-Watch 就是其中一个 —— 我们都知道它是 K8s 的 “神经中枢”,控制器靠它感知变化,声明式 API 靠它落地,但真要有人问 “它到底怎么把消息从 etcd 传到控制器手里的”,我之前还真答不明白。

有时候 Deployment 明明改了副本数,控制器却像 “没看见” 一样,等待了一段时间,还是没反应,通过看各种日志信息、事件等等,最后发现是 Watch 连接超时后,客户端重连时用错了 resourceVersion。

一、先拆个误区:它不是 “推送”,是 “伸长脖子等”

刚开始我一直以为,List-Watch 是 etcd 那边一有风吹草动,就主动把消息 “推” 给控制器 —— 毕竟事件来得挺及时,不像 “客户端主动查” 的样子。

但实际上,这东西本质是客户端在 “主动等”:用 HTTP 长轮询的方式,把请求 “挂” 在 API Server 上,等有新事件了再拿回来。专业点说叫 “长轮询拉取”,不是 “实时推送”。

为啥放着 etcd 原生的 Watch API 不用(它是支持流式推送的)?我翻了早期的设计文档,大概三个原因:

  • 兼容性坑

    :etcd v2 和 v3 的 Watch 接口差太远,K8s 想兼顾两个版本,就得自己套一层壳;

  • 网络太脆

    :长轮询断了重连很简单,拿上次的版本号续上就行;流式连接一旦断了,恢复起来麻烦得多,尤其跨机房部署时,网络波动太常见了;

  • 安全关卡

    :API Server 得做认证授权啊!要是让控制器直接连 etcd,那 RBAC 权限控制不就成了摆设?

所以你看,List-Watch 其实是 API Server 对外开的 “统一窗口”,不管啥客户端,都得从这走。

二、一步步看:控制器是怎么 “盯” 着资源变化的?

拿 Deployment 控制器举例,它启动后干的事儿,其实和我们刷消息差不多:先拉一遍历史,再盯着新动态。

1. 先 “摸底”:发个 List 请求拿全量数据

控制器刚起来,肯定得知道当前有多少 Pod 吧?所以第一步是发个 List 请求:

GET /api/v1/namespaces/default/pods?watch=false

API Server 会返回所有 Pod 的完整信息,控制器拿到后,会存在自己的本地缓存里(就是 Informer Cache)。这一步就像我们打开 App 时,先加载一遍历史消息,心里有个数。

2. 再 “盯梢”:发 Watch 请求等着新变化

紧接着,它会立刻发一个 Watch 请求:

GET /api/v1/namespaces/default/pods?watch=true&resourceVersion=12345

这里有两个关键参数:

  • watch=true

    :告诉 API Server“我要实时盯着了,有新的就喊我”;

  • resourceVersion=12345

    :这个太重要了!意思是 “从版本 12345 之后的变化,都给我”。

你可以把 resourceVersion 理解成 etcd 给每个资源打的 “时间戳”—— 资源每创建、更新、删除一次,这个号就涨一次,有点像我们写文档时的 “修订版本号”。有了它,控制器就知道 “从哪个时间点开始听”。

3. API Server 是个 “二传手”,不是 “主动盯”

很多人(包括我之前)都以为 API Server 会 “主动盯” 着 etcd,一有变化就通知客户端。其实不是!

真相是:API Server 自己不盯 etcd,它是等客户端发来了 Watch 请求,才转头去问 etcd

打个比方:控制器是顾客,API Server 是前台,etcd 是仓库。顾客说 “有新货了告诉我”(发 Watch 请求),前台就给仓库打个电话 “盯着点,有新货了立刻告诉我”(API Server 给 etcd 发 Watch 请求)。仓库有新货了,先告诉前台,前台再转给顾客。

所以完整的链路是:

控制器 → API Server → etcd

而且 API Server 给控制器的,始终是 HTTP 长轮询(就是请求挂着等),不是 etcd 那种流式推送。这一层转换,就是为了让 K8s 自己能掌控所有流程。

4. 事件来了,怎么传过来的?

当你用 kubectl run 创建一个新 Pod 时,etcd 会先动:给这个 Pod 分配一个新的 resourceVersion(比如 12346),然后把变化记下来。

这时候,仓库(etcd)会告诉前台(API Server)“有新货了”,前台再把这个消息转给顾客(控制器)。控制器收到后,会干三件事:

  • 把新 Pod 信息更新到本地缓存;

  • 看看 Deployment 副本数够不够,不够就去创建新的;

  • 赶紧发个新的 Watch 请求,用最新的 resourceVersion=12346,继续等着下一波变化。

就这么循环往复,实现 “实时监听”。

三、etcd 返回的事件长啥样?我抓过包

既然 API Server 要去问 etcd,那 etcd 给的 “原始数据” 是啥样的?我之前用 etcdctl 模拟过一次,大概是这样:

当 API Server 调用 etcd 的 Watch 接口时(用 Go 代码示意):

// 监听某个 Pod 的变化,从版本 12345 开始
watchChan := client.Watch(ctx, "registry/pods/default/mypod", client.WithRev(12345))

etcd 会返回一个通道,里面塞的是 WatchResponse 结构,用 protobuf 「协议缓冲区」定义大概长这样:

message WatchResponse {
  int64 header_revision = 1; // 当前 etcd 最新版本
  repeated Event events = 2; // 事件列表
  bool canceled = 3;         // 监听是否被取消
  string cancel_reason = 4;  // 取消原因
}
message Event {
  enum Type {
    PUT = 0;    // 创建或更新
    DELETE = 1; // 删除
  }
  Type type = 1;          // 事件类型
  KeyValue kv = 2;        // 新数据(PUT 时才有)
  KeyValue prev_kv = 3;   // 旧数据(更新/删除时才有)
}
API Server 拿到这个后,会转换成 K8s 自己的格式,比如:​​​​​​​

{
  "type": "ADDED",
  "object": {
    "apiVersion": "v1",
    "kind": "Pod",
    "metadata": {
      "name": "mypod",
      "namespace": "default",
      "resourceVersion": "12346"
    }
    // ... 其他信息
  }
}

这个转换过程,其实就是 K8s 对底层存储的 “翻译”,让控制器不用关心 etcd 的细节。

四、为啥非得有 resourceVersion?踩过坑才懂

这个字段看着不起眼,但没它真不行。我之前遇到的控制器 “反应慢” 问题,就和它有关:

  • 断点续传

    :如果网络断了,控制器重连时,用上次记住的 resourceVersion 就能把漏掉的事件补回来。上次就是因为重连时没带对版本号,导致控制器 “失忆” 了,等了好久才重新 List 全量数据;

  • 去重防错

    :有时候网络抖动,同一个事件可能发两次。控制器一看 resourceVersion 相同,就知道是重复的,直接忽略;

  • 保证顺序

    :它是递增的,控制器按顺序处理,就不会出现 “先收到删除、再收到创建” 这种逻辑混乱的情况。

五、实战里遇到的几个问题,可能你也碰过

1. 长时间没事件,连接会断吗?

会!默认超时是 5 分钟(API Server 的 --watch-cache-timeout 参数控制)。如果 5 分钟没动静,API Server 会返回个空响应,控制器收到后,会立刻用同一个 resourceVersion 再发一次请求,相当于 “续个费”。

我之前在测试环境故意让一个资源 10 分钟没变化,抓包看到控制器确实每 5 分钟发一次新请求,挺有意思的。

2. 资源删了,还能收到通知吗?

能。etcd 的 DELETE 事件里会带 prev_kv(删除前的数据),API Server 转成 K8s 的 DELETED 事件后,控制器就知道该从缓存里删掉这个资源了。

3. 这东西是 “实时” 的吗?

只能说 “接近实时”。我测过几次,从 Pod 创建到控制器收到事件,大概 10-50 毫秒,主要耗时在 etcd 写入、API Server 转换、网络传输这几步。对 K8s 来说,这个速度完全够用了 —— 控制器又不是高频交易系统,差个几十毫秒没啥影响。

最后说句掏心窝子的话

以前总觉得这些底层机制 “知道个大概就行”,直到真遇到问题卡壳了才明白:多问几个 “为什么”,多拆一层看本质,解决问题时才不会慌。

比如这次搞懂了 List-Watch,再看控制器代码里的 Informer、WorkQueue 这些组件,就知道它们为啥要那么设计 —— 都是为了配合这个 “长轮询拉取” 的机制,保证可靠又高效。

更多推荐