上一篇讲完了 API Server 的请求管道。管道的终点是 etcd。这一篇进入 etcd 内部。

etcd 经常被简单地描述为"分布式 KV 存储"。这个描述成立,但掩盖了一个关键点:etcd 不是 Redis,也不是 Zookeeper,它是一个强一致的、以 Raft 为共识机制的、支持 Watch 的版本化存储。Kubernetes 之所以选择 etcd,正是因为这三个性质缺一不可——缺少任何一个,API Server 的 List-Watch 机制就无法建立在它之上。

本文只问一个问题:etcd 的哪些设计让 Kubernetes 的 List-Watch 机制成为可能?

etcd 在集群中的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────────────────────────────────────────────────┐
│ Kubernetes Control Plane │
│ │
│ kubectl ──► kube-apiserver ──► etcd cluster
│ │ │ │
│ controller-mgr ──┤ │ Raft replication
│ scheduler ───────┤ │ (leader→follower)│
│ kubelet ─────────┘ │ │
│ ┌────┴────┐ │
│ │ etcd │ │
│ │ leader │ │
│ └────┬────┘ │
│ ┌────────┴────────┐ │
│ ┌───┴───┐ ┌───┴───┐ │
│ │etcd-2 │ │etcd-3 │ │
│ │follwer│ │follwer│ │
│ └───────┘ └───────┘ │
└─────────────────────────────────────────────────────┘

所有写入路径:client → API Server → etcd leader → quorum ACK → 返回
所有读取路径:client → API Server → etcd(可读 follower,带 linearizable 选项)

etcd 是控制平面唯一的持久化存储,所有 Kubernetes 对象(Pod、Service、ConfigMap、RBAC 规则……)都以 protobuf 编码存在 etcd 中。API Server 的内存 watch cache 是 etcd 数据的投影,而非独立数据源。

Raft 基础:写入如何达到强一致

etcd 使用 Raft 共识算法保证多节点间的数据一致性。理解 Raft,才能理解"为什么 etcd 是强一致的"。

Leader Election

Raft 集群在任意时刻只有一个 leader。Election 过程:

1
2
3
4
5
6
7
初始状态:所有节点都是 follower,等待 leader 心跳
↓(election timeout 超时,没收到心跳)
节点 A 成为 candidate,term++ ,向所有节点发 RequestVote
↓(多数节点(N/2+1)返回 VoteGranted)
A 成为 leader,开始发送心跳(AppendEntries,无日志条目)
↓(其他节点收到心跳)
其他节点重置 election timeout,继续作为 follower

term(任期)是单调递增的整数,每次选举加一。节点发现更高 term 的消息时立即退化为 follower。这保证了在网络分区后恢复时,旧 leader 不会继续接受写入。

Log Replication 与 Quorum Commit

写入流程:

1
2
3
4
5
6
7
8
客户端写入 → leader 接收
leader 追加日志条目(index, term, command)到本地 log
leader 并行发送 AppendEntries 给所有 follower
follower 写本地 log,回复 ACK
leader 收到 ⌊N/2⌋+1 个 ACK(包括自身)→ 提交(commit)
leader 回复客户端成功
leader 在下一次心跳通知 follower 提交该条目
follower 应用该条目到状态机

关键:leader 不等所有 follower ACK,只需多数(quorum)。这意味着:3 节点集群可容忍 1 个节点故障;5 节点集群可容忍 2 个节点故障。节点数量是奇数最优,因为偶数节点无法提供比少一个奇数节点更高的容错能力,却增加了通信开销。

Raft 的强一致性保证:所有提交的日志条目都被多数节点持久化,因此在任何故障后恢复的新 leader 一定包含所有已提交的条目(Leader Completeness Property)。

etcd MVCC:版本化存储的核心

etcd v3 使用多版本并发控制(MVCC)存储数据。每次写入(put 或 delete)都递增一个全局的 revision 计数器,并保留历史版本。

1
2
3
4
5
6
7
8
9
初始状态:revision = 0

PUT /foo = "v1" → revision = 1, 存储 (/foo, mod_revision=1) = "v1"
PUT /bar = "v2" → revision = 2, 存储 (/bar, mod_revision=2) = "v2"
PUT /foo = "v3" → revision = 3, 存储 (/foo, mod_revision=3) = "v3"
(/foo, mod_revision=1) = "v1" ← 旧版本保留

GET /foo at revision=1 → "v1" ← 时间旅行读取
GET /foo at revision=3 → "v3" ← 当前版本

每个 key 有两个重要的 revision 元数据:

  • create_revision:该 key 首次创建时的全局 revision
  • mod_revision:该 key 最后一次修改时的全局 revision

Kubernetes 对象的 metadata.resourceVersion 就是 etcd 中该 key 的 mod_revision。这解释了为什么 resourceVersion 是单调递增但不连续的:集群中任何对象的任何写入都会递增全局 revision,你的 Pod 的 resourceVersion 会受到完全无关的 ConfigMap 写入的影响(因为全局 revision 在增加)。

Kubernetes 对象在 etcd 中的存储布局

1
2
3
4
5
6
7
8
etcd key 格式:/registry/{resource-group}/{resource-type}/{namespace}/{name}

/registry/pods/default/nginx-xxx-yyy
/registry/services/endpoints/kube-system/kube-dns
/registry/deployments/apps/production/api-server
/registry/secrets/default/my-token
/registry/configmaps/kube-system/kubeadm-config
/registry/clusterroles//cluster-admin ← 集群级资源,namespace 段为空

value 是 protobuf 编码的 API 对象(带有 magic byte 前缀 k8s\x00 标识格式版本)。etcd 中存储的是 hub version(API Server 的内部版本),而不是用户请求的 API version。

这个 key 结构允许高效的前缀查询:

1
2
3
4
5
# 列出 default 命名空间所有 Pod
etcdctl get /registry/pods/default/ --prefix

# 列出所有命名空间的 Service
etcdctl get /registry/services/ --prefix

API Server 的 List 操作就是对应的 etcd prefix range query。

Watch 原理:基于 Revision 的流式推送

etcd Watch 是 List-Watch 机制的物理基础。

1
2
3
4
5
6
7
客户端: Watch /registry/pods/default/ --prefix, startRevision=1000

etcd: 从 revision 1000 开始,将后续所有匹配前缀的写入事件流式推送给客户端

PUT /registry/pods/default/nginx (revision=1001) → 推送 PUT 事件
DELETE /registry/pods/default/old-pod (revision=1002) → 推送 DELETE 事件
PUT /registry/pods/production/other (revision=1003) → 前缀不匹配,不推送

Watch 事件结构:

1
2
3
4
5
6
7
8
9
10
11
{
"type": "PUT",
"kv": {
"key": "/registry/pods/default/nginx",
"value": "<protobuf bytes>",
"create_revision": 800,
"mod_revision": 1001,
"version": 3
},
"prev_kv": { ... } // 修改前的值(需要开启 WithPrevKV 选项)
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
阶段 1:List(打快照)
GET /registry/pods/default/ --prefix
→ 返回当前所有 Pod + header.revision = 5000

阶段 2:Watch(续流)
Watch /registry/pods/default/ --prefix, startRevision = 5001
→ 从 5001 开始接收增量事件

断线重连:
记住最后收到的 revision = 5300
Watch startRevision = 5301
→ 无缝续流,不漏事件

compact 导致 ErrCompacted:
重新执行阶段 1,获取新快照 revision
从新 revision 开始 Watch

没有 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
2
3
4
5
6
7
8
kubelet 启动 → 在 kube-node-lease 命名空间创建同名 Lease 对象
→ 每 10 秒(默认)续租一次(PUT Lease 对象)

kube-controller-manager 的 node-lifecycle-controller
→ watch Lease 对象
→ 若某节点的 Lease 超过 40 秒(默认)未续租
→ 标记节点为 NotReady
→ 超过一定时间后驱逐节点上的 Pod

在引入 Lease 之前(Kubernetes 1.13 之前),kubelet 每次心跳都要更新 Node 对象的 status.conditions(一个较大的对象)。大量节点同时心跳会给 etcd 造成巨大的写入压力。改用 Lease 后,心跳只更新一个小 Lease 对象,Node 对象只在状态真正变化时更新,写入量大幅下降。

Compaction 与 Defrag:空间管理

etcd 保留所有历史 revision 会无限消耗磁盘空间,需要定期 compact。

Compaction 的效果:

1
2
3
4
5
6
7
compact 到 revision 5000 之前:
/foo 在 revision 1、3、5、5000 都有版本,共 4 条记录

compact 到 revision 5000 之后:
只保留 /foo 在 revision 5000 的版本
revision 1、3、5 的历史版本被删除
startRevision < 5000 的 Watch 请求返回 ErrCompacted

Compaction 本身不释放磁盘空间——它只是从 BoltDB(etcd 的底层存储引擎)的逻辑上删除记录,实际文件大小不变。释放物理空间需要 defrag(碎片整理):

1
2
3
4
etcdctl defrag --endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key

defrag 会短暂锁定数据库,生产环境应在低峰期对每个节点逐一执行(先 follower 后 leader)。

生产推荐配置:--auto-compaction-mode=revision --auto-compaction-retention=10000(保留最近 10000 个 revision 的历史),或 --auto-compaction-mode=periodic --auto-compaction-retention=1h(保留最近 1 小时的历史)。

可观察实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 前提:kind 集群已运行
# 进入 etcd 容器
kubectl exec -it -n kube-system etcd-kind-control-plane -- sh

# 设置 etcdctl 环境变量(在容器内)
export ETCDCTL_API=3
export ETCDCTL_CACERT=/etc/kubernetes/pki/etcd/ca.crt
export ETCDCTL_CERT=/etc/kubernetes/pki/etcd/server.crt
export ETCDCTL_KEY=/etc/kubernetes/pki/etcd/server.key
export ETCDCTL_ENDPOINTS=https://127.0.0.1:2379

# 1. 读取一个 Pod 对象的原始存储(protobuf,不可读)
etcdctl get /registry/pods/default/nginx-xxx 2>/dev/null | xxd | head -5

# 2. 列出所有 Deployment 的 key
etcdctl get /registry/deployments/ --prefix --keys-only

# 3. 观察全局 revision 随写入递增
etcdctl get /registry/pods/default/ --prefix --write-out=json | jq '.header.revision'
# 在另一个终端创建 Pod,回来再查 revision(应该增大了)

# 4. Watch etcd 原始事件流
etcdctl watch /registry/pods/default/ --prefix &
# 在另一个终端:kubectl run watch-test --image=nginx
# 观察 etcd watch 事件(是 PUT 事件,value 是 protobuf bytes)

# 5. 查看 Lease(节点心跳)
etcdctl get /registry/leases/kube-node-lease/ --prefix --keys-only

# 6. 查看 etcd 集群状态(revision, leader, db size)
etcdctl endpoint status --write-out=table

实验映射到内部机制

当执行 kubectl run watch-test --image=nginx 时,etcd 内部的事件序列:

  1. API Server 收到 POST /api/v1/namespaces/default/pods
  2. 经过 Admission 管道后,API Server 调用 etcd 的 txn 接口:if revision == current_revision: put /registry/pods/default/watch-test <protobuf>
  3. etcd leader 接收请求,追加日志条目,等待 quorum ACK
  4. 多数节点 ACK 后,leader 提交,全局 revision 递增(假设从 5000 变为 5001)
  5. etcd 触发所有 watch /registry/pods/default/ 的客户端(包括 API Server 的 cacher)
  6. API Server cacher 收到 PUT 事件,更新内存 watch cache,广播给所有监听该前缀的 watcher
  7. kube-scheduler、kube-controller-manager 中的 Informer 收到事件,触发调度或控制循环

kubectl 收到响应后,Pod 对象的 metadata.resourceVersion 就是步骤 4 中的全局 revision(5001)。

练习

  1. 在 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 的响应情况。

  2. 编写一个脚本,在 10 秒内创建 100 个 ConfigMap(kubectl create configmap cm-{i} --from-literal=key=value),然后用 etcdctl endpoint status 查看 revision 增长量和 db size 增量。再执行 etcdctl compactetcdctl defrag,观察 db size 的变化。

  3. 用 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 节点没有实际意义。

系列导航

参考资料