跳转至

etcd:Kubernetes 的状态存储

etcd 是 Kubernetes 控制面的持久状态存储。API Server 通过存储层读写 etcd,Controller、Scheduler 和 Kubelet 则通过 Kubernetes API 读取或更新对象。

创建 default/vllm-demo Deployment 时,API Server 将对象写入 /registry/deployments/default/vllm-demo,etcd 为这次修改分配新的 revision。API Server 返回 201 Created 后,可以使用 Kubernetes API 重新读取该 Deployment;后续组件也从 Kubernetes API 获取对象变化。

etcd 组件

一个 etcd member 对外提供 gRPC API,内部使用 Raft 复制有序日志,再将已经提交的操作交给 MVCC。Raft 日志写入 WAL,MVCC 维护 key 的当前值和历史版本,backend 将已经 apply 的状态保存到本地数据库。成员之间通过 Raft peer transport 通信,与客户端访问的 gRPC 接口分开。

下图展示了一个 etcd member 的主要组件以及它与 Client、其他 member 的连接关系。

etcd member 的内部组件,包括 gRPC、etcd server、Raft、MVCC、WAL、backend 和 Peer 通信

图:etcd member 的主要组件。来源:etcd v3.7.1 etcd_internal_parts.png,Apache-2.0。

Client 请求通过 gRPC 进入 etcd server。需要共识的操作交给 Raft,由 Leader 确定日志顺序并复制给其他投票成员;已经提交的操作再由 MVCC 执行。WAL 保存 Raft 日志和共识状态,backend 保存 MVCC 已经 apply 的结果,两者记录的是写入过程中的不同阶段。

图中的 BoltDB 是旧名称,etcd v3.7 的 backend 使用 bbolt。

etcd 集群成员

etcd 的投票成员通过 Raft 共同维护一条有序日志。投票成员在运行过程中处于 Leader、Follower 或 Candidate 状态;一条日志获得多数投票成员(Quorum)确认后才能提交。

Leader、Follower 与 Candidate

Raft 每轮正式选举使用一个单调递增的 term,同一个 term 最多选出一个 Leader。Leader、Follower 和 Candidate 是投票成员的三种主要运行状态:

  • Leader:为需要共识的请求确定日志顺序,将日志复制给 Follower,并通过周期性心跳维持领导权。请求可以发送到任意投票成员,需要共识的请求到达 Follower 后会转发给 Leader。
  • Follower:接收 Leader 的心跳和日志复制请求,将日志持久化到本机 WAL,并按提交顺序应用日志。Follower 也在选举中投票。
  • Candidate:发起正式选举,增加 term,先给自己投票,再向其他投票成员请求选票。取得多数票后成为 Leader;收到更高 term 的消息或确认有效 Leader 后退回 Follower。

etcd v3.7 默认启用 Pre-Vote。Follower 超过 election timeout 后先发起预选举,确认自己可能取得多数票,再进入 Candidate 状态并增加 term。网络分区中的孤立成员因此不会反复抬高 term,降低它重新连接后干扰现有 Leader 的概率。相关开关和默认值见 etcd Configuration options。

Leader 故障后,投票成员需要重新选举。选举完成前,需要集群共识的请求会等待或失败;新 Leader 继续使用已经提交的日志,尚未提交的日志可能被覆盖。etcd FAQ说明了客户端请求在 Leader 与 Follower 之间的处理方式。

经典交互动画:The Secret Lives of Data — Raft

The Secret Lives of Data:Raft 是 Ben Johnson 制作的 Raft 交互动画,演示角色转换、选举超时、split vote、日志复制和网络分区。

Learner

成员变更一直是 etcd 集群运维中的难点。etcd Learner 设计文档列出了几种常见问题:

  • 新成员增加 Leader 负载:新成员没有已有日志和 backend 数据,需要从 Leader 接收 Snapshot 和后续日志。持续的数据同步会占用 Leader 的网络带宽,心跳延迟或丢失可能使 Follower 触发新的选举。
  • Quorum 在节点就绪前发生变化:三个投票成员加入第四个投票成员后,Quorum 会从 2 增加到 3。如果新进程尚未启动,原来的三个投票成员必须全部在线才能提交日志。单成员集群加入第二个投票成员时,同样会在新进程启动前把 Quorum 从 1 提高到 2。
  • 网络分区的影响被放大:四个投票成员分成 2 + 2 两个分区时,两侧都无法达到 Quorum 3。如果三成员集群已经有一个 Follower 失联,此时再加入第四个投票成员,也会让仍然在线的两个成员失去 Quorum。
  • 配置错误可能难以撤销:错误的 peer URL、进程启动失败或网络不可达会让新投票成员一直无法工作。集群一旦失去 Quorum,删除错误成员本身也无法通过 Raft 提交。

为减轻上述成员变更造成的可用性风险,Raft §4.2.1 引入了 Learner:新成员先以非投票成员加入集群,追上 Leader 的日志后再晋升为投票成员。etcd 采用这一机制,将数据同步和获得投票资格分开处理:

  • 以非投票成员加入:Learner 不参与投票,也不计入 Quorum,原有投票成员继续使用原来的票数提交日志。
  • 先同步再参与选举:Leader 向 Learner 复制日志和 Snapshot,Learner 在不参加选举的情况下追赶 Leader。
  • 通过检查后晋升:member promote 检查 Learner 的日志进度。Learner 尚未追上 Leader 时,etcd 拒绝晋升请求。

使用 member add --learner 添加新成员后,集群仍由原来的三个投票成员计算 Quorum。下图中的 Quorum 保持为 2,Learner 只接收日志和 Snapshot。

三个投票成员的 etcd 集群添加 Learner 后,Quorum 仍然为 2

图:Learner 以非投票成员加入并同步数据。来源:etcd Learner design,Figure 10。

Learner 追上 Leader 的日志后,member promote 将它改为投票成员。下图中的投票成员数从 3 增加到 4,Quorum 随之从 2 增加到 3。

Learner 追上 Leader 后晋升为投票成员,Quorum 从 2 增加到 3

图:Learner 晋升为投票成员并计入 Quorum。来源:etcd Learner design,Figure 11。

Learner 仍会增加 Leader 的日志和 Snapshot 复制流量,也不会在晋升前提高集群可容忍的投票成员故障数。它解决的是成员变更的安全性,而不是读流量扩展或投票容错。

晋升之前,Learner 不参加选举,不能成为 Leader,也不处理客户端读写请求。Learner 不会自动晋升。集群运维人员确认其日志已经追上 Leader 后,需要执行 member promote 将它晋升为投票成员。

Figure 11 用于说明晋升如何改变 Quorum,不代表推荐长期运行四个投票成员。etcd FAQ 对成员数量的说明指出,三个和四个投票成员都只能容忍一个成员故障,使用奇数个投票成员可以避免增加复制开销却不增加故障容忍能力。

kubeadm 的 Learner 加入流程

Kubernetes v1.36 的 kubeadm 将新的 control-plane 节点加入 stacked etcd 时,依次完成以下操作:

  1. 将新成员添加为 Learner:CreateStackedEtcdStaticPodManifestFile 根据新节点的 API Endpoint 计算 etcd Peer URL,并连接现有的 stacked etcd 集群。它调用 AddMemberAsLearner 查询当前成员列表;如果相同的 Peer URL 尚未存在于成员列表中,kubeadm 通过 etcd gRPC API 将新成员添加为 Learner。此时成员关系中已经出现新节点,但新节点上的 etcd 进程还没有启动,Learner 不计入 Quorum。

  2. 启动本地 etcd:kubeadm 使用更新后的成员列表生成 etcd static Pod manifest。manifest 中的 --initial-cluster 包含全部现有成员和新 Learner,--initial-cluster-state 设置为 existing。Kubelet 读取 manifest 后启动 etcd 容器,新成员通过 Peer URL 连接集群并开始接收 Snapshot 和 Raft 日志。kubeadm 默认等待 2 分钟确认新成员进程已经启动;超时后 etcd-join 阶段返回错误,但成员记录和 static Pod manifest 不会回滚。Kubelet 仍会尝试启动该 Pod,Learner 也不会改变原集群的 Quorum。

  3. 晋升为投票成员:新成员进程启动后,kubeadm 根据 Peer URL 找到 Learner 的成员 ID,再通过 MemberPromote 请求晋升。日志尚未追上 Leader 时,etcd 拒绝请求,kubeadm 默认持续重试 2 分钟。重试超时后,etcd-join 阶段返回错误;正在运行的 Learner 继续同步,但会保持非投票成员身份,也不会自动晋升。日志追平后,可以重新执行 kubeadm join phase etcd-join --config join.yaml。晋升成功后,新成员开始计入 Quorum,kubeadm 将它的 Client URL 加入健康检查端点,并确认整个 etcd 集群可用。

上述等待时间由 JoinConfiguration.timeouts.etcdAPICall 设置。数据量较大或 Peer 网络较慢时,可以在执行 kubeadm join 前调大该值。

这套流程只适用于 kubeadm 管理的 stacked etcd。使用 External etcd 时,集群运维人员负责添加、启动和晋升成员,kubeadm 不修改外部 etcd 的成员关系。

etcd 读写与持久化

etcd 将一次写入分成共识和状态机应用两个阶段。Raft 先让投票成员对日志的内容与顺序达成一致;日志提交后,apply loop 将它交给 MVCC,MVCC 为键值变化分配 revision 并更新 backend。多数投票成员的 WAL 保存已经提交的日志,backend 保存当前成员已经 apply 的状态。

机制 负责的工作 持久化位置
Raft 选举 Leader、复制有序日志、确认 committed entry member/wal/*.wal 保存 Raft Entry、HardState 和轻量 Raft Snapshot 记录
MVCC 维护键的历史版本、revision、事务和 Watch 事件 用户 key 到 revision 的 TreeIndex 位于内存,键值历史通过 backend 持久化
backend 使用 bbolt 事务保存已经 apply 的键值状态和 etcd 元数据 member/snap/db,底层为 bbolt B+tree

Raft

etcd 使用 Raft 让多个投票成员维护同一条有序日志。Leader 负责发起日志复制,Quorum 确认后形成 committed entry,再由 etcd 的 MVCC 状态机执行。

日志复制与提交

etcd 客户端可以连接任意投票成员。需要共识的请求到达 Follower 后,etcd 将请求交给当前 Leader;客户端不需要预先查询 Leader。etcd FAQ说明了这项请求转交机制。

请求到达 Leader

下图展示了写请求直接到达 Leader 的正常路径。图中的编号从 gRPC 请求开始,经过 Raft 日志复制、提交和状态机 apply,最后返回成功响应。

写请求到达 etcd Leader 后,Leader 和 Follower 持久化 WAL,并在多数派确认后应用到 MVCC 与 backend

图:写请求直接到达 Leader 的处理流程。来源:etcd v3.7.1 write_workflow_leader.png,Apache-2.0。

图中的编号表示写入的逻辑阶段。Leader 本地 WAL 写入和 Raft peer 消息发送可以并行执行,发送给各个 Follower 的复制消息也彼此独立。

  1. 接收请求:Leader 的 gRPC endpoint 收到写请求。
  2. 提交 proposal:etcd server 将请求交给本地 Raft 模块。
  3. 持久化与复制:Leader 并行处理本地 WAL 和成员间复制。

    • 3':Leader 将 proposal 写入本机 WAL。
    • 3'':Leader 向各个 Follower 发送 Raft 日志复制消息。
  4. Follower 写入 WAL:Follower 先将收到的日志持久化到本机 WAL。

  5. Follower 返回确认:Follower 完成本地 WAL 写入后向 Leader 确认复制结果。
  6. 提交日志:Leader 收到 Quorum 确认后推进 commit index。三投票成员集群只需要 Leader 和任意一个 Follower;新的 commit index 随后异步发送给其他 Follower。
  7. 通知状态机:Raft 将 committed entry 交给 etcd server 的 apply loop。
  8. 更新键空间:apply loop 执行写入,MVCC 分配 revision 并更新 backend。
  9. 返回结果:本地 apply 完成后,etcd server 通过原 gRPC 连接返回成功响应。

图中的 BoltDB 对应当前的 bbolt backend。100ms 是 --backend-batch-interval 的默认最大批处理间隔;达到 batch limit、执行删除或触发强制提交时,backend 会提前提交。WAL 的提交不使用这个周期。

请求到达 Follower

写请求也可以先到达 Follower。Follower 将请求交给自己的 Raft 模块,再转发给 Leader;后续的日志排序、复制和多数派提交仍由 Leader 完成。

写请求到达 etcd Follower 后转发给 Leader,再经过 WAL 复制、提交以及 MVCC 与 backend apply

图:写请求先到达 Follower 的处理流程。来源:etcd v3.7.1 write_workflow_follower.png,Apache-2.0。

  1. 接收请求:Follower 的 gRPC endpoint 收到写请求,原 gRPC handler 留在该成员等待结果。
  2. 提交 proposal:Follower 的 etcd server 将请求交给本地 Raft 模块。
  3. 转发写请求:Follower 的 Raft 模块将写请求转发给 Leader,客户端连接仍由原 Follower 处理。
  4. Leader 持久化与复制:Leader 并行处理本地 WAL 和成员间复制。

    • 4':Leader 将 proposal 写入本机 WAL。
    • 4'':Leader 向其他投票成员发送 Raft 日志复制消息。
  5. 原 Follower 写入 WAL:最初接收请求的 Follower 持久化这条日志。

  6. 原 Follower 返回确认:Follower 向 Leader 确认 WAL 写入完成。
  7. Leader 通知提交进度:Leader 取得 Quorum 后推进 commit index,并把新的提交位置发送给 Follower。
  8. Follower 标记 committed:原 Follower 更新本地 commit index。
  9. 通知状态机:Follower 的 Raft 模块将 committed entry 交给本地 etcd server。
  10. 更新键空间:本地 apply loop 更新 MVCC 和 backend。
  11. 返回结果:原 Follower 完成本地 apply 后,通过最初的 gRPC 连接返回成功响应。

图中由原 Follower 的确认形成 Quorum;实际运行时,Leader 也可以先收到另一个 Follower 的确认。最初接收请求的 Follower 仍需追上 commit index 并完成本地 apply,才能向客户端返回。

Quorum 与故障容忍

Quorum 是 Raft 完成一次选举或提交日志所需的最少投票成员数。设当前集群配置了 N 个投票成员,则 Quorum = ⌊N/2⌋+1,可容忍的故障数为 N - Quorum。Leader 本身也占一票;提交日志时,它还需要收到至少 Quorum - 1 个其他投票成员的确认。

N 按集群成员配置计算,不是当前在线数量。一个投票成员停机或网络不可达后,在它被成员变更正式移除之前,Quorum 的计算仍然包含该成员。

投票成员数 Quorum 可容忍故障数
1 1 0
2 2 0
3 2 1
4 3 1
5 3 2

三成员集群中,Leader 与任意一个 Follower 即可提交写入。四成员集群仍然只能容忍一个成员故障,却要求三个成员确认写入,因此 etcd 通常使用奇数个投票成员。

失去多数派时,Raft 无法提交新的日志。网络分区后,多数派一侧继续形成有效集群;少数派一侧无法提交写入,也不会产生另一条已提交历史。多数成员永久丢失时,需要使用快照建立新的逻辑集群。故障边界见 etcd Failure modes 和 etcd Disaster recovery。

Kubernetes 官方 etcd 运维指南建议生产环境使用静态五成员 etcd,并定期备份。增加成员可以提高故障容忍能力,也会增加共识通信开销;etcd 成员复制同一份状态,不承担数据分片。

读取一致性

etcd 的 KV API 没有单独的 Get RPC。普通的 etcdctl get 调用 KV.Range:只设置 key 时读取单个 key,同时设置 key 和 range_end 时读取左闭右开区间 [key, range_end)。Range 默认执行线性一致读,返回的结果包含请求开始前已经完成的写入。

Range 请求与 ReadIndex

下面的命令先写入三个 key,再读取 /demo/config/ 前缀下的键值。

$ etcdctl put /demo/config/a alpha
OK
$ etcdctl put /demo/config/b beta
OK
$ etcdctl put /demo/other/c gamma
OK
$ etcdctl get /demo/config/ --prefix
/demo/config/a
alpha
/demo/config/b
beta

--prefix 将 /demo/config/ 的下一个字典序前缀 /demo/config0 作为右边界,对应下面的 RangeRequest。serializable: false 是默认值,表示使用线性一致读。

RangeRequest {
  key:          "/demo/config/"
  range_end:    "/demo/config0"
  serializable: false
}

ReadIndex 与请求读取哪个 key 无关。Leader 接收 ReadIndex 请求时,将当时的 raftLog.committed 保存为该请求的 ReadIndex,随后通过 Quorum heartbeat 确认自己仍是当前 term 的有效 Leader。接收 Range 请求的 member 等待本地 appliedIndex >= ReadIndex,再使用原始 key 和 range_end 查询 MVCC。因此,ReadIndex 表示本次读取必须观察到的 Raft 日志位置,不是 key 的版本或 MVCC Revision。对应赋值见 readOnly.addRequest 和 sendMsgReadIndexResponse。

下图展示了线性一致读到达 Follower 后,通过 Leader 确认 ReadIndex,再从本地 MVCC 读取数据的过程。

线性一致读到达 etcd Follower 后,通过 Leader 和 Quorum 确认 ReadIndex,再从本地 MVCC 读取

图:线性一致读到达 Follower 时的 ReadIndex 流程。来源:etcd v3.7.1 consistent_read_workflow.png,Apache-2.0。

设置 serializable=true 后,成员直接读取本地已经 apply 的状态,不再通过 ReadIndex 确认当前读取点。这样可以减少跨成员协调,但结果可能落后于 Leader。两种读取方式的接口语义见 etcd API 的 Range 请求。

etcd 的读取选项与 Kubernetes API 的 resourceVersion 处于不同层次。Kubernetes GET 和 LIST 先由 API Server 解释一致性要求,请求可能读取 Watch Cache,也可能进入 etcd;只有进入 etcd 的读取才会使用上述 Range 语义。

MVCC 与 Revision

Raft 负责在成员之间对写入排序、复制日志并确认提交。状态机随后按照日志顺序执行已经提交的请求,并把结果写入 MVCC 键空间。只有实际修改 KV 的事务才会产生新的 MVCC Revision;Raft 日志中的空 Entry、成员变更等操作不会增加 Revision。同一个 MVCC 事务修改多个 key 时,这些修改共享一个 main revision,并通过 sub revision 记录事务内的顺序。

MVCC(Multi-Version Concurrency Control)为同一个 key 保存当前值和历史值。etcd 更新 key 时写入新版本,旧版本继续保留,直到 Compaction 清理相应的历史数据。读请求可以指定 Revision 读取某个时间点的键空间,Watch 也使用 Revision 确定事件流的起点。etcd 官方的 Data model 介绍了这套多版本数据模型,etcd API 的 Revisions 定义了 Revision 的接口语义。

Revision 是整个集群 KV 键空间的全局逻辑版本号,使用 64 位整数表示。每个修改键空间的原子事务都会将 main revision 增加 1,读请求不会改变 Revision。同一事务修改多个 key 时,这些修改共享 main revision,并使用 sub revision 表示事务内的顺序。

标识 递增条件 作用
Raft log index 每条 Raft Entry 获得一个连续的 index 标识日志顺序和提交进度
MVCC main revision 原子事务实际修改 KV 键空间 标识一次键空间变更,支持历史读取和 Watch
MVCC sub revision 同一事务修改多个 key 标识各项修改在事务内的顺序

下图展示了 MVCC 读取一个 key 时经过的两级索引。左侧的内存 TreeIndex 按用户 key 查找 Revision;右侧的持久化 B+tree 再以 Revision 为 key,读取对应的 mvccpb.KeyValue。图中的 foo 先被解析为 Revision {8, 0},etcd 再使用该 Revision 读取 Value。

etcd MVCC 数据模型:内存 TreeIndex 将用户 key 映射到 Revision,持久化 B+tree 再按 Revision 读取 Value

图:MVCC 的内存索引与持久化数据。来源:etcd Data model,CC BY 4.0。

持久化 B+tree 使用 (major, sub, type) 组成内部 key:major 对应 main revision,sub 表示同一事务内的修改顺序,type 用于区分普通记录和删除记录。图中使用早期的 BoltDB 名称;当前 etcd backend 使用 bbolt 保存这部分数据。

每条键值记录还包含以下版本信息:

字段 含义
create_revision 当前这一代 key 首次创建时的 revision
mod_revision key 最近一次修改时的 revision
version 当前这一代 key 的修改次数;删除后重新创建会从头计数

假设 Deployment 在 revision 210 创建,随后在 revision 214 更新 status,那么它的 create_revision 仍为 210,mod_revision 变为 214,version 增加。

Backend 与 bbolt

bbolt 是一个使用 Go 编写的嵌入式 KV 数据库。它作为库运行在应用进程内,使用单个内存映射文件保存 B+tree。bbolt 提供可串行化的 ACID 事务。整个数据库同一时间只执行一个读写事务,一个事务可以修改多个 Bucket 和 key;多个只读事务可以并发执行,每个事务都能看到一致的数据视图。

Backend 是 etcd MVCC 的持久化层,它使用 bbolt 保存已经 apply 的键值状态和集群元数据,数据库文件位于 member/snap/db。MVCC 在内存中维护用户 key 到 revision 的索引;backend 以编码后的 revision 作为 key,将 mvccpb.KeyValue 保存为 value。etcd Data model说明了内存索引和持久化数据之间的对应关系。

持久化文件

etcd 在 --data-dir 下分别保存 Raft 状态和已经 apply 的状态机结果。etcd 持久化文件说明列出了运行过程中长期保留的文件及其恢复用途。

  • member/snap/db:主 backend 文件,使用 bbolt B+tree 保存已经 apply 的 v3 KV 数据、成员关系、授权信息、Lease、Alarm 和元数据。meta bucket 中的 consistent_index 记录该文件已经应用到的最后一个 Raft log index;成员重启后从这个位置判断哪些 WAL Entry 仍需 apply。
  • member/wal/<seq>-<index>.wal:Raft 的 Write-Ahead Log,按追加方式保存 Raftpb.Entry、Raftpb.HardState、轻量 walpb.Snapshot、CRC 以及集群和成员标识。文件名中的两个十六进制数分别表示 WAL 文件序号和该文件第一条 Entry 或 Snapshot 的 index。可以通过 --wal-dir 将 WAL 放到指定目录,其他文件仍保存在 --data-dir 下。
  • member/snap/<term>-<index>.snap:Raft Snapshot 文件,保存 term、index、ConfState 以及兼容格式的成员信息,不包含完整的 v3 KV 状态。本地周期性 Snapshot 和从 Leader 收到的 Raft Snapshot 都会生成这种文件。
  • member/snap/<index>.snap.db:落后成员无法通过现有 Raft Entry 追赶时,从 Leader 下载的完整 bbolt backend,内容类型与 member/snap/db 相同。接收完成后,etcd 使用它替换本地 backend 并恢复 MVCC、Lease、Auth 和成员配置;周期性 Snapshot 不会为每个 .snap 生成对应的 .snap.db。

WAL 记录与日志恢复

WAL(Write-Ahead Log)是每个 etcd member 本地保存的 Raft 日志。member 将 Raft Entry 和共识状态持久化到 WAL,重启时再结合 Snapshot 恢复 Raft 状态,并继续把已经提交的 Entry apply 到 backend。etcd WAL 文档说明了文件结构和恢复规则。

etcd WAL 只追加记录,不在原位置修改已经写入的 Entry。Leader 切换后,新 Leader 可以为同一个 index 追加新版本;恢复 WAL 时,后写入的版本替代旧版本。HardState.commit 标记已经提交的日志前缀,只有大于 commit index 的日志尾部仍可能被替换。

图中的颜色对应不同的 WAL 记录:

  • 深蓝色 Snapshot:记录 Raft Snapshot 的 term 和 index,作为日志恢复的起点;该记录不包含完整 KV 数据。
  • 浅蓝色 Entry:保存通过 Raft 复制的 proposal,既可能已经提交,也可能仍位于未提交的日志尾部。
  • 绿色 HardState:保存当前 term、投票对象和 commit index。
  • 虚线 Entry:表示已经被后续 Leader 取代的旧 Entry。灰色省略行表示图中没有逐条画出的 Entry 和 HardState。

从上往下读取这段 WAL 时间线:

  1. 已提交的日志前缀:Snapshot (Term: 2, Index: 6) 表示 index 6 及以前的 Raft 状态已经形成 Snapshot。Term 2 随后写入 index 7–9,HardState (Commit: 8) 将 index 7–8 标记为已提交;后续 Leader 必须保留截至 index 8 的日志前缀。
  2. 未提交的日志尾部:Term 2 的 index 9–10 大于 commit index 8。它们已经写入当前 member 的 WAL,但尚未获得 Quorum 确认,因此图中使用虚线表示。Leader 切换后,这两条记录仍可以被新日志取代。
  3. 新一轮选举:member 将新的 term 4 和投票结果写入 HardState,commit index 仍为 8。新 term 不会在 index 8 再写一条 Entry,因为该位置已经提交。
  4. Term 4 的新日志尾部:新 Leader 写入 Entry (Term: 4, Index: 9)。这条记录取代 Term 2 的旧 index 9,同时在逻辑上截断旧的 index 10;随后的 HardState (Commit: 9) 将 Term 4 的 index 9 标记为已提交,index 10–11 再从这个日志前缀继续追加。
  5. Snapshot 恢复:中间未画出的日志推进到 Snapshot (Term: 4, Index: 300)。落后过多的 member 可以从 Leader 接收该 Raft Snapshot 和完整的 .snap.db backend,安装完成后从 index 301 继续复制日志。
  6. 周期性 Snapshot:图中省略了 index 302 之后的 Entry 和 HardState。以上一个 Snapshot index 300 为起点,v3.7.1 默认 snapshot-count=10000 时,10301 - 300 = 10001,已经超过触发阈值,因此示例在 Term 5 的 index 10301 生成新 Snapshot。apply 以批次推进时,实际 Snapshot index 可能高于这个数值;默认值和判断条件见 etcd v3.7.1 Snapshot 配置与 shouldTriggerSnapshot。

修正后的 etcd WAL 时间线:Term 4 的 HardState 保持 Commit 8,新 Leader 从 Index 9 替换未提交日志

图:etcd WAL 中的 Entry、HardState 与 Raft Snapshot。根据 etcd Persistent storage files 重新绘制。

Kubernetes 对象的存储形式

API Server 的存储层将 Kubernetes 资源对象保存为键值记录。vllm-demo Deployment 对应的 key 为:

/registry/deployments/default/vllm-demo

这条记录包含三部分信息:

  • Key:/registry/deployments/default/vllm-demo 用于定位资源类型、namespace 和对象名称。/registry 下的路径属于 Kubernetes 存储实现,不是公开 API。
  • Value:保存当前完整的 Deployment 对象,包括 metadata、spec 和 status。它是 API Server 处理后的存储对象,不是用户提交的 YAML 文本,也不保留 YAML 注释和字段顺序。
  • etcd 元数据:create_revision、mod_revision 和 version 由 etcd 维护,位于 Value 之外。

在默认且未启用静态加密的配置下,Deployment 这类内置资源使用 Kubernetes Protobuf 编码。Value 的二进制结构为:

  • k8s\x00:开头四个字节,十六进制为 6b 38 73 00。
  • runtime.Unknown:外层 Protobuf 消息,typeMeta 记录 apps/v1 和 Deployment。
  • runtime.Unknown.raw:完整的 Deployment Protobuf,包含对象的 metadata、spec 和 status。

Kubernetes Protobuf 编码格式定义了 magic prefix 和 runtime.Unknown 封装;源码中的 prefix 定义与解码路径展示了对应实现。

CRD 定义的 Custom Resource 默认使用 JSON。启用 CBORServingAndStorage 后,Custom Resource 可以使用 CBOR 作为存储编码。EncryptionConfiguration 会在序列化结果写入 etcd 前增加加密包络,此时 Value 不再以 k8s\x00 开头。

使用 etcdctl 检查 Deployment 的原始 Value

--write-out=json 将 etcd 的查询响应封装为 JSON 文本。JSON 不能直接表示任意二进制数据,因此 kvs[].key 和 kvs[].value 使用 Base64 编码。这里生成的 /tmp/vllm-demo-etcd.json 是 JSON 文件;其中的 value 经过 Base64 解码后,才是 etcd 保存的 Kubernetes Protobuf 二进制数据。这个选项只改变 etcdctl 的输出格式,不会把 Deployment 转换成 JSON 对象。

export ETCDCTL_ENDPOINTS="https://127.0.0.1:2379"
export ETCDCTL_CACERT="/etc/kubernetes/pki/etcd/ca.crt"
export ETCDCTL_CERT="/etc/kubernetes/pki/etcd/healthcheck-client.crt"
export ETCDCTL_KEY="/etc/kubernetes/pki/etcd/healthcheck-client.key"
export ETCD_KEY="/registry/deployments/default/vllm-demo"

etcdctl --write-out=json get "${ETCD_KEY}" \
  > /tmp/vllm-demo-etcd.json

重定向将 JSON 文本写入 /tmp/vllm-demo-etcd.json,命令成功时终端没有输出。

下面的命令取出 Base64 编码的 Value,并保存为二进制文件:

jq -r '.kvs[0].value' /tmp/vllm-demo-etcd.json \
  | base64 --decode \
  > /tmp/vllm-demo.storage

默认未加密的 Deployment Value 以前四个 magic bytes 开头:

$ od -An -N4 -t x1 /tmp/vllm-demo.storage
 6b 38 73 00

etcdctl 只能证明 key、revision 和原始字节存在。查看可读对象时,通过 API Server 读取:

kubectl get deployment vllm-demo \
  --namespace default \
  --output yaml

API Server 负责解密、识别编码、解析 runtime.Unknown、解码 Deployment,并按照客户端请求的 API version 返回对象。

etcdctl 读取的是原始字节。需要将未加密的 Kubernetes Protobuf 或 JSON Value 转换为可读对象时,可以使用 etcdedit 的 get 命令。

etcd Watch 机制

etcd Watch 将 MVCC 键空间的变化转换成按 revision 排序的事件流。API Server 持续消费这条事件流,并为启用缓存的资源维护 Watch Cache;Controller、Scheduler 和 Kubelet 再通过 Kubernetes API 执行 LIST/WATCH,接收 Kubernetes 对象事件。

flowchart LR
    subgraph E["etcd"]
        M["MVCC / backend"] --> EW["etcd Watch<br/>PUT / DELETE + revision"]
    end

    subgraph A["kube-apiserver"]
        ES["etcd3 storage"] --> C["Watch Cache<br/>current objects + event history"]
        C --> H["GET / LIST / WATCH"]
        H -. "direct read or fallback" .-> ES
    end

    EW --> ES
    K["Controller / Scheduler / Kubelet"] -- "LIST / WATCH + resourceVersion" --> H
    H -- "objects / ADDED / MODIFIED / DELETED" --> K

事件流与历史窗口

WatchCreateRequest 使用 key 和 range_end 选择监听范围。start_revision 指定事件起点,并且包含该 revision;未设置时,etcd 从创建 Watch 的响应头 revision 之后开始发送事件。键值写入和删除分别产生 PUT 与 DELETE,每个 WatchResponse 的响应头记录当前 revision。etcd Watch API定义了请求字段和事件格式。

Watch 在没有键值变化时也可以报告进度。客户端可以启用 progress_notify 接收不含事件的进度响应,也可以主动发送 WatchProgressRequest;响应头中的 revision 表示该 Watch 已经观察到的存储进度。API Server 使用 WatchProgressRequest 判断 Watch Cache 是否已经追上 etcd,这与 Kubernetes Watch 返回的 BOOKMARK 事件属于两套接口。

Compaction 会删除指定 revision 之前的 MVCC 历史。start_revision 已被压缩时,etcd 取消该 Watch,并通过 compact_revision 返回仍可用历史的边界。客户端重新读取当前状态,再从新的 revision 建立 Watch。

下图展示了正常续接与历史过期后的处理路径。

flowchart LR
    R["Range current state\nrevision 210"] --> W["Watch\nstart_revision 211"]
    W --> E1["PUT at revision 211"]
    E1 --> E2["DELETE at revision 214"]
    E2 --> D["Connection closed"]
    D --> RW["Watch\nstart_revision 215"]
    C["Compaction\ncompact_revision 220"] -. "Revision 215 is unavailable" .-> RW
    RW --> X["Watch canceled\ncompact_revision 220"]
    X --> NR["Range current state"]
    NR --> NW["Watch from the new revision"]

API Server 使用 etcd Watch

API Server 的 etcd3 storage 先通过 Range 取得对象和当前 revision,再从下一 revision 建立 etcd Watch。Kubernetes Watch 使用 resourceVersion=N 续接时,底层 Watch 从 revision N+1 开始,因此只返回客户端尚未观察到的变化;这与 etcd 原生 start_revision=N 包含 revision N 的语义相衔接。

etcd 返回的 Value 经过解密、解码和 API 版本转换后成为 Kubernetes 对象,键值事件再转换成 ADDED、MODIFIED 和 DELETED。Kubernetes Watch 还会应用 label 和 field selector:对象从“不匹配”变为“匹配”时产生 ADDED,从“匹配”变为“不匹配”时产生 DELETED,所以 Kubernetes 事件与 etcd 的 PUT、DELETE 不是简单的一一对应关系。

Kubernetes Watch Cache

API Server 为每个启用缓存的资源创建 Cacher。Cacher 先从 etcd 读取当前对象,再通过底层 Watch 持续更新内存中的对象集合和事件历史。多个 API 客户端共享同一份缓存,不需要各自在 etcd 上建立 Watch;HA 控制面中的每个 API Server 维护自己的缓存。

Watch Cache 将 etcd Value 解码为 Kubernetes 对象,并把变化转换成 ADDED、MODIFIED 和 DELETED。API Server 也可以生成 BOOKMARK 表示 Kubernetes Watch 已经同步到某个 resourceVersion;BOOKMARK 不对应一次键值写入。

Revision 与 resourceVersion

对于由 kube-apiserver 直接持久化的内置资源和 Custom Resource,API Server 使用 etcd 存储 revision 生成 metadata.resourceVersion。客户端保存并原样传回该值,用于并发控制和 LIST/WATCH 续接。

Kubernetes v1.36 规定,kube-apiserver 和 CRD 提供的 resourceVersion 可以在同一 GroupResource 内按任意精度十进制整数比较;同一种资源跨 namespace 也可以比较。这个顺序不能用于比较 Pod 与 Deployment 等不同资源,也不能用于计算下一个版本。聚合 API 由扩展 API Server 实现;只有两个 resourceVersion 都能解析为十进制整数时,客户端才能依赖顺序,否则只能比较是否相等。完整约束见 Kubernetes API 文档的 resourceVersion 语义。

etcd 与 Kubernetes API 的版本参数服务于相同的数据流,但起点和错误接口不同:

接口 起点 事件 历史过期
etcd Watch start_revision=N 包含 revision N PUT、DELETE Watch 被取消,响应返回 compact_revision
Kubernetes Watch resourceVersion=N 返回版本 N 之后的变化 ADDED、MODIFIED、DELETED、BOOKMARK、ERROR API Server 返回 ResourceExpired,HTTP 状态为 410 Gone

LIST 响应中的 .metadata.resourceVersion 表示整个集合的快照版本。集合中每个对象的 metadata.resourceVersion 记录该对象最近一次变化,两者不要求相同。

GET 与 LIST 的读取路径

resourceVersion 表达客户端需要的数据新鲜度,不是选择缓存的开关。API Server 先将请求解释为 Any、Most Recent、NotOlderThan、Exact 或分页续传,再根据操作类型、缓存状态和历史快照选择 Watch Cache 或 etcd。

下表列出了 Watch Cache 已启用时 Kubernetes v1.36.4 的常见路径。完整参数组合见 Kubernetes API 文档的 resourceVersion 语义。

请求 一致性语义 Kubernetes v1.36.4 默认路径
GET,未设置 resourceVersion Most Recent 直接对 etcd 执行强一致读取
GET?resourceVersion=0 Any 从 Watch Cache 返回当前可用版本;缓存未初始化时读取 etcd
GET?resourceVersion=N NotOlderThan 等待 Watch Cache 至少追到 N,再从缓存返回
LIST,未设置 resourceVersion Most Recent 从 etcd 取得当前 revision,等待 Watch Cache 追平,再从缓存返回对象
LIST?resourceVersion=0 Any 使用 Watch Cache 的当前内容,允许返回较旧状态
LIST?resourceVersion=N&resourceVersionMatch=NotOlderThan NotOlderThan 等待 Watch Cache 至少追到 N,返回版本不低于 N 的集合
LIST?resourceVersion=N&resourceVersionMatch=Exact Exact 优先读取 Watch Cache 的历史快照;快照不可用时读取 etcd
LIST 携带 continue token Continuation 优先读取 token 对应的缓存快照;快照不可用时读取 etcd

未设置 resourceVersion 的 LIST 使用 ConsistentListFromCache。API Server 按以下顺序取得强一致结果:

  1. 对 etcd 执行轻量的线性一致读取,只取得当前 revision。
  2. 等待 Watch Cache 至少追到该 revision;没有对象变化时,通过 WatchProgressRequest 推进已观察进度。
  3. 从 Watch Cache 返回对象。缓存追平超时或 etcd 不支持所需的 Watch Progress 时,请求转到 etcd。

ConsistentListFromCache 在 Kubernetes v1.34 进入 GA,Kubernetes v1.36 默认使用这条路径。Watch Progress 是 API Server 与 etcd 的内部同步机制,客户端通过 allowWatchBookmarks=true 请求的 BOOKMARK 则属于 Kubernetes Watch API。

数值型 LIST 应同时设置 resourceVersionMatch。省略该字段时,不分页的 LIST 使用 NotOlderThan,设置 limit 的分页 LIST 使用兼容的 Exact 语义;分页参数会改变一致性要求。

Exact 和分页续传需要读取同一个历史集合。Kubernetes v1.36 默认启用 Beta 的 ListFromCacheSnapshot:缓存中存在对应快照时直接返回,快照不可用时读取 etcd;请求的版本在两处都已过期时返回 410 Gone。

WATCH 起点与续接

经典 LIST-WATCH 先执行 LIST,保存集合的 resourceVersion,再用该值建立 WATCH。WATCH?resourceVersion=N 只返回 N 之后发生的变化,客户端以 LIST 的对象集合为初始状态。

Kubernetes v1.36 的服务端默认启用 Beta 的 WatchList。当 resourceVersion 为空或等于 0,并且请求没有显式设置 sendInitialEvents 和 resourceVersionMatch 时,API Handler 默认补上 sendInitialEvents=true 与 resourceVersionMatch=NotOlderThan:

WATCH 请求 起点与返回内容
未设置 resourceVersion 从 etcd 取得当前 revision,等待 Watch Cache 追平;将现有对象作为合成 ADDED 事件发送,再继续发送变化
resourceVersion=0 接受 Watch Cache 当前可用版本;将该版本的现有对象作为合成 ADDED 事件发送,再继续发送变化,初始状态可能较旧
resourceVersion=N 从精确起点续接,只发送 N 之后的变化

显式使用 WatchList 时,请求设置 sendInitialEvents=true、resourceVersionMatch=NotOlderThan 和 allowWatchBookmarks=true。API Server 先发送合成的 ADDED 事件,再用带有 k8s.io/initial-events-end: "true" 注解的 BOOKMARK 标记初始状态同步点,后续连接继续传输普通变化事件。

Watch Cache 和 etcd 都只保留有限的历史。API Server 无法提供客户端请求的旧 resourceVersion 时返回 410 Gone;客户端清空依赖旧版本的本地状态,重新执行 LIST 或 WatchList,再从新的集合版本继续 WATCH。

client-go 的 WatchListClient 从 Kubernetes v1.35 起默认启用。Reflector 优先使用 WatchList 获取初始对象和后续变化;服务器或请求不支持时回到经典 LIST-WATCH,并负责断线重连与版本过期后的重新同步。

Watch Cache 的适用范围

上述路径适用于由当前 kube-apiserver 持久化并启用 Watch Cache 的资源。配置和资源类型决定 Cacher 是否参与请求:

  • 全局开关:--watch-cache=false 使当前 API Server 直接使用未经过 Cacher 装饰的底层存储。
  • 内置资源:--watch-cache-sizes=deployments.apps#0 可以关闭单个内置资源的 Watch Cache。v1.36 的缓存容量动态调整,非零值只表示保持缓存启用。
  • Event:Kubernetes v1.36.4 默认关闭 core/v1 events 和 events.k8s.io/v1 events 的 Watch Cache。DefaultWatchCacheSizes 记录了这两个例外。
  • Custom Resource:CRD 继承全局 Watch Cache 开关,--watch-cache-sizes 不能逐个控制 Custom Resource。
  • 聚合 API:APIService 请求由扩展 API Server 处理,其缓存和持久化实现由扩展服务决定。

缓存未初始化、缓存落后于请求版本和历史快照过期会触发不同路径。v1.36.4 在缓存未初始化时把 GET 转到 etcd;无 selector 的分页 LIST 也可以绕过尚未就绪的 Cacher,其他依赖 Cacher 的 LIST 和 WATCH 通常返回 429 Too Many Requests。缓存已经就绪后,一致性 LIST 等待 resourceVersion 超时会回到 etcd;WATCH 等待未来版本超时则返回版本错误。API 客户端根据 resourceVersion 和 HTTP 状态选择重试或重新同步。

源码位置:etcd Watch 与 Kubernetes Watch Cache

etcd 的 watchableStore 将 watcher 分成 synced 与 unsynced group。syncWatchers 从 backend 补读历史事件,notify 向已追平的 watcher 发送新事件。

API Server etcd3 storage 的 watchChan.sync 与 startWatching 设置 Range 与 Watch 的 revision 边界,watchChan.transform 负责对象解码和 Kubernetes 事件转换。

API Server 的 EtcdOptions 默认启用 Watch Cache,GetRESTOptions 为资源选择 Cacher 装饰器。

CacheDelegator.Get / GetList 负责缓存与底层存储之间的读取分流,Cacher.Watch 负责 Kubernetes Watch 数据流。

etcd 命令行工具

etcdctl 连接正在运行的 etcd,etcdutl 操作本地快照文件和数据目录。社区工具 etcdedit 也直接连接 etcd,它按 Kubernetes 的存储格式解码或编辑资源对象。

快照备份的源头是正在运行的 etcd。etcdctl snapshot save 通过 endpoint 调用 Maintenance API,由 etcd 成员生成并传回一致快照;直接复制 member/snap/db 可能遗漏仍在 WAL、尚未写入该文件的数据。这个操作需要网络连接、TLS 凭据和在线 etcd,因此属于 etcdctl。

快照生成后,检查和恢复面对的是本地文件。etcdutl snapshot status 直接读取快照中的 revision、hash 和 Key 数量;etcdutl snapshot restore 根据快照创建新的数据目录,并改写 member ID 和 cluster ID,使恢复后的成员组成新的逻辑集群。这两个操作不连接正在运行的 endpoint,因此属于 etcdutl。从 etcd v3.6 开始,官方命令行工具按这条边界划分 Snapshot 操作,完整流程见 etcd Disaster Recovery。

etcdctl

etcdctl 通过网络连接正在运行的 etcd endpoint,用于 KV 读写、Watch、Lease、成员管理、在线维护和快照备份。课程环境使用 etcd 3.6.8;以下命令属于 etcd 3.6 与 3.7 的共有接口,完整的开发者操作见 etcd v3.7 Developer Tasks。

以下输出使用 etcdctl v3.6.8 和 v3.7.1 在隔离环境中验证。endpoint、member ID、revision、文件大小和耗时由运行环境决定,示例值用于说明输出结构。

类型 常用命令 用途
KV put、get、del 写入、读取和删除单个 key 或 key range
原子操作 txn 根据 value、version 或 revision 条件执行一组原子请求
变化通知 watch 监听 key、range 或 prefix 的后续事件
临时数据 lease grant、lease keep-alive、lease revoke 为 key 设置 TTL 并管理 Lease
并发控制 lock、elect 使用 etcd concurrency API 实现互斥锁和 leader election
集群检查 endpoint health、endpoint status、endpoint hashkv、member list 检查 endpoint、Raft 状态、KV hash 和成员列表
在线维护 compact、defrag、alarm、snapshot save 压缩历史、整理 backend、处理告警和创建快照

连接配置

本地测试集群通常通过未启用 TLS 的 loopback endpoint 访问:

$ export ETCDCTL_ENDPOINTS="http://127.0.0.1:2379"
$ etcdctl version
etcdctl version: 3.6.8
API version: 3.6

生产集群应使用 TLS。--endpoints、--cacert、--cert 和 --key 都可以写成 ETCDCTL_ 前缀的环境变量,后续命令会自动读取。

Kubernetes 控制面的连接配置

kubeadm 创建的 stacked etcd 通常监听 https://127.0.0.1:2379。以下命令需要在具有证书文件并且能够访问该 endpoint 的 control-plane 主机或 etcd 容器中执行。

--cluster 根据成员发布的 client URL 检查整个集群,因此输出地址可能与初始的 127.0.0.1 不同:

$ export ETCDCTL_ENDPOINTS="https://127.0.0.1:2379"
$ export ETCDCTL_CACERT="/etc/kubernetes/pki/etcd/ca.crt"
$ export ETCDCTL_CERT="/etc/kubernetes/pki/etcd/healthcheck-client.crt"
$ export ETCDCTL_KEY="/etc/kubernetes/pki/etcd/healthcheck-client.key"
$ etcdctl endpoint --cluster health
https://192.168.97.2:2379 is healthy: successfully committed proposal: took = 3.172073ms

本文对 Kubernetes /registry 下的 key 只做只读取证。下面的命令读取 Deployment key 的 etcd 元数据,不输出二进制 Value:

$ etcdctl --write-out=json \
  get /registry/deployments/default/vllm-demo \
| jq '{
    clusterRevision: .header.revision,
    key: (.kvs[0].key | @base64d),
    createRevision: .kvs[0].create_revision,
    modRevision: .kvs[0].mod_revision,
    version: .kvs[0].version
  }'
{
  "clusterRevision": 531,
  "key": "/registry/deployments/default/vllm-demo",
  "createRevision": 491,
  "modRevision": 495,
  "version": 3
}

集群状态

endpoint health 执行一次线性一致读取,验证 endpoint 能否返回经过 quorum 确认的结果。endpoint status 返回 etcd 版本、leader、Raft term、Raft index、revision 和 backend 大小。endpoint hashkv 用于比较成员在同一 revision 的 KV hash,member list 列出成员 ID、Peer URL 和客户端地址。

以下示例切换到本地三成员测试集群。设置环境变量成功时没有输出;后续 KV、Watch、事务、Lease、并发控制和在线维护命令继续使用这三个 endpoint。

export ETCDCTL_ENDPOINTS="http://127.0.0.1:12379,http://127.0.0.1:22379,http://127.0.0.1:32389"
$ etcdctl endpoint --cluster health
http://127.0.0.1:12379 is healthy: successfully committed proposal: took = 2.200167ms
http://127.0.0.1:22379 is healthy: successfully committed proposal: took = 2.499584ms
http://127.0.0.1:32389 is healthy: successfully committed proposal: took = 2.263542ms
$ etcdctl --write-out=table endpoint --cluster status
┌────────────────────────┬──────────────────┬─────────┬─────────────────┬─────────┬────────┬───────────┬────────────┬───────────┬────────────┐
│        ENDPOINT        │        ID        │ VERSION │ STORAGE VERSION │ DB SIZE │ IN USE │ IS LEADER │ IS LEARNER │ RAFT TERM │ RAFT INDEX │
├────────────────────────┼──────────────────┼─────────┼─────────────────┼─────────┼────────┼───────────┼────────────┼───────────┼────────────┤
│ http://127.0.0.1:12379 │  323c54e765f5ec8 │   3.7.1 │           3.7.0 │   98 kB │  98 kB │      true │      false │         2 │         11 │
│ http://127.0.0.1:22379 │ 499b40881a55412b │   3.7.1 │           3.7.0 │   98 kB │  98 kB │     false │      false │         2 │         11 │
│ http://127.0.0.1:32389 │ 4468ea00be355bc2 │   3.7.1 │           3.7.0 │   98 kB │  98 kB │     false │      false │         2 │         11 │
└────────────────────────┴──────────────────┴─────────┴─────────────────┴─────────┴────────┴───────────┴────────────┴───────────┴────────────┘

上面的输出摘录保留了成员状态和 Raft 相关列,完整表格还包含 quota、applied index、error 与 downgrade 状态。

$ etcdctl --write-out=table endpoint --cluster hashkv
┌────────────────────────┬────────────┬───────────────┐
│        ENDPOINT        │    HASH    │ HASH REVISION │
├────────────────────────┼────────────┼───────────────┤
│ http://127.0.0.1:12379 │ 1084519789 │             1 │
│ http://127.0.0.1:22379 │ 1084519789 │             1 │
│ http://127.0.0.1:32389 │ 1084519789 │             1 │
└────────────────────────┴────────────┴───────────────┘
$ etcdctl --write-out=table member list
┌──────────────────┬─────────┬────────┬────────────────────────┬────────────────────────┬────────────┐
│        ID        │ STATUS  │  NAME  │       PEER ADDRS       │      CLIENT ADDRS      │ IS LEARNER │
├──────────────────┼─────────┼────────┼────────────────────────┼────────────────────────┼────────────┤
│  323c54e765f5ec8 │ started │ infra0 │ http://127.0.0.1:12380 │ http://127.0.0.1:12379 │      false │
│ 499b40881a55412b │ started │ infra1 │ http://127.0.0.1:22380 │ http://127.0.0.1:22379 │      false │
│ 4468ea00be355bc2 │ started │ infra2 │ http://127.0.0.1:32390 │ http://127.0.0.1:32389 │      false │
└──────────────────┴─────────┴────────┴────────────────────────┴────────────────────────┴────────────┘

KV 读写

以下命令面向独立的测试 etcd,并使用 /demo prefix 隔离数据。get --prefix 读取具有相同 prefix 的一组 key,--keys-only 只返回 key,--limit 限制单次结果数量。

$ etcdctl put /demo/config/version v1
OK
$ etcdctl put /demo/config/region us-west
OK
$ etcdctl get /demo/config/version
/demo/config/version
v1
$ etcdctl get /demo/config/ --prefix
/demo/config/region
us-west
/demo/config/version
v1

--keys-only 的 simple 输出仍会为每个 Key 保留一行空 Value:

$ etcdctl get /demo/config/ --prefix --keys-only --limit=10
/demo/config/region

/demo/config/version
$ etcdctl del /demo/config/region
1

JSON 输出中的 key 和 value 使用 Base64 编码。下面的查询同时显示 Value 和 etcd 维护的 revision 元数据:

$ etcdctl --write-out=json get /demo/config/version \
  | jq '{
      clusterRevision: .header.revision,
      key: (.kvs[0].key | @base64d),
      value: (.kvs[0].value | @base64d),
      createRevision: .kvs[0].create_revision,
      modRevision: .kvs[0].mod_revision,
      version: .kvs[0].version
    }'
{
  "clusterRevision": 4,
  "key": "/demo/config/version",
  "value": "v1",
  "createRevision": 2,
  "modRevision": 2,
  "version": 1
}

Kubernetes key 的写入边界

Kubernetes 使用 /registry/... 存储资源对象。etcdctl put、del 和 txn 会绕过 API Server 的认证、授权、Admission、类型校验与并发控制,因此只能对这些 key 执行经过授权的只读查询。Kubernetes 对象的创建、更新和删除通过 Kubernetes API 完成。

Watch

watch 持续接收指定 key 或 prefix 的变化。--rev=N 从 revision N 开始并包含该 revision,--prev-kv 同时返回事件发生前的 Value;它们对应 etcd Watch 机制 中的原生 Watch 语义。

在第一个终端运行 etcdctl watch /demo/config/ --prefix。命令建立 Watch 后持续等待,此时没有初始输出。

在第二个终端写入和删除 key,第一个终端会收到 PUT 与 DELETE:

$ etcdctl put /demo/config/version v2
OK
$ etcdctl del /demo/config/version
1

第一个终端随即收到两条事件。完整的终端记录如下:

$ etcdctl watch /demo/config/ --prefix
PUT
/demo/config/version
v2
DELETE
/demo/config/version

从指定 revision 补读历史事件时,使用仍在 MVCC 历史窗口中的起点。下面的 5 是实测集群中更新 version 的 revision,实际使用时替换为客户端记录的续接位置:

命令先重放 revision 5 和 6 的事件,再继续等待新变化。--prev-kv 使输出同时包含修改前的 KV:

$ etcdctl watch /demo/config/ --prefix --rev=5 --prev-kv
PUT
/demo/config/version
v1
/demo/config/version
v2
DELETE
/demo/config/version
v2
/demo/config/version
事务、Lease 与分布式锁

txn 的输入依次由 compare、条件成立时执行的请求和条件不成立时执行的请求组成,三个部分使用空行分隔。下面的事务只在 Value 仍为 v1 时完成两次写入:

$ etcdctl put /demo/config/version v1
OK

Compare 成立后,SUCCESS 表示 etcd 执行了成功分支,后面的两个 OK 分别对应两次 put:

$ etcdctl txn <<'EOF'
value("/demo/config/version") = "v1"

put /demo/config/version v2
put /demo/config/updated-by txn

get /demo/config/version

EOF
SUCCESS

OK

OK

单独读取目标 key,可以确认事务写入的最终值:

$ etcdctl get /demo/config/version
/demo/config/version
v2

Lease 到期或被撤销时,绑定到该 Lease 的 key 一并删除。先创建一个 60 秒的 Lease:

$ etcdctl lease grant 60
lease 5ec8a04355e31915 granted with TTL(60s)

Lease ID 由集群生成。将返回的 ID 保存到 Shell 变量,再写入一个绑定该 Lease 的 key:

$ lease_id="5ec8a04355e31915"
$ etcdctl put /demo/session/worker-1 active --lease="${lease_id}"
OK

lease timetolive 返回剩余 TTL 和关联 key:

$ etcdctl lease timetolive "${lease_id}" --keys
lease 5ec8a04355e31915 granted with TTL(60s), remaining(59s), attached keys([/demo/session/worker-1])

lease keep-alive 持续刷新 TTL,每次续约都会输出一行结果。命令会保持运行,按 Ctrl-C 结束:

$ etcdctl lease keep-alive "${lease_id}"
lease 5ec8a04355e31915 keepalived with TTL(60)

不再需要 Lease 时,撤销 Lease 并删除绑定的 key:

$ etcdctl lease revoke "${lease_id}"
lease 5ec8a04355e31915 revoked

lock 使用 Lease 管理锁的生命周期。取得锁后执行给定命令,命令退出时释放锁:

$ etcdctl lock /demo/locks/job-runner -- \
  sh -c 'echo "lock acquired"'
lock acquired

elect 在指定 election prefix 下竞选 leader。成功当选后,命令输出候选 key 和提议值,并持续持有 leadership;按 Ctrl-C 退出会撤销本次候选资格:

$ etcdctl elect /demo/election/worker worker-1
/demo/election/worker/5ec8a04355e31925
worker-1

lock 与 elect 面向直接使用 etcd Concurrency API 的应用。Kubernetes Controller 的 leader election 使用 coordination.k8s.io/Lease 对象,并通过 API Server 完成读写。

成员变更与在线维护

member add、member promote 和 member remove 会修改 Raft 成员关系。增加成员时先将新成员作为 learner 加入并完成数据追赶,再进行晋升;成员 ID 来自 member list。

以下输出来自本地三成员测试集群。新 learner 使用 infra3 作为名称,Peer URL 为 http://127.0.0.1:42390:

$ etcdctl member add infra3 \
  --peer-urls="http://127.0.0.1:42390" \
  --learner
Member b47050a460969719 added as learner to cluster 50b6aac6461d2751

ETCD_NAME="infra3"
ETCD_INITIAL_CLUSTER="infra0=http://127.0.0.1:12380,infra2=http://127.0.0.1:32390,infra1=http://127.0.0.1:22380,infra3=http://127.0.0.1:42390"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://127.0.0.1:42390"
ETCD_INITIAL_CLUSTER_STATE="existing"

Member ID 和 cluster ID 由集群生成。启动 infra3 并等待 learner 完成数据追赶后,使用返回的 Member ID 执行晋升:

$ member_id="b47050a460969719"
$ etcdctl member promote "${member_id}"
Member b47050a460969719 promoted in cluster 50b6aac6461d2751

成员下线流程使用同一个 Member ID 删除成员:

$ etcdctl member remove "${member_id}"
Member b47050a460969719 removed from cluster 50b6aac6461d2751

在线 Compaction 删除旧 MVCC 历史;在线 Defragmentation 逐个整理成员的 backend,并在执行期间阻塞该成员的读写。执行前应根据历史保留策略和 backend 指标确定 revision 与操作窗口。

$ revision="$(
  etcdctl --write-out=json endpoint status \
    | jq -r '.[0].Status.header.revision'
)"
$ printf 'revision=%s\n' "${revision}"
revision=14
$ etcdctl compact "${revision}"
compacted revision 14
$ etcdctl defrag --cluster
Finished defragmenting etcd member[http://127.0.0.1:12379]. took 32.863459ms
Finished defragmenting etcd member[http://127.0.0.1:32389]. took 28.708917ms
Finished defragmenting etcd member[http://127.0.0.1:22379]. took 29.979291ms
etcdctl alarm list

没有活动告警时,alarm list 成功退出且 stdout 为空;存在告警时,每行包含 member ID 和告警类型。

快照备份

snapshot save 通过 Maintenance API 从一个运行中的 endpoint 导出一致快照。快照包含调用时仍在 WAL、尚未刷入 backend db 文件的已提交数据;--endpoints 必须只包含一个地址。

install、export 和 chmod 成功时没有输出。snapshot save 会记录下载进度,以下保留完成信息:

$ install -d -m 0700 /var/backups/etcd
$ export ETCDCTL_ENDPOINTS="http://127.0.0.1:12379"
$ etcdctl snapshot save /var/backups/etcd/snapshot.db
Snapshot saved at /var/backups/etcd/snapshot.db
Server version 3.7.0
$ chmod 0600 /var/backups/etcd/snapshot.db

snapshot save 只连接一个 endpoint,因此这里把 ETCDCTL_ENDPOINTS 改为 infra0 的客户端地址。连接地址通过环境变量或 --endpoints 任选一种方式指定。

快照文件的检查和恢复由 etcdutl 完成。

etcdutl

etcdutl 直接操作本地快照文件或 etcd 数据目录,不接受 endpoint 与 TLS 参数。它用于快照检查与恢复、离线 Defragmentation、数据库哈希计算和数据目录格式迁移。课程环境使用的 v3.6.8 与 v3.7.1 release binary 提供相同的稳定顶层命令;具体参数应以当前安装版本的 --help 为准。etcdutl v3.7.1 命令参考说明了各项操作。

命令 操作对象 结果
snapshot status FILE 快照文件 输出数据库 hash、revision、key 数量、文件大小和 storage version
hashkv FILE 快照副本或 backend 数据库 计算指定 revision 之前的 KV hash
snapshot restore FILE 快照文件与新数据目录 使用快照创建新逻辑集群的数据目录
defrag --data-dir DIR 已停止成员的数据目录 离线重写 backend,缩小数据库文件
migrate --data-dir DIR 已停止成员的数据目录 按受支持的升级或降级路径修改存储格式

离线操作边界

snapshot restore 会创建新的数据目录,defrag 和 migrate 会修改现有文件。执行前应停止使用目标数据目录的 etcd 进程,验证快照,并保留原始数据目录。多成员集群需要逐成员规划离线维护,或使用同一份快照恢复所有成员。

快照检查

snapshot status 读取快照的数据库 hash、revision、Key 数量、文件大小和 storage version。它适合在备份完成后确认文件结构,并记录恢复时需要核对的元数据。

$ etcdutl --write-out=table \
  snapshot status /var/backups/etcd/snapshot.db
┌──────────┬──────────┬────────────┬────────────┬─────────┐
│   HASH   │ REVISION │ TOTAL KEYS │ TOTAL SIZE │ VERSION │
├──────────┼──────────┼────────────┼────────────┼─────────┤
│ c4dcbf3f │       14 │          2 │      98 kB │   3.7.0 │
└──────────┴──────────┴────────────┴────────────┴─────────┘

VERSION 表示快照使用的 storage version,不是 etcdutl 二进制的补丁版本。

KV 哈希

hashkv 计算指定 revision 之前的 KV hash。比较多个文件时还要核对 hash revision;revision 不同的 hash 不能直接用于判断数据是否一致。

etcd v3.7.1 的 hashkv 通过可写 backend 打开文件,运行后可能改变文件内容并使原始快照的完整性校验失败。保留原始备份,对单独的工作副本执行 hashkv;这一行为可以从 etcdutl 的 hashkv 实现确认。

cp 成功时没有输出。随后对副本计算 hash:

$ cp /var/backups/etcd/snapshot.db \
  /var/backups/etcd/snapshot-hashkv.db
$ etcdutl --write-out=table \
  hashkv --rev=0 /var/backups/etcd/snapshot-hashkv.db
┌────────────┬───────────────┬──────────────────┐
│    HASH    │ HASH REVISION │ COMPACT REVISION │
├────────────┼───────────────┼──────────────────┤
│ 1918550935 │            14 │               14 │
└────────────┴───────────────┴──────────────────┘

快照恢复

snapshot restore 使用快照创建新的 member ID、cluster ID 和数据目录。单成员集群只需要指定目标目录:

snapshot restore 将运行日志写入 stderr。以下内容只保留关键日志和字段,省略了打开 bbolt 数据库等中间日志,以及 wal-dir、snap-dir 等字段:

$ etcdutl snapshot restore /var/backups/etcd/snapshot.db \
  --data-dir /var/lib/etcd-restored
2026-08-27T21:25:46+08:00 info snapshot/v3_snapshot.go:306 restoring snapshot {"path": "/var/backups/etcd/snapshot.db", "data-dir": "/var/lib/etcd-restored", ...}
2026-08-27T21:25:46+08:00 info snapshot/v3_snapshot.go:334 restored snapshot {"path": "/var/backups/etcd/snapshot.db", "data-dir": "/var/lib/etcd-restored", ...}

Kubernetes 从旧快照恢复时,同时使用 --bump-revision 和 --mark-compacted。前者提高恢复后的 revision,后者使故障前建立的 Watch 失效,Controller 会重新建立 informer cache:

$ etcdutl snapshot restore /var/backups/etcd/snapshot.db \
  --data-dir /var/lib/etcd-restored-bumped \
  --bump-revision 1000000000 \
  --mark-compacted
2026-08-27T21:25:46+08:00 info snapshot/v3_snapshot.go:306 restoring snapshot {"path": "/var/backups/etcd/snapshot.db", "data-dir": "/var/lib/etcd-restored-bumped", ...}
2026-08-27T21:25:46+08:00 info snapshot/v3_snapshot.go:397 bumping latest revision {"latest-revision": 14, "bump-amount": 1000000000, "new-latest-revision": 1000000014}
2026-08-27T21:25:46+08:00 info snapshot/v3_snapshot.go:414 marking revision compacted {"revision": 1000000014}
2026-08-27T21:25:46+08:00 info snapshot/v3_snapshot.go:334 restored snapshot {"path": "/var/backups/etcd/snapshot.db", "data-dir": "/var/lib/etcd-restored-bumped", ...}

两个示例使用不同的数据目录,可以分别执行。目标目录已经存在且包含数据时,snapshot restore 会拒绝覆盖。

三成员集群需要在每台主机分别设置 --name、--initial-advertise-peer-urls 和 --data-dir。三台主机共享相同的 --initial-cluster 与 --initial-cluster-token,并使用同一份快照创建新的逻辑集群。

离线碎片整理

Compaction 删除旧 revision 后,bbolt 会把原来存放这些数据的数据库页标记为空闲。这些页可以被 etcd 后续写入复用,但 member/snap/db 文件仍保持原来的大小,主机文件系统看到的磁盘占用不会立即下降。etcd 官方把这种“backend 可以复用、文件系统尚未收回”的空间称为 internal fragmentation(内部碎片)。

例如 backend 文件为 10 GiB,而当前有效数据只占 6 GiB,其余约 4 GiB 可以被 etcd 复用,文件系统看到的文件仍然接近 10 GiB。etcdutl defrag 在成员停止时重写 backend,只保留有效数据,使文件系统能够收回未使用的磁盘空间。每个成员保存独立的 backend 文件,需要分别执行。具体定义和操作边界见 etcd Defragmentation。

defrag 将日志写入 stderr。以下内容只保留关键日志和字段;文件大小、差值和耗时取决于 backend 中可以交还给文件系统的未使用空间:

$ etcdutl defrag --data-dir /var/lib/etcd
2026-08-27T21:25:46+08:00 info backend/backend.go:532 defragmenting {"path": "/var/lib/etcd/member/snap/db", "current-db-size": "98 kB", "current-db-size-in-use": "66 kB", ...}
2026-08-27T21:25:46+08:00 info backend/backend.go:602 finished defragmenting directory {"path": "/var/lib/etcd/member/snap/db", "current-db-size": "98 kB", "current-db-size-in-use": "66 kB", "took": "27.733916ms", ...}

数据目录迁移

migrate 修改数据目录的存储格式,用于特定的 etcd 版本升级或降级。它不负责迁移 Kubernetes API 的 storage version,也不是普通 v3.6 到 v3.7 滚动升级的固定步骤。下面的命令以迁移到 etcd 3.6 的数据格式为例;实际操作使用目标版本要求的 etcdutl,并遵循对应的升级或降级文档。

$ etcdutl migrate \
  --data-dir /var/lib/etcd \
  --target-version 3.6
2026-08-27T21:12:57+08:00 info schema/migration.go:65 updated storage version {"new-storage-version": "3.6.0"}

数据目录原本使用 3.7 storage version 时,上面的日志表示迁移成功。

目标目录已经是 3.6 格式时,命令输出 storage version up-to-date,不会重复修改格式。

etcdedit

etcdedit 是直接连接 etcd 的社区工具。它根据 Key 路径选择编解码器:Deployment、Pod 等内置资源使用 Kubernetes Protobuf,使用 JSON 存储的 Custom Resource 按 JSON 解码。etcdedit 编解码器实现 使用 k8s.io/kubectl/pkg/scheme 注册内置 API 类型。

etcdedit 接受与 etcdctl 相同的 ETCDCTL_ENDPOINTS、ETCDCTL_CACERT、ETCDCTL_CERT 和 ETCDCTL_KEY 环境变量。Kubernetes stacked etcd 可以复用前文读取 Deployment 原始 Value 时设置的 endpoint 和 TLS 证书。

安装与版本选择

etcdedit v0.1.3 依赖 Kubernetes v0.35.2。课程环境使用 Kubernetes v1.36.4,安装命令固定到依赖 Kubernetes v0.36.4 的提交,避免使用旧版 Go 类型重新编码 Deployment。v0.1.3 go.mod 与提交 92b2d811 的 go.mod 记录了对应依赖版本。

go install \
  github.com/ahmetb/etcdedit@92b2d8118ac214e09c7401a02649d7f0a8a68b84

项目还提供 Homebrew 安装方式。使用发布版执行 edit 前,应确认其中的 Kubernetes Go 依赖不早于目标集群版本。

Key 路径属于 API Server 的存储实现。读取或修改对象前,先使用 etcdctl get --keys-only --prefix <prefix> 查找实际 Key。etcdedit Key 路径说明明确要求不要根据资源名称猜测路径。

get

get 读取一个 Key,将 Kubernetes Protobuf 或 JSON Value 解码为对象,默认输出 YAML。以下是 Kubernetes v1.36.1 Kind 集群中的输出摘录,仅保留对象标识、容器镜像、节点和运行阶段:

$ etcdedit get /registry/pods/default/nginx
apiVersion: v1
kind: Pod
metadata:
  labels:
    app: nginx
  name: nginx
  namespace: default
spec:
  containers:
    - image: nginx:1.27
      name: nginx
  nodeName: etcdedit-demo-control-plane
status:
  phase: Pending

输出中的 metadata、spec 和 status 来自 etcd 中的存储对象。YAML 由解码结果重新生成,不包含用户原始 YAML 的注释和字段顺序。get 的读取路径只读取 etcd,不写回 Value。

edit

edit 将对象解码到临时 YAML 文件,再依次使用 --editor、$EDITOR、$VISUAL 或 vi 打开。保存退出后,工具按原存储格式重新编码对象,并通过 etcd Transaction 比较读取时的 mod_revision;Key 已被其他客户端更新时,写入会因并发冲突失败。

写入成功后会显示修改的 Key 和原始 Value 的备份位置。下面是 Kind 集群中的实测输出:

$ etcdedit edit /registry/pods/default/nginx
Updated /registry/pods/default/nginx
Backup of original saved to: /tmp/etcdedit-backup-1787790132734.bin

备份文件保存修改前的二进制 Value,不是 YAML,也不能代替集群 Snapshot。当前实现以 0644 权限创建该文件,处理 Secret 等敏感 Value 时应立即收紧权限或安全删除。edit 的处理流程记录了条件写入和备份操作。

当前版本即使没有修改 YAML 也会在退出编辑器后写回 Value,增加 revision 并触发 Watch。etcdedit 已知问题记录了这一行为。

apply

apply 使用 YAML manifest 替换指定 Key。已经准备好 manifest 文件时,使用 -f 指定路径:

etcdedit apply -f manifest.yaml /registry/pods/default/my-pod

-f - 从标准输入读取 YAML。下面的示例创建 default/my-config ConfigMap,名称和 namespace 由 Key 路径补入对象:

$ etcdedit apply -f - /registry/configmaps/default/my-config <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
  labels:
    foo: bar
data:
  key1: value1
EOF
Setting metadata.name to "my-config" (derived from etcd key).
Setting metadata.namespace to "default" (derived from etcd key).
Created /registry/configmaps/default/my-config

get 可以读取写入结果:

$ etcdedit get /registry/configmaps/default/my-config
apiVersion: v1
data:
  key1: value1
kind: ConfigMap
metadata:
  labels:
    foo: bar
  name: my-config
  namespace: default
  uid: 5cf2c4c4-6171-4a23-b393-0eda50dd00c6

直接写入 etcd

etcdedit 使用 etcd endpoint 的 TLS 凭据或 etcd 用户完成连接授权,Kubernetes 用户身份和 RBAC 不参与这次请求。etcdedit edit、etcdedit apply、etcdctl put 和 etcdctl del 也不执行 API Server 的准入控制、类型校验和 resourceVersion 冲突检测。写入后的对象可能违反 Kubernetes API 约束,Controller 仍会观察并处理这次变化。

Protobuf 往返转换只保留当前 etcdedit 所使用的 Kubernetes Go 类型能够识别的字段。目标集群包含更新字段时,旧版工具可能在写回时丢失这些字段。edit 只适用于 API Server 无法工作、需要恢复集群状态等明确的应急场景;操作前应创建并验证 etcd Snapshot。

当前实现直接把 etcd Value 交给 Protobuf 或 JSON 解码器,无法解开 EncryptionConfiguration 生成的加密包络,也不支持使用 CBOR 存储的 Custom Resource。get 和 edit 遇到这些 Value 时会解码失败。

加密 etcd 中的数据

EncryptionConfiguration 指定哪些 Kubernetes API 资源需要在写入 etcd 前加密,以及 API Server 使用哪些 provider 和密钥。RBAC 控制谁可以通过 Kubernetes API 读取对象;静态数据加密保护 etcd 数据文件、快照和直接读取到的 Value。具有 API 读取权限的客户端仍会收到 API Server 解密后的对象。

本节使用 secretbox 完成一个不依赖外部服务的示例。Kubernetes 当前文档将 secretbox 列为强加密 provider;具备外部密钥管理系统的生产环境可以使用稳定的 KMS v2,将 Key Encryption Key 保存在 Kubernetes 控制面之外。aescbc 当前不推荐使用,aesgcm 则要求在每 200,000 次写入前完成密钥轮换。EncryptionConfiguration 支持的加密 provider列出了各方案的算法、密钥长度和限制。

Provider 顺序

resources 使用 Kubernetes 的复数资源名。Core API Group 中的 Secret 写作 secrets,Deployment 则写作 deployments.apps。

providers 和每个 provider 中的 keys 都有明确顺序:

  • 新对象和更新对象使用第一个 provider 的第一把密钥写入。
  • 读取时,API Server 按顺序尝试能够识别当前 Value 前缀的 provider 和密钥。
  • identity 不加密数据。示例将它放在最后,用于读取启用加密前已经存在的明文对象。
  • 后续 provider 只提供解密路径。第一个 provider 写入失败时,请求返回错误,不会改用 identity 写入明文。

下面的配置匹配 Secret 和 Deployment。__BASE64_32_BYTE_KEY__ 需要替换为随机生成的 32 字节密钥;固定示例密钥会使任何拿到仓库的人都能解密数据。

examples/chapter-01/03-01-encryption-configuration/encryption-config.yaml.tmpl
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
      - deployments.apps
    providers:
      # 新写入使用第一个 provider 的第一把密钥。
      - secretbox:
          keys:
            - name: key-2026-08
              secret: __BASE64_32_BYTE_KEY__
      # 迁移期间保留 identity,用于读取尚未重写的明文对象。
      - identity: {}

以下命令在控制平面节点生成密钥,并把最终配置保存到 /etc/kubernetes/enc/encryption-config.yaml。配置文件包含解密所需的原始密钥,因此目录和文件只向 API Server 的运行用户开放。

sudo install -d -m 700 /etc/kubernetes/enc

ENCRYPTION_KEY="$(head -c 32 /dev/urandom | base64 | tr -d '\n')"
sed "s|__BASE64_32_BYTE_KEY__|${ENCRYPTION_KEY}|" \
  examples/chapter-01/03-01-encryption-configuration/encryption-config.yaml.tmpl \
  | sudo tee /etc/kubernetes/enc/encryption-config.yaml >/dev/null
unset ENCRYPTION_KEY

sudo chmod 600 /etc/kubernetes/enc/encryption-config.yaml

API Server 配置

kubeadm 将 API Server 作为 static Pod 运行。现有 /etc/kubernetes/manifests/kube-apiserver.yaml 需要增加配置文件参数、自动重载参数和只读文件挂载。以下内容是需要合并到现有 manifest 的字段,不是一份完整的 static Pod manifest。

/etc/kubernetes/manifests/kube-apiserver.yaml
spec:
  containers:
    - name: kube-apiserver
      command:
        # 保留现有的 kube-apiserver 参数。
        - kube-apiserver
        - --encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml
        - --encryption-provider-config-automatic-reload=true
      volumeMounts:
        # 保留现有的 volumeMounts。
        - name: encryption-config
          mountPath: /etc/kubernetes/enc/encryption-config.yaml
          readOnly: true
  volumes:
    # 保留现有的 volumes。
    - name: encryption-config
      hostPath:
        path: /etc/kubernetes/enc/encryption-config.yaml
        type: File

Kubelet 检测到 static Pod manifest 变化后会重建 API Server。--encryption-provider-config-automatic-reload=true 使 API Server 每分钟检查一次配置文件,后续轮换密钥时可以等待自动重载完成;初次增加启动参数仍会触发 static Pod 重建。API Server 恢复后可以检查 Ready 状态:

kubectl get --raw='/readyz?verbose'

自动重载的时间戳记录在 API Server 指标中。多控制面集群需要分别查询每个 API Server 实例;通过负载均衡地址取得一次指标,只能证明处理该请求的实例已经加载配置。

kubectl get --raw='/metrics' \
  | grep 'apiserver_encryption_config_controller_automatic_reload_last_timestamp_seconds'

多控制面集群需要让每个 API Server 在整个发布过程中都能读取明文和密文。安全的启用顺序分为三个阶段:

  1. 在所有控制平面节点先部署 identity 在前、secretbox 在后的配置。此时仍写入明文,但每个 API Server 已经持有新密钥并能够解密后续出现的密文。
  2. 确认所有 API Server 已加载配置,再把 secretbox 移到第一位并逐台发布。发布期间,新旧 API Server 都能读取两种格式。
  3. 重写已有对象并确认 etcd 中已经没有匹配资源的明文 Value,再从所有节点的配置中删除 identity。

写入验证与已有对象迁移

启用配置后,新建一个 Secret。kubectl 通过 API Server 写入和读取,因此读取结果仍是原始值:

kubectl create secret generic encryption-demo \
  --from-literal=token=course-demo

kubectl get secret encryption-demo \
  --output jsonpath='{.data.token}' \
  | base64 --decode
course-demo

直接读取 etcd Value 可以确认静态存储格式。沿用前文的 ETCDCTL_ENDPOINTS、ETCDCTL_CACERT、ETCDCTL_CERT 和 ETCDCTL_KEY,检查加密包络前缀:

etcdctl --write-out=json get /registry/secrets/default/encryption-demo \
  | jq -r '.kvs[0].value' \
  | base64 --decode \
  | grep -ao '^k8s:enc:secretbox:v1:key-2026-08:'
k8s:enc:secretbox:v1:key-2026-08:

EncryptionConfiguration 只影响配置生效后的写入,已有对象不会自动重写。下面的命令读取并重新提交 default/vllm-demo Deployment,使其使用当前第一个 provider 写回 etcd:

kubectl get deployment vllm-demo \
  --namespace default \
  --output json \
  | kubectl replace -f -

相同的 etcd 检查可以用于 Deployment key:

etcdctl --write-out=json get /registry/deployments/default/vllm-demo \
  | jq -r '.kvs[0].value' \
  | base64 --decode \
  | grep -ao '^k8s:enc:secretbox:v1:key-2026-08:'
k8s:enc:secretbox:v1:key-2026-08:

完整的 Kind 验证脚本、kubeadm 配置步骤、预期输出和清理方式放在 EncryptionConfiguration 示例。移除配置或旧密钥前,需要先完成对象重写并确认所有 API Server 都保留当前 Value 所需的解密 provider;丢失最后一份有效密钥会使对应对象无法通过 Kubernetes API 读取。

etcd 集群搭建

etcd v3.7 Clustering Guide 列出了三种新集群搭建方式:Static、etcd Discovery 和 DNS Discovery。三者的区别在于成员从哪里取得初始成员列表;形成集群后的 Raft、MVCC 和客户端接口相同。

根据 kubeadm 的 etcd 版本映射,Kubernetes v1.36.1 默认使用 etcd 3.6.8。本章实验采用 Static,etcd Discovery 与 DNS Discovery 用于说明其他可选的搭建方式。

这三种方式只负责建立初始成员关系。etcd 在空数据目录首次启动时使用这些参数;后续重启从数据目录读取已经建立的成员和集群身份。集群开始运行后,成员的增加与删除通过 Membership API 或 etcdctl member add、etcdctl member remove 完成,具体操作见 etcd Runtime reconfiguration。

以下命令使用 HTTP 展示成员发现和 Peer URL 的对应关系。生产集群应为 Peer 和客户端通信配置 TLS,证书与参数见 etcd Transport security。

方式 初始成员列表的来源 适用条件
Static 每个成员配置相同的 --initial-cluster 成员名称和 Peer URL 在启动前已经确定
etcd Discovery 独立的 etcd discovery service 收集成员注册信息 各成员在启动前无法取得完整成员列表,且已有可用的 discovery service
DNS Discovery DNS SRV 记录 成员拥有稳定域名,DNS 记录可以在启动前配置

Static

Static 将完整的成员名称和 Peer URL 写入每个成员的启动参数。示例中各参数的作用如下:

  • --name:当前成员的唯一名称,必须与 --initial-cluster 中对应成员的名称一致。
  • --data-dir:保存当前成员的 WAL、Snapshot 和 backend 数据库。每个成员使用本机独立的数据目录。
  • --listen-peer-urls:当前进程监听 Raft Peer 流量的本地地址,该地址必须能够在本机绑定。
  • --initial-advertise-peer-urls:其他成员连接当前成员时使用的 Peer URL,必须与 --initial-cluster 中当前成员的 URL 一致。
  • --listen-client-urls:当前进程监听客户端请求的本地地址。示例同时监听节点 IP 和 127.0.0.1。
  • --advertise-client-urls:向客户端公布的访问地址,远程客户端需要能够连接该地址。
  • --initial-cluster:列出新集群的全部初始成员,格式为 name=peerURL。所有成员使用相同的列表。
  • --initial-cluster-token:标识本次创建的逻辑集群。同一集群的成员使用相同 token,新建另一套集群时更换 token。
  • --initial-cluster-state=new:表示这些成员正在创建新集群。通过 Membership API 加入现有集群的成员使用 existing。
配置示例:三成员 Static 集群

下面三组命令分别在对应主机执行。三台主机的 --initial-cluster、--initial-cluster-token 和 --initial-cluster-state 完全相同,本机的名称、监听地址和广播地址各不相同。

# 在 infra0.example.com(10.0.1.10)上执行
etcd \
  --name infra0 \
  --data-dir /var/lib/etcd \
  --initial-advertise-peer-urls http://10.0.1.10:2380 \
  --listen-peer-urls http://10.0.1.10:2380 \
  --advertise-client-urls http://10.0.1.10:2379 \
  --listen-client-urls http://10.0.1.10:2379,http://127.0.0.1:2379 \
  --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster-state new

# 在 infra1.example.com(10.0.1.11)上执行
etcd \
  --name infra1 \
  --data-dir /var/lib/etcd \
  --initial-advertise-peer-urls http://10.0.1.11:2380 \
  --listen-peer-urls http://10.0.1.11:2380 \
  --advertise-client-urls http://10.0.1.11:2379 \
  --listen-client-urls http://10.0.1.11:2379,http://127.0.0.1:2379 \
  --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster-state new

# 在 infra2.example.com(10.0.1.12)上执行
etcd \
  --name infra2 \
  --data-dir /var/lib/etcd \
  --initial-advertise-peer-urls http://10.0.1.12:2380 \
  --listen-peer-urls http://10.0.1.12:2380 \
  --advertise-client-urls http://10.0.1.12:2379 \
  --listen-client-urls http://10.0.1.12:2379,http://127.0.0.1:2379 \
  --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster-state new

etcd Discovery

etcd Discovery 使用另一套已经运行的 etcd 集群协调新集群的初始化。运维人员先创建唯一的 discovery token,并在 /_etcd/registry/<token>/_config/size 写入期望成员数。每个新成员以自己的名称和 Peer URL 注册;注册数量达到目标后,所有成员取得同一份初始成员列表并启动 Raft。

Discovery service 只保存初始化所需的成员信息,不代理新集群的 Peer 或客户端流量。etcd 官方的 v3 Discovery Protocol要求每套新集群使用独立 token;即使初始化中途失败,重新创建集群时也应生成新的 token。

下图展示了 discovery service 如何把三个彼此未知的成员汇总成同一份初始成员列表。

sequenceDiagram
    participant O as Operator
    participant D as Discovery service
    participant M0 as infra0
    participant M1 as infra1
    participant M2 as infra2

    O->>D: Set token and expected size = 3
    M0->>D: Register name and peer URL
    M1->>D: Register name and peer URL
    M2->>D: Register name and peer URL
    D-->>M0: Return complete member list
    D-->>M1: Return complete member list
    D-->>M2: Return complete member list
    Note over M0,M2: Members bootstrap Raft with the same member list
配置示例:使用 v3 Discovery Protocol

在 discovery service 中生成 token 并登记期望成员数。DISCOVERY_TOKEN 需要分发给三台新成员,DISCOVERY_ENDPOINTS 指向用于发现的独立 etcd 集群。

export DISCOVERY_ENDPOINTS="http://10.0.2.10:2379"
export DISCOVERY_TOKEN="$(uuidgen)"

etcdctl --endpoints="${DISCOVERY_ENDPOINTS}" \
  put "/_etcd/registry/${DISCOVERY_TOKEN}/_config/size" \
  "3"

printf '%s\n' "${DISCOVERY_TOKEN}"

put 返回 OK,随后打印新生成的 token。每次创建集群都应使用新的 UUID:

OK
9c6c2f6e-73f0-4e57-9e5e-7fb8ef62f3af

将上一步输出的 token 替换到三条命令的 <shared-discovery-token>。下面三组命令分别在对应主机执行,它们使用相同的 discovery token 和 discovery endpoint。

# 在 infra0.example.com(10.0.1.10)上执行
etcd \
  --name infra0 \
  --data-dir /var/lib/etcd \
  --initial-advertise-peer-urls http://10.0.1.10:2380 \
  --listen-peer-urls http://10.0.1.10:2380 \
  --advertise-client-urls http://10.0.1.10:2379 \
  --listen-client-urls http://10.0.1.10:2379,http://127.0.0.1:2379 \
  --discovery-token "<shared-discovery-token>" \
  --discovery-endpoints http://10.0.2.10:2379 \
  --initial-cluster-state new

# 在 infra1.example.com(10.0.1.11)上执行
etcd \
  --name infra1 \
  --data-dir /var/lib/etcd \
  --initial-advertise-peer-urls http://10.0.1.11:2380 \
  --listen-peer-urls http://10.0.1.11:2380 \
  --advertise-client-urls http://10.0.1.11:2379 \
  --listen-client-urls http://10.0.1.11:2379,http://127.0.0.1:2379 \
  --discovery-token "<shared-discovery-token>" \
  --discovery-endpoints http://10.0.2.10:2379 \
  --initial-cluster-state new

# 在 infra2.example.com(10.0.1.12)上执行
etcd \
  --name infra2 \
  --data-dir /var/lib/etcd \
  --initial-advertise-peer-urls http://10.0.1.12:2380 \
  --listen-peer-urls http://10.0.1.12:2380 \
  --advertise-client-urls http://10.0.1.12:2379 \
  --listen-client-urls http://10.0.1.12:2379,http://127.0.0.1:2379 \
  --discovery-token "<shared-discovery-token>" \
  --discovery-endpoints http://10.0.2.10:2379 \
  --initial-cluster-state new

discovery service 启用 TLS 或身份认证时,还需要配置 --discovery-cacert、--discovery-cert、--discovery-key 或 --discovery-user 等连接参数。

公共 v2 Discovery 服务

discovery.etcd.io 是 etcd 项目提供的公共 v2 Discovery 服务。etcd v3.7.1 仍保留 --discovery 参数,因此兼容该服务生成的 Discovery URL;但 v2 Discovery 已被弃用,公共服务也已停止维护。它可以用于理解旧版集群发现流程,不建议用于新的生产部署。新集群可以选择 Static、DNS Discovery,或者使用 etcd v3.6 发布说明中介绍的自建 v3 Discovery 集群。

size 指定初始成员数,省略时默认为 3。

curl -fsS 'https://discovery.etcd.io/new?size=3'

2026 年 8 月 27 日实际请求得到以下响应:

https://discovery.etcd.io/9b3de60f51fb39f11325c9271c7bcb13

每次请求都会创建包含新 token 的 Discovery URL。上面的 URL 只记录本次验证结果,不应复用。新建集群使用自己的 URL 完成首次初始化,运行中的成员变更通过 Membership API 完成。

v3 Discovery Protocol 使用两个不同的参数:

  • --discovery-token:标识正在创建的新集群。同一批初始成员使用相同 token。
  • --discovery-endpoints:指定提供 etcd v3 gRPC API 的 discovery 集群 endpoint。https://discovery.etcd.io 不是 etcd v3 gRPC endpoint,不能填写在这里。

etcd 官方的 v3 Discovery Protocol只给出自建 discovery 集群的配置,没有提供公共的 v3 endpoint;前面的 http://10.0.2.10:2379 表示这套自建集群。

DNS Discovery

DNS Discovery 从 DNS SRV 记录生成初始成员列表。--discovery-srv=example.com 指定查询使用的 discovery domain,etcd 根据 _service._proto.domain 格式组成以下记录名:

  • _etcd-server-ssl._tcp.example.com:发布使用 TLS 的 etcd Peer endpoint。
  • _etcd-server._tcp.example.com:发布使用明文 HTTP 的 etcd Peer endpoint。
  • _tcp:表示 Peer 连接使用 TCP。
  • example.com:来自 --discovery-srv 的 discovery domain。

etcd 先查询带 -ssl 的记录;查询到结果后使用 TLS 建立 Peer 连接,否则继续查询明文记录。SRV 记录返回 Peer 端口和成员主机名,主机名再通过 A 或 AAAA 记录解析为 IP。成员的 --initial-advertise-peer-urls 必须匹配其中一条记录。

_etcd-client._tcp 和 _etcd-client-ssl._tcp 用于发布客户端 endpoint,不参与 Peer 成员列表的建立。

配置示例:使用 DNS SRV 记录

下面的 DNS 记录定义三个成员的地址和 Peer 端口。三条 _etcd-server 记录共同组成初始成员列表。

infra0.example.com. 300 IN A 10.0.1.10
infra1.example.com. 300 IN A 10.0.1.11
infra2.example.com. 300 IN A 10.0.1.12

_etcd-server._tcp.example.com. 300 IN SRV 0 0 2380 infra0.example.com.
_etcd-server._tcp.example.com. 300 IN SRV 0 0 2380 infra1.example.com.
_etcd-server._tcp.example.com. 300 IN SRV 0 0 2380 infra2.example.com.

以第一条 SRV 记录为例,各字段表示:

  • 300:DNS TTL,单位为秒。
  • IN:Internet DNS class。
  • SRV:记录类型。
  • 第一个 0:priority,数值较小的记录优先。
  • 第二个 0:weight,同一 priority 下的相对权重。
  • 2380:etcd Peer 端口。
  • infra0.example.com.:target,Peer endpoint 对应的主机名。

启动前可以使用 dig 检查 SRV 和 A 记录是否返回预期结果。

dig +noall +answer SRV _etcd-server._tcp.example.com
dig +noall +answer infra0.example.com infra1.example.com infra2.example.com

下面三组命令分别在对应主机执行。三台主机使用相同的 discovery domain、cluster token 和 cluster state,本机的名称、Peer URL 与 Client URL 各不相同。

# 在 infra0.example.com(10.0.1.10)上执行
etcd \
  --name infra0 \
  --data-dir /var/lib/etcd \
  --discovery-srv example.com \
  --initial-advertise-peer-urls http://infra0.example.com:2380 \
  --listen-peer-urls http://10.0.1.10:2380 \
  --advertise-client-urls http://infra0.example.com:2379 \
  --listen-client-urls http://10.0.1.10:2379,http://127.0.0.1:2379 \
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster-state new

# 在 infra1.example.com(10.0.1.11)上执行
etcd \
  --name infra1 \
  --data-dir /var/lib/etcd \
  --discovery-srv example.com \
  --initial-advertise-peer-urls http://infra1.example.com:2380 \
  --listen-peer-urls http://10.0.1.11:2380 \
  --advertise-client-urls http://infra1.example.com:2379 \
  --listen-client-urls http://10.0.1.11:2379,http://127.0.0.1:2379 \
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster-state new

# 在 infra2.example.com(10.0.1.12)上执行
etcd \
  --name infra2 \
  --data-dir /var/lib/etcd \
  --discovery-srv example.com \
  --initial-advertise-peer-urls http://infra2.example.com:2380 \
  --listen-peer-urls http://10.0.1.12:2380 \
  --advertise-client-urls http://infra2.example.com:2379 \
  --listen-client-urls http://10.0.1.12:2379,http://127.0.0.1:2379 \
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster-state new

同一域名承载多套 etcd 集群时,使用 --discovery-srv-name 为 SRV 查询增加集群后缀。TLS 配置还需要把 discovery domain 和成员主机名写入证书的 DNS SAN。

kubeadm 使用 Static 方式搭建 stacked etcd。kubeadm 本地 etcd 阶段为首个 control-plane 配置显式的 --initial-cluster 并启动单成员集群;增加 control-plane 时,kubeadm 先通过 Membership API 添加 learner,再以 --initial-cluster-state=existing 启动并晋升该成员。课程的单 control-plane nvkind 集群采用这条路径。

kubeadm 的 external etcd 配置只接收已经部署完成的 endpoints 和客户端证书。外部 etcd 可以独立选择上述任一搭建方式,并在 kubeadm init 前完成组建。kubeadm join --discovery-token用于发现并验证 Kubernetes API Server,与 etcd Discovery Protocol 无关。

etcd 运维

etcd 的稳定性主要受磁盘持久化延迟、成员间网络质量和存储空间影响。运维工作应从容量规划开始,再根据 RTT、磁盘延迟和 Raft 状态调整参数,并通过指标与定期维护控制故障风险。

etcd Hardware recommendations 中的配置是容量规划的起点,不是最低硬件要求。生产环境需要使用接近真实 Key 数量、请求速率和 Watcher 数量的负载进行验证。

硬件配置

etcd 会将每次已提交写入持久化到 WAL,磁盘延迟直接进入写请求和线性一致读取的等待时间。持续的 I/O 抖动还会延迟 Raft 心跳,造成 Leader 变化和请求超时。因此,磁盘通常比 CPU 峰值更早成为稳定性瓶颈。

资源 常规负载的官方起点 重负载的官方起点 容量依据
CPU 2–4 个 CPU Core 8–16 个独占 CPU Core 客户端数量、请求速率和进程 CPU 使用率
内存 约 8 GB 16–64 GB Key 数量、数据规模和 Watcher 数量
磁盘 约 50 sequential IOPS;优先使用 SSD 约 500 sequential IOPS;恢复量较大时需要更高吞吐 WAL fsync、backend commit 延迟和成员追赶时间
网络 低延迟、可靠的 1 GbE 10 GbE 可缩短大数据量成员的恢复时间 Peer RTT、丢包率和恢复时间目标

表中的“常规负载”和“重负载”描述客户端数量、请求率和数据规模,与 etcd 成员数量无关。CPU 和内存范围也不能按 Kubernetes Node 数量直接换算。

官方示例的工作负载档位

官方页面按客户端数量、请求率和数据规模划分工作负载。云实例型号属于历史示例,下面只保留与容量规划有关的通用资源范围。

工作负载 客户端 请求率 数据规模 CPU 内存
Small <100 <200 req/s ≤100 MB 2 Core 约 8 GB
Medium <500 <1,000 req/s ≤500 MB 4 Core 约 16 GB
Large <1,500 <10,000 req/s ≤1 GB 8 Core 约 32 GB
xLarge >1,500 >10,000 req/s >1 GB 16 Core 约 64 GB
  • CPU:普通集群通常使用 2–4 个 CPU Core。服务数千客户端或每秒数万次请求时,官方建议从 8–16 个独占 CPU Core 开始评估。
  • 内存:etcd 会缓存 KV 数据,并为 Watcher 保留状态。普通负载通常从 8 GB 开始;数百万 Key 或数千 Watcher 的集群可从 16–64 GB 开始测试。
  • 磁盘:优先使用低延迟 SSD。云厂商标注的 concurrent IOPS 可能明显高于 etcd 关心的 sequential IOPS,应使用 fio 或等价工具在目标存储上实测。
  • 网络:成员间通信需要低延迟和稳定带宽。1 GbE 通常可以满足普通负载;数据库较大或恢复时间要求较短时,应评估 10 GbE。

磁盘和网络吞吐决定落后成员的恢复时间。官方给出的估算是:10 MB/s 可以在约 15 秒内传输 100 MB,100 MB/s 可以在约 15 秒内传输 1 GB。容量规划应使用实际数据库大小和恢复时间目标重新计算。

官方硬件示例假设主机由 etcd 独占。生产环境应避免让模型下载、容器镜像解压或日志批量写入与 etcd 竞争同一块磁盘;多成员复制提供可用性,定期快照仍然承担备份和灾难恢复职责。

性能调优

etcd 的默认参数面向低延迟局域网。参数调整应由 Peer RTT、WAL fsync、backend commit、Leader 变化和成员追赶时间等证据驱动,并先处理磁盘或网络瓶颈。etcd Tuning 列出了时间参数和操作系统资源优先级的调整原则。

心跳与选举

--heartbeat-interval 控制 Leader 发送心跳的间隔,默认值为 100 ms。官方建议将它设置为成员间最大平均 RTT 的 0.5–1.5 倍。这个 RTT 包含网络传输和磁盘处理时间。

--election-timeout 控制 Follower 等待 Leader 心跳的时间,默认值为 1000 ms。该值至少应为 RTT 的 10 倍,并且不超过 50,000 ms。过短会在延迟抖动时触发重新选举,过长则会延长真实故障的恢复时间。

例如,实测最大平均 RTT 接近 100 ms 时,可以从以下参数开始压测。所有成员必须使用相同的时间参数,并在滚动重启过程中逐个应用。

--heartbeat-interval=150
--election-timeout=1500

Raft 日志保留

--snapshot-count 表示自上一次 Raft Snapshot 以来,累计多少条已提交记录后创建新 Snapshot 并截断旧日志。它管理 Raft 日志保留,与用于灾难恢复的 etcdctl snapshot save 不是同一个功能。

etcd v3.7.1 的默认值是 10000。增大该值可以让短暂落后的 Follower 通过日志追赶,但会增加内存占用和 Go GC 压力;减小该值会更频繁地生成 Raft Snapshot,落后较多的成员也更容易进入完整 Snapshot 传输。调整前应同时观察成员追赶、内存和 Proposal 延迟。etcd v3.7.1 的 snapshot-count 默认值记录在服务端源码中。

--snapshot-catchup-entries 控制 Raft Storage 压缩后为慢 Follower 额外保留的日志数量,v3.7.1 默认值为 5000。它决定慢成员还能通过多少条日志追赶,--snapshot-count 则决定何时生成新的 Raft Snapshot,两个参数承担不同职责。

资源优先级

  • 磁盘:独占低延迟磁盘是首选。Linux ionice 可以提高 etcd 的 I/O 优先级,但效果取决于 I/O Scheduler,不能替代资源隔离。
  • 网络:当 Peer RTT 和网络丢包同时升高时,可以为成员通信端口 2380 配置高于客户端端口 2379 的优先级。流量分类规则应先在目标网卡和队列模型上验证。
  • CPU:CPU 频率动态降低会增加尾延迟。对延迟敏感的独占主机可以评估 performance 或 conservative governor,并用功耗与延迟数据决定最终设置。

监控指标

每个 etcd 成员都以 Prometheus exposition format 提供 /metrics。该端点默认挂在客户端监听地址上,也可以通过 --listen-metrics-urls 使用单独的监听地址。Prometheus 分别采集每个成员,才能通过 instance 标签识别单个成员的磁盘、网络和 Raft 异常。etcd Monitoring 给出了指标端点和 Prometheus 的基础配置。

etcd 默认使用 --metrics=basic。设置 --metrics=extensive 后会增加服务端 gRPC 延迟直方图,同时产生更多时间序列。etcd_debugging_* 属于不稳定指标,版本升级时可能改名或删除;Dashboard 和长期告警应优先使用不带 debugging 前缀的指标。完整名称、类型和标签见 etcd v3.7 Metrics 与 etcd 监控参数。

指标采集

单独的 metrics listener 可以避免向 Prometheus 分发 etcd 客户端证书。下面的参数需要在每个成员上配置为本机的管理网地址;该端点同时提供健康检查,因此应通过管理网络和防火墙限制访问。

--listen-metrics-urls=http://10.0.1.11:2381

kubeadm 创建的本地 etcd 默认监听 http://127.0.0.1:2381。Node 上的采集器可以直接访问该地址;集中部署的 Prometheus 需要通过受控的管理网络连接独立 metrics listener。对应默认值见 kubeadm local etcd 参数和 2381 端口定义。

Prometheus 将三个成员配置为三个独立 target。job 在官方 Dashboard 中用作集群选择条件,多套 etcd 集群应使用不同的 job_name,或者在采集配置中增加独立的集群标签。

scrape_configs:
  - job_name: etcd-prod
    scrape_interval: 10s
    static_configs:
      - targets:
          - "10.0.1.11:2381"
          - "10.0.1.12:2381"
          - "10.0.1.13:2381"

配置完成后,可以直接检查一个成员返回的指标。过滤 debugging 只用于查看稳定指标,Prometheus 抓取时仍然读取完整的 /metrics 响应。

curl -fsS http://10.0.1.11:2381/metrics \
  | grep -v debugging \
  | sed -n '1,40p'

常用指标

下表列出了 etcd 集群最常用的可用性、Raft、磁盘、网络和存储指标。Counter 使用 rate() 或 increase() 观察一段时间内的变化;Histogram 使用 _bucket 序列和 histogram_quantile() 计算分位数。

关注点 指标 读法
采集可用性 up Prometheus 为每个 target 生成的指标。值为 0 表示采集失败,此时其他 etcd 指标也会同时消失
Leader etcd_server_has_leader、etcd_server_is_leader 前者表示该成员能否看到 Leader;后者用于确认当前 Leader 位于哪个成员
Leader 变化 etcd_server_leader_changes_seen_total 使用 increase() 统计时间窗口内的变化次数。频繁选举通常需要继续检查成员重启、磁盘延迟和 Peer RTT
Raft Proposal etcd_server_proposals_pending、etcd_server_proposals_failed_total Pending 持续增长表示提交队列正在积压;Failed Counter 的增量表示 Proposal 失败
WAL 与 Backend etcd_disk_wal_fsync_duration_seconds、etcd_disk_backend_commit_duration_seconds 两个 Histogram 分别反映 WAL fsync 和 backend commit 延迟,磁盘抖动会直接增加写入和线性一致读取的等待时间
成员网络 etcd_network_peer_round_trip_time_seconds Peer RTT Histogram。跨成员延迟升高会影响心跳、日志复制和 Leader 稳定性
数据库空间 etcd_mvcc_db_total_size_in_bytes、etcd_mvcc_db_total_size_in_use_in_bytes、etcd_server_quota_backend_bytes 分别表示 backend 已分配空间、逻辑使用空间和 quota。已分配空间接近 quota 会触发 NOSPACE 告警;两种数据库大小差距较大时再评估 Compaction 和 Defragmentation
gRPC 请求 grpc_server_started_total、grpc_server_handled_total 使用 rate() 计算请求速率,并按 grpc_code 统计失败结果;启用 --metrics=extensive 后还可以观察服务端请求延迟直方图

PromQL 查询

以下查询保留 job 和 instance 标签,既能判断集群整体状态,也能定位异常成员。Histogram 的 P99 需要对同一成员的 bucket 按 le 聚合。

# Prometheus 是否能够采集所有成员
min by (job) (up{job="etcd-prod"})

# 所有成员是否都能看到 Leader
min by (job) (etcd_server_has_leader{job="etcd-prod"})

# 最近 15 分钟的 Leader 变化次数;max 避免按成员重复累计同一次选举
max by (job) (
  increase(etcd_server_leader_changes_seen_total{job="etcd-prod"}[15m])
)

# 每个成员 WAL fsync 的 P99 延迟
histogram_quantile(
  0.99,
  sum by (job, instance, le) (
    rate(etcd_disk_wal_fsync_duration_seconds_bucket{job="etcd-prod"}[5m])
  )
)

# 每个成员 backend commit 的 P99 延迟
histogram_quantile(
  0.99,
  sum by (job, instance, le) (
    rate(etcd_disk_backend_commit_duration_seconds_bucket{job="etcd-prod"}[5m])
  )
)

# backend 已分配空间占 quota 的百分比
100 *
etcd_mvcc_db_total_size_in_bytes{job="etcd-prod"}
/
etcd_server_quota_backend_bytes{job="etcd-prod"}

# backend 中可通过 Defragmentation 回收的空间比例
1 - (
  etcd_mvcc_db_total_size_in_use_in_bytes{job="etcd-prod"}
  /
  etcd_mvcc_db_total_size_in_bytes{job="etcd-prod"}
)

告警阈值需要结合磁盘类型、成员分布、请求量和延迟目标设定。etcd 官方提供的 Prometheus 告警规则覆盖成员下线、失去多数派、Leader 频繁变化、Peer RTT、WAL fsync、backend commit、quota 和碎片率,可以作为初始规则集。

Grafana Dashboard

etcd 官方提供可直接导入 Grafana 的 Dashboard JSON。Dashboard 包含 Leader、Proposal、数据库大小、WAL fsync、backend commit、gRPC 流量、Peer 流量和进程内存等面板,并通过 Prometheus job 标签选择集群。

下图是官方文档提供的三成员集群 Dashboard 示例。不同颜色对应不同的 etcd 成员;Grafana 版本和 Dashboard JSON 发生变化时,面板样式也会随之变化。

etcd 官方 Grafana Dashboard 示例,展示成员状态、RPC、数据库大小、磁盘同步延迟、内存和网络流量

图:etcd Grafana Dashboard 示例。来源:etcd Monitoring。

curl -LfsS \
  https://etcd.io/docs/v3.7/op-guide/grafana.json \
  --output etcd-grafana.json

etcd Monitoring Mixin 提供可定制的 Jsonnet Dashboard 源码和 Prometheus 告警规则。该 Mixin 的 README 将其标记为 Alpha;使用时应固定到目标 etcd 版本,生成 Dashboard 和规则后再根据 Prometheus 标签及运行环境调整。

空间维护与快照恢复

Compaction、Defragmentation 和 Snapshot 分别处理历史版本、磁盘文件和灾难恢复。三者不能互相替代。

操作 作用 执行边界
Compaction 删除指定 revision 之前的 MVCC 历史,使 backend 可以重用内部空间 数据库文件大小不会立即减小
Defragmentation 重写单个成员的 backend,只保留有效数据并缩小数据库文件 每个成员分别执行;在线操作会阻塞该成员的读写
Snapshot 导出可用于恢复的键空间备份 不负责多数派管理,也不释放数据库空间

维护操作应由数据库使用量、quota、碎片比例和磁盘延迟触发。etcd_mvcc_db_total_size_in_bytes 表示 backend 已分配空间,etcd_mvcc_db_total_size_in_use_in_bytes 表示当前数据实际使用的空间;两者差值较大时,才有较多空间可以通过 Defragmentation 归还给文件系统。etcd Maintenance 给出了完整的操作边界。

MVCC 历史压缩

MVCC 为每次写入分配新的 revision,并保留旧版本供 Watch 续接和历史读取。Compaction 删除保留窗口之前的历史版本,旧 revision 随后返回 ErrCompacted;Watch 客户端需要重新 LIST 当前状态,再从新的 revision 建立 Watch。

--auto-compaction-retention=0 表示默认关闭自动压缩。周期模式按时间保留历史,适合用明确的 Watch 续接窗口表达策略。下面的配置保留约 10 小时历史;10h 是官方文档中的起始示例,需要根据最长断连时间和写入速率调整。

--auto-compaction-mode=periodic
--auto-compaction-retention=10h

Revision 模式按 revision 数量保留历史。下面的配置以最新 revision 为基准,保留约 10,000 个 revision,并由后台任务定期执行压缩。

--auto-compaction-mode=revision
--auto-compaction-retention=10000

手动执行时,先读取集群当前 revision,再提交 Compaction。该操作会影响所有成员,因为 Compaction 本身是一条经过 Raft 提交的集群写入。

revision="$(
  etcdctl --write-out=json endpoint status \
    | jq -r '.[0].Status.header.revision'
)"

etcdctl compact "${revision}"

Backend 碎片整理

Compaction 删除旧 revision 后,相关数据库页可以被 etcd 重新使用,backend 文件不会随之缩小。Defragmentation 把有效数据写入紧凑的 backend,缩小数据库文件,使未使用的磁盘空间重新对文件系统可用。

在线 Defragmentation 会阻塞目标成员的读写,并且不会通过 Raft 自动应用到其他成员。生产集群应逐个成员执行,每次确认成员恢复健康后再处理下一个成员;从 Follower 开始,最后处理 Leader,可以减少对客户端请求的影响。

三个成员依次执行 Defragmentation

以下命令显式指定单个 endpoint,防止一次操作同时影响多个成员。TLS 证书通过前文设置的 ETCDCTL_CACERT、ETCDCTL_CERT 和 ETCDCTL_KEY 环境变量读取。

# 先处理两个 Follower
etcdctl --endpoints="https://infra1.example.com:2379" defrag
etcdctl --endpoints="https://infra2.example.com:2379" defrag

# 最后处理 Leader;执行前根据 endpoint status 确认实际 Leader
etcdctl --endpoints="https://infra0.example.com:2379" defrag

etcdctl --write-out=table endpoint --cluster status
etcdctl endpoint --cluster health

停止的成员也可以使用 etcdutl defrag --data-dir <path> 整理本地数据目录。离线操作前应确认该进程已经完全停止,并保留可恢复的 Snapshot。

空间配额与 NOSPACE

--quota-backend-bytes 限制 backend 大小。etcd v3.7 在该参数为 0 时使用 2 GiB 的默认 quota。任一成员超过 quota 后,集群会触发 NOSPACE alarm,并进入只接受读取和删除等维护请求的模式。

恢复顺序是删除不再需要的数据、执行 Compaction、逐个成员执行 Defragmentation、确认空间低于 quota,再清除 alarm。Kubernetes 对象应通过 API Server 删除,直接删除 /registry/... key 会绕过 Kubernetes 的校验和对象生命周期处理。

处理 NOSPACE alarm

先确认 alarm 和各成员的 backend 大小,再通过 Kubernetes API 删除已经确认不再需要的对象。以下命令展示空间维护和恢复写入验证,实际清理对象应按故障现场决定。

etcdctl alarm list
etcdctl --write-out=table endpoint --cluster status

revision="$(
  etcdctl --write-out=json endpoint status \
    | jq -r '.[0].Status.header.revision'
)"
etcdctl compact "${revision}"

# 按成员逐一执行,并在每次操作后检查健康状态
etcdctl --endpoints="https://infra1.example.com:2379" defrag
etcdctl endpoint --cluster health
etcdctl --endpoints="https://infra2.example.com:2379" defrag
etcdctl endpoint --cluster health
etcdctl --endpoints="https://infra0.example.com:2379" defrag
etcdctl endpoint --cluster health

etcdctl alarm disarm

kubectl create configmap etcd-maintenance-check \
  --from-literal=status=ok \
  --dry-run=client \
  --output=yaml \
| kubectl apply --filename=-
kubectl delete configmap etcd-maintenance-check

客户端收到 ErrGRPCNoSpace 时,写入仍有可能已经进入 Apply 阶段。重试带有外部副作用的操作前,应先读取目标对象确认当前状态。

Snapshot 备份与恢复

etcdctl snapshot save 通过 Snapshot API 从一个指定 endpoint 取得该成员提供的 keyspace 快照。直接复制 member/snap/db 可能遗漏仍在 WAL、尚未写入该文件的数据;在线 Snapshot API 会把这些已提交状态包含在快照中。etcd Disaster recovery说明了两种取快照方式的差别。

备份应定期验证,而不是只检查文件是否存在。使用 etcdutl snapshot status 检查 revision、Key 数量、hash 和文件大小,并在隔离环境中演练恢复流程。快照命令和校验示例见前文的 etcdctl 与 etcdutl 小节。

从旧快照恢复 Kubernetes 时,恢复后的 revision 可能低于故障前的 revision,而 Controller 仍可能持有故障前的 informer cache。etcdutl snapshot restore 的 --bump-revision 用于提高恢复后的 revision,--mark-compacted 使旧 Watch 立即失效,促使 Kubernetes 组件重新 LIST。

恢复前需要停止所有 API Server。多成员集群的每个成员使用同一份快照恢复到新的数据目录,再通过新的数据目录组成新的逻辑集群。启动顺序为 etcd、API Server,再重启 kube-controller-manager、kube-scheduler 和 Kubelet,使各组件重新建立本地缓存。具体步骤见 etcd Disaster recovery 和 Kubernetes 官方 etcd 运维指南。

相关资料