多维度解析etcd,一个比zookeeper更加优秀的键值对存储系统

楔子

这次我们来聊一聊 etcd,不过在此之前先来来说说分布式系统。

分布式系统是一个硬件或软件组件分布在不同的网络计算机上,彼此之间仅仅通过消息传递进行通信和协调的系统,所以它们是一组计算机节点或软件共同对外提供服务。但对于用户而言,就好像在请求一台服务器。因为在分布式系统中,各个节点之间的协作是通过网络进行的,所以分布式系统中的节点在空间分布上几乎没有任何限制,可以分布于不同的机柜、机房,甚至是不同的国家和地区。

分布式系统的设计目标一般包括如下几个方面:

1. 可用性:可用性是分布式系统的核心需求,其用于衡量一个分布式系统持续对外提供服务的能力。

2. 可扩展性:增加机器后不会改变或极少改变系统行为,并且能获得近似线性的性能提升。

3. 容错性:系统发生错误时,具有对错误进行规避以及从错误中恢复的能力。

4. 性能:对外服务的响应延时和吞吐率要能满足用户的需求。

但虽然分布式架构可以组建一个强大的集群,但实际工作中遇到的挑战也要比传统单体架构大得多,具体表现如下所示。

1. 节点之间的网络通信是不可靠的,存在网络延时和丢包等情况。

2. 存在节点处理错误的情况,节点自身随时也有宕机的可能。

3. 同步调用使系统变得不具备可扩展性。

 

 

提到分布式系统,就不得不提 CAP 原理 ,CAP 原理在计算机科学领域广为人知,如果说系统架构师将 CAP 原理视作分布式系统的设计准则一点也不为过。先来看看 CAP 的完整定义:

C: Consistency(一致性),这里的一致性特指强一致。通俗地说,就是所有节点上的数据时刻保持同步,一致性严谨的表述是原子读写,即所有读写都应该看起来是 "原子性" 的,或串行的。所有的读写请求都好像是经全局排序过的一样,写后面的读一定能读到前面所写的内容。

A: Availability(可用性),任何非故障节点都应该在有限的时间内给出请求的响应,不论请求是否成功。

P: Tolerance to the partition of network(分区容忍性),当发生网络分区时(即节点之间无法通信),在丢失任意多消息的情况下,系统仍然能够正常工作。

相信大家都非常清楚 CAP 原理的指导意义:在任何分布式系统中,可用性、一致性和分区容忍性这三个方面都是相互矛盾的, 三者不可兼得,最多只能取其二。

1) AP 满足但 C 不满足:如果既要求系统高可用又要求分区容错,那么就要放弃一致性了。因为一旦发生网络分区(P),节点之间将无法通信,为了满足高可用(A),每个节点只能用本地数据提供服务,这样就会导致数据的不一致。一些信奉 BASE(Basic Availability,So state,Eventually Consistency)原则的 NoSQL 数据库(例如, Cassandra、CouchDB 等)往往会放宽对一致性的要求(满足最终一致性即可),以此来换取基本的可用性。

2) CP 满足但 A 不满足:如果要求数据在各个服务器上是强一致的(C),然而网络分区( P)会导致同步时间无限延长,那么如此一来可用性就得不到保障了。坚持事务 ACID(原子性、一致性、隔离性和持久性)的传统数据库以及对结果一致性非常敏感的应用(例如,金融业务)通常会做出这样的选择。

3) CA 满足但 P 不满足:指的是如果不存在网络分区,那么强一致性和可用性是可以同时满足的。

CAP 原理明确指出了完美满足 CAP 三种属性的分布式系统是不存在的。了解 CAP 原理的目的在于,其能够帮助我们更好地理解分布式协议实现过程中的取舍,在后面了解etcd的时候就会明白。

etcd全面介绍

首先我们为什么要使用etcd?

因为开发分布式系统是一件比较困难的事情,其中的困难主要体现在分布式系统的 "部分失败" 上。"部分失败" 是指信息在网络的两个节点之间传送的时候,网络出现了故障,发送者无法知道接收者是否收到了这个信息。而且导致这种故障的原因很复杂,接收者可能在出现网络错误之前就已经收到了信息,也可能没有收到,又或者接收者的进程结束而没能接收。

这就需要我们有一个天生支持分布式的系统,而etcd就是帮我们做这件事的,并且它是一个键值对存储系统。

其实现代的键值对存储系统都是分布式的,zookeeper 是其中历史最悠久的项目之一,它起源于 Hadoop,具有成熟、健壮以及丰富的特性。既然如此,那么我们为什么不使用zookeeper呢?原因有以下几点:

  • 1. zookeeper的部署和维护比较复杂,管理员需要掌握一系列的知识和技能。而zookeeper所使用的Paxos强一致性算法也素来是以复杂难懂而闻名于世;并且,zookeeper的使用也比较复杂。
  • 2. java编写,由于java偏向于重型应用,会引入大量的依赖。而运维人员则希望保持强一致、高可用的机器集群尽可能简单,维护起来也不容易出错。
  • 3. 发展缓慢,Apache基金会庞大的结构以及松散的管理导致项目发展缓慢。

而现在,我们有了更好的选择 etcd,与zookeeper相比它更简单,安装、部署和使用更加容易,并且 etcd 的某些功能是 zookeeper 所没有的。因此,在很多场景下,etcd 比 zookeeper 更受用户的青睐,具体表现在如下几个方面:

  • 1. etcd 更加稳定可靠, 它的唯一目标就是把分布式一致性键值存储做到极致, 所以它更注重稳定性和扩展性
  • 2. 在服务发现的实现上, etcd 使用的是节点租约(Lease), 并且支持 Group(多key); 而 zookeeper 使用的是临时节点, 临时节点存在不少的问题, 这些问题后面会提到
  • 3. etcd 支持稳定的 watch, 而不是 zookeeper 样简单的单次触发式(one time trigger)watch; 因为在未来微服务的环境下, 通过调度系统的调度, 一个服务随时可能会下线, 也可能为应对临时访问压力而增加新的服务节点, 而很多调度系统是需要得到完整节点历史记录的; 在这方面, etcd 可以存储数十万个历史变更
  • 4. etcd 支持 MVCC(多版本并发控制), 因为有协同系统需要无锁操作
  • 5. etcd 支持更大的数据规模, 支持存储百万到千万级别的 key
  • 6. 相比 zookeeper, etcd 的性能更好; 在一个由 3台 8核节点组成的云服务器上, etcd v3 版本可以做到每秒数万次的写操作和数十万次的读操作

etcd 是什么

etcd 的官方定义如下:

A highly-available key value store for shared configuration and service discovery

很多人看到上述官方定义的第一反应可能是,etcd 是一个键值存储仓库,却没有重视官方定义的后半句:用于配置共享和服务发现。

也就是说,etcd 是一个 Go 语言编写的分布式、高可用的一致性键值存储系统,用于提供可靠的分布式键值存储、配置共享和服务发现等功能。etcd 可以用于存储关键数据和实现分布式调度,它在现代化的集群运行中能够起到关键性的作用。

etcd 以一致和容错的方式存储数据,分布式系统可以使用 etcd 实现一致性键值存储、配置管理、服务发现和分布式系统的协同等功能。而常见的 etcd 使用场景包括:服务发现、分布式锁、分布式数据队列、分布式通知和协调、主备选举等等。

etcd 基于 Raft 协议,通过复制日志文件的方式来保证数据的强一致性。当客户端应用写一个 key 时,首先会存储到 etcd 的 Leader 上,然后再通过 Raft 协议复制到 etcd 集群的所有成员中,以此维护各成员(节点)状态的一致性与 可靠性。虽然 etcd 是一个强一致性的系统,但也支持从非 Leader 节点读取数据以提高性能,但是写操作仍然需要 Leader,所以当发生网络分区时,写操作仍可能失败。此外 etcd 实现了一个 Go 语言版的 Raft 程序库 ,并广泛应用于各种项目,除了 etcd 之外,还包括 docker swarm kit等。

etcd 具有一定的容错能力,假设集群中共有 n 个节点,即便集群中 (n - 1) / 2 个节点发生了故障,只要剩下的 (n + 1) / 2 个节点达成一致,也能操作成功。因此,它能够有效地应对网络分区和机器故障带来的数据丢失风险。

etcd 默认数据一更新就落盘持久化,数据持久化存储使用 WAL (write ahead log ,预写式日志) 格式。WAL 记录了数据变化的全过程,在 etcd 中所有数据在提交之前都要先写入 WAL 中;etcd 的 Snapshot(快照)文件则存储了某一时刻 etcd 的所有数据,默认设置为每 10000 条记录做一次快照,经过快照后 WAL 文件即可删除。

etcd 架构简介

etcd 在设计的时候重点考虑了如下的四个要素。

1. 简单

  • 1. 支持 RESTful 风格的 HTTP+JSON API
  • 2. 从性能角度考虑, etcd 增加了对 gRPC 的支持, 同时也提供 rest gateway 进行转化
  • 3. 使用 Go 语言编写, 跨平台、部署和维护简单
  • 4. 使用 Raft 算法保证强一致性, Raft 算法可理解性好

2. 安全

  • 支持 TLS 客户端安全认证

3. 性能

  • 单实例支持每秒一千次以上的写操作(v2版本), 极限写性能可达 10K+ 的Qps(v3版本), 现在都用v3不用v2了

4. 可靠

  • 使用 Raft 算法充分保证了分布式系统数据的强一致性; etcd 集群是一个分布式系统, 由多个节点相互通信构成整体的对外服务, 每个节点都存储了完整的数据, 并且通过 Raft 协议保证了每个节点维护的数据都是一致的。

简单地说,etcd 可以扮演两大角色,具体如下:

  • 持久化的键值存储系统
  • 分布式系统数据一致性服务提供者

在分布式系统中,如何管理节点间的状态一直是一个难题,etcd 像是专门为集群环境的服务发现和注册而设计的,它提供了数据 TTL 失效、数据改变监视、多值、目录、分布式锁原子操作等功能,可以方便地跟踪并管理集群节点的状态

etcd (server)大体上可以分为如下几个部分:

  • HTTP Server:提供网络数据读写功能, 监听服务端口; 用于处理用户发送的api请求, 以及其它etcd节点的数据同步与心跳信息请求
  • Raft:Raft强一致性算法的具体实现,是etcd的核心
  • Store:用于处理etcd支持的各类功能的事务,包括数据索引、节点状态变更、监控与反馈、事件处理与执行等等,是etcd对用户提供的大多数API功能的具体实现
  • WAL:Write Ahead Log(预写式日志),是etcd的数据存储方式。除了在内存中存有所有数据的状态以及节点的索引以外,etcd就通过WAL进行持久化存储。WAL中,所有的数据提交前都会事先记录日志。Snapshot是为了防止数据过多而进行的状态快照;Entry表示存储的具体日志内容

通常,一个用户的请求发送过来,会经由 HTTP Server 转发给 Store 进行具体的事务处理。如果涉及到节点的修改,则交给 Raft 模块进行仲裁 和 日志记录,然后再同步给别的 etcd 节点以确认数据提交,只有当半数以上的节点确认了该节点状态的修改之后,才会真正进行数据的提交(持久化),然后再次同步。

各个节点在任何时候都有可能变成 Leader、Follower、Candidate 等角色,同时为了减少创建链接开销,etcd 节点在启动之初就会创建并维持与集群其他节点之间的链接。

etcd 集群的各个节点之间需要通过网络来传递数据,具体表现为如下几个方面。

  • 1. Leader 向 Follower 发送心跳包,Follower 向 Leader 回复消息
  • 2. Leader 向 Follower 发送日志追加信息
  • 3. Leader 向 Follower 发送 Snapshot 数据
  • 4. Candidate 节点发起选举,向其他节点发起投票请求
  • 5. Follower 将收到的写操作转发给 Leader

那么问题来了,用户会从集群中哪个节点读写数据呢?

为了保证数据的强一致性,etcd集群中所有的数据流向都是一个方向,从 Leader (主节点)流向 Follower,也就是所有 Follower 的数据必须与 Leader 保持一致,如果不一致会被覆盖。

简单点说就是,用户可以对etcd集群中的所有节点进行读写。首先读取非常简单,因为每个节点保存的数据是强一致的。对于写入来说,etcd集群中的节点会选举出Leader节点,如果写入请求来自Leader节点,则可以直接写入,然后Leader节点会把写入分发给所有Follower;如果写入请求来自其他Follower节点,那么写入请求会给转发给Leader节点,由Leader节点写入之后再分发给集群上所有其他节点。

如何选取 Leader?

假设集群中有三个节点,集群启动之初节点中并没有被选举出的Leader。

Raft 算法使用随机 Timer 来初始化 Leader 选举流程,比如说在上面三个节点上都运行了Timer(每个Timer的持续时间是随机的)。第一个节点率先完成了Timer,随后它就会向其他两个节点发送成为Leader的请求,其他节点接收到请求后会以投票回应然后第一个节点被选举为Leader。

成为Leader后,该节点会以固定时间间隔向其他节点发送通知,确保自己仍是Leader。有些情况下当Follower们收不到Leader的通知后,比如说Leader节点宕机或者失去了连接,其他节点会重复之前选举过程选举出新的Leader。

etcd 典型应用场景举例

正如上面介绍的那样,etcd 的定位是通用的一致性 key/value 存储,但也有服务发现和共享配置的功能。因此,典型的 etcd 应用场景包括但不限于分布式数据库、服务注册与发现、分布式锁、分布式消息队列、分布式系统选主等 。etcd 的定位是通用的一致性 key/value 存储,同时也面向服务注册与发现的应用场景。下面将对 etcd 的一些典型应用场景进行简单概括。

1. 服务注册与发现

服务发现( Service Discovery )要解决的是分布式系统中最常见的问题之 一,即在同一个分布式集群中的进程或服务如何才能找到对方并建立连接。

从本质上说,服务发现就是要了解集群中是否有进程在监听 UDP 或者 TCP 端口,并且通过名字就可以进行查找和链接。而要解决服务发现的问题,需要具备如下三个条件:

  • 1. 需要一个强一致性、高可用的服务存储目录, 而基于 Raft 算法 etcd 天生就是这样一个强一致性、高可用的服务存储目录
  • 2. 可以对服务进行注册, 并且还能监控服务的健康状况; 而用户可以在 etcd 中注册服务, 并且对注册的服务配置 key TTL, 定义保持服务的心跳以达到监控健康状态的效果
  • 3. 具备查找和连接服务的机制, 在etcd 指定的主题下注册的服务也能在对应的主题下找到; 为了确保连接, 我们可以在各个服务机器上都部署一个代理模式的 etcd, 这样就可以确保访问 etcd 集群的服务都可以互相连接

图上有三个角色,分别是服务请求方、服务提供方、服务注册方。假设有三台机器:A、B、C,用于提供邮件发送服务,这个时候服务请求方如果想要使用邮件发送服务,那么就会去找服务提供方。但是服务请求方并不是直接寻找服务提供方,因为它不知道提供请求的是谁,所以它会寻找服务注册方,然后服务注册方将服务提供方的信息返回给服务请求方,比如:返回A、B、C,表示这三台机器是用来提供服务的。

但是服务注册方如何才能准确返回服务提供方的信息呢?显然服务提供方是要先进行注册的,服务注册方保存了提供方的信息。并且提供方还要不断地向注册方发送心跳信息,表示自己还活着。假设B机器挂掉了,那么请求方向注册方寻找服务方的时候,注册方就不会再返回B机器的信息了。所以服务请求方首先找的是服务注册方,而我们的 etcd 充当的就是服务注册方这一角色,当然zookeeper也是类似。

2. 消息发布和订阅

在分布式系统中,组件之间的通信机制最为适用的是消息的发布和订阅机制。具体而言就是,设置一个配置共享中心,消息提供者在这个配置中心发布消息,而消息使用者则订阅它们关心的主题,一旦所关心的主题有消息发布,就会实时通知订阅者。通过这种方式,我们可以实现分布式系统配置的集中式管理和实时动态更新。

1. etcd 管理应用配置信息更新

这类场景的使用方式通常是,应用在启动的时候主动从 etcd 获取一次配置信息,同时在 etcd节点上注册 Watcher 并等待。以后每当配置有更新的时候,etcd 都会实时通知订阅者,以此达到获取最新配置信息的目的。

2. 分布式日志收集系统

这个系统的核心工作是收集分布在不同机器上的日志。

收集器通常按应用(或主题)来分配收集任务单元,因此可以在 etcd 上创建一个以应用(或主题)为名字的目录,并将这个应用(或主题)相关的所有机器 IP 以子目录的形式存储在目录下。然后设置一个递归的 etcd Watcher,递归式地监控应用(或主题)目录下所有信息的变动。这样就能够实现在机器 IP(消 息)发生变动时,系统能够实 时接受收集器调整的任务分配。

3. 负载均衡

在分布式系统中,为了保证服务的高可用以及数据的一致性,通常都会把数据和服务部署为多份,以此达到对等服务,即使其中的某一个服务失效了,也不会影响使用。

这样的实现虽然会导致一定程度上数据写入性能的下降,但是却能够实现数据访问时的负载均衡 。因为每个对等服务节点上都存储有完整的数据,因此所有用户的访问流量都可以分流到不同的机器上。

4. 分布式通知与协调

这里讨论的分布式通知与协调,和消息的发布订阅有点相似。两者都使用了 etcd 的 Watcher 机制,通过注册与异步通知机制, 实现分布式环境下不同系统之间的通知与协调,从而对数据变更进行实时处理。

实现方式是不同的系统都在 etcd 上对同一个目录进行注册,同时设置 Watcher 监控该目录的变化(如果子目录的变化也有需求,那么可以设置成递归模式)。若某个系统更新了 etcd 的目录,那么设置了 Watcher 的系统就会收到通知,并也做出相应的通知,然后进行相应的处理。

5. 分布式锁

因为 etcd 使用 Raft 算法保持了数据的强一致性,操作之后存储到集群中的值就必然是全局一致的,所以 etcd 很容易实现分布式锁。

而锁服务包含两种使用方式:保持独占,以及控制时序。

1. 保持独占

即所有试图获取锁的用户最终只有一个可以得到。

etcd 为此提供了一套实现分布式锁原子操作 CAS(ComparaAndSwap)的 API,通过设置 prevExist 值,可以保证在多个节点上同时创建某个目录时,只有一个节点能够成功,而成功的那个即可获得分布式锁。

2. 控制时序

试图获取锁的所有用户都会进入等待队列,获得锁的顺序是全局唯一的,同时还能决定队列的执行顺序。

etcd 为此也提供了一套 API(自动创建有序键),它会将一个目录的键值指定为 POST 动作,这样,etcd 就会在目录下生成一个当前最大的值作为键,并存储这个新的值(客户端编号)。

同时还可以使用 API 按顺序列出所有目录下的键值,此时这些键的值就是客户端的时序,而这些键中存储的值则可以是代表客户端的编号。

6. 分布式队列

分布式队列的常规用法与分布式锁的控制时序类似,即通过创建一个先进先出的队列来保证顺序。

另一种比较有意思的实现是在保证队列达到某个条件时再统一按顺序执行,要实现这种方法,可以在 "/queue" 目录中另外建立 一个 "/queue/condition" 节点,如图所示:

1. condition 可以表示队列的大小,比如一个大的任务需要在很多小任务都就绪的情况下才能执行,那么每当有一个小任务就绪时,就将这个 condition 的数值加 1,直到到达大任务规定的数字,然后再开始执行队列里的 一系列小任务,直至最终执行大任务。

2. condition 可以表示某个任务不在队列中,这个任务既可以是所有排序任务的首个执行程序,也可以是拓扑结构中没有依赖的点。通常,必须在执行这些任务之后才能执行队列中的其他任务。

3. condition 还可以表示开始执行任务的通知,可以由控制程序来指定,当 condition 发生变化时,开始执行队列任务。

7. 集群监控与 Leader 竞选

通过 etcd 来进行监控的功能实现起来非常简单并且实时性较强,主要会用到如下两点特性:

  • 前面几个场景已经提到了 Watcher 机制, 当某个节点消失或发生变动时, Watcher 会第一时间发现并告知用户;
  • 节点可以设置 TTL key, 比如每隔 30s 向 etcd 发送一次心跳信号, 以此代表该节点依然存活着, 否则就说明节点已经消失了;

这样就可以第一时间检测到各节点的健康状态,以完成集群的监控要求。

另外,使用分布式锁还可以完成 Leader 竞选,对于一些需要长时间进行 CPU 计算或使用 I/O 的操作,只需要由竞选出 Leader 计算或处理一次,再把结果复制给其他的 Follower 即可,从而避免重复劳动,节省计算资源。

Leader 应用的经典场景是在搜索系统中建立全量索引,如果各个机器分别进行索引的建立,那么将很难保证索引的一致性。通过 etcd 的 CAS 机制竞选 Leader,再由 Leader 进行索引计算,最后将计算结果分发到其它节点即可。

但etcd也是有缺点的

比如我们之前说 etcd 有 v2 和 v3 两个版本,现在推荐使用 v3。但是相比于 etcd v2,etcd v3 版本的接口是通过 gRPC 提供 RPC 接口的,它放弃了 v2 版本的 HTTP 接口,虽然这种改变可以明显提升连接效率 ,但使用便利性不如 v2,特别是不便于维护长连接的应用场景。此外,etcd 的定位是通用的一致性 KV 存储,但在面向服务注册与发现的应用场景中,过于广泛的通用性会使得每个应用的服务注册都有自己的元数据格式,不利于互相整合,受限于元数据格式的兼容性问题,也不利于实现更高级的功能。

etcd 与其它键值存储系统的对比

这里我们主要探讨 etcd 和 zookeeper 之间的区别。zookeeper 是一个用户维护配置信息 、命名、分布式同步以及分组服务的集中式服务框架,它使用 Java 语言编写,通过 Zab 协议来保证节点的一致性。因为 zookeeper 是一个 CP 型系统,所以在发生网络分区问题时,系统不能注册或查找服务。

zookeeper 和 etcd 可用于解决的问题:分布式系统的协同和元数据存储。然而,etcd 却有 着zookeeper 的设计和实现的后见之明,zookeeper 最大的问题就是太复杂了,etcd 吸取了 zookeeper 的教训后具备更好的工程和运维体验。

etcd 和 zookeeper 相比,其改进之处在于如下几个方面。

  • 动态的集群节点关系重配置
  • 高负载条件下的稳定读写
  • 多版本并发控制的数据模型
  • 持久、稳定的 watch 而不是简单的单次触发式 watch。zookeeper 的单次触发式 watch 是指监听到一次事件之后, 需要客户端重新发起监听, 这样 zookeeper 服务器在接收到客户端的监听请求之前的事件是获取不到的; 而且在两次监昕请求的时间间隔内发生的 事件, 客户端也是没法感知的。etcd 的持久监听是每当有事件发生时, 就会连续触发,不需要客户端重新发起监听
  • 租约 (lease) 原语实现了连接和会话的解耦
  • 安全的分布式共享锁 API

另外, etcd 广泛支持各种各样的语言和框架,但 zookeeper 只有它自己的客户端协议:Jute RPC 协议。 Jute 是 zookeeper 独一无二的协议,且只在特定的语言库(Java 和 C)中绑定。etcd 的客户端协议 gRPC,它是一个流行的 RPC 框架,支持的语言非常多。gRPC 也能序列化成通过 HTTP 传输的 JSON,所以通用的命令行工具 curl 也能与它进行交互。这就为分布式系统的构建者提供了丰富的选择,他们能够用操作系统原生的工具来构建而不是非得围绕 etcd 用指定的技术,也就是无需迎合 etcd,而是让 etcd 配合你。

至于其它的分布式键值存储系统就不对比了,总之 etcd 绝对是脱颖而出的。

etcd 相关概念

etcd 中存在许多概念,或者说术语,来看一下,先有一个印象。

  • Raft: etcd 所采用的保证分布式系统强一致性的算法
  • Node: 一个 Raft 状态机实例
  • Member: 一个 etcd 实例, 它管理着一个 Node, 并且可以为客户端请求提供服务
  • Cluster: 由多个 Member 构成的, 遵循 Raft 一致性协议的 etcd 集群
  • Peer: 对同一个 etcd 集群中另外一个 Member 的叫法
  • Client: 凡是连接 etcd 服务器请求服务的, 比如获取 key-value、写数据等等, 都统称为 Client; 所以 etcd 命令行连接工具、编写的连接 etcd 服务的代码对应的进程都是Client
  • Proposal: 一个需要经过 Raft 一致性协议的请求, 例如写请求或配置更新请求
  • Quorum: Raft 协议需要的、能够修改集群状态的、活跃的 etcd 集群成员数量称为 Quorum(法定人数), 通俗地讲就是 etcd 集群成员的半数以上。etcd 使用仲裁机制, 若集群中存在 n 个节点, 那么集群中有 (n+l)/2 个节点达成一致, 则操作成功。建议的最优节点数 3、5、7、9等奇数个, 大多数用户场景中, 一个包含 7 个节点的集群是足够的。更多的节点(比如 9, 11等)可以最大限度地保证数据安全, 但是写性能会受影响, 因为需要向更多的集群写入数据
  • WAL: 预写式日志, etcd 用于持久化存储的日志格式
  • Snapshot: etcd 集群在某一时间点的快照(备份), etcd 为防止 WAL 文件过多而设置的快照, 用于存储 etcd 的数据状态
  • Proxy: etcd 的一种模式, 为 etcd 集群提供反向代理服务
  • Leader: Raft 算法中通过竞选而产生的处理所有数据提交的节点
  • Follower: 竞选失败的节点作为 Raft 中的从属节点, 为算法提供强一致性保证
  • Candidate: Follower 超过一定的时间还接收不到 Leader 的心跳时, 会转变为 Candidate 开始竞选
  • Term: 某个节点从成为 Leader 到下一次竞选的时间, 称为一个 Term
  • Index: WAL 日志数据项编号, Raft 中通过 Term 和 Index 来定位数据
  • Key: 键
  • Key space: 键空间, etcd 集群内所有键的集合
  • Revision: etcd 集群范围内 64 位的计数器, 键空间的每次修改都会导致该计数器的增加
  • Modification Revision: 一个 key 最后一次修改的 revision
  • Lease: 一个短时的(会过期), 可续订的契约(租约), 当它过期时, 就会删除与之关联的所有键
  • Transition: 事务, 一个自动执行的操作集, 要么一块成功, 要么一块失败
  • Watcher: 观察者, etcd 最具特色的概念之一; 客户端通过打开一个观察者来获取一个给定键范围的更新
  • Key Range: 键范围, 一个键的集合, 这个集合既可以是一个 key、也可以是在一个字典区间, 例如(a, b], 或者是大于某个 key 的所有 key
  • Endpoint: 指向 etcd 服务或资源的 URL
  • Compaction: etcd 的压缩(Compaction)操作, 丢弃所有 etcd 的历史数据并且取代一个给定 revision 之前的所有 key; 压缩操作通常用于重新声明 etcd 后端数据库的存储空间; 其与 Raft 的日志压缩是一个原理
  • key version: 键版本, 即一个键从创建开始的写(修改)次数, 从 1 开始; 一个不存在或已删除的键版本是 0, 注意 key version 和 revision 的概念不同

etcd 发展里程碑

etcd 已经进化到 3.x 版本了,发展到现在总共有 3 个可以成为里程碑的版本,分别是 0.4、2.0、3.0,我们分别介绍一下。

etcd 0.4

etcd 0.4 版本是 etcd 对外发布的第一个稳定版本,很多特性均在这个版本成型。比如以下几个特性:

  • 使用 Raft 算法做分布式协同
  • HTTP + JSON 的 API
  • 使用 SSL 客户端证书验证
  • 基准测试在每个实例中每秒写入 1000 次等等

etcd 2.0

etcd 2.0 版本是 etcd 第一个真正意义上的大版本,其引入了如下几个重要特性:

  • 内部 etcd 协议的优化能够有效避免意外的错误配置
  • etcdctl 增加了 backup 子命令, 便于从集群异常中恢复数据
  • 运行时动态更新集群 member 配置, 通过 etcdctl 客户端的 member 子命令: etcd member list/add/remove 可以动态查看集群信息和调整集群大小
  • 通过 CRC校验 和append-only的行为提高了存盘数据的安全性
  • 优化的 Raft 一致性算法实现, 该实现被其他项目, 例如 CockroachDB 引用
  • etcd 的 TCP 2379/2380 端口正式成为 IANA(The Internet Assigned Numbers Authority, 互联网数字分配机构)官方分配的端口; 变得更有名了, 比如提到3306你会想到MySQL、提到6379你会想到Redis

etcd 3.0

etcd 3.0 版本在 etcd 2.0 的基础上引入了多处优化,可以说是万众瞩目,千呼万唤始出来,并且一经发布即引起了巨大的轰动。优化内容具体如下:

  • 提升了整体吞吐率、降低了延迟, 通过 gRPC API 降低了 Raft 协议调用的开销, 提高了 WAL 的磁盘利用率
  • 全新的存储后端带来了每个 key 平均内存开销的减少
  • 自动的 TLS 配置(可能需要用户提供 ca 证书)
  • 扁平的二进制键空间, 摒弃了 v2 的 key-value 层级和目录
  • 全新的 v3 API, 支持基于 key 为前缀和范围的 get/watch
  • 多版本的键空间, 允许访问历史版本的 key
  • 事务, 将对 etcd 服务的多个请求合并成一个操作
  • 租约, 允许一组 key 共享一个 TTL
  • 监控/告警, 通过存储配额保护 etcd 免受偶然发生的超额使用

以上就是 etcd 的几个著名的版本,如果想要更加详细地了解 etcd 的变迁,可以查看 etcd 发布的各个版本的 CHANGELOG。

etcd 初体验

在对 etcd 有一个大致的了解之后,我们就来安装并使用 etcd 了。

安装 etcd

下面我们来看一下 etcd 的安装,首先操作系统是 CentOS7,而且我们这里要搭建三个节点的集群。这里采用我阿里云上的三台 centOS7,IP和配置分别如下:

  • 47.94.174.89: 2核心8GB内存; 主机名: kano;
  • 47.93.39.238: 2核心4GB内存; 主机名: matsuri;
  • 39.105.52.92: 2核心4GB内存; 主机名: aqua;

然后是安装,etcd的安装方式非常非常简单,直接去 https://github.com/etcd-io/etcd/releases 下载对应系统的 etcd 即可,然后解压之后就可以直接用了。但是 etcd 可以采用 yum 进行安装,因此我们这里就使用 yum  来安装,因为后面可以直接通过 systemctl 控制启停。方式也很简单,直接 yum install etcd -y 即可。

此时三个节点都已经安装完毕了,版本是 3.3.11。然后我们来看一下 etcd 的配置文件。首先etcd的配置文件位于 /etc/etcd/etcd.conf 中,配置文件总共69行,我们直接原封不动地贴出来吧。

#[Member]
#ETCD_CORS=""
ETCD_DATA_DIR="/var/lib/etcd/default.etcd"
#ETCD_WAL_DIR=""
#ETCD_LISTEN_PEER_URLS="http://localhost:2380"
ETCD_LISTEN_CLIENT_URLS="http://localhost:2379"
#ETCD_MAX_SNAPSHOTS="5"
#ETCD_MAX_WALS="5"
ETCD_NAME="default"
#ETCD_SNAPSHOT_COUNT="100000"
#ETCD_HEARTBEAT_INTERVAL="100"
#ETCD_ELECTION_TIMEOUT="1000"
#ETCD_QUOTA_BACKEND_BYTES="0"
#ETCD_MAX_REQUEST_BYTES="1572864"
#ETCD_GRPC_KEEPALIVE_MIN_TIME="5s"
#ETCD_GRPC_KEEPALIVE_INTERVAL="2h0m0s"
#ETCD_GRPC_KEEPALIVE_TIMEOUT="20s"
#
#[Clustering]
#ETCD_INITIAL_ADVERTISE_PEER_URLS="http://localhost:2380"
ETCD_ADVERTISE_CLIENT_URLS="http://localhost:2379"
#ETCD_DISCOVERY=""
#ETCD_DISCOVERY_FALLBACK="proxy"
#ETCD_DISCOVERY_PROXY=""
#ETCD_DISCOVERY_SRV=""
#ETCD_INITIAL_CLUSTER="default=http://localhost:2380"
#ETCD_INITIAL_CLUSTER_TOKEN="etcd-cluster"
#ETCD_INITIAL_CLUSTER_STATE="new"
#ETCD_STRICT_RECONFIG_CHECK="true"
#ETCD_ENABLE_V2="true"
#
#[Proxy]
#ETCD_PROXY="off"
#ETCD_PROXY_FAILURE_WAIT="5000"
#ETCD_PROXY_REFRESH_INTERVAL="30000"
#ETCD_PROXY_DIAL_TIMEOUT="1000"
#ETCD_PROXY_WRITE_TIMEOUT="5000"
#ETCD_PROXY_READ_TIMEOUT="0"
#
#[Security]
#ETCD_CERT_FILE=""
#ETCD_KEY_FILE=""
#ETCD_CLIENT_CERT_AUTH="false"
#ETCD_TRUSTED_CA_FILE=""
#ETCD_AUTO_TLS="false"
#ETCD_PEER_CERT_FILE=""
#ETCD_PEER_KEY_FILE=""
#ETCD_PEER_CLIENT_CERT_AUTH="false"
#ETCD_PEER_TRUSTED_CA_FILE=""
#ETCD_PEER_AUTO_TLS="false"
#
#[Logging]
#ETCD_DEBUG="false"
#ETCD_LOG_PACKAGE_LEVELS=""
#ETCD_LOG_OUTPUT="default"
#
#[Unsafe]
#ETCD_FORCE_NEW_CLUSTER="false"
#
#[Version]
#ETCD_VERSION="false"
#ETCD_AUTO_COMPACTION_RETENTION="0"
#
#[Profiling]
#ETCD_ENABLE_PPROF="false"
#ETCD_METRICS="basic"
#
#[Auth]
#ETCD_AUTH_TOKEN="simple"

可以看到里面绝大部分都是被注释掉了,我们来解释一下里面的一些选项。

  • ETCD_NAME: etcd的节点名;
  • ETCD_DATA_DIR: etcd的数据存储目录;
  • ETCD_SNAPSHOT_COUNTER: 多少次的事务提交将触发一次快照;
  • ETCD_HEARTBEAT_INTERVAL: etcd节点之间心跳传输的间隔, 单位毫秒;
  • ETCD_ELECTION_TIMEOUT: 该节点参与选举的最大超时时间,单位毫秒;
  • ETCD_LISTEN_PEER_URLS: 和其他节点通信时所使用的地址列表, 相当于告诉其它节点, 如果你想和我通信, 那么就用这里指定的地址; 当然也可以是多个地址, 使用逗号隔开即可; 地址的格式可以划分为scheme://IP:PORT,这里的scheme可以是http、https, 但是ip不可以是域名;
  • ETCD_LISTEN_CLIENT_URLS: 和ETCD_LISTEN_PEER_URLS类似, 但它是针对客户端的, 也就是对外提供服务的地址;
  • ETCD_INITIAL_ADVERTISE_PEER_URLS: 该成员节点在整个集群中的通信地址列表, 这个地址用来传输集群数据的地址, 因此这个地址必须可以连接集群中所有的成员;
  • ETCD_INITIAL_CLUSTER: 配置集群内部所有成员的地址, 其格式为 ETCD_NAME=ETCD_INITIAL_ADVERTISE_PEER_URLS,如果有多个使用逗号隔开;
  • ETCD_ADVERTISE_CLIENT_URLS: 广播给集群中其他成员自己的客户端地址列表;

下面就来编写配置文件了,这里我们只写需要的,没写的都是被注释掉的。

首先配置 kano 节点上的 etcd:

# 该etcd节点的名字, 这里叫做kano_etcd; 同理另外两个节点的ETCD_NAME我们会分别起名为matsuri_etcd和aqua_etcd
ETCD_NAME=kano_master_etcd

# etcd数据的存储目录, 直接采用默认的
ETCD_DATA_DIR="/var/lib/etcd/default.etcd"

# 节点之间通信默认使用2380端口, 这里我们也是用2380, 当然你也可以改成别的; IP需要写成0.0.0.0
ETCD_LISTEN_PEER_URLS="http://0.0.0.0:2380"

# 客户端来访问的话, 端口默认2379, IP写成0.0.0.0
ETCD_LISTEN_CLIENT_URLS="http://0.0.0.0:2379"

# 配置成所在节点即可, 但是IP写对外的IP
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://47.94.174.89:2380"

# 指定etcd集群内部所有成员的地址, 我们总共有三台节点, 显然都要配置在这里面, IP使用对外的IP
ETCD_INITIAL_CLUSTER="kano_etcd=http://47.94.174.89:2380,matsuri_etcd=http://47.93.39.238:2380,aqua_etcd=http://39.105.52.92:2380"

# 本机和客户端通信的地址, IP为0.0.0.0
ETCD_ADVERTISE_CLIENT_URLS="http://0.0.0.0:2379"

然后配置 matsuri 节点上的etcd,和kano节点一样,只需要把ETCD_NAMEETCD_INITIAL_ADVERTISE_PEER_URLS改成对应的即可。

ETCD_NAME=matsuri_etcd
ETCD_DATA_DIR="/var/lib/etcd/default.etcd"
ETCD_LISTEN_PEER_URLS="http://0.0.0.0:2380"
ETCD_LISTEN_CLIENT_URLS="http://0.0.0.0:2379"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://47.93.39.238:2380"
ETCD_INITIAL_CLUSTER="kano_etcd=http://47.94.174.89:2380,matsuri_etcd=http://47.93.39.238:2380,aqua_etcd=http://39.105.52.92:2380"
ETCD_ADVERTISE_CLIENT_URLS="http://0.0.0.0:2379"

然后配置 aqua 节点上的etcd,估计不需要我说了。

ETCD_NAME=aqua_etcd
ETCD_DATA_DIR="/var/lib/etcd/default.etcd"
ETCD_LISTEN_PEER_URLS="http://0.0.0.0:2380"
ETCD_LISTEN_CLIENT_URLS="http://0.0.0.0:2379"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://39.105.52.92:2380"
ETCD_INITIAL_CLUSTER="kano_etcd=http://47.94.174.89:2380,matsuri_etcd=http://47.93.39.238:2380,aqua_etcd=http://39.105.52.92:2380"
ETCD_ADVERTISE_CLIENT_URLS="http://0.0.0.0:2379"

然后修改对应的配置文件即可。至此整个etcd集群就配置完了,其实也没有什么难度,主要还是修改配置文件,因为etcd天生就擅长集群。

但我还是要在吐槽一句,etcd的配置文件是真的恶心。

然后启动etcd:systemctl start etcd,启动之后查看etcd集群:etcdctl member list。可以看到etcd集群是可以正常启动的,并且 kano 主机是主节点。

etcdctl 常用命令行

我们可以使用 etcd 的命令行工具 etcdctl 和 etcd 服务端进行交互。默认情况下,etcdctl 使用 v2 的 API,如果需要使用 v3 的 API,则可以先设置以下环境变量。具体命令如下所示:

export ETCDCTL_API=3

然后我们来演示一下 etcdctl 的常用命令,这里我们使用的都是 v3 的API,v2 和 v3 在 API 方面差别还是蛮大的,如果你发现后面的命令不存在的话,那么你很可能是忘记指定 API 版本为 v3了。

key 的常规操作

1. 写入一个 key

所有存储的 key 都通过 Raft 协议被复制到 etcd 集群的所有节点上, Raft 协议保证了数据的一致性和可靠性。向一个 key 写入一个值,最简单的一条命令如下所示:

etcdctl put key value

[root@aqua ~]# etcdctl put name kagura_mea
OK
[root@aqua ~]# 

我们这里是在 aqua 主机上写入的,它不是主节点,但是会将请求交给主节点(Leader),如果是读请求就自己处理了,因为 Raft 协议保证所有节点的数据都是一致的。修改一个key的话还是使用put,直接设置新的value即可。另外如果设置的value包含特殊字符,比如空格、横杠等等,需要使用引号包起来。

如果需要为这个 key 设置一个老化时间,比如10分钟,那么可以通过为它绑定一个 "租约" 来实现:

# 申请一个 600s 的租约
[root@aqua ~]# etcdctl lease grant 600 
lease 1bdc76805eb02206 granted with TTL(600s)
[root@aqua ~]# etcdctl put name1 natsuiro_matsuri --lease=1bdc76805eb02206
OK
[root@aqua ~]# 

十分钟之后再读取这个 key,就会返回一个100错误,表示该 key 不存在。

租约后面会单独说。

2. 读取一个 key

为了更好观察,我们再写两个key吧。

[root@aqua ~]# etcdctl put name2 kagura_nana
OK
[root@aqua ~]# etcdctl put name3 shizuku_ruru
OK
[root@aqua ~]# 

然后我们来读取key的内容,可以使用如下命令:

etcdctl get key

# 会同时打印 key 和 value, 可以指定 --print-value-only 只打印 value
[root@aqua ~]# etcdctl get name
name
kagura_mea
[root@aqua ~]# etcdctl get name --print-value-only
kagura_mea
[root@aqua ~]# 

还可以打印指定范围的key:etcdctl get left_key right_key,注意区间是左闭右开,会按照字典序比较。

[root@aqua ~]# etcdctl get name name3
name
kagura_mea
name1
natsuiro_matsuri
name2
kagura_nana
[root@aqua ~]# etcdctl get name name3 --print-value-only
kagura_mea
natsuiro_matsuri
kagura_nana
[root@aqua ~]# 

遍历所有以 name 为前缀的 key,具体命令:etcdctl get name --prefix

[root@aqua ~]# etcdctl get name --prefix
name
kagura_mea
name1
natsuiro_matsuri
name2
kagura_nana
name3
shizuku_ruru
[root@aqua ~]# etcdctl get name --prefix --print-value-only
kagura_mea
natsuiro_matsuri
kagura_nana
shizuku_ruru
# 可以通过--limit限制返回数量
[root@aqua ~]# etcdctl get name --prefix --print-value-only --limit 2
kagura_mea
natsuiro_matsuri
[root@aqua ~]# 

3. 读取老版本的 key

etcd 支持客户端读取老版本的 key,原因是有些应用程序将 etcd 当作一个配置中心来使用,有读取之前版本 key 的需求。例如,一个应用可以利用这个特性回滚到较早的某个版本的配置,因为对 etcd 后端存储的每次修改都会增加 etcd 集群全局的版本号(revision),所以只需要提供指定的版本号就能读取相应版本的 key。

首先我们每执行一次put,全局版本号就会加 1,当前的集群版本号是 8,因为我做了几个测试。

# 首先集群版本是8
[root@aqua ~]# etcdctl get name
name
kagura_mea
# 将name给改掉, 使用put, 版本加1变成了9
[root@aqua ~]# etcdctl put name kagura_MEA
OK
# 默认读取最新版的key
[root@aqua ~]# etcdctl get name
name
kagura_MEA
# 指定集群版本, 我们看到又输出之前的内容了
[root@aqua ~]# etcdctl get name --rev=8
name
kagura_mea
# 因为9是最新版本, 所以是最新修改的值
[root@aqua ~]# etcdctl get name --rev=9
name
kagura_MEA
# 10的话还没有这个版本, 所以报错了
[root@aqua ~]# etcdctl get name --rev=10
Error: etcdserver: mvcc: required revision is a future revision
[root@aqua ~]# 

4. 按 key 的字典序来读取

当客户端希望读取大于或等于 key 的字节值时,可使用 --from-key 参数来实现。我们来添加以下键值对:

[root@aqua ~]# etcdctl put a 1
OK
[root@aqua ~]# etcdctl put b 2
OK
[root@aqua ~]# etcdctl put c 3
OK
[root@aqua ~]# etcdctl put d 4
OK

然后读取字典序大于等于 c 的记录:

[root@aqua ~]# etcdctl get c --from-key
c
3
d
4
name
kagura_MEA
name1
natsuiro_matsuri
name2
kagura_nana
name3
shizuku_ruru
[root@aqua ~]# etcdctl get c --from-key --print-value-only
3
4
kagura_MEA
natsuiro_matsuri
kagura_nana
shizuku_ruru
[root@aqua ~]# 

5. 删除key

用户可以删除 etcd 集群中的一个 key 或者 一个范围内的 key。

# 返回删除的个数
[root@aqua ~]# etcdctl del a
1
# 删除不存在的key也不会报错
[root@aqua ~]# etcdctl del a
0
# 删除指定范围的key, 按照字典序排序, 依旧是左闭右开
[root@aqua ~]# etcdctl del b d
2
# d确实没有被删除
[root@aqua ~]# etcdctl del d
1
# 删除key的同时返回key和value
[root@aqua ~]# etcdctl del name --prev-kv
1
name
kagura_MEA
[root@aqua ~]#

与 get 命令类似,del 命令也支持 --prefix 参数,删除以某个字符串为前缀的key;也支持 --from-key,删除字典序大于等于指定的key的所有key。

[root@aqua ~]# etcdctl del name3 --from-key
1
[root@aqua ~]# etcdctl del name --prefix --prev-kv 
2
name1
natsuiro_matsuri
name2
kagura_nana
[root@aqua ~]# 

key 的历史与 watch

etcd 具有观察(watch)机制,一旦某个 key 发生变化,客户端就能感知到变化。对应到 etcdctl 就是 watch 子命令,除非该子命令捕获到退出信号(例如,按 Ctrl+C 捷键就能向 etcdctl 发送强制退出信号量),否则会一直等待而不会退出,举个栗子:

[root@aqua ~]# etcdctl watch name

此时就卡在这个地方了,目前操作的主机是 aqua,下面我们在主机 kano 中更新 name 这个 key,注意:之前name这个key是被我们删掉了的。

[root@kano ~]# etcdctl put name hanser
OK
[root@kano ~]# etcdctl put name yousa
OK
[root@kano ~]# etcdctl del name
1
[root@kano ~]# etcdctl del name
0
[root@kano ~]# etcdctl del name
0
[root@kano ~]# 

再来观察之前的终端:

[root@aqua ~]# etcdctl watch name
PUT
name
hanser
PUT
name
yousa
DELETE
name

可以看到输出了 相关操作、key、value,并且多次delete只会输出一次。当然除了使用 kano 主机外,我们还可以新启一个 aqua 主机的终端,效果是一样的,只不过既然是集群就要有集群的样子,不能只用一个节点对吧。而且 kano 主机才是Leader,虽然我们是在主机aqua上写的,但其实这些请求都会交给 kano 主机,因为它是Leader。

除此之外还可以监视指定范围的key:etcdctl watch left_key right_key,凡是字典序位于该范围内的key都会被监视,注意:区间依旧是左闭右开;也可以监视以某个字符串为前缀的key,etcdctl watch key --prefix string。

以上可以自己尝试,相信此时对于你是不难的。

watch 子命令还支持交互(interactive)模式,使用 -i 选项可 watch 多个 key ,具体命令如下所示:

# 回车之后直接输入即可
[root@aqua ~]# etcdctl watch -i
watch a
watch b

这里使用主机matsuri。

[root@matsuri ~]# etcdctl put a 1
OK
[root@matsuri ~]# etcdctl put b 2
OK
[root@matsuri ~]# 

回到之前的终端。

[root@aqua ~]# etcdctl watch -i
watch a
watch b
PUT
a
1
PUT
b
2

1. 从某个版本号开始观察

watch除了监视一个key之外,还可以监视这个key的所有版本的变化,这个功能非常有用。例如,一个应用可能希望得到某个 key 所有变化的通知,如果它一直与 etcd 保持连接则没问题,但如果这个应用挂起了,而某个 key 又恰巧在这个时候发生了变化,那么这个应用会有很大的可能性没法及时接收到这个 key 的更新。为了保证 key 的变化不丢失,etcd 支持客户端能够在任意时刻观察该 key 的所有变化。

# name我们之前就删掉了
[root@aqua ~]# etcdctl del name
0
# 获取不到
[root@aqua ~]# etcdctl get name
# 但是可以获取之前版本的name
[root@aqua ~]# etcdctl get name --rev=3
name
kagura_mea
# 从rev=3的版本开始监视, 我们看到返回了每一个版本的信息
[root@aqua ~]# etcdctl watch name --rev=3
PUT
name
kagura_mea
PUT
name
kagura_MEA
DELETE
name

PUT
name
hanser
PUT
name
yousa
DELETE
name

watch监视key的时候也可以指定--prev-kv参数,会返回该key修改前最近一个版本的value,可以自己尝试一下。

2. 压缩 key 版本

为了让客户端能够访问 key 过去任意版本的 value,etcd 会一直保存 key 所有历史的版本的 value。然而,etcd 所占的磁盘空间不能无限膨胀,因此需要为 etcd 配置压缩 key 版本号来释放磁盘空间,具体代码如下所示:

# 释放版本号为5之前的所以数据
[root@aqua ~]# etcdctl compact 5
compacted revision 5
# 能够获取
[root@aqua ~]# etcdctl get name --rev=5
name
kagura_mea
# 版本号为4的获取不到了
[root@aqua ~]# etcdctl get name --rev=4
Error: etcdserver: mvcc: required revision has been compacted
[root@aqua ~]#

在压缩 key 版本之前,用户需要认真权衡,因为压缩后指定版本之前的所有 key/value 都将不可用。用户可以通过 get 一个 key(不论存在与否均可)来获取当前 etcd 服务端的版本号 ,比如:

[root@aqua ~]# etcdctl get key -w=json
{"header":{"cluster_id":788639929760900286,"member_id":2256447048954485724,"revision":28,"raft_term":3}}
[root@aqua ~]# 

通过上述代码我们可以看到,最新版本号是28。

租约

租约是 etcd v3 API 的特性,客户端可以为 key 授予租约(lease)。当一个 key 绑定一个租约时,它的生命周期便会与该租约的 TTL (time to live)保持一致,每个租约都有一个由用户授予的最 小 TTL 值,而租约的实际 TTL 值至少等于用户授予的 TTL 值,但事实上,它很有可能会大于该值,这一切都由 etcd 来决定。 如果某个租约的 TTL 超时了,那么该租约就会过期而且上面绑定的所有 key 都会被自动删除。下面演示一下如何为一个租约授予一个 TTL,以及如何为该租约绑定一个 key:

# 申请一个时限为 20秒 的租约
[root@aqua]# etcdctl lease grant 20
lease 686376805e9bda1e granted with TTL(20s)
# 为该租约绑定一个 key
[root@aqua]# etcdctl put foo bar --lease=686376805e9bda1e
OK
# 获取值
[root@aqua]# etcdctl get foo
foo
bar
# 再次获取, 由于租约已经过期, 绑定在是上面的key也就被删除了
[root@aqua]# etcdctl get foo
[root@aqua]# etcdctl get foo
[root@aqua]# 
# 并且一旦过期, 此租约也就不能再次使用了
[root@aqua]# etcdctl put foo bar --lease=686376805e9bda1e
Error: etcdserver: requested lease not found
[root@aqua]#

需要注意的是:租约一旦申请,那么计时就已经开始了,举个栗子。

[root@aqua]# etcdctl lease grant 5
lease 686376805e9bda26 granted with TTL(5s)
# 绑定的时候直接就过期了, 因为时间已经过去了
[root@aqua]# etcdctl put foo bar --lease=686376805e9bda26
Error: etcdserver: requested lease not found

1. 撤销租约

客户端既然能够授予租约,那么也能够撤销租约,下面介绍一下如何撤销租约:

# 申请一个时限为 3600秒 的租约
[root@aqua]# etcdctl lease grant 3600
lease 686376805e9bda2a granted with TTL(3600s)
# 创建一个key 绑定在租约上
[root@aqua]# etcdctl put age 20 --lease=686376805e9bda2a
OK
# 正常获取
[root@aqua]# etcdctl get age
age
20
# 但是这个租约我们不想用了, 可以将其取消, 租约取消之后等价于过期, 因此上面的key也会被删除
[root@aqua]# etcdctl lease revoke 686376805e9bda2a
lease 686376805e9bda2a revoked
# 再次获取发现没有了
[root@aqua]# etcdctl get age
[root@aqua]# 

但是存在一个问题,如果我要更新一个绑定在租约上的key该怎么做呢?

[root@aqua ~]# etcdctl lease grant 30
lease 1bdc76805eb0225f granted with TTL(30s)
# 绑定在租约上
[root@aqua ~]# etcdctl put name matsuri --lease=1bdc76805eb0225f
OK
# 更新name
[root@aqua ~]# etcdctl put name MATSURI
OK
[root@aqua ~]# etcdctl get name
name
MATSURI
# 30s 后再次获取, 发现还在
[root@aqua ~]# etcdctl get name
name
MATSURI
[root@aqua ~]

所以这种情况下等于创建了一个新的没有租约的name,如果想更新时还能使得租约生效,那么可以这么做。

[root@aqua ~]# etcdctl lease grant 40
lease 1bdc76805eb0226e granted with TTL(40s)
[root@aqua ~]# etcdctl put name kagura_mea --lease=1bdc76805eb0226e
OK
[root@aqua ~]# etcdctl get name
name
kagura_mea
# 更新的时候也指定租约就可以了, 因为租约是有时间限制的, 时间一到自动就过期了
[root@aqua ~]# etcdctl put name kagura_MEA --lease=1bdc76805eb0226e
OK
[root@aqua ~]# etcdctl get name
name
kagura_MEA
[root@aqua ~]# etcdctl get name
[root@aqua ~]#

如果租约上的某个key比较重要,我们能不能将其从租约上取消呢?就是希望在租约过期之后key还在,显然是可以的,刚才已经演示过了。先获取key对应的value,然后在不绑定租约的情况下重新设置即可。

2. 续租

客户端也能通过刷新 TTL 的方式为租约续租,使它不过期:

[root@aqua ~]# etcdctl lease keep-alive 1bdc76805eb0227f
lease 1bdc76805eb0227f keepalived with TTL(10)
lease 1bdc76805eb0227f keepalived with TTL(10)
lease 1bdc76805eb0227f keepalived with TTL(10)
lease 1bdc76805eb0227f keepalived with TTL(10)
.......

这个命令是阻塞的,每当块过期时就会续租,并且续租的 TTL 等于最初授予的值,显然这不常用。

3. 获取租约信息

用户可能想知道租约的详细信息,比如查看租约是否存在或过期,以及租期还剩下多长时间,或这查看绑定的所有 key。

# 生成一个时限为 200秒 的租约
[root@aqua ~]# etcdctl lease grant 200
lease 1bdc76805eb02281 granted with TTL(200s)
# 在上面绑定两个key
[root@aqua ~]# etcdctl put name1 hanser --lease=1bdc76805eb02281
OK
[root@aqua ~]# etcdctl put name2 yousa --lease=1bdc76805eb02281
OK
# 查看指定租约的剩余存活时间
[root@aqua ~]# etcdctl lease timetolive 1bdc76805eb02281
lease 1bdc76805eb02281 granted with TTL(200s), remaining(150s)
[root@aqua ~]# etcdctl lease timetolive 1bdc76805eb02281
lease 1bdc76805eb02281 granted with TTL(200s), remaining(143s)
# 加上 --keys 可以查看绑定在上面的key
[root@aqua ~]# etcdctl lease timetolive 1bdc76805eb02281 --keys
lease 1bdc76805eb02281 granted with TTL(200s), remaining(135s), attached keys([name1 name2])
[root@aqua ~]# 

etcd 常用配置参数

etcd 可以通过命令行选项 和 配置文件来设置启动参数,命令行参数 和 配置文件中的变量 之间的关系是:命令行参数由小写变成大写、横杠变成下滑线、再加上一个 ETCD_ 前缀即可得到配置文件中的变量,这条规则适用于所有的配置项。以我们之前的配置文件为例:

ETCD_NAME=aqua_etcd
ETCD_DATA_DIR="/var/lib/etcd/default.etcd"
ETCD_LISTEN_PEER_URLS="http://0.0.0.0:2380"
ETCD_LISTEN_CLIENT_URLS="http://0.0.0.0:2379"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://39.105.52.92:2380"
ETCD_INITIAL_CLUSTER="kano_etcd=http://47.94.174.89:2380,matsuri_etcd=http://47.93.39.238:2380,aqua_etcd=http://39.105.52.92:2380"
ETCD_ADVERTISE_CLIENT_URLS="http://0.0.0.0:2379"

如果要通过命令行启动的话,就可以这么做:

etcd --name aqua_etcd --data-dir /var/lib/etcd/default.etcd \
--listen-peer-urls http://0.0.0.0:2380 --listen-client-urls http://0.0.0.0:2379 \
--initial-advertise-peer-urls http://39.105.52.92:2380 \
--initial-cluster kano_etcd=http://47.94.174.89:2380,matsuri_etcd=http://47.93.39.238:2380,aqua_etcd=http://39.105.52.92:2380 \
--advertise-client-urls http://0.0.0.0:2379

这两者之间是完全等价的,只不过方式不同。但如果我们就是希望通过配置文件的方式来启动呢?之前是通过 systemctl 的方式,因为 yum 安装之后注册成服务了。但如果我们是直接下载的安装包呢?这种方式也很常见,这个时候指定配置文件启动的话,可以通过:etcd --config-file 配置文件路径 的方式启动。

下面我们解释一下配置文件里面的每一个变量(命令行参数),之前介绍了一部分,下面再详细地从头介绍一遍。首先配置文件里面分为好几个块,分别是:Member、Clustering等等,我们一块一块说。

member 相关参数项:

clustering 相关参数项:

proxy 相关参数项:

仅支持 v2 版本,暂且不表。

安全 相关参数项:

其它 相关参数项:

这里面绝大部分参数我们都是用不到的,最重要的参数是我们安装 etcd 的时候说的。

从 etcd v2 到 etcd v3

etcd v3 于 2016年6月30日 正式发布,该版本标志着 etcd v3 数据模型和 API 正式稳定。etcd v3 存储的数据通过 KV API 对外暴露,并在 API 的层级支持 mini 事务。而为了保证向后的兼容性,etcd v3 依然保留了 etcd v2 的协议和 API,同时又提供了一套 v3 的 API。也就是说 etcd v2 和 etcd v3 本质上是共享同一套 Raft 协议代码的两个独立应用,它们的区别在于 API 不同,存储不同,数据互相隔离。如果从 etcd v2 升级到 etcd v3,那么原来 v2 的数据还是只能用 v2 的API来访问,通过 v3 API 创建的数据也只能通过 v3 的接口来访问,这意味着将 etcd 集群从 v2 升级到 v3 对客户端来讲是透明的。

etcd v3 吸收了 etcd v2 的很多经验,同时又根据 etcd v2 在实际应用中遇到的问题进行了很多重要的改进,尤其是在效率、可靠性,以及性能上进行了各种优化。

etcd 原本的定位就是解决分布式系统的协调问题,现在 etcd 已经广泛应用于分布式网络、服务发现、配置共享、分布式系统调度和负载均衡等领域。etcd v2 的大部分设计和决策已在实践中证明是非常正确的:专注于 key-value 存储而不是一个完整的数据库,通过 HTTP+JSON 的方式暴露给外部 API,观察者(watch)机制提供持续监听某个 key 变化的功能,以及基于 TTL 的 key 自动过期机制等。这些特性和设计很好地满足了 etcd 的初步需求。

然而,在实际使用过程中我们也发现了一些问题。比如,客户端需要频繁地与服务端进行通信,集群即使在空闲时间也要承受较大的压力,以及垃圾回收 key 的时间不稳定等。另外,虽然 etcd v2 可以基本满足分布式协调的功能, 但是当今的 "微服务" 架构要求 etcd 能够单集群支撑更大规模的并发。

鉴于以上问题和需求,etcd v3 充分借鉴了 etcd v2 的经验,吸收了 etcd v2 的教训,做出了如下改进和优化。

  • 使用 gRPC+protobuf 取代 HTTP+JSON 通信, 提高通信效率; 另外通过 gRPC gateway 来继续保持对 HTTP JSON 接口的支持;
  • 使用更轻量级的基于租约(lease)的 key 自动过期机制, 取代了基于 TTL key 的自动过期机制;
  • 观察者(watcher)机制也进行了重新设计; etcd v2 的观察者机制是基于 HTTP 长连接的事件驱动机制; 而 etcd v3 的观察者机制是基于 HTTP/2 的 server push, 并且对事件进行了多路复用(multiplexing)优化;
  • etcd v3 的数据模型也发生了较大的改变, etcd v2 是一个简单的 key-value 的内存数据库, 而 etcd v3 则是支持事务和多版本并发控制的磁盘数据库; etcd v2 数据不直接落盘, 落盘的日志和快照文件只是数据的中间格式而非最终形式, 系统通过回放日志文件来构建数据的最终形态; etcd v3 落盘的是数据的最终形态, 日志和快照的主要作用是进行分布式的复制;

下面来解释一下这些特性,以及 v2 和 v3 的对比。

gRPC

gRPC 是 Google 开源的一个高性能、跨语言的 RPC 框架,基于 HTTP/2 协议实现。它使用 protobuf 作为序列化和反序列化协议,即基于 protobuf 来声明数据模型和 RPC 接口服务。

序列化和反序列化优化

protobuf 的效率很高,远高于 JSON。尽管 etcd v2 的客户端已经对 JSON 的序列化和反序列化进行了大量的优化,但是 etcd v3 的 gRPC 序列化和反序列化的速度依旧是 etcd v2 的两倍多。

减少 TCP 连接

etcd v2 的通信协议使用的是 HTTP/1.1,而 gRPC 支持 HTTP/2,HTTP/2 对 HTTP 通信进行了多路复用,可以共享一个 TCP 连接。因此 etcd v3 大大减少了客户端与服务器端的连接数,一个客户端只需要与服务器端建立一个 TCP 连接即可。而对于 etcd v2 来说,一个客户端需要与服务器端建立多个 TCP 连接,每个 HTTP 请求都需要建立一个连接。

租约机制

etcd v2 的 key 自动过期机制是基于 TTL 的:客户端可以为一个 key 设置自动过期时间,一旦 TTL 到了,服务端就会自动删除该 key,如果客户端不想服务器端删除某个 key,就需要定期去更新这个 key 的 TTL。也就是说,即使整个集群都处于空闲状态,也会有很多客户端需要与服务器端进行定期通信,以保证某个 key 不被自动删除。而且 TTL 是设置在 key 上的,那么对于客户端来说,想保留的N 个 key,那么需要对 N 个 key 都进行定期更新,即使这些 key 过期时间是一样的。

etcd v3 使用租约(lease)机制,替代了基于 TTL 的自动过期机制。用户可以创建一个租约,然后将这个租约与 key 关联起来。一旦一个租约过期,etcd v3 服务器端就会删除与这个租约关联的所有的 key。也就是说,如果多个 key 的过期时间是一样的,那么这些 key 就可以共享一个租约,这就大大减小了客户端请求的数量。对于过期时间相同,共享了一个租约的所有 key,客户端只需要更新这个租约的过期时间即可,而不是像 etcd v2 那样更新所有 key 的过期时间。

etcd v3 的观察者模式

观察者机制使得客户端可以监控一个 key 的变化,当 key 发生变化时,服务器端将通知客户端,而不是让客户端定期向服务器端发送请求去轮询 key 的变化。这一点不像 zookeeper 和 consul,对于每个 watch 请求(实现上是 HTTP GET 请求)只返回一个事件,如果客户端想要继续 watch 之前的 key,就只能再发送一次 watch 请求。而在两次 watch 请求之间,如果 key 发生了变更,那么客户端就会感知不到。etcd 从设计之初就想解决这个问题,支持客户端连续不断地接收所监控的 key 更新事件。

etcd v2 通过索引的方式支持连续 watch,客户端每次 watch 都可以带上之前的 key 的索引,然后服务端会返回比上一次 watch 更新的数据。然而,etcd v2 的服务端对每个客户端的每个 watch 请求都维持着一个 HTTP 长连接,如果数千个客户端 watch 了数千个 key ,那么 etcd v2 服务器端的 socket 和内存等资源很快就会被耗尽。

etcd v3 的改进方法是对来自于同一个客户端的 watch 请求进行了多路复用 (multiplexing),这样的话,同一个客户端只需要与服务器端维护一个 TCP 连接即可,这就大大减轻了服务器端的压力。

etcd v3 的数据存储模型

etcd v2 是一个 key-value 数据库,etcd v2 只保存了 key 的最新 value,之前的 value 直接被覆盖了 。但是有的应用需要知道一个 key 的所有 value 的历史变更记录,因此时 etcd v2 维护了一个全局的 key 的历史记录变更窗口,默认保存最新的 1000 个变更,而且这 1000 个变更不是某一个 key 的,而是整个数据库全局的历史变更记录。由于 etcd v2 最多只能保存 1000 个历史变更,因此在很短的时间内如果有频繁的写操作的话,那么变更记录会很快超过 1000;如果 watch 过慢就会无法得到之前的变更,带来的后果就是 watch 丢失事件。etcd v3 为了支持多纪录,抛弃了这种不稳定的 "滑动窗口" 式的设计,通过引入MVCC(多版本并发控制),采用了从历史记录为主索引的存储结构,保存了 key 的所有历史变更记录。etcd v3 可以存储上十万个纪录进行快速查询,并且支持根据用户的要求进行压缩合并。

多版本键值可以减轻用户设计分布式系统的难度,通过对多版本的控制,用户可以获得一个一致的键值空间的快照。用户可以在无锁的状态下查询快照上的键值,从而帮助做出下一步决定。

客户端在 GET 一个 key 的 value 时,可以指定一个版本号,服务器端会返回紧接着这个版本之后的 value。这样的话,有需要的应用就可以知道 key 的所有历史变更记录。客户端也可以指定版本号进行 watch,服务端会连续不断地把该版本号之后的变更都通知给客户端。

etcd v3 除了保存 key 的所有历史变更记录之外,它还在存储的实现上摒弃了 etcd v2 的目录式层级化设计,采用一个扁平化的设计。这是因为有的应用会针对单个 key 进行操作,而有的应用则会递归地对一个目录下的所有 key 进行操作。在实现上,维护一个目录式的层级化存储会带来一些额外的开销,而扁平化的设计也可以支持用户的这些操作,同时还会更加轻量级。etcd v3 使用扁平化的设计,用一个线段树来支持范围查询 、前缀查询等。对目录的查询操作,在实现上其实是将目录看作是对相同前缀的 key 的查询操作。

由于etcd v3 实现了 MVCC,保存了每个 key-value pair 的历史版本,数据量大了很多,不能将整个数据库都放在内存里了。因此 etcd v3 摒弃了内存数据库,转为磁盘数据库,整个数据库都存储在磁盘上,底层的存储引擎使用的是 BoltDB。

etcd v3 的迷你事务

etcd v3 除了提供读写 API 以外,还提供组合 API ,即事务 API。

很多情况下,客户端需要同时去读或者写一个 key 或者很多个 key。提供同步原语来防止数据竞争是非常重要的,出于这个目的, etcd v2 提供了条件更新操作,即 CAS(Compare And-Swap)操作。客户端在对一个 key 进行写操 作的时候需要提供该 key 的版本号或当前值,服务器端会对其进行比较,如果服务器端的 key 值或者版本号已经更新了,那么 CAS 操作就会失败。但 CAS 操作只是针对单个 key 提供了简单的信号量和有限的原子操作,因此远远不能满足更加复杂的使用场景,尤其是当涉及多个 key 的变更操作时,比如分布式锁和事务处理。故而 etcd v3 引入了迷你事务(mini transaction)的概念。每个迷你事务都可以包含一系列的条件语句,只有在还有条件满足时事务才会执行成功。

迷你事务支持原子地比较多个键值并且操作多个键值,之前的 CAS 实际上是一个特殊的针对单个 key 的迷你事务。这里列举一个简单的例子:Tx(compare: A=1 && B=2, success: C=3, D=3, fail: C=0, D=0,当etcd收到这条事务请求时,etcd 会原子地判断 A 和 B 当前的值和期望的值。如果判断成功,则 C 和 D 的值都会被设置为 3。

快照

etcd v2 与其他类似的开源一致性系统一样,最多只能有数十万级别的 key。主要原因是一致性系统都采用了基于 log 的复制,而 log 不能无限增长,所以在某时刻系统需要做个完整的快照,并且将快照存储到磁盘中,在存储快照之后才能将之前的 log 丢弃。每次存储完整的快照是件非常没有效率的事情,而且对于一致性系统来说,设计增量快照以及传输同步大量数据都是非常烦琐的。etcd 通过对 Raft 和存储系统的重构,能够很好地支持增量快照和传输相对较大的快照,目前 etcd v3 可以存储百万到千万级别的 key。

大规模 watch

etcd v2 中的每个 Watcher 都会占用一个 TCP 资源和一个 goroutine 资源,大概要消耗 30 ~ 40 kb。etcd v3 通过减小每个 Watcher 带来的资源消耗来支持大规模的 watch。一方面, etcd 利用了 HTTP/2 的 TCP 连接多路复用,这样同一个客户端的不同 Watch 就可以共享同一个 TCP 连接了;另一方面,同一个用户的不同 Watcher 只消耗一个 goroutine,这样就再一次减轻了 etcd 服务器的资源消耗。

Python 操作etcd

然后我们来看看如何使用 Python 来操作 etcd,Python 操作 etcd 使用的是一个叫 etcd3 的模块,直接 pip install etcd3 即可。

我们随便举个栗子吧。

1. 写入一个 key

import etcd3
# 连接到主节点, 当然连接到其它节点也是可以的
client = etcd3.Etcd3Client(host="47.94.174.89")
# 注意: value需要传入字节
print(client.put("name", bytes("神乐七奈", encoding="utf-8")))
"""
header {
  cluster_id: 788639929760900286
  member_id: 12186326826460735587
  revision: 57
  raft_term: 3
}
"""

在Python中,直接调用put方法写入即可,并且我们看到这个方法还有返回值。会返回:集群id、在哪个member上写的(这个肯定是Leader)、集群版本、raft成员数量(说白了就是集群成员数量)

2. 读取一个 key

client = etcd3.Etcd3Client(host="47.94.174.89")
# 返回一个二元组, 但并不是我们想像的 key-value
result = client.get("name")

# 第一个元素是 value, 不过是字节形式, 需要转成字符串
print(str(result[0], encoding="utf-8"))  # 神乐七奈

# 第二个元素是 <class 'etcd3.client.KVMetadata'>, 具有如下属性
print(result[1].create_revision)  # 57
print(result[1].key)  # b'name'
print(result[1].lease_id)  # 0
print(result[1].mod_revision)  # 57

读取指定范围的key

client = etcd3.Etcd3Client(host="47.94.174.89")
# 再写几个
client.put("name1", bytes("神乐mea", encoding="utf-8"))
client.put("name2", bytes("夏色祭", encoding="utf-8"))
client.put("name3", bytes("亚绮罗森", encoding="utf-8"))

# 读取指定范围的key, 返回一个生成器
result = client.get_range("name", "name2")
for item in result:
    print(str(item[0], encoding="utf-8"), str(item[1].key, encoding="utf-8"))
    """
    神乐七奈 name
    神乐mea name1
    """

读取以某个字符串为前缀的key

client = etcd3.Etcd3Client(host="47.94.174.89")

# 同样返回一个生成器
result = client.get_prefix("name")
for item in result:
    print(str(item[0], encoding="utf-8"), str(item[1].key, encoding="utf-8"))
    """
    神乐七奈 name
    神乐mea name1
    夏色祭 name2
    亚绮罗森 name3

3. 租约

client = etcd3.Etcd3Client(host="47.94.174.89")

# 创建一个租约
lease = client.lease(60)
# 创建key绑定在租约上
client.put("age", b"28", lease=lease)
# 获取租约信息
print(client.get_lease_info(lease.id))
"""
header {
  cluster_id: 788639929760900286
  member_id: 12186326826460735587
  revision: 65
  raft_term: 3
}
ID: 7521986096354024026
TTL: 59
grantedTTL: 60
keys: "age"
"""

总体来说还是比较简单的,至于其它实现可以自己查看。

etcd 安全

etcd 安全是指安全模式下 etcd 的运行状态,下面我们来阐述一下访问安全和传输安全。访问安全包括用户的认证和授权,传输安全是指使用 SSL/TLS 来加密数据通道。

访问安全

用户权限功能是在 etcd 2.1 版本中新增加的功能,在 2.1 版本之前,etcd 是一个完全开放的系统,任何用户都可以通过 REST API 修改 etcd 存储的数据。etcd 2.1 版本中增加了用户(User)和角色(Role)的概念,引入了用户认证的功能,为了保持向后的兼容性和可升级性,etcd 的用户权限功能默认是关闭的。

用户 和 角色,很好理解,用户扮演的角色不同,那么相应的权限也不同。

无论数据信道是否经过加密(SSL/TLS,后面会讨论),etcd都支持安全认证以及权限管理。etcd 的权限管理借鉴了操作系统的权限管理思想,存在用户和角色两种权限管理方法。在操作系统中,默认存在一个超级管理员 root(需要你自己创建),拥有最高权限,其余所有的用户权限都派生自 root。

etcd 认证体系分为 User 和 Role,Role 被授予给 User,代表 User 拥有某种权利,至于权利有多大,则取决于 Role 到底是什么角色,是超级管理员、普通用户,还是其它的什么。而etcd的认证体系中有一个特殊的用户和角色,那就是root。

root用户用于对 etcd 访问的全部权限,并且必须在启动认证之前预先创建。而设置root用户的初衷是为了方便管理:管理角色 和 普通用户,root用户必须是root角色。

root角色可以授予任何用户,一旦某个用户被授予了root角色,它就拥有了全局的读写权限以及修改集群认证配置的权限。一般情况下,root角色所赋予的特权用于集群维护,例如修改集群member关系,存储碎片整理,做数据快照等。

而 etcd 包含三种类型的资源,具体如下:

  • 权限资源(permission resources): 表示用户和角色信息;
  • 键值资源(key-value resources): 表示键值对数据信息;
  • 配置资源(settings resources): 安全配置信息、权限配置信息和etcd集群动态配置信息(选举/心跳等等);

权限资源

1. User

User(用户)是一个被授予权限的身份,每一个用户都可以拥有多个角色(Role),用户操作资源的权限(例如读资源或写资源)是根据用户所具有的角色来确定的,而用户分为 root 用户和非 root 用户。

root用户是 etcd 提供的一个特殊用户,在安全功能被激活之前必须创建 root 用户,否则会无法启动身份认证功能。root 用户具有 root 角色功能并允许对 etcd 内部进行任何操作。root 用户的主要目的是为了进行恢复:会生成一个密码并存储在某个地方,并且被授予 root 角色来承担系统管理员的功能。root 用户在我们对etcd集群进行故障排除和恢复时非常有用。

2. Role

Role(角色)用来关联权限,etcd 中每个角色都具有相应的权限列表,这个权限列表定义了角色对键值资源的访问权限。

root角色具有对所有键值资源的完整权限,而且只有root角色具有管理用户资源和配置资源的权限(例如:修改etcd集群的成员信息)。root角色是内置的,不需要被创建而且不能被修改,但是可以授予任何用户相同的权限。也就是说 root 既是一个用户也是一个角色,其它的用户也可以具有root角色,那么一旦具备root角色的话和root用户就是差不多等价的了。

3. 租约

etcd 提供了两种类型的权限(permission):读和写,对权限的所有管理和设置都需要通过具有root角色的用户来实现。权限列表是一个许可的特定权限的列表,后面会说。

键值资源

键值资源是指存储在 etcd 中的键值对信息,给定一个用于匹配的模式(pattern)列表,当用户请求的 key 值匹配到模式列表中的某项时,相应的权限便会被授予。

配置资源

配置资源存放着整个集群的特定配置信息,包括添加 / 删除的集群成员、启动 / 禁用认证功能、替换证书和其它由管理员(root角色持有者)维护的动态配置信息等。

etcd 访问控制实践

User 相关命令

可使用 etcdctl user 子命令来处理与用户相关的操作,比如:

1. 获取所有的 User

[root@kano ~]# etcdctl user list
[root@kano ~]# 

当前没有任何的User。

2. 创建一个 User

# 创建成功
[root@kano ~]# etcdctl user add mea
Password of mea: 
Type password of mea again for confirmation: 
User mea created
[root@kano ~]# 
[root@kano ~]# etcdctl user list
mea
[root@kano ~]#

3. 授予用户对应的 Role 和撤销用户所拥有的 Role(允许部分撤销)

# 给 mea 添加角色, 但是 super 显然不存在, 这里只是演示命令
[root@kano ~]# etcdctl user grant-role mea super
Error: etcdserver: role name not found
[root@kano ~]#
# 显然是失败的, 因为 super 不是一个角色, 也没有授予用户 mea
[root@kano ~]# etcdctl user revoke-role mea super
Error: etcdserver: role is not granted to the user
[root@kano ~]# 

4. 一个用户的详细信息可以通过下面的命令进行获取

# 角色为空
[root@kano ~]# etcdctl user get mea
User: mea
Roles:
[root@kano ~]# 

5. 修改密码

[root@kano ~]# etcdctl user passwd mea
Password of mea: 
Type password of mea again for confirmation: 
Password updated
[root@kano ~]# 

6. 删除用户

[root@kano ~]# etcdctl user delete mea
User mea deleted
[root@kano ~]#

Role 相关命令

与 User 子命令类似,Role 子命令可用来处理与角色相关的操作。可使用 etcdctl 子命令 etcdctl role 来为对应的 Role 角色指定相应的权限,然后将 Role 角色授予相应的 User,从而使 User 具有相应的权限。

1. 列出所有的 Role

[root@kano ~]# etcdctl role list
[root@kano ~]#

2. 创建一个 Role

[root@kano ~]# etcdctl role add common
Role common created
[root@kano ~]# 

一个角色没有密码,它定义了一组访问权限,etcd 里的角色被授予访问一个或一个范围内的key。这个范围可以由一个区间 [start-key, end-key],其中起始值 start-key 的字典序要小于结束值 end-key。

访问权限可以是读、写或者可读可写,Role 角色能够指定键空间下不同部分的访问权限,不过一次只能设置一个 path 或 一组path(使用前缀 + * 来表示,相当于以某个字符串为开头)的访问权限。

3. 授予对某个 key 只读权限

# 授予 name的只读权限
[root@kano ~]# etcdctl role grant-permission common read name
Role common updated
[root@kano ~]# 

4. 授予对一个范围的 key 只写权限

# 授予 a 开头的key的只写权限
[root@kano ~]# etcdctl role grant-permission common write a b
Role common updated
[root@kano ~]# 

5. 授予对一组 key 只写权限

# 授予 c 开头的key的可读可写权限, 需要加上--prefix
[root@kano ~]# etcdctl role grant-permission common readwrite c --prefix
Role common updated
[root@kano ~]# 

6. 查看一个角色具有的权限

[root@kano ~]# etcdctl role get common
Role common
KV Read:
	c*
	name
KV Write:
	[a, b) (prefix a)
	c*
[root@kano ~]# 

7. 收回一个角色的某个权限

# 收回对 c* 进行操作的权限, 这里不需要指定读或写, 显然是读写都收回
[root@kano ~]# etcdctl role revoke-permission common c*
Permission of key c* is revoked from role common
# 对 c* 进行操作的权限已经没了
[root@kano ~]# etcdctl role get common
Role common
KV Read:
	name
KV Write:
	[a, b) (prefix a)
[root@kano ~]# 
# 收回一个本来就没有权限操作的key会报错
[root@kano ~]# etcdctl role revoke-permission common d*
Error: etcdserver: permission is not granted to the role
[root@kano ~]# 
# 只有读或写一种权限也可以, 只要有权限, 在收回的时候就不会报错
[root@kano ~]# etcdctl role revoke-permission common name
Permission of key name is revoked from role common
[root@kano ~]# 
# 可以看到只剩下对 [a, b) 的写权限了
[root@kano ~]# etcdctl role get common
Role common
KV Read:
KV Write:
	[a, b) (prefix a)
[root@kano ~]# 

8. 移除某个角色

# 此时整个角色就被删除了
[root@kano ~]# etcdctl role delete common
Role common deleted
[root@kano ~]# etcdctl role get common
Error: etcdserver: role name not found
[root@kano ~]# 

启用用户权限功能

虽然我们介绍了权限相关,但是我们之前貌似并不需要权限就可以操作,这是因为没有开启权限。而开始权限可以通过 etcdctl auth 子命令开启。

1. 确认 root 用户已经创建

[root@kano ~]# etcdctl user list
[root@kano ~]# etcdctl user add root
Password of root: 
Type password of root again for confirmation: 
User root created
[root@kano ~]# 

2. 启用权限认证功能

# 此时认证就开启了
[root@kano ~]# etcdctl auth enable
Authentication Enabled
[root@kano ~]# 
# 也就不能随随便便地写了
[root@kano ~]# etcdctl put name nana
Error: etcdserver: user name is empty
[root@kano ~]# etcdctl get name
Error: etcdserver: user name is empty
# 如果想写的话, 需要指定用户, 然后会提示输入密码
[root@kano ~]# etcdctl put name nana --user="root"
Password: 
OK
# 也可以直接指定, 通过 user:password 方式
[root@kano ~]# etcdctl put age 16 --user="root:123456"
OK
# 读也是同理
[root@kano ~]# etcdctl get name --user="root"
Password: 
name
nana
# 直接指定密码
[root@kano ~]# etcdctl get age --user="root:123456"
age
16
[root@kano ~]# 

3. 关闭权限认证功能

[root@kano ~]# etcdctl auth disable
Error: etcdserver: user name not found
# 即便是关闭权限, 也依旧需要指定一个用户
[root@kano ~]# etcdctl auth disable --user="root:123456"
Authentication Disabled
[root@kano ~]# 

这个时候可能有人好奇了,要是没有用户怎么办?答案是如果没有用户,etcd是不会允许你开启认证的,我们举个栗子。

[root@kano ~]# etcdctl user delete root
User root deleted
[root@kano ~]# etcdctl auth enable
Error: etcdserver: root user does not exist

注意:我们说角色会被授予用户,而当我们开启认证的时候,会自动创建 root 角色并授予 root 用户。

# 此时 用户 和 角色 都没有
[root@kano ~]# etcdctl user list
[root@kano ~]# etcdctl role list
# 创建一个root, 否则无法开启认证
[root@kano ~]# etcdctl user add root
Password of root: 
Type password of root again for confirmation: 
User root created
[root@kano ~]# etcdctl user list
root
# 用户多了 root, 但是角色还不存在
[root@kano ~]# etcdctl role list
# 开启认证
[root@kano ~]# etcdctl auth enable
Authentication Enabled
# 发现root角色自动被创建了
[root@kano ~]# etcdctl role list --user="root:123456"
root

而我们说 root 用户 和 root 角色都是可以被删除的,但那是在没有开启认证的情况下,如果开启了认证呢?

# 此时再创建一个用户 mea
[root@kano ~]# etcdctl role add mea --user="root:123456"
Role mea created
# 用户 mea 是可以被删除的, 当然权限也可以
[root@kano ~]# etcdctl role delete mea --user="root:123456"
Role mea deleted
# 但是: root用户和root权限, 是无法被删除的
[root@kano ~]# etcdctl role delete root --user="root:123456"
Error: etcdserver: invalid auth management
[root@kano ~]# etcdctl user delete root --user="root:123456"
Error: etcdserver: invalid auth management
# 如果想删除, 那么需要先把认证给关掉
[root@kano ~]# etcdctl auth disable --user="root:123456"
Authentication Disabled
# 此时 root 就可以删除了
[root@kano ~]# etcdctl user delete root
User root deleted
# 但是这里还有一个容易忽略的地方, 如果我们想再次启动认证呢? 显然再创建一个 root 启动不就行了吗? 我们来试试
# 创建root
[root@kano ~]# etcdctl user add root
Password of root: 
Type password of root again for confirmation: 
User root created
# 开启认证, 但是报错了: 告诉我们角色已存在, 相信你肯定想到了
# 因为我们刚才只把 root用户 删掉了, 但是没有删 root角色, 而我们说开启认证的时候会自动创建 root 角色
# 但是 root角色已经存在了, 所以就报错了
[root@kano ~]# etcdctl auth enable
Error: etcdserver: role name already exists
# 此时认证是没有开启的
[root@kano ~]# etcdctl put name hanser
OK
[root@kano ~]# etcdctl get name
name
hanser
# 而解决办法也很简单, 直接把 root角色给删掉就可以了
[root@kano ~]# etcdctl role delete root
Role root deleted
# 此时成功开启认证
[root@kano ~]# etcdctl auth enable
Authentication Enabled
[root@kano ~]# 

4. 综合以上例子

# 创建一个普通用户, 由于开启了认证, 所以下面每一步都需要指定用户
[root@kano ~]# etcdctl role add common --user="root:123456"
Role common created
# 就是我们刚才演示的, 赋予对name的只读权限
[root@kano ~]# etcdctl role grant-permission common read name --user="root:123456"
Role common updated
# 赋予对[a, b)的只写权限
[root@kano ~]# etcdctl role grant-permission common write a b --user="root:123456"
Role common updated
# 赋予对c*的可读可写权限
[root@kano ~]# etcdctl role grant-permission common readwrite c --prefix --user="root:123456"
Role common updated
# 然后创建一个用户 mea
[root@kano ~]# etcdctl user add mea --user="root:123456"
Password of mea: 
Type password of mea again for confirmation: 
User mea created
# 以后让别人不使用root, 只能使用mea这个用户, 但是默认它是没有任何权限的
# 所以我们将角色common授予用户mea
[root@kano ~]# etcdctl user grant-role mea common --user="root:123456"
Role common is granted to user mea
# 那么以后通过 --user="mea:123456" 便可以操作指定的key了; 这里的密码是123456, 只是为了方便
# 如果 用户mea 还希望操作其它key, 则需要root再次赋予新的角色, 一个用户可以有多个角色, 或者更新common所具有的权限也是可以的
[root@kano ~]# 
# 我们看到写name这个key的时候, 被告知权限不够
[root@kano ~]# etcdctl put name nana --user="mea:123456"
Error: etcdserver: permission denied
# 我们用 root 写一下
[root@kano ~]# etcdctl put name nana --user="root:123456"
OK
# 虽然写不行, 但是读可以
[root@kano ~]# etcdctl get name --user="mea:123456"
name
nana
# [a, b)具有写权限
[root@kano ~]# etcdctl put aaaa bbbb --user="mea:123456"
OK
# 但是没有读权限
[root@kano ~]# etcdctl get aaaa --user="mea:123456"
Error: etcdserver: permission denied
[root@kano ~]# etcdctl get aaaa --user="root:123456"
aaaa
bbbb
[root@kano ~]#
# c开头的key是可读可写
[root@kano ~]# etcdctl put crystal krystal --user="mea:123456"
OK
[root@kano ~]# etcdctl get crystal --user="mea:123456"
crystal
krystal
[root@kano ~]# 

传输安全

etcd 支持 TLS 协议加密通信,TLS 通道既能用于加密 etcd 集群内部通信,也能加密客户端与服务端的通信。如果 etcd 服务启动时传入参数 client-cert-auth=true、或者在配置文件中配置了 ETCD_CLIENT_CERT_AUTH="true",那么客户端 TLS 证书的 CN 字段就能被用于标识一个 etcd 用户,默认使用该证书登录的用户即为权限管理系统中对应的用户,这样客户段就无须再输入密码来进行权限认证了。

etcd 的传输层安全模型使用了常见的非对称加密模型,其由公开密钥、私钥和证书三部分组成。通信的基础是公私钥以及证书系统,因此,我们将首先介绍生成公 / 私钥以及证书的过程,然后利用生成好的公私钥和证书配置 etcd 传输层的安全。

关于 etcd 的传输安全,这里就不说了,有兴趣可以自己去了解,直接简单回顾一下 TLS / SSL 的工作原理吧。

TLS/SSL 工作原理

最新版本的 TLS(Transport Layer Security,传输层安全协议)是 IETF (Internet Engi-neering Task Force,Internet 工程任务组)制定的一种新协议,TLS 建立在 SSL 3.0 协议规范之上,是 SSL 3.0 的后续版本。TLS 与 SSL3.0 之间的差异主要是它们所支持的加密算法不同,但其基本原理相同。因此,下面将以 SSL 为例进行介绍。

SSL 是一个安全协议,它为基于 TCP/IP 的通信应用程序提供了隐私与完整性。HTTPS 便是使用 SSL 来实现安全通信的,在客户端与服务器之间传输的数据是通过对称算法(如 DES、RC4)进行加密的。公用密钥算法(通常为 RSA)是用来获得加密密钥交换和数字签名,此算法使用服务器的 SSL 数字证书中的公用密钥。有了服务器的 SSL 数字证书,客户端便可以验证服务器的身份了。

SSL / TSL 认证分为单向认证和双向认证两种方式。SSL 协议的版本 1 和版本 2 只提供客户端对服务器的认证,即单向认证。版本 3 支持客户端和服务器端互相进行身份认证,即双向认证,此认证同时需要客户端和服务器端的数字证书。例如,我们登录淘宝买东西,为了防止登录的是假淘宝网站,浏览器会验证我们登录的网站是否为真的淘宝网站,而淘宝网站不关心我们是否 "合法",这就是单向认证。而双向认证则是服务器端也需要对客户端做出认证。

SSL 连接总是由客户端启动的,在 SSL 会话开始时会先进行 SSL 握手。客户端和服务器端的 SSL 握手流程如下图所示:

1 )客户端向服务器发送消息 "你好"(以客户端首选项顺序排序),消息中包含 SSL 的版本、客户端支持的密码对(加密套件)和客户端支持的数据压缩方法(哈希函数)等。此外,还包含 28 字节的随机数。

2 )服务器端以消息 "你好" 回应客户端,此消息包含密码方法(密码对)和由服务器选择的数据压缩方法,以及会话标识和另一个随机数。

客户端和服务器至少必须支持一个公共密码对,否则握手会失败,服务器一般选择最大的公共密码对。

3 )服务器端向客户端发送其 SSL 数字证书(服务器使用带有 SSL 的 X.509 V3 数字证书。如果服务器端需要通过数字证书与客户端进行认证,则客户端会发出 "数字证书请求" 的消息。在 "数字证书请求" 消息中,服务器端发出支持的客户端数字证书类型的列表和可接受的 CA 的名称。

4 )服务器端发出 "您好完成" 的消息并等待客户端响应。

5 )一接收到服务器的 "您好完成" 消息,客户端(Web 浏览器)就会验证服务器的 SSL 数字证书的有效性,并检查服务器的 "你好" 消息参数是否可以接受。如果服务器请求客户端数字证书,那么客户端将发送其数字证书;如果没有合适的数字证书是可用的,那么客户端将发送 "没有数字证书" 的警告。此警告仅仅是警告而己,但是如果客户端数字证书认证是强制性的话,那么服务器应用程序将会使会话失败。

6 )客户端发送 "客户端密钥交换" 消息,此消息包含 pre-master secret(一个用于对称加密密钥生成中的 46 字节的随机数字)和消息认证代码(MAC)密钥(用服务器的公用密钥加密)。如果客户端向服务器发送了数字证书,客户端将发出签有客户端的专用密钥的 "数字证书验证" 消息,通过验证此消息的签名,服务器可以显示验证客户端数字证书的所有权。

如果服务器没有属于数字证书的专用秘钥,它将无法解密 pre-master 密码,也无法创建对称加密算法的正确密钥,而且握手也将失败。

7 )客户端使用一系列的加密运算将 pre-master secret 转化为 master secret,其中将派生出所有用于加密和消息认证的密钥。然后,客户端将发出 "更改密码规范" 消息将服务器转换为新协商的密码对。客户端发出的下一个消息("未完成"的消息)为使用此密码方法和密钥加密的第一条消息。

8 )服务器以自己的 "更改密码规范" 和 "已完成" 消息进行响应。

9 )SSL 握手结束,并且可以发送加密的应用程序数据。

多版本并发控制

在数据库领域,并发控制是一个很具有挑战性的问题,常见的并发控制方式包括悲观并发控制、乐观并发控制和多版本并发控制。

在关系数据库管理系统中,悲观并发控制(又名 "悲观锁",Pessimistic Concurrency Control,PCC)是一种并发控制的方法,它能以阻止其它事务的方式来修改数据。如果事务执行的操作对某行数据应用了锁,那么只有在这个事务将锁释放之后,其他事务才能够执行与该锁冲突的操作。悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本。

乐观并发控制(又名 "乐观锁")也是一种并发控制的方法,它假设多用户并发的事务在处理时彼此之间不会互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务都会先检查在该事务读取数据之后,有没有其它事务又修改了该数据,如果其它事务有更新的话,那么正在提交的事务会进行回滚。

乐观并发控制多用于数据争用不大、冲突较少的环境,在这种环境中,偶尔回滚事务的成本会低于读取数据时锁定数据的成本,因此这种情况下乐观并发控制可以获得比其它并发控制方法更高的吞吐量。

多版本并发控制( Multiversion Concurrency Control,MVCC)并不是一个与乐观并发控制、悲观并发控制相对立的概念,它能够与两者很好地结合以增加事务的并发量,目前最流行的 SQL 数据库 MySQL  和 PostgreSQL 都对 MVCC 进行了实现。MVCC 的每一个写操作都会创建一个新版本的数据,读操作会从有限多个版本的数据中挑选一个 "最合适"(要么是最新版本,要么是指定版本) 的结果直接返回。通过这种方式,读写操作之间的冲突就不再需要受到关注。因此如何管理和高效地选取数据的版本就成了 MVCC 需要解决的主要问题。

为什么选择 MVCC

对一个系统进行各种优化时,相应的思路其实并不是凭空产生的,而是有方法论的,首先我们应该分析 etcd 的使用场景,然后才能进行针对性的优化。首先我们知道 etcd 的定位是一个分布式的、一致的 key-value 存储,主要用途是共享配置和服务发现,它不是一个类似于 ceph 那样存储海量数据的存储系统,也不是类似于 MySQL 这样的 SQL 数据库。它存储的其实是一些非常重要的元数据,当然,元数据的写操作其实是比较少的,但是会有很多的客户端同时 watch 这些元数据的变更。也就是说 etcd 的使用场景是一种 "读多写少" 的场景,etcd 里的一个 key ,其实并不会发生频繁的变更,但是一旦发生变更,etcd 就需要通知监控这个 key 的所有客户端。

因为同一时间可能会存在很多用户连接,那么这段时间一定会存在许多并发问题,比如数据竞争,这些并发问题必须得到解决。在这样的背景下, etcd 就必须保证并发操作产生的结果是安全的。etcd v2 是个纯内存数据库,整个数据库有一把 Stop-the-World 大锁,可以通过锁的机制来解决并发带来的数据竞争,但是通过锁的方式也有一些缺点,比如:

  • 锁的粒度不好控制, 每次操作 Stop-the-World 时都会锁住整个数据库;
  • 读锁和写锁会相互阻塞(block);
  • 如果使用基于锁的隔离机制, 并且有一段很长的读事务, 那么在这段时间内这个对象就会无法被改写, 后面的事务也会被阻塞, 直到这个事务完成为止; 这种机制对于并发性能来说影响很大;

多版本并发控制(Multi-Version Concurrency Control,MVCC)则以一种优雅的方式解决了锁带来的问题。在 MVCC 中,每当想要更改或者删除某个数据对象时,DBMS 不会在原地删除或修改这个已有的数据对象本身,而是针对该数据对象创建一个新的版本,这样一来,并发的读取操作仍然可以读取老版本的数据,而写操作就可以同时进行。这个模式的好处在于,可以让读取操作不再阻塞,事实上根本就不需要锁。这是种非常诱人的特性,以至于很多主流的数据库中都采用了 MVCC 的实现,比如 MySQL、PostgreSQL、Oracle、Microsoft SQL Server等等。

可能有人会有疑问,既然整个数据库使用一把 Stop-the-World 大锁会导致并发上不去,那么如果换成每个 key 一把锁是不是就可以了呢?MVCC 方案与这种一个 key 一把锁的方案相比又有什么优势呢?其实即使每个 key 一把锁,写锁也是会阻塞读锁的(写的时候不能读),而 MVCC 在写的时候也是可以并发读的,因为写是在最新的版本上进行写的,读却可以读老的版本(客户端读 key 的时候可以指定一个版本号,服务端保证能返回基于此版本号的新数据,而不是保证返回最新的数据)。

总而言之, MVCC 能最大化地实现高效的读写并发,尤其是高效的读,因此其非常适合 etcd 这种 "读多写少" 的场景。

etcd v2 存储机制实现

我们先来简单回顾一下 etcd v2 的存储和持久化机制,etcd v2 是一个纯内存数据库,写操作先通过 Raft 复制日志文件,复制成功后将数据写入内存,整个数据库在内存中是一个简单的树结构。etcd v2 并未实时地将数据写入磁盘, 持久化是靠快照来实现的,具体实现就是将整个内存中的数据复制一份出来,然后序列化成 JSON,写入磁盘中,成为一个快照。做快照的时候使用的是复制出来的数据库,客户端的读写请求依旧落在原始的数据库上,这样的话,做快照的操作才不会阻塞客户端的读写请求。

值得一提的是,将 etcd v2 整个内存数据库复制一份出来序列化到磁盘,并不会因为此操作而花费很多时间,也不会造成内存使用量的显著增加。具体原因可以自行搜索,这里不做展开讨论,我们只讨论 v3。

etcd v3 数据模型

etcd 旨在可靠地存储不经常更新的数据,并提供可靠的 watch 查询。etcd v3 和 etcd v2 不同的是,它支持暴露旧版本的键值对来支持高效的快照和 watch 历史事件(即所谓的 "时间旅行查询")。 一个持久化的,多版本并发控制的数据模型非常适合 etcd v3 的使用场景。因为如果仅仅维护一个 key 一个 value 的数据模型,那么连续的更新就只能保存最后一个 value ,历史版本无从追溯,而多版本则可以解决这个问题。

etcd v3 将数据存储在一个多版本的持久化 key-value 存储里面,值得注意的是,作为 key-value 存储的 etcd 会将数据存储在另一个 key-value 数据库中。当持久键值存储的值发生变化时,持久化键值存储将保存先前版本的键值对。etcd 后台的键值存储实际上是不可变的,etcd 操作不会就地更新结构,而是始终生成一个更新之后的结构,发生修改后, key 先前版本的所有值仍然可以访问和 watch。为了防止数据存储随着时间的推移无限期增长,并且为了维护旧版本,etcd 可能会压缩(删除)key 的旧版本数据。

逻辑视图

etcd v3 存储的逻辑视图是一个扁平的二进制键空间,该键空间对 key 有一个词法排序索引,因此范围查询的成本很低。

etcd 的键空间可维护多个 revision,每个原子的修改操作(例如,一个事务操作可能包含多个操作)都会在键空间上创建一个新的 revision,之前 revision 的所有数据均保持不变。旧版本(version)的 key 仍然可以通过之前的 revision 进行访问,同样,revision 也是被索引的,因此 Watcher 可以实现高效的范围 watch。revision 在 etcd 中可以起到逻辑时钟的作用,revision 在群集的生命周期内是单调递增的,如果因为要节省空间而压缩键空间,那么在此 revision 前的所有 revision 都将被删除,只保留该 revision 之后的。

我们将 key 的创建和删除过程称为一个生命周期,在 etcd 中,每个 key 都可能有多个生命周期,也就是说被创建、删除多次。创建一个新 key 时,如果在当前 revision 中该 key 不存在(即之前也没有创建过),那么它的 version 就会被设置成 1;删除 key 会生成一个 key 的墓碑,可通过将其 version 重置为 0 来结束 key 的当前生命周期。对 key 的每一次修改都会增加其 version,因此,key 的 version 在 key 的一次生命周期中是单调递增的。下面让我们来看 revision 和 version 在 etcd v3 中是如何实现的吧。

etcd v3 的请求响应的 header 数据结构具体如下所示:

而 etcd v3 的最核心的键值对数据结构的定义具体如下所示:

revison 是集群存储状态的版本号,存储状态的每一次更新(例如写、删除、事务等)都会让 revison 的值加 1。ResponseHeader.Revision 代表该请求成功执行之后 etcd 的 revision,KeyValue.CreateRevision 代表 etcd 的某个 key 最后一次创建时 etcd 的 revison,KeyValue.ModRevision 则代表 etcd 的某个 key 最后一次更新时 etcd 的 revison。verison 特指 etcd 键空间某个 key 从创建开始被修改的次数,即 KeyValue.Version,etcd v3 支持的 Get ( …, WithRev(rev)) 操作会获取 etcd 处于 rev 这个 revision 时的数据,就好像 etcd revision 还是 rev 的时候一样。

[root@kano ~]# etcdctl put foo bar
OK
# 输出信息比较多, 这里进行了筛选
# 我们看到此时 revision 和 mod_revision 都是 77
[root@kano ~]# etcdctl get foo -w=json
revision: 77
mod_revision: 77
version: 1

# 再重新添加以下
[root@kano ~]# etcdctl put foo bar
OK
# revision 和 mod_revision 都加 1 变成了 78, 而 version 加 1 变成了 2
[root@kano ~]# etcdctl get foo -w=json
revision: 78
mod_revision: 78
version: 2

# 然后我们设置一个新的键值对
[root@kano ~]# etcdctl put hello world
OK
# 集群版本号加 1, 但是原来的 mod_revision 还是78, version 还是原来的 2, 因为改的 key 不是 foo
[root@kano ~]# etcdctl get foo -w=json
revision: 79
mod_revision: 78
version: 2

# 显然 hello 这个 key 的 revision 和 mod_revision 都是 79, version 是 1
[root@kano ~]# etcdctl get hello -w=json
revision: 79
mod_revision: 79
version: 1

# 重新设置
[root@kano ~]# etcdctl put hello world
OK
# revision 和 mod_revision 都加 1 变成了 80, version 也加 1 变成了 2
[root@kano ~]# etcdctl get hello -w=json
revision: 80
mod_revision: 80
version: 2

# 但是原来的 foo 我们没有修改, 所以它的 mod_revision 还是 78, version 还是 2, 但是revision是最新的
[root@kano ~]# etcdctl get foo -w=json
revision: 80
mod_revision: 78
version: 2

简单地说,revision(包括 mod_revision )就像是时间,举个例子,你在 20: 00 (revision 78)创建了一个文件,然后在 21: 00(revision 79)读了这个文件,虽然当前时间已经是 21 : 00(revision 79),但这个文件的修改时间却是在 20: 00(revision 3)。

物理视图

etcd 将物理数据存储为一棵持久 B+ 树中的键值对,为了高效,每个 revision 的存储状态都只包含相对于之前 revision 的增量,一个 revision 可能对应于树中的多个 key。

B+ 树中键值对的 key 即 revision,revision 是一个二元组(main, sub),其中 main 是该 revision 的主版本号,sub 是同一 revision 的副版本号,其用于区分同一个 revision 的不同 key。B+ 树中键值对的 value 包含了相对于之前 revision 的修改,即相对于之前 revision 的一个增量。

B+ 树按 key 的字典字节序进行排序,这样 etcd v3 对 revision 增量的范围查询(range query,即从某个 revision 到另一个 revision)会很快。因为我们已经记录了从一个特定 revision 到其它 revision 的修改量,而且 etcd v3 的压缩操作会删除过时的键值对。

etcd v3 还在内存中维护了一个基于 B 树的二级索引来加快对 key 的范围查询,该 B 树索引的 key 是向用户暴露的 etcd v3 存储的 key ,而该 B 树索引的 value 则是一个指向上文讨论的持久化 B+ 树的增量的指针。而且 etcd v3 的压缩操作会删除指向 B 树索引的无效指针。

etcd v3 的 MVCC 的实现

etcd v2 的每个 key 只保留一个 value ,所以数据库并不大,可以直接放在内存中。但是 etcd v3 实现了 MVCC 以后,每个 key 的 value 都需要保存多个历史版本,这就极大地增加了存储的数据量,因此内存中就会存储不下这么多数据。对此,一个自然的解决方案就是将数据存储在磁盘里,etcd v3 当前使用 BoltDB 将数据存储在磁盘中。

BoltDB 是根据 Howard Chu 的 LMDB 项目开发的一个纯粹的 Go 语言版的 key/value 存储,它的目标是为项目提供一个简单、高效可靠的嵌入式的、可序列化的键 / 值数据库,而不是要求一个像 MySQL 那样完整的数据库服务器 。BoltDB 还是一个支持事务的键值存储, etcd 的事务就是基于 BoltDB 的事务实现的。

用作者的话说,BoltDB 只提供简单的 key / value 存储,没有其他的特性, 以后也不会有。因此 BoltDB 可以做到代码精简(小于 3KB),质量高,非常适合以 BoltDB 为基础在其之上构建更加复杂的数据库功能。由于 BoltDB 的设计适合 "读多写少" 的场景,因此其也非常适合于 etcd。

etcd 在 BoltDB 中存储的 key 是 reversion,value 是 etcd 自己的 key-value 组合,也就是说 etcd  会在 BoltDB 中保存每个版本,从而实现多版本机制。

为什么选择 BoltDB 作为底层的存储引擎

底层的存储引擎一般包含如下三大类的选择:

  • SQL Lite 等 SQL 数据库
  • LevelDB 和 RocksDB
  • LMDB 和 BoltDB

其中 SQL Lite 支持 ACID 事务,但是作为一个关系型数据库, SQL Lite 主要定位于提供高效灵活的 SQL 查询语句支持,可以支持复杂的联表查询等。而 etcd 只是一个简单的 KV 数据库,并不需要复杂的 SQL 支持。

LevelDB 和 RocksDB 分别是 Google 和 Facebook 开发的存储引擎,RocksDB 是在 LevelDB 的基础上针对 Flash 设备做了优化,其底层实现原理都是 log-structured merge-tree(LSM tree)。就是将有序的 key/value 存储在不同的文件中,并通过 "层级" 将它们分开,并且周期性地将小的文件合并为更大的文件,这样做就能把随机写转化为顺序写,从而提高随机写的性能,因此特别适合 "写多读少" 和 "随机写多" 的场景。同时需要注意的是,LevelDB 和 RocksDB 都不支持完整的 ACID 事务。

而 LMDB 和 BoltDB 则是基于 B 树和 mmap 的数据库,基本原理是用 mmap 将磁盘的 page 映射到内存的 page ,而操作系统则是通过 COW (copy-on-write) 技术进行 page 管理,通过 cow 技术,系统可实现无锁的读写并发,但是无法实现无锁的写写并发,这就注定了这类数据库读性能超高,但写性能一般,因此非常适合于 "读多写少" 的场景。同时 BoltDB 支持完全可序列化的 ACID 事务,因此最适合作为 etcd 的底层存储引擎。

posted @ 2018-08-08 21:55  古明地盆  阅读(10987)  评论(0编辑  收藏  举报