etcd 3.7
etcd 持久化存储文件
etcd 3.7 开发、运维、升级、API 与内部原理中文指南
本文介绍了 etcd 持久化存储格式:命名规则、内容结构以及可供开发者用于检查存储内容的工具。后续应随着存储模型的变更持续扩展本文内容。本文面向 etcd 开发者,旨在帮助其满足数据恢复需求。
先决条件
以下文章为本文提供了有益的背景信息:
- etcd 数据模型概述
- Raft 概述 (特别是“5.3 日志复制”节)。
概述
长期存在的文件
| 文件名 | 主要用途 |
|---|---|
./member/snap/db |
bbolt b+tree 用于存储所有已应用的数据、成员权限信息及元数据。它知晓最新的已应用 WAL 日志索引("consistent_index")。 |
./member/snap/0000000000000002-0000000000049425.snap ./member/snap/0000000000000002-0000000000061ace.snap |
定期生成的旧版 v2 存储系统快照,包含:
自 etcd v3 起,其内容与 /snap/db 文件的内容重复。 |
/member/snap/000000000007a178.snap.db |
如果副本严重滞后,则从 etcd 领导者下载完整的bbolt 快照。 其内容类型与 该文件用于以下两种场景:
恢复完成后,该文件不会被删除(其全部内容会填充到 ./member/snap/db 文件中)。这些文件会定期(30s)进行清理。
此处同样只保留 |
./member/wal/000000000000000f-00000000000b38c7.wal ./member/wal/000000000000000e-00000000000a7fe3.wal ./member/wal/000000000000000d-000000000009c70c.wal |
Raft 的预写日志,包含 Raft 接受的近期事务、定期快照或 CRC 记录。 保留最近的 如果快照生成频率过低,可能会出现超过 |
./member/wal/0.tmp (or .../1.tmp) |
为下一个预写日志文件预留的空间。 用于避免因 WAL 日志容量不足而导致 Raft 卡住,且无法触发告警的情况。 |
临时文件
在 etcd 内部处理过程中,可能会遇到多个生命周期较短的文件:
| 文件 | 主要用途 |
|---|---|
./member/snap/0000000000000002-000000000007a178.snap.broken |
当快照文件无法加载时,会被重命名为“broken”。
etcd 启动时会尝试加载最新文件。 或在执行 etcdctl 的备份/迁移命令期间。 |
./member/snap/tmp071677638 (random suffix) |
该临时 bbolt 文件由副本创建,用于响应领导者的 msgSnap 请求,按要求从指定快照恢复存储。
完整内容成功获取后,文件将被重命名为 参见 etcd/issues/12837。已在 etcd 3.5 中修复。 |
/member/snap/db.tmp.071677638 (random suffix) |
在碎片整理过程中,该临时文件用于保存后端数据库内容(/member/snap/db)的副本。整理成功后,文件会重命名为 /member/snap/db,替换原后端数据库。 在 etcd 服务器启动时,这些文件会被清理。 |
bbolt B+ 树:member/snap/db
该文件包含已应用至 Raft 日志某个特定位置的 etcd 主要内容(参见 consistent_index )。
物理组织结构
Bolt 存储系统在物理上按 B+树 组织。B+树的物理页永远不会原地修改1。相反,内容会被复制到新页(从空闲页列表中回收)中,一旦没有正在运行的事务可能访问该旧页,该旧页即被加入空闲页列表。得益于这一机制,打开的只读(RO)事务可观察到存储系统一致的历史状态。读写(RW)事务具有排他性,会阻塞其他所有读写事务。 大值存储在多个连续页上。页回收过程与需分配不同大小的连续页区域的需求相结合,可能导致 bbolt 存储系统出现日益严重的碎片化。
bbolt 文件不会自行缩小。只有在执行碎片整理过程中,文件才能被重写为一个新的文件,该文件末尾保留了一定数量的空闲页,并且大小已被截断。
逻辑组织
bbolt 存储系统划分为多个桶。每个桶中以字典序存储键(byte[]→value byte[] 键值对)。下表列出了 etcd(截至版本 3.5)所使用的桶及其使用的键。
| 存储桶 | 键 | 示例值 | 描述 |
|---|---|---|---|
| alarm | rpcpb.Alarm:
{MemberID, Alarm: NONE|NOSPACE|CORRUPT}
|
nil |
表明其中一个成员已诊断出问题。 |
| auth | "authRevision" | ""(空)或 BigEndian.PutUint64 |
角色或用户任何变更都会在事务提交时递增此字段。 该值仅用于授权过程中的乐观锁。 |
| authRoles | [roleName] 为字符串 | authpb.Role 序列化 |
|
| authUsers | [userName] 为字符串 | authpb.User 序列化 |
|
| cluster | "clusterVersion" | "3.5.0"(字符串) |
次要 版本的共识达成的通用存储版本。 |
| "downgrade" | JSON: {
"target-version": "3.4.0"
"enabled": true/false
}
|
保存最近一次 自 v3.5 版本起 |
|
| key |
[revisionId] 使用 bytesToRev{main,sub} 编码 删除的键值对在序列化时会以 't' 结尾,表示“墓碑”(Tombstone) |
mvccpb.KeyValue 序列化 proto(key, create_rev, mod_rev, version, value, lease id) |
|
| lease | leasepb.Lease 序列化后的 proto(ID、TTL、RemainingTTL) |
注意:LeaseCheckpoint 仅扩展 RemainingTTL。TTL 仍来自原始 Grant。 注意 2:我们以秒为单位持久化 TTL(从未定义的“现在”开始计算)。崩溃循环的服务器不会释放租约! |
|
| members | 以十六进制字符串形式表示的 [memberId]:"8e9e05c52164694d" |
以字符串形式序列化的 Member 结构:
{
"id":10276657743932975437,
"peerURLs":[
"http://localhost:2380"],
"name":"default",
"clientURLs": ["http://localhost:2379"]
}
|
已达成一致的集群成员关系信息。 |
| members_removed | 以十六进制字符串形式表示的 [memberId]:"8e9e05c52164694d" |
[]byte("removed") |
所有已移除成员的 ID。用于验证已移除的成员不会以相同 ID 再次添加。 该字段目前(3.4 版本)从 V2 存储系统读取,从不从 V3 读取。详见 https://github.com/etcd-io/etcd/pull/12820 |
| meta | "consistent_index" | uint64 字节(大端序) | 表示最后应用的 WAL 条目在 Bolt DB 存储系统中的偏移量。 |
| scheduledCompactRev | bytesToRev{main,sub} 编码(共 16 字节)。 | 在执行压缩请求后发生崩溃时,用于重新初始化压缩。 | |
| finishedCompactRev | bytesToRev{main,sub} 编码(共 16 字节)。 | 存储系统最近成功完成压缩时的修订版本(https://github.com/etcd-io/etcd/blob/ae7862e8bc8007eb396099db4e0e04ac026c8df5/server/mvcc/kvstore_compaction.go#L54) | |
| "confState" | 自 etcd 3.5 版本起 | ||
| "term" | 自 etcd 3.5 版本起 | ||
| "storage-version" |
工具
bbolt
bbolt 提供了一个命令行工具,可用于检查文件内容。
使用示例:
列出给定 bbolt 文件中的所有桶:
读取特定键值对:
etcd-dump-db
etcd-dump-db 可用于列出 v3 etcd 后端数据库(bbolt)的内容。
更多示例请参见:https://github.com/etcd-io/etcd/tree/master/tools/etcd-dump-db
WAL:预写日志
预写日志(Write Ahead Log)是 Raft 协议的持久化存储,用于存储提案。首先,领导者将提案写入其日志,然后(并发地)通过 Raft 协议将提案复制到跟随者。每个跟随者在向领导者确认复制之前,会先将其提案持久化到自身的 WAL 中。
etcd 中使用的 WAL 日志与标准 Raft 模型存在两方面的差异:
- 它不仅持久化索引条目,还持久化 Raft 快照(轻量级)和硬状态。因此,仅通过 WAL 日志即可恢复成员的完整 Raft 状态。
- WAL 只支持追加写入。条目不会原地覆盖;后续追加到文件中的同索引条目会取代先前的条目。
文件名
WAL 日志文件的命名遵循以下模式:
示例:./member/wal/0000000000000010-00000000000bf1e6.wal
因此,文件名包含十六进制编码:
- WAL 日志文件的顺序编号
- 文件中第一条条目或快照的索引。 特别地,第一个文件“0000000000000000-0000000000000000.wal”包含索引为 0 的初始快照记录。
物理内容
WAL 日志文件由一系列“帧 ”组成。每个帧包含:
- 使用 LittleEndian 2 编码的 uint64,表示序列化后 walpb.Record 的长度(3)。
- 填充:若干个 0 字节,使整个帧的大小按 8 字节对齐。
- 序列化后的 walpb.Record
数据:
- type - 以整数编码的枚举,用于决定如何解释下述 data 字段
- data - 由类型决定,通常是序列化后的 Protocol Buffers 数据
- crc - 自 WAL 日志创建以来,该副本上所有日志记录中所有“data”字段(不包含 type)的 RC-32 校验和。请注意,CRC 计算包含所有记录(即使它们未被 Raft 提交)。
当当前文件超过 64*10^6 字节时,会将其切分并开始写入新文件。
逻辑内容
预写日志文件在逻辑层包含:
Raftpb.Entry:由 Raft 领导者复制的最近提案。其中部分提案被视为“已提交”,其余提案可能被逻辑覆盖。Raftpb.HardState(term,commit,vote):关于日志条目索引的周期性(非常频繁)信息,该索引表示条目已“提交”(复制到多数服务器),因此保证不会被更改或覆盖,并可应用于后端(v2、v3)。该信息还包含“任期”(指示是否存在与选举相关的变更)以及投票信息——当前副本在当前任期中投给的成员。walpb.Snapshot(term, index):Raft 状态的周期性快照(不包含数据库内容,仅包含快照日志索引和 Raft 任期)- v2 存储内容存储在独立的 *.store 文件中。
- v3 存储内容保存在 bbolt 文件中,一旦条目被应用,该文件即成为隐式快照。
- crc32 校验和记录(位于每个文件开头),用于恢复对文件剩余部分的 CRC 检查。
etcdserverpb.Metadata(node_id, cluster_id)—— 用于标识日志所代表的集群与副本。
每个 WAL 日志文件按以下顺序构建:
-
CRC-32 帧(从之前所有文件延续的 CRC;第一个文件为 0)。
-
元数据帧(集群 ID 与副本 ID)。
-
仅初始 WAL 文件包含:
- 空快照帧(索引:0,任期:0)。 此帧用于维持一个不变量:所有条目之前均有一个快照。
对于非初始(第 2 个及后续)WAL 文件:
- HardState 帧。
-
条目、硬状态与快照记录的混合
WAL 日志可能包含同一索引的多个条目。这种情况可能出现在 Raft 论文 图 7 所描述的场景中。etcd 的 WAL 日志仅支持追加写入,因此当新条目以相同索引写入时,原有条目会被覆盖。
特别是在读取 WAL 时,逻辑会用新条目覆盖旧条目 。因此,仅当条目索引 entry.index <= HardState.commit 时,才可视为最终版本。索引大于 HardState.commit 的条目可能发生变化。
WAL 日志中的“任期”应保持单调递增。
WAL 日志中的“索引”预期满足以下要求:
- 从某个快照开始
- 在该任期期间,索引应持续递增
- 若任期发生变化,索引可能减少,但必须大于最新的 HardState.commit 值
- 任意索引大于等于 HardState.commit 的新快照都可能发生,从而开启新的索引序列

工具
etcd-dump-logs
etcd 的 WAL 日志可使用 etcd-dump-logs 工具读取:
% go install go.etcd.io/etcd/v3/tools/etcd-dump-logs@latest
% go run go.etcd.io/etcd/v3/tools/etcd-dump-logs --start-index=0 aname.etcd请注意:
- 该工具仅显示条目,而不显示 WAL 日志文件中的所有记录(如快照、HardState)。
- 该工具会自动应用“覆盖”规则。若某条目被同一索引下更新的条目覆盖,则工具仅输出最终值。
- 该工具还会输出未提交的条目(来自日志尾部),但不包含 HardState.commitIndex 信息,因此无法判断条目是否为最终值。
Store V2 快照:member/snap/{term}-{index}.snap
文件名:
member/snap/{term}-{index}.snap
文件名在 此处
("%016x-%016x.snap") 生成,采用 2 个十六进制编码的组合形式:
term-> 快照生成时的 Raft 任期(两次选举之间的时段)index-> 快照生成时最后一个已应用提案的索引
创建
*.snap 文件由 Snapshotter.SaveSnap 方法创建。
有两个触发器控制这些文件的创建:
- 每处理大约 –snapshotCount=(默认为 100'000)个已应用提案,就会创建一个新文件。这只是近似值:提案可能分批到达,而系统只会在一批处理结束时考虑生成快照,最终的快照过程还会异步调度。 标志名 –snapshotCount 容易产生误解:它控制最后一次快照索引与最后一次已应用提案索引之间的索引差 。
- Raft 请求副本从快照恢复。副本通过网络接收快照(msgSnap 消息)时,也会将其以轻量方式记录到 WAL 日志中。这可确保 WAL 日志尾部始终存在一个后接日志条目的有效快照,从而避免 WAL 日志中可能出现的不连续。
目前,这些文件大致3与 WAL 日志中的 Snapshot 条目一一对应。随着 v2 存储系统停用,预计将彻底停止写入这些文件(3.5.x 可选启用,3.6.x 强制执行)。
内容
该文件包含序列化后的 snapdb.snapshot proto
(uint32 crc, bytes data),
其中 data 字段包含 Raftpb.Snapshot
:
(字节数据,SnapshotMetadata{index, term, conf } 元数据),
最后,嵌套数据包含序列化为 JSON 格式的 存储 v2 内容 。
特别是存在:
- 任期
- 索引
- 成员数据:
/0/members/8e9e05c52164694d/attributes -> {"name":"default","clientURLs":["http://localhost:2379"]}/0/members/8e9e05c52164694d/RaftAttributes -> "{"peerURLs":["http://localhost:2380"]}"
- 存储版本:/0/version -> 3.5.0
工具
protoc
以下命令可在 etcd 根目录下执行,用于查看文件内容:
cat default.etcd/member/snap/0000000000000002-0000000000049425.snap |
protoc --decode=snappb.snapshot \
server/etcdserver/api/snap/snappb/snap.proto \
-I $(go list -f '{{.Dir}}' github.com/gogo/protobuf/proto)/.. \
-I . \
-I $(go list -m -f '{{.Dir}}' github.com/gogo/protobuf)/protobuf类似地,可以提取 data 字段,并将其解码为 Raftpb.Snapshot
。
etcd 3.4 *.snap 文件中示例 JSON 序列化存储 v2 内容:
{
"Root":{
"Path":"/",
"CreatedIndex":0,
"ModifiedIndex":0,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"",
"Children":{
"0":{
"Path":"/0",
"CreatedIndex":0,
"ModifiedIndex":0,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"",
"Children":{
"members":{
"Path":"/0/members",
"CreatedIndex":1,
"ModifiedIndex":1,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"",
"Children":{
"8e9e05c52164694d":{
"Path":"/0/members/8e9e05c52164694d",
"CreatedIndex":1,
"ModifiedIndex":1,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"",
"Children":{
"attributes":{
"Path":"/0/members/8e9e05c52164694d/attributes",
"CreatedIndex":2,
"ModifiedIndex":2,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"{\"name\":\"default\",\"clientURLs\":[\"http://localhost:2379\"]}",
"Children":null
},
"RaftAttributes":{
"Path":"/0/members/8e9e05c52164694d/RaftAttributes",
"CreatedIndex":1,
"ModifiedIndex":1,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"{\"peerURLs\":[\"http://localhost:2380\"]}",
"Children":null
}
}
}
}
},
"version":{
"Path":"/0/version",
"CreatedIndex":3,
"ModifiedIndex":3,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"3.5.0",
"Children":null
}
}
},
"1":{
"Path":"/1",
"CreatedIndex":0,
"ModifiedIndex":0,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"",
"Children":{
}
}
}
},
"WatcherHub":{
"EventHistory":{
"Queue":{
"Events":[
{
"action":"create",
"node":{
"key":"/0/members/8e9e05c52164694d/RaftAttributes",
"value":"{\"peerURLs\":[\"http://localhost:2380\"]}",
"modifiedIndex":1,
"createdIndex":1
}
},
{
"action":"set",
"node":{
"key":"/0/members/8e9e05c52164694d/attributes",
"value":"{\"name\":\"default\",\"clientURLs\":[\"http://localhost:2379\"]}",
"modifiedIndex":2,
"createdIndex":2
}
},
{
"action":"set",
"node":{
"key":"/0/version",
"value":"3.5.0",
"modifiedIndex":3,
"createdIndex":3
}
}
]
}
}
}
}变更记录
本节用于描述不同 etcd 版本之间引入的文件格式变更。
来源与许可
文档取自 pig.center · 上游文档
- 版本
- 3.7
- 许可
- CC-BY-4.0
- 来源修订
dba8dc7e1afd3f6eea300ebf9ecb67c731145f1ee7db1ca0d3ebc49508812609- 译文修订
dba8dc7e1afd3f6eea300ebf9ecb67c731145f1ee7db1ca0d3ebc49508812609