↑↓ 选择↵ 打开⌫ 切换范围完整搜索

PG.CENTER 连接 PostgreSQL 文档、百科与生态知识。由 Pigsty 维护。

etcd 3.7

etcd 持久化存储文件

etcd 3.7 开发、运维、升级、API 与内部原理中文指南

本文介绍了 etcd 持久化存储格式:命名规则、内容结构以及可供开发者用于检查存储内容的工具。后续应随着存储模型的变更持续扩展本文内容。本文面向 etcd 开发者,旨在帮助其满足数据恢复需求。

先决条件

以下文章为本文提供了有益的背景信息:

概述

长期存在的文件

文件名 主要用途
./member/snap/db
bbolt b+tree 用于存储所有已应用的数据、成员权限信息及元数据。它知晓最新的已应用 WAL 日志索引("consistent_index")。
./member/snap/0000000000000002-0000000000049425.snap
./member/snap/0000000000000002-0000000000061ace.snap

定期生成的旧版 v2 存储系统快照,包含:

  • 基本成员信息
  • etcd 版本

自 etcd v3 起,其内容与 /snap/db 文件的内容重复。

这些文件会定期(30s)清理,仅保留最近的 --max-snapshots=5 个。

/member/snap/000000000007a178.snap.db

如果副本严重滞后,则从 etcd 领导者下载完整的bbolt 快照。

其内容类型与 ./member/snap/db 文件相同。

该文件用于以下两种场景:

  • 响应领导者从快照恢复的请求。
  • 服务器启动期间,发现最后一个快照(.snap.db 文件),且其索引比当前 snap.db 文件中的 consistent_index 更新时。
请注意:每个副本定期生成的快照只以 *.snap 文件形式输出,而不是 snap.db 文件。因此,无法保证 WAL 日志中的最新快照存在对应的 *.snap.db 文件。但在这种情况下,后端(snap/db)应比快照更新。

恢复完成后,该文件不会被删除(其全部内容会填充到 ./member/snap/db 文件中)。这些文件会定期(30s)进行清理。 此处同样只保留 --max-snapshots=5 个文件。由于这些文件可能达到 O(GBs) 量级,可能导致磁盘空间耗尽。

./member/wal/000000000000000f-00000000000b38c7.wal
./member/wal/000000000000000e-00000000000a7fe3.wal
./member/wal/000000000000000d-000000000009c70c.wal

Raft 的预写日志,包含 Raft 接受的近期事务、定期快照或 CRC 记录。

保留最近的 --max-wals=5 个文件。每个文件的大小为 ~64*10^6 字节。文件超过此硬编码大小时才会被切分,因此实际大小可能略超过该值(所以预分配的 0.tmp 无法完全防止磁盘空间耗尽)。

如果快照生成频率过低,可能会出现超过 --max-wals=5 个文件的情况,因为文件系统级锁会保护这些文件,防止其过早删除。

./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 请求,按要求从指定快照恢复存储。

完整内容成功获取后,文件将被重命名为 /member/snap/[SNAPSHOT-INDEX].snap.db。若服务器在下载过程中崩溃或被终止,这些文件会保留在磁盘上,且不会自动清理。其体积可能达到数 GB。

参见 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
}

保存最近一次 Downgrade RPC 请求所配置的意图。

自 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 文件中的所有桶:
% go run go.etcd.io/bbolt/cmd/bbolt buckets ./default.etcd/member/snap/db
读取特定键值对:
% go run go.etcd.io/bbolt/cmd/bbolt get ./default.etcd/member/snap/db cluster clusterVersion

etcd-dump-db

etcd-dump-db 可用于列出 v3 etcd 后端数据库(bbolt)的内容。

% go run go.etcd.io/etcd/v3/tools/etcd-dump-db  list-bucket default.etcd
alarm
auth
...

更多示例请参见: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 日志文件的命名遵循以下模式:

"%016x-%016x.wal", seq, index

示例:./member/wal/0000000000000010-00000000000bf1e6.wal

因此,文件名包含十六进制编码:

  • WAL 日志文件的顺序编号
  • 文件中第一条条目或快照的索引。 特别地,第一个文件“0000000000000000-0000000000000000.wal”包含索引为 0 的初始快照记录。

物理内容

WAL 日志文件由一系列“帧 ”组成。每个帧包含:

  1. 使用 LittleEndian 2 编码的 uint64,表示序列化后 walpb.Record 的长度(3)。
  2. 填充:若干个 0 字节,使整个帧的大小按 8 字节对齐。
  3. 序列化后的 walpb.Record 数据:
    1. type - 以整数编码的枚举,用于决定如何解释下述 data 字段
    2. data - 由类型决定,通常是序列化后的 Protocol Buffers 数据
    3. 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 日志文件按以下顺序构建:

  1. CRC-32 帧(从之前所有文件延续的 CRC;第一个文件为 0)。

  2. 元数据帧(集群 ID 与副本 ID)。

  3. 仅初始 WAL 文件包含:

    • 空快照帧(索引:0,任期:0)。 此帧用于维持一个不变量:所有条目之前均有一个快照。

    对于非初始(第 2 个及后续)WAL 文件:

    • HardState 帧。
  4. 条目、硬状态与快照记录的混合

WAL 日志可能包含同一索引的多个条目。这种情况可能出现在 Raft 论文 图 7 所描述的场景中。etcd 的 WAL 日志仅支持追加写入,因此当新条目以相同索引写入时,原有条目会被覆盖。

特别是在读取 WAL 时,逻辑会用新条目覆盖旧条目 。因此,仅当条目索引 entry.index <= HardState.commit 时,才可视为最终版本。索引大于 HardState.commit 的条目可能发生变化。

WAL 日志中的“任期”应保持单调递增。

WAL 日志中的“索引”预期满足以下要求:

  1. 从某个快照开始
  2. 在该任期期间,索引应持续递增
  3. 若任期发生变化,索引可能减少,但必须大于最新的 HardState.commit 值
  4. 任意索引大于等于 HardState.commit 的新快照都可能发生,从而开启新的索引序列
etcd 持久化存储文件

工具

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 版本之间引入的文件格式变更。


  1. bbolt 文件开头的元数据页会原地修改。 ↩︎

  2. 不一致,因为大多数 uint 的写入格式为大端字节序 ↩︎

  3. WAL 日志开头的初始快照(索引: 0)不与 *.snap 文件关联。此外,旧的 *.snap 文件(或 WAL 日志)可能已被清除。 ↩︎

来源与许可

文档取自 pig.center · 上游文档

版本
3.7
许可
CC-BY-4.0
来源修订
dba8dc7e1afd3f6eea300ebf9ecb67c731145f1ee7db1ca0d3ebc49508812609
译文修订
dba8dc7e1afd3f6eea300ebf9ecb67c731145f1ee7db1ca0d3ebc49508812609
原始 Markdown