etcd 与持久化:集群状态的唯一事实来源
上一篇讲完了 API Server 的请求管道。管道的终点是 etcd。这一篇进入 etcd 内部。
etcd 经常被简单地描述为"分布式 KV 存储"。这个描述成立,但掩盖了一个关键点:etcd 不是 Redis,也不是 Zookeeper,它是一个强一致的、以 Raft 为共识机制的、支持 Watch 的版本化存储。Kubernetes 之所以选择 etcd,正是因为这三个性质缺一不可——缺少任何一个,API Server 的 List-Watch 机制就无法建立在它之上。
本文只问一个问题:etcd 的哪些设计让 Kubernetes 的 List-Watch 机制成为可能?
etcd 在集群中的位置
1 | |
etcd 是控制平面唯一的持久化存储,所有 Kubernetes 对象(Pod、Service、ConfigMap、RBAC 规则……)都以 protobuf 编码存在 etcd 中。API Server 的内存 watch cache 是 etcd 数据的投影,而非独立数据源。
Raft 基础:写入如何达到强一致
etcd 使用 Raft 共识算法保证多节点间的数据一致性。理解 Raft,才能理解"为什么 etcd 是强一致的"。
Leader Election
Raft 集群在任意时刻只有一个 leader。Election 过程:
1 | |
term(任期)是单调递增的整数,每次选举加一。节点发现更高 term 的消息时立即退化为 follower。这保证了在网络分区后恢复时,旧 leader 不会继续接受写入。
Log Replication 与 Quorum Commit
写入流程:
1 | |
关键:leader 不等所有 follower ACK,只需多数(quorum)。这意味着:3 节点集群可容忍 1 个节点故障;5 节点集群可容忍 2 个节点故障。节点数量是奇数最优,因为偶数节点无法提供比少一个奇数节点更高的容错能力,却增加了通信开销。
Raft 的强一致性保证:所有提交的日志条目都被多数节点持久化,因此在任何故障后恢复的新 leader 一定包含所有已提交的条目(Leader Completeness Property)。
etcd MVCC:版本化存储的核心
etcd v3 使用多版本并发控制(MVCC)存储数据。每次写入(put 或 delete)都递增一个全局的 revision 计数器,并保留历史版本。
1 | |
每个 key 有两个重要的 revision 元数据:
create_revision:该 key 首次创建时的全局 revisionmod_revision:该 key 最后一次修改时的全局 revision
Kubernetes 对象的 metadata.resourceVersion 就是 etcd 中该 key 的 mod_revision。这解释了为什么 resourceVersion 是单调递增但不连续的:集群中任何对象的任何写入都会递增全局 revision,你的 Pod 的 resourceVersion 会受到完全无关的 ConfigMap 写入的影响(因为全局 revision 在增加)。
Kubernetes 对象在 etcd 中的存储布局
1 | |
value 是 protobuf 编码的 API 对象(带有 magic byte 前缀 k8s\x00 标识格式版本)。etcd 中存储的是 hub version(API Server 的内部版本),而不是用户请求的 API version。
这个 key 结构允许高效的前缀查询:
1 | |
API Server 的 List 操作就是对应的 etcd prefix range query。
Watch 原理:基于 Revision 的流式推送
etcd Watch 是 List-Watch 机制的物理基础。
1 | |
Watch 事件结构:
1 | |
startRevision 参数允许客户端指定从哪个历史点开始接收事件。当 API Server 重启后,它从上次保存的 resourceVersion 继续 watch,不会漏掉中间的变更。
但 etcd 不会永远保留历史版本——compaction 会清理旧 revision。若 startRevision 早于 compact revision,etcd 返回 ErrCompacted,API Server 收到这个错误后需要重新 List 获取快照,再从当前 revision 开始 Watch——这就是 API Server 的 list-watch 重建逻辑。
List-Watch 分离:避免全量重传的核心设计
Kubernetes 的 List-Watch 协议利用 MVCC revision 实现了高效的断点续传:
1 | |
没有 MVCC,就无法做到"List 快照 + Watch 续流",必须每次断线都全量重传。对于大型集群(数万个 Pod),全量重传会造成 API Server 和 etcd 的严重压力——这正是 Kubernetes 早期 v2 API(无 MVCC)遇到的问题,也是 v3 API 引入 MVCC 的核心动机。
Lease:轻量级心跳机制
etcd Lease 是有 TTL 的租约。客户端必须在 TTL 过期前续租(keepalive),否则租约到期,所有关联了该租约的 key 自动删除。
Kubernetes 用 Lease 实现 kubelet 节点心跳:
1 | |
在引入 Lease 之前(Kubernetes 1.13 之前),kubelet 每次心跳都要更新 Node 对象的 status.conditions(一个较大的对象)。大量节点同时心跳会给 etcd 造成巨大的写入压力。改用 Lease 后,心跳只更新一个小 Lease 对象,Node 对象只在状态真正变化时更新,写入量大幅下降。
Compaction 与 Defrag:空间管理
etcd 保留所有历史 revision 会无限消耗磁盘空间,需要定期 compact。
Compaction 的效果:
1 | |
Compaction 本身不释放磁盘空间——它只是从 BoltDB(etcd 的底层存储引擎)的逻辑上删除记录,实际文件大小不变。释放物理空间需要 defrag(碎片整理):
1 | |
defrag 会短暂锁定数据库,生产环境应在低峰期对每个节点逐一执行(先 follower 后 leader)。
生产推荐配置:--auto-compaction-mode=revision --auto-compaction-retention=10000(保留最近 10000 个 revision 的历史),或 --auto-compaction-mode=periodic --auto-compaction-retention=1h(保留最近 1 小时的历史)。
可观察实验
1 | |
实验映射到内部机制
当执行 kubectl run watch-test --image=nginx 时,etcd 内部的事件序列:
- API Server 收到 POST
/api/v1/namespaces/default/pods - 经过 Admission 管道后,API Server 调用 etcd 的 txn 接口:
if revision == current_revision: put /registry/pods/default/watch-test <protobuf> - etcd leader 接收请求,追加日志条目,等待 quorum ACK
- 多数节点 ACK 后,leader 提交,全局 revision 递增(假设从 5000 变为 5001)
- etcd 触发所有 watch
/registry/pods/default/的客户端(包括 API Server 的 cacher) - API Server cacher 收到 PUT 事件,更新内存 watch cache,广播给所有监听该前缀的 watcher
- kube-scheduler、kube-controller-manager 中的 Informer 收到事件,触发调度或控制循环
kubectl 收到响应后,Pod 对象的 metadata.resourceVersion 就是步骤 4 中的全局 revision(5001)。
练习
-
在 kind 集群中,用
etcdctl endpoint status --write-out=table观察当前 leader 节点。然后删除 kind 集群的 etcd leader 容器(docker stop kind-control-plane后重启,或用kubectl delete pod etcd-xxx -n kube-system),观察 etcd 集群完成新一轮选举需要多长时间,以及期间kubectl get pods的响应情况。 -
编写一个脚本,在 10 秒内创建 100 个 ConfigMap(
kubectl create configmap cm-{i} --from-literal=key=value),然后用etcdctl endpoint status查看 revision 增长量和 db size 增量。再执行etcdctl compact和etcdctl defrag,观察 db size 的变化。 -
用 etcdctl watch 监听
/registry/pods/default/ --prefix,同时在另一个终端对一个 Deployment 执行滚动更新(修改镜像版本)。观察 watch 流中每个 Pod 经历的事件序列(创建新 Pod → 多次 PUT 表示状态变化 → 删除旧 Pod),将事件序列与 Pod 生命周期状态机(Pending → ContainerCreating → Running)对应起来。
模式提炼
etcd 在 Kubernetes 中扮演的角色可以概括为"带版本历史的强一致 KV + 发布订阅总线",而不是通用数据库。
强一致 KV 方面:Raft 协议保证任意时刻只有一个 leader 接受写入,所有已提交写入按全局 revision 严格排序,任意节点读到的数据要么是最新的,要么明确告知需要等待(linearizable read)。这个保证使 API Server 可以放心地用 resourceVersion 做乐观锁——如果两个并发写入都携带相同的 resourceVersion,etcd 事务保证只有一个能成功,另一个返回失败,不会产生写入竞争导致的数据损坏。
版本历史方面:MVCC 使 Watch 从任意历史位置续流成为可能,是 List-Watch 协议的物理基础。没有 MVCC,断线重连的唯一选择是全量重传——对大型集群(数万对象)这会造成严重压力。compaction 是版本历史的生命周期管理,清理过旧的历史以控制磁盘使用,同时通过 Bookmark 事件帮助客户端推进本地记录的 revision,降低 compaction 触发全量重传的频率。
发布订阅方面:每次写入都通过 Watch 机制广播给所有订阅者,事件按 revision 严格有序,不遗漏,不乱序。这是 Kubernetes 所有控制器从"感知变化"到"执行调谐"的信息管道。etcd 在这里既是存储又是消息总线,两者共享同一个数据模型(key-value + revision),不需要额外的消息中间件。
工程迁移表
| etcd 机制 | 工程类比 | 关键相似点 |
|---|---|---|
| Raft leader 选举 | MySQL MHA / Zookeeper leader election | 多节点共识,同一时刻只有一个写入点,防止脑裂 |
| Raft 日志复制 + quorum | MySQL Group Replication (paxos) | 写入需要多数节点确认才提交,一致性优先于可用性 |
| 全局 revision (MVCC) | MySQL GTID / PostgreSQL LSN / Kafka offset | 全局单调递增的写入序列号,用于复制位点和增量追追 |
| Watch from revision | Kafka consumer 从指定 offset 消费 | 消费者从指定位置开始接收增量,不全量重传 |
| Compaction | MySQL binlog purge / Kafka log retention | 清理历史数据,保留近期快照,控制磁盘用量 |
| Defrag | MySQL OPTIMIZE TABLE / PostgreSQL VACUUM FULL | 逻辑删除后物理回收空间,需要短暂暂停服务 |
| etcd Lease (TTL key) | Redis key TTL / 数据库心跳行 | 客户端需定期续约,超时后数据自动失效,实现心跳检测 |
| etcd snapshot | mysqldump + binlog / pg_basebackup | 全量快照加增量日志,备份恢复的标准模式 |
常见误解
误解一:etcd 性能很强,可以存大对象,不需要担心容量。
事实是 etcd 有严格的大小限制,单个 value 默认上限 1.5MB,官方建议不超过 8MB(即使调高 --max-request-bytes)。etcd 基于 Raft 日志复制,大 value 意味着更大的网络消息和更长的 fsync 时间,直接影响写入延迟和集群稳定性。Kubernetes 有意只把元数据(对象定义、状态、引用)存入 etcd,实际数据(镜像层、日志、指标)放在外部系统。如果 ConfigMap 需要存储大量配置内容,应考虑只存引用(S3 路径、数据库主键),而不是把内容直接写入 etcd。
误解二:etcd Watch 会丢事件,控制器需要定时轮询兜底。
etcd Watch 在连接存活期间不会丢失事件。Raft 保证每次写入有且仅有一次,Watch 按 revision 严格有序推送。“丢事件"只发生在两种情况:Watch 连接断开、且重连时提供的 revision 已被 compaction 清理。Kubernetes 通过 Informer 的 reflector 处理这种情况——收到 ErrCompacted 时重新 List 打快照,再从当前 revision 续流。整个过程对控制器透明,控制器的 Reconcile 函数只需要处理"当前状态”,不需要关心是否漏过了中间事件。额外的定时轮询只会增加 etcd 读压力,不能解决任何实际问题。
误解三:etcd 集群只能是 3 节点,节点数越多越好。
3 节点是最常见的生产配置,允许 1 个节点故障,对大多数场景够用。5 节点允许 2 个节点故障,适合对可用性要求更高的场景(如跨可用区部署,每个 AZ 各 1-2 节点,允许一个 AZ 整体故障)。7 节点以上收益递减:每次写入需要等待 4 个节点 ACK,延迟显著增加,而额外的容错能力(允许 3 节点故障)在实践中很少用到。节点数必须为奇数——4 节点需要 3 个 ACK(quorum = 3),与 3 节点相同(quorum 也是 2,但 4 节点 quorum 实际是 3),容错能力没有提升,写延迟却更高,所以 4 节点没有实际意义。
系列导航
- 00 核心概念导读
- 01 API Server 与声明式 API
- 02 etcd 与持久化
- 03 控制器模式与 Informer 机制
- 04 Pod 生命周期
- 05 调度器深入
- 06 kubelet 与容器运行时
- 07 网络模型与 CNI
- 08 Service 与 kube-proxy
- 09 Ingress 与 Gateway API
- 10 存储体系
- 11 配置与密钥管理
- 12 RBAC 与安全模型
- 13 资源管理与自动伸缩
- 14 CRD 与 Operator 模式
- 15 Helm 与应用打包
- 16 可观测性
- 17 生产集群运维
参考资料
- etcd Documentation: https://etcd.io/docs/
- etcd Data Model (MVCC internals): https://etcd.io/docs/v3.5/learning/data_model/
- Kubernetes etcd Cluster Administration: https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/
- Raft Extended Paper: https://raft.github.io/raft.pdf
- etcd Raft Implementation: https://github.com/etcd-io/raft
