概述
etcd 是云原生架构中重要的基础组件,由 CNCF 孵化托管。etcd 在微服务和 Kubernates 集群中不仅可以作为服务注册于发现,还可以作为 key-value
存储的中间件。
特点
完全复制
:集群中的每个节点都可以使用完整的存档高可用性
:etcd可用于避免硬件的单点故障或网络问题简单
:安装配置简单,而且提供了 HTTP API 进行交互,使用也很简单键值对存储
:将数据存储在分层组织的目录中,如同在标准文件系统中监测变更
:监测特定的键或目录以进行更改,并对值的更改做出反应安全
:实现了带有可选的客户端证书身份验证的自动化TLS(支持 SSL 证书验证)快速
:根据官方提供的 benchmark 数据,单实例支持每秒 2k+ 读操作可靠
:采用 raft 算法,实现分布式系统数据的可用性和一致性
- etcd 采用 Go 语言编写,它具有出色的跨平台支持,很小的二进制文件和强大的社区。etcd 机器之间的通信通过 Raft 算法处理。
- etcd 是一个高度一致的分布式键值存储,它提供了一种可靠的方式来存储需要由分布式系统或机器集群访问的数据。它可以优雅地处理网络分区期间的 leader 选举,以应对机器的故障,即使是在 leader 节点发生故障时。
- 从简单的 Web 应用程序到 Kubernetes 集群,任何复杂的应用程序都可以从 etcd 中读取数据或将数据写入 etcd。
应用场景
etcd 比较多的应用场景是用于服务注册与发现 ,除此之外,也可以用于键值对存储,应用程序可以读取和写入 etcd 中的数据。
键值对存储
etcd 是一个键值存储的组件,其他的应用都是基于其键值存储的功能展开。etcd 的存储有如下特点:
- 采用 kv 型数据存储,一般情况下比关系型数据库快。
- 支持动态存储 (内存) 以及静态存储 (磁盘)。
- 分布式存储,可集成为多节点集群。
- 存储方式,采用类似目录结构。
- 只有叶子节点才能真正存储数据,相当于文件。
- 叶子节点的父节点一定是目录,目录不能存储数据。
etcd leader 的延迟是要跟踪的最重要的指标,并且内置仪表板具有专用于此的视图。
服务注册与发现
服务注册与发现 (Service Discovery) 要解决的是分布式系统中最常见的问题之一,即在同一个分布式集群中的进程或服务如何才能找到对方并建立连接。
从本质上说,服务发现就是想要了解集群中是否有进程在监听 UDP 或者 TCP 端口,并且通过名字就可以查找和连接。
消息发布与订阅
在分布式系统中,最适用的一种组件间通信方式就是消息发布与订阅。即构建一个配置共享中心,数据提供者在这个配置中心发布消息,而消息使用者则订阅他们关心的主题,一旦主题有消息发布,就会实时通知订阅者。通过这种方式可以做到分布式系统配置的集中式管理与动态更新。
配置中心
将一些配置信息放到 etcd 上进行集中管理。
etcd 广泛用于 Kubernetes 和 Docker 等现代容器编排系统中,受到了广泛的支持。它的生态系统相对现代,集成性强,特别是在云原生技术栈中,它是配置存储的标准选择。
这类场景的使用方式通常是这样:应用在启动的时候主动从 etcd 获取一次配置信息,同时,在 etcd 节点上注册一个 Watcher 并等待,以后每次配置有更新的时候,etcd 都会实时通知订阅者,以此达到获取最新配置信息的目的。
分布式通知与协调
这里说到的分布式通知与协调,与消息发布和订阅有些相似。在分布式系统中,最适用的一种组件间通信方式就是消息发布与订阅。即构建一个配置共享中心,数据提供者在这个配置中心发布消息,而消息使用者则订阅他们关心的主题,一旦主题有消息发布,就会实时通知订阅者。通过这种方式可以做到分布式系统配置的集中式管理与动态更新。
这里用到了 etcd 中的 Watcher 机制,通过注册与异步通知机制,实现分布式环境下不同系统之间的通知与协调,从而对数据变更做到实时处理。实现方式通常是这样:不同系统都在 etcd 上对同一个目录进行注册,同时设置 Watcher 观测该目录的变化(如果对子目录的变化也有需要,可以设置递归模式),当某个系统更新了 etcd 的目录,那么设置了 Watcher 的系统就会收到通知,并作出相应处理。
通过 etcd 进行低耦合的心跳检测。检测系统和被检测系统通过 etcd 上某个目录关联而非直接关联起来,这样可以大大减少系统的耦合性。
通过 etcd 完成系统调度。某系统有控制台和推送系统两部分组成,控制台的职责是控制推送系统进行相应的推送工作。管理人员在控制台作的一些操作,实际上是修改了 etcd 上某些目录节点的状态,而 etcd 就把这些变化通知给注册了 Watcher 的推送系统客户端,推送系统再作出相应的推送任务。
通过 etcd 完成工作汇报。大部分类似的任务分发系统,子任务启动后,到 etcd 来注册一个临时工作目录,并且定时将自己的进度进行汇报(将进度写入到这个临时目录),这样任务管理者就能够实时知道任务进度。
分布式锁
利用 etcd 的一致性保证来实施数据库 leader 选举或在一组 follower 之间执行分布式锁定。
当在分布式系统中,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。与单机模式下的锁不仅需要保证进程可见,分布式环境下还需要考虑进程与锁之间的网络问题。
分布式锁可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如 Redis、Memcache。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行。
因为 etcd 使用 Raft 算法保持了数据的强一致性,某次操作存储到集群中的值必然是全局一致的,所以很容易实现分布式锁。锁服务有两种使用方式,一是保持独占,二是控制时序。
- 保持独占即所有获取锁的用户最终只有一个可以得到。etcd 为此提供了一套实现分布式锁原子操作 CAS(CompareAndSwap)的 API。通过设置 prevExist 值,可以保证在多个节点同时去创建某个目录时,只有一个成功。而创建成功的用户就可以认为是获得了锁。
- 控制时序,即所有想要获得锁的用户都会被安排执行,但是获得锁的顺序也是全局唯一的,同时决定了执行顺序 。etcd 为此也提供了一套 API(自动创建有序键),对一个目录建值时指定为POST动作,这样 etcd 会自动在目录下生成一个当前最大的值为键,存储这个新的值(客户端编号)。同时还可以使用 API 按顺序列出所有当前目录下的键值。此时这些键的值就是客户端的时序,而这些键中存储的值可以是代表客户端的编号。
etcd & ZooKeeper
etcd 和 ZooKeeper 都是常用的分布式协调系统,它们在很多分布式系统中用于提供配置管理、服务发现、分布式锁等功能。
为什么不选择 ZooKeeper?
部署维护复杂
,其使用的 Paxos 强一致性算法复杂难懂。官方只提供了 Java 和 C 两种语言的接口。使用Java编写引入大量的依赖
。ZooKeeper 的集群需要专门的协调和监控管理,运维人员维护起来比较麻烦。- 最近几年发展缓慢,不如 etcd 和 consul 等后起之秀。
为什么选择 etcd?
- 简单。使用 Go 语言编写部署简单;支持HTTP/JSON API,使用简单;使用 Raft 算法保证强一致性让用户易于理解。
- etcd 默认数据一更新就进行持久化。
- etcd 支持 SSL 客户端安全认证。
- etcd 的设计相对现代,支持快速恢复和易于集成的故障恢复机制。
etcd集群
etcd 作为一个高可用键值存储系统,天生就是为集群化而设计的。由于 Raft 算法在做决策时需要多数节点的投票,所以 etcd 一般部署集群推荐奇数个节点,推荐的数量为 3、5 或者 7 个节点构成一个集群。
在 Docker 中部署 Etcd 是非常方便的,下面是详细的步骤,介绍如何通过 Docker 启动一个 Etcd 容器实例,并进行基本配置。
使用 Docker 部署 Etcd
1. 启动 Etcd 容器
可以通过以下命令来启动一个单节点的 Etcd 实例:
bash
docker run -d \
--name etcd \
-p 2379:2379 -p 2380:2380 \
quay.io/coreos/etcd:v3.5.0 \
/usr/local/bin/etcd \
--name my-etcd \
--data-dir /etcd-data \
--listen-client-urls http://0.0.0.0:2379 \
--advertise-client-urls http://localhost:2379 \
--listen-peer-urls http://0.0.0.0:2380 \
--initial-cluster my-etcd=http://localhost:2380 \
--initial-cluster-token etcd-cluster-1 \
--initial-cluster-state new
参数解释:
-d
:让容器在后台运行。--name etcd
:设置容器的名称。-p 2379:2379 -p 2380:2380
:将容器的 2379 和 2380 端口映射到主机的相同端口。2379
用于客户端访问,2380
用于节点间通信。quay.io/coreos/etcd:v3.5.0
:指定使用的 Etcd 镜像及版本。/usr/local/bin/etcd
:容器内执行的命令(Etcd 启动命令)。--name my-etcd
:为这个 Etcd 节点指定一个名称。--data-dir /etcd-data
:指定 Etcd 数据存储路径。--listen-client-urls http://0.0.0.0:2379
:指定监听客户端连接的地址。--advertise-client-urls http://localhost:2379
:指定 Etcd 广播的客户端 URL。--listen-peer-urls http://0.0.0.0:2380
:指定监听对等节点(peer)通信的 URL。--initial-cluster my-etcd=http://localhost:2380
:指定初始集群的配置。这里只配置了单个节点。--initial-cluster-token etcd-cluster-1
:初始化集群时的标识符。--initial-cluster-state new
:指定集群的状态,new
表示这是一个新集群。
启动后,可以通过 docker ps
来查看容器是否正常运行。
bash
docker ps
输出应类似于:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
abcdef123456 quay.io/coreos/etcd:v3.5.0 "/usr/local/bin/etcd ..." 2 minutes ago Up 2 minutes 0.0.0.0:2379->2379/tcp, 0.0.0.0:2380->2380/tcp etcd
2. 测试 Etcd 服务
可以通过 curl
或其他 HTTP 客户端来测试 Etcd 服务是否正常工作。比如,获取 Etcd 的健康状态:
bash
curl http://localhost:2379/health
正常情况下,应该看到类似以下的输出:
json
{"health":"true"}
使用 Docker Compose 部署 Etcd 集群
如果想要部署一个多节点的 Etcd 集群,可以使用 docker-compose
来简化配置。以下是一个示例的 docker-compose.yml
文件,部署一个包含 3 个 Etcd 节点的集群。
1. 创建 docker-compose.yml
yaml
version: '3.7'
services:
etcd1:
image: quay.io/coreos/etcd:v3.5.0
container_name: etcd1
environment:
- ETCD_NAME=etcd1
- ETCD_DATA_DIR=/etcd-data
- 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://etcd1:2380
- ETCD_ADVERTISE_CLIENT_URLS=http://etcd1:2379
- ETCD_INITIAL_CLUSTER=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
- ETCD_INITIAL_CLUSTER_STATE=new
- ETCD_INITIAL_CLUSTER_TOKEN=etcd-cluster-1
volumes:
- /tmp/etcd1:/etcd-data
ports:
- "2379:2379"
- "2380:2380"
etcd2:
image: quay.io/coreos/etcd:v3.5.0
container_name: etcd2
environment:
- ETCD_NAME=etcd2
- ETCD_DATA_DIR=/etcd-data
- 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://etcd2:2380
- ETCD_ADVERTISE_CLIENT_URLS=http://etcd2:2379
- ETCD_INITIAL_CLUSTER=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
- ETCD_INITIAL_CLUSTER_STATE=new
- ETCD_INITIAL_CLUSTER_TOKEN=etcd-cluster-1
volumes:
- /tmp/etcd2:/etcd-data
depends_on:
- etcd1
ports:
- "2381:2379"
- "2381:2380"
etcd3:
image: quay.io/coreos/etcd:v3.5.0
container_name: etcd3
environment:
- ETCD_NAME=etcd3
- ETCD_DATA_DIR=/etcd-data
- 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://etcd3:2380
- ETCD_ADVERTISE_CLIENT_URLS=http://etcd3:2379
- ETCD_INITIAL_CLUSTER=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
- ETCD_INITIAL_CLUSTER_STATE=new
- ETCD_INITIAL_CLUSTER_TOKEN=etcd-cluster-1
volumes:
- /tmp/etcd3:/etcd-data
depends_on:
- etcd1
- etcd2
ports:
- "2382:2379"
- "2382:2380"
2. 启动集群
运行以下命令启动 Etcd 集群:
bash
docker-compose up -d
3. 检查集群状态
可以通过 docker ps
来检查各个 Etcd 节点是否都在运行。
bash
docker ps
4. 测试集群
可以通过以下命令测试集群是否正常工作。假设使用的是第一个节点 (etcd1
):
bash
curl http://localhost:2379/health
返回的结果应该是:
json
{"health":"true"}
5. 连接到 Etcd 集群
当 Etcd 集群启动完成后,可以通过客户端连接到其中的任意节点(在这个例子中,连接到 localhost:2379
),进行各种操作,如写入数据、读取数据等。
Go语言操作etcd
在Go语言中,操作etcd可以通过官方提供的etcd/clientv3
库来实现。etcd
是一个分布式键值存储,常用于服务发现、配置管理等场景。clientv3
是Go语言中与etcd进行交互的客户端库。
安装etcd客户端库
使用go get
命令来安装 etcd/clientv3
库:
bash
go get go.etcd.io/etcd/client/v3
示例代码
下面是一个简单的示例,展示了如何在Go中操作etcd,包括连接到etcd服务器、设置键值、获取键值、删除键值等操作。
导入必要的包
go
package main
import (
"context"
"fmt"
"log"
"time"
"go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/pkg/transport"
)
func main() {
// 创建一个etcd客户端
client, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"}, // etcd集群地址
DialTimeout: 5 * time.Second, // 连接超时时间
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
// 设置键值
putKeyValue(client)
// 获取键值
getKeyValue(client)
// 删除键值
deleteKeyValue(client)
}
// 设置键值
func putKeyValue(client *clientv3.Client) {
_, err := client.Put(context.Background(), "foo", "bar")
if err != nil {
log.Fatal(err)
}
fmt.Println("Set key 'foo' to 'bar'")
}
// 获取键值
func getKeyValue(client *clientv3.Client) {
resp, err := client.Get(context.Background(), "foo")
if err != nil {
log.Fatal(err)
}
if len(resp.Kvs) > 0 {
fmt.Printf("Key: %s, Value: %s\n", resp.Kvs[0].Key, resp.Kvs[0].Value)
} else {
fmt.Println("Key 'foo' not found")
}
}
// 删除键值
func deleteKeyValue(client *clientv3.Client) {
_, err := client.Delete(context.Background(), "foo")
if err != nil {
log.Fatal(err)
}
fmt.Println("Deleted key 'foo'")
}
运行代码
- 确保etcd服务器已经在运行,并且端口
2379
可用。可以使用Docker快速启动一个etcd实例:
bash
docker run -d --name etcd -p 2379:2379 -p 2380:2380 \
quay.io/coreos/etcd:v3.5.0 /usr/local/bin/etcd \
--name my-etcd \
--data-dir /etcd-data \
--listen-client-urls http://0.0.0.0:2379 \
--advertise-client-urls http://localhost:2379 \
--listen-peer-urls http://0.0.0.0:2380 \
--initial-cluster my-etcd=http://localhost:2380 \
--initial-cluster-token etcd-cluster-1 \
--initial-cluster-state new
- 运行Go代码,确保没有报错,应该能看到:
bash
Set key 'foo' to 'bar'
Key: foo, Value: bar
Deleted key 'foo'
常见操作
设置键值(Put)
go
_, err := client.Put(context.Background(), "key", "value")
key
是要设置的键。value
是键对应的值。
获取键值(Get)
go
resp, err := client.Get(context.Background(), "key")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(resp.Kvs[0].Key), string(resp.Kvs[0].Value))
key
是要获取的键。resp.Kvs
是返回的键值对,Key
和Value
是字节数组,需要转换为字符串。
删除键值(Delete)
go
_, err := client.Delete(context.Background(), "key")
key
是要删除的键。
监听键变化(Watch)
etcd提供了watch功能,可以监听某个键的变化(获取未来更改的通知)。可以用Watch
来监听特定键的变化:
go
watchChan := client.Watch(context.Background(), "foo")
for watchResp := range watchChan {
for _, ev := range watchResp.Events {
fmt.Printf("Type: %s, Key: %s, Value: %s\n", ev.Type, ev.Kv.Key, ev.Kv.Value)
}
}
lease租约
在 Etcd 中,lease(租约) 是一种用于管理键值对生命周期的机制,它允许你为某个键值对设置一个过期时间。当租约过期时,Etcd 会自动删除与该租约关联的键值对。Lease 机制非常适合处理动态配置和自动过期的场景,尤其是服务发现、分布式锁等应用场景。
- 租约 (Lease):一个租约是一个在指定时间内有效的标识符,租约本身不包含任何数据,它只是一个生命周期标识。
- 租约过期:租约在规定的过期时间后会自动失效,任何与该租约相关联的键值对都会被删除。
- 续约:可以在租约到期之前对其进行续约,续约会更新租约的有效期。
在 Etcd 中,租约与键值对是关联的,你可以创建租约并将它与特定的键值对绑定。租约本身有一个过期时间,通常通过秒数来指定,租约到期后,所有绑定的键值对会被删除。
go
package main
import (
"context"
"fmt"
"log"
"time"
"go.etcd.io/etcd/v3/client/v3"
"go.etcd.io/etcd/v3/client/v3/concurrency"
)
func main() {
// 创建一个 Etcd 客户端
client, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"},
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
// 创建租约,TTL 为 60 秒
leaseResp, err := client.Grant(context.TODO(), 60)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created lease with ID: %v\n", leaseResp.ID)
// 将键值对与租约绑定
_, err = client.Put(context.TODO(), "/foo", "bar", clientv3.WithLease(leaseResp.ID))
if err != nil {
log.Fatal(err)
}
fmt.Println("Key '/foo' set with lease")
// 续约租约
_, err = client.KeepAliveOnce(context.TODO(), leaseResp.ID)
if err != nil {
log.Fatal(err)
}
fmt.Println("Lease renewed")
// 等待租约过期
time.Sleep(65 * time.Second)
// 检查键是否已过期
getResp, err := client.Get(context.TODO(), "/foo")
if err != nil {
log.Fatal(err)
}
if len(getResp.Kvs) == 0 {
fmt.Println("Key '/foo' expired and deleted")
} else {
fmt.Printf("Key '/foo' still exists: %s\n", getResp.Kvs[0].Value)
}
}
keepAlive
keepAlive
是 Etcd 中的一个机制,用于续约租约,确保租约在其生存时间内不被过期。通过调用 keepAlive
,客户端可以周期性地发送续约请求,延长租约的有效期。
在 Go 语言客户端中,可以通过 KeepAlive
或 KeepAliveOnce
方法来实现。KeepAlive
会持续返回续约响应,直到租约被撤销或连接断开;KeepAliveOnce
则只续约一次。
例如,使用 KeepAlive
:
go
leaseResp, err := client.Grant(context.TODO(), 60) // 创建租约,60秒
if err != nil {
log.Fatal(err)
}
ch, err := client.KeepAlive(context.TODO(), leaseResp.ID) // 保持租约
if err != nil {
log.Fatal(err)
}
for resp := range ch {
fmt.Printf("Lease renewed: %v\n", resp)
}
这种方式可以让租约在其生命周期内不断地续期,直到客户端显式地撤销或停止续约。
基于 etcd 实现分布式锁
go.etcd.io/etcd/clientv3/concurrency
在 etcd 之上实现并发操作,如分布式锁、屏障和选举。
导入该包:
go
import "go.etcd.io/etcd/clientv3/concurrency"
基于etcd实现的分布式锁示例:
go
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建两个单独的会话用来演示锁竞争
s1, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer s1.Close()
m1 := concurrency.NewMutex(s1, "/my-lock/")
s2, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer s2.Close()
m2 := concurrency.NewMutex(s2, "/my-lock/")
// 会话s1获取锁
if err := m1.Lock(context.TODO()); err != nil {
log.Fatal(err)
}
fmt.Println("acquired lock for s1")
m2Locked := make(chan struct{})
go func() {
defer close(m2Locked)
// 等待直到会话s1释放了/my-lock/的锁
if err := m2.Lock(context.TODO()); err != nil {
log.Fatal(err)
}
}()
if err := m1.Unlock(context.TODO()); err != nil {
log.Fatal(err)
}
fmt.Println("released lock for s1")
<-m2Locked
fmt.Println("acquired lock for s2")
输出:
go
acquired lock for s1
released lock for s1
acquired lock for s2