etcd: 把 Raft 包装成 k8s 的事实数据源
- etcd: 一个分布式、强一致、高可用的 KV 存储, 底层用 Raft 做共识, Go 写的, 现属 CNCF, 是 k8s 唯一的事实数据源 (source of truth);
- revision: etcd 集群级、全局单调递增的版本号, 每次成功的写/事务 +1, 是整个 keyspace 的"全局时钟";
- MVCC: Multi-Version Concurrency Control, 多版本并发控制, 写不覆盖旧值而是生成新版本, 让读写互不阻塞;
- Watch: 订阅某个 key/区间从指定 revision 起的变更, 按序、不丢地推给客户端;
- Lease: 带 TTL 的 key, 客户端要不断续约 (keepalive), 断了自动删, 拿来做心跳/选主;
- Txn: etcd 的迷你事务, If/Then/Else, 本质是基于 revision 的 CAS (compare-and-swap);
- compaction: 压缩, 丢弃某 revision 之前的历史版本来回收空间, 压缩点同时也是 Watch 能回放的最早边界;
- reconcile: 调谐, 比较"期望状态"和"实际状态"、采取动作把差距抹平的幂等操作;
- List-Watch: 先全量拉一次基线 (List) 再持续追增量 (Watch) 的模式, k8s informer 的核心范式;
从 Raft 到 etcd: 中间多了什么
上回书我们学完了 Raft, 知道 Raft 解决的是「一组节点如何就一条有序日志达成一致」; 它是个 replicated state machine 的框架, 但它不规定状态机到底是什么, 只保证所有副本以相同顺序看到相同的 log entry;
etcd 的回答是:
那条 Raft 日志里的每一条 entry, 就是一次 KV 写操作; 状态机就是一个 MVCC 的键值存储 (底层 bbolt);
1 | client 写 → Leader 把这次写当成一条 log entry → Raft 复制到多数派 → |
所以分工很清楚: Raft 管"所有副本以相同顺序看到相同的写", etcd 管"写的是什么、能查到什么、上层暴露什么 API"; etcd 真正值钱的地方不是它内嵌了 Raft (那是地基), 而是它在 Raft 之上搭了一组让分布式协调变得好写的能力, 这也是 k8s 选它、而不是自己撸一套 Paxos 的根本原因:
| 能力 | 是什么 | k8s 为什么离不开 |
|---|---|---|
| MVCC + revision | 全局单调递增版本号, 旧版本保留到 compaction 才删 | Watch / 一致读 / CAS 的共同地基 |
| Watch | 从指定 revision 起订阅变更, 不丢不乱序 | informer 的命根子, 驱动 reconcile |
| Lease | 带 TTL 的 key, 断约自动删 | 节点心跳、组件 leader election |
| Txn | 基于 revision 的 CAS 迷你事务 | 乐观并发控制 |
版本提醒: 现在都是 v3 API (gRPC + protobuf + 扁平 MVCC keyspace); v2 是老的 REST + 层级结构, 已经弃用了, 看资料别看串了;
MVCC: 给存储装上一条时间轴
它要解决的矛盾: 读写并发别互相堵
没有 MVCC 的老办法, 数据就一个"当前值", 一个事务在写、另一个想读同一份数据就撞上了, 只能上锁: 读者等写者、写者等读者, 并发死活上不去;
MVCC 换了个思路: 每次写不是"把旧值改掉", 而是"生成一个新版本", 旧版本留着; 每个读看到的是某个时间点的一致快照, 各读各的版本, 于是读者不挡写者、写者也不挡读者, 因为它俩碰的根本就不是同一份东西; PostgreSQL、MySQL InnoDB、Oracle 全是这么干的;
etcd 里的 MVCC 长什么样
- 一个集群级、全局单调递增的 revision, 每次写/事务成功就 +1;
- 底层 bbolt 存的不是
key → value, 而是(key, revision) → value, 所以历史版本天然就共存了; - 每个 key 还挂三个数:
create_revision: 这 key 被创建时的 revision;mod_revision: 最后一次改它的 revision, CAS/Txn 就靠比这个;version: 它被改过几次;
- 读可以
etcdctl get x --rev=6直接读历史快照;
拿 key x 走一遍:
1 | rev 1: x = "a" ← 版本1 |
三个读者各看各的: "读最新"看到已删除; "读 as of rev 6"看到 x="b"; "读 as of rev 3"看到 x="a"; 同一份数据同一时刻, 带着各自的版本视角, 看到的不一样还都自洽;
代价就是旧版本越堆越多, 所以得定期 compaction 把老版本丢掉回收空间; 但压缩之后那些老快照就读不到了, Watch 也没法从更早的点续传 (这个坑后面还会再撞一次);
说到底, MVCC 把 etcd 从"一个会用 Raft 同步的当前值存储", 升级成了"一个带时间轴的存储"; 而 k8s 要的恰恰就是这条时间轴: 谁在哪个 revision 改了什么;
Watch: 时间轴上的游标 + 重放引擎
在做什么
订阅一个 key 或一段区间 (前缀), 从某个 revision 开始, 把之后所有变更按序、不丢地推给你;
“从某个 revision 开始"就是 MVCC 直接喂出来的能力: 因为旧版本还在, 才能让你"回到过去某个点开始订阅”, 而不是只能盯着未来;
API 形态 (v3, gRPC streaming)
Watch 是一条双向 gRPC 流, 不是一次性请求:
1 | client → WatchCreateRequest { |
一条 gRPC 流能复用好多个逻辑 watcher (各有一个 watch_id); DELETE 事件的 kv.value 是空的 (对应那个 tombstone);
三条核心保证 (一定要记)
| 保证 | 含义 |
|---|---|
| Ordered 有序 | 事件严格按 revision 递增, 绝不乱序 |
| Reliable 可靠 | 只要没被 compaction 干掉, watched 区间里的事件一个都不会被悄悄丢 |
| Atomic 原子 | 同一个 revision 产生的多个事件, 一定在同一个 WatchResponse 里整批给你 |
第三条特别关键: 一次 Txn 可能同时改好几个 key, 它们共享同一个 mod_revision, Watch 保证把它们打包一起给你, 你不会读到"半个事务";
内部实现: synced / unsynced 双队列
服务端拿 watchableStore 把 MVCC store 一裹, 然后把所有 watcher 分成两拨:
- synced (已经追平的): start_revision 已经赶上当前 revision 了; 走热路径, 每次写一提交就在 notify 流程里直接推过去, 延迟最低;
- unsynced (落后的): start_revision 还停在过去 (比如你 watch from rev 100, 现在都 rev 5000 了); 有个后台 goroutine
syncWatchers周期性地去 bbolt 按 revision 区间扫历史事件, 把落在你 watch 区间里的补发给你, 追平了再把它从 unsynced 挪进 synced;
1 | 你 watch from rev=100 |
- victim / 慢消费者: 万一你的接收 channel 满了 (你处理太慢), etcd 把这个 watcher 丢进 victim 列表先缓着、之后再重试, 免得一个慢家伙把整条写路径拖死;
- 底层撑这一切的是内存里一个
treeIndex(B-tree), 把"用户 key → keyIndex(这 key 历史上所有 revision)"映射起来; 补历史的时候拿 revision 区间去 bbolt 扫, 再用 treeIndex 过滤;
和 compaction 的致命交互
如果你的
start_revision <= compact_revision(你想从的起点已经被压缩掉了), 服务端没法重放那段历史了, 就返回ErrCompacted、把这个 watch 取消掉 (canceled=true);
客户端这时候唯一的出路: 重新拉一次全量当前状态, 拿到新 revision, 再从新 revision 重新 watch; 记住这个套路, k8s 的 relist 就是它;
断线续传
客户端得一直记着自己最后收到的那条事件的 revision = R, 重连时用 start_revision = R+1 重建 watch (+1 是因为 R 那条已经收到过了); 对于很久不变的 key, progress_notify 会周期性推一个空 WatchResponse, 只带当前 revision, 告诉你"到这儿为止你没漏", 让 R 能跟着往前挪, 免得回退太多一不小心撞上 compaction;
Watch 到底是为了啥: 拿事件流换掉轮询
机制讲完, 退一步看它为啥存在; 一句话, 就是让分布式组件从"自己主动去轮询共享状态"变成"被动收变更", 把状态同步变成事件驱动;
没有 Watch 你只能轮询, 而轮询有个死结:
1 | while true: |
- 间隔短, 实时是实时, 但 N 个组件高频拉, 直接把 etcd 打爆 (惊群);
- 间隔长, 负载是下来了, 但变更得等下一轮才发现, 延迟高;
- 更阴间的是漏更新: 两次轮询之间一个 key 被改了又改回去, 你压根察觉不到中间发生过啥;
轮询的根子病在于它只能看到"现在的快照", 看不到"发生过的事件序列"; Watch 拿"推送 + 有序不丢 + 能从历史续传"一次性把这仨补齐, 于是应用就能安心搭这么个东西: 本地存一份共享状态的实时镜像, 每来一个变更就触发一段反应逻辑, 也就是 List-Watch 模式;
落到具体应用:
| 应用 | Watch 在这干啥 |
|---|---|
| 动态配置 | watch 配置 key, 一改全实例热加载, 不重启也不轮询 |
| 服务发现 | watch /services/ 前缀, 实时维护一份活的 endpoint 列表 |
| Leader election | watch leader key, leader 一挂立刻感知然后接管 |
| 分布式锁/队列 | watch “排我前面那个 key”, 它一释放就精确把我叫醒 |
| k8s 控制器调谐 | watch 期望状态, 一变就触发 reconcile, 把实际拽向期望 |
reconcile: 控制器调谐到底是个啥
最后一行是 k8s 选 etcd 的根本理由, 单拎出来讲;
reconcile 这词的本义
它在会计里就是"对账、把两边弄一致": 你 APP 里记的余额和银行对账单本该一样, 不一样就逐项核对、调整, 直到对上; k8s 把这词原封不动搬了过来, 两份"账"换成了:
- 期望状态 (desired state): 你声明的、存在 etcd 里的 (“我要 3 个 Pod”);
- 实际状态 (actual state): 集群里真在跑的 (“现在只有 2 个 Pod”);
reconcile 就是一个循环跑的函数
1 | reconcile(): |
拿 Deployment 声明 replicas: 3 走一遍:
1 | 第1轮: desired=3, actual=2 → 差1 → 创建1个 Pod |
所谓控制器, 就是一个不停跑这个 reconcile 的死循环 (control loop);
关键的一点: 它是 level-triggered, 不是 edge-triggered
| 风格 | 怎么反应 | 毛病 |
|---|---|---|
| edge-triggered 边沿触发 | 盯"发生了什么变化" (“刚有人删了个 Pod, 我补一个”) | 漏一个事件, 状态就永久错了 |
| level-triggered 电平触发 | 盯"当前是什么状态" (“不管刚才发生过啥, 我现在看一眼差几个, 补几个”) | 漏事件无所谓, 下一轮重算就修正 |
k8s 的 reconcile 是 level-triggered 的; 这就解释了为啥 Watch 偶尔漏个事件、或者 relist 一下不致命:
Watch 事件在这儿其实只是个"醒一醒, 去重新对一次账"的闹钟; 真正干活的 reconcile 每次都重新读全量期望、重新看一眼实际、重算差距; 所以你漏了某个事件, 下次随便哪个事件 (或者定时 resync) 触发 reconcile, 照样能算出差距修回来;
也正因为这样, reconcile 必须是幂等的: 算出"差 0"就什么都不做, 重复跑也没副作用;
你在别处见过的 reconcile (Terraform 的 plan/apply、React 协调虚拟 DOM、Git merge) 本质都是同一个模式: 比较"想要的"和"现有的", 生成并执行抹平差异的动作; k8s 只是把它做成了集群级、永不停歇的基础范式;
谁在 watch 谁: Pod 要自己维持 Watch 吗
这是个特别常见的误解, 先甩结论:
普通应用 Pod 里的程序完全不需要、也根本不参与 Watch; Watch 是 k8s 控制面在"外面"自动维持的, 你的 Pod 对此一无所知;
1 | etcd |
两个关键事实:
- 只有 kube-apiserver 直连 etcd; 你的 Pod 永远不碰 etcd, 连它地址都不知道;
- 真正干 Watch 这活的是控制面组件 (controller-manager 里各种控制器、scheduler、kubelet), 它们 watch 的还是 apiserver (apiserver 再去 etcd 拿, 中间还垫了个 watch cache, 不然几千个 informer 能把 etcd 打穿);
心智模型纠一下: 你的 Pod 是"账本里被对账的那一行 (actual state)", 不是"对账的人"; Pod 崩了, 是控制器 watch 到"少了一个"然后补一个, 不是 Pod 自己 watch 自己、自己救自己; 所以应用层啥 watch 都不用写、不用心跳上报、不用维持连接, 这正是声明式最爽的地方: 应用无感;
唯一的例外是你自己写控制器/Operator (ingress-controller、各种 Operator、service mesh 控制面) 的时候, 这个 Pod 本身就是个 watcher 了; 但就算这样, 一来它 watch 的仍然是 apiserver, 绝不直连 etcd; 二来你也不用手撸 gRPC watch 流、重连、relist 那一套, 用官方的 client-go informer 框架就行: 注册几个事件回调 + 一个 reconcile 函数, watch 的生命周期 (连接、重连、relist、本地缓存) 全由库替你维持, 你只管写"差距出现时干啥";
k8s 怎么把这套吃下去 (List-Watch 全景)
informer/reflector 基本就是 Watch 语义的教科书式用法:
- List: 先全量拉一次某个资源 (比如所有 Pod), 拿到
resourceVersion = RV0; 这个resourceVersion本质就是 etcd 的 revision (对上层是个不透明字符串); - Watch: 从
RV0 + 1开始 watch, 增量事件灌进本地缓存 (Indexer/Store), 控制器从本地缓存做 reconcile, 这就是调谐数据的来路; - Relist (兜底): watch 报
410 Gone / too old resourceVersion(底层就是 etcdErrCompacted), 说明你落后到被压缩了; reflector 就把缓存一扔, 重新 List、重新 Watch; 跟前面 Watch 那节的续传套路一模一样;
两个 k8s 特有的优化:
- Watch Cache: apiserver 在内存里维护一圈最近事件的环形缓冲, 几千个 informer 都从这个 cache 拿, 而不是各自打穿到 etcd;
- Bookmark 事件: apiserver 周期性发一个只带 resourceVersion、没有实际变化的 bookmark, 让闲着的 watcher 也能把自己的 RV 往前挪 (跟 etcd 的 progress_notify 一个思路), 减少 relist;
小结: 一条链路
1 | Raft(有序日志共识) |
所以 etcd 的角色, 与其说是"存数据的数据库", 不如说是"整个集群协调逻辑的事件骨架": MVCC 提供时间轴, Watch 把时间轴变成可靠事件流, reconcile 在事件流上做幂等对账; 这三层叠起来, k8s "你声明期望、系统自己收敛"这件事才在工程上真正成立;
待填 / 下一步
- [ ] Lease + leader election 实操: 上表"成员管理/选主"那一格的具体实现;
- [ ] 线性一致读 (ReadIndex): 读侧怎么保证不读到旧 Leader 的脏数据 (serializable vs linearizable read);
- [ ] Txn / CAS 乐观并发的实际写法;
- [ ] 存储与持久化: bbolt 后端 + WAL + snapshot, Raft 日志怎么落盘、怎么截断;
- [ ] 回到 k8s: 抓一眼 apiserver 访问 etcd 的实际 key 结构 (
/registry/...);
