etcd (发音 et-see-dee) 是一个开源的、分布式、强一致性的键值存储系统,旨在可靠地存储分布式系统所需的关键信息。它的名称来源于 Unix 的 /etc 目录(用于存放配置文件)和 distributed system(分布式系统)的结合,寓意着它是分布式系统的配置中心。etcd 最著名的应用是作为 Kubernetes 的主要数据存储,用于管理所有集群的状态数据、配置数据和元数据。

核心思想:

  • 分布式键值存储:以键值对的形式存储数据,轻量且高效。
  • 强一致性:基于 Raft 共识算法,确保集群中所有节点数据的高度一致性。
  • 高可用性:设计为无单点故障,能够优雅地容忍硬件故障和网络分区。
  • Watch 机制:提供订阅机制,允许客户端监听键的变化,实现事件驱动的分布式协调。

一、为什么需要 etcd?

在构建和运行分布式系统时,存在一些固有的挑战,etcd正是为了解决这些问题而生:

  1. 分布式一致性:在多节点环境中,如何确保所有节点对某个共享状态的视图是最终一致且正确的?在没有强一致性保障的情况下,节点间的状态不同步可能导致系统行为异常,甚至数据损坏。
  2. 服务发现:分布式系统中的服务实例会动态地启动或停止。服务如何找到它们依赖的其他服务?传统的手动配置或固定 IP 难以适应动态变化的环境。
  3. 配置管理:应用程序的配置(例如数据库连接字符串、API 端点)需要在多个服务实例之间共享和同步。手动分发配置复杂且容易出错,尤其是在配置频繁更新时。
  4. 分布式锁与协调:在分布式环境中,为了避免竞态条件和确保资源的互斥访问,需要分布式锁机制。此外,还可能需要选举 Leader、实现分布式队列等协调机制。
  5. 高可用性和容错:单个节点的故障不应导致整个系统瘫痪。需要一个能够容忍部分节点故障,并能在故障后自动恢复的存储系统。

etcd 通过其分布式、强一致性和高可用性的特性,成为了解决这些挑战的理想选择。

二、etcd 核心概念

2.1 键值存储 (Key-Value Store)

etcd 本质上是一个简单的键值存储系统。数据以键值对的形式存储,其中键 (Key) 和值 (Value) 均可以是任意字节序列,通常是字符串。虽然 etcd 内部以扁平的方式存储键值对,但开发者通常会使用带有 / 分隔符的键来模拟目录结构和层次关系,例如 /configs/myapp/database_url

2.2 Raft 共识算法 (Raft Consensus Algorithm)

强一致性是 etcd 的基石。etcd 通过实现 Raft 共识算法来确保集群中所有节点的视角保持一致。

  • Leader-Follower 模型:在 etcd 集群中,所有节点通过选举产生一个 Leader 节点,其他节点则为 Follower。所有写请求都必须经过 Leader 处理,Leader 负责将这些请求同步复制给 Follower 节点。
  • 日志复制:Leader 将所有客户端写操作作为日志条目附加到其本地日志中,然后将这些日志条目复制到所有 Follower。只有当多数节点(quorum)确认接收并持久化了该日志条目后,Leader 才会将操作提交并应用到其状态机,并响应客户端。
  • Leader 选举:如果 Follower 在一定时间内没有收到 Leader 的心跳(Heartbeat),它们会认为 Leader 故障并触发新的选举过程,选出新的 Leader,从而保证集群的高可用性。

通常建议 etcd 集群包含奇数个成员(例如 3 或 5 个节点),这样可以在容忍相同数量的故障情况下,使用更少的节点实现多数派(quorum).

2.3 Watch 机制 (Watch Mechanism)

etcd 的 Watch 机制允许客户端订阅特定键或键前缀的变化事件。一旦被监听的键发生 PUT (创建/更新) 或 DELETE (删除) 操作,etcd 会立即通知所有订阅的客户端,并提供发生变化的键值对信息及事件类型. 这一机制使得构建事件驱动的、响应式分布式系统成为可能,例如 Kubernetes 利用 Watch 机制监控集群状态变化并重新配置自身.

2.4 Leases (TTL / Time-To-Live)

租约 (Lease) 机制允许客户端为键附加一个有效期 (TTL)。如果客户端未能在租约到期前续约 (Keep Alive),该租约下的所有键都将自动从 etcd 中删除.

  • 心跳续约 (Keep Alive):客户端需要定期发送心跳来延长租约的生命周期。
  • 应用场景:这对于实现服务注册与发现(服务实例注册一个带有租约的键,当服务崩溃或断开连接时,键自动过期)、分布式锁(通过租约自动释放锁)以及其它需要短暂存在状态的场景非常有用。

2.5 事务 (Transactions)

etcd 支持原子性的多操作事务。它允许客户端以批处理的方式执行一系列操作(如多个 PUTGETDELETE),并可以根据条件判断是否执行这些操作。这意味着事务中的所有操作要么全部成功,要么全部失败,保证了数据的一致性。

2.6 版本 (Revisions) 与多版本并发控制 (MVCC)

etcd 实现了多版本并发控制 (MVCC) 机制。每次对键值对的修改(创建、更新、删除)都会生成一个新的修订版本 (Revision)。 客户端可以读取特定历史版本的数据,这对于调试、回溯或者构建乐观锁非常有用。revision 类似于一个逻辑时钟,每次修改都会递增。

三、etcd 架构

etcd 集群由多个 etcd 节点组成,这些节点通过 Raft 算法协同工作。其内部架构可以大致分为以下几个模块:

  1. Client Layer (客户端层):客户端通过 gRPC 协议与 etcd 服务器交互。etcd 提供了多种语言的客户端库 (如 Go, Python) 和命令行工具 etcdctl
  2. gRPC Gateway / API Layer (API 层):提供 gRPC API 供客户端调用。由于 etcd v3 API 基于 gRPC,也支持 HTTP/JSON Gateway 兼容。
  3. Raft Consensus Module (Raft 共识模块):这是 etcd 的核心,负责处理 Leader 选举、日志复制、成员变更等 Raft 协议相关的逻辑,确保集群数据的一致性。
  4. Backend (存储后端):etcd 使用 BoltDB (或 bbolt,一个 Go 语言实现的键值存储) 作为其持久化存储后端. BoltDB 将数据存储在磁盘上,以 B+tree 的形式索引,确保数据在集群故障时能够恢复.
    • WAL (Write-Ahead Log):所有修改操作在应用到存储后端之前,都会首先写入 WAL 日志文件。WAL 是 Raft 算法实现持久性和故障恢复的关键。即使 etcd 进程崩溃,也可以通过重放 WAL 中的日志来恢复状态。
    • Snapshot (快照):为了防止 WAL 文件无限增长,etcd 会定期创建数据库的快照。快照是某个时间点的完整数据库状态,可以极大加快启动和恢复过程。

下图展示了 etcd 集群的简化架构:

四、etcd 关键特性

  • 分布式和高可用:通过 Raft 算法确保即使部分节点故障,集群也能继续提供服务,没有单点故障。
  • 强一致性:所有读操作都会返回最新写入的数据,保证数据在所有节点间的一致性。
  • 高性能:虽然是持久化存储,但 etcd 针对小规模数据和高频读写进行了优化,可以达到每秒数千次写入的性能。
  • 简单易用:基于 HTTP/JSON 的 API(通过 gRPC Gateway)和 gRPC 客户端库使得与 etcd 的交互变得简单。
  • 可靠的键监控:Watch 机制确保客户端不会丢失任何事件通知,即使在网络不稳定或 etcd 节点切换的情况下。
  • 事务支持:通过多操作事务保证复杂操作的原子性。
  • 安全性:支持 TLS (Transport Layer Security) 进行客户端与 etcd 之间的通信加密,以及客户端证书认证和基于角色的访问控制 (RBAC)。

五、etcd 应用场景

etcd 是云原生基础设施中的关键组件,广泛应用于各种分布式协调任务:

  1. 服务发现 (Service Discovery)
    • 服务将其地址和端口注册到 etcd 中,其他服务通过监听这些键来发现可用实例。
    • 例如,Kubernetes 使用 CoreDNS 与 etcd 集成,实现集群内的服务发现。
  2. 配置管理 (Configuration Management)
    • 集中存储和分发应用程序的配置。当配置更新时,通过 Watch 机制实时通知所有相关的服务实例进行动态加载。
    • 例如,无服务器架构中的函数配置、微服务的业务参数等。
  3. 分布式锁与 Leader 选举 (Distributed Locks & Leader Election)
    • 利用 etcd 的事务和租约机制,可以轻松实现分布式锁,确保在分布式系统中对共享资源的互斥访问。
    • 通过 Leader 选举机制,确保只有一个服务实例作为 Leader 执行某些关键任务(如任务调度、数据处理)。
  4. 集群状态存储
    • Kubernetes 将所有重要的集群状态、资源对象(Pods、Services、Deployments 等)的定义和期望状态存储在 etcd 中。API Server 负责与 etcd 交互。
  5. 分布式队列/屏障 (Distributed Queues/Barriers)
    • 可以利用 etcd 的有序键和 Watch 机制实现简单的分布式队列或并发控制的屏障。

六、使用 etcd (示例)

6.1 etcdctl 命令行工具

etcdctl 是与 etcd 交互的主要命令行客户端。默认使用 v3 API (需要设置 ETCDCTL_API=3)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 确保使用 etcdctl v3 API
export ETCDCTL_API=3

# 连接到 etcd 节点 (默认端口 2379)
# etcdctl --endpoints=localhost:2379

# 1. 存储键值对
etcdctl put /config/myapp/db_host "mydb.example.com"
# 输出: OK

# 2. 获取键值对
etcdctl get /config/myapp/db_host
# 输出:
# /config/myapp/db_host
# mydb.example.com

# 3. 获取带有前缀的键值对
etcdctl put /services/backend/instance1 "192.168.1.100:8080"
etcdctl put /services/backend/instance2 "192.168.1.101:8080"
etcdctl get --prefix /services/backend
# 输出:
# /services/backend/instance1
# 192.168.1.100:8080
# /services/backend/instance2
# 192.168.1.101:8080

# 4. 监听键的变化 (在一个新的终端会话中运行)
# 当 /config/myapp/db_host 发生变化时,会打印事件
etcdctl watch /config/myapp/db_host

# 5. 创建带租约的键 (模拟服务注册)
# 创建一个 60 秒的租约
LEASE_ID=$(etcdctl lease grant 60 | awk '{print $2}')
echo "Lease ID: $LEASE_ID"

# 将服务注册到 etcd,并附加租约
etcdctl put /services/frontend/webserver1 "192.168.1.200:80" --lease=$LEASE_ID
# 60 秒后,如果没有 `keep-alive`,该键将自动删除

# 6. 保持租约活跃 (在一个新的终端会话中运行)
# etcdctl lease keep-alive $LEASE_ID
# 持续输出: lease <lease_id> keepalived with TTL(60)

6.2 Go 语言客户端示例 (go.etcd.io/etcd/client/v3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
package main

import (
"context"
"fmt"
"log"
"time"

clientv3 "go.etcd.io/etcd/client/v3"
)

func main() {
// 配置 etcd 客户端
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379"}, // 替换为你的 etcd 节点地址
DialTimeout: 5 * time.Second,
})
if err != nil {
log.Fatalf("无法连接到 etcd: %v", err)
}
defer cli.Close() // 确保客户端连接关闭

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

// 1. 存储键值对 (PUT)
_, err = cli.Put(ctx, "/go-app/config/version", "1.0.0")
if err != nil {
log.Fatalf("PUT /go-app/config/version 失败: %v", err)
}
fmt.Println("PUT /go-app/config/version 成功.")

// 2. 获取键值对 (GET)
getResponse, err := cli.Get(ctx, "/go-app/config/version")
if err != nil {
log.Fatalf("GET /go-app/config/version 失败: %v", err)
}
for _, kv := range getResponse.Kvs {
fmt.Printf("GET /go-app/config/version: %s = %s (Rev: %d)\n", kv.Key, kv.Value, kv.ModRevision)
}

// 3. 监听键的变化 (Watch) - 另开 Goroutine 进行演示
go func() {
rch := cli.Watch(context.Background(), "/go-app/config/version")
fmt.Println("开始监听 /go-app/config/version 的变化...")
for wresp := range rch {
for _, ev := range wresp.Events {
fmt.Printf("[Watch Event] Type: %s, Key: %s, Value: %s (Rev: %d)\n", ev.Type, ev.Kv.Key, ev.Kv.Value, ev.Kv.ModRevision)
}
}
}()

// 等待 Watch 启动
time.Sleep(time.Second)

// 修改键以触发 Watch 事件
ctxUpdate, cancelUpdate := context.WithTimeout(context.Background(), time.Second)
_, err = cli.Put(ctxUpdate, "/go-app/config/version", "1.0.1")
cancelUpdate()
if err != nil {
log.Fatalf("PUT (update) /go-app/config/version 失败: %v", err)
}
fmt.Println("PUT (update) /go-app/config/version 成功.")

// 4. 使用租约 (Lease)
// 创建一个 10 秒的租约
leaseGrantResponse, err := cli.Lease.Grant(ctx, 10)
if err != nil {
log.Fatalf("创建租约失败: %v", err)
}
leaseID := leaseGrantResponse.ID
fmt.Printf("创建租约成功, ID: %x, TTL: %d秒\n", leaseID, leaseGrantResponse.TTL)

// 将键与租约关联
_, err = cli.Put(ctx, "/go-app/service/worker1", "active", clientv3.WithLease(leaseID))
if err != nil {
log.Fatalf("PUT /go-app/service/worker1 失败 (带租约): %v", err)
}
fmt.Println("PUT /go-app/service/worker1 成功 (带租约).")

// 保持租约活跃 (Keep Alive) - 持续 5 秒,然后停止,让键过期
fmt.Println("开始保持租约活跃...")
leaseKeepAliveCtx, leaseKeepAliveCancel := context.WithCancel(context.Background())
defer leaseKeepAliveCancel()

// `KeepAlive` 是一个 stream RPC,会自动续约直到上下文取消或流关闭
kaRespChan, err := cli.Lease.KeepAlive(leaseKeepAliveCtx, leaseID)
if err != nil {
log.Fatalf("KeepAlive 失败: %v", err)
}

go func() {
for kaResp := range kaRespChan {
fmt.Printf("租约 %x 续约成功, 新 TTL: %d\n", kaResp.ID, kaResp.TTL)
}
fmt.Printf("租约 %x KeepAlive 结束\n", leaseID)
}()

time.Sleep(5 * time.Second) // 保持活跃 5 秒
leaseKeepAliveCancel() // 取消 KeepAlive
time.Sleep(6 * time.Second) // 等待租约过期 (10 - 5 = 5秒 + 1秒缓冲)

// 尝试获取 key,看是否已过期
getResponseAfterExpire, err := cli.Get(ctx, "/go-app/service/worker1")
if err != nil {
log.Fatalf("GET /go-app/service/worker1 失败 (过期后): %v", err)
}
if len(getResponseAfterExpire.Kvs) == 0 {
fmt.Println("key /go-app/service/worker1 已过期并自动删除.")
} else {
fmt.Printf("key /go-app/service/worker1 仍在存在: %s\n", getResponseAfterExpire.Kvs[0].Value)
}

fmt.Println("程序结束.")
}

6.3 Python 客户端示例 (python-etcd3)

需要先安装 python-etcd3 库: pip install python-etcd3.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import etcd3
import time

def watch_callback(event):
"""一个简单的 Watch 事件回调函数"""
print(f"[Watch Event] Type: {event.event_type}, Key: {event.key.decode()}, Value: {event.value.decode()}")

def main():
# 配置 etcd 客户端 (默认连接 localhost:2379)
# 可以指定 host 和 port: etcd = etcd3.client(host='etcd-host-01', port=2379)
etcd = etcd3.client()

print("--- 1. 存储键值对 ---")
etcd.put('/python-app/config/status', 'ready')
print("PUT /python-app/config/status = ready 成功.")

print("\n--- 2. 获取键值对 ---")
value, metadata = etcd.get('/python-app/config/status')
if value:
print(f"GET /python-app/config/status: {value.decode()} (Revision: {metadata.mod_revision})")
else:
print("键 /python-app/config/status 不存在.")

print("\n--- 3. 监听键的变化 ---")
# etcd3 库的 watch 方法是阻塞的,为了演示,一般放在单独的线程或使用 watch_prefix
# 这里演示一个简单的 watch_prefix,它会返回一个迭代器,可以逐个处理事件
# 实际应用中,通常使用 add_watch_callback 或在一个独立的线程中循环处理

# 启动一个 Watch 线程
import threading
stop_event = threading.Event()
def watch_thread_func():
print("开始监听 /python-app/config/ 的前缀变化...")
watcher = etcd.watch_prefix('/python-app/config/', callbacks=[watch_callback], event_callback=None)
# watch_prefix 会一直运行,直到 etcd 连接断开或 stop_event 被设置。
# 实际使用中可能需要更复杂的循环或错误处理
try:
for event in watcher: # watcher 是一个生成器
if stop_event.is_set():
break
except Exception as e:
print(f"Watch 线程异常: {e}")
finally:
print("Watch 线程停止.")

watch_thread = threading.Thread(target=watch_thread_func)
watch_thread.start()

time.sleep(1) # 等待 Watch 线程启动

print("\n--- 触发 Watch 事件 (修改键) ---")
etcd.put('/python-app/config/status', 'running')
etcd.put('/python-app/config/new_setting', 'value_xyz')
time.sleep(2) # 等待事件被处理
stop_event.set() # 停止 Watch 线程
watch_thread.join() # 等待 Watch 线程结束


print("\n--- 4. 使用租约 ---")
# 创建一个 5 秒的租约
lease = etcd.lease(5)
print(f"创建租约成功, ID: {lease.id}, TTL: {lease.ttl}秒")

# 将键与租约关联
etcd.put('/python-app/service/myworker-01', 'active', lease=lease)
print("PUT /python-app/service/myworker-01 = active (带租约) 成功.")

# 保持租约活跃 (Keep Alive)
print("开始保持租约活跃 3 秒...")
# etcd3 的 `lease` 对象本身有 `refresh()` 方法或使用 `keep_alive()`
# `keep_alive()` 会在一个新的后台线程中自动续约
lease.keep_alive() # 启动一个后台线程进行续约
time.sleep(3) # 保持活跃 3 秒
lease.revoke() # 撤销租约,停止续约并立即删除关联的键

print("租约已撤销,等待键过期并删除...")
time.sleep(1) # 给 etcd 一点时间来处理撤销

# 尝试获取 key,看是否已过期
value_after_expire, _ = etcd.get('/python-app/service/myworker-01')
if value_after_expire is None:
print("key /python-app/service/myworker-01 已过期并自动删除.")
else:
print(f"key /python-app/service/myworker-01 仍在存在: {value_after_expire.decode()}")

print("\n程序结束.")

if __name__ == "__main__":
main()

七、部署注意事项

部署和管理 etcd 集群需要注意以下几点:

  1. 节点数量:生产环境建议部署 3 或 5 个节点的 etcd 集群,以兼顾高可用性和性能。一般不推荐偶数节点或过多节点,因为会增加 Raft 选举和日志复制的开销。
  2. 硬件资源:etcd 对磁盘 I/O 性能和网络延迟非常敏感。建议运行在 SSD 存储和低延迟网络环境中。CPU 和内存也需要充足,以避免心跳超时和集群不稳定。
  3. 数据备份:定期对 etcd 数据进行快照备份至关重要,以防止数据丢失和灾难恢复。etcdctl snapshot save 是常用的备份命令。
  4. 安全性:始终通过 TLS 加密客户端与 etcd 之间的通信。启用客户端证书认证和 RBAC,确保只有授权的客户端才能访问和修改数据。
  5. 监控:密切监控 etcd 集群的健康状况、Leader 状态、Raft 指标、磁盘 I/O、网络延迟等,以便及时发现和解决问题。

八、总结

etcd 是一个强大且成熟的分布式键值存储,其核心在于通过 Raft 共识算法提供强一致性和高可用性。它在云原生生态系统中扮演着基石性的角色,尤其在 Kubernetes 中作为集群的“大脑”存储所有状态数据。

通过理解和利用其键值存储、Raft 共识、Watch 机制、租约以及事务等特性,开发者能够构建出健壮、可靠且易于管理的分布式应用程序。随着分布式系统的持续发展,etcd 将继续作为其核心组件之一,为各种云原生应用提供稳定可靠的分布式协调服务。