Kubernetes 基础体系 · 第 9/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes etcd 数据层:Revision、Watch、事务、Compaction 和备份

Kubernetes 控制面中的 etcd 是一个分布式键值存储。kube-apiserver 将 API 对象持久化到 etcd,并通过 etcd 的读取、事务和 Watch 能力实现 API 查询、并发更新以及控制器的事件驱动调谐。

可以先把数据流抽象为:

flowchart LR
    C[客户端 kubectl/controller] --> A[kube-apiserver]
    A --> S[API Server storage 层]
    S --> E[etcd 集群]
    E --> W[etcd Watch 流]
    W --> A
    A --> C
    A -->|List/Watch API| C

客户端不会直接访问 etcd。Kubernetes API Server 负责认证、授权、准入、对象默认值、版本转换、资源校验和存储编码;etcd 只负责在 API Server 指定的键空间中保存数据。因此,直接修改 Kubernetes 使用的 etcd 键,虽然技术上可能可行,但绕过了 Kubernetes 的全部 API 语义,通常会导致对象无法解码、资源版本异常或控制器状态不一致。


1. etcd 的 MVCC 数据模型

1.1 键值、版本和 Revision

etcd v3 使用 MVCC(Multi-Version Concurrency Control,多版本并发控制)保存键值。一个键不是只有“当前值”,而是具有一系列历史版本:

键 /app/config

revision 100: value = "v1"
revision 105: value = "v2"
revision 109: delete

这里需要区分几个概念:

  • 全局 Revision:etcd 集群级别的逻辑版本号。
  • CreateRevision:某个键第一次创建时所在的 Revision。
  • ModRevision:该键最近一次被修改时所在的 Revision。
  • Version:该键自身被修改的次数;删除后重新创建通常会开始新的键生命周期。
  • 当前值:在当前 Revision 上仍然存在的值。

etcd 响应头中的 header.revision 表示这次操作观察到的集群全局 Revision。它不是某个对象的版本号,也不是时间戳。

可以用下式表示一次提交:

Ri+1=Ri+1R_{i+1} = R_i + 1

其中:

  • RiR_i 是提交前的全局 Revision;
  • Ri+1R_{i+1} 是提交后的全局 Revision;
  • 一次成功提交可以包含多个键的修改;
  • 同一个事务中的多个修改通常共享同一个提交 Revision。

例如,初始 Revision 为 20,下面事务同时写入两个键:

put /a = 1
put /b = 2

提交后可能得到:

/a: ModRevision = 21
/b: ModRevision = 21
header.revision = 21

这说明 Revision 表示“提交顺序”,而不是“每个键修改一次就加一”。如果分别执行两个独立的 put,则它们通常会获得不同的 Revision:

put /a = 1  -> revision 21
put /b = 2  -> revision 22

因此,同一个 Revision 可以代表一个原子提交批次。

1.2 Revision 的排序含义

Revision 具有单调递增的全局顺序:

Ra<Rb提交 a 发生在提交 b 之前R_a < R_b \Rightarrow \text{提交 } a \text{ 发生在提交 } b \text{ 之前}

这个顺序不是物理时间的精确替代。两个客户端可能在不同时间读取数据,网络延迟也可能使“请求发出时间”和“提交时间”不同。Revision 能可靠表达的是 etcd 内部的线性化提交顺序,而不是墙上时钟。

例如:

客户端 A 请求在 10:00:00 发出
客户端 B 请求在 09:59:59 发出

如果 B 因网络延迟更晚提交,则可能出现:

A -> revision 50
B -> revision 51

Revision 应用于并发控制和 Watch 续接,不应被当作 Unix 时间戳。

1.3 Kubernetes 中的 resourceVersion

Kubernetes API 对象通常包含:

metadata:
  resourceVersion: "12345"

对使用 etcd 的默认存储实现而言,这个值通常与 etcd 的 Revision 有直接关系,但 Kubernetes API 将 resourceVersion 定义为不透明字符串。客户端可以使用它进行一致性读取、乐观并发控制和 List/Watch 协调,但不应假设:

  • 它永远是可解析的十进制整数;
  • 它一定等于某个对象的 ModRevision
  • 它可以跨集群比较;
  • 它可以作为时间戳;
  • 所有资源和所有存储后端都以完全相同的方式生成它。

在当前 Kubernetes 的 etcd 存储实现中,典型键形态类似:

/registry/pods/default/nginx
/registry/deployments/default/web

这是 Kubernetes 的常见内部实现,而不是面向用户承诺的稳定存储路径。资源名称、编码方式和存储前缀可能随版本、资源类型和实现变化。不要通过直接操作 /registry 键来代替 Kubernetes API。


2. 从写入到 Watch:一条事件如何传播

一次 Kubernetes 对象更新大致经过以下步骤:

  1. 客户端向 API Server 发送创建、更新或删除请求。
  2. API Server 完成认证、授权、准入和对象处理。
  3. API Server 将对象编码为 etcd 存储格式。
  4. API Server 通过 etcd 的单次写入或事务提交数据。
  5. etcd 为该提交分配新的全局 Revision。
  6. etcd 将变更记录提供给匹配的 Watch。
  7. API Server 将内部事件转换为 Kubernetes API 事件。
  8. Informer、控制器或其他客户端更新本地缓存并触发调谐。

控制器并不是不断完整扫描 etcd,而是通常执行:

List 当前对象 -> 记录 resourceVersion -> Watch 后续变化

这两个步骤必须结合起来理解。只 Watch 不先 List,会缺少 Watch 建立前已经存在的对象;只 List 不 Watch,则无法及时获知后续变化。

sequenceDiagram
    participant K as Kubernetes 客户端/Informer
    participant A as kube-apiserver
    participant E as etcd

    K->>A: LIST,获取对象和 resourceVersion=R
    A->>E: 在一致性语义下读取
    E-->>A: 当前对象集合,header.revision=R
    A-->>K: List 响应

    K->>A: WATCH,从 resourceVersion=R 开始
    A->>E: 建立 Watch
    E-->>A: revision R+1 的事件
    A-->>K: ADDED/MODIFIED/DELETED 事件

2.1 etcd Watch 的语义

etcd Watch 是从某个 Revision 开始接收键变化的流。一个 Watch 请求可以指定:

  • 精确键;
  • 键前缀;
  • 起始 Revision;
  • 是否返回前一个值;
  • 是否接收进度通知;
  • 是否创建过滤器。

如果指定从 Revision RR 开始,语义目标是接收从该位置之后发生的匹配事件。实际客户端 API 对“是否包含起始 Revision”的表达可能通过参数和版本实现细节体现,因此应遵循所使用客户端的文档;在 Kubernetes API 层则应使用 resourceVersion 语义,而不是自行拼接 etcd Watch 参数。

一次 etcd 事务可能修改多个键。Watch 事件通常会携带同一个 Revision,例如:

revision 200:
  PUT /a = x
  PUT /b = y

Watch 客户端可以据此知道这些变化属于同一个 etcd 提交批次。

Watch 提供的是有序事件流,但不是永久历史日志。它受到 Compaction 的限制,连接也可能因为网络断开、服务器取消、超时或资源压力而终止。一个正确的客户端必须能够:

  1. 记录已经处理到的 Revision;
  2. 断线后从合适的 Revision 重新建立 Watch;
  3. 处理重复事件或重新同步;
  4. 遇到 ErrCompacted 时重新 List;
  5. 处理 Watch 流结束而不是把流结束当作“没有更多事件”。

2.2 进度通知不是对象事件

etcd 可以发送进度通知,用于告诉客户端 Watch 已经追赶到某个 Revision,但这类通知不代表某个键发生了修改。

因此以下两者不能混淆:

PUT /registry/pods/default/p1

表示对象变化;

progress notification: revision = 300

表示 Watch 处理位置的推进,不表示对象变化。

Kubernetes 的 Informer 通常还会通过定期重新 List、Reflector 重连和本地缓存校验来处理事件丢失或连接故障。Watch 本身不能替代状态同步。

2.3 Watch 的完整性边界

假设客户端最后处理到 Revision 100,随后断线。恢复时有两种情况:

情况 A:历史仍可 Watch

如果 etcd 尚未压缩掉需要的历史,客户端可以从 Revision 101 继续 Watch:

last processed = 100
resume from    = 101

情况 B:历史已经被 Compaction 删除

如果客户端请求从已经压缩的 Revision 开始,etcd 会返回 ErrCompacted。此时不能通过重试同一个 Watch 修复,因为事件历史本身已经不存在。

正确做法是:

重新 List 当前完整状态
记录新的 resourceVersion
从新的 resourceVersion 建立 Watch

这也是“List + Watch”模式必须具备重新同步路径的原因。


3. etcd 事务:把读取条件和写入绑定为一次原子提交

3.1 事务的基本形式

etcd 事务可以形式化为:

if C1C2Cn then Tsuccess else Tfailure\text{if } C_1 \land C_2 \land \cdots \land C_n \text{ then } T_{\text{success}} \text{ else } T_{\text{failure}}

其中:

  • CiC_i 是对键的比较条件;
  • success 分支是一组操作;
  • failure 分支是另一组操作;
  • 条件判断与选中分支的执行属于一次原子提交。

常见比较条件包括:

  • 键是否存在;
  • CreateRevision 是否等于某值;
  • ModRevision 是否等于某值;
  • Version 是否等于某值;
  • 当前值是否等于某值;
  • Lease ID 是否等于某值。

事务的关键不是“把多个 RPC 拼在一起”,而是让条件判断和结果写入在 etcd 内部以原子方式完成。

3.2 用事务实现创建时的条件竞争

假设两个客户端都想创建:

/locks/job

如果使用以下逻辑:

1. get /locks/job
2. 如果不存在
3. put /locks/job

这不是原子操作。两个客户端可能同时在第 2 步看到键不存在,然后都执行第 3 步:

客户端 A: get -> 不存在
客户端 B: get -> 不存在
客户端 A: put -> 成功
客户端 B: put -> 覆盖 A

事务可以将条件和写入合并:

if CreateRevision("/locks/job") == 0:
    put "/locks/job" = "client-A"
else:
    get "/locks/job"

条件成立表示键不存在;只有一个并发事务能在键仍不存在时成功创建。

使用 etcdctl 的示例:

export ETCDCTL_API=3
export ETCDCTL_ENDPOINTS=https://127.0.0.1:2379
export ETCDCTL_CACERT=/path/to/ca.crt
export ETCDCTL_CERT=/path/to/client.crt
export ETCDCTL_KEY=/path/to/client.key

etcdctl txn <<'EOF'
compare
create /locks/job = 0
then
put /locks/job client-A
else
get /locks/job
EOF

这里:

  • create /locks/job = 0 表示 CreateRevision 为 0,即键不存在;
  • then 分支只在条件全部满足时执行;
  • else 分支返回已经存在的值;
  • 命令必须使用与目标 etcd 兼容的客户端版本和有效 TLS 证书。

如果两个客户端同时运行,至多一个客户端会进入 then 分支。另一个客户端会执行 else 分支并读到已有值。

3.3 用 ModRevision 实现乐观并发控制

假设当前值为:

键: /config/feature
值: off
ModRevision: 40

客户端 A 读取到 ModRevision=40,准备改为 on。客户端 B 先一步把它改成了 test,新的 ModRevision 变为 41。

客户端 A 使用:

if ModRevision("/config/feature") == 40:
    put "/config/feature" = "on"
else:
    abort

由于当前 ModRevision 已经是 41,事务失败,A 不会覆盖 B 的更新。

这种模型等价于:

写入成功    读取时版本=提交时版本\text{写入成功} \iff \text{读取时版本} = \text{提交时版本}

Kubernetes API 的资源更新也具有类似的乐观并发控制语义:客户端提交带有旧 resourceVersion 的更新时,如果对象已经被其他写入者修改,API Server 通常返回冲突,客户端需要重新读取、合并并重试。具体字段和 API 行为由 Kubernetes API 定义,不应直接把 etcd 事务格式暴露给普通 Kubernetes 客户端。

3.4 事务的原子性不等于跨系统事务

etcd 事务可以保证同一个 etcd 集群内的键修改原子提交,但不能自动保证以下操作的一致性:

etcd 写入成功
数据库写入成功
消息队列发送成功

也不能让 Kubernetes 对象更新和外部云资源创建成为一个两阶段提交事务。控制器通常使用声明式状态、状态字段、重试和幂等操作来处理这类跨系统一致性,而不是依赖 etcd 事务解决所有问题。

3.5 线性化读取和串行化读取

etcd 常见读取语义有:

  • 线性化读取(linearizable read):读取结果反映一个满足实时顺序约束的最新状态,通常需要与集群协调;
  • 串行化读取(serializable read):可以从本地成员读取,延迟较低,但可能落后于集群最新状态。

当 API Server 需要一致地读取 Kubernetes 对象时,存储层会依据 Kubernetes 的存储语义选择读取方式。直接使用 etcdctl 诊断时,要注意本地成员返回的数据可能与领导者刚提交的数据存在短暂差异。读取成功不等于读取到了集群最新状态。


4. Compaction:删除历史版本,而不是删除当前对象

4.1 为什么需要 Compaction

MVCC 如果无限保留历史版本,会持续消耗磁盘空间。Compaction 的作用是删除某个 Revision 之前不再需要的历史版本,使 etcd 的逻辑历史变短。

假设:

revision 10: /x = A
revision 20: /x = B
revision 30: /x = C

在当前 Revision 30 上,/x 的值是 C。如果对 Revision 20 进行 Compaction,通常可以删除恢复到更早状态所需的历史版本,但不会删除当前值 C

因此:

get /x

仍然可以成功返回:

/x = C

但从过旧 Revision 建立 Watch 可能失败:

watch /x from revision 10
-> ErrCompacted

Compaction 删除的是“过去如何变化”的部分记录,不是“当前键值”。

4.2 Compaction 和 Watch 的关系

设:

  • 客户端最后处理到 Revision RcR_c
  • etcd 已经 Compaction 到 Revision RkR_k

如果:

RcRkR_c \leq R_k

客户端再试图从 RcR_c 继续 Watch,就可能无法获得完整事件历史,因为其中一部分已经被删除。它必须重新 List 当前状态。

完整恢复逻辑可以写成:

while true:
    try:
        watch from lastRevision + 1
    if stream ends:
        reconnect
    if ErrCompacted:
        objects, newRevision = list current state
        replace local cache with objects
        lastRevision = newRevision
        continue watch

“收到事件后直接把 revision 加一”并不总是足够。客户端还需要处理同一 Revision 的多个事件、事件重复、连接断开和列表与 Watch 建立之间的竞态。成熟的 Kubernetes Informer/Reflector 已经实现了这些机制。

4.3 Compaction 不等于 Defragmentation

这两个操作解决不同问题:

Compaction

清理逻辑上已经过期的 MVCC 历史版本。

历史版本减少
逻辑可用空间改善

Defragmentation

重新整理后端 BoltDB 等存储文件中的空闲空间,把已释放空间归还给文件系统。

文件内部空洞减少
磁盘文件可能变小

Compaction 后数据库文件不一定立刻变小,因为文件内部可能只是出现可复用空间。Defrag 通常是更重的操作,可能产生额外磁盘和 IO 压力,必须结合成员健康、磁盘延迟和业务负载安排。

典型诊断命令:

etcdctl endpoint status --cluster -w table
etcdctl endpoint health --cluster

输出通常包含:

  • endpoint;
  • member ID;
  • 当前 Revision;
  • 是否为 leader;
  • 数据库大小;
  • 已使用数据库大小;
  • Raft term 和 index 等信息。

不同 etcd 版本的表格列可能不同,不能把某一列的固定位置写死在自动化脚本中;生产脚本应使用 JSON 输出并按字段解析。

4.4 Kubernetes 如何面对 Compaction

Kubernetes API Server 的 etcd 存储层会维护对象读写和 Watch 所需的资源版本语义。API Server 也通常会执行或配置自动 Compaction;具体参数名称、默认周期和版本行为取决于 Kubernetes 版本及启动参数,例如 kube-apiserver 的 etcd compaction 相关选项。

不要因为 Kubernetes 自动 Compaction 就取消监控。仍然需要关注:

  • etcd 数据库大小;
  • 磁盘剩余空间;
  • mvcc 压缩相关指标;
  • Watch 取消和重建次数;
  • API Server 的 List/Watch 延迟;
  • etcd leader 变更和 Raft 提交延迟。

Compaction 周期过长会增加存储空间和恢复扫描成本;过于激进或配合错误的客户端实现,则可能频繁触发重新 List,增加 API Server 和 etcd 负载。合理值取决于对象变更率、客户端重连时间和磁盘容量,而不是一个适用于所有集群的固定数字。


5. Kubernetes API Server 与 etcd 的边界

Kubernetes 不是把 API 对象原样以 JSON 文件放进 etcd。API Server 存储层通常会:

  1. 将外部 API 版本转换为内部对象;
  2. 执行认证、授权和准入;
  3. 应用默认值和字段处理;
  4. 以配置的编码格式写入 etcd;
  5. 在读取时反序列化并转换回请求版本;
  6. 通过 API 层维护 resourceVersion、分页、List/Watch 和并发冲突语义。

因此,下面的命令适合诊断,而不是业务操作:

etcdctl get /registry/pods/default/nginx

它可能返回二进制或不可读内容。即使加上 --print-value-only,也不保证是 JSON,因为 Kubernetes 可能使用 protobuf 或其他编码。

查看 etcd 键空间时,前置条件包括:

  • 具有 etcd 客户端证书;
  • 证书的身份被 etcd 授权;
  • 使用正确的 CA、客户端证书、私钥;
  • 目标 endpoint 与 etcd 集群配置一致;
  • 使用兼容的 etcdctl 版本。

例如只查看键名:

etcdctl get /registry/ --prefix --keys-only

这类命令可能返回大量键,不应在大集群上无条件执行。更安全的方式是限制前缀、分页或在低峰期执行,并先确认命令不会把对象值打印到终端或日志中。

5.1 为什么不应该直接改 /registry

直接执行:

etcdctl put /registry/pods/default/nginx ...

至少会绕过:

  • Kubernetes 对象 schema 校验;
  • ResourceVersion 冲突检查;
  • 准入 Webhook;
  • 相关索引和 API 语义;
  • Secret 加密提供者;
  • 版本转换;
  • 控制器对合法事件的预期。

错误编码可能使 API Server 无法读取对象;错误字段可能导致控制器反复报错;直接删除对象也可能绕过正常的终结器和级联删除流程。诊断可以直接读 etcd,修复 Kubernetes 对象应优先使用 Kubernetes API。


6. etcd 备份:Snapshot 保存的是什么

6.1 Snapshot 的定义

etcd snapshot 是某个 etcd 后端状态的一致性快照,包含:

  • 当前键值;
  • MVCC 所需的状态;
  • 与 Raft 状态相关的持久化数据;
  • etcd 数据库中的 Revision 等元数据。

它不是:

  • Kubernetes YAML 导出;
  • Kubernetes 资源的完整语义备份;
  • 控制面所有配置的自动备份;
  • TLS 私钥、CA、加密配置和云资源状态的自动备份。

可以把灾难恢复需要的内容分为四类:

类别 示例 Snapshot 是否自动包含
Kubernetes 对象数据 Pod、Deployment、Secret、CRD 实例 通常包含
etcd 数据状态 MVCC、Revision、授权数据等 包含在 etcd 数据库范围内
控制面配置 API Server 参数、Admission 配置 不包含
访问和解密材料 CA、etcd 证书、KMS 密钥、加密配置 不应假设包含

具体是否包含某类 etcd 内部数据,应以所用 etcd 版本的 Snapshot 文档为准;但 TLS 文件、Kubernetes 静态 Pod manifest、加密配置文件显然不在 etcd 数据库中。

6.2 使用 etcdctl snapshot save

下面是典型形式:

export ETCDCTL_API=3

etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt \
  --key=/etc/kubernetes/pki/etcd/healthcheck-client.key \
  snapshot save /var/backups/etcd/snapshot-$(date +%Y%m%d-%H%M%S).db

参数含义:

  • --endpoints:访问的 etcd endpoint;
  • --cacert:验证 etcd 服务端证书的 CA;
  • --cert--key:客户端身份;
  • snapshot save:从 etcd 读取并保存快照;
  • 最后的路径:本地快照文件。

Kubernetes kubeadm 集群常见证书路径位于 /etc/kubernetes/pki/etcd/,但这不是所有发行版和托管 Kubernetes 都适用。云厂商托管控制面通常不允许管理员直接访问 etcd;此时必须使用厂商提供的备份和恢复机制。

备份后至少执行完整性检查:

etcdctl snapshot status /var/backups/etcd/snapshot-20250101-120000.db -w table

较新的 etcd 发行版可能推荐使用 etcdutl snapshot statusetcdutl snapshot restoreetcdctletcdutl 的职责和可用子命令随 etcd 版本变化,生产脚本必须使用与服务器兼容的工具,并以该版本官方命令为准,不能仅依据另一版本的博客命令复制。

6.3 从哪个成员创建 Snapshot

etcd 客户端可以访问集群 endpoint。快照操作必须在 etcd 自身一致性语义下完成,而不是简单复制正在运行的数据库文件。

不安全的做法包括:

cp /var/lib/etcd/member/snap/db /backup/

在 etcd 运行期间直接复制数据库文件可能得到不完整或不适合恢复的文件。在线备份应使用 etcd 提供的 Snapshot API/CLI;离线复制只有在正确停止服务、保证文件系统状态一致并遵守版本要求时才有意义。

备份过程还需要记录:

  • 快照生成时间;
  • endpoint 和 member 信息;
  • etcd、Kubernetes、客户端工具版本;
  • 文件校验和;
  • 快照是否成功完成;
  • 上传到远端存储是否成功。

例如:

sha256sum /var/backups/etcd/snapshot-20250101-120000.db

校验和只能证明文件未被意外改变,不能证明这个快照能够启动恢复后的控制面;可恢复性必须通过定期恢复演练验证。


7. Secret、静态 Pod 和加密配置的备份风险

7.1 Snapshot 可能包含敏感信息

etcd 中包含大量 Kubernetes 敏感对象,尤其是:

  • Secret;
  • ServiceAccount token 或相关凭据;
  • Webhook 配置中的证书;
  • 自定义资源中的密码和密钥;
  • 可能由控制器写入的云平台凭据。

因此 Snapshot 必须按高敏感级别处理:

  • 限制文件权限;
  • 加密传输;
  • 远端备份加密;
  • 最小化访问主体;
  • 设置保留期和删除策略;
  • 记录审计日志;
  • 避免把 etcdctl get 输出写入普通日志。

7.2 Kubernetes 静态加密不等于 Snapshot 无风险

如果 Kubernetes 配置了 Secret 静态加密,etcd 中保存的可能是加密后的内容,但恢复时仍需要对应的:

  • encryption configuration;
  • 加密提供者配置;
  • KMS 插件连接信息;
  • 解密密钥或密钥管理系统访问权限。

这些内容通常不在 etcd Snapshot 中。只有 Snapshot 而没有解密材料,可能导致 API Server 无法正常读取 Secret。

相反,即使数据在 etcd 中是密文,拥有有效解密配置的恢复环境仍可恢复明文语义。因此 Snapshot 不能因为“可能加密”就降低保护等级。


8. Snapshot 恢复:为什么不能只替换一个数据目录

8.1 恢复的总体状态转换

一次典型的单集群恢复需要经历:

停止 API Server 写入
        ↓
准备隔离的恢复环境
        ↓
校验 Snapshot
        ↓
使用兼容版本恢复 etcd 数据目录
        ↓
配置新的 member/cluster identity
        ↓
启动 etcd 并验证健康
        ↓
恢复 API Server 访问证书和配置
        ↓
启动 kube-apiserver
        ↓
验证 API、控制器和节点重连

停止 API Server 写入很重要。如果旧集群仍在运行,恢复后的 etcd 可能与旧集群发生双写或客户端误连。灾难恢复不是把旧数据目录复制回原位置后立即启动所有控制面进程。

8.2 恢复到新 etcd 集群

不同 etcd 版本的命令略有差异。典型的新版本工具形式类似:

etcdutl snapshot restore /var/backups/etcd/snapshot.db \
  --data-dir=/var/lib/etcd-restored \
  --name=etcd-restore-1 \
  --initial-cluster=etcd-restore-1=https://10.0.0.10:2380 \
  --initial-advertise-peer-urls=https://10.0.0.10:2380 \
  --initial-cluster-token=etcd-restore-token

这些参数的作用是:

  • --data-dir:新的恢复数据目录;
  • --name:恢复成员名称;
  • --initial-cluster:初始成员列表;
  • --initial-advertise-peer-urls:成员间通信地址;
  • --initial-cluster-token:避免与旧集群身份混淆。

命令必须根据实际 etcd 版本、TLS 配置、单成员或多成员拓扑调整。恢复到原集群的成员目录、保留旧集群 token 或让旧成员继续对外提供服务,都可能造成成员身份冲突和数据分叉风险。

恢复后先验证 etcd,而不是马上验证 Kubernetes:

etcdctl \
  --endpoints=https://10.0.0.10:2379 \
  --cacert=/path/to/ca.crt \
  --cert=/path/to/client.crt \
  --key=/path/to/client.key \
  endpoint health

etcdctl \
  --endpoints=https://10.0.0.10:2379 \
  --cacert=/path/to/ca.crt \
  --cert=/path/to/client.crt \
  --key=/path/to/client.key \
  endpoint status -w table

需要确认:

  • endpoint 可访问;
  • member 已形成预期集群;
  • leader 选举正常;
  • Revision 存在且符合快照内容;
  • 磁盘可写;
  • API Server 使用的是恢复后的 endpoint,而不是旧 endpoint。

8.3 恢复 Revision 与旧客户端缓存

灾难恢复中的一个重要问题是:恢复后的 etcd Revision 可能低于灾难发生前的 Revision。

例如:

旧集群最后 Revision: 100000
快照包含 Revision: 95000
恢复后 Revision: 95000

如果某些客户端、Informer 或中间层保留了旧集群的资源版本和缓存状态,恢复后的较小 Revision 可能破坏它们对事件进度的假设。Kubernetes 控制面也可能需要重新建立 List/Watch 关系。

较新的 etcd 恢复工具提供了与 revision bump、mark compacted 相关的选项,用于让恢复后的 Revision 高于旧集群并阻止客户端从旧历史继续 Watch。选项名称、支持版本和使用限制具有版本敏感性,例如可能出现:

--bump-revision=...
--mark-compacted

不能不加验证地复制这些参数。应使用目标 etcd 版本的官方恢复工具文档,并结合 Kubernetes 版本和恢复方案决定是否启用。其基本原理是:

  1. 让恢复后的逻辑时钟不会倒退;
  2. 让客户端无法把旧集群事件历史与恢复后的状态错误拼接;
  3. 强制客户端重新 List 当前状态,而不是继续消费不连续的 Watch 历史。

这不是“让丢失的数据回来”。Revision bump 只改变逻辑版本编号和客户端观察边界,不会恢复快照生成之后已经丢失的对象更新。


9. 一个可验证的 Revision、Watch 和 Compaction 实验

下面实验适用于独立的测试 etcd,不应在生产 Kubernetes 使用的 etcd 上直接执行。

9.1 准备数据并观察 Revision

export ETCDCTL_API=3

etcdctl put /demo/item v1
etcdctl get /demo/item -w json

JSON 输出中通常可以看到类似信息:

{
  "header": {
    "revision": 2
  },
  "kvs": [
    {
      "key": "...",
      "create_revision": 2,
      "mod_revision": 2,
      "version": 1,
      "value": "..."
    }
  ]
}

实际 Revision 不一定从 1 开始,因为集群可能已经执行过其他操作。应从响应中的真实值读取,而不是假设固定数字。

继续修改:

etcdctl put /demo/item v2
etcdctl get /demo/item -w json

预期现象:

  • valuev1 变成 v2
  • mod_revision 增大;
  • version 增加;
  • create_revision 保持不变。

删除并重新创建:

etcdctl del /demo/item
etcdctl put /demo/item v3
etcdctl get /demo/item -w json

重新创建后,通常会看到:

  • 新的 create_revision
  • 新的键生命周期;
  • version 从新的生命周期开始计算。

9.2 从指定 Revision 观察事件

先在一个终端启动 Watch:

etcdctl watch /demo/item --rev 1

再在另一个终端执行:

etcdctl put /demo/item v4
etcdctl del /demo/item

Watch 会输出匹配键的 PUT 和 DELETE 事件。事件输出格式受 etcdctl 版本影响,但关键字段包括:

  • 事件类型;
  • 键;
  • 当前值或旧值;
  • 事件 Revision;
  • 事件发生时的 etcd header Revision。

使用前缀 Watch:

etcdctl watch /demo/ --prefix

它会接收 /demo/ 下多个键的事件。Kubernetes 的对象 Watch 在概念上类似,但 Kubernetes API 会额外处理对象版本、API 版本转换和资源事件类型。

9.3 触发 Compaction

先获取当前 Revision:

REV=$(etcdctl endpoint status -w json | \
  python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["Status"]["header"]["revision"])')

echo "$REV"

JSON 字段结构可能随 etcdctl 输出版本变化;在生产脚本中应针对实际版本验证字段路径。测试时也可以手工从 etcdctl get ... -w jsonheader.revision 中读取。

执行:

etcdctl compact "$REV"

然后尝试从过旧 Revision Watch:

etcdctl watch /demo/item --rev 1

如果 Revision 1 已经落入压缩范围,客户端会收到类似 required revision has been compacted 的错误。此时:

etcdctl get /demo/item

仍然可以读取当前值,因为读取当前状态不依赖已删除的历史事件。


10. 常见误解与失败表现

10.1 “resourceVersion 越大,数据一定越新”

Revision 只说明 etcd 提交顺序。一个对象的 resourceVersion 较大,不代表它的业务字段一定比另一个对象“更新”,也不代表它在所有业务意义上更重要。

错误做法:

比较两个不同集群的 resourceVersion
用 resourceVersion 推断创建时间
把 resourceVersion 转成时间戳

正确做法是把它用于 Kubernetes API 定义的读取和并发语义。

10.2 “Watch 连接不断开,所以不会丢事件”

Watch 可能断开,也可能因为 Compaction 无法从旧 Revision 恢复。客户端必须有全量 List 重同步逻辑。

失败表现可能包括:

  • 控制器本地缓存长期缺少对象;
  • API Server 日志出现 compaction 相关错误;
  • Watch 频繁重建;
  • 控制器反复执行全量 List;
  • 自定义客户端出现“事件处理成功但最终状态不一致”。

诊断时要同时查看:

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

以及 API Server、etcd 日志和监控指标。只看某一个 Watch 客户端的错误,不能判断是网络、权限、Compaction、API Server 负载还是 etcd 不健康。

10.3 “Compaction 会删除 Kubernetes 对象”

正常 Compaction 不会删除当前对象。若对象消失,应检查:

  • 是否真的执行了 etcdctl del
  • 是否发生了 API 删除;
  • 控制器是否进行了级联删除;
  • 快照或恢复是否使用了旧状态;
  • 是否查询了错误的 namespace、资源版本或集群;
  • 是否把历史事件删除误认为当前对象删除。

10.4 “Snapshot 等于完整 Kubernetes 备份”

Snapshot 通常能恢复 etcd 中的 Kubernetes 持久化对象,但不自动恢复:

  • API Server 证书;
  • etcd TLS 证书;
  • Kubernetes CA;
  • 静态 Pod manifest;
  • 加密配置和 KMS 密钥;
  • 云厂商控制面配置;
  • 节点本地磁盘;
  • 外部负载均衡器;
  • 云资源和外部数据库;
  • 备份系统本身的访问权限。

恢复演练必须验证“从备份材料重建 API 可用集群”,而不是只验证快照文件能够被 snapshot status 读取。

10.5 “复制正在运行的 db 文件就能备份”

运行中的 etcd 可能仍在写 WAL 和后端数据库。直接复制文件可能导致恢复失败或得到不一致状态。应使用 etcd Snapshot API/CLI;离线复制必须停止相关服务并遵守版本的文件一致性要求。

10.6 “恢复后只要 etcd 健康,Kubernetes 就恢复了”

etcd 健康只证明 etcd 自身可以提供服务。还必须验证:

kubectl cluster-info
kubectl get nodes
kubectl get --raw='/readyz?verbose'
kubectl get pods -A

并观察:

  • API Server 是否能读取核心资源;
  • Controller Manager 是否开始调谐;
  • Scheduler 是否能获取和更新调度对象;
  • 节点是否重新连接;
  • Informer 是否完成重新同步;
  • Secret 是否能正确解密;
  • CRD 和自定义资源是否可读取。

11. 生产备份与恢复中的取舍

11.1 备份频率

备份间隔决定最大数据丢失窗口。设:

  • TbT_b:两次成功备份之间的时间;
  • TfT_f:故障发生时间;
  • TsT_s:最近一次成功备份时间。

理论上的数据丢失窗口为:

ΔT=TfTs\Delta T = T_f - T_s

最坏情况下接近 TbT_b,但这只是在备份确实完成、远端复制成功且快照可恢复的前提下成立。备份任务启动不等于备份成功,写入本地文件也不等于远端备份成功。

11.2 一致性与可用性的取舍

在线 Snapshot 通常不要求停止整个 etcd 集群,但会消耗磁盘读取、网络和 CPU 资源。大规模或高变更率集群需要观察:

  • 快照耗时;
  • etcd 后端大小;
  • 磁盘吞吐和 fsync 延迟;
  • Raft 提交延迟;
  • API Server 请求延迟;
  • leader 变更;
  • 快照上传是否阻塞本地备份目录。

备份任务不能只关注“命令返回 0”,还应验证快照文件存在、大小合理、校验和正确、状态可读,并且已经复制到独立故障域。

11.3 多成员集群的恢复策略

etcd 是 Raft 集群。正常运行时需要多数成员形成 quorum:

quorum=N2+1\text{quorum} = \left\lfloor \frac{N}{2} \right\rfloor + 1

例如:

  • 3 成员需要 2 个成员;
  • 5 成员需要 3 个成员。

增加成员并不等于无限增加可靠性,因为更多成员也会增加网络和磁盘协调成本。灾难恢复时,通常先从一致 Snapshot 构建新的成员集合,再按新的成员地址和 token 启动,而不是把失效成员逐个强行拼回旧集群。

如果只是单个成员磁盘损坏,但多数成员仍健康,处理方式可能是移除并重新加入该成员;如果 quorum 已丢失,则通常需要走 Snapshot 恢复流程。两者不能混为一谈。


12. 一套最小可行的恢复验证清单

恢复演练应形成可重复的操作流程,而不是临时执行命令。至少需要验证以下路径:

  1. 使用与服务器兼容的工具读取 Snapshot 状态。
  2. 在隔离环境恢复 etcd 数据目录。
  3. 启动恢复后的 etcd 并检查 endpoint health。
  4. 验证已知 Kubernetes 键或通过 API Server 读取已知对象。
  5. 配置 API Server 的 etcd endpoint、证书和加密配置。
  6. 启动 API Server,检查 /readyz
  7. 验证核心资源、CRD、Secret 和自定义资源。
  8. 建立 List/Watch,确认控制器能够重新同步。
  9. 验证节点重新注册以及工作负载状态。
  10. 记录实际恢复时间和丢失窗口。

尤其要测试以下反例:

Snapshot 文件存在,但 TLS 证书丢失
Snapshot 可读取,但 Secret 解密配置丢失
etcd 已恢复,但 API Server 仍指向旧 endpoint
API Server 已启动,但恢复 Revision 导致旧 Watch 无法续接
核心资源可读,但 CRD 或自定义资源编码不兼容

这些情况都可能表现为“etcd 是健康的,但 Kubernetes 不可用”。


结语

Revision 是 etcd MVCC 的全局提交顺序,提供了读取、并发控制和 Watch 续接所需的逻辑坐标;事务把条件判断与写入绑定成原子操作,解决并发创建和乐观更新问题;Watch 将 Revision 序列转换为事件流,但它不是永久日志,必须与 List、重连和重新同步结合;Compaction 清理历史版本,可能使过旧 Watch 返回 ErrCompacted,且它与回收文件空间的 Defragmentation 不同。

Kubernetes 在这些 etcd 原语之上实现 API 对象存储、resourceVersion、Informer 和控制器调谐。备份时,Snapshot 保护的是 etcd 数据状态,而不是整个控制面;恢复时,必须同时处理 etcd 成员身份、TLS 证书、API Server 配置、Secret 加密材料以及恢复 Revision。只有经过真实恢复演练的 Snapshot,才具有可依赖的灾难恢复价值。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。