cloudinyachao 2020-03-23
从哪里说起呢?官网第一个页面,有那么一句话:
"A distributed, reliable key-value store for the most critical data of a distributed system"
即 etcd 是一个分布式、可靠 key-value 存储的分布式系统。当然,它不仅仅用于存储,还提供共享配置及服务发现。
提供配置共享和服务发现的系统比较多,其中最为大家熟知的是 Zookeeper,而 etcd 可以算得上是后起之秀了。在项目实现、一致性协议易理解性、运维、安全等多个维度上,etcd 相比 zookeeper 都占据优势。
本文选取 Zookeeper 作为典型代表与 etcd 进行比较,而不考虑 Consul 项目作为比较对象,原因为 Consul 的可靠性和稳定性还需要时间来验证(项目发起方自身服务并未使用Consul,自己都不用)。
...
etcd 比较多的应用场景是用于服务发现,服务发现 (Service Discovery) 要解决的是分布式系统中最常见的问题之一,即在同一个分布式集群中的进程或服务如何才能找到对方并建立连接。和 Zookeeper 类似,etcd 有很多使用场景,包括:
etcd 使用 raft 协议来维护集群内各个节点状态的一致性。简单说,etcd 集群是一个分布式系统,由多个节点相互通信构成整体对外服务,每个节点都存储了完整的数据,并且通过 Raft 协议保证每个节点维护的数据是一致的。
每个 etcd 节点都维护了一个状态机,并且,任意时刻至多存在一个有效的主节点。主节点处理所有来自客户端写操作,通过 Raft 协议保证写操作对状态机的改动会可靠的同步到其他节点。
etcd 的设计目标是用来存放非频繁更新的数据,提供可靠的 Watch插件,它暴露了键值对的历史版本,以支持低成本的快照、监控历史事件。这些设计目标要求它使用一个持久化的、多版本的、支持并发的数据数据模型。
当 etcd 键值对的新版本保存后,先前的版本依然存在。从效果上来说,键值对是不可变的,etcd 不会对其进行 in-place 的更新操作,而总是生成一个新的数据结构。为了防止历史版本无限增加,etcd 的存储支持压缩(Compact)以及删除老旧版本。
逻辑视图
从逻辑角度看,etcd 的存储是一个扁平的二进制键空间,键空间有一个针对键(字节字符串)的词典序索引,因此范围查询的成本较低。
键空间维护了多个修订版本(Revisions),每一个原子变动操作(一个事务可由多个子操作组成)都会产生一个新的修订版本。在集群的整个生命周期中,修订版都是单调递增的。修订版同样支持索引,因此基于修订版的范围扫描也是高效的。压缩操作需要指定一个修订版本号,小于它的修订版会被移除。
一个键的一次生命周期(从创建到删除)叫做 “代 (Generation)”,每个键可以有多个代。创建一个键时会增加键的版本(version),如果在当前修订版中键不存在则版本设置为1。删除一个键会创建一个墓碑(Tombstone),将版本设置为0,结束当前代。每次对键的值进行修改都会增加其版本号 — 在同一代中版本号是单调递增的。
当压缩时,任何在压缩修订版之前结束的代,都会被移除。值在修订版之前的修改记录(仅仅保留最后一个)都会被移除。
物理视图
etcd 将数据存放在一个持久化的 B+ 树中,处于效率的考虑,每个修订版仅仅存储相对前一个修订版的数据状态变化(Delta)。单个修订版中可能包含了 B+ 树中的多个键。
键值对的键,是三元组(major,sub,type):
键值对的值,包含从上一个修订版的 Delta。B+ 树 —— 键的词法字节序排列,基于修订版的范围扫描速度快,可以方便的从一个修改版到另外一个的值变更情况查找。
etcd 同时在内存中维护了一个 B 树索引,用于加速针对键的范围扫描。索引的键是物理存储的键面向用户的映射,索引的值则是指向 B+ 树修该点的指针。
按照官网给出的数据, 在 2CPU,1.8G 内存,SSD 磁盘这样的配置下,单节点的写性能可以达到 16K QPS, 而先写后读也能达到12K QPS。这个性能还是相当可观。
构建
需要Go 1.9以上版本:
cd $GOPATH/src mkdir go.etcd.io && cd go.etcd.io git clone https://github.com/etcd-io/etcd.git cd etcd ./build
使用 build 脚本构建会在当前项目的 bin 目录生产 etcd 和 etcdctl 可执行程序。etcd 就是 etcd server 了,etcdctl 主要为 etcd server 提供了命令行操作。
静态集群
如果Etcd集群成员是已知的,具有固定的IP地址,则可以静态的初始化一个集群。
每个节点都可以使用如下环境变量:
ETCD_INITIAL_CLUSTER="radon=http://10.0.2.1:2380,neon=http://10.0.3.1:2380" ETCD_INITIAL_CLUSTER_STATE=new
或者如下命令行参数
--initial-cluster radon=http://10.0.2.1:2380,neon=http://10.0.3.1:2380 --initial-cluster-state new
来指定集群成员。
初始化集群
完整的命令行示例:
etcd --name radon --initial-advertise-peer-urls http://10.0.2.1:2380 --listen-peer-urls http://10.0.2.1:2380 --listen-client-urls http://10.0.2.1:2379,http://127.0.0.1:2379 --advertise-client-urls http://10.0.2.1:2380 # 所有以-initial-cluster开头的选项,在第一次运行(Bootstrap)后都被忽略 --initial-cluster-token etcd.gmem.cc --initial-cluster radon=http://10.0.2.1:2380,neon=http://10.0.3.1:2380 --initial-cluster-state new
使用TLS
Etcd支持基于TLS加密的集群内部、客户端-集群通信。每个集群节点都应该拥有被共享CA签名的证书:
# 密钥对、证书签名请求 openssl genrsa -out radon.key 2048 export SAN_CFG=$(printf "\n[SAN]\nsubjectAltName=IP:127.0.0.1,IP:10.0.2.1,DNS:radon.gmem.cc") openssl req -new -sha256 -key radon.key -out radon.csr -subj "/C=CN/ST=BeiJing/O=Gmem Studio/CN=Server Radon" -reqexts SAN -config <(cat /etc/ssl/openssl.cnf <(echo $SAN_CFG)) # 执行签名 openssl x509 -req -sha256 -in radon.csr -out radon.crt -CA ../ca.crt -CAkey ../ca.key -CAcreateserial -days 3650 -extensions SAN -extfile <(echo "${SAN_CFG}")
初始化集群命令需要修改为:
etcd --name radon --initial-advertise-peer-urls https://10.0.2.1:2380 --listen-peer-urls https://10.0.2.1:2380 --listen-client-urls https://10.0.2.1:2379,https://127.0.0.1:2379 --advertise-client-urls https://10.0.2.1:2380 # 所有以-initial-cluster开头的选项,在第一次运行(Bootstrap)后都被忽略 --initial-cluster-token etcd.gmem.cc --initial-cluster radon=https://10.0.2.1:2380,neon=https://10.0.3.1:2380 # 指定集群成员列表 --initial-cluster-state new # 初始化新集群时使用 --initial-cluster-state existing # 加入已有集群时使用 # 客户端TLS相关参数 --client-cert-auth --trusted-ca-file=/usr/share/ca-certificates/GmemCA.crt --cert-file=/opt/etcd/cert/radon.crt --key-file=/opt/etcd/cert/radon.key # 集群内部TLS相关参数 --peer-client-cert-auth --peer-trusted-ca-file=/usr/share/ca-certificates/GmemCA.crt --peer-cert-file=/opt/etcd/cert/radon.crt --peer-key-file=/opt/etcd/cert/radon.key
etcd 提供了 etcdctl
命令行工具 和 HTTP API 两种交互方法。etcdctl
命令行工具用 go 语言编写,也是对 HTTP API 的封装,日常使用起来也更容易。所以这里我们主要使用 etcdctl
命令行工具演示。
put
应用程序通过 put 将 key 和 value 存储到 etcd 集群中。每个存储的密钥都通过 Raft 协议复制到所有 etcd 集群成员,以实现一致性和可靠性。
这里是设置键的值的命令 foo
到 bar
:
$ etcdctl put foo bar OK
get
应用程序可以从一个 etcd 集群中读取 key 的值。
假设 etcd 集群已经存储了以下密钥:
foo = bar foo1 = bar1 foo2 = bar2 foo3 = bar3 a = 123 b = 456 z = 789
foo
的命令:$ etcdctl get foo foo // key bar // value
$ etcdctl get foo --print-value-only bar
foo
的命令:$ etcdctl get foo --hex \x66\x6f\x6f \x62\x61\x72
$ etcdctl get foo foo3 foo bar foo1 bar1 foo2 bar2
请注意,foo3
由于范围超过了半开放时间间隔[foo, foo3)
,因此不包括在内foo3
。
$ etcdctl get --prefix foo foo bar foo1 bar1 foo2 bar2 foo3 bar3
$ etcdctl get --limit=2 --prefix foo foo bar foo1 bar1
$ etcdctl get --from-key b b 456 z 789
应用程序可能希望通过访问早期版本的 key 来回滚到旧配置。由于对 etcd 集群键值存储区的每次修改都会增加一个 etcd 集群的全局修订版本,因此应用程序可以通过提供旧的 etcd 修订版来读取被取代的键。
假设一个 etcd 集群已经有以下 key:
foo = bar # revision = 2 foo1 = bar1 # revision = 3 foo = bar_new # revision = 4 foo1 = bar1_new # revision = 5
以下是访问以前版本 key 的示例:
$ etcdctl get --prefix foo # 访问最新版本的key foo bar_new foo1 bar1_new $ etcdctl get --prefix --rev=4 foo # 访问第4个版本的key foo bar_new foo1 bar1 $ etcdctl get --prefix --rev=3 foo # 访问第3个版本的key foo bar foo1 bar1 $ etcdctl get --prefix --rev=2 foo # 访问第3个版本的key foo bar $ etcdctl get --prefix --rev=1 foo # 访问第1个版本的key
del
应用程序可以从一个 etcd 集群中删除一个 key 或一系列 key。
假设一个 etcd 集群已经有以下key:
foo = bar foo1 = bar1 foo3 = bar3 zoo = val zoo1 = val1 zoo2 = val2 a = 123 b = 456 z = 789
foo
的命令:$ etcdctl del foo 1
$ etcdctl del --prev-kv zoo 1 zoo val
foo
到 foo9
的命令:$ etcdctl del foo foo9 2
$ etcdctl del --prefix zoo 2
$ etcdctl del --from-key b 2
watch
应用程序可以使用watch
观察一个键或一系列键来监视任何更新。
打开第一个终端,监听 foo
的变化,我们输入如下命令:
$ etcdctl watch foo
再打开另外一个终端来对 foo 进行操作:
$ etcdctl put foo 123 OK $ etcdctl put foo 456 OK $ ./etcdctl del foo 1
第一个终端结果如下:
$ etcdctl watch foo PUT foo 123 PUT foo 456 DELETE foo
除了以上基本操作,watch
也可以像 get
、del
操作那样使用 prefix、rev、 hex等参数,这里就不一一列举了。
lock
Distributed locks: 分布式锁,一个人操作的时候,另外一个人只能看,不能操作
lock
可以通过指定的名字加锁。注意,只有当正常退出且释放锁后,lock命令的退出码是0,否则这个锁会一直被占用直到过期(默认60秒)
在第一个终端输入如下命令:
$ etcdctl lock mutex1 mutex1/326963a02758b52d
在第二个终端输入同样的命令:
$ etcdctl lock mutex1
从上可以发现第二个终端发生了阻塞,并未返回像 mutex1/326963a02758b52d
的字样。此时我们需要结束第一个终端的 lock
,可以使用 Ctrl+C 正常退出lock
命令。第一个终端 lock
退出后,第二个终端的显示如下:
$ etcdctl lock mutex1 mutex1/694d6ee9ac069436
txn
txn 从标准输入中读取多个请求,将它们看做一个原子性的事务执行。事务是由条件列表,条件判断成功时的执行列表(条件列表中全部条件为真表示成功)和条件判断失败时的执行列表(条件列表中有一个为假即为失败)组成的。
$ etcdctl put user frank OK $ ./etcdctl txn -i compares: value("user") = "frank" success requests (get, put, del): put result ok failure requests (get, put, del): put result failed SUCCESS OK $ etcdctl get result result ok
解释如下:
compact
正如我们所提到的,etcd保持修改,以便应用程序可以读取以前版本的 key。但是,为了避免累积无限的历史,重要的是要压缩过去的修订版本。压缩后,etcd删除历史版本,释放资源供将来使用。在压缩版本之前所有被修改的数据都将不可用。
$ etcdctl compact 5 compacted revision 5 $ etcdctl get --rev=4 foo Error: etcdserver: mvcc: required revision has been compacted
lease 与 TTL
etcd 也能为 key 设置超时时间,但与 redis 不同,etcd 需要先创建 lease,然后 put 命令加上参数 –lease= 来设置。lease 又由生存时间(TTL)管理,每个租约都有一个在授予时间由应用程序指定的最小生存时间(TTL)值。
以下是授予租约的命令:
$ etcdctl lease grant 30 lease 694d6ee9ac06945d granted with TTL(30s) $ etcdctl put --lease=694d6ee9ac06945d foo bar OK
以下是撤销同一租约的命令:
$ etcdctl lease revoke 694d6ee9ac06945d lease 694d6ee9ac06945d revoked $ etcdctl get foo
应用程序可以通过刷新其TTL来保持租约活着,因此不会过期。
假设我们完成了以下一系列操作:
$ etcdctl lease grant 10 lease 32695410dcc0ca06 granted with TTL(10s)
以下是保持同一租约有效的命令:
$ etcdctl lease keep-alive 32695410dcc0ca06 lease 32695410dcc0ca06 keepalived with TTL(10) lease 32695410dcc0ca06 keepalived with TTL(10) lease 32695410dcc0ca06 keepalived with TTL(10) ...
应用程序可能想要了解租赁信息,以便它们可以续订或检查租赁是否仍然存在或已过期。应用程序也可能想知道特定租约所附的 key。
假设我们完成了以下一系列操作:
$ etcdctl lease grant 200 lease 694d6ee9ac06946a granted with TTL(200s) $ etcdctl put demo1 val1 --lease=694d6ee9ac06946a OK $ etcdctl put demo2 val2 --lease=694d6ee9ac06946a OK
以下是获取有关租赁信息的命令:
$ etcdctl lease timetolive 694d6ee9ac06946a lease 694d6ee9ac06946a granted with TTL(200s), remaining(178s)
以下是获取哪些 key 使用了租赁信息的命令:
$ etcdctl lease timetolive --keys 694d6ee9ac06946a lease 694d6ee9ac06946a granted with TTL(200s), remaining(129s), attached keys([demo1 demo2])
如果有一个让系统可以动态调整集群大小的需求,那么首先就要支持服务发现。就是说当一个新的节点启动时,可以将自己的信息注册到 master,让 master 把它加入到集群里,关闭之后也可以把自己从集群中删除。这个情况,其实就是一个 membership protocol,用来维护集群成员的信息。
整个代码的逻辑很简单,worker 启动时向 etcd 注册自己的信息,并设置一个带 TTL 的租约,每隔一段时间更新这个 TTL,如果该 worker 挂掉了,这个 TTL 就会 expire 并删除相应的 key。发现服务监听 workers/ 这个 etcd directory,根据检测到的不同 action 来增加,更新,或删除 worker。
首先我们要建立一个 etcd client:
func NewMaster(endpoints []string) *Master { // etcd 配置 cfg := client.Config{ Endpoints: endpoints, DialTimeout: 5 * time.Second, } // 创建 etcd 客户端 etcdClient, err := client.New(cfg) if err != nil { log.Fatal("Error: cannot connect to etcd: ", err) } // 创建 master master := &Master{ members: make(map[string]*Member), API: etcdClient, } return master }
这里我们先建立一个 etcd client,然后把它的 key API 放进 master 里面,这样我们以后只需要通过这个 API 来跟 etcd 进行交互。Endpoints 是指 etcd 服务器们的地址,如 ”http://127.0.0.1:2379“ 等。go master.WatchWorkers() 这一行启动一个 Goroutine 来监控节点的情况。下面是 WatchWorkers 的代码:
func (master *Master) WatchWorkers() { // 创建 watcher channel watcherCh := master.API.Watch(context.TODO(), "workers", client.WithPrefix()) // 从 chanel 取数据 for wresp := range watcherCh { for _, ev := range wresp.Events { key := string(ev.Kv.Key) if ev.Type.String() == "PUT" { // put 方法 info := NodeToWorkerInfo(ev.Kv.Value) if _, ok := master.members[key]; ok { log.Println("Update worker ", info.Name) master.UpdateWorker(key,info) } else { log.Println("Add worker ", info.Name) master.AddWorker(key, info) } } else if ev.Type.String() == "DELETE" { // del 方法 log.Println("Delete worker ", key) delete(master.members, key) } } } }
worker 这边也跟 master 类似,保存一个 etcd KeysAPI,通过它与 etcd 交互,然后用 heartbeat 来保持自己的状态,在 heartbeat 定时创建租约,如果租用失效,master 将会收到 delete 事件。代码如下:
func NewWorker(name, IP string, endpoints []string) *Worker { // etcd 配置 cfg := client.Config { Endpoints: endpoints, DialTimeout: 5 * time.Second, } // 创建 etcd 客户端 etcdClient, err := client.New(cfg) if err != nil { log.Fatal("Error: cannot connect to etcd: ", err) } // 创建 worker worker := &Worker { Name: name, IP: IP, API: etcdClient, } return worker } func (worker *Worker) HeartBeat() { for { // worker info info := &WorkerInfo{ Name: worker.Name, IP: worker.IP, CPU: runtime.NumCPU(), } key := "workers/" + worker.Name value, _ := json.Marshal(info) // 创建 lease leaseResp, err := worker.API.Lease.Grant(context.TODO(), 10) if err != nil { log.Fatalf("设置租约时间失败:%s\n", err.Error()) } // 创建 watcher channel _, err = worker.API.Put(context.TODO(), key, string(value), client.WithLease(leaseResp.ID)) if err != nil { log.Println("Error update workerInfo:", err) } time.Sleep(time.Second * 3) } }
启动的时候需要有多个 worker 节点(至少一个)和一个 master 节点,所以我们在启动程序的时候,可以传递一个 “role” 参数。代码如下:
var role = flag.String("role", "", "master | worker") flag.Parse() endpoints := []string{"http://127.0.0.1:2379"} if *role == "master" { master := discovery.NewMaster(endpoints) master.WatchWorkers() } else if *role == "worker" { worker := discovery.NewWorker("localhost", "127.0.0.1", endpoints) worker.HeartBeat() } else { ... }
项目地址: https://github.com/chapin666/etcd-service-discovery
文章来源:https://zhuanlan.zhihu.com/p/96428375?from_voters_page=true
###host字段指定授权使用该证书的etcd节点IP或子网列表,需要将etcd集群的3个节点都添加其中。cp etcd-v3.3.13-linux-amd64/etcd* /opt/k8s/bin/