etcd 是一个使用 Go 语言编写的用于存储分布式系统中的数据的高可用键值数据库(key-value store),它是 CoreOS 团队在 2013 年 6 月发起的开源项目,并在 2018 年 12 月正式加入 CNCF。我们知道在 Linux 操作系统中有一个目录叫 /etc,它是专门用来存储操作系统配置的地方,etcd 这个名词就是源自于此,etcd = etc + distibuted,所以它的目的就是用来存储分布式系统中的关键数据。

etcd.png

etcd 内部采用 Raft 一致性算法,以一致和容错的方式存储元数据。利用 etcd 可以实现包括配置管理、服务发现和协调分布式任务这些功能,另外 etcd 还提供了一些常用的分布式模式,包括领导选举,分布式锁和监控机器活动等。

etcd 已经被各大公司和开源项目广泛使用,最著名的莫过于 Kubernetes 就是使用 etcd 来存储配置数据的,etcd 的一致性对于正确安排和运行服务至关重要,Kubernetes 的 API Server 将集群状态持久化在 etcd 中,通过 etcd 的 Watch API 监听集群,并发布关键的配置更改。

k8s-apiserver-etcd.png

快速开始

这一节我们将学习如何在本地快速启动一个单节点的 etcd 服务,并学习 etcd 的基本使用。

安装

首先从 GitHub Releases 页面下载和你的操作系统对应的最新版本:

$ curl -LO https://github.com/etcd-io/etcd/releases/download/v3.4.20/etcd-v3.4.20-linux-amd64.tar.gz

解压并安装到 /usr/local/etcd 目录:

$ tar xzvf etcd-v3.4.20-linux-amd64.tar.gz -C /usr/local/etcd --strip-components=1

目录下包含了 etcdetcdctl 两个可执行文件,etcdctletcd 的命令行客户端。另外,还包含一些 Markdown 文档:

$ ls /usr/local/etcd
Documentation  README-etcdctl.md  README.md  READMEv2-etcdctl.md  etcd  etcdctl

然后将 /usr/local/etcd 目录添加到 PATH 环境变量(如果要让配置永远生效,可以将下面一行添加到 ~/.profile 文件中):

export PATH=$PATH:/usr/local/etcd

至此,etcd 就安装好了,使用 etcd --version 检查版本:

$ etcd --version
etcd Version: 3.4.20
Git SHA: 1e26823
Go Version: go1.16.15
Go OS/Arch: linux/amd64

启动

直接不带参数运行 etcd 命令,即可(a single-member cluster of etcd):

$ etcd
[WARNING] Deprecated '--logger=capnslog' flag is set; use '--logger=zap' flag instead
2022-09-06 07:21:49.244548 I | etcdmain: etcd Version: 3.4.20
2022-09-06 07:21:49.253270 I | etcdmain: Git SHA: 1e26823
2022-09-06 07:21:49.253486 I | etcdmain: Go Version: go1.16.15
2022-09-06 07:21:49.253678 I | etcdmain: Go OS/Arch: linux/amd64
2022-09-06 07:21:49.253846 I | etcdmain: setting maximum number of CPUs to 8, total number of available CPUs is 8
2022-09-06 07:21:49.253981 W | etcdmain: no data-dir provided, using default data-dir ./default.etcd
2022-09-06 07:21:49.254907 N | etcdmain: the server is already initialized as member before, starting as etcd member...
[WARNING] Deprecated '--logger=capnslog' flag is set; use '--logger=zap' flag instead
2022-09-06 07:21:49.264084 I | embed: name = default
2022-09-06 07:21:49.264296 I | embed: data dir = default.etcd
2022-09-06 07:21:49.264439 I | embed: member dir = default.etcd/member
2022-09-06 07:21:49.264667 I | embed: heartbeat = 100ms
2022-09-06 07:21:49.264885 I | embed: election = 1000ms
2022-09-06 07:21:49.265079 I | embed: snapshot count = 100000
2022-09-06 07:21:49.265244 I | embed: advertise client URLs = http://localhost:2379
2022-09-06 07:21:49.265389 I | embed: initial advertise peer URLs = http://localhost:2380
2022-09-06 07:21:49.265681 I | embed: initial cluster =
2022-09-06 07:21:49.302456 I | etcdserver: restarting member 8e9e05c52164694d in cluster cdf818194e3a8c32 at commit index 5
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d switched to configuration voters=()
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d became follower at term 2
raft2022/09/06 07:21:49 INFO: newRaft 8e9e05c52164694d [peers: [], term: 2, commit: 5, applied: 0, lastindex: 5, lastterm: 2]
2022-09-06 07:21:49.312871 W | auth: simple token is not cryptographically signed
2022-09-06 07:21:49.319814 I | etcdserver: starting server... [version: 3.4.20, cluster version: to_be_decided]
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d switched to configuration voters=(10276657743932975437)
2022-09-06 07:21:49.329816 I | etcdserver/membership: added member 8e9e05c52164694d [http://localhost:2380] to cluster cdf818194e3a8c32
2022-09-06 07:21:49.331278 N | etcdserver/membership: set the initial cluster version to 3.4
2022-09-06 07:21:49.331489 I | etcdserver/api: enabled capabilities for version 3.4
2022-09-06 07:21:49.333146 I | embed: listening for peers on 127.0.0.1:2380
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d is starting a new election at term 2
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d became candidate at term 3
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d received MsgVoteResp from 8e9e05c52164694d at term 3
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d became leader at term 3
raft2022/09/06 07:21:50 INFO: raft.node: 8e9e05c52164694d elected leader 8e9e05c52164694d at term 3
2022-09-06 07:21:50.419379 I | etcdserver: published {Name:default ClientURLs:[http://localhost:2379]} to cluster cdf818194e3a8c32
2022-09-06 07:21:50.419988 I | embed: ready to serve client requests
2022-09-06 07:21:50.427600 N | embed: serving insecure client requests on 127.0.0.1:2379, this is strongly discouraged!

该命令会在当前位置创建一个 ./default.etcd 目录作为数据目录,并监听 2379 和 2380 两个端口,http://localhost:2379advertise-client-urls,表示建议使用的客户端通信 url,可以配置多个,etcdctl 就是通过这个 url 来访问 etcd 的;http://localhost:2380advertise-peer-urls,表示用于节点之间通信的 url,也可以配置多个,集群内部通过这些 url 进行数据交互(如选举,数据同步等)。

测试

打开另一个终端,输入 etcdctl put 命令可以向 etcd 中以键值对的形式写入数据:

$ etcdctl put hello world
OK

然后使用 etcdctl get 命令可以根据键值读取数据:

$ etcdctl get hello
hello
world

搭建 etcd 集群

上面的例子中,我们在本地启动了一个单节点的 etcd 服务,一般用于开发和测试。在生产环境中,我们需要搭建高可用的 etcd 集群。

启动一个 etcd 集群要求集群中的每个成员都要知道集群中的其他成员,最简单的做法是在启动 etcd 服务时加上 --initial-cluster 参数告诉 etcd 初始集群中有哪些成员,这种方法也被称为 静态配置

我们在本地打开三个终端,并在三个终端中分别运行下面三个命令:

$ etcd --name infra1 \
    --listen-client-urls http://127.0.0.1:12379 \
    --advertise-client-urls http://127.0.0.1:12379 \
    --listen-peer-urls http://127.0.0.1:12380 \
    --initial-advertise-peer-urls http://127.0.0.1:12380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new
$ etcd --name infra2 \
    --listen-client-urls http://127.0.0.1:22379 \
    --advertise-client-urls http://127.0.0.1:22379 \
    --listen-peer-urls http://127.0.0.1:22380 \
    --initial-advertise-peer-urls http://127.0.0.1:22380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new
$ etcd --name infra3 \
    --listen-client-urls http://127.0.0.1:32379 \
    --advertise-client-urls http://127.0.0.1:32379 \
    --listen-peer-urls http://127.0.0.1:32380 \
    --initial-advertise-peer-urls http://127.0.0.1:32380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new

我们可以使用 Foreman 这个小工具来简化上面的过程,Foreman 通过一个 Procfile 文件在本地启动和管理多个进程。

我们随便选择一个节点,通过 member list 都可以查询集群中的所有成员:

$ etcdctl --write-out=table --endpoints=localhost:12379 member list
+------------------+---------+--------+------------------------+------------------------+------------+
|        ID        | STATUS  |  NAME  |       PEER ADDRS       |      CLIENT ADDRS      | IS LEARNER |
+------------------+---------+--------+------------------------+------------------------+------------+
| b217c7a319e4e4f8 | started | infra2 | http://127.0.0.1:22380 | http://127.0.0.1:22379 |      false |
| d35bfbeb1c7fbfcf | started | infra1 | http://127.0.0.1:12380 | http://127.0.0.1:12379 |      false |
| d425e5b1e0d8a751 | started | infra3 | http://127.0.0.1:32380 | http://127.0.0.1:32379 |      false |
+------------------+---------+--------+------------------------+------------------------+------------+

测试往集群中写入数据:

$ etcdctl --endpoints=localhost:12379 put hello world
OK

换一个节点也可以查出数据:

$ etcdctl --endpoints=localhost:22379 get hello
hello
world

但是一般为了保证高可用,我们会在 --endpoints 里指定集群中的所有成员:

$ etcdctl --endpoints=localhost:12379,localhost:22379,localhost:32379 get hello
hello
world

然后我们停掉一个 etcd 服务,可以发现集群还可以正常查询,说明高可用生效了,然后我们再停掉一个 etcd 服务,此时集群就不可用了,这是因为 Raft 协议必须要保证集群中一半以上的节点存活才能正常工作,可以看到集群中的唯一节点也异常退出了:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x75745b]

goroutine 188 [running]:
go.uber.org/zap.(*Logger).check(0x0, 0x1, 0x10a3ea5, 0x36, 0xc002068900)
        /root/go/pkg/mod/go.uber.org/zap@v1.10.0/logger.go:264 +0x9b
go.uber.org/zap.(*Logger).Warn(0x0, 0x10a3ea5, 0x36, 0xc002068900, 0x2, 0x2)
        /root/go/pkg/mod/go.uber.org/zap@v1.10.0/logger.go:194 +0x45
go.etcd.io/etcd/etcdserver.(*EtcdServer).requestCurrentIndex(0xc0002b4000, 0xc001fef200, 0xbfcf831fa9e8b606, 0x0, 0x0, 0x0)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/v3_server.go:805 +0x873
go.etcd.io/etcd/etcdserver.(*EtcdServer).linearizableReadLoop(0xc0002b4000)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/v3_server.go:721 +0x2d6
go.etcd.io/etcd/etcdserver.(*EtcdServer).goAttach.func1(0xc0002b4000, 0xc00011c4a0)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/server.go:2698 +0x57
created by go.etcd.io/etcd/etcdserver.(*EtcdServer).goAttach
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/server.go:2696 +0x1b1

使用静态的方法运行 etcd 集群虽然简单,但是这种方法必须提前规划好集群中所有成员的 IP 和端口,在有些场景下成员的地址是无法提前知道的,这时我们可以使用 动态配置 的方法来初始化集群,etcd 提供了两种动态配置的机制:etcd DiscoveryDNS Discovery,具体的内容可以参考 Clustering GuideDiscovery service protocol

操作 etcd

有多种不同的途径来操作 etcd,比如使用 etcdctl 命令行,使用 API 接口,或者使用 etcd 提供的不同语言的 SDK。

使用 etcdctl 命令行操作 etcd

在快速开始一节,我们使用 etcdctl putetcdctl get 来测试 etcd 是否可以正常写入和读取数据,这是 etcdctl 最常用的命令。

查看版本

通过 etcdctl version 查看 etcdctl 客户端和 etcd API 的版本:

$ etcdctl version
etcdctl version: 3.4.20
API version: 3.4

写入数据

通过 etcdctl put 将数据存储在 etcd 的某个键中,每个存储的键通过 Raft 协议复制到 etcd 集群的所有成员来实现一致性和可靠性。下面的命令将键 hello 的值设置为 world:

$ etcdctl put hello world
OK

etcd 支持为每个键设置 TTL 过期时间,这是通过租约(--lease)实现的。首先我们创建一个 100s 的租约:

$ etcdctl lease grant 100
lease 694d8324b408010a granted with TTL(100s)

然后写入键值时带上这个租约:

$ etcdctl put foo bar --lease=694d8324b408010a
OK

foo 这个键将在 100s 之后自动过期。

读取数据

我们提前往 etcd 中写入如下数据:

$ etcdctl put foo bar
$ etcdctl put foo1 bar1
$ etcdctl put foo2 bar2
$ etcdctl put foo3 bar3

读取某个键的值:

$ etcdctl get foo
foo
bar

默认情况下会将键和值都打印出来,使用 --print-value-only 可以只打印值:

$ etcdctl get foo --print-value-only
bar

或者使用 --keys-only 只打印键。

我们也可以使用两个键进行范围查询:

$ etcdctl get foo foo3
foo
bar
foo1
bar1
foo2
bar2

可以看出查询的范围为半开区间 [foo, foo3)

或者使用 --prefix 参数进行前缀查询,比如查询所有 foo 开头的键:

$ etcdctl get foo --prefix
foo
bar
foo1
bar1
foo2
bar2
foo3
bar3

我们甚至可以使用 etcdctl get "" --prefix 查询出所有的键。使用 --limit 限制查询数量:

$ etcdctl get "" --prefix --limit=2
foo
bar
foo1
bar1

删除数据

etcdctl get 一样,我们可以从 etcd 中删除一个键:

$ etcdctl del foo
1

也可以范围删除:

$ etcdctl del foo foo3
2

或者根据前缀删除:

$ etcdctl del --prefix foo
1

历史版本

etcd 会记录所有键的修改历史版本,每次对 etcd 进行修改操作时,版本号就会加一。使用 etcdctl get foo 查看键值时,可以通过 --write-out=json 参数看到版本号:

$ etcdctl get foo --write-out=json | jq
{
    "header": {
        "cluster_id": 14841639068965178418,
        "member_id": 10276657743932975437,
        "revision": 9,
        "raft_term": 2
    },
    "kvs": [
        {
            "key": "Zm9v",
            "create_revision": 5,
            "mod_revision": 5,
            "version": 1,
            "value": "YmFy"
        }
    ],
    "count": 1
}

其中 "revision": 9 就是当前的版本号,注意 JSON 格式输出中 key 和 value 是 BASE64 编码的。

修改 foo 的值:

$ etcdctl put foo bar_new
OK

查看最新的键值和版本:

$ etcdctl get foo --write-out=json | jq
{
    "header": {
        "cluster_id": 14841639068965178418,
        "member_id": 10276657743932975437,
        "revision": 10,
        "raft_term": 2
    },
    "kvs": [
        {
            "key": "Zm9v",
            "create_revision": 5,
            "mod_revision": 10,
            "version": 2,
            "value": "YmFyX25ldw=="
        }
    ],
    "count": 1
}

发现版本号已经加一变成了 10,此时如果使用 etcdctl get foo 查看的当前最新版本的值,也可以通过 --rev 参数查看历史版本的值:

$ etcdctl get foo
foo
bar_new

$ etcdctl get foo --rev=9
foo
bar

过多的历史版本会占用 etcd 的资源,我们可以将不要的历史记录清除掉:

$ etcdctl compact 10
compacted revision 10

上面的命令将版本号为 10 之前的历史记录删除掉,此时再查询 10 之前版本就会报错:

$ etcdctl get foo --rev=9
{"level":"warn","ts":"2022-09-10T09:48:41.021+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-ae27c266-06c4-4be8-9659-47b9318ec8f4/127.0.0.1:2379","attempt":0,"error":"rpc error: code = OutOfRange desc = etcdserver: mvcc: required revision has been compacted"}
Error: etcdserver: mvcc: required revision has been compacted

监听键值变化

使用 etcdctl watch 可以监听某个键的变动情况:

$ etcdctl watch foo

打开另一个终端修改 foo 的值,然后将键删除:

$ etcdctl put foo bar_new
OK
$ etcdctl del foo
1

在监听窗口我们可以实时看到键值的变化:

$ etcdctl watch foo
PUT
foo
bar_new
DELETE
foo

也可以监听某个范围内的键:

$ etcdctl watch foo foo3

或以监听指定前缀的所有键:

$ etcdctl watch --prefix foo

如果要监听的键没有相同的前缀,也不是在某个范围内,可以通过 watch -i 以交互的形式手工设置监听多个键:

$ etcdctl watch -i
watch foo
watch hello

另外通过 watch 命令还可以查看某个键的所有历史修改记录:

$ etcdctl watch --rev=1 foo
PUT
foo
bar
DELETE
foo

PUT
foo
bar
PUT
foo
bar_new

租约

在上面使用 etcdctl put 写入数据时,已经介绍了可以通过租约实现 TTL 功能,每个租约都带有一个存活时间,一旦租约到期,它绑定的所有键都将被删除。

创建一个租约:

$ etcdctl lease grant 100
lease 694d8324b4080150 granted with TTL(100s)

查询租约信息:

$ etcdctl lease timetolive 694d8324b4080150
lease 694d8324b4080150 granted with TTL(100s), remaining(96s)

查询租约信息时,也可以加上 --keys 参数查询该租约关联的键:

$ etcdctl lease timetolive --keys 694d8324b4080150
lease 694d8324b4080150 granted with TTL(100s), remaining(93s), attached keys([foo])

还可以通过 keep-alive 命令自动维持租约:

$ etcdctl lease keep-alive 694d8324b4080150

etcd 会每隔一段时间刷新该租约的到期时间,保证该租约一直处于存活状态。

最后我们通过 revoke 命令撤销租约:

$ etcdctl lease revoke 694d8324b4080150
lease 694d8324b4080150 revoked

租约撤销后,和该租约关联的键也会一并被删除掉。

其他命令

使用 etcdctl --help 查看支持的其他命令。

使用 etcd API 操作 etcd

etcd 支持的大多数基础 API 都定义在 api/etcdserverpb/rpc.proto 文件中,官方的 API reference 就是根据这个文件生成的。

etcd 将这些 API 分为 6 大类:

  • service KV

    • Range
    • Put
    • DeleteRange
    • Txn
    • Compact
  • service Watch

    • Watch
  • service Lease

    • LeaseGrant
    • LeaseRevoke
    • LeaseKeepAlive
    • LeaseTimeToLive
    • LeaseLeases
  • service Cluster

    • MemberAdd
    • MemberRemove
    • MemberUpdate
    • MemberList
    • MemberPromote
  • service Maintenance

    • Alarm
    • Status
    • Defragment
    • Hash
    • HashKV
    • Snapshot
    • MoveLeader
    • Downgrade
  • service Auth

    • AuthEnable
    • AuthDisable
    • AuthStatus
    • Authenticate
    • UserAdd
    • UserGet
    • UserList
    • UserDelete
    • UserChangePassword
    • UserGrantRole
    • UserRevokeRole
    • RoleAdd
    • RoleGet
    • RoleList
    • RoleDelete
    • RoleGrantPermission
    • RoleRevokePermission

实际上,每个接口都对应一个 HTTP 请求,比如上面执行的 etcdctl put 命令就是调用 KV.Put 接口,而 etcdctl get 命令就是调用 KV.Range 接口。

调用 KV.Put 写入数据(注意接口中键值对使用 BASE64 编码,这里的键为 key,值为 value):

$ curl -L http://localhost:2379/v3/kv/put \
  -X POST -d '{"key": "a2V5", "value": "dmFsdWU="}'
{"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"24","raft_term":"3"}}

调用 KV.Range 查询数据:

curl -L http://localhost:2379/v3/kv/range \
  -X POST -d '{"key": "a2V5"}'
{"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"24","raft_term":"3"},"kvs":[{"key":"a2V5","create_revision":"24","mod_revision":"24","version":"1","value":"dmFsdWU="}],"count":"1"}

这和使用 etcdctl 命令行查询效果是一样的:

$ etcdctl get key
key
value

除了这 6 类基础 API,etcd 还提供了两个并发类 API,包括分布式锁和集群选主:

使用 Go 语言操作 etcd

etcd 提供了各个语言的 SDK 用于操作 etcd,比如 Go 的 etcd/client/v3、Java 的 coreos/jetcd、Python 的 kragniz/python-etcd3 等,官方文档 Libraries and tools 列出了更多其他语言的 SDK。这一节通过一个简单的 Go 语言示例来学习 SDK 的用法。

首先安装依赖:

$ go get go.etcd.io/etcd/client/v3

然后通过 clientv3.New 创建一个 etcd 连接:

cli, err := clientv3.New(clientv3.Config{
    Endpoints:   []string{"localhost:2379"},
    DialTimeout: 5 * time.Second,
})
if err != nil {
    panic("Connect etcd server error")
}
defer cli.Close()

然后就可以通过 cli 来对 etcd 操作了,比如调用 cli.Put 写入数据:

_, err = cli.Put(ctx, "hello", "world")
if err != nil {
    panic("Put kv error")
}

调用 cli.Get 查询数据:

resp, err := cli.Get(ctx, "hello")
if err != nil {
    panic("Get kv error")
}
for _, kv := range resp.Kvs {
    fmt.Printf("%s: %s\n", kv.Key, kv.Value)
}

不过在运行的时候,报了下面这样一个奇怪的错:

go run .\main.go
# github.com/coreos/etcd/clientv3/balancer/resolver/endpoint
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\resolver\endpoint\endpoint.go:114:87: undefined: resolver.BuildOption
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\resolver\endpoint\endpoint.go:182:40: undefined: resolver.ResolveNowOption
# github.com/coreos/etcd/clientv3/balancer/picker
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\picker\err.go:37:53: undefined: balancer.PickOptions
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\picker\roundrobin_balanced.go:55:63: undefined: balancer.PickOptions

这里 有个解决方案,说是 grpc 版本较高导致的,需要降低 grpc 的版本:

$ go get google.golang.org/grpc@v1.26.0

etcd 和 其他键值存储的区别

etcd vs. Redis

Redis 和 etcd 一样,支持键值存储,而且也支持分布式特性,他们之间的差异如下:

  • Redis 支持的数据类型比 etcd 更丰富
  • Redis 在分布式环境下不是强一致的,可能会丢数据或读取不到最新数据
  • Redis 的数据监听机制没有 etcd 完善
  • etcd 为了保证强一致性,性能要低于 Redis

综上考虑,Redis 适用于缓存,需要频繁读写,但对系统没有强一致性的要求,etcd 适用于系统读写较少,但是对系统有强一致性要求的场景,比如存储分布式系统的元数据。

etcd vs. ZooKeeper

ZooKeeper 和 etcd 的定位都是分布式协调系统,ZooKeeper 起源于 Hadoop 生态系统,etcd 则是跟着 Kubernetes 的流行而流行。他们都是顺序一致性的(满足 CAP 的 CP),意味着无论你访问任意节点,都将获得最终一致的数据。他们之间的差异如下:

  • ZooKeeper 从逻辑上来看是一种目录结构,而 etcd 从逻辑上来看就是一个 KV 结构,不过 etcd 的 Key 可以是任意字符串,所以也可以模拟出目录结构
  • etcd 使用 Raft 算法实现一致性,比 ZooKeeper 的 ZAB 算法更简单
  • ZooKeeper 采用 Java 编写,etcd 采用 Go 编写,相比而言 ZooKeeper 的部署复杂度和维护成本要高一点,而且 ZooKeeper 的官方只提供了 Java 和 C 的客户端,对其他编程语言不是很友好
  • ZooKeeper 属于 Apache 基金会顶级项目,发展较缓慢,而 etcd 得益于云原生,近几年发展势头迅猛
  • etcd 提供了 gRPC 或 HTTP 接口使用起来更简单
  • ZooKeeper 使用 SASL 进行安全认证,而 etcd 支持 TLS 客户端安全认证,更容易使用

总体来说,ZooKeeper 和 etcd 还是很相似的,在 新技术学习笔记:ZooKeeper 这篇文章中介绍了一些 ZooKeeper 的使用场景,我们使用 etcd 同样也都可以实现。在具体选型上,我们应该更关注是否契合自己所使用的技术栈。

官方有一个比较完整的表格 Comparison chart,从不同的维度对比了 etcd 和 ZooKeeper、Consul 以及一些 NewSQL(Cloud Spanner、CockroachDB、TiDB)的区别。

参考

  1. Etcd Quickstart - Get etcd up and running in less than 5 minutes!
  2. Etcd 中文文档
  3. Etcd 官方文档中文版
  4. Etcd 教程 | 编程宝库
  5. etcd 教程 | 梯子教程
  6. 七张图了解Kubernetes内部的架构

更多

etcd 的安全性

开启用户角色认证

etcd 支持开启 RBAC 认证,但是开启认证之前,我们需要提前创建 root 用户和 root 角色。

创建 root 用户:

$ etcdctl user add root
Password of root:
Type password of root again for confirmation:
User root created

创建 root 角色:

$ etcdctl role add root
Role root created

root 用户赋权 root 角色:

$ etcdctl user grant-role root root
Role root is granted to user root

开启认证:

$ etcdctl auth enable
Authentication Enabled

此时访问 etcd 就必须带上用户名和密码认证了,否则会报错:

$ etcdctl put hello world
{"level":"warn","ts":"2022-09-11T09:59:07.093+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-9b824d27-c8d4-4195-a17e-31eb0cf70a1c/127.0.0.1:2379","attempt":0,"error":"rpc error: code = InvalidArgument desc = etcdserver: user name is empty"}
Error: etcdserver: user name is empty

使用 --user 带上用户名和密码访问:

$ etcdctl --user root:123456 put hello world
OK

开启 TLS 证书认证

etcd 还支持开启 TLS 安全模式,使用 TLS 可以从多个维度保证 etcd 通信的安全性,包括客户端和服务器之间的安全通信,客户端认证,Peer 之间的安全通信和认证等,官方文档 Transport security model 中列举了四种使用 TLS 的常用场景:

  • Client-to-server transport security with HTTPS
  • Client-to-server authentication with HTTPS client certificates
  • Transport security & client certificates in a cluster
  • Automatic self-signed transport security

首先我们需要创建一个自签名的证书(self-signed certificate),CFSSL 是 CDN 服务商 Cloudflare 开源的一套 PKI/TLS 工具集,可以非常方便的创建 TLS 证书。从 GitHub Release 页面下载并安装 CFSSL:

$ curl -LO https://github.com/cloudflare/cfssl/releases/download/v1.6.2/cfssl_1.6.2_linux_amd64
$ chmod +x cfssl_1.6.2_linux_amd64
$ cp cfssl_1.6.2_linux_amd64 /usr/local/bin/cfssl


$ curl -LO https://github.com/cloudflare/cfssl/releases/download/v1.6.2/cfssljson_1.6.2_linux_amd64
$ chmod +x cfssljson_1.6.2_linux_amd64
$ cp cfssljson_1.6.2_linux_amd64 /usr/local/bin/cfssljson

然后准备一个配置文件 ca-csr.json,内容如下:

{
  "CN": "etcd",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ]
}

再通过 cfssl gencert 命令生成 CA 证书:

$ mkdir -p certs
$ cfssl gencert -initca ca-csr.json | cfssljson -bare certs/ca

cfssl gencert 命令输出结果为 JSON 格式,需要通过 cfssljson 转换为证书文件,上面的命令会在 certs 目录下生成三个文件:

  • ca-key.pem
  • ca.csr
  • ca.pem

其中 ca.csr证书签名请求(CSR,Certificate Signing Request) 文件,我们这里用不上,ca.pemca-key.pem 两个文件需要妥善保管,后面就是使用它们来生成其他证书的。ca.pem 文件可以公开发送给任意人,客户端可以将其添加到可信机构列表中,ca-key.pem 是私钥文件,绝对不能泄露,要不然别人就可以通过该文件冒充你生成任意证书了。

有了 CA 证书后,我们就可以签发其他证书了。接着创建一个 ca-config.json 文件:

{
  "signing": {
    "default": {
        "usages": [
          "signing",
          "key encipherment",
          "server auth",
          "client auth"
        ],
        "expiry": "876000h"
    }
  }
}

以及一个 req-csr.json 文件:

{
  "CN": "etcd",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ],
  "hosts": [
    "localhost",
    "127.0.0.1"
  ]
}

ca-config.json 文件包含一些证书签名的信息,比如证书有效时间和证书用途等,req-csr.json 文件用于生成证书,和上面的 ca-csr.json 文件类似,只是多了 hosts 字段,用于表示这个证书只能用于 localhost127.0.0.1,也可以在这里添加其他 IP 地址。

使用 cfssl gencert 生成 etcd 服务端证书:

$ cfssl gencert \
    -ca certs/ca.pem \
    -ca-key certs/ca-key.pem \
    -config ca-config.json \
    req-csr.json | cfssljson -bare certs/etcd

上面的命令在 certs 目录下也生成了三个文件:

  • etcd-key.pem
  • etcd.csr
  • etcd.pem

接下来开启 TLS 功能了:

$ etcd --name infra0 --data-dir infra0 \
    --cert-file=./certs/etcd.pem --key-file=./certs/etcd-key.pem \
    --advertise-client-urls=https://127.0.0.1:2379 \
    --listen-client-urls=https://127.0.0.1:2379

注意此时 advertise-client-urlslisten-client-urls 都是 HTTPS 地址,所以执行 etcdctl 命令时需要指定 --endpoints=https://localhost:2379

$ etcdctl --endpoints=https://localhost:2379 get hello
{"level":"warn","ts":"2022-09-12T13:43:38.384+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-5e888f67-4ad6-4893-a0b4-954aba00f984/localhost:2379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = latest balancer error: all SubConns are in TransientFailure, latest connection error: connection error: desc = \"transport: authentication handshake failed: x509: certificate signed by unknown authority\""}
Error: context deadline exceeded

不过由于证书是我们自己签发的,所以是不可信的,命令会报 certificate signed by unknown authority 这样的错,我们可以通过 --cacert 参数指定 CA 证书:

$ etcdctl --endpoints=https://localhost:2379 get hello \
    --cacert=./certs/ca.pem

这时客户端和服务端之间都是通过 TLS 加密通信的,保证了客户端和服务端之间的安全性,此外,我们还可以在启动 etcd 时加上 --client-cert-auth 参数开启客户端认证功能进一步加强 etcd 服务的安全,同时还需要加上 --trusted-ca-file 参数用于指定 CA 证书:

$ etcd --name infra0 --data-dir infra0 \
    --client-cert-auth --trusted-ca-file=./certs/ca.pem \
    --cert-file=./certs/etcd.pem --key-file=./certs/etcd-key.pem \
    --advertise-client-urls=https://127.0.0.1:2379 \
    --listen-client-urls=https://127.0.0.1:2379  

再和上面一样执行 etcdctl 命令,发现就算带上了 --cacert 参数,也一样会报错:

$ etcdctl --endpoints=https://localhost:2379 get hello \
    --cacert=./cfssl/certs/ca.pem
{"level":"warn","ts":"2022-09-12T14:00:16.057+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-9c6cf82a-7cf1-488e-b1fa-8713a1b036a9/localhost:2379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = latest balancer error: all SubConns are in TransientFailure, latest connection error: connection closed"}
Error: context deadline exceeded

这是因为 etcd 服务端接收到请求后,会对客户端的证书进行校验,而我们的请求中并没有带上证书。接下来我们就通过 CA 为客户端生成一个证书,首先创建一个 client-csr.json 文件:

{
  "CN": "etcd client",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ]
}

使用 cfssl gencert 生成客户端证书:

$ cfssl gencert \
    -ca certs/ca.pem \
    -ca-key certs/ca-key.pem \
    -config ca-config.json \
    client-csr.json | cfssljson -bare certs/client

最后使用生成的客户端证书就可以连接 etcd 了:

$ etcdctl --endpoints=https://localhost:2379 get hello\
    --cacert=./certs/ca.pem \
    --cert=./certs/client.pem \
    --key=./certs/client-key.pem

由于客户端和服务端用的是同一个 CA 生成的证书,所以客户端认证通过,服务端认为该客户端是合法的。

使用 etcd 实现服务注册和发现

扫描二维码,在手机上阅读!