Kubernetes控制平面组件:etcd(二)

云原生学习路线导航页(持续更新中)

本文将继续对Kubernetes控制平面组件 etcd 进行介绍,从etcd的组件架构和组件交互层面,深入理解etcd如何实现 数据存储、数据生命周期、多版本并发控制、故障恢复和watch机制等

  • 上节Kubernetes控制平面组件:etcd(一)中,我们详细讲解了Raft协议的原理
    • 两大核心功能
      • Leader Election(选主)
      • Data Replication(数据同步)
    • 安全保障
      • 通过多数节点投票机制保障数据一致性
  • 本节将重点介绍etcd的实现细节,包括 Etcd 的数据存储、数据生命周期、多版本并发控制、故障恢复和watch机制等

1.Etcd的存储机制

1.1.分层设计原则

  • 持久化层
    • 必须将数据可靠地写入磁盘(安全性保障)
    • 缺点:纯磁盘读写性能较差
  • 内存加速层
    • 通过缓存和索引提高访问速度

1.2.关键技术组件

1.2.1.KV Index(内存索引)

  • 定位方式:Key-based 查询
  • 数据结构 :基于 Google B-Tree 的开源 Golang 实现 (github.com/google/btree)
  • 特性
    • 天然适合键值数据库场景
    • 避免传统关系型数据库复杂的行/列索引

1.2.2.Backend Storage(后端存储)

  • 实现引擎:BoltDB
  • 工作原理
    • 操作底层 .db 文件
    • 采用追加式日志结构合并树(LSM Tree)变种
    • 支持事务操作
  • 注意:BoltDB 中存储的key是reversion,value是 我们的数据k-v

1.3.多版本控制系统

1.3.1.Revision 机制

  • 版本组成

    • Main Revision(全局递增版本号),每次事务进行+1
    • Sub-Revision(同一事务内的操作序号),同一个事务内部每次操作+1
  • 核心特征

    • 每次 Key 修改生成新版本,非覆盖式
    • 允许历史版本追溯
    • Watch 机制可指定起始 revision
  • 典型场景

    bash 复制代码
    # 查看 key 的历史版本
    etcdctl get --rev=<specific_revision> <key>

1.3.2.版本压缩机制

  • 必要性

    • 防止长期运行的 Key 占用过多存储空间
    • 避免单个 Key 频繁修改导致数据库膨胀
  • 操作方法

    bash 复制代码
    # 执行版本压缩(保留最近 N 个版本)
    etcdctl compact <revision_number>
    
    # 碎片整理(需停机维护)
    etcdctl defrag
  • 注意事项

    • Compact 后旧版本不可访问
    • Defragment 会影响服务可用性

2.etcd 存储键值对 字段解释

sh 复制代码
sh-5.0# etcdctl --endpoints=127.0.0.1:2379 get b
{
"header": {
	"cluster_id": 6652528222563268894,
	"member_id": 4812728548556146573,
	"revision": 81695026,
	"raft_term": 5
},
"kvs": [{
	"key": "Yg==",
	"create_revision": 81694561,
	"mod_revision": 81694561,
	"version": 1,
	"value": "VjE="
}],
"count": 1
}

2.1.整体结构

json 复制代码
{
  "header": { ... },     // 响应的元数据头
  "kvs": [ ... ],        // 匹配到的键值对列表
  "count": 1             // 实际返回的键值对总数
}

2.2.Header 字段详解

字段名 类型 说明
cluster_id uint64 集群唯一 ID (十六进制转换示例:printf '%x\n' 6652528222563268894)
member_id uint64 处理此请求的成员节点 ID
revision int64 当前全局数据版本号,代表整个集群的最新提交版本
raft_term uint64 Raft 共识算法当前的任期编号(Leader 发生变更时会递增)

2.3.kvs 字段详解(单条记录)

json 复制代码
{
  "key": "Yg==",              // Base64 编码的原 key 
  "create_revision": 81694561,// 首次创建该k-v时的全局版本号
  "mod_revision": 81694561,   // 最后一次修改该k-v时的全局版本号
  "version": 1,               // 该 key 的修改次数计数器
  "value": "VjE="             // Base64 编码的值
}

# 解码 key (Base64 -> ASCII)
echo "Yg==" | base64 -d      # 输出: b

# 解码 value (Base64 -> ASCII)
echo "VjE=" | base64 -d      # 输出: V1

2.4.Count 字段

字段名 类型 说明
count int 实际匹配到的键值对数量(当使用范围查询或前缀查询时可能 >1)

2.5.关键差异对比

字段 区别点 示例场景
revision vs raft_term revision 跟踪数据变化,raft_term 跟踪 leader 任期 数据写入会增加 revision,leader 换届会改变 raft_term
create_revision vs mod_revision create 只在创建时设置,mod 随每次更新变化 新建 key 时二者相等,更新后 mod_revision 变大

3.Etcd 节点组件详解

3.1.组件交互全景图

  • 每一个etcd节点,内部都包括下面这些模块
    • Http Server模块:接收客户端请求,进行预检查
    • KV Server:协调读写请求的分发
    • 一致性模块:Raft协议的具体实现,多数投票保证数据一致性
    • WAL日志模块:数据提交前的持久化
    • MVCC状态机模块:数据查询和存储模块
  • 数据写入控制流
    • 客户端发起数据写入请求 → HTTP Server → KV Server → Raft模块 → WAL持久化 → MVCC模块(TreeIndex + BoltDB)
  • 数据写入流程完整流程
    • 客户端发送写入请求到etcd,先通过HTTP Server的预检查,然后请求进入KV Server
    • KV Server将请求交给Raft一致性模块,进行多数投票
    • Leader构造提案发给Follower,同时把数据序列化为二进制写入WAL日志(有异步线程进行持久化,防止数据丢失)
    • 当多数投票成功,Leader 的 KV Server会发起数据apply,将数据发给Leader的 MVCC模块进行状态机修改
    • 状态机修改成功后,Leader 的 KV Server会修改自己的MatchIndex,并在下一次心跳时将MatchIndex最新值发给Follower,Follower就会更新自己的 MatchIndex 和 状态机
    • 内存索引和后端存储都成功,本条数据写入就完成了

3.2.关键组件职责与交互细节

3.2.1.HTTP Server

  • 职责
    • 接收客户端请求(PUT/GET)
    • 提供 RESTful API 接口
  • 交互流程
    1. 对请求进行预处理:
      • 配额检查 :检查存储空间是否足够(ETCD_SPACE_QUOTA
      • 限流:基于令牌桶算法控制 QPS
      • 认证鉴权:校验用户 RBAC 权限
      • 请求合规性:校验请求大小(默认限制 1.5MB,过大的资源会直接影响Etcd的心跳数据包大小,拖慢心跳速度,所以etcd默认最大资源限制1.5MB,我们在设计k8s资源时要注意不能太大)
    2. 通过后,将请求转发至 KV Server

3.2.2.KV Server

  • 职责
    • 协调读写请求的分发
    • 管理事务逻辑
  • 交互流程
    1. 接收 HTTP Server 的写入请求(如 put key=foo value=bar

    2. 构造提案(Proposal)

      go 复制代码
      type Proposal struct {
          Op:    Put,          // 操作类型
          Key:   "foo",        // 键
          Value: "bar",        // 值
          Lease: 0,            // 租约(可选)
      }
    3. 调用 Raft 模块的 Propose() 方法提交提案,该提案就会被传给一致性模块

    4. 当超过半数的节点达成一致后,KV Server会控制该数据 移到Raft Log的committed log中,并更新自己的 matchIndex

    5. 最后 KV Server 会发起该提案的 Apply,将数据应用到状态机MVCC

  • matchIndex 是用于标识有多少数据已经被半数确认,是一个递增的数字

3.2.3.Raft 模块

  • 核心组件

    • Leader 节点:处理提案,发起日志复制
    • Follower 节点:接收并持久化日志
  • RaftLog

    • etcd每个节点在内存里都会预留一块空间,叫做 raftLog
    • RaftLog包括三部分:unstable log、committed log、applied log
  • 交互流程

    1. Propose 阶段
      • Leader 将提案写入 Memory Store(unstable log),然后返回响应给KV Server
      • KV Server 会同时做两件事情:
        • 通过 RPC 发送 AppendEntries 请求给所有 Follower(通过心跳
        • 将提案写入 WAL日志,目的是将提案进行持久化。WAL 日志本身也是不稳定的,但是会有后台线程定期将数据序列化到磁盘进行持久化
    2. Replicate 阶段
      • Follower 将日志写入本地 unstable logWAL 日志
      • 返回 AppendEntriesResponse 确认
    3. Commit 阶段
      • 当收到超过半数节点的确认后,Leader:
        • 将日志标记为 committed
        • 更新 commit index
    4. Apply 阶段
      • 在Apply之前,数据还没有提交到状态机,此时get数据是拿不到的

      • 通过 Ready 结构体通知 KV Server 可提交到状态机MVCC

      • 当数据成功写入MVCC后,就会被移到 applied Log

        go 复制代码
        type Ready struct {
            CommittedEntries: []Entry,  // 已提交的日志条目
            Messages:         []Message, // 待发送的 RPC 消息
        }
  • 注意

    • 如果客户端把请求发到了Follower,Follower会把请求转给leader,由leader发起提案

3.2.4.WAL(Write-Ahead Log)

  • 职责
    • 将Raft Log 的数据序列化为二进制后,进行持久化,防止宕机丢失
    • 记录所有状态变更操作。但注意WAL日志并非状态机,数据apply之前,在这里的数据依旧可能是不稳定的
  • 交互流程
    1. 日志格式

      go 复制代码
      type WALEntry struct {
          Term    uint64    // 当前任期
          Index   uint64    // 日志索引
          Type    EntryType // 日志类型(Normal/ConfChange)
          Data    []byte    // 序列化后的提案数据
      }
    2. 写入策略

      • 同步写入 :每次提案均触发 fsync(高可靠性)
      • 批量写入:合并多个提案后写入(高性能模式)

3.2.5.MVCC 模块

MVCC:multiversion concurrent control 多版本并发控制,在尽量不加锁的前提下实现一个多版本数据存储。

etcd 使用 TreeIndex(内存索引) 和 BoltDB(持久化存储) 协同工作,实现键值存储的多版本并发控制。

3.2.5.1.TreeIndex:内存中的键版本索引
  • 核心作用

    • 快速定位键的历史版本 :通过内存中的 B-tree 索引,支持高效的范围查询(如 etcdctl get --prefix /key)和版本遍历。
    • 管理键的生命周期:记录键的创建、修改、删除事件,实现 MVCC 的版本隔离。
  • 数据结构详解

    go 复制代码
    type KeyIndex struct {
        Key      []byte          // 键名(如 "/foo/bar")
        Modified Revision        // 最后一次修改的 Revision(如 12345.0)
        Generations []Generation // 键的"代"历史(解决键的删除与重建问题)
    }
    
    type Generation struct {
        Version   int64       // 同一代内的版本号(从1开始递增)
        Created   Revision    // 当前代的创建 Revision
        Revs      []Revision  // 当前代的所有修改 Revision(包括删除)
    }
  • KeyIndex.Generations 的作用:解决键的删除与重建问题

    • 当一个键被删除后重新创建,会生成新的 Generation。例如:
      • Generation 1: 创建(rev=100),修改(rev=101),删除(rev=102)
      • Generation 2: 重新创建(rev=200),修改(rev=201)...
    • 通过 TreeIndex 的 Generations,etcd 能够准确追踪键的生命周期,确保查询操作的正确性。
3.2.5.2.BoltDB:持久化存储引擎
  • BoltDB 和 ETCD 的关系

    • ETCD 的真实后端存储是可选的,目前是 Google开发的 基于 B+树 的 BoltDB
  • BoltDB 的 key

    • 存到BoltDB的每条k-v,其实都是作为value存到后端存储中的
    • BoltDB实际存放的key,是它自行拼接的 Revision{Main}_{Sub} 结构
  • 存储结构

    • Key:Revision{Main}_{Sub}(例如 1000_0)

      • Main:全局单调递增的事务 ID(每个写操作递增 1)。
      • Sub:同一事务内的操作编号(通常为 0,事务批量写时可能大于 0)。
    • Value:

      • 存进来的k-v就放在 KeyValue 的 key和value 中
      go 复制代码
      type KeyValue struct {
          Key            []byte   // 键名(如 "/a")
          Value          []byte   // 值(如 "hello")
          CreateRevision int64    // 创建时的 Revision(对应 Generation.Created)
          ModRevision    int64    // 最后一次修改的 Revision(对应 KeyIndex.Modified)
          Version        int64    // 版本号(对应 Generation.Version)
          Lease          int64    // 关联的租约 ID(0 表示无租约)
      }
  • 数据组织方式

    • 按 Revision 顺序存储

      • 由于etcd将全局Revision,作为BoltDB Key的 Main主版本号,所以 BoltDB 所有的键值对 都按 Revision 排序写入 BoltDB
      • 确保数据在磁盘上的写入顺序与逻辑操作顺序一致。
    • 例如:

      bash 复制代码
      BoltDB Bucket:
        Key: 1000_0 → Value: {Key: "/a", Value: "v1", CreateRevision: 1000, ModRevision: 1000, Version: 1}
        Key: 1001_0 → Value: {Key: "/a", Value: "v2", CreateRevision: 1000, ModRevision: 1001, Version: 2}
        Key: 1002_0 → Value: {Key: "/a", Value: "",   CreateRevision: 1000, ModRevision: 1002, Version: 3}(删除标记)
3.2.5.3.TreeIndex 与 BoltDB 的协作流程
3.2.5.3.1.写入键 Put /a "v1"
  • 假设当前全局 Revision 为 1000

  • TreeIndex 更新

    • 检查 /a 是否已存在。若不存在,创建新的 KeyIndexGeneration

      go 复制代码
      KeyIndex{
         Key:      []byte("/a"),
         Modified: Revision{1000},
         Generations: []Generation{
             {
                 Version:   1,
                 Created:   Revision{1000},
                 Revs:      []Revision{1000},
             },
         },
      }
  • BoltDB 写入

    • 将键值对按 Revision 写入 BoltDB:

      go 复制代码
      Key: 1000_0 → Value: KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v1"),
          CreateRevision: 1000,
          ModRevision:    1000,
          Version:        1,
          Lease:          0,
      }
3.2.5.3.2.修改建 Put /a "v2"
  • 此时全局 Revision:1001

  • TreeIndex 更新

    • 找到 /aKeyIndex,更新 Modified 和当前 Generation

      go 复制代码
      KeyIndex{
          Key:      []byte("/a"),
          Modified: Revision{1001},
          Generations: []Generation{
              {
                  Version:   2,
                  Created:   Revision{1000},
                  Revs:      []Revision{1000, 1001},
              },
          },
      }
  • BoltDB 写入

    • 将新的键值对按 Revision 写入 BoltDB:

      go 复制代码
      Key: 1001_0 → Value: KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v2"),
          CreateRevision: 1000,
          ModRevision:    1001,
          Version:        2,
          Lease:          0,
      }
3.2.5.3.3.删除建 Delete /a
  • 此时全局 Revision:1002

  • TreeIndex 更新

    • TreeIndex 会记录删除操作的 Revision,并将其添加到当前 Generation 的 Revs 列表中。因此删除并不能直接体现出来,只是在查询时找到最新版本1002,发现在BoltDB中Value已经是空,就知道该数据是被删除了

    • 删除操作会结束当前的 Generation,表示该键的生命周期已经终止。下次重建将会创建新的Generation

    • 找到 /aKeyIndex,标记删除并结束当前Generation

      go 复制代码
      KeyIndex{
          Key:      []byte("/a"),
          Modified: Revision{1002},
          Generations: []Generation{
              {
                  Version:   2,
                  Created:   Revision{1000},
                  Revs:      []Revision{1000, 1001, 1002},
              },
          },
      }
  • BoltDB 写入

    • 在 etcd 中,空值(Value 字段为空)被用作删除标记。当读取数据时,如果发现某个 Revision 的 Value 为空,则认为该键已被删除。

    • 因此删除只是为该key添加了一个空数据KeyValue作为最新版本。实际上所有旧的数据都还在

    • 将删除标记按 Revision 写入 BoltDB:

      go 复制代码
      Key: 1002_0 → Value: KeyValue{
          Key:            []byte("/a"),
          Value:          []byte(""), // 空值表示删除
          CreateRevision: 1000,
          ModRevision:    1002,
          Version:        2,
          Lease:          0,
      }
    • 删除操作结束后,BoltDB最终的数据存储如下:

      go 复制代码
      Key: 1000_0 → Value: {Key: "/a", Value: "v1", CreateRevision: 1000, ModRevision: 1000, Version: 1}
      Key: 1001_0 → Value: {Key: "/a", Value: "v2", CreateRevision: 1000, ModRevision: 1001, Version: 2}
      Key: 1002_0 → Value: {Key: "/a", Value: "",   CreateRevision: 1000, ModRevision: 1002, Version: 2}(删除标记)
3.2.5.3.4.重新创建键 Put /a "v3"
  • 假设当前全局 Revision 为 1003

  • TreeIndex 更新

    • 检查 /a 是否已存在。

      • 若不存在,创建新的 KeyIndexGeneration
      • 发现 /aKeyIndex 已经存在,就找到KeyIndex,通过最新版本1002知道该数据已经被删除
      • 因此为其创建新的 Generation
      go 复制代码
      KeyIndex{
          Key:      []byte("/a"),
          Modified: Revision{1003},
          Generations: []Generation{
              {
                  Version:   2,
                  Created:   Revision{1000},
                  Revs:      []Revision{1000, 1001, 1002},
              },
              {
                  Version:   1,
                  Created:   Revision{1003},
                  Revs:      []Revision{1003},
              },
          },
      }
  • BoltDB 写入

    • 将新的键值对按 Revision 写入 BoltDB:

      go 复制代码
      Key: 1003_0 → Value: KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v3"),
          CreateRevision: 1003,
          ModRevision:    1003,
          Version:        1,
          Lease:          0,
      }
  • 重建工作完成后,BoltDB数据的真实存储

    bash 复制代码
    Key: 1000_0 → Value: {Key: "/a", Value: "v1", CreateRevision: 1000, ModRevision: 1000, Version: 1}
    Key: 1001_0 → Value: {Key: "/a", Value: "v2", CreateRevision: 1000, ModRevision: 1001, Version: 2}
    Key: 1002_0 → Value: {Key: "/a", Value: "",   CreateRevision: 1000, ModRevision: 1002, Version: 2}(删除标记)
    Key: 1003_0 → Value: {Key: "/a", Value: "v3", CreateRevision: 1003, ModRevision: 1003, Version: 1}
3.2.5.3.5.查询 /a 的最新值 Get /a
  • TreeIndex 查询 KeyIndex

    • 从 TreeIndex 查找 /a 的 KeyIndex,获取最新 Modified Revision:1003

      go 复制代码
      KeyIndex{
          Key:      []byte("/a"),
          Modified: Revision{1003},
          Generations: []Generation{
              {
                  Version:   2,
                  Created:   Revision{1000},
                  Revs:      []Revision{1000, 1001, 1002},
              },
              {
                  Version:   1,
                  Created:   Revision{1003},
                  Revs:      []Revision{1003},
              },
          },
      }
  • BoltDB 读取数据

    • 从 BoltDB 读取 1003_0,返回:

      go 复制代码
      KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v3"),
          CreateRevision: 1003,
          ModRevision:    1003,
          Version:        1,
          Lease:          0,
      }
3.2.5.3.6.查询 /a 的历史值(Revision=1001) Get /a --rev=1001
  • TreeIndex 查询 KeyIndex

    • 从 TreeIndex 查找 /a 的 KeyIndex,,确认 Revision 1001 存在于第一个 Generation

      go 复制代码
      KeyIndex{
          Key:      []byte("/a"),
          Modified: Revision{1003},
          Generations: []Generation{
              {
                  Version:   2,
                  Created:   Revision{1000},
                  Revs:      []Revision{1000, 1001, 1002},
              },
              {
                  Version:   1,
                  Created:   Revision{1003},
                  Revs:      []Revision{1003},
              },
          },
      }
  • BoltDB 读取数据

    • 从 BoltDB 读取 1001_0,返回:

      go 复制代码
      KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v2"),
          CreateRevision: 1000,
          ModRevision:    1001,
          Version:        2,
          Lease:          0,
      }
3.2.5.3.7.查询 /a 的历史值(Revision=1002,删除标记) Get /a --rev=1002
  • TreeIndex 查询 KeyIndex

    • 从 TreeIndex 查找 /a 的 KeyIndex,,确认 Revision 1002 存在于第一个 Generation

      go 复制代码
      KeyIndex{
          Key:      []byte("/a"),
          Modified: Revision{1003},
          Generations: []Generation{
              {
                  Version:   2,
                  Created:   Revision{1000},
                  Revs:      []Revision{1000, 1001, 1002},
              },
              {
                  Version:   1,
                  Created:   Revision{1003},
                  Revs:      []Revision{1003},
              },
          },
      }
  • BoltDB 读取数据

    • 从 BoltDB 读取 1002_0,返回:

      go 复制代码
      KeyValue{
          Key:            []byte("/a"),
          Value:          []byte(""), // 空值表示删除
          CreateRevision: 1000,
          ModRevision:    1002,
          Version:        2,
          Lease:          0,
      }
3.2.5.3.8.范围查询前缀 / 的所有键(查最新版本) Range / --prefix
  • 流程

    • 从 TreeIndex 找到所有键(目前只有 /a)。
    • 获取 /a 的最新 Revision:1003。
    • 从 BoltDB 读取 1003_0,返回:
  • TreeIndex 查询所有 前缀为/ 的 KeyIndex

    go 复制代码
    KeyIndex{
        Key:      []byte("/a"),
        Modified: Revision{1003},
        Generations: []Generation{
            {
                Version:   2,
                Created:   Revision{1000},
                Revs:      []Revision{1000, 1001, 1002},
            },
            {
                Version:   1,
                Created:   Revision{1003},
                Revs:      []Revision{1003},
            },
        },
    }
  • BoltDB 读取数据

    • 从 BoltDB 读取 /a 的最新 Revision:1003_0,返回:

      go 复制代码
      KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v3"),
          CreateRevision: 1003,
          ModRevision:    1003,
          Version:        1,
          Lease:          0,
      }
3.2.5.3.9.范围查询前缀 / 的所有键(查历史版本) Range / --prefix --rev=1001
  • 流程

    • 从 TreeIndex 找到所有键(目前只有 /a)。
    • 获取 /a 在 Revision 1001 时的值。
    • 从 BoltDB 读取 1001_0,返回。
  • TreeIndex 查询所有 前缀为/ 的 KeyIndex

    go 复制代码
    KeyIndex{
        Key:      []byte("/a"),
        Modified: Revision{1003},
        Generations: []Generation{
            {
                Version:   2,
                Created:   Revision{1000},
                Revs:      []Revision{1000, 1001, 1002},
            },
            {
                Version:   1,
                Created:   Revision{1003},
                Revs:      []Revision{1003},
            },
        },
    }
  • BoltDB 读取数据

    • 从 BoltDB 读取 /a 的最新 Revision:1001_0,返回:

      go 复制代码
      KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v2"),
          CreateRevision: 1000,
          ModRevision:    1001,
          Version:        2,
          Lease:          0,
      }
3.2.5.3.10.压缩(Compact)
  • 操作:压缩到 Revision 1002

  • TreeIndex 清理

    • 删除已压缩的 Generation(第一个 Generation)。

    • 更新后的 KeyIndex:

      go 复制代码
      KeyIndex{
          Key:      []byte("/a"),
          Modified: Revision{1003},
          Generations: []Generation{
              {
                  Version:   1,
                  Created:   Revision{1003},
                  Revs:      []Revision{1003},
              },
          },
      }
  • BoltDB 清理

    • 删除 Revision 1000_0、1001_0、1002_0
    • 保留 Revision 1003_0
  • 注:除了Compact,还可以定期为 Etcd 生成snapshot,以此避免版本过多导致存储占用太大

3.2.5.3.11.总结
  • TreeIndex 记录了键的完整生命周期(创建、修改、删除、重建)。
  • BoltDB 按 Revision 顺序存储键值对,确保数据持久化和一致性。
  • 压缩机制 清理过期数据,释放存储空间。

3.3.关键协作场景

3.3.1.场景 1:日志复制与提交

Client Leader Follower1 Follower2 MVCC put key=foo value=bar Propose(写入 unstable log) AppendEntries RPC AppendEntries RPC ACK(确认日志写入) ACK(确认日志写入) 标记日志为 committed Apply 到状态机 返回成功 Client Leader Follower1 Follower2 MVCC

3.3.2.场景 2:数据版本回滚

  • 当客户端指定 --rev 参数时:
    • MVCC 模块从 TreeIndex 查找指定 Revision
    • 通过 BoltDB 按 Revision 读取历史数据
  • 但是读取已提交(committed)的版本,未提交的数据还没有应用到状态机,读取不到

3.4.容错机制补充

3.4.1.Leader 故障恢复

  • 新 Leader 选举完成后:
    • 从 WAL 中读取所有未提交的日志
    • 重新发起提案(重新走 Propose → Commit 流程)

3.4.2.Follower 数据追赶

  • 落后节点通过 AppendEntries RPC 获取缺失日志
  • 应用所有未提交日志到本地状态机

3.5.与 Kubernetes ResourceVersion 与 ETCD 的关联设计

3.5.1.Kubernetes ResourceVersion 实现

  • Kubernetes 将 metadata.resourceVersion 直接映射到 etcd 的 ModRevision,即 Kubernetes 资源的 metadata.resourceVersion == etcd.ModRevision

  • 数据流:

    • 当 Kubernetes 创建一个资源时,etcd 会为该资源分配一个 ModRevision,并将其写入 metadata.resourceVersion。
    • 当 Kubernetes 更新一个资源时,etcd 会生成一个新的 ModRevision,并更新 metadata.resourceVersion。
    • 当 Kubernetes 删除一个资源时,etcd 会标记删除,并保留最终的 ModRevision。
  • 示例

    • 创建资源时:

      yaml 复制代码
      apiVersion: v1
      kind: Pod
      metadata:
        name: my-pod
        resourceVersion: "1000"  # 对应 etcd 的 ModRevision
    • 更新资源时:

      yaml 复制代码
      apiVersion: v1
      kind: Pod
      metadata:
        name: my-pod
        resourceVersion: "1001"  # 对应 etcd 的新 ModRevision
  • 我们找个k8s的pod资源验证一下

    • 找一个pod,从yaml中得知 metadata.resourceVersion == 80581574

      yaml 复制代码
      apiVersion: v1
      kind: Pod
      metadata:
        creationTimestamp: "2025-02-08T14:55:27Z"
        generateName: envoy-deployment-555699d88-
        labels:
          app: envoy
          pod-template-hash: 555699d88
          manager: kubelet
          operation: Update
          time: "2025-02-08T14:55:28Z"
        name: envoy-deployment-555699d88-gr9qq
        namespace: default
        ownerReferences:
        - apiVersion: apps/v1
          blockOwnerDeletion: true
          controller: true
          kind: ReplicaSet
          name: envoy-deployment-555699d88
          uid: 003390a5-bd85-4c1b-afc1-b633804becb4
        resourceVersion: "80581574"
        selfLink: /api/v1/namespaces/default/pods/envoy-deployment-555699d88-gr9qq
        uid: 93f6c401-272c-4f46-9b51-99c6229dcd72
      spec:
        containers:
        - image: envoyproxy/envoy:v1.19.1
          imagePullPolicy: IfNotPresent
          name: envoy
          ports:
          - containerPort: 8080
            protocol: TCP
          resources: {}
          terminationMessagePath: /dev/termination-log
          ......
    • 进入kubernetes的etcd pod中,执行命令。查看boltdb存储数据,对输出json结果进行格式化,ModRevision果然是 80581574

      shell 复制代码
      sh-5.0# etcdctl --endpoints=127.0.0.1:2379 get /registry/pods/default/envoy-deployment-555699d88-gr9qq -wjson
      {
      	"header": {
      		"cluster_id": 6652528222563268894,
      		"member_id": 4812728548556146573,
      		"revision": 81923826,
      		"raft_term": 5
      	},
      	"kvs": [{
      		"key": "L3JlZ2lzdHJ5L3BvZHMvZGVmYXVsdC9lbnZveS1kZXBsb3ltZW50LTU1NTY5OWQ4OC1ncjlxcQ==",
      		"create_revision": 80581558,
      		"mod_revision": 80581574,
      		"version": 4,
      		"value": "azhzAAoJCgJ2MR........."
      	}],
      	"count": 1
      }

3.5.2.Kubernetes Watch 机制与增量同步

  • Watch 机制:

    • Kubernetes 客户端可以通过 Watch API 监听资源的变化。客户端可以指定一个 resourceVersion,从该版本开始接收后续的变更事件。
  • 增量同步:

    • 客户端首次启动时,会通过 List API 获取全量资源列表,并记录最新的 resourceVersion。
    • 后续通过 Watch API 监听变更,从记录的 resourceVersion 开始接收增量事件。
  • 示例:

    bash 复制代码
    # 首次 List 获取全量数据
    kubectl get pods --watch --resource-version=1000
    
    # Watch 监听变更
    kubectl get pods --watch --resource-version=1001

3.5.3.乐观锁(Optimistic Lock)

  • 乐观锁的概念

    • 定义:乐观锁是一种并发控制机制,假设冲突发生的概率较低,因此在更新资源时不会加锁,而是在提交更新时检查资源是否被修改。

    • 实现方式:通过比较资源的当前版本(resourceVersion)和客户端持有的版本,判断是否发生冲突。

  • Kubernetes 中的乐观锁实现

    • 更新流程:

      • 客户端读取资源的当前状态,并记录 metadata.resourceVersion。
      • 客户端修改资源后,尝试更新到服务器。
      • 服务器检查资源的当前 ModRevision,是否与客户端提供的 resourceVersion 一致:
        • 如果一致,允许更新,并生成新的 ModRevision。
        • 如果不一致,返回 409 Conflict,表示资源已被修改。
    • 具体实现:

      go 复制代码
      if etcd.kv.ModRevision == clientResourceVersion {
          allowUpdate()
      } else {
          return 409 Conflict
      }

3.6.有了MVCC进行数据持久化,为什么还需要WAL日志?

  • 如果etcd只有一个节点,自然只有一个持久化组件就够了,但是Etcd天生就支持分布式,需要协调 Leader、Follower 达到一致(多数投票通过)的状态后数据才能写入状态机
  • 而在Leader、Follower尚未达成一致的这段时间里,写的数据只存在于内存。一旦发生节点重启,数据就丢失掉了,因此需要WAL对尚未提交的数据也做持久化

3.7.性能优化点

  • Batch 提交:合并多个提案批量写入 WAL
  • Pipeline 复制:Leader 不等待前一个 RPC 响应即发送下一个请求
  • ReadIndex 优化:通过 ReadIndex 机制实现线性一致读(避免访问 Raft 日志)

3.8.Etcd如何保证数据一致性

  • 日志索引(Index)

    • 每个日志条目都有一个唯一的索引(Index),表示其在日志中的位置。
    • 日志是顺序追加的(Append-Only),确保日志的顺序性和一致性。
  • 多数确认机制

    • 只有当日志条目被多数节点确认后,才会被提交(Commit)。
    • 未提交的日志条目(如未达到多数确认)不会应用到状态机,因此不会对外可见。
  • 示例

    • 假设集群有 3 个成员:Leader(A)、Follower(B)、Follower(C)。
    • 日志条目:
      • Index 1-5:已提交(多数节点确认)。
      • Index 6:Leader(A)和 Follower(B)确认,Follower(C)未确认。
      • Index 7:Leader(A)和 Follower(B)确认,Follower(C)未确认。
      • Index 8:Leader(A)收到请求,但未同步到任何 Follower。
    • 结果
      • Index 1-7:已提交,数据有效。
      • Index 8:未提交,数据无效。
  • Raft多数确认机制 决定数据的commit

    • 虽然Follower C数据落后,但是fg已经超过2个节点commit,就已经是一个有效数据
    • 虽然Leader A存在数据h,但是该数据还没有多数确认,就没有commit,就是无效数据

4.Etcd的Watch机制

2.1.Watch 的基本概念

  • Watch 的作用

    • 监听指定键(Key)或键范围(Range)的变化。
    • 当键的值发生变化时,etcd 会向客户端推送变更事件。
  • Watch 的类型

    1. Key Watch:监听单个键的变化。
    2. Range Watch :监听某个前缀下的所有键的变化(类似于前缀查询)。

2.2.Watch 的实现细节

携带revision revision <= 当前版本 revision > 当前版本 客户端Watch请求 revision检查 加入unsynced组 从BoltDB加载历史数据 数据就绪后移入synced组 直接加入synced组

  • Revision 与 Watcher Group
    • 每个 Watch 请求可以携带一个 Revision 参数,表示从哪个版本开始监听
    • 通过对 Revision 参数的值key当前版本值 的比较,可以将 Watch 请求分为 Sync Group 和 Unsync Group
  • Sync Group
    • 如果 Revision 参数的值 > key当前版本值 ,表示监听未来数据,放入 Sync Group,并持续监听未来的变更
  • Unsync Group
    • 如果 Revision 参数的值 <= key当前版本值 ,表示监听历史数据,放入 Unsync Group。
    • 因为有数据未同步,需要先从 BoltDB 加载历史数据进行同步
    • 历史数据加载完成后,请求会被转移到 Sync Group,并开始监听未来的变更

2.3.Watch 的工作流程

  1. 客户端发起 Watch 请求

    • 指定监听的键(Key)或键范围(Range)。
    • 可选的 Revision 参数,表示从哪个版本开始监听。
  2. etcd 处理 Watch 请求

    • 比较 Revision 和当前 Store 的 Revision
    • 根据比较结果,将请求放入 Sync Group 或 Unsync Group。
  3. Sync Group 的处理

    • 直接返回当前数据。
    • 持续监听未来的变更,并将变更事件推送给客户端。
  4. Unsync Group 的处理

    • 从 BoltDB 加载历史数据。
    • 加载完成后,将请求转移到 Sync Group,并开始监听未来的变更。
相关推荐
qq_448941081 小时前
10、k8s对外服务之ingress
linux·容器·kubernetes
野猪佩挤1 小时前
minio作为K8S后端存储
云原生·容器·kubernetes
斯普信专业组3 小时前
K8S下redis哨兵集群使用secret隐藏configmap内明文密码方案详解
redis·kubernetes·bootstrap
福大大架构师每日一题8 小时前
6.4 k8s的informer机制
云原生·容器·kubernetes
炸鸡物料库9 小时前
Kubernetes 使用 Kube-Prometheus 构建指标监控 +飞书告警
运维·云原生·kubernetes·飞书·prometheus·devops
CarryBest10 小时前
搭建Kubernetes (K8s) 集群----Centos系统
容器·kubernetes·centos
Karoku06611 小时前
【CI/CD】持续集成及 Jenkins
运维·ci/cd·docker·云原生·容器·kubernetes·jenkins
A ?Charis18 小时前
k8s-对接NFS存储
linux·服务器·kubernetes
KTKong19 小时前
kubeadm拉起的k8s集群证书过期的做法集群已奔溃也可以解决
云原生·容器·kubernetes
运维开发王义杰1 天前
Kubernetes:EKS 中 Istio Ingress Gateway 负载均衡器配置及常见问题解析
kubernetes·gateway·istio