引子:一次让整个集群瘫痪的报错

某天凌晨,监控告警疯狂响起。200+ 托管集群的 Hub 集群上,所有写操作开始报错:

etcdserver: mvcc: database space exceeded

kubectl apply 失败,新 Pod 调度不了,HPA 扩容停滞。整个集群进入「只读」状态——读操作勉强正常,但任何写入都被拒绝。

排查发现,数百个集群的 ManagedClusterManifestWork 等 CR 大量累积,加上历史 revision 从未清理,etcd 数据库体积膨胀到超过默认的 2GB 空间配额(--quota-backend-bytes),触发了自我保护机制。

这次事故让我意识到:要真正运维好 Kubernetes,必须彻底搞懂 etcd。 它不是一个「装好就不用管」的组件,而是整个集群的大脑。


核心定位:etcd 是 K8s 的「大脑」

在 Kubernetes 架构中,etcd 的定位非常清晰:

  • 唯一的持久化存储:集群中所有资源对象(Pod、Service、Deployment、ConfigMap、CRD…)的期望状态和实际状态,全部存储在 etcd 中。
  • 唯一的客户端:API Server。kubelet、Controller Manager、Scheduler 都不会直接访问 etcd,它们全部通过 API Server 间接读写。API Server 是 etcd 的「守门人」。
graph LR
    kubelet --> APIServer[API Server]
    scheduler --> APIServer
    controller --> APIServer
    APIServer --> etcd

这意味着:

  1. etcd 挂了 = 集群失忆,所有控制面操作停止
  2. etcd 慢了 = 整个集群反应迟钝
  3. etcd 的一致性保证 = K8s 状态的正确性保证

那么,etcd 是如何保证数据一致性的?答案是 Raft 共识算法


Raft 共识算法:如何让多个节点达成一致

Raft 的核心思想是日志复制。这里的"日志"不是程序打印的调试日志,而是每一次数据写入操作的有序记录——比如 PUT /pods/nginx = {...}DELETE /services/my-svc。etcd 把所有写操作按顺序记录到日志中(WAL,Write-Ahead Log),然后把这份日志复制到集群中的其他节点。只要多数节点都持有同一份日志,数据就不会丢失。

etcd 每个节点的内部存储分两层:

  • WAL(Write-Ahead Log):写操作的有序日志文件,用于 Raft 复制和故障恢复
  • boltdb:etcd 内部的存储引擎,一个本地的嵌入式 KV 数据库文件。日志被 commit 后,数据 apply 到 boltdb 中,读请求从 boltdb 读取

类比:etcd 之于 boltdb,就像 MySQL 之于 InnoDB——etcd 是面向用户的分布式数据库,boltdb 是每个节点本地的存储引擎。后面提到的"apply 到状态机"就是指写入 boltdb。

三种角色

Raft 协议中,每个 etcd 节点在任意时刻只会处于三种角色之一:

角色职责
Leader(领导者)接收所有写请求,将日志复制到 Follower,驱动共识流程
Follower(跟随者)被动接收 Leader 的日志复制和心跳,不主动发起请求
Candidate(候选人)Follower 超时未收到心跳后转变为 Candidate,发起选举

正常运行时,集群中只有 一个 Leader,其余全是 Follower。

写入流程

一次完整的写操作如何在 Raft 集群中完成?以 3 节点集群为例:

sequenceDiagram
    participant C as Client
    participant L as Leader
    participant F1 as Follower-1
    participant F2 as Follower-2

    C->>L: PUT key
    par 并行复制日志
        L->>F1: Append Log
        L->>F2: Append Log
    end
    F1-->>L: ACK
    F2-->>L: ACK
    Note over L: 多数确认, commit
    par 通知 commit
        L->>F1: Commit 通知
        L->>F2: Commit 通知
    end
    L-->>C: 写入成功

关键步骤:

  1. Client 发送写请求到 Leader(如果发到 Follower,会被转发到 Leader)
  2. Leader 将操作写入本地 WAL 日志(Write-Ahead Log),并行发送 AppendEntries RPC 给所有 Follower
  3. Follower 写入自己的 WAL 日志,返回 ACK。注意:此时 Follower 只是说"日志我收到了",但它不知道其他节点是否也收到了
  4. Leader 收到多数派确认(包括自己,3 节点中需要 2 个确认)后,将日志标记为 committed
  5. Leader apply 到状态机(即 boltdb),返回成功给 Client
  6. Leader 通过后续心跳通知 Follower commit,Follower 也 apply 到本地状态机

为什么还需要 Commit 通知? 因为 Follower 返回 ACK 只代表"日志写到本地了",但 Follower 没有全局视角——它不知道其他节点有没有成功。只有 Leader 知道"多数派已确认"。Commit 通知就是告诉 Follower:“这条日志已经被多数派确认,你可以安全地 apply 到状态机(boltdb)了。” 没有 Commit 通知的话,Follower 的数据只存在日志里,不会写入 boltdb,客户端读不到。

为什么是「多数派」?因为只要多数节点存活并达成一致,即使少数节点故障,集群仍然能正常工作。这就是 Quorum(法定人数)机制——借用议会术语:投票必须达到法定最低出席人数,决议才有效。Raft 中就是"超过半数节点确认,写入才生效"。

写入失败的情况: 如果多数派没有确认呢?以 3 节点集群为例,假设两个 Follower 都出了问题:

sequenceDiagram
    participant C as Client
    participant L as Leader
    participant F1 as Follower-1
    participant F2 as Follower-2

    C->>L: PUT key
    par 并行复制日志
        L->>F1: Append Log
        L->>F2: Append Log
    end
    F1--xL: 网络超时,无响应
    F2--xL: 节点宕机,无响应
    Note over L: 只有自己 1 票
不满足 Quorum(2) L-->>C: 写入超时/失败 Note over L: 日志不会被标记为 committed
不会 apply 到 boltdb
数据不生效

Leader 加上自己只有 1 票,不满足 Quorum(需要 2 票),所以这条日志不会被 commit——不会 apply 到 boltdb,不会通知 Follower,Client 收到写入失败。数据就像从未存在过一样。

当 Follower 恢复后,这条未 commit 的日志会被新 Leader 的日志覆盖,保证集群数据一致性。

Leader 选举

当 Follower 在 election timeout(选举超时时间)(通常 1000-1500ms 随机值)内没有收到 Leader 的心跳(默认 100ms 间隔),它会:

  1. 将自己的 term(任期号)+1——term 借用政治术语"任期",每次选举开启新一届任期,用来区分不同时期的 Leader
  2. 转变为 Candidate
  3. 先投自己一票,然后向所有其他节点发送 RequestVote RPC
  4. 如果收到多数票,成为新 Leader
  5. 如果收到其他节点的心跳且 term >= 自己,退回 Follower
  6. 如果选举超时(没人赢),term+1 重新选举

为什么 election timeout 要随机? 避免所有 Follower 同时超时、同时发起选举导致「分票」僵局。随机化大幅降低了冲突概率。

容错能力

集群节点数可容忍故障数Quorum
312
523
734

生产环境推荐 3 或 5 节点。为什么不用更多?因为节点越多,写入延迟越高(需要等待更多 ACK),且收益递减。


MVCC:乐观并发控制的基石

全局递增的 Revision

etcd 使用 多版本并发控制(MVCC) 来管理数据。每次写操作(不论修改哪个 key)都会让全局的 revision 递增 1:

操作                          revision
─────────────────────────────────────
PUT /a = "hello"                1
PUT /b = "world"                2
PUT /a = "hello2"               3
DELETE /b                       4

注意:revision 是 全局唯一、单调递增 的,不是每个 key 独立计数。每个 key 还有自己的 version(从 1 开始,每次修改该 key 时 +1,删除后重置)。

Kubernetes 的 resourceVersion

K8s 中每个资源对象的 resourceVersion 字段,就是 etcd 的 revision。

apiVersion: v1
kind: ConfigMap
metadata:
  name: my-config
  resourceVersion: "384920"   # ← 这就是 etcd 的 revision

这个数字看起来很大——你刚创建一个 ConfigMap,resourceVersion 就已经是 38 万了。这是因为 revision 是整个 etcd 数据库的全局版本号,不是这个 ConfigMap 自己的。在它之前,集群中所有资源(Pod、Service、Node、各种 CRD)的每一次写操作都让 revision +1。换句话说,整个 etcd 就是一个状态接一个状态地演进,revision 就是状态的序号。

这个映射关系看似简单,却是整个 K8s 并发控制和 watch 机制的基础。

乐观并发控制(Optimistic Concurrency Control)

多个 Controller 或 API Server 实例同时修改同一个资源时,K8s 如何避免冲突?

锁有两种思路:悲观锁假设冲突随时会发生,读数据时就加锁,别人必须等我改完才能动;乐观锁假设冲突很少发生,不加锁,大家随便读随便改,但在写入时检查"我读到的版本和现在的版本还一致吗?"——如果一致就写入,如果被别人改过了就报冲突,重新来过。

K8s 用的就是乐观锁,而 resourceVersion 就是那个"版本号"。

sequenceDiagram
    participant A as API Server A
    participant E as etcd
    participant B as API Server B

    A->>E: GET /pods/nginx
    E-->>A: {rv: 100, ...}
    B->>E: GET /pods/nginx
    E-->>B: {rv: 100, ...}

    A->>E: PUT /pods/nginx (if rv == 100)
    E-->>A: OK {rv: 101}

    B->>E: PUT /pods/nginx (if rv == 100)
    E-->>B: CONFLICT! (当前 rv=101≠100)

    B->>E: GET /pods/nginx
    E-->>B: {rv: 101, ...}
    B->>E: PUT /pods/nginx (if rv == 101)
    E-->>B: OK {rv: 102}

流程解读:

  1. A 和 B 同时读取 /pods/nginx,都拿到 resourceVersion: 100
  2. A 先写入成功,etcd 将 revision 更新为 101
  3. B 尝试写入,携带条件 if rv == 100,但 etcd 发现当前 rv 已经是 101,返回冲突
  4. B 重新读取最新版本(rv: 101),合并修改后重试写入

这就是 K8s 中经常看到的 409 Conflict 错误的根源。

为什么选择乐观锁?

考量乐观锁悲观锁
读写比例K8s 读远多于写,乐观锁在无冲突时零开销每次读都要加锁,开销大
分布式锁管理不需要额外的分布式锁服务需要锁管理器,增加复杂度
死锁风险无死锁可能需要死锁检测/超时
Controller 模式Controller 天然有 reconcile 重试循环,冲突后自动重试锁持有期间其他 Controller 阻塞

K8s 的设计哲学是 level-triggered(基于期望状态,而非事件驱动),Controller 本身就会不断 reconcile。冲突只是触发一次额外的 reconcile,完美契合。


Watch 机制:驱动 K8s 的事件循环

如果说 etcd 是 K8s 的大脑,那么 watch 就是它的神经系统。

基于 Revision 的 Watch

etcd 的 watch 基于 revision 实现:客户端告诉 etcd「从 revision N 开始,告诉我这个 key(或前缀)的所有变更」。底层使用 gRPC 双向流,服务端持续推送事件。

// 伪代码:从 revision 100 开始 watch 所有 pod
watcher := client.Watch(ctx, "/registry/pods/", clientv3.WithRev(100))
for resp := range watcher {
    for _, event := range resp.Events {
        // event.Type: PUT 或 DELETE
        // event.Kv.ModRevision: 变更时的 revision
    }
}

API Server 的 watchCache

如果每个 kubelet、Controller 都直接 watch etcd,etcd 会被压垮。所以 API Server 充当了聚合层

graph LR
    C1[Controller-1] --> APIServer[API Server
watchCache] C2[Controller-2] --> APIServer K1[kubelet-1] --> APIServer K2[kubelet-2] --> APIServer APIServer -->|单个 watch| etcd

API Server 内部维护了 watchCache

  • 对 etcd 建立 一个 watch 连接(per 资源类型)
  • 将收到的事件缓存在内存环形缓冲区中
  • 所有客户端的 watch 请求都从 watchCache 提供服务

resourceVersion 的映射

  • 客户端 watch 时携带 resourceVersion,API Server 在 watchCache 中查找对应位置
  • 如果请求的 resourceVersion 还在缓冲区内,直接从缓存回放
  • 如果已经被淘汰(太旧),返回 410 Gone,客户端需要重新 LIST

性能瓶颈与优化

etcd 作为整个集群的核心存储,性能问题会被放大到全局。以下是 5 个常见的性能瓶颈及应对策略:

1. 大 Value 问题

etcd 默认限制单个请求大小为 1.5MB。当存储大型 ConfigMap、Secret 或 CRD 时:

  • 现象:读写延迟飙升,Controller watch 这些大对象时内存暴涨甚至 OOM。一个常见的场景是用 Secret 或 ConfigMap 存放 CA 证书,单个对象可能几十到几百 KB,Controller 的 informer 缓存会把所有 watch 到的对象完整加载到内存中
  • 优化
    • 避免在 ConfigMap/Secret 中存放大文件

    • 拆分大对象为多个小对象

    • 考虑将大数据放在外部存储(如 S3),etcd 只存引用

    • 如果 Controller 只需要感知变化而不需要读取 data 部分,使用 controller-runtime 的 builder.OnlyMetadata 选项。它让 informer 只缓存对象的 metadata(labels、annotations、ownerReferences 等),不缓存 spec 和 data,大幅降低内存占用:

      // 只 watch Secret 的 metadata,不缓存 data 部分
      ctrl.NewControllerManagedBy(mgr).
          For(&myv1.MyResource{}).
          Watches(&corev1.Secret{}, handler.EnqueueRequestForOwner(...),
              builder.OnlyMetadata).
          Complete(r)
      

      Reconcile 函数中需要用 metav1.PartialObjectMetadata 代替具体类型来读取:

      secret := &metav1.PartialObjectMetadata{}
      secret.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret"))
      client.Get(ctx, key, secret)
      // secret.Labels, secret.Annotations 等可用
      // secret.Data 不可用(需要时再用具体类型单独 Get)
      

2. 频繁写入 / 磁盘 I/O

etcd 的写入性能严重依赖磁盘 I/O,特别是 fsync 延迟

  • 现象etcd_disk_wal_fsync_duration_seconds 持续偏高
  • 优化
    • 必须使用 SSD,HDD 在生产环境不可接受
    • 使用专用磁盘,不与其他 I/O 密集型应用共享
    • 云环境选择高 IOPS 的存储类型(如 AWS gp3 或 io2)

3. Watch 连接过多

大规模集群中,大量 Controller + 大量资源类型 = 海量 watch 连接:

  • 现象:API Server 内存暴涨,etcd gRPC 连接数告警
  • 优化
    • 确保 API Server 的 watchCache 正常工作(--watch-cache-sizes
    • 避免 Controller 对全量资源做无过滤的 watch
    • 使用 label selector 或 field selector 缩小 watch 范围

4. 数据增长 / Compaction / Defrag

这正是我们开头事故的根因:

  • Compaction(压缩):删除旧的 revision 历史。etcd 默认保留所有历史版本,K8s 的 --etcd-compaction-interval(默认 5 分钟)会定期清理
  • Defrag(碎片整理):compaction 只是标记空间可复用,不会释放磁盘空间。需要 defrag 来真正回收
  • 优化
    • 确认 auto-compaction 在正常运行
    • 定期执行 defrag(注意:defrag 会阻塞读写,应逐节点操作)
    • 监控 etcd_mvcc_db_total_size_in_bytes

5. 大规模节点心跳

当集群节点数达到数千时,Node 的 Lease 续约和状态更新会产生大量写入:

  • 现象:etcd 写入 QPS 随节点数线性增长
  • 优化
    • 调整 --node-status-update-frequency(默认 10s)
    • 使用 Node Lease 机制(K8s 1.14+ 默认启用)减少 Node 对象的更新频率
    • 考虑分片或联邦架构分散压力

Q&A

Q1: etcd 如何备份和恢复?

# 备份
etcdctl snapshot save /backup/etcd-snapshot.db \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/etcd/ca.crt \
  --cert=/etc/etcd/server.crt \
  --key=/etc/etcd/server.key

# 恢复(需要停止 etcd,所有节点都要操作)
etcdctl snapshot restore /backup/etcd-snapshot.db \
  --name=etcd-0 \
  --data-dir=/var/lib/etcd-restore \
  --initial-cluster="etcd-0=https://10.0.0.1:2380,etcd-1=https://10.0.0.2:2380,etcd-2=https://10.0.0.3:2380" \
  --initial-advertise-peer-urls=https://10.0.0.1:2380

关键点:

  • snapshot 是某一时刻的全量快照,包含所有 key-value 数据
  • 恢复时会创建新的 cluster ID 和 member ID,避免与旧集群冲突
  • 恢复后所有节点需要使用相同的 snapshot 文件

Q2: 为什么 K8s 选 etcd 而不是 ZooKeeper 或 Consul?

维度etcdZooKeeperConsul
共识协议Raft(更易理解和实现)ZAB(复杂)Raft
Watch 机制基于 revision 的持续 watch一次性 watch,需反复注册长轮询,效率较低
APIgRPC,高效的二进制协议自定义 TCP 协议HTTP
数据模型扁平 key-value树形 ZNodekey-value + 服务发现
功能定位专注一致性 KV 存储偏重协调和锁服务偏重服务发现

etcd 的 watch 基于 revision 可以可靠地回放历史变更,这对 K8s 的 Controller 模式至关重要。ZooKeeper 的一次性 watch 在高并发下容易丢事件。

Q3: etcd revision 和 K8s resourceVersion 是什么关系?

直接映射。API Server 将 etcd 的 ModRevision(key 最后一次修改时的全局 revision)作为该资源对象的 resourceVersion 返回给客户端。

etcd key: /registry/pods/default/nginx
etcd ModRevision: 384920

→ K8s Pod nginx 的 metadata.resourceVersion = "384920"

LIST 操作返回的 resourceVersion 是当前 etcd 的最新 revision,代表这次 LIST 的「快照点」。

Q4: 一个 etcd 节点挂了,集群读写是否正常?

3 节点集群挂 1 个:读写都正常。 因为剩下 2 个节点满足多数派(quorum = 2)。

但有细节:

  • 如果挂的是 Leader,会触发重新选举,期间(通常 1-2 秒)写操作会短暂中断
  • 如果挂的是 Follower,对客户端完全透明
  • 如果 2 个节点挂了,剩 1 个节点无法形成 quorum,读写都不可用

Q5: revision 和 key version 的区别?

属性revisionkey version
作用域全局,所有 key 共享单个 key 独立
递增规则任何 key 的任何写操作都会 +1该 key 被修改时 +1
删除后继续递增重置为 0(重新 PUT 后从 1 开始)
K8s 映射resourceVersion无直接映射

示例:

PUT /a = 1    → revision=1, /a version=1
PUT /b = 2    → revision=2, /b version=1
PUT /a = 3    → revision=3, /a version=2
DELETE /a     → revision=4
PUT /a = 5    → revision=5, /a version=1(重新计数)

Q6: 为什么 K8s 用乐观锁不用悲观锁?

核心原因:

  1. 读多写少:K8s 的读写比例极高(大量 LIST/GET vs 少量 UPDATE),乐观锁在无冲突时零额外开销
  2. 无需锁管理器:悲观锁需要分布式锁服务(如 ZooKeeper),引入额外的复杂度和故障点
  3. 无死锁:乐观锁不持有锁,不存在死锁问题
  4. 天然契合 Controller 模式:Controller 的 reconcile 循环本身就是「读取 → 计算 → 写入 → 冲突则重试」,乐观锁是最自然的选择

Q7: linearizable read vs serializable read

etcd 支持两种一致性级别的读操作:

类型一致性性能实现
Linearizable(默认)强一致,读到最新数据较慢,需要经过 Leader 确认读请求需要 Leader 确认自己仍是 Leader(通过一轮心跳)
Serializable可能读到过期数据快,任何节点本地读取直接从本地状态机读取,不需要共识

K8s API Server 默认使用 Serializable read(通过 --etcd-servers 直连),因为 API Server 有 watchCache 补偿,且对读的实时性要求可以容忍微小延迟。

Q8: compaction 和 defrag 为什么要分两步?

这是一个常见的困惑,也是开头事故的关键知识点:

Compaction(压缩历史版本):

  • 删除指定 revision 之前的所有历史版本
  • 操作是 逻辑删除,在 boltdb 中将旧数据标记为可覆盖
  • 不阻塞正常读写,可以在线执行
  • etcd 中由 --auto-compaction-retention 控制

Defrag(碎片整理):

  • 将 boltdb 文件重新整理,真正释放被标记的空间
  • 操作是 物理删除,需要重写整个数据库文件
  • 会阻塞该节点的读写,必须逐节点执行
  • 需要手动触发:etcdctl defrag --endpoints=...

为什么不合并?因为 compaction 需要频繁执行(默认 5 分钟),而 defrag 开销大且会阻塞服务。如果每次 compaction 都做 defrag,etcd 会频繁不可用。分开设计让日常清理(compaction)不影响服务,磁盘回收(defrag)可以在维护窗口执行。

Q9: 两个 API Server 同时写同一个 key,etcd 怎么处理?

etcd 提供 mini-transaction(也叫 Txn),支持 CAS(Compare-And-Swap)语义:

// 伪代码:只有当 mod_revision == 100 时才更新
txnResp, err := client.Txn(ctx).
    If(clientv3.Compare(clientv3.ModRevision(key), "=", 100)).
    Then(clientv3.OpPut(key, newValue)).
    Else(clientv3.OpGet(key)).
    Commit()

API Server 的更新操作在底层就是一个 mini-transaction:

  1. 比较 mod_revision 是否等于客户端传入的 resourceVersion
  2. 如果相等,执行 PUT(Then 分支)
  3. 如果不等,返回当前值(Else 分支),上层转化为 409 Conflict

因为 Raft 保证了所有写操作的线性顺序,两个并发 Txn 一定有先后。先到的成功,后到的在 If 比较时发现 revision 已变,走 Else 分支。不需要加锁,全靠 revision 对比。


实战场景

场景 1:云环境磁盘延迟引发 Leader 选举风暴

现象:etcd 日志中频繁出现 Leader 变更,集群不稳定,API 响应时间飙升。

根因:云环境使用了网络附加存储(如 AWS EBS gp2),在 I/O 突发消耗完后,磁盘延迟从 <1ms 突增到 >100ms。etcd Leader 写 WAL 日志时 fsync 超时,无法按时发送心跳,Follower 认为 Leader 已死,发起选举。

关键指标

# WAL fsync 延迟(应 < 10ms)
etcd_disk_wal_fsync_duration_seconds

# 心跳发送间隔(应稳定在 100ms 左右)
etcd_network_peer_round_trip_time_seconds

解决方案

  • 切换到高性能存储(gp3 + 预配置 IOPS,或本地 NVMe SSD)
  • 确保 etcd 数据目录使用专用磁盘
  • 适当调大 --heartbeat-interval--election-timeout(但会增加故障检测时间)

场景 2:数据库膨胀触发 Space Quota — 集群进入只读

这就是文章开头的真实事故。展开讲:

事件链

  1. 200+ 集群的 CR(ManagedCluster、ManifestWork 等)大量创建和更新
  2. 每次更新生成新的 revision,旧版本占用空间
  3. Auto-compaction 在运行,但 defrag 从未执行过
  4. boltdb 文件持续增长(即使 compaction 标记了可回收空间,物理文件不缩小)
  5. 文件大小超过 --quota-backend-bytes(默认 2GB),触发 alarm
  6. etcd 设置 NOSPACE alarm,拒绝所有写操作

恢复步骤

# 1. 确认 alarm 状态
etcdctl alarm list

# 2. 执行 compaction(压缩到当前 revision)
rev=$(etcdctl endpoint status --write-out="json" | jq '.[0].Status.header.revision')
etcdctl compaction $rev

# 3. 逐节点执行 defrag
etcdctl defrag --endpoints=https://etcd-0:2379
etcdctl defrag --endpoints=https://etcd-1:2379
etcdctl defrag --endpoints=https://etcd-2:2379

# 4. 解除 alarm
etcdctl alarm disarm

# 5. 验证写入恢复
etcdctl put /health ok

预防措施

  • 监控 etcd_mvcc_db_total_size_in_bytesetcd_mvcc_db_total_size_in_use_in_bytes
  • 当两者差值过大时,说明碎片过多,需要 defrag
  • 设置告警:db size > quota 的 80% 时报警
  • 定期在维护窗口执行 defrag

场景 3:Watch 事件丢失 — 410 Gone

现象:Controller 日志中出现 too old resource version,watch 断开后 re-LIST 导致 API Server 负载突增。

根因:API Server 的 watchCache 使用环形缓冲区存储事件。当事件产生速度超过 Controller 消费速度,旧事件被覆盖。Controller 尝试用过期的 resourceVersion 恢复 watch 时,API Server 发现该 revision 已不在缓存中。

时间线

sequenceDiagram
    participant Ctrl as Controller
    participant API as API Server
watchCache Ctrl->>API: watch (rv=1000) API-->>Ctrl: 正常接收事件... Note over API: 大量事件涌入
缓冲区: [rv=5000...rv=8000]
rv=1000 已被淘汰 Ctrl->>API: 断线重连 watch (rv=4999) API-->>Ctrl: 410 Gone Ctrl->>API: re-LIST (全量数据加载) API-->>Ctrl: 返回全量数据

解决方案

  • 增大 watchCache 大小:--watch-cache-sizes=pods#1000,nodes#500
  • 检查 Controller 的事件处理是否有阻塞(如 reconcile 中的长时间操作)
  • 使用 reflector_watch_duration_seconds 监控 watch 连接寿命

场景 4:大对象 / 海量小对象导致 etcd 读取缓慢

现象etcdctl get 某些 key 耗时数秒,LIST 操作超时。

大对象问题

  • 某些 CRD status 中嵌入了完整的集群状态,单个对象达到数百 KB
  • etcd 的 value 通过 boltdb 存储,大 value 需要跨多个 page 读取
  • 影响同一个 boltdb page 上的其他 key 的读取性能

海量小对象问题

  • 数万个 Event 对象频繁创建和过期
  • 大量 Lease 对象占用存储
  • LIST 操作需要序列化所有对象,内存和 CPU 开销大

解决方案

  • CRD 设计时控制 status 大小,考虑使用 subresource 或外部存储
  • 缩短 Event 的 TTL(--event-ttl,默认 1h)
  • 使用分页 LIST(limit + continue token)避免全量加载
  • 监控 etcd_request_duration_seconds 按操作类型分析瓶颈

关键结论

  • 生产环境的 etcd 必须用独立的 SSD 磁盘,不和任何其他服务共享 IO。这不是"建议",是硬性要求——fsync 延迟超过 10ms 就会拖慢整个集群,超过 100ms 就会触发 Leader 选举风暴。
  • 运维 etcd 要同时做好两件事:compaction(自动的,默认 5 分钟)+ defrag(手动的,需要在维护窗口逐节点执行)。只做 compaction 不做 defrag 就是本文开头事故的根因——磁盘文件只增不减,迟早触发 space quota。
  • 监控必须覆盖三个指标:etcd_disk_wal_fsync_duration_seconds(磁盘健康)、etcd_mvcc_db_total_size_in_bytes(空间用量)、以及它和 in_use 的差值(碎片率)。db size 达到 quota 的 80% 时就该告警。
  • Controller 代码中看到 409 Conflict 不要当 bug 处理——这是乐观锁在正常工作。正确的做法是重新 GET 最新版本再重试,而不是加重试间隔或记录 error 日志告警。
  • CRD status 中不要塞大量数据(如完整的集群状态快照)。大 value 会跨多个 boltdb page,拖慢同页其他 key 的读取。考虑用外部存储放大数据,etcd 只存引用。

总结

回到那次 database space exceeded 事故:根因并不复杂——缺少 defrag 导致物理空间无法回收。但理解这个问题的「为什么」,需要串联 etcd 的整套知识体系:

  1. Raft 保证了分布式一致性,但对磁盘 I/O 有硬性要求
  2. MVCC 用 revision 实现了高效的乐观并发控制,但历史版本会持续积累
  3. Watch 基于 revision 驱动了整个 K8s 的事件循环,但依赖 watchCache 的健康运行
  4. Compaction 清理历史,Defrag 回收空间——两步缺一不可

etcd 不是一个「装好就不用管」的组件。理解它的工作原理,才能在出问题时快速定位,在设计系统时避开陷阱。