问题描述
该笔记将记录:我们将学习 etcd 数据读流程,以理解 etcd 数据读取的工作原理;
解决方案
极客时间 /etcd 实战课(唐聪,腾讯云资深工程师,etcd 活跃贡献者)
写数据命令:
etcdctl put hello world --endpoints http://127.0.0.1:2379 OK
Client =(gPRC)=> etcd Node
通过负载均衡算法,Client 选择一个 etcd Node,发起 gRPC 调用,即 ① 步骤;
Quota Module
然后 etcd Node 收到请求后,经过 gRPC 拦截器,Write Request 需要经过 Quota(配额模块)处理,即 ② 步骤;
模块功能:配额限制;
概述原理
当 etcd Server 收到 put/txn 等 Write Request 的时候,会首先检查当前 etcd db 大小加上请求的 K/V 大小之和是否超过配额(quota-backend-bytes);
如果超过配额,它会产生一个告警(Alarm)请求,告警类型是 NO SPACE,并通过 Raft 日志同步给其它 Follower,告知 db 无空间,并将 Alarm 持久化存储到 db 中;
最终,gRPC Module(API 层 )和 Apply Module(负责将 Raft 侧已提交的日志条目应用到状态机),都拒绝写入,集群只读;
触发配额限制的场景
在使用 etcd 过程中,出现 etcdserver: mvcc: database space exceeded 错误,则表示当前 etcd db 文件大小超过配额。当出现此错误后,整个集群将不可写入(只读状态);
默认 db 配额仅为 2G,当业务数据、Write QPS、Kubernetes 集群规模增大后,etcd db 大小就可能会超过 2G;
etcd v3 是个 MVCC 数据库,保存 key 的历史版本。当未配置压缩策略的时候,随着数据不断写入,db 大小会不断增大,导致超限;
如果 etcd Version < 3.2.10 版本,备份操作可能会触发 boltdb 的某个 Bug,它会导致 db 大小不断上涨,最终达到配额限制;
调整配额的方法(流程)
调整配额:
1)直接调整配额,但 etcd 社区建议不超过 8G;
2)需要注意配额(quota-backend-bytes)的行为:默认’0’就是使用 etcd 默认的 2GB 大小;如果参数 < 0 ,就会禁用配额功能,可能会导致 db 大小处于失控,导致性能下降,所以不建议禁用配额;
取消警告:还需要额外发送一个取消告警(etcdctl alarm disarm)的命令,以消除所有告警。原因就是,前面提到的 NO SPACE 告警未消除,而 Apply Module 在执行每个命令的时候,都会去检查当前是否存在 NO SPACE 告警。如果存在 NO SPACE 告警,那么即使把配额(quota-backend-bytes)调大后,集群依然拒绝写入;
压缩检查:
1)其次需要检查 etcd 的压缩(compact)配置是否开启,及其配置是否合理。etcd 保存一个 key 所有变更历史版本,如果没有 Compactor Module(压缩模块)去回收旧的版本,那么内存和 db 大小就会不断膨胀;
2)压缩模块支持按多种方式回收旧版本,比如保留最近一段时间内的历史版本等等。注意,它仅将旧版本占用的空间通过 Free 标记,后续新的数据写入时,将复用这块空间,而无需申请新的空间。换句话说,开启 Compactor 功能并不会减小 etcd 占用的空间,排查 etcd 的可用空间时,不能仅关注 etcd 已占用的空间;
碎片整理:如果需要回收空间,减少 db 大小,需要使用碎片整理(defrag),其将遍历旧的 db 文件数据,写入到一个新的 db 文件。但是它对服务性能有较大影响,不建议在生产集群频繁使用;
KVServer Module
当通过 Quota Module 检查后,请求就从 API 层转发到的 KVServer Module 的 put 方法,即 ③ 步骤;
KVServer Module 会向 Raft Module 提交 propose(put hello) 写请求;
Preflight Check
为保证集群稳定性(避免雪崩),任何提交到 Raft Module 的请求,在执行前,都会做些简单的限速判断及其他检查;
流程如下图所示:
当 WAL 在多数节点应用成功后,就会返回写入成功,后面在异步提交到状态机,那么会出现已提交日志索引比应用到状态机的日志索引超出的情况。所以,如果 Raft Module 已提交的日志索引(committed index)比已应用到状态机的日志索引(applied index)超过 5000,那么它就返回 etcdserver: too many requests 错误给 Client;
然后,它会尝试去获取请求中的鉴权信息,若使用密码鉴权、请求中携带 token,如果 token 无效,则返回”auth: invalid auth token”错误给 client;
其次,它会检查写入的包大小是否超过默认的 1.5MB(所以 Kubernetes 的 ConfigMap 不能超过 1.5mb), 如果超过,则会向 Client 返回 etcdserver: request is too large 错误;
Propose
当通过 Preflight Check 检查后,会生成一个唯一的 ID,将此请求关联到一个对应的消息通知 channel,
etcd 是基于 Raft 实现节点间数据复制的,所以它需要将 Write Request 内容打包成 Propose 消息,提交给 Raft Module 来处理。所以 KVServer 向 Raft Module 发起一个提案(Proposal),其内容为“大家好,请使用 put 方法执行一个 key 为 hello,value 为 world 的命令”,也就是 ④ 流程;
KVServer 向 Raft Module 发送 Propose 后,KVServer 会等待此 put 请求的写入结果通过消息通知 channel 返回。或超时,默认超时时间是 7s(5 秒磁盘 IO 延时 + 2*1 秒竞选超时时间)。如果请求超时未返回结果,则可能会出现 etcdserver: request timed out 错误;
Raft Module
当前节点 Raft Module 收到 propose 后,如果当前节点是 Follower,它会转发给 Leader(只有 Leader 才能处理 Write Request,如果此时集群中无 Leader,整个请求就会超时;);
当 Leader 收到 propose 后:
1)需要把 Leader 任期号、投票信息、已提交索引、propose 内容持久化到 WAL 文件中,以保证集群一致性及可恢复性,即 ⑤ 流程;
2)同时(并行)Leader 会将 propose(put hello) 消息广播给各个 Follower;
Apply Module
流程概述
执行 put propose 内容,如图 ⑦ 流程,其细节如下;
Apply Module 是如何执行 put 请求的呢?
Apply Module 在执行 propose 内容前,首先会判断当前 propose 是否已经执行过。如果执行,则直接返回。若未执行,同时无 db 配额满告警,则通过 MVCC Module 执行 propose 内容,来更新状态机(持久化存储);
常见问题
若 put 请求 propose 在执行流程七的时候 etcd 突然 crash,重启恢复的时候,etcd 是如何找回异常 propose,再次执行的呢?
如何确保幂等性,防止 propose 重复执行导致数据混乱呢?etcd 是个 MVCC 数据库,每次更新都会生成新的版本号。如果没有幂等性保护,同样的命令,一部分节点执行一次,一部分节点遭遇异常故障后执行多次,则系统的各节点一致性状态无法得到保证。因此 etcd 必须要确保幂等性。怎么做呢?Apply Module 从 Raft Module 获得的日志条目信息里,是否有唯一的字段能标识这个 propose?
但是这还不够安全,如果执行命令的请求更新成功,更新已执行 Index 的请求却失败,是不是一样会导致异常?
MVCC Module
当 Apply Module 判断此 propose 未执行后,就会调用 MVCC Module 来执行 propose 内容;
MVCC 主要由两部分组成:是内存索引模块 treeIndex,保存 key 的历史版本号信息; boltdb 模块,用来持久化存储 key-value 数据;
那么 MVCC Module 执行 put hello 为 world 命令时,它是如何构建内存索引和保存哪些数据到 db 呢?
treeIndex
当 treeIndex 收到更新 key hello 为 world 的时候,此 key 的索引版本号信息是怎么生成的呢?需要维护、持久化存储一个全局版本号吗?
版本号(revision)在 etcd 里面发挥着重大作用,它是 etcd 的逻辑时钟。但 etcd 启动时,默认 revision=1,随着 key 的增、删、改操作而全局单调递增;
因为 boltdb 中的 key 就包含此信息,所以 etcd 并不需要再去持久化一个全局版本号。我们只需要在启动的时候,从最小值 1 开始枚举到最大值,未读到数据的时候则结束,最后读出来的版本号即是当前 etcd 的最大版本号 currentRevision;
MVCC 写事务在执行 put hello 为 world 的请求时,会基于 currentRevision 自增生成新的 revision 如{2,0},然后从 treeIndex 模块中查询 key 的创建版本号、修改次数信息。这些信息将填充到 boltdb 的 value 中,同时将用户的 hello key 和 revision 等信息存储到 B-tree,
也就是下面简易写事务图的流程一,整体架构图中的流程八;
boltdb
MVCC 写事务自增全局版本号后,生成的 revision{2,0},它就是 boltdb 的 key,通过它就可以往 boltdb 写数据,进入整体架构图中的流程九;
在 etcd 里面通过 put/txn 等 KV API 操作的数据,全部保存在一个名为 key 的桶里面,这个 key 桶在启动 etcd 的时候会自动创建;
除保存用户 KV 数据的 key 桶,etcd 本身及其它功能需要持久化存储的话,都会创建对应的桶。比如上面我们提到的 etcd 为保证日志的幂等性,保存一个名为 consistent index 的变量在 db 里面,它实际上就存储在 meta 桶里面;
那么写入 boltdb 的 value 含有哪些信息呢?
1)key 名称;
2)key 创建时的版本号(create_revision)、最后一次修改时的版本号(mod_revision)、key 自身修改的次数(version);
3)value 值;
4)租约信息(后面介绍);
但是 put 调用成功,就能够代表数据已经持久化到 db 文件吗?
那么解决的办法是什么呢?etcd 的解决方案是合并再合并;
但是这优化又引发另外的一个问题:因为事务未提交,Read Request 可能无法从 boltdb 获取到最新数据;