Files
wiki/存储/Ceph/知识库/16-内部实现-核心机制篇.md
T
2026-09-18 13:49:11 +08:00

352 KiB
Raw Blame History

基于 Ceph 官方文档整理 | 文档版本:Tentacle v20.2.4 | 抓取日期:2026-09-17 | 源文:ceph/ceph@v20.2.4 doc/dev/ 官网入口:https://docs.ceph.com/en/tentacle/dev/

第 16 册 · 内部实现篇(一):核心机制、数据结构与协议


本册导读

内部实现四册的分工

本册是「内部实现」四册中的第一册,四册的分工大致如下:

册 主题 关注点
第 16 册(本册) 核心机制、数据结构与协议 存储引擎抽象(ObjectStore / BlueStore / SeaStore)、PG 与 peering 状态机、Messenger 与 msgr2 线协议、编码序列化、cephx/Kerberos 认证、集群映射(mon)元数据、数据路径特性(条带化 / EC / 重删 / CRUSH MSR)、配置与可观测性内部实现
第 17 册 OSD 内部与数据服务 osd_internals/ 下的 async_recovery、backfill_reservation、erasure_coding(ECBackend / jerasure / developer_notes / enhancements / proposals)等
第 18 册 Crimson 与 MDS 内部 crimson/(crimson.rst、osd.rst、pipeline.rst、error-handling.rst、backfillmachine.rst、poseidonstore.rst)与 mds_internals/(data-structures、locking、exports、quiesce)
第 19 册 开发者工具链与工程实践 developer_guide/、测试体系、构建与打包、文档工具链等

💡 补充:上表四册的分工是编者为便于阅读而做的划分,官方 doc/dev/ 并未明确给出「四册」的说法。官方的入口索引是 dev/internals.rst,它只是把内部实现页面按字母/主题平铺列出。

谁该读本册

本册面向想读懂 Ceph 源码、或要做深度调优 / 二次开发的人。

  • 如果你是运维人员,日常做集群部署、扩缩容、故障处理:本册可以跳过。你需要的是 RADOS 运维手册(rados/ 目录下)而不是 dev/ 目录。dev/ 是 Ceph 的开发者文档,官方明确把它定位为「给想理解 Ceph 内部实现的人」的材料,其中的内容不构成任何稳定性承诺——页面里大量出现 TODO、.. todo::、Issues:、This is very incomplete 等字样,很多结构描述甚至落后于代码(例如 cephx_protocol.rst 开头就声明「代码可能已经变了,本文档不会同步更新」)。
  • 如果你是研发 / 性能工程师 / 存储方向的学习者:本册会带你读完 Ceph 最核心的几条内部主线:对象是怎么落到磁盘的(一)→ 副本之间怎么达成一致(二)→ 节点之间怎么通信(三)→ 客户端和服务端怎么互相证明身份(四)→ 集群元数据怎么组织(五)→ 上层特性怎么切分数据(六)→ 怎么观测和调参(七)。

阅读本册需要注意的三件事

  1. 类名、函数名、配置项、默认值、协议字段名、状态机状态名一律保留英文原文。 这不是偷懒:Ceph 里 peering、acting set、up_thru、onode、lba_tree 这类词翻译成中文后几乎无法与代码对应,反而会增加阅读成本。
  2. 本册严格遵循「不编造」原则。 doc/dev/ 的实现类文档普遍不完整,object-store.rst 只有 62 行(其中一半是 graphviz 图,末尾一句 .. todo:: write more here)、messenger.rst 只有 33 行且只讲了一个 benchmark 工具、delayed-delete.rst 只有 13 行。凡是官方没写的,本册会明写「文档未明确」,而不是用想象去填充。
  3. 源码路径是本册的重要信息。 官方在这些页面里大量直接指向 src/... 路径(如 src/crimson/os/seastore/journal.h、src/osd/PeeringState.h)。本册会原样保留这些路径,因为它们是「文档 → 代码」之间唯一的桥梁。

一、对象存储抽象与实现

本章覆盖 object-store.rst、bluestore.rst、seastore.rst、zoned-storage.rst 四页。

⚠️ 关于本章的预期管理:官方 doc/dev/ 的这四页并不是 BlueStore 的完整设计文档。真正的 BlueStore 设计细节(RocksDB / BlueFS / onode / Extent / allocator 的完整语义)在 doc/rados/configuration/bluestore-config-ref.rst 和源码里,而不在 doc/dev/bluestore.rst。本章会如实说明每一页到底覆盖了什么。

1.1 ObjectStore 抽象层(object-store.rst)

官方页面的实际内容

必须首先说明:object-store.rst 全文只有 62 行,正文部分几乎为零。整页由两部分组成:

  1. 一段 graphviz 有向图(第 5–58 行),描述各组件之间的依赖/调用关系;
  2. 第 61 行一句 .. todo:: write more here。

也就是说,官方这一页没有描述 ObjectStore 的接口签名、事务模型(ObjectStore::Transaction)、mkfs/mount/umount 生命周期、collection 语义等任何内容。这些在 dev/ 里是缺失的。

依赖关系图(graphviz 原文还原)

官方图的边(edge)如下,方向表示「上层依赖下层 / 调用下层」:

testrados        -> librados
testradospp      -> librados
rbd              -> librados
radostool        -> librados
radosgw-admin    -> radosgw
radosgw          -> librados
radosacl         -> librados
librados         -> objecter
ObjectCacher     -> Filer
dumpjournal      -> Journaler
Journaler        -> Filer
SyntheticClient  -> Filer
SyntheticClient  -> objecter
Filer            -> objecter
objecter         -> OSDMap
ceph-osd         -> PG
ceph-osd         -> ObjectStore
crushtool        -> CrushWrapper
OSDMap           -> CrushWrapper
OSDMapTool       -> OSDMap
PG               -> PrimaryLogPG
PG               -> ObjectStore
PG               -> OSDMap
PrimaryLogPG     -> ObjectStore
PrimaryLogPG     -> OSDMap
ObjectStore      -> BlueStore
BlueStore        -> rocksdb

从这张图能读出的分层结构

虽然官方没有写正文,但这张图本身信息量不小,可以整理成完整的分层视图:

        ┌───────────────────────────────────────────────┐
        │  测试 / 工具层                                  │
        │  testrados  testradospp  radostool  crushtool   │
        │  dumpjournal  SyntheticClient  OSDMapTool       │
        └───────────────────────────────────────────────┘
                            │
        ┌───────────────────────────────────────────────┐
        │  高层客户端层                                   │
        │  rbd    radosgw (+radosgw-admin)   radosacl     │
        │  ObjectCacher  →  Filer  ←  Journaler          │
        └───────────────────────────────────────────────┘
                            │
        ┌───────────────────────────────────────────────┐
        │  librados  →  objecter  →  OSDMap  →  CrushWrapper │
        └───────────────────────────────────────────────┘
                            │  (网络另一侧)
        ┌───────────────────────────────────────────────┐
        │  ceph-osd  →  PG  →  PrimaryLogPG              │
        │                  ↘  ObjectStore ↘              │
        │  ceph-osd ───────────────────↗    BlueStore    │
        │                                    → rocksdb   │
        └───────────────────────────────────────────────┘

关键点解读:

观察 含义
ObjectStore -> BlueStore 是唯一一条出边 在这份文档的语境里,BlueStore 是 ObjectStore 抽象的唯一实现(图中未画出 SeaStore / FileStore / MemStore)。注意 SeaStore 在 Crimson 分支下也实现了 ObjectStore 接口,但此图未体现
BlueStore -> rocksdb BlueStore 的元数据(onode、extent map、omap 等)由 RocksDB 承载
PG -> PrimaryLogPG 副本型池的 PG 逻辑在 PrimaryLogPG 中;EC 池另有 ECBackend/ECPG,此图未画
PG -> ObjectStore 与 PrimaryLogPG -> ObjectStore 两条边并存 PG 基类与其副本型子类都会直接使用 ObjectStore
ceph-osd -> PG 且 ceph-osd -> ObjectStore OSD 守护进程同时持有 PG 集合与 ObjectStore 实例
objecter -> OSDMap 客户端侧通过 OSDMap + CRUSH 自行计算对象该发给哪个 OSD(这正是 Ceph「客户端可自路由」的体现)
Filer / ObjectCacher / Journaler CephFS/MDS 侧的对象读写与日志抽象,位于 librados 之上

文档未明确的 ObjectStore 内容

以下内容官方 object-store.rst 完全没有提及,本册不做推测:

  • ObjectStore 的虚函数接口清单(mkfs、mount、umount、mkcoll、stat、read、write、touch、clone、omap_* 等);
  • ObjectStore::Transaction 的操作类型与 queue_transaction 语义;
  • coll_t / ghobject_t 的结构定义;
  • ObjectStore 与 PGBackend 之间的边界。

💡 补充:这些内容的权威来源是源码头文件 src/os/ObjectStore.h(类 ObjectStore、ObjectStore::Transaction)与 src/osd/PGBackend.h。本册不转述源码,只标注路径。

引自:Object Store Architecture Overview


1.2 BlueStore 内部:小写入策略(bluestore.rst)

官方页面的实际内容

bluestore.rst 全文 85 行,只讲了一件事:小写入(small write)策略的分类与选型矩阵。它没有描述:

  • RocksDB 与 BlueFS 的关系与布局;
  • onode 的字段结构;
  • Extent / Blob / SharedBlob / pextent 的完整语义;
  • Allocator(BitmapAllocator / AvlAllocator / HybridAllocator / StupidAllocator)的实现;
  • 校验和(csum)计算与校验流程;
  • 压缩、bluefs_db 分层、WAL/DB 设备等配置语义。

⚠️ 这一点需要特别提醒:很多人以为 dev/bluestore.rst 是 BlueStore 的设计总纲,实际上它只是「小写入策略速查表」。本册严格按官方内容展开,不补充官方未写的实现细节。

设计动机

BlueStore 直接管理裸块设备,绕过本地文件系统。这带来一个问题:在裸设备上做小写(small write / partial overwrite)开销极大——因为分配粒度(min_alloc_size,HDD 上通常 64 KiB、SSD 上通常 4 KiB)远大于一次小写的数据量,且校验和是以 csum_block_size 为块计算的,改一个字节就要重算整块。

因此 BlueStore 需要一套策略矩阵:对给定的「写入模式 × blob 类型」,选择一种代价最小的落盘方式。这就是本页全部内容。

六种当前已实现的小写入策略

代号 名称 步骤 说明
U Uncompressed write of a complete, new blob ① write to new blob ② kv commit 写一个全新的、完整的 blob。最干净的路径
P Uncompressed partial write to unused region of an existing blob ① write to unused chunk(s) of existing blob ② kv commit 写已有 blob 的未使用区域,不覆盖已有数据,因此可以避开 RMW
W WAL overwrite ① kv commit ② wal overwrite (chunk-aligned) of existing blob 先把「意图」以 WAL 形式提交到 RocksDB,再异步覆盖写。必须 chunk_size = MAX(block_size, csum_block_size) 对齐
N Uncompressed partial write to a new blob ① write into a new (sparse) blob ② kv commit 写到一个新的稀疏 blob;未来对该区域的写会退化成 P 或 W
R+W Read partial chunk, then WAL overwrite ① read (out to chunk boundaries) ② kv commit ③ wal overwrite (chunk-aligned) of existing blob 最贵:必须先读补齐到 chunk 边界,再走 WAL 覆盖写
C Compress data, write to new blob ① compress and write to new blob ② kv commit 压缩后写入新 blob,用于开启了压缩且数据量够大的场景

关键区分点:

  • U / P / N 是「先写数据、后提交元数据」(write → kv commit)。
  • W / R+W 是「先提交意图、后写数据」(kv commit → wal overwrite)。这两者依赖 BlueStore 的 deferred write(小写先落 RocksDB WAL 再异步落盘)机制。
  • W 要求 chunk 对齐,因为 WAL 覆盖写必须能独立校验与重放。
  • R+W 必须先读,代价最高,通常只在「100 字节级别的覆盖写 + 无缓存 + 有校验和」时出现。

两种「未来可能实现」的模式

代号 名称 步骤 说明
F Fragment lextent space by writing small piece of data into a piecemeal blob ① write to a piecemeal blob (min_alloc_size 或更大,但只用其中 1 个 block) ② kv commit 用一个「碎片化收集 blob」来承载零散小写数据(piecemeal blob 收集随机的、不连续的小块写)。注意:写 min_alloc_size 或更大的 blob,但只使用其中一个 block
X WAL read/modify/write on a single block (like legacy bluestore) ① kv commit ② wal read/modify/write 单块上的 WAL 读改写,类似 legacy bluestore 行为。无校验和(No checksum)

策略选型矩阵(官方原表)

官方说明:这张表是「写入类型 → 遇到某种 blob 时的处理方式」的粗略映射。实际实现更复杂,因为可能同时有多个候选 blob 可供考虑(例如既可以对 blob A 做 W,也可以对 blob B 做 P),这张表传达的是大致策略。

行 = 写入类型;列 = blob 类型(raw / raw cached / 带 4KB 校验和 / 带 16KB 校验和 / 128KB 压缩):

写入类型 raw raw (cached) csum (4 KB) csum (16 KB) comp (128 KB)
128+ KB (over)write U U U U C
64 KB (over)write U U U U U 或 C
4 KB overwrite W P | W P | W P | R+W P | N (F?)
100 byte overwrite R+W P | W P | R+W P | R+W P | N (F?)
100 byte append R+W P | W P | R+W P | R+W P | N (F?)
4 KB clone overwrite P | N P | N P | N P | N N (F?)
100 byte clone overwrite P | N P | N P | N P | N N (F?)

矩阵解读(几条可读出来的规律)

  1. 大写入(≥64 KB)几乎总是 U(或压缩场景的 C):因为写入量已经覆盖整个分配单元,不需要 RMW,直接写新 blob 最省。
  2. 一旦引入校验和(csum 列),小写的代价显著上升:同样的 4 KB 覆盖写,在 raw 上是 W,在 csum(4K) 上是 P|W,在 csum(16K) 上退化到 P|R+W——因为 csum_block_size 越大,一次小写需要读回重算的字节就越多。
  3. 开启压缩(comp 128 KB)后,小覆盖写只能走 N:因为压缩 blob 无法原地覆盖,只能写新 blob(N),或在未来版本中用 F(piecemeal blob)来聚集碎片。
  4. raw (cached) 一列比 raw 便宜:因为缓存里已有数据,可以省掉 R+W 的读,从 R+W 降为 P|W。
  5. clone overwrite 一律是 P|N:克隆出的对象需要做 copy-on-write,因此要么写未使用区域(P),要么写新 blob(N)。

💡 补充:矩阵中的 | 表示「备选」,不是固定的优先级;官方未给出在 P 与 W 同时可行时的选择判据,这部分逻辑在源码 src/os/bluestore/BlueStore.cc 的 _do_write 相关路径中,本册不展开。

引自:BlueStore Internals


1.3 SeaStore:Crimson 的新存储引擎(seastore.rst)

SeaStore 是 Crimson(Ceph 的 Seastar 化 OSD)配套的新一代存储引擎。官方这一页有 325 行,是本章内容最完整的一页。

1.3.1 目标与基础(Goals and Basics)

目标 说明
面向 NVMe 设备 不主要关注 pmem 或 HDD
使用 SPDK 做用户态驱动的 IO
使用 Seastar futures 编程模型 以支撑 run-to-completion 与分片式(sharded)内存/处理模型
零拷贝 / 最少拷贝 当与基于 Seastar + DPDK 的 messenger 结合时,读写路径上做到零(或极小)数据拷贝

1.3.2 设计动机与背景(Motivation and background)

核心矛盾:

全闪存设备内部以 segment 为单位组织:
  - segment 可以被高效写入
  - 但必须整体擦除(erase)
NVMe 设备本身对「segment 内哪些数据是 live(未被逻辑丢弃)」所知有限
  → 设备内部的垃圾回收(GC)因此效率低下

解决思路:设计一种对下层 GC 友好的 on-disk 布局,并把垃圾回收上移到更高层来做。

具体做法:

  1. 所有数据顺序地流式写入设备上的大 segment。SSD 硬件内部的 segment 大小通常在数百 MB 到数十 GB 量级。
  2. SeaStore 的逻辑 segment 理想情况下应与硬件 segment 完美对齐。但实践中很难确定设备几何信息,也很难向设备提示「正在写的 LBA 应对齐到底层硬件」。
  3. 最坏情况下的兜底:把逻辑 segment 设为物理 segment 的 5 倍,这样大约只有 ~20% 的数据是错位的。
  4. 达到某个利用率阈值后,把清理工作混入正在进行的写工作负载中,从先前写过的 segment 里疏散(evacuate)还活着的数据。
  5. 一旦一个 segment 完全空闲,就可以 discard 整个 segment,让设备擦除并回收它。
  6. 关键:把一小部分清理工作混入每一个写事务,以避免写延迟出现尖峰(spike)和剧烈波动(variance)。

为什么不用 fine-grained discard(细粒度 discard)向设备传达意图?官方给出的理由是:实践中 discard 在设备固件和中间软件层里实现得很差。

1.3.3 数据布局基础(Data layout basics)

  • 一个或多个 core/shard 会同时对设备进行读写。
  • 每个 shard 有自己独立的数据,并流式写入自己打开的 segment。
  • 支持 streams 的设备可以被相应地提示(hint),使得来自不同 shard 的数据不会在底层介质上混杂。

持久内存(Persistent Memory):待上述的「顺序写」设计成熟后,会引入持久内存用于承载元数据和缓存结构。(官方只是一句展望,未给出方案。)

1.3.4 整体设计(Design)

设计** heavily based on f2fs 和 btrfs**。

  • 每个 reactor 管理自己的 root。
  • 在重用一个 segment 之前,先把其中所有 live block 重写到某个 open segment。
  • 因为只向 open segment 顺序写,所以稳态下每写入 1 字节,就必须清理 1 字节的已有 segment。
  • 因此必须预留一部分可用容量,以保证写放大(write amplification)维持在可接受水平。官方给出的数字是「20% for 2x?」,并标注 TODO: find prior work(即这个数字没有被严谨论证)。
  • 明确的设计选择:避免后台 GC 机制。理由有二:
    1. 后台 GC 会使操作成本估算复杂化;
    2. 后台 GC 容易引入不确定的延迟行为(non-deterministic latency)。
  • 因此需要一套能够把 block 重定位工作内联(inline)进正常客户端 IO 的结构。

两棵基础元数据树:

树 作用 备注
onode_by_hobject 映射 ghobject_t → onode_t 后文 Logical Layout 小节写作「onode tree: maps hobject_t to onode_t」
lba_tree ① 找到一个 segment 内的 live block;② 把内部引用与物理位置解耦 后文写作「lba_tree: maps lba_t to lba_range_t」

⚠️ 注意:官方在 Design 小节说第一棵树是 ghobject_t -> onode_t,在 Logical Layout 小节说是 hobject_t -> onode_t。文档自身存在表述不一致,本册原样记录。

每个 onode 直接包含 xattrs,以及 omap 树 与 extent 树的树根。 (优化方向:足够小的对象应该能整个塞进 onode 里。)

1.3.5 Segment 布局(Segment Layout)

Segment 三态:empty、open、closed。

Segment 的字节内容 = 一串 record(记录)序列。

Record 结构:

record: [ header | delta | delta... | block | block ... ]
segment: [ record ... ]
  • header:包含长度(length)与校验和(checksums)。
  • delta:描述对某个 block 的一次逻辑变更(logical mutation)。
  • block:一个对齐的 extent,由 <segment_id_t, segment_offset_t> 寻址。
  • 一个事务 = 构造一个 record(组合若干 delta 与更新后的 block),并把它写到一个 open segment。

Segment 会很大(大约 ≥256MB),因此通常不会有太多个。

每个 shard 保持 N 个 segment 处于 open 状态用于写入:

设备类型 N 的取值
HDD 大概是 1(在单个 shard 上)
NVME/SSD 大概是 2/shard:一个用作 "journal",一个用作已完成的 data record,因为它们的生命周期不同

官方明确指出:open segment 的确切数量、以及如何在它们之间划分写入,是一个调优问题 —— gc/layout 应当保持灵活。实践中的目标大概是按预期生命周期对 block 分区,使得一个 segment 要么全是长生命周期 block,要么全是短生命周期 block。

相关源码:

  • Journal 实现:src/crimson/os/seastore/journal.h
  • 大部分 seastore 结构定义:src/crimson/os/seastore/seastore_types.h
  • 后端物理层以 segment 为单位抽象出来:src/crimson/os/seastore/segment_manager.h

1.3.6 Journal 与原子性(Journal and Atomicity)

  • 某一个 open segment 被指定为 journal。
  • 一个事务由一个原子写入的 record 表示。
  • 一个 record 包含:① 作为该事务一部分写入的 block;② 对已有物理 extent 的逻辑变更 delta。
  • 事务的 delta 总是写入 journal。
  • 如果事务还关联着写到其他 segment 的 block,那么「包含这些 delta 的最终 record」必须等到那些 block 都持久化之后才能写入。

崩溃恢复流程(Crash recovery):

  1. 找到包含当前 journal 起点的 segment;
  2. 加载 root node;
  3. 重放(replay)delta;
  4. 按需把 block 加载进 cache。

源码:src/crimson/os/seastore/journal.h

1.3.7 Block Cache

每个 block 处于两种状态之一:

状态 含义
clean 可能在 cache 中,也可能不在;读操作可能导致其进入 cache,也可能不会
dirty 该 record 的当前版本需要叠加(overlay)来自 journal 的 delta;必须完整存在于 cache 中

Journal trim(裁剪):

  • 必须周期性地 trim journal,否则就得从「时间起点」开始重放 journal delta。
  • Trim 的方法是创建一个 checkpoint:重写 root block 与所有当前 dirty block。
  • Journal checkpoint 可以做得相对不频繁,且不必阻塞写流。

Delta 不一定是字节范围修改(byte range modification) —— 这一点很重要:

考虑一个 btree 节点,其结构是「key 在左、value 在右」(提升点查 / key scan 性能的常见技巧)。在这个节点的最小位置插入一个 key/value,需要移动大量字节——如果纯粹用一串字节操作来表达,代价会非常昂贵(或者非常冗长)。

因此,每个 delta 都会指明它所对应的 extent 的「类型」以及「位置」,每种 block 类型可以自行实现 CachedExtent::apply_delta。

源码:src/os/crimson/seastore/cached_extent.h、src/os/crimson/seastore/cache.h

1.3.8 GC(垃圾回收)

基本约束:在重用一个 segment 之前,必须先重定位其中所有 live block。因为只向 empty segment 顺序写,所以每向当前 open segment 写 1 字节,就需要清理 1 字节的已有 closed segment。

与 Design 小节一致:避免后台工作,因此每个 seastore reactor 会内联插入足够的清理工作,使清理 segment 的速率与到达的操作速率相同。

如何廉价地识别死块(dead block):

  • 为了让稀疏 segment 的清理变得便宜,需要一种能正向识别死块的方法。
  • 做法:每写一个 block,就往 lba tree 里加一个条目,其中包含指向该 segment 中前一个 lba 的指针(即形成链表)。
  • 任何移动 block、或修改已有 block 引用集的事务,都会包含更新 lba tree 所需的 delta/block,以更新或删除前一个 block 分配。
  • 因此 GC 状态只需维护:
    • 一个指向「当前正在清理的 segment」在 lba tree segment 链表中的迭代器(某种意义上的);
    • 一个指向「下一个待检查 record」的指针。
  • 注意:不在分配树(allocation tree)里的 record 仍可能包含 root(例如 allocation tree block 本身),所以必须检查 record 元数据中指示 root block 的标志位。

清理判据:对每个事务,评估一个关于「当前可用空间」与「当前 live 空间」的启发式函数,以决定是否需要做清理工作(可以简单到就是一个 live/used 空间比例区间)。

⚠️ 官方明确写道:TODO: there is not yet a GC implementation —— 到 Tentacle v20.2.4 为止,SeaStore 的 GC 尚未实现。

1.3.9 逻辑布局(Logical Layout)

在上述 block + delta 语义之上,构建两棵 root 级树:

树 映射
onode tree hobject_t → onode_t
lba_tree lba_t → lba_range_t

寻址方式:

  • 上述结构都由「以 delta 编码变更」的 block 组成;树的每个节点对应一个 block。
  • 每个 block 要么是物理寻址(physically addressed),要么是逻辑寻址(logically addressed):
类型 适用对象 寻址方式
物理寻址 root block、lba_tree 节点 由 paddr_t(<segment_id_t, segment_off_t> 二元组)定位,并在 record 中被标记为 physically addressed
逻辑寻址 其余一切 由 laddr_t 寻址,需要查 lba_tree 才能定位

原子性的来源:因为 cache/transaction 机制位于 lba tree 之下,所以「对 lba tree 与其他结构的原子变更」可以通过简单地把两者放进同一个事务来表示。

1.3.10 LBAManager / BtreeLBAManager

  • LBAManager 接口的实现负责管理逻辑 → 物理的映射。源码:crimson/os/seastore/lba_manager.h。
  • BtreeLBAManager 在 Journal 与 SegmentManager 之上,用 wandering btree 方法直接实现该接口。

相对地址问题(关键设计点):

因为 SegmentManager 不允许我们预测一个已提交 record 的位置(这是 SMR 与 Zone 设备的共同属性),所以同一事务内创建的 block 之间的引用必然是「相对地址」。

BtreeLBAManager 维护的不变量(invariant):

条件 内存中 block 副本包含的地址类型
!is_pending() 只包含绝对地址;on_commit 与 complete_load 基于实际 block 地址填充,on_delta_write 基于刚提交的 record 填充
is_pending() && is_initial_pending block_relative(因为它们将被写到原始 block 位置)
is_pending() 且非 initial_pending record_relative(值将被写入 delta)

1.3.11 TransactionManager

TransactionManager 负责在 Journal、SegmentManager、Cache、LBAManager 之上提供一个统一接口。使用者可以基于逻辑地址分配和变更 extent,segment cleaning 在后台处理。

源码:crimson/os/seastore/transaction_manager.h

1.3.12 Next Steps(官方列出的未完成项)

模块 待办
Journal ① 支持扫描一个 segment 以找到物理寻址的 block;② 支持 trim journal 并释放 segment
Cache ① 支持重写 dirty block —— 需要给 CachedExtent 增加查找/更新依赖 block 的能力;② 需要支持把 dirty block 写出加到 try_construct_record 里
LBAManager ① 支持 pinning;② 支持 segment -> laddr 供 GC 使用;③ 支持定位 segment 中剩余的已用 block
GC ① 初始实现;② BtreeLBAManager 支持跟踪 segment 中的已用 block;③ 识别待清理 segment 的启发式
Other ① 支持周期性生成 journal checkpoint;② Onode tree;③ Extent tree;④ 剩余的 ObjectStore 集成工作

1.3.13 ObjectStore 的适配问题:split、merge 与 sharding

当前的 ObjectStore 需求之一:能够在 O(1) 时间内拆分(split)一个 collection(PG)。从 mimic 开始,还需要能够把两个 collection 合并成一个(即 split 的严格逆操作)。

冲突点:

  1. 我们 split 出来的那些 PG,在当前的 sharding 方案下会 hash 到 OSD 的不同 shard 上。
  2. 可以想象用一种「临时映射」替代该 sharding 方案,把较小的子 PG 引导到正确的 shard(因为我们通常随后就会把该 PG 迁移到另一个 OSD)。
  3. 但这对 merge 场景没帮助:merge 时,组成部分可能一开始就在不同 shard 上,最终却需要被放进同一个 collection(并且要能通过单个事务来操作)。

结论:我们很可能需要一种机制,让通过某个 shard 写入的数据能够「切换所有权」(switch ownership),之后被另一个 shard 读取和管理。

引自:SeaStore


1.4 Zoned Storage(zoned-storage.rst)

背景

Zoned Storage 是一类存储设备,通过让主机与存储设备协作来实现更高容量、更高吞吐与更低延迟。分区存储接口目前通过以下标准提供:

标准 适用设备
SCSI ZBC(Zoned Block Commands) 叠瓦式磁记录(SMR)硬盘
ATA ZAC(Zoned Device ATA Command Set) 同上
NVMe ZNS(Zoned Namespaces) NVMe SSD(当时尚未普及)

项目主页:http://zonedstorage.io

项目目标

  1. 使 Ceph 能够运行在 zoned storage 设备上;
  2. 同时探索采用这种新接口所涉及的研究问题。

第一阶段目标:在 host-managed SMR(HM-SMR) 驱动器上支持非覆盖写(non-overwrite)负载(例如 RGW),并探索清理(garbage collection)策略。HM-SMR 是带 ZBC/ZAC 接口的大容量硬盘。 长期目标:支持 ZNS SSD(随其可用性推进),以及覆盖写(overwrite)负载。

引入的新组件:ZonedFreelistManger

官方说明的补丁序列:

  1. 第一个 patch:使得可以向 HM-SMR 驱动器写数据。
  2. 本 patch:引入 ZonedFreelistManger —— 一个 FreelistManager 的实现,它向 ZonedAllocator 传递足够的信息,通过**跟踪每个 zone 的写指针(write pointer)与死字节数(number of dead bytes per zone)**来正确初始化 zone 状态。
  3. 第三个(计划中的)patch:引入一个初步的 cleaner,作为后续研究的基线。

为什么必须引入新的 FreelistManager?

状态数 能表达的状态
现有 BitmapFreelistManager 2 empty、used
分区设备上的一块磁盘区域 3 empty、used、dead

只跟踪两种状态无法准确初始化 ZonedAllocator 中的 zone 状态。

当前能力状态

官方声称可以做到:

  • 在运行于 HM-SMR 驱动器的 OSD 上执行基本的 RADOS benchmark;
  • 重启 OSD;
  • 读取已写入的数据,并写入新数据。

示例:在 HM-SMR 上跑 vstart 集群

$ sudo zbd report -i -n /dev/sdc
Device /dev/sdc:
    Vendor ID: ATA HGST HSH721414AL T240
    Zone model: host-managed
    Capacity: 14000.520 GB (27344764928 512-bytes sectors)
    Logical blocks: 3418095616 blocks of 4096 B
    Physical blocks: 3418095616 blocks of 4096 B
    Zones: 52156 zones of 256.0 MB
    Maximum number of open zones: no limit
    Maximum number of active zones: no limit
52156 / 52156 zones

$ MON=1 OSD=1 MDS=0 sudo ../src/vstart.sh --new --localhost --bluestore \
      --bluestore-devs /dev/sdc --bluestore-zoned

$ sudo ./bin/ceph osd pool create bench 32 32
$ sudo ./bin/rados bench -p bench 10 write --no-cleanup

重启(注意第二次不带 --new):

$ sudo ../src/stop.sh
$ MON=1 OSD=1 MDS=0 sudo ../src/vstart.sh --localhost --bluestore \
      --bluestore-devs /dev/sdc --bluestore-zoned
$ sudo ./bin/rados bench -p bench 10 rand

官方给出的实测数据(使用 HGST HSH721414AL,52156 个 256MB zone):

负载 带宽 平均 IOPS 平均延迟
4 MB 顺序写(write --no-cleanup,首次) 153.333 MB/s 38 0.411226 s
4 MB 随机读(rand,重启后) 251.558 MB/s 62 0.249385 s
4 MB 顺序写(重启后再次写) 约 143 MB/s — 约 0.436 s

联系人:Abutalib Aghayev aghayev@psu.edu

引自:Zoned Storage Support


二、PG 与 peering

本章覆盖 placement-group.rst 与 peering.rst。peering 是 Ceph 中最难理解的部分,2.2 节会尽可能讲透。

2.1 PG 的内部结构与状态(placement-group.rst)

官方页面的定位

页面开头第一句就说明了它的性质:

Miscellaneous copy-pastes from emails, when this gets cleaned up it should move out of /dev.

即:这是一堆邮件片段的拼贴,整理好之后应该从 dev/ 移走。 因此这页的风格是问答式的,而不是规范式的。

2.1.1 概述:为什么需要 PG

概念 说明
PG = placement group 放置数据时,对象被映射到 PG,PG 再被映射到 OSD
为什么要这层间接? 为了把对象分组,从而减少需要维护的 per-object 元数据量、以及需要运行的进程数量。如果不分组,按对象跟踪(例如)placement history 会昂贵到不可行
PG 数量的权衡 增加 PG 数可以降低集群中 per-OSD 负载的方差,但每个 PG 都会在存放它的 OSD 上多消耗一点 CPU 和内存
经验值 大致按 100 PGs/OSD 估算;官方说「根据你的集群不同,可以有很大偏差而不会产生不良影响」
PG 类别 存在几种不同类别的 PG。(原文提到:在原始邮件作者的 ceph -s 输出里有 6 个 "local" PG,它们绑定在特定 OSD 上,但在标准 Ceph 配置中并不使用)

2.1.2 映射算法(简化版)

官方以问答形式给出:

问题 回答
Object→PG 是怎么映射的?一个 PG 上会映射多个对象吗?一个对象会映射到多个 PG 吗? 多个对象映射到一个 PG。每个对象恰好映射到一个 PG。
PG→OSD 呢?一个 PG 只属于一个 OSD 吗? 一个 PG 映射到一个 OSD 列表,列表中第一个是 primary,其余是 replica。多个 PG 可以映射到同一个 OSD。
一个 PG 代表固定大小的存储空间吗? 不代表。 PG 只是对象的一个分组;你配置你想要的 PG 数量(OSD 数 × 100 是个不错的起点),所有存储的对象伪随机、均匀地分布到这些 PG 上。所以一个 PG 明确地不代表固定的存储量,它代表你 OSD 上存储量的 1/pg_num。

伪代码(忽略 CRUSH 与自定义放置的细节):

locator = object_name
obj_hash = hash(locator)
pg = obj_hash % num_pg
OSDs_for_pg = crush(pg)   # 返回一个 OSD 列表
primary = osds_for_pg[0]
replicas = osds_for_pg[1:]

crush() 的极简心智模型(官方原文:想象一个「真空中的完美球形数据中心」——即所有 OSD 权重都是 1.0、数据中心没有拓扑结构、所有 OSD 都在顶层、使用默认值等,此时它退化为一致性哈希):

def crush(pg):
    all_osds = ['osd.0', 'osd.1', 'osd.2', ...]
    result = []
    # size 是副本数;primary + replicas
    while len(result) < size:
        r = hash(pg)
        chosen = all_osds[r % len(all_osds)]
        if chosen in result:
            # 同一个 OSD 只能被选中一次
            continue
        result.append(chosen)
    return result

2.1.3 用户可见的 PG 状态(完整清单)

官方在此处有一个 .. todo:: diagram of states and how they can overlap(状态图及它们如何重叠的示意图尚未绘制)。

本册把全部 28 个状态整理成表(顺序与官方一致,释义逐条对应原文):

状态 官方释义
creating PG 正在被创建
active 发往该 PG 的请求会被处理
clean PG 中所有对象都已按正确的副本数复制
down 一个持有必要数据的副本挂了,因此该 PG 离线
recovery_unfound recovery 无法完成,因为有对象处于 unfound 状态
backfill_unfound backfill 无法完成,因为有对象处于 unfound 状态
premerge 由于即将发生 PG merge,该 PG 处于 IO 静默(quiesced-IO)状态。发生在 pg_num_pending < pg_num 时,适用于满足 pg_num_pending <= ps < pg_num 的 PG,以及它要合并进去的那个对应 peer PG
scrubbing PG 正在被检查不一致性
degraded PG 中部分对象尚未达到足够的副本数
inconsistent PG 的各副本不一致(例如对象大小错误、recovery 结束后某副本仍缺对象等)
peering PG 正在经历 peering 过程
repair PG 正在被检查,发现的任何不一致都会被修复(如果可能)
recovering 对象正在被迁移 / 与副本同步
backfill_wait PG 正在排队等待开始 backfill
incomplete PG 缺少一段必要的历史记录(log)。如果看到这个状态,请报 bug,并尝试启动任何可能含有所需信息的、已失败的 OSD
stale PG 处于未知状态 —— monitor 自 PG 映射变更后就没有收到过它的更新
remapped PG 被临时映射到一组与 CRUSH 指定不同的 OSD
deep 与 scrubbing 同时出现时,表示这是一次 deep scrub
backfilling recovery 的一种特殊情形:扫描并同步 PG 的全部内容,而不是根据近期操作的 PG log 推断需要传输什么
backfill_toofull backfill 预留被拒绝,OSD 太满
recovery_wait PG 正在等待本地 / 远程 recovery 预留
undersized 给定其 size,PG 无法选出足够的 OSD
activating PG 已经 peered,但尚未 active
peered PG 已经 peered,但无法进入 active
snaptrim PG 正在 trim 快照
snaptrim_wait PG 已排队等待 trim 快照
recovery_toofull recovery 预留被拒绝,OSD 太满
snaptrim_error PG 由于错误无法完成 snap trimming
forced_recovery PG 已被标记为最高优先级 recovery
forced_backfill PG 已被标记为最高优先级 backfill
failed_repair 一次修复该 PG 的尝试失败了,需要人工介入

💡 补充:这组状态在代码中对应 PG_STATE_* 位标志(见 src/include/object.h / src/osd/osd_types.h),因此多个状态可以同时出现(典型如 active+clean、active+degraded+remapped+backfilling)。官方在这里的 TODO 正是「画一张状态图说明它们如何重叠」。

2.1.4 OMAP 统计(OMAP STATISTICS)

Omap 统计在 deep scrub 期间收集,并显示在以下命令的输出中:

ceph pg dump
ceph pg dump all
ceph pg dump summary
ceph pg dump pgs
ceph pg dump pools
ceph pg ls

重要限制:由于这些统计不是持续更新的,在「deep scrub 运行得不频繁」或「omap 活动很频繁」的环境中,它们可能相当不准确。因此不应依赖它们来获得精确数值,而应只作为参考(guide)。

想获得当前 omap 用量的较好指示:跑一次 deep scrub,并紧接着检查这些统计。

引自:PG (Placement Group) notes


2.2 peering(peering.rst)—— Ceph 最难的部分

本节是全册的重点。peering 决定了 Ceph 在故障、重启、网络分区之后,如何在不丢失已确认写入的前提下重新建立一致性。理解它需要同时理解:acting set / up set、interval、PG log、missing set、past intervals、up_thru、authoritative history。

2.2.1 概念表(Concepts)

官方定义逐条整理(保留英文术语):

术语 定义
Peering 让存放某个 PG 的所有 OSD 就该 PG 中所有对象的状态、以及这些对象关联的所有元数据达成一致的过程。注意:两个 OSD 可以就 PG 中对象的状态达成一致,却仍未必持有最新的内容
Acting set 负责某个 PG 的有序 OSD 列表(是「某个 epoch 时」或「曾经是」的负责者)
Up set 根据 CRUSH,某个特定 epoch 下负责某个 PG 的有序 OSD 列表。它与 acting set 相同,除非 acting set 被通过 OSDMap 中的 PG temp 显式覆盖
PG temp 在回填(backfill)primary OSD 期间使用的临时 PG acting set
current interval / past interval 一段 OSD map epoch 序列,在此期间某 PG 的 acting set 与 up set 都不发生变化
primary acting set 中负责协调 peering 的成员。也是唯一接受客户端发起的、写往该 PG 中对象的写操作的 OSD。按惯例,primary 是 acting set 的第一个成员
replica PG 的 acting set 中的非 primary OSD。一个 replica 已被确认为非 primary OSD,且已被 primary activate
stray 一个不是当前 acting set 成员、且尚未被通知删除其某 PG 副本的 OSD
recovery 确保 PG 中所有对象的副本都存在于 acting set 的所有 OSD 上的过程。peering 完成后,primary 就可以开始接受写操作,recovery 则在后台进行
PG info 关于 PG 的基本元数据:创建 epoch、该 PG 最近一次写入的 version、last epoch started、last epoch clean、以及 current interval 的起点。任何 OSD 间关于 PG 的通信都包含 PG info,这样任何知道某 PG 存在(或曾经存在)的 OSD 也就有了 last epoch clean 或 last epoch started 的一个下界
PG log 对 PG 中对象所做的最近更新的列表。在 acting set 中所有 OSD 都确认了这些变更之后,这些 log 可以被截断
missing set 所有「内容尚未更新到与 log 条目一致」的对象的集合。missing set 由每个 OSD 各自整理。missing set 是按 <OSD,PG> 粒度跟踪的
Authoritative History 一个完整的、全序的操作集合,能够把某个 OSD 的 PG 副本更新到最新状态
epoch (单调递增的)OSD map 版本号
last epoch start acting set 中所有节点就一份 authoritative history 达成一致的最后一个 epoch。在 last epoch 开始时,peering 被认为已经成功
up_thru 在 primary 能够成功完成 peering 之前,它必须通过让 monitor 在 osd map 中设置其 up_thru,来告知 monitor 它「活到了当前 OSD map epoch」。这有助于 peering 忽略那些 peering 从未完成的先前 acting set
last epoch clean acting set 中所有节点完全最新(既包括 PG 的 log,也包括 PG 的对象内容)的最后一个 epoch。此时 recovery 被认为已经完成

2.2.2 PG temp 的工作方式(官方原文示例)

假设 acting set = [0,1,2],且我们处于 active+clean。
现在发生了一些事,acting set 变成 [3,1,2]。
在这种情况下,OSD 3 是空的,即使它是 primary 也无法服务读。

处理流程:

  1. osd.3 会响应:使用一条 MOSDPGTemp 消息,向 monitor 请求一个 PG temp = [1,2,3];
  2. osd.1 临时成为 primary;
  3. osd.1 会选择 osd.3 作为 backfill peer,并在 osd.3 被 backfill 期间继续服务读写;
  4. backfill 完成后,PG temp 被丢弃;acting set 变回 [3,1,2],osd.3 成为 primary。

2.2.3 Golden Rule(黄金规则)—— 整个 peering 的基石

对任何 PG 的任何写操作,在该 PG acting set 的所有成员都将其持久化之前,不会向客户端确认。

由此推出的两个关键结论:

  1. 可恢复性:如果自上次成功 peering 以来,我们能与每个 acting set 中的至少一个成员通信,那么一定有人持有自上次成功 peering 以来每一次(已确认的)操作的记录。
  2. 可构造性:因此,当前的 primary 应当总是有可能构造并分发一份新的 authoritative history。

2.2.4 OSD map 在 peering 中的角色

官方强调三点:

  1. 当 OSD 上线或下线(或被添加/移除)时,这可能影响很多 PG 的 active set。
  2. 在 primary 成功完成 peering 之前,OSD map 必须反映出「该 OSD 在 current interval 的第一个 epoch 时是活的、健康的」。
  3. 只有在 peering 成功之后,才能进行变更。

综合起来:一个新的 primary 可以利用最新的 OSD map,外加近期 map 的历史,生成一组 past intervals,从而确定在能够成功 peer 之前必须咨询哪些 OSD。

  • past intervals 的集合由 last epoch started 界定 —— last epoch started 是「我们知道 peering 已完成」的最近的那个 past interval。
  • OSD 最初发现某个 PG 存在的途径,是通过交换 PG info 消息,因此 OSD 总是对 last epoch started 有某个下界。

2.2.5 up_thru 到底解决什么问题(官方给出的失败序列)

up_thru 的定义里给出了一个具体的失败序列,这是理解 up_thru 的关键:

* acting set = [A,B]
* acting set = [A]
* acting set = []    (紧随其后,例如同时发生故障但检测有先后)
* acting set = [B]   (B 重启了,A 没有)

问题:如果没有 up_thru,peering 会去咨询那些「peering 从未真正完成」的旧 acting set,从而可能基于不完整的历史做出判断。

解决:primary 必须让 monitor 把它的 up_thru 设到当前 OSD map epoch,以此证明「我确实活到了当前 epoch」。这样 peering 就可以忽略掉那些 peering 从未完成的先前 acting set。

💡 补充:up_thru 与 last epoch started 是配合使用的——后者界定「要回溯到哪个 past interval 为止」,前者用来筛掉那些「虽然存在过、但 peering 从未完成」的 interval。判据见下面步骤 2。

2.2.6 peering 主流程(官方的 10 大步骤)

以下是官方 Description of the Peering Process 的完整流程,本册按原文逐步展开。

前提:当前 PG primary 执行下述过程。


步骤 1:获取最近的 OSD map

  • 目的:① 识别出所有有意义的 acting set 的成员;② 确认我们自己仍然是 primary。

步骤 2:生成自 last epoch started 以来的 past interval 列表,并筛选

  • 生成自 last epoch started 以来的所有 past interval。
  • 筛选条件:考虑那些「在最后一个 interval epoch 的 OSD map 中,up_thru 大于该 interval 的第一个 epoch」的 interval 子集。
    • 用人话说:只保留那些「在 acting set 变成另一组 OSD 之前,peering 有可能已经完成」的 interval。
  • 成功的 peering 要求我们能够联系到每个 past interval 的 acting set 中的至少一个 OSD。

步骤 3:向列表中的每个节点索要 PG info

  • PG info 包含:① 该 PG 最近的写入;② 一个 last epoch started 的值。
  • 优化:如果我们得知某个 last epoch started 比我们自己的更新,就可以剪掉(prune)更老的 past interval,从而减少需要联系的 peer OSD。

步骤 4:补齐自己的 PG log

  • 如果任何其他节点的 PG log 里有我们没有的操作,就指示它们把这些缺失的 log 条目发给我,使得 primary 的 PG log 是最新的(包含最新的写入)。

步骤 5:对当前 acting set 的每个成员,做两件事

5.a 索要自 last epoch start 以来的所有 PG log 条目副本

  • 目的:验证它们与我的 log 一致(或者,弄明白我将要指示它删除哪些对象)。

分歧(divergence)场景 —— 这是 peering 最反直觉的地方:

如果集群在某个操作被 acting set 所有成员持久化之前发生故障,
且随后的 peering 没有记住那个操作,
而后一个「确实记住了那个操作」的节点重新加入,
那么它的 log 就会记录一段与「故障后 peering 重建出来的 authoritative history」
不同的(divergent)历史。

处理方式:

  • 由于这些 divergent 事件没有被该 acting set 的其他 log 记录,因此它们没有被向客户端确认过;
  • 所以丢弃它们并无害处(这样所有 OSD 就都对 authoritative history 达成一致了);
  • 但是,我们必须指示任何存有「来自 divergent 更新的数据」的 OSD,删除受影响的(现在被认定为伪造的 / apocryphal)对象。

5.b 索要它的 missing set

  • missing set = 在它的 PG log 中记录了、但它还没有新数据的那些对象更新。
  • 这份清单就是在我们能接受写操作之前,必须被完整复制的对象列表。

步骤 6:authoritative history 成型

  • 此时,primary 的 PG log 包含了该 PG 的一份 authoritative history,且该 OSD 已有充足信息可以把 acting set 中的任何其他 OSD 更新到最新。

步骤 7:确保 up_thru(需要时向 monitor 申请)

  • 如果 primary 在当前 OSD map 中的 up_thru 值没有大于或等于 current interval 的第一个 epoch:
    • 向 monitor 发送请求以更新它;
    • 等待直到收到反映了该变更的更新后的 OSD map。

步骤 8:向当前 acting set 的每个成员分发日志更新

8.a 给它们发送 log 更新,使它们的 PG log 与我自己的(authoritative history)一致……这可能涉及决定删除 divergent 对象。

8.b 等待它们确认已持久化这些 PG log 条目。


步骤 9:元数据完全一致,开始接受写与数据恢复

此时,acting set 中的所有 OSD 就所有元数据达成一致,并且(在任何未来的 peering 中)都会返回完全相同的更新记录。

9.a 开始接受客户端写操作

  • 因为我们对「这些更新将被写入其中的对象的状态」取得了一致同意。
  • 注意:如果客户端尝试写一个对象,该对象会被提升到 recovery 队列的最前面,并且该写操作会在它被完整复制到当前 acting set 之后才被应用。

9.b 更新本地 PG info 中的 last epoch started 值

  • 并指示其他 active set OSD 做同样的事。

9.c 开始拉取(pull)其他 OSD 有、而我没有的对象数据更新

  • 我们可能需要查询额外的 past interval 中的 OSD:
    • 早于 last epoch started(上一次 peering 完成的时间)的;
    • 以及晚于 last epoch clean(上一次 recovery 完成的时间)的;
  • 以便找到所有对象的副本。

9.d 开始推送(push)对象数据更新给还没有这些更新的其他 OSD

为什么是 primary 推送,而不是让 replica 自己拉取? 官方给出两条理由:

  1. 这使得 primary 可以确保一个 replica 在收到更新写之前已经拥有当前内容;
  2. 这使得一次读(从 primary)就可以用于把数据写到多个 replica。如果每个 replica 各自拉取,数据可能必须被读多次。

步骤 10:更新 last epoch clean 并解散 stray

  • 一旦所有 replica 都存有了所有对象(在本 epoch 开始之前就存在的那些对象)的所有副本,我们就可以:
    • 更新 PG info 中的 last epoch clean;
    • 解散(dismiss)所有 stray 副本,允许它们删除那些「它们已不再属于其 acting set」的对象的副本。

为什么在此之前不能解散 stray? 官方解释:

因为有可能这些 stray 中的某一个,持有某个旧对象的唯一存活副本(该对象的所有副本在当前 acting set 成员完成复制之前就都消失了)。

2.2.7 全流程时序图(本册整理)

 primary                        replicas / past interval OSDs        monitor
    |                                     |                              |
    |--(1) 获取最近 OSDMap --------------------------------------------->|
    |                                     |                              |
    |--(2) 计算 past intervals(用 up_thru 过滤)                        |
    |                                     |                              |
    |--(3) PG info 请求 ----------------->|                              |
    |<---- PG info(含 last_epoch_started)-|                             |
    |      (若别人的 les 更新 → prune 更老的 interval)                  |
    |                                     |                              |
    |--(4) 请求缺失 log 条目 ------------->|                              |
    |<---- log entries -------------------|                              |
    |                                     |                              |
    |--(5a) 请求自 last epoch start 的 log->|                             |
    |<---- log(发现 divergent → 标记待删)|                              |
    |--(5b) 请求 missing set ------------->|                              |
    |<---- missing set -------------------|                              |
    |                                     |                              |
    |==(6) primary 的 PG log = authoritative history ==                  |
    |                                     |                              |
    |--(7) up_thru 不足? 请求更新 -------------------------------------->|
    |<------------------------- 更新后的 OSDMap(up_thru 已反映)---------|
    |                                     |                              |
    |--(8a) 分发 log 更新(含删除 divergent 对象)------------------------>|
    |--(8b) 等待持久化 ack --------------------------------------------->|
    |<------------------------------- ack --------------------------------|
    |                                     |                              |
    |==(9) 元数据完全一致 ==                                              |
    |   9a 开始接受客户端写(未恢复的对象会被提到 recovery 队列最前)      |
    |   9b 更新 last epoch started(并通知 replicas)                     |
    |   9c pull 别人有我没有的对象(可能查更早的 past interval OSD)       |
    |   9d push 我有没有的对象给 replicas                                 |
    |                                     |                              |
    |==(10) 所有对象齐备 ==                                               |
    |   更新 last epoch clean                                            |
    |   解散 stray(允许其删除过期副本)                                  |

2.2.8 如何生成 peering 状态机图

官方给出了从源码自动生成最新 peering 状态模型的方法:

$ git clone https://github.com/ceph/ceph.git
$ cd ceph
$ cat src/osd/PeeringState.h src/osd/PeeringState.cc | doc/scripts/gen_state_diagram.py > doc/dev/peering_graph.generated.dot
$ sed -i 's/7,7/1080,1080/' doc/dev/peering_graph.generated.dot
$ dot -Tsvg doc/dev/peering_graph.generated.dot > doc/dev/peering_graph.generated.svg

关键源码文件:src/osd/PeeringState.h 与 src/osd/PeeringState.cc(类 PeeringState 及其状态机)。 生成脚本:doc/scripts/gen_state_diagram.py。

⚠️ 官方页面上嵌入的示例状态图是 peering_graph.generated.svg(一个图片文件),不是 rst 文本,因此本册无法从源文中还原出具体的状态节点与转移边。想看完整状态机请按上面的命令自行生成,或直接读 src/osd/PeeringState.h 中的 PeeringState 状态定义(如 Initial、Reset、Started、Primary、WaitActingChange、Stray 等)。

引自:Peering


三、Messenger 与网络

本章覆盖 messenger.rst、msgr2.rst、network-protocol.rst、network-encoding.rst、encoding.rst、dpdk.rst 六页。

3.1 Messenger 笔记(messenger.rst)

官方页面的实际内容

messenger.rst 全文只有 33 行,内容只有一句话的概述 + 一个 benchmark 工具的使用说明。它没有描述:

  • Messenger 抽象基类的接口;
  • Connection / Dispatcher 的语义;
  • Policy(连接策略,如 lossy / stateless_server / server / client);
  • AsyncMessenger 的线程模型(Worker / EventCenter / EventCallback);
  • 消息 throttle / 优先级队列。

官方原文全部信息:

  1. Messenger 是 Ceph 的网络层实现。当前 Ceph 支持一种 messenger 类型:"async"。
  2. 其余内容全部是关于 ceph_perf_msgr 这个 benchmark 工具的。

ceph_perf_msgr:messenger 模块的专用基准测试

用途:只对 messenger 模块做 benchmark,帮助定位 messenger 模块内部的瓶颈或耗时点。官方比喻:它就像 "iperf"。

服务端启动:

# ./ceph_perf_msgr_server 172.16.30.181:10001 1 0
参数位置 含义
第 1 个参数 ip:port 对,指明客户端需要指定的目标地址
第 2 个参数 配置服务端线程数
第 3 个参数 指定 dispatch 消息时的 "think time"(微秒)

关于 "think time" 的官方解释:

在 Giant 之后,CEPH_OSD_OP 消息(即真正的客户端读/写 io 请求)会被快速派发(fast dispatched),不排队到 Dispatcher,以获得更好的性能。所以 CEPH_OSD_OP 消息会被内联处理,"think time" 就是用来模拟这个 "inline process" 过程的。

客户端启动:

# ./ceph_perf_msgr_client 172.16.30.181:10001 1 32 10000 10 4096
参数位置 含义
第 1 个 服务端 ip:port
第 2 个 客户端线程数
第 3 个 并发度(每个客户端线程的最大 inflight 消息数)
第 4 个 每个客户端线程要向服务端发出的 io 数量
第 5 个 客户端线程在接收消息时的 "think time",也是用来模拟客户端的 fast dispatch 过程
第 6 个 要发出的消息的数据长度

引自:Messenger notes


3.2 msgr2 协议(msgr2.0 与 msgr2.1)(msgr2.rst)

msgr2 是对 SimpleMessenger 实现的 legacy Ceph on-wire 协议的一次修订,用于解决性能与安全问题。官方这一页有 956 行,是本章最详尽的一页。

3.2.1 修订目标(Goals)

目标 说明
Flexible handshaking 原协议没有足够灵活的协议协商机制,无法支持那些「非必需」的特性
Encryption 将引入线上加密(encryption over the wire)
Performance 提供协议层面的特性(例如 padding),以尽可能把计算与内存拷贝排除在 fast path 之外
Signing 允许流量被签名(但未必加密)。官方标注:This is not implemented(尚未实现)

3.2.2 定义(Definitions)

术语 定义
client (C) 发起(TCP)连接的一方
server (S) 接受(TCP)连接的一方
connection 两个进程之间一个(TCP)连接的实例
entity 一个 ceph entity 实例,例如 osd.0。每个 entity 凭借 nonce 字段(通常是一个 pid 或随机值)拥有一个或多个唯一的 entity_addr_t
session 两个 entity 之间的有状态会话,其中消息交换是有序且无损的。如果发生中断(TCP 连接断开),一个 session 可以跨越多个 connection
frame peer 之间发送的一个离散消息。每个 frame 由一个 tag(类型码)、payload、以及(如果启用了签名或加密)一些其他字段组成
tag 与 frame 关联的类型码,tag 决定 payload 的结构

3.2.3 连接的四个阶段(Phases)

1. banner
2. authentication frame exchange
3. message flow handshake frame exchange
4. message frame exchange

3.2.4 Banner

client 与 server 在连接建立后双方都发送一个 banner:

"ceph v2\n"
__le16 banner payload length
banner payload

banner payload 的形式:

__le64 peer_supported_features
__le64 peer_required_features
  • 这是一个全新的、独立的 feature bit 命名空间(CEPH_MSGR2_*)。
  • 目前只定义了 CEPH_MSGR2_FEATURE_REVISION_1。它是 supported 但 not required,因此 msgr2.0 与 msgr2.1 的 peer 可以互相通信。
  • 如果远端宣告了我们不支持的 required feature,我们可以断开连接。
           +---------+        +--------+
           | Client  |        | Server |
           +---------+        +--------+
                | send banner     |
                |----+       +----|
                |    |       |    |
                |    +-------+--->|
                | send banner|    |
                |<-----------+    |
                |                 |

3.2.5 Frame 格式

前置条件:banner 交换之后,所有后续通信都以 frame 形式进行。frame 的确切格式取决于连接模式。

四种连接模式:msgr2.0-crc、msgr2.0-secure、msgr2.1-crc、msgr2.1-secure。 所有连接都从 crc 模式开始(是 msgr2.0-crc 还是 msgr2.1-crc,取决于 banner 中的 peer_supported_features)。

通用 preamble(32 字节)
__u8 tag
__u8 number of segments
{
  __le32 segment length
  __le16 segment alignment
} * 4
__u8 flags
reserved (1 byte)
__le32 preamble crc

规则:

  • 一个空 frame 有一个(空的)segment。
  • 一个非空 frame 可以有 1 到 4 个 segment,除最后一个外,其余 segment 都可以是空的。
  • 如果 segment 少于 4 个,未使用的(末尾的)segment length 与 segment alignment 字段被置零。
  • 当前支持的 flag:FRAME_EARLY_DATA_COMPRESSED(见「压缩」一节)。
  • Reserved 字节被置零。
  • preamble 校验和是 CRC32-C,覆盖它之前的全部内容(28 字节),且无论连接模式如何(即使 frame 被加密)都会计算并校验。
msgr2.0-crc 模式
preamble (32 bytes)
{
  segment payload
} * number of segments
epilogue (17 bytes)

epilogue:

__u8 late_flags
{
  __le32 segment crc
} * 4

late_flags 用于 frame 中止(frame abortion):

在传输完 preamble 和第一个 segment 之后,发送方可以用零填满剩余 segment,并设置一个标志指示接收方必须丢弃该 frame。这使得发送方在一个正在上线(put on the wire)的 frame **被撤销(revoked,即从 messenger 中被抽走)**时,可以避免额外的缓冲:payload buffer 可以被立即 unpin 并交还给用户,无需做拷贝,也无需阻塞到整个 frame 传输完成。

目前只有 kernel client 使用这个机制,见 ceph_msg_revoke()。

segment 校验和规则:

  • 校验和是 CRC32-C。
  • 对于「被使用的」空 segment,它被设为 (__le32)-1。
  • 对于「未使用的」(末尾的)segment,它被置零。
  • CRC 只是为了防护比特错误(bit errors)。不提供任何真实性保证 —— 这与 msgr1 不同,msgr1 尝试通过「用 session key 对 segment 长度和 crc 做可选签名」来提供某种程度的真实性保证。

官方列出的两个已知问题(Issues):

  1. ceph_msg_header2 的 crc 被移到了 epilogue
    • 背景:为了引入一个「segment 数量可变、同时适用于控制帧与消息帧」的通用 frame 结构,msgr2.0 把消息帧第一个 segment(ceph_msg_header2)的 crc 移到了 epilogue。
    • 后果:ceph_msg_header2 再也无法在整个 frame 被从线上读完之前被安全地解释。
    • 这是相对 msgr1 的退步(regression):因为要想「把 payload 直接 scatter 到用户提供的 buffer 中」,以避免接收消息帧时的额外缓冲与拷贝,就必须提前拿到 ceph_msg_header2——它存放着用户 buffer 赖以索引的 transaction id。
    • 实现必须二选一:要么放弃这个优化,要么在未校验的 segment 上行动。
  2. late_flags 没有被任何 crc 覆盖
    • 因为它存放 abort 标志,所以单比特翻转可能导致:① 一个已完成的 frame 被丢弃(导致发送方挂起等待回复);② 更糟,一个被中止的、segment payload 是垃圾数据的 frame 被派发(dispatch)。
    • 这个问题在 msgr1 中就存在,并被带到了 msgr2.0。
msgr2.1-crc 模式(相对 msgr2.0-crc 的差异)
# 差异
1 第一个 segment 的 crc 存放在第一个 segment 的末尾,而不是 epilogue 里。 epilogue 最多存 3 个 crc,而不是 4 个。如果第一个 segment 为空,不生成 (__le32)-1 crc
2 只有当 frame 有超过一个 segment 时才生成 epilogue(即第 2 到第 4 个 segment 中至少有一个非空)。理由:如果 frame 只有一个 segment,它就无法被 abort,而且 epilogue 里也没有 crc 可存
3 未校验的 late_flags 被替换为 late_status,后者通过「每个标志使用一个 4-bit nibble,以及两个 Hamming Distance = 4 的码字(且不是全 0 或全 1)」内建了比特错误检测。代价是只剩一个保留标志位

msgr2.1-crc 的 epilogue:

__u8 late_status
{
  __le32 segment crc
} * 3

示例帧(官方原文):

* 0+0+0+0 帧(空,无 epilogue):
    preamble (32 bytes)

* 20+0+0+0 帧(无 epilogue):
    preamble (32 bytes)
    segment1 payload (20 bytes)
    __le32 segment1 crc

* 0+70+0+0 帧:
    preamble (32 bytes)
    segment2 payload (70 bytes)
    epilogue (13 bytes)

* 20+70+0+350 帧:
    preamble (32 bytes)
    segment1 payload (20 bytes)
    __le32 segment1 crc
    segment2 payload (70 bytes)
    segment4 payload (350 bytes)
    epilogue (13 bytes)

3.2.6 Hello

TAG_HELLO:client→server 且 server→client

__u8 entity_type
entity_addr_t peer_socket_address

作用:我们立即共享自己的 entity type 以及 peer 的地址(后者可用于检测我们的有效 IP 地址,尤其在存在 NAT 的情况下)。

3.2.7 认证阶段(Authentication)

Tag 方向 载荷
TAG_AUTH_REQUEST client→server __le32 method;(CEPH_AUTH_{NONE, CEPHX, ...})
__le32 num_preferred_modes;
list<__le32> mode(CEPH_CON_MODE_*)
method specific payload
TAG_AUTH_BAD_METHOD server→client __le32 method
__le32 negative error result code
__le32 num_methods
list<__le32> allowed_methods(CEPH_AUTH_{NONE, CEPHX, ...})
__le32 num_modes
list<__le32> allowed_modes(CEPH_CON_MODE_*)
TAG_AUTH_REPLY_MORE server→client __le32 len;
method specific payload
TAG_AUTH_REQUEST_MORE client→server __le32 len;
method specific payload
TAG_AUTH_DONE server→client __le64 global_id
__le32 connection mode(CEPH_CON_MODE_*)
method specific payload

要点:

  • TAG_AUTH_BAD_METHOD 返回:被尝试的 auth method、错误码(若该 method 不被支持,则为 -EOPNOTSUPP)、以及允许的认证方法列表。
  • TAG_AUTH_DONE:由 server 决定认证是否完成,以及最终的连接模式是什么。

正常流程(客户端使用了被允许的认证方法):

           +---------+        +--------+
           | Client  |        | Server |
           +---------+        +--------+
                | auth request    |
                |---------------->|
                |<----------------|
                |        auth more|
                |                 |
                |auth more        |
                |---------------->|
                |<----------------|
                |        auth done|

客户端首次尝试了一个被禁止的认证方法:

                | auth request    |
                |---------------->|
                |<----------------|
                |   bad method    |
                |                 |
                | auth request    |
                |---------------->|
                |<----------------|
                |        auth more|
                |                 |
                | auth more       |
                |---------------->|
                |<----------------|
                |        auth done|

3.2.8 认证后的 frame 格式(Post-auth frame format)

根据 TAG_AUTH_DONE 协商出的连接模式,连接要么停留在 crc 模式,要么切换到对应的 secure 模式(msgr2.0-secure 或 msgr2.1-secure)。

msgr2.0-secure 模式
{
  preamble (32 bytes)
  {
    segment payload
    zero padding (out to 16 bytes)
  } * number of segments
  epilogue (16 bytes)
} ^ AES-128-GCM cipher
auth tag (16 bytes)

epilogue:

__u8 late_flags
zero padding (15 bytes)
  • late_flags 的含义与 msgr2.0-crc 模式相同。
  • 每个 segment 与 epilogue 都被零填充到 16 字节。
  • 为什么需要 padding? 官方解释:严格来说 GCM 不需要任何 padding,因为 Counter mode(GCM 中的 C)本质上把块密码变成了流密码。但是,如果整体输入长度不是 16 字节的倍数,GCM 用于生成 auth tag 的 GHASH 函数只能作用于 16 字节块,因此内部还是会发生隐式的零填充。

官方列出的两个已知问题:

  1. 解密预言机(decryption oracle)问题
    • 发送方用单个 nonce 加密整个 frame,并生成单个 auth tag。
    • 由于 segment 长度存放在 preamble 里,接收方别无选择,只能在不校验 auth tag 的情况下解密并解释 preamble —— 否则它连「要从线上读多少字节才能拿到 auth tag」都无从得知!
    • 这创造了一个 decryption oracle,它与 **Counter mode 的可延展性(malleability)**结合,可能导致敏感信息被恢复。
    • 这个问题同样延伸到消息帧的第一个 segment:与 msgr2.0-crc 模式一样,ceph_msg_header2 无法在整个 frame 读完之前被安全解释。
  2. nonce 复用风险(CVE-2020-1759)
    • 使用确定性 nonce 构造:一个 4 字节 counter 字段后面跟一个 8 字节固定字段。初始值取自 connection secret(认证阶段生成的随机字节串)。
    • 由于 counter 字段只有 4 字节,它可能在不到一天的时间内回绕并重复,导致 GCM nonce 重用,进而可能完全丧失该连接的真实性与机密性。
    • 缓解措施:在 counter 重复之前断开连接(CVE-2020-1759)。
msgr2.1-secure 模式(相对 msgr2.0-secure 的差异)
# 差异
1 preamble、第一个 segment、以及 frame 的其余部分被分别加密,使用各自的 nonce 并生成各自的 auth tag。这消除了「使用未验证的明文」的问题,并使 msgr2.1-secure 模式与 msgr2.1-crc 模式保持接近,使实现可以用类似的方式接收消息帧(几乎不需要缓冲、同样的 scatter/gather 逻辑等)。为了减少每帧的加/解密操作次数,preamble 被扩展了一个固定大小的内联缓冲区(48 字节),第一个 segment 会被(全部或部分)内联进去。preamble 的 auth tag 同时覆盖 preamble 与内联缓冲区,所以如果第一个 segment 小到可以完整内联,它在单次解密操作后就可用了
2 与 msgr2.1-crc 一样,只有当 frame 有超过一个 segment 时才生成 epilogue。理由更充分:因为那需要一次额外的加/解密操作
3 为与 msgr2.1-crc 保持一致,late_flags 被替换为 late_status(内建的比特错误检测在 secure 模式下其实并不真的需要)
4 遵循 NIST Recommendation for GCM:使用确定性 nonce 构造,一个 4 字节固定字段后面跟一个 8 字节 counter 字段。8 字节 counter 字段永远不应重复,但为 msgr2.0-secure 设置的 nonce 重用保护仍然存在。初始值与 msgr2.0-secure 相同

padding 规则:

  • 与 msgr2.0-secure 一样,每个 segment 被零填充到 16 字节。
  • 如果第一个 segment 被完整内联,它的 padding 进入内联缓冲区;否则 padding 在剩余部分上。
  • 推论:内联缓冲区以 16 字节为单位被消耗。
  • 内联缓冲区中未使用的部分被置零。

示例帧(官方原文,全部 6 种):

* 0+0+0+0 帧(空,无可内联内容,无 epilogue):
    {
      preamble (32 bytes)
      zero padding (48 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)

* 20+0+0+0 帧(第一个 segment 完整内联,无 epilogue):
    {
      preamble (32 bytes)
      segment1 payload (20 bytes)
      zero padding (28 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)

* 0+70+0+0 帧(无可内联内容):
    {
      preamble (32 bytes)
      zero padding (48 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)
    {
      segment2 payload (70 bytes)
      zero padding (10 bytes)
      epilogue (16 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)

* 20+70+0+350 帧(第一个 segment 完整内联):
    {
      preamble (32 bytes)
      segment1 payload (20 bytes)
      zero padding (28 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)
    {
      segment2 payload (70 bytes)
      zero padding (10 bytes)
      segment4 payload (350 bytes)
      zero padding (2 bytes)
      epilogue (16 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)

* 105+0+0+0 帧(第一个 segment 部分内联,无 epilogue):
    {
      preamble (32 bytes)
      segment1 payload (48 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)
    {
      segment1 payload remainder (57 bytes)
      zero padding (7 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)

* 105+70+0+350 帧(第一个 segment 部分内联):
    {
      preamble (32 bytes)
      segment1 payload (48 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)
    {
      segment1 payload remainder (57 bytes)
      zero padding (7 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)
    {
      segment2 payload (70 bytes)
      zero padding (10 bytes)
      segment4 payload (350 bytes)
      zero padding (2 bytes)
      epilogue (16 bytes)
    } ^ AES-128-GCM cipher
    auth tag (16 bytes)

epilogue:

__u8 late_status
zero padding (15 bytes)

3.2.9 压缩(Compression)

压缩握手是通过 msgr2 基于 feature 的握手机制实现的。在此阶段,客户端会向服务端表明:是否可以对消息传输使用 on-wire-compression,并附带支持的压缩方法列表。如果客户端与服务端都启用了 on-wire-compression,服务端会基于客户端的请求与自身偏好选择一个压缩方法。握手完成后,两端都设置好各自的 compression handler(如果需要的话)。

TAG_COMPRESSION_REQUEST(client→server):声明压缩能力与要求

bool  is_compress
std::vector<uint32_t> preferred_methods
  • 如果客户端识别出双方都支持压缩 feature,它就发起这个握手。
  • is_compress 标志表示客户端的配置是否要使用压缩。
  • preferred_methods 是客户端支持的压缩算法列表。

TAG_COMPRESSION_DONE(server→client):确定压缩设置

bool is_compress
uint32_t  method
  • 服务端根据自己的配置判断压缩是否可行。
  • 如果可行,它会挑选优先级最高的、同时又被客户端支持的压缩方法。
  • 如果不存在这样的方法,它就判定这两个 peer 之间的会话不使用压缩。
           +---------+              +--------+
           | Client  |              | Server |
           +---------+              +--------+
                | compression request    |
                |----------------------->|
                |<-----------------------|
                |   compression done     |

与安全模式的关系(msgr2.x-secure mode):

压缩与加密结合会引入安全隐患。使用 secure 模式时,除非管理员专门配置,否则无法使用压缩。

压缩后的 frame 格式(Post-compression frame format):

根据 TAG_COMPRESSION_DONE 协商的连接模式,连接可以接受/发送压缩帧,或把所有帧都按未压缩处理。

模式 行为
msgr2.x-force mode 后续通过该连接发送的所有帧都会被压缩(如果满足压缩条件,例如帧大小)。对于压缩帧,发送 peer 会启用 FRAME_EARLY_DATA_COMPRESSED 标志,使接收 peer 能够检测到并解压该帧
msgr2.x-none mode preamble 中的 FRAME_EARLY_DATA_COMPRESSED 标志被禁用

3.2.10 Message flow handshake(消息流握手)

此阶段中,两端互相标识身份,并且(如果需要)重连到一个已建立的 session。

TAG_CLIENT_IDENT(client→server):标识我们自己

__le32 num_addrs
entity_addrvec_t*num_addrs entity addrs
entity_addr_t target entity addr
__le64 gid (numeric part of osd.0, client.123456, ...)
__le64 global_seq
__le64 features supported (CEPH_FEATURE_* bitmask)
__le64 features required (CEPH_FEATURE_* bitmask)
__le64 flags (CEPH_MSG_CONNECT_* bitmask)
__le64 cookie

官方注解(逐条):

  • 客户端先发,服务端用同样的帧回复。 如果这是一个新 session,客户端和服务端可以继续进行消息交换。
  • target addr 是客户端试图连接的目标,这样服务端一侧可以在「客户端找错了守护进程」时关闭连接。
  • type.gid(entity_name_t)在此处设置 —— 通过把 hello 帧中共享的 type 与此处的 gid 组合而成。这意味着:① 我们不需要在每个消息的 header 里都带上它;② 也意味着我们不能发送「来自」其他 entity_name_t 的消息。当前实现在 _send_message 等的顶部设置它,因此这不应破坏任何现有功能。实现很可能想要把它与「已认证的凭证所允许的范围」做一次掩码(mask)校验。
  • cookie 是客户端用于标识一个 session 的 cookie,可用于重连到已存在的 session。
  • 我们去掉了 msgr1 中的 protocol_version 字段。

TAG_IDENT_MISSING_FEATURES(server→client):抱怨 TAG_IDENT 的 feature 太少

__le64 features we require that the peer didn't advertise

TAG_SERVER_IDENT(server→client):接受 client ident 并标识服务端

__le32 num_addrs
entity_addrvec_t*num_addrs entity addrs
__le64 gid (numeric part of osd.0, client.123456, ...)
__le64 global_seq
__le64 features supported (CEPH_FEATURE_* bitmask)
__le64 features required (CEPH_FEATURE_* bitmask)
__le64 flags (CEPH_MSG_CONNECT_* bitmask)
__le64 cookie
  • 服务端 cookie 可以被客户端在之后断开时使用,用于重连并恢复 session。

TAG_RECONNECT(client→server):重连到已建立的 session

__le32 num_addrs
entity_addr_t * num_addrs
__le64 client_cookie
__le64 server_cookie
__le64 global_seq
__le64 connect_seq
__le64 msg_seq (the last msg seq received)

TAG_RECONNECT_OK(server→client):确认一次重连尝试

__le64 msg_seq (last msg seq received)
  • 客户端一收到这个,就可以开始消息交换。
  • 服务端一发出这个,就可以开始消息交换。

失败类 tag:

Tag 方向 含义
TAG_RECONNECT_RETRY_SESSION server only 由于 stale connect_seq 导致重连失败
TAG_RECONNECT_RETRY_GLOBAL server only 由于 stale global_seq 导致重连失败
TAG_RECONNECT_WAIT server only 由于 connect race(连接竞争) 导致重连失败。表示服务端已经在连接客户端了,那个方向应当赢得竞争。客户端应当等待那个连接完成
TAG_RESET_SESSION server only 要求客户端重置 session。__u8 full —— full 标志表示 peer 是否应做完全重置(即丢弃消息队列)

六种失败场景(官方原文时序图):

  1. 客户端的第一个 client_ident 消息丢失,然后客户端重连
    c_cookie(a) | client_ident(a)     |
                |-------------X       |
                |                     |
                | client_ident(a)     |
                |-------------------->|
                |<--------------------|
                |     server_ident(b) | s_cookie(b)
                |                     |
                | session established |
  1. 服务端的 server_ident 消息丢失,然后客户端重连
    c_cookie(a) | client_ident(a)     |
                |-------------------->|
                |        X------------|
                |     server_ident(b) | s_cookie(b)
                |                     |
                | client_ident(a)     |
                |-------------------->|
                |<--------------------|
                |     server_ident(c) | s_cookie(c)
                |                     |
                | session established |
  1. 服务端的 server_ident 消息丢失,然后服务端重连
    c_cookie(a) | client_ident(a)     |
                |-------------------->|
                |        X------------|
                |     server_ident(b) | s_cookie(b)
                |                     |
                |     reconnect(a, b) |
                |<--------------------|
                |-------------------->|
                | reset_session(F)    |
                |                     |
                |     client_ident(a) | c_cookie(a)
                |<--------------------|
                |-------------------->|
    s_cookie(c) | server_ident(c)     |
  1. session 建立后连接失败,然后客户端重连
    c_cookie(a) | session established | s_cookie(b)
                |<------------------->|
                |        X------------|
                |                     |
                | reconnect(a, b)     |
                |-------------------->|
                |<--------------------|
                |        reconnect_ok |
  1. session 建立后因为服务端 reset 导致连接失败,然后客户端重连
    c_cookie(a) | session established | s_cookie(b)
                |<------------------->|
                |        X------------| reset
                |                     |
                | reconnect(a, b)     |
                |-------------------->|
                |<--------------------|
                |  reset_session(RC*) |
                |                     |
    c_cookie(c) | client_ident(c)     |
                |-------------------->|
                |<--------------------|
                |     server_ident(d) | s_cookie(d)

RC* 表示 reset session 的 full 标志取决于该连接的 policy.resetcheck。

  1. session 建立后因为客户端 reset 导致连接失败,然后客户端重连
    c_cookie(a) | session established | s_cookie(b)
                |<------------------->|
          reset |        X------------|
                |                     |
    c_cookie(c) | client_ident(c)     |
                |-------------------->|
                |<--------------------| reset if policy.resetcheck
                |     server_ident(d) | s_cookie(d)

3.2.11 消息交换(Message exchange)

Tag 载荷 说明
TAG_MSG ceph_msg_header2 + front + middle + data_pre_padding + data 一条消息
TAG_ACK __le64 seq 确认收到消息。只用于有状态 session
TAG_KEEPALIVE2 ceph_timespec stamp 检查连接存活。时间戳是发送方本地的
TAG_KEEPALIVE2_ACK ceph_timestamp stamp 对 keepalive2 的回复。时间戳取自所响应的那个 TAG_KEEPALIVE2
TAG_CLOSE — 终止一个连接。表示连接应当被终止。等价于一次 hangup 或 reset(即应当触发 ms_handle_reset)。它并非严格必要或有用,因为我们本可以直接断开 TCP 连接

ceph_msg_header2 相对 ceph_msg_header 的修改(官方列出 3 点):

  1. 包含一个 ack_seq。这避免了大多数情况下对 TAG_ACK 消息的需要。
  2. 去掉了 src 字段,我们现在从消息流握手(TAG_IDENT)中获得它。
  3. 指定了 data_pre_padding 长度,可用于调整 data payload 的对齐。
    • 官方在此处带了一个疑问:(NOTE: is this is useful?) —— 官方自己也不确定这个字段是否有用。

3.2.12 状态机(官方 graphviz)

客户端侧状态机:

start_connect --<connected>--> banner_connecting

subgraph "hello banner exchange":
    banner_connecting --"banner exchange"--> hello_connecting
    hello_connecting --"hello exchange"--> banner_connecting

banner_connecting --<exchange done>--> auth_connecting
auth_connecting   --"auth reply more"--> auth_connecting
auth_connecting   --"auth bad method"--> auth_connecting
auth_connecting   --"auth done"--> auth_connecting_sign
auth_connecting_sign --"auth signature"--> session_connecting
session_connecting --"wait"--> wait
wait --<backoff>--> start_connect
session_connecting --"ident missing features"--> closed
session_connecting --"server ident"--> ready
ready --"keep alive"--> ready

官方对 session_connecting --"server ident"--> ready 这条边的 tooltip:set peer_name, peer_addr and connection features。

服务端侧状态机:

start_accept --<accepted>--> banner_accepting

subgraph "hello banner exchange":
    banner_accepting --"banner exchange"--> hello_accepting
    hello_accepting --"hello exchange"--> banner_accepting

banner_accepting --<exchange done>--> auth_accepting
auth_accepting      --"auth_request => 0"--> auth_accepting_more
auth_accepting      --"auth_request => 1"--> auth_accepting_sign
auth_accepting_more --"auth_request => 0"--> auth_accepting_more
auth_accepting_more --"auth_request => 1"--> auth_accepting_sign
auth_accepting_more --"auth_request => EBUSY"--> standby
auth_accepting_more --"auth_request => *"--> auth_accepting_more
auth_accepting      --"auth_request => EBUSY"--> standby
auth_accepting      --"send <auth bad method>"--> auth_accepting
auth_accepting_sign --"auth signature"--> session_accepting
session_accepting   --"reconnect"--> session_accepting
session_accepting   --"ident missing features"--> closed
session_accepting   --"client ident"--> ready
ready               --"keep alive"--> ready

官方对 session_accepting --"client ident"--> ready 这条边的 tooltip:set connection features。

引自:msgr2 protocol (msgr2.0 and msgr2.1)


3.3 网络协议(network-protocol.rst)

官方在此页开头明确建议:为了理解这些结构是如何定义的,建议先阅读 network-encoding 的介绍。

3.3.1 Hello 阶段

协议以一个握手开始,用于确认双方都在说 ceph,并共享一些基本信息。

Banner
  • 第一个动作是 server 向 client 发送 banner。 banner 定义在 src/include/msgr.h 的 CEPH_BANNER 中。
  • banner 之后是 server 的地址,然后是 client 的地址,各自编码为一个 entity_addr_t。
  • 一旦 client 验证 server 的 banner 与自己的匹配,它就回复自己的 banner 与自己的地址。
Connect

banner 验证完毕、地址交换完成后,开始连接协商。首先客户端发送一个 ceph_msg_connect 结构,携带它的信息:

// From src/include/msgr.h
struct ceph_msg_connect {
    u64le features;            // Supported features (CEPH_FEATURE_*)
    u32le host_type;           // CEPH_ENTITY_TYPE_*
    u32le global_seq;          // Number of connections initiated by this host.
    u32le connect_seq;         // Number of connections initiated in this session.
    u32le protocol_version;
    u32le authorizer_protocol;
    u32le authorizer_len;
    u8    flags;               // CEPH_MSG_CONNECT_*
    u8    authorizer[authorizer_len];
}
字段 含义
features 支持的特性(CEPH_FEATURE_*)
host_type CEPH_ENTITY_TYPE_*
global_seq 该主机发起过的连接数
connect_seq 该 session 中发起过的连接数
protocol_version 协议版本
authorizer_protocol 认证器协议
authorizer_len 认证器长度
flags CEPH_MSG_CONNECT_*
authorizer 变长认证器数据
Connect Reply

一旦 connect 被发送,连接实际上就已经打开了,但服务端发送的第一个消息必须是一个 connect reply 消息:

struct ceph_msg_connect_reply {
    u8    tag; // Tag indicating response code.
    u64le features;
    u32le global_seq;
    u32le connect_seq;
    u32le protocol_version;
    u32le authorizer_len;
    u8    flags;
    u8    authorizer[authorizer_len];
}

3.3.2 MSGR 协议

这是一个承载消息传递的底层协议。这一层的消息由一个 tag 字节(标识消息类型)后跟消息数据组成。

// Virtual structure.
struct {
    u8 tag;     // CEPH_MSGR_TAG_*
    u8 data[];  // Length depends on tag and data.
}
  • data 的长度由 tag 字节决定,并且根据消息类型的不同,还可能由 data 数组自身中的信息决定。
  • ⚠️ 官方 Note:如果你不理解消息的类型,就没有办法确定消息的长度。

消息 tag 定义在 src/include/msgr.h 中。官方同时说明:下面列出的这些结构在源码中并不存在,它们只是为了表示协议而给出的。

CEPH_MSGR_TAG_CLOSE (0x06)
struct ceph_msgr_close {
    u8 tag = 0x06;
    u8 data[0]; // No data.
}

表示连接正在被关闭。

CEPH_MSGR_TAG_MSG (0x07)
struct ceph_msgr_msg {
    u8 tag = 0x07;
    ceph_msg_header header;
    u8 front [header.front_len ];
    u8 middle[header.middle_len];
    u8 data  [header.data_len  ];
    ceph_msg_footer footer;
}

ceph_msg_header(from src/include/msgr.h):

struct ceph_msg_header {
    u64le seq;       // Sequence number.
    u64le tid;       // Transaction ID.
    u16le type;      // Message type (CEPH_MSG_* or MSG_*).
    u16le priority;  // Priority (higher is more important).
    u16le version;   // Version of message encoding.

    u32le front_len;  // The size of the front section.
    u32le middle_len; // The size of the middle section.
    u32le data_len;   // The size of the data section.
    u16le data_off;   // The way data should be aligned by the receiver.

    ceph_entity_name src; // Information about the sender.

    u16le compat_version; // Oldest compatible encoding version.
    u16le reserved;       // Unused.
    u32le crc;            // CRC of header.
}

ceph_msg_footer(from src/include/msgr.h):

struct ceph_msg_footer {
    u32le front_crc;  // Checksums of the various sections.
    u32le middle_crc; //
    u32le data_crc;   //
    u64le sig;        // Cryptographic signature.
    u8    flags;
}

要点:

  • 消息是 Ceph 的业务逻辑,是用于在节点间发送请求与数据的东西。
  • 消息 header 包含消息的长度,因此未知的消息可以被优雅地处理(unknown messages can be handled gracefully)。
  • 消息类型常量有两个命名:CEPH_MSG_* 与 MSG_*。两者唯一的区别是前者被视为 "public",后者仅供内部使用。在协议层面没有任何区别。
CEPH_MSGR_TAG_ACK (0x08)
struct ceph_msgr_ack {
    u8    tag = 0x08;
    u64le seq; // The sequence number of the message being acknowledged.
}
CEPH_MSGR_TAG_KEEPALIVE (0x09)
struct ceph_msgr_keepalive {
    u8 tag = 0x09;
    u8 data[0]; // No data.
}
CEPH_MSGR_TAG_KEEPALIVE2 (0x0E)
struct ceph_msgr_keepalive2 {
    u8      tag = 0x0E;
    utime_t timestamp;
}
CEPH_MSGR_TAG_KEEPALIVE2_ACK (0x0F)
struct ceph_msgr_keepalive2_ack {
    u8      tag = 0x0F;
    utime_t timestamp;
}

引自:Network Protocol


3.4 网络编码(network-encoding.rst)

本页描述用于序列化数据的编码方式。官方说明:它不覆盖具体的 object/message,而聚焦于基础类型(base types)。

核心警告:这些类型不以任何方式自我描述(self documenting)。除非你知道它们是什么,否则无法解码。

3.4.1 约定(Conventions)

整数

整数类型的命名规则是 {signed}{size}{endian}。

示例 含义
u16le 无符号 16 位整数,小端字节序
s64be 有符号 64 位整数,大端字节序
u8 / s8 无符号 / 有符号字节

有符号整数使用二进制补码(two's complement)编码。

复杂类型

本页使用类 C 语法描述结构。结构代表将在线上传输的数据。元素之间没有 padding,元素按出现的顺序发送。

struct foo {
    u8    tag;
    u32le data;
}

当编码值 0x05 与 0x12345678 时,线上出现的字节序列是:

05 78 56 34 12
变长数组(Variable Arrays)

与 C 不同,长度数组可以用在结构中的任何位置,并且在协议中是内联的。此外,长度可以用结构中更早出现的某个元素来描述。

struct blob {
    u32le size;
    u8    data[size];
    u32le checksum;
}

该结构被编码为:32 位 size + size 个数据字节 + 32 位 checksum。

基本类型别名(Primitive Aliases)
// From /src/include/types.h

typedef u32le epoch_t;
typedef u32le ceph_seq_t;
typedef u64le ceph_tid_t;
typedef u64le version_t;

3.4.2 结构(Structures)

官方说明:这些结构在源码中并不真实存在,它们是不同类型被编码的方式。

类型 编码方式 用于编码
Optional 一个 presence 字节,后跟(如果存在的话)该元素 boost::optional,以及引入 C++17 之后的 std::optional
Pair 第一个元素后跟第二个元素 std::pair
Triple 三个元素依次排列 ceph::triple
List 一个元素计数,后跟那么多个元素 std::list、std::vector、std::deque、std::set、std::unordered_set
Blob 一个字节列表(u32le size + u8 data[size]) std::string、const char *、bufferlist
Map 一个 pair 列表(u32le length + ceph_pair<K,V> entries[length]) std::map、std::multimap、std::unordered_map、std::unordered_multimap

Optional:

struct ceph_optional<T> {
    u8 present;
    T  element[present? 1 : 0]; // Only if present is non-zero.
}

Pair:

struct ceph_pair<A,B> {
    A a;
    B b;
}

Triple:

struct ceph_triple<A,B,C> {
    A a;
    B b;
    C c;
}

List:

struct ceph_list<T> {
    u32le length;
    T     elements[length];
}

⚠️ 官方 Note:列表中元素的大小不一定是一致的。

Blob:

struct ceph_string {
    ceph_list<u8>;
}

// AKA

struct ceph_string {
    u32le size;
    u8    data[size];
}

⚠️ 官方 Note:Blob 的内容是任意二进制数据。

Map:

struct ceph_map<K,V> {
    ceph_list<ceph_pair<K,V>>;
}

// AKA

struct ceph_map<K,V> {
    u32le length;
    ceph_pair<K,V> entries[length];
}

3.4.3 复杂类型(Complex Types)

utime_t:

// From /src/include/utime.h
struct utime_t {
    u32le tv_sec;  // Seconds since epoch.
    u32le tv_nsec; // Nanoseconds since the last second.
}

ceph_entity_name:

// From /src/include/msgr.h
struct ceph_entity_name {
    u8    type; // CEPH_ENTITY_TYPE_*
    u64le num;
}

引自:Network Encoding


3.5 序列化(encode/decode)(encoding.rst)

本页讲的是内存中结构与字节串之间的转换机制,与 3.4 的线上编码是互补关系:3.4 是「字节怎么排布」,3.5 是「这套排布如何做到版本兼容」。

3.5.1 基本模型

当一个结构被通过网络发送或写入磁盘时,它被编码成一串字节。通常(但不总是 —— Ceph 中并存着多套序列化设施)可序列化结构会有 encode 和 decode 方法,它们从代表字节串的 bufferlist 对象写入/读取。

3.5.2 术语(Terminology)

官方建议的思维方式:不要从 daemon 和 client 的角度思考,而要从 encoder(编码器)和 decoder(解码器)的角度思考。

  • encoder 把一个结构序列化进一个 bufferlist;decoder 做相反的事。
  • encoder 与 decoder 合称 dencoders。
  • Dencoders 同时存在于 daemon 和 client 中。

角色可以互换 —— 官方给的例子:

当一个 RBD 客户端发起一个 IO 操作时:
  它准备一个 MOSDOp 结构的实例,并把它编码进一个 bufferlist,放到线上。
  一个 OSD 读取这些字节,并把它们解码回一个 MOSDOp 实例。
  → 这里 encoder 被客户端使用,decoder 被 OSD 使用。

但处理响应时角色就反转了:
  OSD 编码 MOSDOpReply,而 RBD 客户端解码。

Encoder 和 decoder 按照一种「由程序员通过实现 encode 和 decode 方法定义的格式」运作。

3.5.3 格式变更的原则(Principles for format change)

一般规则:一个 decoder 必须能够理解一个 encoder 所编码的东西。

大多数困难出现在两个过程中:① 确保旧 decoder 与新 encoder 的兼容性;② 确保新 decoder 与旧 encoder 的兼容性。除非另有说明,否则应当假定集群中「新旧混合」的任何组合都是可能的。

两个主要关切:

  1. 升级(Upgrades)。虽然有关于实体类型顺序(mons/OSDs/clients)的建议,但它不是强制性的,不应做任何假设。
  2. 客户端版本的巨大差异性。内核升级(因而内核客户端)与 Ceph 升级是解耦的;容器化甚至给 librbd 带来了可变性 —— 现在用户态库就在容器自身里。

限制 dencoder 之间互操作程度的三条规则:

规则 内容
daemon 之间的 dencoding n-2
客户端场景的硬性要求 n-3
客户端场景的软性要求 n-3..。理想情况下每个客户端都应该能与任何版本的 daemon 通信

官方指出:由于底层原因相同,dencoders 遵循的规则与我们的 feature bit 弃用规则几乎相同,参见 src/include/ceph_features.h 中的 Notes on deprecation。

3.5.4 框架(Frameworks)

当前并存着多类 dencoding helper:

框架 特点
encoding.h 最普及的一个(the most proliferated one)
denc.h 性能优化过的,主要见于 BlueStore
Message 继承体系 —

虽然细节各异,但互操作性规则保持不变。

3.5.5 向结构中增加一个字段

初始版本:

class AcmeClass
{
    int member1;
    std::string member2;

    void encode(bufferlist &bl)
    {
        ENCODE_START(1, 1, bl);
        ::encode(member1, bl);
        ::encode(member2, bl);
        ENCODE_FINISH(bl);
    }

    void decode(bufferlist::iterator &bl)
    {
        DECODE_START(1, bl);
        ::decode(member1, bl);
        ::decode(member2, bl);
        DECODE_FINISH(bl);
    }
};

ENCODE_START 宏写入一个 header,指定了 version 与 compat_version(初始都是 1)。

  • message version 在编码发生任何变更时被递增。
  • compat_version 只有在该变更会破坏已有 decoder 时才被递增 —— 因为 decoder 容忍尾随字节,所以在结构末尾追加字段的变更不需要递增 compat_version。

DECODE_START 宏接受一个参数,指定这段代码能处理的最新 message version。它会与消息中编码的 compat_version 比较,如果消息太新,就会抛出异常。由于 compat_version 的变更很罕见,在追加字段时通常不必担心这一点。

增加第三个字段后的版本:

class AcmeClass
{
    int member1;
    std::string member2;
    std::vector<std::string> member3;

    void encode(bufferlist &bl)
    {
        ENCODE_START(2, 1, bl);
        ::encode(member1, bl);
        ::encode(member2, bl);
        ::encode(member3, bl);
        ENCODE_FINISH(bl);
    }

    void decode(bufferlist::iterator &bl)
    {
        DECODE_START(2, bl);
        ::decode(member1, bl);
        ::decode(member2, bl);
        if (struct_v >= 2) {
            ::decode(member3, bl);
        }
        DECODE_FINISH(bl);
    }
};

关键点:

  1. compat_version 没有变,因为编码出的消息仍然可以被只理解 version 1 的代码解码 —— 它们只是忽略我们编码 member3 的那些尾随字节。
  2. 在 decode 函数中,解码新字段是条件性的,因为我们可能仍会收到没有该字段的旧版本消息。
  3. struct_v 变量是由 DECODE_START 宏设置的一个局部变量。

3.5.6 深入机制(# Into the weeeds)

append-extendability(追加可扩展性)的来源:是 ENCODE_START 与 DECODE_FINISH 宏带来的前向兼容性(forward compatibility)。

它们实现的是一套可扩展性设施(extensibility facilities)。一个 encoder 在填充 bufferlist 时,前置三个字段:

  1. 当前格式的版本(version of the current format);
  2. 与之兼容的 decoder 的最小版本(minimal version of a decoder compatible with it);
  3. 所有已编码字段的总大小(the total size of all encoded fields)。

ENCODE_START 宏(官方原文):

/**
 * start encoding block
 *
 * @param v current (code) version of the encoding
 * @param compat oldest code version that can decode it
 * @param bl bufferlist to encode to
 *
 */
#define ENCODE_START(v, compat, bl)                             \
  __u8 struct_v = v;                                            \
  __u8 struct_compat = compat;                                  \
  ceph_le32 struct_len;                                         \
  auto filler = (bl).append_hole(sizeof(struct_v) +             \
    sizeof(struct_compat) + sizeof(struct_len));                \
  const auto starting_bl_len = (bl).length();                   \
  using ::ceph::encode;                                         \
  do {

struct_len 字段的作用:允许 decoder 吃掉(eat)在用户提供的 decode 实现中未被解码的所有字节。

对应地,decoder 会跟踪在用户提供的 decode 方法中已经解码了多少输入:

#define DECODE_START(bl)		                        \
  unsigned struct_end = 0;					\
  __u32 struct_len;						\
  decode(struct_len, bl);					\
  ...                                                           \
  struct_end = bl.get_off() + struct_len;			\
  }								\
  do {

decoder 使用这个信息来丢弃它不理解的额外字节:

#define DECODE_FINISH(bl)					\
  } while (false);						\
  if (struct_end) {						\
    ...                                                         \
    if (bl.get_off() < struct_end)				\
      bl += struct_end - bl.get_off();				\
  }

为什么「推进 bufferlist」是关键? 官方解释:

推进 bufferlist 是关键的,因为 dencoders 往往是嵌套的;如果不动它,只有嵌套结构中最后一次 decode 调用才能正常工作。

整套协作机制的效果:

这一整套协作机制允许 encoder(及其后续修订)生成更多的字节流(例如由于在末尾增加了一个新字段),而不必担心这些残留字节会让较旧的 decoder 修订版本崩溃。

引自:Serialization (encode/decode)


3.6 DPDK(dpdk.rst)

3.6.1 编译 DPDKStack

ceph dpdkstack 默认不编译,需要重新编译并启用 DPDKstack 组件。可以先在有预编译 DPDK 包的发行版上安装 dpdk-devel 或 dpdk-dev,然后编译:

$ do_cmake.sh -DWITH_DPDK=ON

3.6.2 设置 DPDK 网卡

  • 大多数主流网卡支持 SR-IOV,可以虚拟化为多个 VF 网卡。
  • 每个 OSD 通过 DPDK 使用若干专用网卡;mon、mgr 和 client 通过 POSIX 协议栈使用 PF 网卡。

加载 DPDK 依赖的驱动:

# modprobe vfio
# modprobe vfio_pci

通过编辑 /etc/sysctl.conf 配置 Hugepage:

vm.nr_hugepages = xxx

根据 OSD 数量配置 VF 数量:

# echo $numvfs > /sys/class/net/$port/device/sriov_numvfs

把网卡绑定到 DPDK 应用:

# dpdk-devbind.py -b vfio-pci 0000:xx:yy.z

3.6.3 配置 OSD DPDKStack

默认情况下,DPDK RTE 初始化过程需要 root 权限以访问系统中的各种资源。授予 ceph 用户 root 访问权限:

# usermod -G root ceph

OSD 通过 ms_dpdk_devs_allowlist 选择网卡:

  1. 配置单个网卡:
ms_dpdk_devs_allowlist=-a 0000:7d:010

或

ms_dpdk_devs_allowlist=--allow=0000:7d:010
  1. 配置 Bond 网卡:
ms_dpdk_devs_allowlist=--allow=0000:7d:01.0 --allow=0000:7d:02.6 --vdev=net_bonding0,mode=2,slave=0000:7d:01.0,slave=0000:7d:02.6

DPDK 相关配置项(官方原文):

[osd]
ms_type=async+dpdk
ms_async_op_threads=1

ms_dpdk_port_id=0
ms_dpdk_gateway_ipv4_addr=172.19.36.1
ms_dpdk_netmask_ipv4_addr=255.255.255.0
ms_dpdk_hugepages=/dev/hugepages
ms_dpdk_hw_flow_control=false
ms_dpdk_lro=false
ms_dpdk_enable_tso=false
ms_dpdk_hw_queue_weight=1
ms_dpdk_memory_channel=2
ms_dpdk_debug_allow_loopback = true

[osd.x]
ms_dpdk_coremask=0xf0
ms_dpdk_host_ipv4_addr=172.19.36.51
public_addr=172.19.36.51
cluster_addr=172.19.36.51
ms_dpdk_devs_allowlist=--allow=0000:7d:01.1

3.6.4 调试与优化

根据日志定位故障,并把日志调到合适的级别:

debug_dpdk=xx
debug_ms=xx
  • 如果日志中包含大量重传消息,减小 ms_dpdk_tcp_wmem 的值。

查看 DPDKStack 统计:

$ ceph daemon osd.$i perf dump | grep dpdk
  • 如果 dpdk_device_receive_nombuf_errors 持续增长,检查是否超出限流:
$ ceph daemon osd.$i perf dump | grep throttle-osd_client -A 7 | grep "get_or_fail_fail"
$ ceph daemon osd.$i perf dump | grep throttle-msgr_dispatch_throttler -A 7 | grep "get_or_fail_fail"
  • 如果超过阈值,提高限流阈值或禁掉限流。

检查网卡是否故障或异常:

$ ceph daemon osd.$i show_pmd_stats
$ ceph daemon osd.$i show_pmd_xstats

某些 DPDK 版本(如 dpdk-20.11-3.e18.aarch64)或网卡 TSO 异常,尝试关闭 tso:

ms_dpdk_enable_tso=false

如果 VF 网卡支持多队列,可以给单核分配更多网卡队列以提升性能:

ms_dpdk_hw_queues_per_qp=4

3.6.5 现状与未来工作

与 POSIX Stack 的对比(官方给出的实测结论):

测试项 结论
多并发测试:4K 随机写 性能相同
多并发测试:8K 随机写 性能提升 28%
多并发测试:1 MB 包 不稳定
单延迟测试:4K 与 8K 随机写 延迟降低 15%(延迟越低越好)

未来工作(高层计划):

  • OSD 多网络支持(public network 与 cluster network):public 与 cluster 网卡可以被配置;在连接或监听时,可以根据 IP 地址选择 public 或 cluster 网卡;在 msgr-work 初始化期间,同时初始化 public 与 cluster 网卡,并创建两个 DPDKQueuePair。

引自:Ceph messenger DPDKStack


四、协议与认证

本章覆盖 rados-client-protocol.rst、cephx.rst、cephx_protocol.rst、ceph_krb_auth.rst、session_authentication.rst 五页。

4.1 RADOS 客户端协议(rados-client-protocol.rst)

官方在页面第一句就给出了免责声明:"This is very incomplete, but one must start somewhere."(这非常不完整,但总得有个开始。)本页只有 117 行,且只覆盖了 Basics、Resends、Backoff 三节。

4.1.1 基础(Basics)

要素 说明
请求消息 MOSDOp
回复消息 MOSDOpReply
寻址目标 一个对象请求的目标是 hobject_t,它包含:pool、hash 值、object name、placement key(通常为空)、以及 snapid
hash 值 一个 32 位 hash 值,通常由对 object name 做 hash 生成。但 hobject_t 可以被任意构造,用任何 hash 值和名字
⚠️ 注意 在 MOSDOp 中,上述这些组成部分分散在若干个字段里,并没有被逻辑地组装成一个真正的 hobject_t 成员(主要是历史原因)
针对 PG 的请求 请求也可以直接针对一个 PG。此时 ps 值匹配一个特定的 PG,object name 为空,且(希望如此)请求中的 ops 是 PG ops
最终目标 无论哪种方式,请求最终都指向一个 PG —— 要么使用显式的 pgid,要么把 hash 值折叠(fold)到该 pool 当前的 pg 数量上。客户端把请求发送给该关联 PG 的 primary
tid 每个请求被分配一个唯一的 tid

4.1.2 重发(Resends)

触发重发的两种情况:

  1. 连接断开 —— 客户端会重发所有未完成的请求。
  2. PG 映射发生变更导致 primary 改变 —— 客户端负责重发请求。
    • 注意:虽然从 OSD 的角度看,可能发生了一次 interval change(触发 PG peering),但如果 primary 没有变,客户端就不需要重发。

这条规则的三条例外:

例外 说明
last_force_op_resend OSDMap 的 pg_pool_t 中有一个 last_force_op_resend 字段。如果它发生变化,客户端就被强制重发所有未完成的请求。(例如在 tiering 被调整时会发生)
某些请求在任何 PG interval 变更时重发 判据由 pg_interval_t::is_new_interval() 定义(与 OSD 中 peering 使用的判据相同)
PAUSE OSDMap 标志 如果该标志被设置然后被清除

attempt 字段:

  • 每次请求被发往 OSD 时,attempt 字段被递增。
  • 第一次是 0,下一次是 1,依此类推。

4.1.3 退避(Backoff)—— 客户端侧的流量控制

设计动机:

通常,OSD 会简单地把它无法立即处理的请求在内存中排队,直到能处理为止。
这可能变得有问题,因为 OSD 会限制入站消息所消耗的总内存:
  如果「消息条数」或「字节数」中任一阈值被达到,
  新的消息就不会被从网络 socket 上读走,从而通过网络产生背压(backpressure)。

但在某些情况下,OSD 知道或预期某个 PG 或对象将在一段时间内不可用,并且不希望通过排队请求来消耗内存。此时它可以向客户端发送一条 MOSDBackoff 消息。

Backoff 请求的四个属性
# 属性 说明
1 op code block、unblock 或 ack-block
2 id 在该 session 内分配的一个唯一 id
3 hobject_t begin 范围起点
4 hobject_t end 范围终点
两种 Backoff 类型
类型 作用范围 表示方式
PG backoff 在客户端侧堵住(plug)针对整个 PG 的所有请求 用 hash/hobject_t 空间的一个区间 [begin, end) 描述
object backoff 堵住针对单个对象的所有请求 begin == end
客户端侧的行为
  1. 当客户端收到一条 block backoff 消息时,它就负责不再发送由该 backoff 所描述的那些 hobject_t 的请求。
  2. backoff 一直有效,直到:① 该 backoff 被清除(通过一条 unblock 消息);② 或 OSD session 被关闭。
  3. 客户端会立即回送一条 ack_block 消息给 OSD,以确认收到了该 backoff。

unblock 的处理(重要细节):

  • 当收到 unblock 时,它会引用一个客户端先前已阻塞的特定 id。
  • 但是,unblock 所描述的范围可能比原始范围更小,因为 PG 可能在 OSD 上发生了 split。
  • unblock 应当只解除 unblock 消息中所指定的那个范围。
  • 任何落在 unblock 请求范围内的请求会被重新检查,并且如果没有其他已安装的 backoff 适用,就被重发。
OSD 侧的行为:Backoff 的三个状态

Backoff 在 OSD 上也是按 hash 空间的区间来跟踪的,存在三种状态:

状态 说明
new 新安装的 backoff 被设为 new,并向客户端发送一条消息
acked 当收到 ack-block 消息时,它被变更为 acked 状态
deleting 待删除状态

规则:

  • OSD 可以处理来自客户端的、被处于 new 状态的 backoff 覆盖的其他消息。
  • 但一旦 backoff 变成 acked,除非有 bug,否则 OSD 就不应再看到被阻塞的请求。
  • 如果 OSD 想移除一个处于 acked 状态的 backoff,它可以简单地移除它并通知客户端。
  • 如果 backoff 处于 new 状态,OSD 必须先把它移到 deleting 状态,并继续用它来丢弃客户端请求,直到收到 ack-block 消息,此时它才能最终被移除。
    • 为什么必须这样? 官方解释:这是为了保持 OSD 所处理操作的顺序(preserve the order of operations processed by the OSD)。

引自:RADOS client protocol


4.2 cephx(cephx.rst)—— 认证协议(权威版)

本节是 cephx 的现代权威描述;4.3 节是 2012 年的历史文档(Peter Reiher 写的代码级走查),两者对照阅读效果最佳。

4.2.1 概述(Intro)

  • 协议设计看起来很像 Kerberos。
  • 「KDC」角色由 monitor 承担,monitor 持有每个 entity 的共享密钥数据库。
  • 客户端与非 monitor 守护进程都从与 monitor 认证开始,以获取 ticket(在代码中多被称为 authorizer)。
  • 这些 ticket 同时提供 authentication(认证)与 authorization(授权),因为它们包含了对该 entity 的 capabilities(能力) 的描述 —— 这是一种简洁的、结构化的「哪些动作被允许」的描述,可以被服务守护进程解释并强制执行。

4.2.2 术语(Terms)

术语 定义
monitor(s) 中心授权机构(central authorization authority)
service 某一特定类型的所有守护进程构成的集合(例如所有 OSD、所有 MDS)
client 正在访问该 service 的一个 entity 或 principal
entity name principal 的字符串标识符(例如 client.admin、osd.123)
ticket 一段以密码学方式断言身份与授权的数据
principal 一个 client 或 daemon,由唯一的 entity_name 标识,与 monitor 共享一个 secret
principal_secret principal 的密钥,一个由该 principal 与 monitor 共享的密钥(16 字节)
mon_secret monitor 密钥,被所有 monitor 共享
service_secret 一个**轮转的(rotating)**密钥,被某一 service class 的所有成员共享(例如所有 OSD)
auth ticket 向 monitor 证明身份的 ticket
service ticket 向某个 service 证明身份与授权的 ticket

4.2.3 记法(Terminology)

{X, Y, ...}^secret 表示用 secret 对 X + Y + ... 加密。

4.2.4 上下文(Context)

  • 这里描述的认证消息特定于 cephx auth 实现。
  • 这些消息由 Messenger 协议传输,或由 MAuth 消息传输,取决于 messenger 协议的版本。参见 msgr2 协议。
  • 一次初始的(messenger)握手会协商:① 要使用的认证方法(cephx vs none vs krb 等);② 客户端或守护进程试图以哪个 entity 的身份进行认证的声明。

4.2.5 Phase I:获取 auth ticket

交换由 monitor 发起 —— monitor 已经知道客户端声称自己是谁,并发出第一条 cephx 消息:

a->p :
  CephxServerChallenge {
    u64 server_challenge     # random (by server)
  }

客户端响应:加上自己的 challenge,并基于两个 challenge 与自己的共享密钥 principal_secret 计算一个值:

p->a :
  CephxRequestHeader {
    u16 CEPHX_GET_AUTH_SESSION_KEY
  }
  CephXAuthenticate {
    u8 2                     # 2 means nautilus+
    u64 client_challenge     # random (by client)
    u64 key = digest({client_challenge, server_challenge}^principal_secret)   # (roughly)
    blob old_ticket          # old ticket, if we are reconnecting or renewing
    u32 other_keys           # bit mask of service keys we want
  }

Nautilus 之前:

  CephXAuthenticate {
    u8 1                     # 2 means nautilus+
    u64 client_challenge     # random (by client)
    u64 key = digest({client_challenge, server_challenge}^principal_secret)   # (roughly)
    blob old_ticket          # old ticket, if we are reconnecting or renewing
  }

⚠️ 官方 Note:这在本协议中并不是一个认证步骤。 server_challenge 容易受到 MitM 攻击。这最多可以被看作一个 DoS 过滤器,别的什么都不是。

monitor 的处理:

  1. 在数据库中查找 principal_secret,并验证 key 是否正确。
  2. 如果 old_ticket 存在,验证它有效,并复用同一个 global_id。
  3. 否则,monitor 分配一个新的 global_id。

monitor 的响应:

a->p :
  CephxReplyHeader {
    u16 CEPHX_GET_AUTH_SESSION_KEY
    s32 result (0)
  }
  u8 encoding_version = 1
  u32 num_tickets ( = 1)
  ticket_info           # (N = 1) for AUTH ticket

Nautilus 及以后,还要加上:

  u32 connection_secret_len      # in bytes
  connection_secret^session_key  # using CEPH_ENTITY_TYPE_AUTH session key
  u32 other_keys_len             # bytes of other keys (encoded)
  other_keys {
    u8 encoding_version = 1
    u32 num_tickets
    service_ticket_info * N      # for each service ticket
  }

各个子结构:

ticket_info {
  u32 service_id       # CEPH_ENTITY_TYPE_AUTH
  u8 msg_version (1)
  {CephXServiceTicket service_ticket}^principal_secret # principal_secret ONLY for _AUTH
  {CephxTicketBlob ticket_blob}^existing session_key   # if we are renewing a ticket,
  CephxTicketBlob ticket_blob                          # otherwise
}

service_ticket_info {
  u32 service_id       # CEPH_ENTITY_TYPE_{MON,MGR,OSD,MDS}
  u8 msg_version (1)
  {CephxServiceTicket service_ticket}^auth_session_key # session_key from _AUTH CephxServiceTicket
  CephxTicketBlob ticket_blob
}

CephxServiceTicket {
  CryptoKey session_key      # freshly generated (even if old_ticket is present)
  utime_t expiration         # now + auth_mon_ticket_ttl
}

CephxTicketBlob {
  u64 secret_id             # which service ticket encrypted this; -1 == monsecret,
                            # otherwise service's rotating key id
  {CephxServiceTicketInfo ticket}^rotating_service_secret
}

CephxServiceTicketInfo {
  CryptoKey session_key     # same session_key as above
  AuthTicket ticket
}

AuthTicket {
  EntityName name           # client's identity, as proven by its possession of principal_secret
  u64 global_id             # newly assigned, or from old_ticket
  utime_t created, renew_after, expires
  AuthCapsInfo       # what client is allowed to do
  u32 flags = 0      # unused
}

关于 rotating_service_secret(官方 Note):

它是给定类型的 service 所共享的三个(默认)密钥之一。Monitor 有两个用于此目的的 service type:

  • CEPH_ENTITY_TYPE_MON —— 用于保护 mon 授权;
  • CEPH_ENTITY_TYPE_AUTH —— 用于保护 "auth" ticket。

"auth" ticket 允许客户端重新获取一个已分配给它的 global_id。

Phase I 小结:

  • 对每一个 ticket,principal 得到:
    • 一个它能用自己的 secret 解密、从而得到 session_key 的部分(CephxServiceTicket);
    • 一个不透明的 CephxTicketBlob(由 mon secret 保护),它可以在稍后用于证明「我是谁、我能做什么」。
  • Nautilus+ 我们还顺便一并返回 service tickets。
  • 客户端可以推断 monitor 是可信的,因为它能够用自己的 secret 解密 service_ticket(即服务端确实持有它的密钥)。

4.2.6 Phase II:获取 service tickets(pre-nautilus)

现在客户端需要用于与非 monitor(osd、mds、mgr)通信的密钥:

p->a :
  CephxRequestHeader {
    u16 CEPHX_GET_PRINCIPAL_SESSION_KEY
  }
  CephxAuthorizer authorizer
  CephxServiceTicketRequest {
    u32 keys    # bitmask of CEPH_ENTITY_TYPE_NAME (MGR, OSD, MDS, etc)
  }
CephxAuthorizer {
  u8 AUTH_MODE_AUTHORIZER (1)
  u64 global_id
  u32 service_id    # CEPH_ENTITY_TYPE_*
  CephxTicketBlob auth_ticket
  {CephxAuthorize msg}^session_key  # using CEPH_ENTITY_TYPE_AUTH session key
}

CephxAuthorize msg {
  u8 2
  u64 nonce                         # random from client
  bool have_challenge = false       # not used here
  u64 server_challenge_plus_one = 0 # not used here
}

monitor 的处理:

  1. 通过用 rotating_service_secret 解密 auth_ticket 来验证该 authorizer;
  2. 确认它所说的这个 principal 与 CephxAuthorizer 各字段中的一致。
  3. ⚠️ 注意:nonce 随机字节在这里并没有被使用(该字段是为下面的 Phase III 存在的)。

假设一切正常,authorizer 就可以基于 keys bitmask 中的 CEPH_ENTITY_TYPE_* 位生成 service ticket。

响应:

CephxResponseHeader {
  u16 CEPHX_GET_PRINCIPAL_SESSION_KEY
  s32 result (= 0)
}
u8 encoding_version = 1
u32 num_tickets
ticket_info * N

其中(与上面一样):

ticket_info {
  u32 service_id      # CEPH_ENTITY_TYPE_{OSD,MGR,MDS}
  u8 msg_version (1)
  {CephXServiceTicket service_ticket}^principal_secret
  {CephxTicketBlob ticket_blob}^session_key # using **old** CEPH_ENTITY_TYPE_AUTH session key
}

CephxServiceTicket {
  CryptoKey session_key
  utime_t expiration
}

CephxTicketBlob {
  u64 secret_id       # which version of the (rotating) service ticket encrypted this
  {CephXServiceTicketInfo ticket}^rotating_service_secret
}

CephxServiceTicketInfo {
  CryptoKey session_key
  AuthTicket ticket
}

AuthTicket {
  EntityName name
  u64 global_id
  utime_t created, renew_after, expires
  AuthCapsInfo       # what you are allowed to do
  u32 flags = 0      # unused
}

这结束了与 monitor 的认证交换。 客户端或守护进程现在拥有了用于与 mon 以及所有其他相关守护进程通信的 ticket。

4.2.7 Phase III:打开到某个 service 的连接

当一个连接被打开时,会发送一个 "authorizer" 载荷:

p->s :
  CephxAuthorizer {
    u8 AUTH_MODE_AUTHORIZER (1)
    u64 global_id
    u32 service_id    # CEPH_ENTITY_TYPE_*
    CephxTicketBlob auth_ticket
    {CephxAuthorize msg}^session_key
  }

  CephxAuthorize msg {
    u8 2
    u64 nonce               # random from client
    bool have_challenge = false
    u64 server_challenge_plus_one = 0
  }

历史差异:在 Luminous v12.2.6 或 Mimic v13.2.2 之前,CephxAuthorize msg 不包含 challenge,只由以下内容组成:

CephxAuthorize msg {
  u8 1
  u64 nonce               # random from client
}

服务端(S)的处理分支:

服务端会检查 auth_ticket 的 CephxTicketBlob(用它当前的 rotating service key 解密)。

  • 如果是 v12.2.6 / v13.2.2 之前的客户端,服务端立即回复:
s->p :
  {CephxAuthorizeReply reply}^session_key

CephxAuthorizeReply {
  u64 nonce_plus_one
}
  • 否则(现代客户端),服务端会用一个 challenge 来响应(以防止重放攻击):
s->p :
  {CephxAuthorizeChallenge challenge}^session_key

CephxAuthorizeChallenge {
  u64 server_challenge        # random from server
}

客户端解密并相应地更新它的 CephxAuthorize msg,重新发送大部分与之前相同的信息:

p->s :
  CephxAuthorizer {
    u8 AUTH_MODE_AUTHORIZER (1)
    u64 global_id
    u32 service_id    # CEPH_ENTITY_TYPE_*
    CephxTicketBlob auth_ticket
    {CephxAuthorize msg}^session_key
  }

CephxAuthorize msg {
  u8 2
  u64 nonce                        # (new) random from client
  bool have_challenge = true
  u64 server_challenge_plus_one    # server_challenge + 1
}

服务端像之前一样验证 ticket,然后还验证 msg 的 nonce 是否等于它的 challenge + 1,从而确认这是一次实时的认证尝试(而不是重放)。

最后,服务端回复一个向客户端证明其真实性的消息。它还包含一些熵(entropy),用于在需要时加密该 session:

s->p :
  {CephxAuthorizeReply reply}^session_key

CephxAuthorizeReply {
  u64 nonce_plus_one
  u32 connection_secret_length
  connection secret
}

Nautilus 之前没有 connection secret:

CephxAuthorizeReply {
  u64 nonce_plus_one
}

客户端解密,并确认服务端正确地递增了 nonce,从而确认这是一次实时的认证请求而不是重放。

4.2.8 轮转 service secret(Rotating service secrets)

设计动机:

守护进程对它们的 ticket 使用一个「轮转密钥」而不是固定密钥,
目的是限制「某个守护进程被攻破」的严重程度。

如果一个守护进程的密钥被攻击者获取:
  - 该守护进程及其密钥可以从 monitor 的数据库中移除;
  - 但攻击者可能也已经获得了一份「被所有守护进程共享的 service secret」的副本。

为缓解这一点,service key 会周期性地轮转,
这样在一段时间之后(auth_service_ticket_ttl),
攻击者获得的那个密钥就不再有效了。

获取轮转密钥的交换:

p->a :
  CephxRequestHeader {
    u16 CEPHX_GET_ROTATING_KEY
  }

a->p :
  CephxReplyHeader {
    u16 CEPHX_GET_ROTATING_KEY
    s32 result = 0
  }
  {CryptoKey service_key}^principal_secret

即:新的轮转密钥就是被该守护进程自己的 secret 保护着。

实现细节(官方 Note):service 会同时保留当前密钥和上一个密钥,以便在密钥轮转期间继续验证请求。

引自:Cephx


4.3 cephx 认证协议的详细代码级描述(cephx_protocol.rst)

这是 Peter Reiher 于 2012-07-13 撰写的一份历史文档,官方在 cephx.rst 中把它作为"Other references"引用。

⚠️ 文档开头的自我声明(必须注意):

  • 因为本备忘录讨论了被调用的例程与使用的变量的细节,它代表的是一个快照;
  • 代码可能在本文档创建之后被改变,而本文档不太可能同步更新;
  • 运气好的话,代码注释会指出协议实现方式上的重大变更。

因此本节的代码路径、函数名、变量名可能与 Tentacle v20.2.4 的实际代码不一致。它的价值在于把「为什么要这样做」与「每个字段是谁填的」讲清楚了。

4.3.1 引言:协议的基本思想

  • 基本思想基于 Kerberos。客户端 C 想从服务端 S 获取某个东西,S 只会把所请求的服务提供给已授权的客户端。
  • 与其要求每个服务端都处理认证与授权问题,系统使用一个授权服务器(authorization server)A。
  • 因此客户端必须先与 A 通信:认证自己,并获取能授予它访问所想要服务的凭证(credentials)。

认证 ≠ 授权:

概念 说明
Authentication(认证) 提供「某一方确实是它所声称的那个」的证据
Authorization(授权) 提供「某个特定方被允许做某事」的证据

一般而言,安全的授权蕴含安全的认证(因为没有认证,你可能把东西授权给了一个冒名者),但反过来不成立 —— 可以只认证不授权。本协议的目的是授权(authorize)。

基本方法:全程使用对称密码学。

  • 每个客户端 C 有自己的密钥,只有它自己和 A 知道。
  • 每个服务端 S 有自己的密钥,只有它自己和 A 知道。
  • 授权信息通过 ticket 传递,用提供该服务的一方的密钥加密。
  • 会有一个 A 给 C 的 ticket,它允许 C 向 A 请求其他 ticket。这个 ticket 用 A 的密钥加密(因为 A 是那个需要检查它的一方)。
  • 之后会有 A 签发的、允许 C 与 S 通信以请求服务的 ticket。这些 ticket 用 S 的密钥加密(因为 S 需要检查它们)。
  • 由于我们还希望提供通信的安全性,会随 ticket 一起建立 session key。
    • 目前,这些 session key 只在本协议期间、以及客户端 C 提供服务 ticket 时与服务端 S 之间的握手期间用于认证目的。
    • 通过对系统做一些改动,它们可以被用于全程的认证或保密。

4.3.2 协议必须达成的六个证明目标

# 目标
1 C 必须向 A 证明它确实是 C。由于一切都是通过消息完成的,客户端还必须证明「这条证明真实性的消息是新鲜的(fresh)」,而不是被攻击者重放的
2 A 必须向 C 证明它确实是那个 authenticator。同样,也需要证明没有发生重放
3 A 与 C 必须安全地共享一个 session key,用于之后在它们之间分发授权材料。同样不允许重放,且该密钥必须只有 A 和 C 知道
4 A 必须从 C 那里收到证据,使 A 能够查找出 C 对服务端 S 的被授权操作
5 C 必须从 A 收到一个 ticket,该 ticket 能向 S 证明 C 可以执行其被授权的操作。这个 ticket 必须只能被 C 使用
6 C 必须从 A 收到一个 session key,用于保护 C 与 S 之间的通信。该 session key 必须是新鲜的,而不是重放的结果

4.3.3 起步(Getting Started With Authorization)

  • 当客户端第一次需要获取服务时,它联系 monitor。此时它没有任何 ticket,因此它使用 "unknown" 协议与 monitor 通信,该协议被指定为 CEPH_AUTH_UNKNOWN。
  • monitor 同时承担认证服务器 A 的角色。
  • 其余的通信将使用 cephx 协议(其大部分代码位于 auth/cephx 下的文件中)。该协议负责创建并传递上面所说的那些 ticket。
  • 本文档不跟踪 pre-cephx 的协议流程,它从「客户端已联系上服务端、并准备好开始 cephx 协议本身」这一点开始。

两阶段划分:

  • Phase I:C 需要一个 ticket 来与 A 安全通信。这个 ticket 随后可用于获取其他 ticket。由 C→A 的一次发送和 A→C 的一次响应组成。
  • Phase II:C 需要一个 ticket 来与 S 通信以获取服务。同样由 C→A 的一次发送和 A→C 的一次响应组成。

4.3.4 Phase I(代码路径逐段)

客户端侧
  1. 客户端通过 AuthClientHandler 类中的一个名为 need 的变量来知道自己需要什么(CephxClientHandler 继承自 AuthClientHandler)。

    • 此时,need 中编码的一件事是 CEPH_ENTITY_TYPE_AUTH,表示我们需要从头开始启动认证协议。
    • 由于我们总是与同一个授权服务器通信,如果我们之前已经走过这一步(且得到的 ticket/session 尚未超时),我们可以跳过这一步,直接请求客户端 ticket。但最初必须做这一步,本文档假定我们正处于那个状态。
  2. Phase I 中 C 发给 A 的消息在 CephxClientHandler::build_request()(位于 auth/cephx/CephxClientHandler.cc)中构造。该例程被用于多个目的。

  3. 在本例中,我们首先调用 validate_tickets()(来自 CephXTicketManager::validate_tickets(),位于 auth/cephx/CephxProtocol.h)。

    • 这段代码遍历可能的 ticket 列表以确定我们需要什么,按需设置 need 标志中的值。
  4. 然后调用 ticket.get_handler()(在 CephxProtocol.h 中)。

    • 该例程在 ticket map 中找到指定类型的 ticket(一个用于执行授权的 ticket),
    • 为它创建一个 ticket handler 对象,
    • 并把该 handler 放进 map 中的正确位置。
  5. 然后进入处理各种具体情况的专有代码。这里的分支是 if (need & CEPH_ENTITY_TYPE_AUTH)(我们仍需要向 A 认证)。

  6. 我们现在创建一条类型为 CEPHX_GET_AUTH_SESSION_KEY 的消息。

    • 我们需要用 C 的 secret key 认证这条消息,因此从本地密钥仓库(key repository)中取出它。
    • 我们创建一个随机 challenge,其目的是防止重放。
    • 我们使用 cephx_calc_client_server_challenge() 加密该 challenge。
    • 我们已经从 pre-cephx 阶段获得了一个 server challenge(一组类似的随机字节,但由服务端创建并发给客户端)。
    • 我们取这两个 challenge 和我们的 secret key,生成一个组合的加密 challenge 值,放进 req.key。
  7. 如果我们有一个旧 ticket,我们把它存放在 req.old_ticket 中。(我们马上要拿一个新的。)

  8. 整个 req 结构 —— 包括旧 ticket 与两个 challenge 的加密哈希 —— 被放进消息中。然后我们从该函数返回,消息被发送。

服务端(A)侧
  1. 服务端收到类型为 CEPH_GET_AUTH_SESSION_KEY 的消息。该消息在 prep_auth()(位于 mon/AuthMonitor.cc)中被处理,后者调用 CephxServiceHandler.cc 中的 handle_request() 来完成大部分工作。这个例程同样处理多种情况。

  2. 控制流由与该消息关联的 cephx_header 中的 request_type 决定。我们这里的分支是 CEPH_GET_AUTH_SESSION_KEY。

  3. 我们需要 A 与 C 共享的 secret key,因此调用 get_secret() 从本地密钥仓库获取它。

    • 官方注解:它在代码中被叫做 key_server,但它并不是一个真正的独立机器或处理实体,更像是「存放本地使用的密钥的地方」。
  4. 我们应该已经与该客户端建立了一个 server challenge,因此确认我们确实有一个。

    • 该变量特定于一个 CephxServiceHandler,因此我们创建的每个这样的结构都有一个不同的值(大概是 A 正在处理的每个客户端一个)。
    • 如果没有 challenge,我们就需要重新开始,因为我们需要检查客户端的加密哈希,而它部分地依赖于一个 server challenge。
  5. 我们现在调用客户端使用的同一个例程,基于同样的值计算哈希:

    • 客户端 challenge(在收到的消息中);
    • 服务端 challenge(我们保存的那个);
    • 客户端的 key(我们刚获取的)。
    • 我们检查客户端发来的东西是否与我们期望的相同。
    • 如果相同,我们就知道「我们正在与正确的客户端通信」。我们知道该 session 是新鲜的,因为它用了我们发给它的 challenge 来计算它的加密哈希。所以我们可以给它一张认证 ticket。
  6. 我们获取 C 的 eauth 结构。它包含一个 ID、一个 key、以及一组 caps(capabilities)。

  7. 客户端在消息中把它旧 ticket(如果有的话)发给了我们。

    • 如果有,我们就设置一个标志 should_enc_ticket 为 true,并把 global ID 设为那个旧 ticket 中的 global ID。
    • 如果解码它的旧 ticket 失败(很可能是因为它根本没有),should_enc_ticket 保持为 false。
  8. 现在我们设置新 ticket:填时间戳、C 的名字、以及方法调用中提供的 global ID(除非存在旧 ticket)。

    • 我们需要一个新的 session key,以帮助客户端安全地与我们通信(而不是使用它的永久密钥)。
    • 我们把 service ID 设为 CEPH_ENTITY_TYPE_AUTH,这将告诉客户端 C 如何处理我们发给它的消息。
    • 我们构建一个 cephx 响应 header,并调用 cephx_build_service_ticket_reply()。
  9. cephx_build_service_ticket_reply()(位于 auth/cephx/CephxProtocol.cc)构建响应消息。

    • 它的大部分工作是把数据从参数拷贝到消息结构中。
    • 其中一部分信息(session key 与有效期)会用 C 的永久密钥加密。
    • 如果 should_encrypt_ticket 标志被设置,就用旧 ticket 的密钥对它加密。否则(没有旧 ticket 密钥),新 ticket 就不加密。(当然,它已经用 A 的永久密钥加密过了。)
    • 官方对这第二层加密的推测:大概是想减少「用永久密钥加密的材料」的暴露量。
  10. 然后我们用 entity name、一个标志 CEPH_ENTITY_TYPE_MON、以及 capabilities(将由该例程填充)调用 key server 的 get_service_caps() 例程。

    • 使用该常量标志意味着我们要获取的是客户端对 A 的 caps,而不是对某个其他数据服务端的 caps。这里的 ticket 是用于访问授权者 A,而不是服务 S。
    • 该调用的结果是:caps 变量(我们所处例程的一个参数)被填入了 monitor capabilities,这些 caps 将允许 C 访问 A 的授权服务。
  11. handle_request() 本身并不发送响应消息。 它构建出 result_bl(基本上持有该消息的内容)与 capabilities 结构,但不发送消息。

  12. 我们回到 mon/AuthMonitor.cc 中的 prep_auth() 来发送。

    • 该例程对刚填好的 caps 结构做一些处理;
    • 有一个作为该处理结果出现的 global ID 被放进回复消息中;
    • 回复消息在这里被构建(主要来自 response_bl buffer)并被发送。

Phase I 结束时的状态:C 已经向 A 认证了自己,且 A 已经生成了一个新的 session key 与 ticket,允许 C 从 A 获取服务端 ticket。

4.3.5 Phase II(代码路径逐段)

Phase II 在 C 收到来自 A 的、包含新 ticket 与 session key 的消息时开始。 目标:为 C 提供用于与 S 通信的 session key 与 ticket。

客户端侧
  1. A 发给 C 的消息被派发到 CephxClientHandler.cc 中的 build_request() —— 与 Phase I 早期用来构造协议第一条消息的是同一个例程。

  2. 这一次,当调用 validate_tickets() 时,need 变量将不再包含 CEPH_ENTITY_TYPE_AUTH,因此会走主干例程中的另一个分支 —— 即由 if (need) 指示的分支。

    • 我们有一个用于授权者的 ticket,但我们仍需要 service ticket。
  3. 我们必须向 A 发送另一条消息,以获取用于服务端 S 的 ticket(与 session key)。

    • 我们把消息的 request_type 设为 CEPHX_GET_PRINCIPAL_SESSION_KEY,
    • 并调用 ticket_handler.build_authorizer() 来获取一个 authorizer。该例程在 CephxProtocol.cc 中。
      • 我们把这个 authorizer 的 key 设为我们刚从 A 得到的 session key,并创建一个新的 nonce;
      • 我们把 global ID、service ID 和 ticket 放进属于该 authorizer 的一个消息 buffer 中;
      • 然后我们创建一个新的 CephXAuthorize 结构。刚创建的 nonce 放进去;
      • 我们用当前 session key 加密这个 CephXAuthorize 结构,并塞进 authorizer 的 buffer;
      • 我们返回该 authorizer。
  4. 回到 build_request():我们取出刚构建的 authorizer 的那一部分(它的 buffer,不包括 session key 或其他东西),并塞进我们正在为要发给 A 的消息创建的 buffer 中。然后删除该 authorizer。

    • 我们把我们想要的东西的需求放进 req.keys,并把 req 放进 buffer。然后返回,消息被发送。
服务端(A)侧
  1. A 收到类型为 CEPHX_GET_PRINCIPAL_SESSION_KEY 的消息。该消息在 mon/AuthMonitor.cc 的 prep_auth() 中被处理,后者再次调用 CephxServiceHandler.cc 中的 handle_request() 来完成大部分工作。

  2. 本例中,handle_request() 走 CEPHX_GET_PRINCIPAL_SESSION_KEY 分支。它会调用 CephxProtocol.cc 中的 cephx_verify_authorizer()。

  3. 在 cephx_verify_authorizer() 中,我们从输入 buffer 中抓取一堆数据,包括 global ID、service ID、以及给 A 的 ticket。

  4. 该 ticket 包含一个 secret_id,指示正在使用哪个 key。

    • 如果从 ticket 中取出的 secret ID 是 -1,则该 ticket 没有指明 A 应当使用哪个 secret key。
      • 在这种情况下,A 应当使用「C 想要联系的那个特定 entity」的 key,而不是「同一类型的所有服务端 entity 共享的轮转 key」。
      • 要得到那个 key,A 必须查询密钥仓库以找到正确的 key。
    • 否则,已经有了一个从密钥仓库获得的、用于持有必要 secret 的结构。
      • 服务端 secret 基于时间过期来轮转(key rotation 不在本文档覆盖范围内),因此遍历该结构以找到它当前的 secret。
    • 无论哪种方式,A 现在知道了用于创建这个 ticket 的 secret key。 现在用这个 key 解密该 ticket 的加密部分。它应当是给 A 的 ticket。
  5. 该 ticket 还包含一个 session key,C 本应使用它来加密本消息的其他部分。用那个 session key 解密消息的其余部分。

  6. 创建一个 CephXAuthorizeReply 来存放我们的回复。

    • 取出 nonce(在我们刚解密的那些东西里),加 1,把结果放进回复中;
    • 加密该回复,放进调用 cephx_verify_authorizer() 时提供的 buffer 中,并返回 handle_request()。
    • 这将用于向 C 证明「是 A(而不是攻击者)创建了这个响应」。
  7. 验证了消息有效且来自 C 之后,现在我们需要为它构建一张用于 S 的 ticket。

    • 我们需要知道它想与哪个 S 通信、以及它想要什么服务。
    • 从它的消息中取出描述这些事情的 ticket request;
    • 然后遍历该 ticket request 以查看它想要什么。(它可能在同一条请求中请求多个不同的服务,但为便于讨论,我们假定只有一个。)
    • 一旦我们知道它在找哪个 service ID,就调用 build_session_auth_info()。
  8. build_session_auth_info() 位于 CephxKeyServer.cc。

    • 它检查服务 S 的 service_ID 的 secret 是否可用,并把它放进某个参数的子字段中;
    • 然后调用位于同一文件中的、名字相似的 _build_session_auth_info()。
      • 该例程用 S 的 ID、一个 ticket、以及该 ticket 的一些时间戳来装载新的 auth_info 结构;
      • 它生成一个新的 session key 并放进该结构;
      • 然后调用 get_caps() 来填充 info.ticket 的 caps 字段。get_caps() 也在 CephxKeyServer.cc 中,它用「C 对 S 被允许的 caps」填充所提供的 caps_info 结构。
  9. build_session_auth_info() 返回后,A 就有了允许 C 对 S 执行的能力列表。

    • 我们把一个基于该上下文当前 TTL 的有效期放进 info 结构,
    • 并把它放进我们正在准备的、作为对该消息响应的 info_vec 结构中。
  10. 现在调用 build_cephx_response_header()(也在 CephxServiceHandler.cc 中)。填入 request_type(为 CEPHX_GET_PRINCIPAL_SESSION_KEY)、状态 0、以及 result buffer。

  11. 现在调用 cephx_build_service_ticket_reply()(在 CephxProtocol.cc 中)。

    • 与 Phase I 中 A 处理其响应时用的是同一个例程。
    • 这里,session key(现在是一个用于与 S 通信的 session key,而不是与 A 通信的)以及该 key 的有效期,会用 C 与 A 之间已有的 session key 加密。
    • should_encrypt_ticket 参数在这里是 false,且没有为该加密提供 key。 相关 ticket(一旦 C 把它发到那里,就是发给 S 的)已经用 S 的 secret 加密过了。
    • 所以,本质上,这个例程会把 ID 信息、加密的 session key、以及允许 C 与 S 通信的 ticket 放进要发给 C 的 buffer 中。
  12. 该例程返回后,我们退出 handle_request(),回到 prep_auth(),最终到底层的消息发送代码。

客户端侧(收尾)
  1. 客户端收到这条消息。nonce 在消息通过 Pipe::connect()(位于 msg/SimpleMessager.cc)时被检查。

    • 在该例程中部一个冗长的 while(1) 循环中,它获取一个 authorizer。如果获取成功,最终它会调用 verify_reply(),后者检查 nonce。
    • ⚠️ 官方指出一个值得注意的点:connect() 从不显式检查是否拿到了 authorizer,这暗示「不提供 authorizer 可能使攻击者跳过 nonce 检查」。但是,在很多地方,如果没有 authorizer,重要的连接字段会被置零,这最终会导致连接无法提供数据。官方的结论是:这值得测试一下,但看起来「不提供包含 nonce 的 authorizer」对攻击者没什么帮助。
  2. 该消息最终进入 CephxClientHandler.cc 中的 handle_response()。

    • 在该例程中,我们调用 get_handler() 来获取一个 ticket handler 以持有我们刚收到的 ticket。
    • 该例程嵌入在 CephXTicketManager 结构的定义中。它接受一个 type(本例中是 CEPH_ENTITY_TYPE_AUTH)并遍历 tickets_map 以找到该 type。
    • 应当能找到一个,且它的条目中应当有 C 与 A 之间 session 的 session key。 该 key 将被用于解密 A 提供的信息,特别是那个允许 C 与 S 通信的新 session key。
  3. 然后我们调用 CephxProtocol.cc 中的 verify_service_ticket_reply()。

    • 该例程需要判断该 ticket 是否 OK,并获取与该 ticket 关联的 session key;
    • 它用与 A 共享的 session key 解密消息 buffer 的加密部分;
    • 这个 ticket 没有被加密(准确说是没有被双重加密 —— ticket 总是被加密的,只是有时被双重加密,而这个没有),所以它可以直接存入一个 service ticket buffer;
    • 我们从那个 buffer 中取出该 ticket;
    • 我们用「C 与 A 共享的 session key」解密出来的东西包括了那个新的 session key。那就是我们当前用于这个 ticket 的 session key,所以我们设置它;
    • 检查有效性并设置过期时间。如果走到这一步,返回 true。
  4. 回到 handle_response(),我们现在调用 validate_tickets() 来调整我们认为自己还需要什么,因为我们已经有了一个之前没有的 ticket。如果我们已经把需要的东西都搞定了,就返回 0。

Phase II 结束时的状态(官方总结):

我们现在已经成功地为客户端 C 建立了用于与服务端 S 通信的 ticket 与 session key。

  • S 会知道 C 就是它所声称的那个,因为 A 会验证它。
  • C 会知道它在与 S 通信,同样是因为 A 验证过。
  • 用于 C 与 S 通信的 session key 的唯一副本是分别用 C 和 S 的永久密钥加密后发送的,因此除 A(被所有方信任)之外没有其他任何一方知道该 session key。
  • 该 ticket 会安全地、且由 A 背书地向 S 表明 C 被允许做什么。
  • A 与 C 之间来回传递的 nonce 确保了它们没有遭受重放攻击。
  • C 还没有真正与 S 对话,但它已经准备好了。

4.3.6 密钥被攻破时会发生什么(官方分析)

如果 C 的永久密钥被攻破:

  • 攻击者可以冒充 C,并获得 C 的全部权限;
  • 攻击者可以窃听 C 的合法会话;
  • 攻击者也可以冒充 A,但只能在与 C 的对话中冒充;
  • 由于(按假设)它没有任何服务的密钥,它无法为任何服务生成新的 ticket;
  • 不过它可以重放旧的 ticket 和 session key,直到 S 的永久密钥被更换、或旧 ticket 超时。

如果 S 的密钥被攻破:

  • 攻击者可以向任何一方冒充 S;
  • 攻击者可以窃听任何用户与 S 的会话;
  • 除非某个客户端的密钥也被攻破,否则攻击者无法为 S 生成新的伪造客户端 ticket,因为那需要它用「它并不知道的那个客户端密钥」把自己认证为 A。

引自:A Detailed Description of the Cephx Authentication Protocol


4.4 Kerberos 认证(ceph_krb_auth.rst)

官方这一页有 1096 行,是本章最长的一页。但它绝大部分内容是 Kerberos / LDAP / SASL / GSSAPI 的通用背景知识与 KDC 服务器的搭建步骤,而非 Ceph 内部实现。本页最后更新时间标注为 Dec 3, 2018,作者 Daniel Oliveira (doliveira@suse.com)。

4.4.1 背景概念(官方给出的定义)

概念 官方定义要点
Directory Services(目录服务) 一个可定制的信息存储,作为用户定位分布在网络中的资源与服务的单一入口,也为管理员提供管理其对象及属性的单一入口。虽然对用户表现为单一入口,实际通常以分布式形式存储。至少由一个 Directory Server 和一个 Directory Client 组成,基于 X.500 标准实现。 例子:OpenLDAP、389 Directory Server、MS Active Directory、NetIQ eDirectory。典型特征是 write-once-read-many-times。其数据库不是为事务型数据设计的
LDAP (v3) 一组 LDAP 协议交换(不是服务器的实现),定义数据被访问的方法。由 IETF 在 RFC 2251 中标准化,描述数据在目录服务中如何表示(Data Model / DIT),以及数据如何被导入/导出(LDIF)。LDAP 不定义数据如何存储或操作。 没有任何目录服务实现实现了 LDAP v3 的全部特性
Authentication(认证) 校验凭证(如用户名/ID 与密码)以验证身份
Authorization(授权) 在身份被成功认证之后发生,最终授予访问资源的完整权限。决定访问系统的能力以及到什么程度
Auditing(审计) 取 authentication 与 authorization 的结果并记录到 audit log。认证与授权是预防性系统,审计是反应性系统
Kerberos (KRB v5) 一种网络认证协议,用对称密钥(secret-key)密码学为 client/server 应用提供强认证。使用 ticket 来认证用户或服务应用,且从不以明文在网络上传输口令,因此双方都能证明身份而无需发送任何未加密的秘密。可以用于 SSO(单点登录)
SASL RFC 4422,一个帮助开发者实现不同认证机制的框架(实现一系列 challenge 与 response),允许客户端与服务端为每条连接协商一个双方都可接受的机制,而不是硬编码
GSSAPI RFCs 2078, 2743, 2744, 4121, 4752。它不定义协议、认证或安全机制本身,而是为多种认证机制提供统一、通用的安全服务 API。特点:mechanism independence、protocol independence、platform independence、QOP independence。大多数支持 GSSAPI 的应用也支持 Kerberos v5。 Windows 用 SSPI 而非 GSSAPI,但 SSPI 客户端可以与 GSSAPI 服务端通信
SPNEGO RFC 2478, 4178。GSSAPI 解决了「给不同机制提供单一 API」的问题,但没有解决「协商用哪个机制」的问题。SPNEGO 提供一个框架,让参与认证的两方从一组可能的机制中安全地协商一个双方都支持的机制。它是一个 meta protocol,完全搭载在其他应用协议中传输,从不直接使用

为什么关心这些?(官方的总结)

  1. Ceph Kerberos 认证完全基于 MIT Kerberos 实现,并使用 GSSAPI。
  2. 目前仍在使用 Kerberos 的默认后端数据库,但计划加入 LDAP 作为后端,这将提供:用 GSSAPI (KRB5) 做认证 + 用 LDAP (LDAPv3) 做授权,经由 SASL 机制。

4.4.2 SASL 机制一览(官方列表)

机制 RFC 说明
ANONYMOUS RFC 4505 访客访问,即未认证
CRAM-MD5 RFC 2195 基于 HMAC-MD5 的简单 challenge-response 方案,不建立任何安全层,比 DIGEST-MD5 与 GSSAPI 更不安全
DIGEST-MD5 RFC 2831 与 HTTP Digest(部分)兼容的、基于 MD5 的 challenge-response 方案,提供数据安全层。LDAPv3 服务器的强制认证方法
EXTERNAL RFCs 4422, 5246, 4301, 2119 认证隐含在上下文中(例如使用 IPsec 或 TLS)。使用公钥做强认证
GS2 RFC 5801 支持在 SASL 中使用任意 GSS-API 机制的机制族
NTLM MS 私有 MS Windows NT LAN Manager 认证机制
OAuth 1.0/2.0 RFCs 5849, 6749, 7628 用于委托资源访问的认证协议
OTP RFC 2444 一次性口令机制(废弃了 SKEY)
PLAIN RFC 4616 简单明文口令机制,大多数应用中不推荐
SCRAM RFCs 5802, 7677 带 channel binding 支持的现代 challenge-response 机制

4.4.3 前置条件(Before We Start)

环境假设:Kerberos 已被正确配置,对每个服务器和 KDC都意味着:

  1. 时间同步(用 NTP 或 chrony)。
    • 官方强调:不只 Kerberos,Ceph 本身也依赖并仰仗时间同步。
  2. DNS 解析:
    • 正向和反向区域都要有,使用 fqdn(hostname + domain.name);
    • KDC 发现可配置为使用 DNS srv resources 作为服务定位协议(RFCs 2052, 2782),以及用 host 或 domain 映射到合适的 realm(txt record);
    • 虽然这些 DNS 条目/设置不是运行一个 Kerberos realm 所必需的,但它们确实有助于消除在所有客户端上手工配置的需要;
    • 这一点极为重要,因为大多数 Kerberos 问题通常与名称解析有关。Kerberos 在检查系统名与主机查询时非常挑剔。
  3. 尽可能为基础设施中的每一部分(ntp、dns、kdc 服务器)配置备份/备用/从节点,以避免单点故障。

4.4.4 Kerberos 术语

术语 说明
Ticket 一组可用于验证客户端身份的信息。Kerberos ticket 可以存放在文件里,也可以只存在于内存中。获得的第一张 ticket 是 ticket-granting ticket (TGT),它允许客户端获取额外的 ticket,而这些额外的 ticket 给予客户端访问特定服务的许可。请求与授予这些额外 ticket 的过程是透明的
KDC KDC 为客户端创建 TGT,用客户端的口令作为密钥加密它,并把加密后的 TGT 发回客户端。客户端随后尝试用自己的口令解密 TGT。如果解密成功(即口令正确),它就保留解密后的 TGT,这就是客户端身份的证明。KDC 由三部分组成:Kerberos database、Authentication service (AS)、Ticket-granting service (TGS)
Client 发送请求以获取 ticket 的用户、主机或服务
Principal Kerberos 可以为其分配 ticket 的唯一身份。Principal 可以有任意数量的组成部分,各部分之间通常用 / 分隔。最后一部分是 realm,通常用 @ 分隔。 如果没有 realm 部分,则假定该 principal 处于当前上下文的默认 realm。典型格式:primary/instance@REALM。对 Ceph,我们将使用 ceph 作为 primary 名字,这样更容易组织和识别与 Ceph 相关的 principal。realm 通常是大写字母的域名
Keytab 一个 keytab 文件存放可用于代替口令挑战的实际加密密钥。为非交互式 principal(如 Service Principal Names,通常关联 Ceph 守护进程这类长期运行的进程)创建 keytab 文件很有用。一个 keytab 文件不必须与单个 principal 一一对应,多个不同 principal 的 key 可以存放在同一个 keytab 文件中。 keytab 应受到适当保护。 默认客户端 keytab 文件是 /etc/krb5.keytab

4.4.5 Ceph 侧的配置步骤(官方 8 步)

步骤 1:登录到 Kerberos 客户端(Ceph 服务器节点),通过检查和正确编辑 /etc/krb5.conf 确认其配置正确:

/etc/krb5.conf
[libdefaults]
    dns_canonicalize_hostname = false
    rdns = false
    forwardable = true
    dns_lookup_realm = true
    dns_lookup_kdc = true
    allow_weak_crypto = false
    default_realm = MYDOMAIN.COM
    default_ccache_name = KEYRING:persistent:%{uid}
[realms]
    MYDOMAIN.COM = {
        kdc = kerberos.mydomain.com
        admin_server = kerberos.mydomain.com
        ...
    }
...

步骤 2:登录 KDC 服务器,确认它已正确配置为可以对该 Kerberos realm 进行认证。

a) Kerberos 相关的 DNS RRs:

/var/lib/named/master/mydomain.com
kerberos                IN A        192.168.10.21
kerberos-slave          IN A        192.168.10.22
_kerberos               IN TXT      "MYDOMAIN.COM"
_kerberos._udp          IN SRV      1 0 88 kerberos
_kerberos._tcp          IN SRV      1 0 88 kerberos
_kerberos._udp          IN SRV      20 0 88 kerberos-slave
_kerberos-master._udp   IN SRV      0 0 88 kerberos
_kerberos-adm._tcp      IN SRV      0 0 749 kerberos
_kpasswd._udp           IN SRV      0 0 464 kerberos
...

b) KDC 配置文件:

/var/lib/kerberos/krb5kdc/kdc.conf
[kdcdefaults]
        kdc_ports = 750,88
[realms]
        MYDOMAIN.COM = {
            acl_file = /var/lib/kerberos/krb5kdc/kadm5.acl
            admin_keytab = FILE:/var/lib/kerberos/krb5kdc/kadm5.keytab
            default_principal_flags = +postdateable +forwardable +renewable +proxiable
                                                    +dup-skey -preauth -hwauth +service
                                                    +tgt-based +allow-tickets -pwchange
                                                    -pwservice
            dict_file = /var/lib/kerberos/krb5kdc/kadm5.dict
            key_stash_file = /var/lib/kerberos/krb5kdc/.k5.MYDOMAIN.COM
            kdc_ports = 750,88
            max_life = 0d 10h 0m 0s
            max_renewable_life = 7d 0h 0m 0s
        }
...

步骤 3:在 KDC 服务器上运行 Kerberos 管理工具 kadmin.local,列出现有 principal:

kadmin.local:  listprincs
K/M@MYDOMAIN.COM
krbtgt/MYDOMAIN.COM@MYDOMAIN.COM
kadmin/admin@MYDOMAIN.COM
kadmin/changepw@MYDOMAIN.COM
kadmin/history@MYDOMAIN.COM
kadmin/kerberos.mydomain.com@MYDOMAIN.COM
root/admin@MYDOMAIN.COM
...

步骤 4:为每一个希望通过 Kerberos 认证的 Ceph 集群节点添加一个 principal:

kadmin.local:  addprinc -randkey ceph/ceph-mon1
Principal "ceph/ceph-mon1@MYDOMAIN.COM" created.
kadmin.local:  addprinc -randkey ceph/ceph-osd1
Principal "ceph/ceph-osd1@MYDOMAIN.COM" created.
...

用户 principal 同理:

kadmin.local:  addprinc johndoe
WARNING: no policy specified for johndoe@MYDOMAIN.COM; defaulting to no policy
Enter password for principal "johndoe@MYDOMAIN.COM":
Re-enter password for principal "johndoe@MYDOMAIN.COM":
Principal "johndoe@MYDOMAIN.COM" created.

步骤 5:为每个 Ceph 集群节点创建 keytab 文件:

kadmin.local:  ktadd -k /etc/gss_client_mon1.ktab ceph/ceph-mon1
Entry for principal ceph/ceph-mon1 with kvno 2, encryption type aes256-cts-hmac-sha1-96 added to keytab WRFILE:/etc/gss_client_mon1.ktab.
Entry for principal ceph/ceph-mon1 with kvno 2, encryption type aes128-cts-hmac-sha1-96 added to keytab WRFILE:/etc/gss_client_mon1.ktab.
Entry for principal ceph/ceph-mon1 with kvno 2, encryption type des3-cbc-sha1 added to keytab WRFILE:/etc/gss_client_mon1.ktab.
Entry for principal ceph/ceph-mon1 with kvno 2, encryption type arcfour-hmac added to keytab WRFILE:/etc/gss_client_mon1.ktab.
kadmin.local:  ktadd -k /etc/gss_client_osd1.ktab ceph/ceph-osd1
Entry for principal ceph/ceph-osd1 with kvno 2, encryption type aes256-cts-hmac-sha1-96 added to keytab WRFILE:/etc/gss_client_osd1.ktab.
Entry for principal ceph/ceph-osd1 with kvno 2, encryption type aes128-cts-hmac-sha1-96 added to keytab WRFILE:/etc/gss_client_osd1.ktab.
Entry for principal ceph/ceph-osd1 with kvno 2, encryption type des3-cbc-sha1 added to keytab WRFILE:/etc/gss_client_osd1.ktab.
Entry for principal ceph/ceph-osd1 with kvno 2, encryption type arcfour-hmac added to keytab WRFILE:/etc/gss_client_osd1.ktab.
kadmin.local:  ktadd -k /etc/gss_client_osd3.ktab ceph/ceph-osd3
Entry for principal ceph/ceph-osd3 with kvno 3, encryption type aes256-cts-hmac-sha1-96 added to keytab WRFILE:/etc/gss_client_osd3.ktab.
kadmin.local:  ktadd -k /etc/gss_client_osd4.ktab ceph/ceph-osd4
Entry for principal ceph/ceph-osd4 with kvno 4, encryption type aes256-cts-hmac-sha1-96 added to keytab WRFILE:/etc/gss_client_osd4.ktab.
...

官方提示:每个 ktadd 都会为同一 principal 写入 4 种 encryption type 的 key:aes256-cts-hmac-sha1-96、aes128-cts-hmac-sha1-96、des3-cbc-sha1、arcfour-hmac;不同 principal 的 kvno 会各自递增(示例中 osd1/osd2 是 2,osd3 是 3,osd4 是 4)。

列出生成的所有 keytab 文件,并用 klist -kte 逐个校验:

# ls -1 /etc/gss_client_*
/etc/gss_client_mon1.ktab
/etc/gss_client_osd1.ktab
/etc/gss_client_osd2.ktab
/etc/gss_client_osd3.ktab
/etc/gss_client_osd4.ktab

# klist -kte /etc/gss_client_mon1.ktab
Keytab name: FILE:/etc/gss_client_mon1.ktab
KVNO Timestamp           Principal
---- ------------------- ------------------------------------------------------
   2 10/8/2018 14:35:30 ceph/ceph-mon1@MYDOMAIN.COM (aes256-cts-hmac-sha1-96)
   2 10/8/2018 14:35:31 ceph/ceph-mon1@MYDOMAIN.COM (aes128-cts-hmac-sha1-96)
   2 10/8/2018 14:35:31 ceph/ceph-mon1@MYDOMAIN.COM (des3-cbc-sha1)
   2 10/8/2018 14:35:31 ceph/ceph-mon1@MYDOMAIN.COM (arcfour-hmac)

步骤 6:Ceph 新增了一个参数 gss_ktab_client_file,它指向与该 Ceph 节点(或 principal)相关的 keytab 文件。

  • 默认值:/var/lib/ceph/$name/gss_client_$name.ktab
  • 例如对于 Ceph 服务器 osd1.mydomain.com,keytab 文件的位置与名字应为:/var/lib/ceph/osd1/gss_client_osd1.ktab

分发 keytab:

# for node in mon1 osd1 osd2 osd3 osd4; do scp /etc/gss_client_$node*.ktab root@ceph-$node:/var/lib/ceph/$node/; done

此时,即使不使用任何 keytab 客户端文件,我们也应当已经能够认证一个用户 principal:

# kdestroy -A && kinit -f johndoe && klist -f
Password for johndoe@MYDOMAIN.COM:
Ticket cache: KEYRING:persistent:0:0
Default principal: johndoe@MYDOMAIN.COM

Valid starting       Expires              Service principal
10/10/2018 15:32:01  10/11/2018 07:32:01  krbtgt/MYDOMAIN.COM@MYDOMAIN.COM
    renew until 10/11/2018 15:32:01, Flags: FRI

使用 keytab 认证:

# kdestroy -A && kinit -k -t /etc/gss_client_mon1.ktab -f 'ceph/ceph-mon1@MYDOMAIN.COM' && klist -f
Ticket cache: KEYRING:persistent:0:0
Default principal: ceph/ceph-mon1@MYDOMAIN.COM

Valid starting       Expires              Service principal
10/10/2018 15:54:25  10/11/2018 07:54:25  krbtgt/MYDOMAIN.COM@MYDOMAIN.COM
    renew until 10/11/2018 15:54:25, Flags: FRI

步骤 7:默认客户端 keytab(若存在且可读)被用于自动获取 GSSAPI 客户端应用的初始凭证。获取初始凭证时默认使用该客户端 keytab 中第一条记录的 principal 名字。查找顺序是:

  1. 环境变量 KRB5_CLIENT_KTNAME;
  2. [libdefaults] 中的 profile 变量 default_client_keytab_name;
  3. 硬编码默认值 DEFCKTNAME。

Ceph 的做法是:在内部把环境变量 KRB5_CLIENT_KTNAME 设为与 gss_ktab_client_file 相同的位置,并修改 ceph.conf 以加入新的认证方法:

/etc/ceph/ceph.conf
[global]
    ...
    auth_cluster_required = gss
    auth_service_required = gss
    auth_client_required = gss
    gss_ktab_client_file = /{$my_new_location}/{$my_new_ktab_client_file.keytab}
    ...

步骤 8:GSSAPI 随后就能读取 keytab 文件,并借助(由 DNS 提供的)名称与服务解析,按如下流程请求 TGT:

  1. 用户/客户端把 principal 身份与凭证发送给 KDC 服务器(TGT 请求);
  2. KDC 在其内部数据库中检查该 principal;
  3. KDC 用该 principal 的 key 创建并包装一个 TGT(TGT + Key);
  4. 新创建的 TGT 被解密并存入凭证缓存;
  5. 此时,Kerberos/GSSAPI 感知的应用(和/或服务)就能够检查 keytab 文件中活跃的 TGT 列表。

4.4.6 给 Ceph 开发者:为什么用 GSSAPI 而不是原生 KRB5 API

官方原文:我们当然可以直接使用原生 KRB5 APIs(而不是 GSSAPIs),但我们想要一个在网络安全方面更可移植的选项,而这正是 GSS(Generic Security Standard)-API 的标志。它本身并不真正提供安全服务,而是一个「以通用方式向调用者提供安全服务」的框架。

分层:

+---------------------------------+
|        Application              |
+---------------------------------+
| Protocol (RPC, Etc. [Optional]) |
+---------------------------------+
|         GSS-API                 |
+---------------------------------+
|   Security Mechs (Krb v5, Etc)  |
+---------------------------------+

GSS-API 做的两件主要的事:

  1. 创建一个安全上下文(security context),数据可以在应用之间在其中传递。上下文可以被看作应用之间的一种「信任状态(state of trust)」。共享一个上下文的应用互相知道对方是谁,因此只要上下文持续,就可以允许数据传输。
  2. 对要传输的数据施加一种或多种保护,即所谓的「安全服务(security services)」。

GSS-API 提供的四种可移植性:

类型 说明
Mechanism independence 为已实现的机制提供通用接口。指定默认安全机制后,应用不需要知道它在用哪个机制
Protocol independence 独立于任何通信协议或协议族,可用于 sockets、RPC、TCP/IP 等。RPCSEC_GSS 是把 GSS-API 与 RPC 平滑集成的附加层
Platform independence 完全不在意应用运行在什么操作系统上
Quality of Protection (QOP) independence QOP 是用于加密数据或生成密码学标签的算法类型;GSS-API 允许程序员忽略 QOP,使用其提供的默认值(也可以指定)

GSS-API 提供的三种安全服务:

服务 说明
Authentication(基本服务) 验证身份:如果你被认证了,就意味着你被认可是你所声称的那个人
Integrity 数据可以附带一个密码学标签,即 Message Integrity Code (MIC),以证明到达的数据与发送方发出的数据相同
Confidentiality 允许数据被加密(如果底层机制支持)

当前 GSS-API 可用的机制(官方表格):

Mechanism Name Object Identifier Shared Library Kernel Module
diffie_hellman_640_0 1.3.6.4.1.42.2.26.2.4 dh640-0.so.1
diffie_hellman_1024_0 1.3.6.4.1.42.2.26.2.5 dh1024-0.so.1
SPNEGO 1.3.6.1.5.5.2
iakerb 1.3.6.1.5.2.5
SCRAM-SHA-1 1.3.6.1.5.5.14
SCRAM-SHA-256 1.3.6.1.5.5.18
GSS-EAP (arc) 1.3.6.1.5.5.15.1.1.*
kerberos_v5 1.2.840.113554.1.2.2 gl/mech_krb5.so gl_kmech_krb5

OID 的两种格式:

  1. { 1 2 3 4 } —— GSS-API 规范正式规定的格式,gss_str_to_oid() 期望这一种;
  2. 1.2.3.4 —— 更广泛使用,但不是官方标准格式。

GSS-API 不做的事(为了最大化其通用性):

# 不做的事
a 为用户或应用提供安全凭证(必须由底层安全机制提供;GSS-API 允许应用自动或显式地获取凭证)
b 在应用之间传输数据(传输所有数据是应用自己的责任)
c 区分不同类型的被传输数据(例如判断一个数据包是普通数据还是 GSS-API 相关数据)
d 指示由于远端(异步)错误导致的状态
e 自动保护多进程程序的进程之间发送的信息
f 分配要传给 GSS-API 函数的字符串缓冲区
g 释放 GSS-API 数据空间(必须用 gss_release_buffer() 和 gss_delete_name() 等函数显式释放)

使用 GSS-API 的基本步骤(6 步):

  1. 每个应用(发送方与接收方)获取凭证(如果凭证尚未被自动获取,则显式获取);
  2. 发送方发起一个安全上下文,接收方接受它;
  3. 发送方对要传输的消息(数据)施加安全保护 —— 即加密消息,或给它打上识别标签,然后传输。(发送方也可以选择不施加任何安全保护,此时消息只关联 GSS-API 的默认安全服务,即认证 —— 接收方知道发送方就是它所声称的那个人);
  4. 接收方解密消息(如需要)并验证它(如适用);
  5. (可选)接收方向发送方返回一个识别标签以作确认;
  6. 双方销毁共享的安全上下文;如有必要,也可以释放任何"残留"的 GSS-API 数据。

使用 GSS-API 的应用应当包含头文件 gssapi.h。

官方给出的参考:RFC 1964、RFC 2743、RFC 2744、RFC 4178、RFC 6649,以及 MIT Kerberos Documentation。

4.4.7 Kerberos 服务器搭建(官方步骤摘要)

⚠️ 官方明确声明:"this is not a recommendation for a production environment"(这不是对生产环境的建议)。不覆盖 Master/Slave 复制集群,也不覆盖生产环境相关的任何东西(ntp/chrony、dns、pam/nss、sssd 等)。

官方另外补充的几点前提(源文 830–866 行):

  • 服务端:不同的后端数据库可能需要不同的依赖和配置步骤,LDAP as a backend database 就是一个典型例子。
  • 客户端:不同的客户端后端配置步骤不同,例如 PAM/NSS 或 SSSD(配合 LDAP 做 identity service、Kerberos 做 authentication service)——SSSD 是加入 MS Active Directory 域、做 User Logon Management 时最合适的选项。
  • 官方无意覆盖所有可能的场景/组合,这里只给"把一个(MIT)Kerberos Server 跑起来"的最简步骤。
  • rpm 包名、二进制与配置文件路径可能因发行版而略有不同。

部分发行版自带 wizard,可以完成基础配置(官方以 SUSE 为例),但我们下面的内容走的是手工配置(manual configuration):

SUSE:
    Kerberos server:
        yast2 auth-server

    Kerberos client:
        pam/nss: yast2 ldapkrb
        sssd: yast2 auth-client

步骤 1:安装 KDC 服务器

a) 安装所需软件包:

SUSE: zypper install krb5 krb5-server krb5-client
    开发额外需要: krb5-devel
    使用 'sssd' 额外需要: sssd-krb5 sssd-krb5-common

REDHAT: yum install krb5-server krb5-libs krb5-workstation
    Additionally: 'Needs to be checked'

💡 补充:官方对 REDHAT 的额外依赖只写了 'Needs to be checked'(待确认),未给出具体包名。

b) 编辑 KDC 服务器配置文件 /var/lib/kerberos/krb5kdc/kdc.conf(与 4.4.5 步骤 2b 为同一份配置):

/var/lib/kerberos/krb5kdc/kdc.conf
[kdcdefaults]
        kdc_ports = 750,88
[realms]
        MYDOMAIN.COM = {
            acl_file = /var/lib/kerberos/krb5kdc/kadm5.acl
            admin_keytab = FILE:/var/lib/kerberos/krb5kdc/kadm5.keytab
            default_principal_flags = +postdateable +forwardable +renewable +proxiable
                                                    +dup-skey -preauth -hwauth +service
                                                    +tgt-based +allow-tickets -pwchange
                                                    -pwservice
            dict_file = /var/lib/kerberos/krb5kdc/kadm5.dict
            key_stash_file = /var/lib/kerberos/krb5kdc/.k5.MYDOMAIN.COM
            kdc_ports = 750,88
            max_life = 0d 10h 0m 0s
            max_renewable_life = 7d 0h 0m 0s
        }
...

c) 编辑 Kerberos 客户端配置文件 /etc/krb5.conf(注意这里比 4.4.5 步骤 1 多了两行 //--> 注释):

/etc/krb5.conf
[libdefaults]
    dns_canonicalize_hostname = false
    rdns = false
    forwardable = true
    dns_lookup_realm = true     //--> if using DNS/DNSMasq
    dns_lookup_kdc = true       //--> if using DNS/DNSMasq
    allow_weak_crypto = false
    default_realm = MYDOMAIN.COM
    default_ccache_name = KEYRING:persistent:%{uid}

[realms]
    MYDOMAIN.COM = {
        kdc = kerberos.mydomain.com
        admin_server = kerberos.mydomain.com
        ...
    }
...

步骤 2:创建 Kerberos 数据库

SUSE:   kdb5_util create -s
REDHAT: kdb5_util create -s

步骤 3:启用并启动 KDC 与 KDC admin 服务器

SUSE:   systemctl enable/start krb5kdc
        systemctl enable/start kadmind
REDHAT: systemctl enable/start krb5kdc
        systemctl enable/start kadmin

步骤 4:创建 Kerberos 管理员

Kerberos principal 既可以在 KDC 服务器本地创建,也可以经由网络使用 'admin principal' 创建。下面在 KDC 服务器上使用 kadmin.local。

a) 列出现有 principal:

kadmin.local:  listprincs
K/M@MYDOMAIN.COM
krbtgt/MYDOMAIN.COM@MYDOMAIN.COM
kadmin/admin@MYDOMAIN.COM
kadmin/changepw@MYDOMAIN.COM
kadmin/history@MYDOMAIN.COM
kadmin/kerberos.mydomain.com@MYDOMAIN.COM
root/admin@MYDOMAIN.COM
...

b) 如果没有内置的 'admin principal',就创建一个。官方用 root 作为 principal 名,因为默认情况下 kinit 会尝试用与系统登录用户名相同的名字去认证,除非显式传入一个 principal 参数(kinit principal):

# kadmin.local -q "addprinc root/admin"
Authenticating as principal root/admin@MYDOMAIN.COM with password.
WARNING: no policy specified for root/admin@MYDOMAIN.COM; defaulting to no policy
Enter password for principal "root/admin@MYDOMAIN.COM":

c) 确认新建的 'admin principal' 在 KDC ACL 中有所需权限(ACL 变更后需要重启 kadmind):

SUSE:   /var/lib/kerberos/krb5kdc/kadm5.acl
REDHAT: /var/kerberos/krb5kdc/kadm5.acl

###############################################################################
#Kerberos_principal      permissions     [target_principal]      [restrictions]
###############################################################################
#
*/admin@MYDOMAIN.COM     *

d) 创建一个简单的 'user principal'(与 The 'Ceph side' of the things 的 4a 步骤相同):

kadmin.local:  addprinc johndoe
WARNING: no policy specified for johndoe@MYDOMAIN.COM; defaulting to no policy
Enter password for principal "johndoe@MYDOMAIN.COM":
Re-enter password for principal "johndoe@MYDOMAIN.COM":
Principal "johndoe@MYDOMAIN.COM" created.

e) 确认新建的 'user principal' 能够认证(与 The 'Ceph side' of the things 的第 6 步相同):

# kdestroy -A && kinit -f johndoe && klist -f
Password for johndoe@MYDOMAIN.COM:
Ticket cache: KEYRING:persistent:0:0
Default principal: johndoe@MYDOMAIN.COM

Valid starting       Expires              Service principal
11/16/2018 13:11:16  11/16/2018 23:11:16  krbtgt/MYDOMAIN.COM@MYDOMAIN.COM
        renew until 11/17/2018 13:11:16, Flags: FRI
...

步骤 5:到此我们应当已经有一个简单可用的(MIT)Kerberos Server 了

a) 考虑到我们会想同时为 'user' 和 'service' principal 使用 keytab 文件,请回到 The 'Ceph side' of the things 的第 4 步开始做起。

b) 官方建议熟悉下面这些工具及其 man page(列表见本节末)。

步骤 6:名称解析(官方强调:Kerberos 严重依赖名称解析,大多数 Kerberos 问题都与它有关)

a) DNS RRs(见 4.4.5 步骤 2a,源文此处为 6a,指向 The 'Ceph side' of the things; step 2a 与 Before We Start 的 DNS resolution 一节)。使用 srv resources 做服务定位、用 txt record 做 host/domain 到 realm 的映射; b) 对于没有 DNS 服务器可用的小网络或开发环境,可以选择用 DNSMasq —— 一个易于配置的轻量级 DNS 服务器(还带有一些其他能力)。除所需的 'host records' 外,可以把下面这些记录加进 /etc/dnsmasq.conf:

/etc/dnsmasq.conf
...
txt-record=_kerberos.mydomain.com,"MYDOMAIN.COM"
srv-host=_kerberos._udp.mydomain.com,kerberos.mydomain.com,88,1
srv-host=_kerberos._udp.mydomain.com,kerberos-2.mydomain.com,88,20
srv-host=_kerberos-master._udp.mydomain.com,kerberos.mydomain.com,88,0
srv-host=_kerberos-adm._tcp.mydomain.com,kerberos.mydomain.com,749,0
srv-host=_kpasswd._udp.mydomain.com,kerberos.mydomain.com,464,0
srv-host=_kerberos._tcp.mydomain.com,kerberos.mydomain.com,88,1
...

c) b) 全部配置好、且 dnsmasq 服务起来之后,可以用下面的方式测试:

# nslookup kerberos
Server:     192.168.10.1
Address:    192.168.10.1#53

Name:   kerberos.mydomain.com
Address: 192.168.10.21

# host -t SRV _kerberos._tcp.mydomain.com
_kerberos._tcp.mydomain.com has SRV record 1 0 88 kerberos.mydomain.com.

# host -t SRV {each srv-host record}
# host -t TXT _kerberos.mydomain.com
_kerberos.mydomain.com descriptive text "MYDOMAIN.COM"

f) 只要 name resolution 正常工作(无论用的是 dnsmasq 还是 named),Kerberos 就应当能够找到它所需要的服务。

💡 补充:源文这一节的编号是 a、b、c、f,跳过了 d 和 e(原文即如此,未见对应内容)。

常用的 Kerberos 工具(官方建议熟悉它们的 man page):

名称 用途
krb5.conf Krb 客户端配置文件
kdc.conf KDC 服务器配置文件
krb5kdc KDC 服务器守护进程
kadmind KDC 管理守护进程
kadmin Krb 管理工具
kdb5_util Krb 底层数据库管理工具
kinit 获取并缓存 Kerberos TGT 的工具
klist 列出已缓存 Kerberos ticket 的工具
kdestroy 销毁 Kerberos ticket 的工具

4.4.8 相关配置项速查(含 v20.2.4 真实默认值)

默认值取自 src/common/options/*.yaml.in @ v20.2.4

配置项 type / level 默认值 官方 desc 要点
gss_ktab_client_file str / advanced /var/lib/ceph/$name/gss_client_$name.ktab GSS/KRB5 Keytab file for client authentication;long_desc:设置 GSS/Kerberos 客户端 keytab 文件的完整路径。services: mon, osd
gss_target_name str / advanced ceph long_desc:设置 gss target service name。services: mon, osd(无 desc,只有 long_desc)
auth_cluster_required str / advanced cephx(flags: runtime) authentication methods required by the cluster;fmt_desc:Valid settings are cephx or none
auth_service_required str / advanced cephx(flags: runtime) authentication methods required by service daemons;Valid settings are cephx or none
auth_client_required str / advanced cephx, none(flags: runtime) authentication methods allowed by clients;Valid settings are cephx or none

gss_ktab_client_file 的默认值与源文步骤 6 的描述完全一致:源文说它默认指向 /var/lib/ceph/$name/gss_client_$name.ktab,例如 osd1.mydomain.com 上就是 /var/lib/ceph/osd1/gss_client_osd1.ktab。

Ceph 侧启用 gss 的最小配置(源文步骤 7 给出):

/etc/ceph/ceph.conf
[global]
    ...
    auth_cluster_required = gss
    auth_service_required = gss
    auth_client_required = gss
    gss_ktab_client_file = /{$my_new_location}/{$my_new_ktab_client_file.keytab}
    ...

💡 补充:源文没有提到 gss_target_name,该配置项来自 src/common/options/global.yaml.in,其默认值 ceph 正好对应源文里"Ceph 用 ceph 作为 principal 的 primary 名字"的约定。

4.4.9 与 cephx 的关系、限制与已知问题

源文实际写明的、与 cephx 的对照关系(只有两条,见 4.4.1 末尾官方总结):

  1. Ceph Kerberos 认证完全基于 MIT Kerberos 实现,并使用 GSSAPI——而 cephx 是 Ceph 自带的认证协议(其协议设计"看起来很像 Kerberos",见 4.2 节);
  2. 当前仍使用 Kerberos 的默认后端数据库,官方计划增加 LDAP 作为后端,从而实现 GSSAPI (KRB5) 做认证 + LDAP (LDAPv3) 做授权,经由 SASL 机制。

源文未写明的内容(据实标注):

问题 结论
gss 认证是否覆盖 mon / osd / mds / mgr 全部守护进程与全部客户端 文档未明确(仅 gss_ktab_client_file / gss_target_name 的 services 标注为 mon、osd)
启用 gss 后是否仍可同时使用 cephx、两者如何共存或切换 文档未明确
Kerberos ticket 与 Ceph 的 auth ticket / capability(caps)如何映射 文档未明确
已知问题(known issues)列表 文档未明确(源文没有专门的限制/已知问题章节)

💡 补充(基于本机 options 源文件的观察,非官方结论):在 v20.2.4 的 src/common/options/global.yaml.in 中,auth_cluster_required / auth_service_required / auth_client_required 三者 fmt_desc 里声明的 Valid settings 只有 cephx 或 none,并未列出 gss;而 gss_ktab_client_file 与 gss_target_name 仍然存在且 services 仅为 mon、osd。结合源文最后更新时间为 Dec 3, 2018,这一页属于开发文档(dev/)中的实验性/历史性说明,落地使用前应自行核对当前代码与版本支持情况。

💡 补充:源文正文在第 1096 行(即 6f 那句"只要名称解析正常工作,无论 dnsmasq 还是 named,Kerberos 就应能找到所需服务")结束,其后没有更多内容。

引自:Kerberos Authentication


4.5 会话认证(session_authentication.rst)

这也是 Peter Reiher 于 2012-07-30 撰写的历史文档,描述的是当时新加入的会话认证功能。⚠️ 注意文档中的文件路径(msg/Pipe.cc、msg/SimpleMessager.cc 等)属于 SimpleMessenger 时代,Tentacle 中的 async messenger 路径已不同。

4.5.1 设计动机

原始的 Cephx 协议把客户端认证给认证服务器,并建立一个 session key,
该 key 用于把客户端认证给它需要通信的服务端。

但是,它并没有认证客户端与服务端之间「持续的(ongoing)」消息。

基于它们共享一个秘密密钥这一事实,
这些持续的会话消息可以很容易地通过「用该 key 对消息签名」来认证。

本文档描述的就是允许这种持续会话认证的代码改动。

  • 这些改动允许未来让其他认证协议(以及现有的 null NONE 与 UNKNOWN 协议)处理签名,但在撰写本文时,唯一真正做签名的协议是 Cephx 协议。
  • 代码也准备好处理会话消息的加密与解密(这将在签名提供的完整性之上增加保密性)。不过目前没有任何已实现的协议会加密持续的会话消息。

4.5.2 基本模型

  • 本代码在 Cephx 协议完成之后才发挥作用。此时客户端与服务端共享一个秘密密钥,该 key 将用于认证。
  • 这里的 "session" 由一个已建立的 pipe(管道)表示。
  • 对于这样的 pipe,应当有一个 session_security 结构附在其上。
  • 每当要在该 pipe 上发送一条消息时,处理此类会话安全性的签名的代码就会被调用。
  • 在 pipe 的另一端,检查此类会话安全性的消息签名的代码会被调用。
  • 签名检查失败的消息将不会被继续处理。
    • 这意味着:发送方最好与接收方就「正在使用的会话安全性」达成一致,否则它们之间的消息会被一律丢弃。

要让这个功能工作,需要几个步骤:

  1. 发送方与接收方必须有一次成功的 cephx 协议运行,以建立一个共享 key;
  2. 它们必须把该 key 存放在某个 pipe 稍后能取到的地方,以便用它签名消息;
  3. 发出的消息必须被签名,收到的消息必须被检查签名。

签名的计算方式可以有多种,但目前其大小被限制为 64 位。 一条消息的签名被放在它的 footer 中,字段名为 sig。

开关:签名代码可以在运行时通过一个名为 cephx_sign_messages 的 Ceph boolean 选项打开或关闭。 它当前默认为 false,因此不会有消息被签名。必须把它改为 true 才会计算并检查签名。

4.5.3 存储密钥(Storing the Key)

  • 该 key 在发送端用于创建签名、在接收端用于检查签名。
  • 未来如果使用非对称加密,可能需要存两个 key(本端一个私钥、对端一个公钥)。目前两个方向的消息都用同一个 key 签名,所以只需要保存那一个 key。

密钥在 pipe 建立时被保存:

端 时机 位置
客户端侧 在 connect() 中(位于 msg/Pipe.cc) 该 key 从一次 Cephx 协议运行中取得,该运行产生了一个已被成功检查的 authorizer 结构。如果有这样一个 authorizer 可用,代码就调用 get_auth_session_handler() 创建一个新的认证会话 handler,并把它存放在 pipe 数据结构中
服务端侧 在 accept() 中,在客户端提供的 authorizer 被验证之后 做类似的事情

一旦连接两端都完成了这些事情,会话认证就可以开始了。

特殊情况:connect() 与 accept() 也被用于处理「正在建立一个新 session」的情形。此时还没有 authorizer 被创建,因此没有 key。 调用签名代码的那些代码中的特例会跳过这些调用 —— 当 CEPH_AUTH_UNKNOWN 协议正在使用时。 该协议标签出现在 session 中 pre-authorizer 的那些消息上,表示「正在协商认证协议,因此无法签名」。 官方认为这不是安全问题:在本 session 稍后会进行一次可靠的认证操作,之后才会传递任何敏感信息。

4.5.4 签名消息(Signing Messages)

  • 消息在位于 msg/Pipe.cc 的 write_message 调用中被签名。
  • 实际的签名过程是:用共享密钥加密消息的 CRC。 因此,我们必须把签名推迟到所有 CRC 都计算完毕之后。
  • header CRC 是最后计算的,所以我们一计算出那个 CRC,就调用 sign_message()。

sign_message() 是定义在 auth/AuthSessionHandler.h 中的一个虚函数。因此,必须为每种被支持的认证协议编写一个特定版本。当前只支持 UNKNOWN、NONE 和 CEPHX,所以在以下三处各有一个 sign_message() 版本:

协议 位置 行为
UNKNOWN auth/unknown/AuthUnknownSessionHandler.h 简单地返回 0,表示成功
NONE auth/none/AuthNoneSessionHandler.h 简单地返回 0,表示成功
CEPHX auth/cephx/CephxSessionHandler.cc 完整实现

CEPHX 版本的 sign_message() 流程:

  1. 首先判断是否开启了处理签名的运行时选项(见上)。
    • 如果没有开启,Cephx 版本只是返回成功,而不实际计算签名或把它插入消息。
  2. 如果开启了该选项:
    • 把消息的所有 CRC(一个来自 header,三个来自 footer)拷贝到一个 buffer 中;
    • 对该 buffer 调用 encode_encrypt(),使用从 pipe 的 session_security 结构中取得的 key;
    • 把加密结果的 64 位放进消息 footer 的签名字段,并设置一个 footer 标志以表明该消息已被签名;
      • ⚠️ 官方说明:这个标志只是一个完整性检查(sanity check),它不被视为「该消息已被签名」的决定性证据。接收端存在 session_security 结构这一事实本身就要求必须有签名,无论该标志的值是什么。;
    • 如果一切顺利,sign_message() 返回 0;
    • 如果过程中任何地方出问题、没有计算出签名,它返回 SESSION_SIGNATURE_FAILURE。

4.5.5 检查签名(Checking Signatures)

  • 签名由一个名为 check_message_signature() 的例程检查。它也是定义在 auth/AuthSessionHandler.h 中的一个虚函数,因此同样有针对各被支持协议(UNKNOWN、NONE、CEPHX)的特定版本。UNKNOWN 与 NONE 版本同样只是返回 0。

CEPHX 版本的 check_message_signature()(位于 auth/cephx/CephxSessionHandler.cc)流程:

  1. 如果运行时选项禁用了签名,则以成功退出。
  2. 否则,取出 header 与 footer 中的 CRC,对结果加密,并与 footer 中存放的签名比较。
    • 由于更早的一个例程已经检查过 CRC 与消息内容确实匹配,因此没有必要在消息的原始数据上重新计算 CRC。
  3. 加密是用与发送端相同的 encode_encrypt() 例程执行的,使用存放在本地 session_security 数据结构中的 key。
  4. 如果一切正常,返回 0(成功);如果有问题,返回 SESSION_SIGNATURE_FAILURE。

4.5.6 如何新增会话认证方法

仅就会话认证而言(不是指当前由 Cephx 协议执行的客户端与服务端的基本认证),除了新增一个协议之外,该协议还必须有一个 sign_message() 例程和一个 check_message_signature 例程。

  • 这些例程接受一个 message 指针作为参数,成功时返回 0;
  • 用于签名和检查的过程将特定于新方法,但很可能会有一个附在 pipe 上、包含密码学密钥的 session_security 结构;
  • 该结构将是 AuthSessionHandler(位于 auth/AuthSessionHandler.h)或从该类型派生的结构。

4.5.7 如何给会话增加加密

  • 现有代码**部分(但未完全)**为会话数据包加密做好了准备。
  • 增加加密的工作一部分与增加新的认证方法类似,但还需要在 write_message() 和 read_message() 中增加对加密与解密例程的调用。
    • 这些调用的位置大概会靠近当前认证调用所在的位置。
  • 官方建议:应当考虑是否要「用某种更通用的东西」替换现有的调用,该通用东西做「所选会话安全形式所要求的任何事情」,而不是显式地说 sign 或 encrypt。

4.5.8 会话安全统计(Session Security Statistics)

  • 现有的 Cephx 认证代码保存统计信息:① 多少消息被签名;② 多少消息签名被检查;③ 多少次检查成功与失败。
  • 它也准备好保存关于加密与解密的类似统计。
  • 这些统计可以通过调用 auth/AuthSessionHandler.cc 中的 printAuthSessionHandlerStats 来访问。
  • 如果新增了认证或加密方法,它们应当包含保存这些统计的代码。

引自:Session Authentication for the Cephx Protocol


五、集群映射与元数据(mon 内部)

本章覆盖 mon-on-disk-formats.rst、mon-bootstrap.rst、mon-elections.rst、mon-osdmap-prune.rst 四页。

📌 说明:这四页不在本册的指定输入清单中,但它们确实存在于 handbook-src/raw/dev/ 目录下。按本册大纲「若文件存在则讲」的要求,本章一并覆盖。

5.1 Monitor 磁盘格式(mon-on-disk-formats.rst)

5.1.1 升级路径(UPGRADE PATH)

  • 磁盘格式,甚至数据结构格式,都可能在一次升级期间被改变。
  • 希望这样做的服务,可以通过 PaxosService::upgrade_format() 调用路径来实现。
  • 没有形式化的、统一的格式版本管理:PaxosService 类通过一个 store 中的 key 来跟踪它的 format_version,被假定为一个 unsigned int,但赋予这些版本以含义是该服务自己的责任。

5.1.2 AUTH MONITOR 的格式版本

版本用单个 unsigned int 表示。默认情况下,值 0 表示「不存在正式的已升级格式」。 第一个格式版本是在 Dumpling 中引入的;升级到 Dumpling 的集群会看到其格式版本从零升到一:

版本迁移 引入版本
0 → 1 引入于 v0.65(v0.67 dumpling 的开发版本)
1 → 2 引入于 v12.0.2(luminous 的开发版本)
2 → 3 引入于 mimic
版本值 适用集群
0 所有 pre-dumpling 集群
1 所有 dumpling+ 且 pre-luminous 集群
2 所有 luminous+ 且 pre-mimic 集群
3 所有 mimic+ 集群

各版本的内容:

版本 引入的内容
version 1 引入 new-style monitor caps(即 profiles)
version 2 引入 mgr caps 与 bootstrap-mgr key
version 3 如果所有 bootstrap 与 admin key 尚不存在,就创建它们

5.1.3 调用栈(callstack)

format_version 在 PaxosService::refresh() 中被设置:

- 最初从 Monitor::refresh_from_paxos 调用
  - 最初从 Monitor::init_paxos() 调用
    - 最初从 Monitor::preinit() 调用

AuthMonitor::upgrade_format() 由 PaxosService::_active() 调用:

- 从 C_Committed callback 调用,来自 PaxosService::propose_pending()
- 从 C_Active callback 调用,来自 PaxosService::_active()
- 从 PaxosService::election_finished() 调用

- 在一个全新部署的集群上,upgrade_format() 将**在 create_initial() 之后**首次被调用
- 在一个已存在的集群上,upgrade_format() 将在**第一次选举之后**被调用

- 在一个全新部署的集群上,upgrade_format() 是**无关紧要的**,
  因为此时没有需要升级的格式

5.1.4 升级逻辑(boil down)

版本判定:

* 如果 format_version >= current_version,那么格式是最新的,返回
* 如果 features 不包含 LUMINOUS,则 current_version = 1
* 否则如果 features 不包含 MIMIC,则 current_version = 2
* 否则 current_version = 3

如果 format_version == 0:

- 升级到 format version 1
  - 迁移到 new-style monitor caps(即 profiles):
    - 为已有的 entity 设置 daemon profiles
    - 为已有的 bootstrap key 设置 profile

如果 format_version == 1:

- 升级到 format version 2
  - 对已有的 entity:
    - 为 mgr 增加新的 cap
  - 对已有的 'mgr' entity,修复因 kraken 的一个 bug 而被设为 'allow *' 的 'mon' caps,
    改为设置 'allow profile mgr'
  - 增加 bootstrap-mgr key

如果 format_version == 2:

- 升级到 format version 3
  - 如果所有 bootstrap key 当前不存在,就创建它们

💡 补充:官方这一页只覆盖了 AUTH MONITOR 一个服务的磁盘格式。官方在标题下只写了 AUTH MONITOR 一节,OSDMonitor、MDSMonitor、MonmapMonitor 等其他 Paxos 服务的 on-disk 格式文档未明确。

引自:ON-DISK FORMAT


5.2 Monitor bootstrap(mon-bootstrap.rst)

5.2.1 术语

术语 定义
cluster 一组 monitor
quorum 一个活跃的 monitor 集合,由该 cluster 的多数派构成

5.2.2 初始化一个新 monitor 的必备输入

必须始终被提供:

  1. 一个逻辑名字(logical name);
  2. mon. 的 secret key。注意它不带 id 限定,因为所有 Monitor 共享同一个 secret;
  3. 一个 cluster fsid (uuid)。

此外还需要知道两件事:

  1. 要绑定(bind)到什么地址;
  2. 它的 peer 是谁(如果有的话)。

5.2.3 逻辑 id

  • 逻辑 id 应当在集群内唯一。
  • 它会被附加到 mon. 之后,以在 Ceph 集群中从逻辑上描述该 monitor。例如,如果逻辑 id 是 foo,该 monitor 的名字就是 mon.foo。
  • 对大多数用户而言,每台主机不超过一个 monitor,因此短主机名(short hostname)是一个合乎逻辑的选择。

5.2.4 Secret keys

mon. 的 secret key 存放在 mon data 目录中的一个 keyring 文件里。可以用类似下面的命令生成:

ceph-authtool --create-keyring /path/to/keyring --gen-key -n mon.

创建一个新的 monitor 集群时,该 keyring 还应当包含一个 client.admin key,用于管理系统:

ceph-authtool /path/to/keyring --gen-key -n client.admin --cap mon 'allow *' --cap osd 'allow *' --cap mds 'allow'

生成的 keyring 通过命令行参数 --keyring <keyring> 喂给 ceph-mon --mkfs。

⚠️ 官方 Note:Monitor keyring 文件是 KeyServer 的一个 fallback store,用于存放通过常规 ceph auth 命令套件管理的 key。因此,client.admin key 可以被放在这个 keyring 里,但未来对 client.admin 凭证的一次轮转或更新可能会使它失效(obviate it)。

5.2.5 Cluster fsid

cluster fsid 是一个普通的 uuid,类似于 uuidgen 命令生成的那种。它有两种方式提供给 monitor:

  1. 通过命令行参数 --fsid <uuid>(或配置文件选项);
  2. 通过一个 monmap,经 --monmap <path> 命令行参数提供给新 monitor。

5.2.6 Monitor 地址(4 种提供方式)

# 方式
1 通过命令行选项 --public-addr <ip[:port]>(或配置文件选项)
2 通过命令行选项 --public-network <cidr>(或配置文件选项)
3 通过 --monmap <path> 提供的 monmap(如果它包含一个与我们的名字相同的 monitor)
4 通过 bootstrap monmap(经 --inject-monmap <path> 提供,或由 --mon-host <list> 生成)—— 如果它包含一个没有名字的 monitor(noname-<something>)以及一个配置在本地主机上的地址

5.2.7 Peers(4 种提供方式)

# 方式
1 通过 initial monmap,经 --monmap <filename> 提供
2 通过由 --mon-host <list> 生成的 bootstrap monmap
3 通过配置文件中 [mon.*] 节里已弃用的 mon addr 选项生成的 bootstrap monmap(⚠️ 官方明确说:该方法不推荐使用,且不支持同时绑定 v1 与 v2 协议地址)
4 通过 admin socket 动态提供

⚠️ 官方强调:这些方法不能完全互换,因为「在没有竞争(race)危险的情况下创建一个新的 monitor 集群」这件事本身很复杂。

5.2.8 集群创建的三种基本方式

官方归纳为三种:

  1. 提前指定 monitor 的名字与地址来创建新集群;
  2. 提前指定 monitor 的名字,然后在 ceph-mon 守护进程自行配置时动态设置地址;
  3. 提前指定 monitor 的地址。
方式 A:名字 + 地址(Names and addresses)

用 monmaptool 生成一个包含初始 monitor 名字与地址的 monmap。生成的 monmap 也会包含一个 cluster fsid。 把这个 monmap 喂给每个 monitor 守护进程:

ceph-mon --mkfs -i <name> --monmap <initial_monmap> --keyring <initial_keyring>

当守护进程启动时,它们会确切地知道自己是谁、peer 是谁。

方式 B:只有地址(Addresses only)

初始 monitor 地址可以用 mon host 配置值指定,通过配置文件或命令行参数。这种方法的优点是:集群的一个全局配置文件里可以有这样一行:

mon host = a.foo.com, b.foo.com, c.foo.com

并且它同时也用于告知任何 ceph 客户端或守护进程「monitor 是谁」。

ceph-mon 守护进程还需要被喂给初始 keyring 与 cluster fsid 来初始化自己:

ceph-mon --mkfs -i <name> --fsid <uuid> --keyring <initial_keyring>

当守护进程首次启动时,它们会互相分享自己的名字,并形成一个新的集群。

方式 C:只有名字(Names only)

适用场景:在动态的"云"环境中,集群创建者可能(还)不知道 monitor 的地址会是什么。相反,他们可能希望机器并行地自行配置和启动,并随着它们上线而自行组成一个新集群。

问题在于:monitor 集群依赖严格多数派来保持自身一致,而为了"创建"一个新集群,它需要知道初始的 monitor 集合是什么。

解决办法:使用 mon initial members 配置项,它应当列出被允许创建该集群的初始 monitor 的 id:

mon initial members = foo, bar, baz

这些 monitor 随后可以通过提供其余信息(keyring、cluster fsid、以及一种确定自己地址的方式)来初始化:

ceph-mon --mkfs -i <name> --mon-initial-hosts 'foo,bar,baz' --keyring <initial_keyring> --public-addr <ip>

当这些守护进程启动时,它们会知道自己的地址,但不知道 peer 的地址。它们可以通过 admin socket 学到那些地址:

ceph daemon mon.<id> add_bootstrap_peer_hint <peer ip>

一旦它们从初始成员集合中学到了足够多的 peer,它们就能够创建该集群。

5.2.9 集群扩容(Cluster expansion)

集群扩容的要求比集群创建略低,因为「初始 quorum 的创建」不是问题,也无需担心创建出多个相互独立的集群。

让新节点加入一个已存在集群的两种方式:

  1. 不提供任何初始 monitor peer 地址,然后动态喂给它们;
  2. 指定 mon initial members 配置项以防止新节点形成一个新的、独立的集群,并通过任何可用的方法喂给它们一些已有的 monitor。
最初无 peer 的扩容(Initially peerless expansion)

创建一个新的 monitor,除了它自己的地址外不给它任何 peer 地址:

ceph-mon --mkfs -i <myid> --fsid <fsid> --keyring <mon secret key> --public-addr <ip>

守护进程启动后,你可以给它一个或多个 peer 地址以加入集群(最好是不带端口的裸 IP 地址;mon 会为你设置 addr types 与端口):

ceph daemon mon.<id> add_bootstrap_peer_hint <peer ip>

或者,你可以显式地指定 addrvec_t:

ceph daemon mon.<id> add_bootstrap_peer_hintv <peer addrvec>

例如:

ceph daemon mon.new add_bootstrap_peer_hintv v2:1.2.3.4:3300,v1:1.2.3.4:6789

这个 monitor 永远不会参与集群创建;它只能加入一个已存在的集群。

⚠️ 注意:指定的地址应当与新 monitor 所绑定的地址完全匹配。例如,如果新 mon 只绑定到 v2 地址,但提供了 v2 与 v1 两个地址,mon 之间就有可能产生某种混淆。

带 initial members 的扩容(Expanding with initial members)

你可以一开始就喂给新 monitor 一些 peer 地址,并通过同时设置 mon initial members 来避免出问题:

ceph-mon --mkfs -i <myid> --fsid <fsid> --keyring <mon secret key> --public-addr <ip> --mon-host foo,bar,baz

守护进程启动时,mon initial members 必须通过命令行或配置文件设置:

ceph-mon -i <myid> --mon-initial-members foo,bar,baz

以防止任何 split-brain(脑裂)的风险。

引自:Monitor bootstrap


5.3 Monitor 选举(mon-elections.rst)

5.3.1 原始算法(The Original Algorithm)

历史上,monitor leader 选举非常简单:排名最低(lowest-ranked)的 monitor 获胜!

  • 这是通过一个低状态的 "Elector" 模块完成的(尽管它现在已被拆分成一个处理消息传递的 Elector,与一个做投票选择的 ElectionLogic)。
  • 它跟踪选举 epoch,别的几乎不跟踪。
  • 奇数 epoch 是选举;偶数 epoch 有一个 leader,并让 monitor 做它的日常工作。
  • 当发生超时,或 monitor 要求一次新选举时,我们递增 epoch,并向所有已知 monitor 发送 Propose 消息。

消息处理规则:

情况 处理
收到一条旧消息 要么丢弃它,要么触发一次新选举(如果我们认为发送方是新启动的、需要加入 quorum)
收到一条来自更新 epoch 的消息 把我们的 epoch 提升以匹配,然后要么 Defer 给该 Proposer,要么再次递增 epoch 并 Propose 我们自己(如果我们预期能赢过他们)
在当前 epoch 内收到一条 Propose 要么 Defer 给发送方,要么忽略他们(如果他们的 rank 比我们高,或比我们已经 defer 给的那个 rank 更高,我们就忽略他们)

⚠️ 官方 Note:如果我们拥有最高的 rank,那么我们有可能在同一个选举 epoch 内依次 defer 给每一个其他 monitor!

收敛性:在正常情况下这会收敛,因为所有 monitor 对「优先级投票顺序」是一致的,且只有在一个 monitor 没有参与、或看到与已知 proposer 的可能冲突时,epoch 才会被递增。

5.3.2 原有算法的问题(The Problems)

原始算法在多种网络分区(netsplit)条件下完全不工作。

  • 实践中这并不经常显现;
  • 但随着社区与商业厂商把 Ceph 带入需要使用 "stretch clusters"(延展集群)的场景,它变得重要了。

5.3.3 新算法(The New Algorithms)

  • 我们仍然默认使用原始的("classic")选举算法,但支持让用户通过 CLI 切换到新的算法。
  • 这些算法作为不同的函数与 switch 语句实现在 ElectionLogic 类中。
算法 说明
disallow 非常简单:允许你把若干 monitor 加入一个「不允许成为 leader」的列表
connectivity 纳入连接评分(connection score ratings),并选出评分最高的 monitor
算法:disallow
  • 如果一个 monitor 在 disallowed 列表中,它总是 defer 给另一个 monitor,无论 rank 如何。
  • 其他方面与 classic 算法相同。
  • 由于修改 disallowed 列表需要一次 paxos 更新,一起参与选举的 monitor 应当总是拥有相同的集合。
  • 这意味着选举顺序在整个 monitor 集合中是恒定且静态的,选举可以平凡地(trivially)收敛(假定网络连通)。
  • 官方说:这个算法实际上只是作为一个演示、以及通往更高级的 connectivity 模式的垫脚石而存在的,但它在非对称网络与集群中可能有用。
算法:connectivity
  • 该算法以每条连接的评分(双向,见下一节)为输入,并尝试选出总评分最高的 monitor。
  • 我们保持与 classic 算法相同的基本消息传递流程(选举由对 Propose 消息的反应驱动)。
  • 但这带来了若干挑战,因为与 rank 不同,score 不是静态的(而且可能在一次选举期间发生变化!)。
  • 为了保证一个选举 epoch 不会产生多个 leader,我们必须维护两个关键不变量:
# 不变量
1 Monitor 必须在一次选举 epoch 期间保持静态的 score
2 任何 deferral 必须是传递的 —— 如果 A defer 给 B,然后又 defer 给 C,那么 B 最好也 defer 给 C!

如何显式地处理这两点:

  1. 每当开始一次选举(或递增 epoch)时,就分支出(branch)我们 peer_tracker 评分对象的一个副本 stable_peer_tracker;
  2. 如果某个 monitor 不会被我们当前的 leader 选择所 defer 给,我们就拒绝对它 defer。(所有 Propose 消息都包含一份「该 leader 正在使用的 score」的副本,因此 peer 可以评估它们。)

为了保证前向进展(forward progress),还做了几处进一步调整:

  1. 如果我们想 defer 给一个新的 peer,但已经 defer 给了一个「其 score 不允许那样做」的 peer,我们就递增选举 epoch 并重新 start() 选举。
  2. 所有选举消息都包含发送方所知道的 score。

收敛性论证(官方原文):

这保证了只要网络 reasonably stable(即使断开了),我们就能解决选举:只要所有 score "views" 产生相同的 deferral 顺序,一次选举就会正常完成。 并且通过在全部 monitor 之间广泛共享 score,monitor 会迅速收敛到全局最新的状态。

相比 classic 与 disallow handler 的一个更进一步的重要特性:

它可以忽略 out-of-quorum 的 peer。

通常,当 monitor B 收到来自一个 out-of-quorum peer C 的 Propose 时,B 会自己触发一次新选举,以给 C 一个加入的机会。 但由于最高评分的 monitor A 可能与 C 发生了 netsplit,这并不令人满意。 因此在 connectivity 选举算法中,B 只在其 score 表明「集群会选择一个 A 以外的 leader」时才"转发"(forward)Propose 消息。

5.3.4 连接评分(Connection Scoring)

  • 评分实现在 ConnectionTracker 类中,它由 Elector 驱动,并作为一个资源提供给 ElectionLogic。
  • Elector 负责发出 MMonPing 消息,并负责把结果以「用相关的 peer 和该次调用所计的时间单位调用 report_[live|dead]_connection」的形式报告进 ConnectionTracker。
    • 这些时间单位在 monitor 里是秒,但 ConnectionTracker 是不可知(agnostic)的,我们的单元测试只计简单的时间步数。

评分公式(官方原文):

我们配置一个 "half life"(半衰期),每次报告都会更新该 peer 的当前状态
(alive 或 dead)及其总评分。

新的 score 是:

    current_score * (1 - units_alive / (2 * half_life)) + (units_alive / (2 * half_life))

(对于一次 dead report,我们当然是**减去**新的 delta,而不是加上它。)

其他能力:

  • 我们可以进一步编码与解码 ConnectionTracker 以进行线上传输;
  • 可以 receive_peer_report() 一个完整的 ConnectionTracker(包含所有已知 score)或一个 ConnectionReport(代表单个 peer 的 score),以吸收来自 peer 的 score;
  • 这些 score 当然都是带版本的,因此我们没有「意外地回到过去」的危险;
  • 我们可以查询:
    • 单条连接的 score(如果该连接是 down 的,它就是 0);
    • 某个特定 monitor 的总 score —— 即从所有其他 monitor 进入该 monitor 的连接 score。

默认参数:

参数 默认值 含义
mon_elector_ping_timeout 2 秒 超过该时间我们认为 ping 失败
mon_elector_ping_divisor 1 秒 每 1 秒 ping 一次存活的连接
mon_con_tracker_score_halflife 12 小时 半衰期

引自:Monitor Elections


5.4 全量 OSDMap 版本裁剪(mon-osdmap-prune.rst)

5.4.1 问题背景

对于每一个增量 osdmap epoch,monitor 都会在 store 中保存一个全量(full)osdmap epoch。

优点 缺点
在响应客户端的 osdmap 请求时非常好 —— 使我们无需从大量增量中重新计算就能满足请求 一旦我们开始保存无界数量的 osdmap,它就会变成负担

基本回收机制:monitor 会尝试在 store 中保存有界数量的 osdmap。该数量由 mon_min_osdmap_epochs 定义并配置,默认为 500 个 epoch。一般而言,一旦超过这个限制,我们就会移除较老的 osdmap epoch。

但移除 osdmap 有若干约束(全部定义在 OSDMonitor::get_trim_to() 中):

  • 如果其中一个条件不满足,我们可能超过 mon_min_osdmap_epochs 所定义的边界;
  • 如果集群有一段时间不满足 trim 条件(例如存在 unclean pg),monitor 可能开始保存大量 osdmap;
  • 这会给底层 key/value store 以及可用磁盘空间带来压力。

5.4.2 被否决的替代方案与其问题

方案:干脆不在磁盘上保存全量 osdmap epoch。

  • 那样我们就必须按需重建 osdmap,或者如果它们最近被服务过就从缓存中抓取;
  • 我们仍然必须至少保存一个 osdmap,并在这个「保存在 store 中的最老 map epoch」或「从缓存中抓取的较新 map」之上应用所有增量;
  • 官方评价:虽然这是可行的,但似乎会有大量 CPU(以及潜在的 IO)被用于重建 osdmap;
  • 而且:这只能防止未来出现上述问题,对当前已经处于「确实需要不保存 osdmap」状态的 store 毫无帮助。

这就引出了「全量 osdmap 裁剪(full osdmap pruning)」。

5.4.3 裁剪思想:打洞而不是全删

我们不是不保存全量 osdmap epoch,而是在「数量过多」时裁剪掉其中一部分。

  • 判定「是否过多」由一个可配置选项 mon_osdmap_full_prune_min(默认:10000)决定。一旦超过这个阈值,裁剪算法就会被启动。
  • 但我们不会移除全部 mon_osdmap_full_prune_min 个全量 osdmap epoch。相反,我们要在全量 map 的序列中「打一些洞」。
  • 默认情况下,我们会「自上一个保留的 map 起,每 10 个 map 保留 1 个全量 map」 —— 即,如果我们保留 epoch 1,我们也会保留 epoch 10,并移除全量 map epoch 2 到 9。
  • 这个间隔的大小可以通过 mon_osdmap_full_prune_interval 配置。

本质上,我们提议保留约 10% 的全量 map,但我们总是会遵守由 mon_min_osdmap_epochs 定义的最小 osdmap epoch 数量,且这些不会被计入「待裁剪的最小版本数」。

官方给出的两个具体例子:

例 1:磁盘上有版本 [1..50000]
  → 裁剪算法只被允许在 osdmap epoch [1..49500) 上操作

例 2:磁盘上有版本 [1..10200]
  → 我们不会裁剪,因为算法只会在版本 [1..9700) 上操作,
    而这个区间包含的版本数少于 mon_osdmap_full_prune_min 所要求的最小数量

5.4.4 裁剪算法(ALGORITHM)

设定:store 中有 50,000 个 osdmap epoch,且所有可配置选项均使用默认值。

-----------------------------------------------------------
|1|2|..|10|11|..|100|..|1000|..|10000|10001|..|49999|50000|
-----------------------------------------------------------
 ^ first                                            last ^

裁剪的两个前置条件(必须全部满足):

# 条件
1 版本数量大于 mon_min_osdmap_epochs
2 first 与 prune_to 之间的版本数量大于或等于 mon_osdmap_full_prune_min,其中 prune_to = last - mon_min_osdmap_epochs

如果其中任何一个条件不满足,我们不会裁剪任何 map。

此外:如果已知我们一直在裁剪,但此后我们不再满足上述至少一个条件,我们就不会继续裁剪。 本质上:只有当 store 中的 epoch 数量确实需要时,我们才裁剪全量 osdmap。

pinned maps 与 manifest

由于裁剪会在全量 map 序列中制造空洞,我们需要跟踪「缺失 map 的区间」。我们的做法是:维护一份「被 pin 的 map」的 manifest —— 即,一份「因为被 pin 所以不应被裁剪」的 map 列表。

  • 被 pin 的 map 不会从 store 中移除,而两个相邻 pinned map 之间的 map 会被移除;
  • 被移除的 map 数量由可配置选项 mon_osdmap_full_prune_interval 决定;
  • 算法会尽力让 pinned map 之间相隔该选项所定义的那么多 map,但在极端情况下它可能允许更小的间隔;
  • 此外,由于这是一个「在每次裁剪迭代时都会读取」的可配置选项,如果用户改了这个配置项,这个间隔有可能发生变化。

Pin map 是惰性执行的:我们会在移除 map 的同时 pin map。 这带来两个好处:

  1. 在裁剪进行中给了我们更大的灵活性去改变 prune interval;
  2. 大大简化了算法,以及我们需要在 manifest 中保存的信息。
简化版算法(官方原文)
manifest.pin(first)
last_to_prune = last - mon_min_osdmap_epochs

while manifest.get_last_pinned() + prune_interval < last_to_prune AND \
      last_to_prune - first > mon_min_osdmap_epochs AND \
      last_to_prune - first > mon_osdmap_full_prune_min AND \
      num_pruned < mon_osdmap_full_prune_txsize:

    last_pinned = manifest.get_last_pinned()
    new_pinned = last_pinned + prune_interval
    manifest.pin(new_pinned)
    for e in (last_pinned .. new_pinned):
        store.erase(e)
        ++num_pruned

算法要点(官方解释):

  • 该算法本质上确保「store 中的第一个版本」总是被 pin。 毕竟,我们在重建 map 时需要一个起点,且我们不能简单地移除我们所拥有的最早的那个 map;否则我们将无法为第一个被裁剪的区间重建 map。
  • 一旦我们至少有一个 pinned map,算法的每一次迭代都可以简单地基于 manifest 的「最后一个被 pin 的 map」来进行(我们可以通过读取 manifest 的 pinned maps 列表的尾元素来获得它)。
  • 接下来我们需要确定待移除 map 的区间:从 last_pinned 到 new_pinned 的所有 map,而 new_pinned 又不过是 last_pinned 加上 mon_osdmap_full_prune_interval。我们知道这两个值之间的所有 map(即 last_pinned 与 new_pinned 之间)都可以被移除,因为 new_pinned 已经被 pin 了。
算法的停止条件

一旦两个初始前置条件之一不满足,算法就停止执行;或者,如果我们不满足另外两个「对算法正确性没有影响」的条件,也停止:

# 停止条件 理由
1 我们无法创建一个与 mon_osdmap_full_prune_interval 正确对齐、且低于 last_pruned 的新裁剪区间时就停止 并没有特别的技术原因要求这一点,除了:① 让我们保持区间具有预期的大小;② 防止小的、不规则的区间(它们最终必定会出现,例如裁剪在多次迭代中持续进行,每次只移除一两个或三个 map)
2 一旦我们知道我们已经裁剪了超过一定数量的 map 就停止。该值由 mon_osdmap_full_prune_txsize 定义,确保我们不会花无界的时间周期去裁剪 map 我们并不严格执行这个值(删除操作开销不大),但会尽力遵守它

为什么分批? 官方解释:我们本可以一次性完成移除,但我们不知道那要花多长时间。因此我们会执行多次迭代,每次最多移除 mon_osdmap_full_prune_txsize 个 osdmap。

裁剪后的磁盘形态
------------------------------------------
|1|10|20|30|..|49500|49501|..|49999|50000|
------------------------------------------
 ^ first                           last ^

5.4.5 osdmap_manifest_t 数据结构

由于我们不是一次性裁剪完所有版本,我们需要保存「我们裁剪到什么程度了」的状态。为此创建了一个数据结构 osdmap_manifest_t,它保存被 pin 的 map 集合:

struct osdmap_manifest_t:
    set<version_t> pinned;

关键推论:由于我们只在裁剪时 pin map,我们不需要保存关于「最后被裁剪的版本」的额外状态。 我们事实上知道:任意两个相邻 pinned map 之间的所有中间 map 都已被裁剪。

崩溃安全性

问题:如果 monitor 崩溃了,我们如何能确定已经裁剪了所有中间 map?

解决:我们总是在「删除 map 的同一个事务」中把 osdmap manifest 写入磁盘。 这样我们就有了保证:如果 monitor 崩溃,我们会读到 manifest 的最新版本 —— 要么包含新 pin 的 map(意味着我们也裁剪了其间的 map);要么我们会找到 manifest 的先前版本,它不包含我们在崩溃时正在 pin 的那些 map,因为「我们本会写入更新后的 osdmap manifest」的那个事务没有连同 map 移除一起被应用。

其他规则:

  • osdmap manifest 在每次我们裁剪时被写入 store,带有更新后的 pinned map 列表;
  • 它被写在「实际裁剪 map」的那个事务中,因此我们保证 manifest 总是最新的;
  • 作为该标准的一个推论:我们第一次写入 osdmap manifest 就是我们第一次裁剪的时候。如果一个 osdmap manifest 不存在,我们可以确定我们没有持有已裁剪的 map 区间。
  • 我们将依赖该 manifest 来确定我们是否有已裁剪的 map 区间。理论上这总是磁盘上的 osdmap manifest,但我们确保每次从 paxos 更新时都读取磁盘上的 osdmap manifest,这样我们总能确保拥有一个最新的内存中的 osdmap manifest。
  • 一旦我们完成裁剪 map,我们会把 manifest 保留在 store 中,以便我们轻松找到哪些 map 被 pin 了(而不是去检查 store 直到找到一个 map)。这还有个额外的好处:让我们能快速算出下一个需要裁剪的区间(即 last pinned 加上 prune interval)。
  • 但这并不意味着我们会永远保留 osdmap manifest:一旦 monitor trim 了 osdmap,且 store 中最早可用的 epoch 大于我们最后裁剪的那个 map,osdmap manifest 就不再需要了。

5.4.6 Map trimming(与裁剪的交互)

如果 monitor trim 了 map,我们就必须调整 osdmap manifest 以反映我们的裁剪状态,或者在该 manifest 不再有意义时把它整个移除。

场景 1:trim 越过了最后一个 pruned epoch

假设我们没有完成全部裁剪,磁盘形态是:

-------------------------------------------------------------
|1|10|20|30|..|490|500|501|502|..|49500|49501|..|49999|50000|
-------------------------------------------------------------
 ^ first            ^ pinned.last()                   last ^

pinned = {1, 10, 20, ..., 490, 500}

现在假设 monitor 要 trim 到 epoch 501。这意味着移除 epoch 501 之前的所有 map,并把 first_committed 指针更新为 501。

由于移除所有这些 map 会使我们现有的裁剪成果失效,我们可以认为裁剪已经完成,并丢弃我们的 osdmap manifest。 这样做也简化了「如果所有起始条件在我们从 store 刷新状态后仍被满足时,重新开始一次新的裁剪」。

结果:

---------------------------------------
|501|502|..|49500|49501|..|49999|50000|
---------------------------------------
 ^ first                        last ^
场景 2:trim 落在 pruned 区间内部

现在设想一个稍微更复杂的场景:monitor 要 trim 到 epoch 491,而 epoch 491 先前已从 store 中被裁剪掉了。

由于我们总是需要在 store 中拥有最老的已知 map,在 trim 之前我们必须检查「那个 map 是否处于 prune 区间内」(即,该 map epoch 是否属于 [ pinned.first()..pinned.last() ))。

情况 处理
它是一个 pinned map 除了从 manifest 的 pinned 列表中移除更低的 epoch 之外,我们没什么好担心的
被 trim 到的那个 map 不是 pinned map 我们需要重建该 map 并 pin 它,然后才移除早于该 map epoch 的 pinned map

结果:

-----------------------------------------------
|491|500|501|502|..|49500|49501|..|49999|50000|
-----------------------------------------------
 ^   ^- pinned.last()                   last ^
 `- first
场景 3:trim 到「最后一个被裁剪的 epoch」(边界情况)

考虑我们要 trim 到 epoch 499,而它正是最后那个被裁剪的 epoch。

  • 与上面的场景非常像,我们最终会把 osdmap epoch 499 写入 store;
  • 但我们应该如何处理 pinned map 与裁剪?

最简单的解决方案是:丢弃 osdmap manifest。 官方理由:

既然我们正在 trim 到最后一个被裁剪的 map,且我们正在重建这个 map,我们就可以保证所有大于 499 的 map 都是连续的(因为我们没有裁剪它们中的任何一个)。 本质上,在这种情况下丢弃 osdmap manifest,与「trim 越过最后一个被裁剪的 epoch」是完全相同的:如果满足了所需条件,我们稍后可以再裁剪。

5.4.7 配置项的健全性检查(CONFIGURATION OPTIONS SANITY CHECKS)

我们在裁剪之前执行额外检查,以确保所有相关配置选项都是合理的:

# 检查 理由
1 如果 mon_osdmap_full_prune_interval 为零,我们不裁剪 我们需要一个实际的、大于 1 的正数才能裁剪 map。如果 interval 是 1,我们实际上不会裁剪任何 map,因为 pinned map 之间的间隔本质上就是一个 epoch,这意味着 pinned map 之间有零个 map,因此永远不会有 map 被裁剪
2 如果 mon_osdmap_full_prune_min 为零,我们不裁剪 我们需要一个大于零的正值,以便知道「超过哪个阈值我们就应当裁剪」。我们不想去猜
3 如果 mon_osdmap_full_prune_interval 大于 mon_osdmap_full_prune_min,我们不裁剪 因为无法确定一个合适的裁剪区间
4 如果 mon_osdmap_full_prune_txsize 小于 mon_osdmap_full_prune_interval,我们不裁剪 我们需要一个 txsize,其值至少等于 interval,且(取决于后者的值)理想上更高

5.4.8 需求、条件与不变量(REQUIREMENTS, CONDITIONS & INVARIANTS)

REQUIREMENTS(需求):

  • quorum 中的所有 monitor 都需要支持裁剪。
  • 一旦裁剪被启用,不支持裁剪的 monitor 将不被允许进入 quorum,也不被允许同步。
  • 移除 osdmap manifest 会导致「裁剪特性」的 quorum 要求被禁用。 这意味着不支持裁剪的 monitor 将被允许同步并加入 quorum,前提是它们支持所需的任何其他特性。

CONDITIONS & INVARIANTS:

场景 条件 / 不变量
从未发生过裁剪,或我们已 trim 越过其先前的区间 invariant: first_committed > 1
condition: pinned.empty() AND !store.exists(manifest)
至少发生过一次裁剪 invariant: first_committed > 0
invariant: !pinned.empty()
invariant: pinned.first() == first_committed
invariant: pinned.last() < last_committed

precond: pinned.last() < prune_to AND pinned.last() + prune_interval < prune_to

postcond: pinned.size() > old_pinned.size() AND
(for each v in [pinned.first()..pinned.last()]:
if pinned.count(v) > 0: store.exists_full(v)
else: !store.exists_full(v))
裁剪已完成 invariant: first_committed > 0
invariant: !pinned.empty()
invariant: pinned.first() == first_committed
invariant: pinned.last() < last_committed

condition: pinned.last() == prune_to OR pinned.last() + prune_interval < prune_to
裁剪区间可以被 trim precond: OSDMonitor::get_trim_to() > 0

condition: !pinned.empty()

invariant: pinned.first() == first_committed
invariant: pinned.last() < last_committed
invariant: pinned.first() <= OSDMonitor::get_trim_to()
invariant: pinned.last() >= OSDMonitor::get_trim_to()
Trim 已裁剪的区间 invariant: !pinned.empty()
invariant: pinned.first() == first_committed
invariant: pinned.last() < last_committed
invariant: pinned.first() <= OSDMonitor::get_trim_to()
invariant: pinned.last() >= OSDMonitor::get_trim_to()

postcond: pinned.empty() OR
(pinned.first() == OSDMonitor::get_trim_to() AND
pinned.last() > pinned.first() AND
(for each v in [0..pinned.first()]: !store.exists(v) AND !store.exists_full(v)) AND
(for each m in [pinned.first()..pinned.last()]:
if pinned.count(m) > 0: store.exists_full(m)
else: !store.exists_full(m) AND store.exists(m)))

postcond: !pinned.empty() OR
(!store.exists(manifest) AND
(for each v in [pinned.first()..pinned.last()]:
!store.exists(v) AND !store.exists_full(v)))

引自:FULL OSDMAP VERSION PRUNING


六、数据路径与特性

本章覆盖 5 个页面:file-striping.rst、erasure-coded-pool.rst、deduplication.rst、delayed-delete.rst、crush-msr.rst。它们分别回答「文件怎么切成对象」「纠删码池怎么用」「重删怎么做」「删除为什么是延迟的」「CRUSH 怎么支持多步重试」五个问题。

6.1 file-striping.rst:文件条带化布局

官方该页共 161 行,主题单一——只讲 CephFS 客户端的文件数据如何映射到 RADOS 对象,不涉及 RBD、不涉及 OSD 侧实现。

6.1.1 设计动机

CephFS 客户端的一个「文件」在 RADOS 里并不是一个对象。文件数据会被**条带化(striping)**分布到若干底层对象上,这样做有两个目的:

  1. 突破单对象的吞吐与并行度上限(一个文件可以并行写多个对象、落在多个 OSD)。
  2. 让超大文件不至于产生超大对象(对象大小有上限 fl_object_size)。

官方把这种分布定义为改良版 RAID 0:数据在一组对象上按条带轮转,写满每个对象的固定大小后,换下一组对象继续。

6.1.2 关键术语(官方文档原文定义)

术语 官方定义 说明
file 从 Ceph 客户端视角命名的连续数据集合(即 Linux 上使用 Ceph 存储的一个文件) 文件数据被切分为固定大小的 stripe unit,存进 Ceph object
stripe unit 用于 RAID 0 分布的数据块大小(字节)。同一文件所有 stripe unit 大小相等 最后一个 stripe unit 通常是不完整的——它包含文件末尾数据,以及到 stripe unit 边界为止的未使用「空间」
stripe count 构成一个 RAID 0「stripe」的连续 stripe unit 数量 也就是一个 stripe 横跨多少个对象
stripe 一段连续的文件数据,以固定大小 stripe unit 为单位,RAID 0 条带化分布在 stripe count 个对象上 —
object Ceph 存储维护的数据集合 用来存放 Ceph 客户端文件的一部分
object set 一组对象,合起来表示文件的一个连续片段 每个 object set 里的对象数量 = stripe count

6.1.3 核心数据结构:ceph_file_layout

由三个字段定义整个映射关系(磁盘上的实际格式是 __le32):

u32 fl_stripe_unit;   // 条带单元大小(字节)
u32 fl_stripe_count;  // 每个 stripe 的 stripe unit 数(= 每个 object set 的对象数)
u32 fl_object_size;   // 承载文件数据的对象的最大大小(字节)

字段约束:

  • fl_object_size 是 fl_stripe_unit 的整数倍。
  • 文件数据先切成 stripe unit,连续的 stripe unit 依次存进 object set 中的对象。
  • 存文件数据的对象不会超过 fl_object_size,所以写满若干个完整 stripe 后,会启用新的 object set。

6.1.4 布局算法(按官方叙述整理)

💡 补充:官方该页没有给出伪代码或公式,只给了文字描述和一个具体数值例子。下面第 1~5 步是按官方文字与例子反推出的读法,供理解用,官方未以公式形式给出。

  1. 计算 stripe size = fl_stripe_unit × fl_stripe_count(一个 stripe 覆盖的字节数)。
  2. 计算每个对象可容纳的 stripe unit 数:fl_object_size / fl_stripe_unit。
  3. 计算一个 object set 的容量:fl_object_size × fl_stripe_count(也等于 step2 的结果 × stripe size)。
  4. 对给定文件偏移,依次分解为:第几个 object set → 该 set 内第几个 stripe → 该 stripe 内第几个 stripe unit → unit 内字节偏移。
  5. 落在最后一个 stripe unit 的数据可能不足一个 unit,剩余部分是空洞(不占存储)。

默认策略:官方明确指出「by default, Ceph uses a simple striping strategy in which object_size equals stripe_unit and stripe_count is 1」——即每个对象只放一个 stripe unit,等价于不条带化(一个大文件顺序落在 N 个对象上,每个对象 4MB)。

6.1.5 官方给出的完整数值例子

输入:

file size = 1 trillion = 1000000000000 bytes

fl_stripe_unit    = 64KB  = 65536       bytes
fl_stripe_count   = 5   (stripe units per stripe)
fl_object_size    = 64GB  = 68719476736 bytes

推导:

file stripe size       = 64KB * 5 = 320KB = 327680        bytes
each object holds      = 64GB / 64KB = 1048576            stripe units
file object set size   = 64GB * 5 = 320GB = 343597383680  bytes
                       (also 1048576 stripe units * 327680 bytes per stripe unit)

把 1 万亿字节逐级分解:

- 1 trillion bytes / 320GB per object set = 2 complete object sets
    (with 312805232640 bytes remaining)
- 312805232640 bytes / 320KB per stripe = 954605 complete stripes
    (with 266240 bytes remaining)
- 266240 bytes / 64KB per stripe unit = 4 complete stripe units
    (with 4096 bytes remaining)
- and the final incomplete stripe unit holds those 4096 bytes.

即:2 个完整 object set(= 10 个对象)→ 954605 个完整 stripe → 4 个完整 stripe unit → 最后 1 个只装了 4096 字节的不完整 stripe unit。

6.1.6 官方 ASCII 结构图(摘录)

官方原文用 ASCII 画出三个 object set(object 04 / object 59 / object 10~14),每个对象纵向堆叠 1048576 个 stripe unit。下面摘录最后一个 object set 的尾部,用来说明「不完整 stripe unit」:

     _________   _________   _________   _________   _________
    /object 10\ /object 11\ /object 12\ /object 13\ /object 14\
    +=========+ +=========+ +=========+ +=========+ +=========+
    |  stripe | |  stripe | |  stripe | |  stripe | |  stripe | stripe
  o |   unit  | |   unit  | |   unit  | |   unit  | |   unit  | 2097152
  b | 10485760| | 10485761| | 10485762| | 10485763| | 10485764|
  j |---------| |---------| |---------| |---------| |---------|
  e |  stripe | |  stripe | |  stripe | |  stripe | |  stripe | stripe
  c |   unit  | |   unit  | |   unit  | |   unit  | |   unit  | 2097153
  t | 10485765| | 10485766| | 10485767| | 10485768| | 10485769|
    |---------| |---------| |---------| |---------| |---------|
  s |     .   | |     .   | |     .   | |     .   | |     .   |
  e       .           .           .           .           .
  t |     .   | |     .   | |     .   | |     .   | |     .   |
    |---------| |---------| |---------| |---------| |---------|
  2 |  stripe | |  stripe | |  stripe | |  stripe | |  stripe | stripe
    |   unit  | |   unit  | |   unit  | |   unit  | |   unit  | 3051756
    | 15258780| | 15258781| | 15258782| | 15258783| | 15258784|
    |---------| |---------| |---------| |---------| |---------|
    |  stripe | |  stripe | |  stripe | |  stripe | | (partial| (partial
    |   unit  | |   unit  | |   unit  | |   unit  | |  stripe | stripe
    | 15258785| | 15258786| | 15258787| | 15258788| |  unit)  | 3051757)
    \=========/ \=========/ \=========/ \=========/ \=========/

图中 object 10~14 构成 object set 2;最后一个 stripe(编号 3051757)只有前 4 个对象有完整 stripe unit,object 14 上是一个 (partial stripe unit)。

6.1.7 注意事项

  • 该页只描述 CephFS 客户端文件的存储方式,官方原文第一句即限定:「how files from Ceph file system clients are stored across objects stored in RADOS」。RBD 的条带化是另一套(在 librbd 层),本页未涉及。
  • 条带参数(stripe unit / count / object size)是 per-file 的,写在文件的 layout 里,不是全局配置。
  • 修改已有文件的 layout 不会重排已有数据(文档未明确,本页未讨论 layout 变更语义)。

引自:File striping


6.2 erasure-coded-pool.rst:纠删码池

官方该页共 135 行,且定位是「使用说明」而非「实现说明」——它讲 EC 池的用途、场景和 ceph osd erasure-code-profile / ceph osd pool create 的用法,几乎不讲内部实现(没有编解码流程、没有 ErasureCodeInterface、没有 ECBackend)。想要实现细节需看 osd_internals/ 下的页面(不在本册范围)。

6.2.1 设计动机(官方 Purpose 段)

官方用两句话概括 EC 池的取舍:

维度 与副本池对比
存储空间 EC 池需要更少的存储空间
计算开销 EC 的计算要求更高(编解码要 CPU)
支持的操作 只支持对象操作的一个子集——官方举例:不支持部分写(partial write)

这三点决定了 EC 池的适用面:省空间,但别用在需要随机小写、需要 omap、需要 RBD 的热数据上。

6.2.2 两个官方用例

用例一:冷存储(Cold storage)

官方描述的一套完整架构:

  1. 建一个 EC 池存放大量 1GB 对象(影像、基因组等),每月约 10% 被读取;每天新增对象,写入后不再修改;平均 1 次写对 10000 次读。
  2. 建一个副本池作为该 EC 池的 cache tier。
  3. 一个 agent 负责 demote(降级/下沉):把一周未被访问的对象从副本池移到 EC 池。
  4. EC 池的 CRUSH rule 指向为冷存储设计的硬件——高延迟、慢访问;副本池的 CRUSH rule 指向更快的硬件以提供更好的响应时间。

用例二:廉价多数据中心存储(Cheap multidatacenter storage)

  • 10 个数据中心通过专线互联,每个数据中心存储容量相同,无 UPS、无空调。
  • 建一个 EC 池,CRUSH rule 保证最多 3 个数据中心同时故障时数据不丢。
  • 用 k=6, m=3(数据分 6 份 + 3 个校验块),开销 50%。
  • 对比:用副本达到同样容错需要 4 副本,开销 400%。

💡 补充:4 副本对应「容忍 3 个 DC 同时故障」的开销是 (4-1)/1 = 300% 额外,官方写 400% 应是按总量/原始量的口径(4 倍)表述,原文为 "With replication the overhead would be 400% (four replicas)"。此处按原文直译,未做换算。

6.2.3 关键操作与命令(官方 Interface 段全量)

1. 创建默认 EC 池:

$ ceph osd pool create ecpool erasure

2. 创建 EC 池并指定 CRUSH rule ecrule:

$ ceph osd crush rule create-erasure ecrule
$ ceph osd pool create ecpool erasure default ecrule

3. 把 CRUSH failure domain 改成 osd(默认是 host):

$ ceph osd erasure-code-profile set myprofile \
    crush-failure-domain=osd
$ ceph osd erasure-code-profile get myprofile
k=2
m=2
plugin=isa
technique=reed_sol_van
crush-failure-domain=osd
$ ceph osd pool create ecpool erasure myprofile

4. 控制 EC 插件参数(k / m):

$ ceph osd erasure-code-profile set myprofile \
    k=3 m=2
$ ceph osd erasure-code-profile get myprofile
k=3
m=2
plugin=isa
technique=reed_sol_van
$ ceph osd pool create ecpool erasure myprofile

5. 选择其他 EC 插件:

$ ceph osd erasure-code-profile set myprofile \
    plugin=example technique=xor
$ ceph osd erasure-code-profile get myprofile
k=2
m=2
plugin=example
technique=xor
$ ceph osd pool create ecpool 12 12 erasure \
    myprofile

6. 查看默认 profile:

  $ ceph osd erasure-code-profile ls
  default
  $ ceph osd erasure-code-profile get default
  k=2
  m=2
  plugin=isa
  technique=reed_sol_van

即 Tentacle 默认 EC profile 为 k=2, m=2, plugin=isa, technique=reed_sol_van。

7. 自定义 k/m(k+m=6,容忍 3 个 OSD 失效):

  $ ceph osd erasure-code-profile set myprofile k=3 m=3
  $ ceph osd erasure-code-profile get myprofile
  k=3
  m=3
  plugin=isa
  technique=reed_sol_van
  $ ceph osd erasure-code-profile ls
  default
  myprofile

8. 删除不再使用的 profile(若仍被池引用会失败,报 EBUSY):

  $ ceph osd erasure-code-profile ls
  default
  myprofile
  $ ceph osd erasure-code-profile rm myprofile
  $ ceph osd erasure-code-profile ls
  default

9. 把 rule 指向 ssd(默认是 default root):

 $ ceph osd erasure-code-profile set myprofile \
     crush-root=ssd
 $ ceph osd erasure-code-profile get myprofile
 k=2
 m=2
 plugin=isa
 technique=reed_sol_van
 crush-root=ssd

6.2.4 profile 参数一览(从官方示例归纳)

参数 官方示例中出现的值 含义(官方说明)
k 2(默认)/ 3 / 6 数据块数量
m 2(默认)/ 3 编码(校验)块数量
plugin isa(默认)/ example 纠删码插件
technique reed_sol_van(默认)/ xor 插件内的具体算法
crush-failure-domain osd(默认 host) CRUSH 故障域
crush-root ssd(默认 default) CRUSH root

6.2.5 注意事项

  • 不支持部分写(partial write):官方在 Purpose 段明确点出。因此 EC 池不能用于 RBD(RBD 依赖部分写),也不能用于 CephFS 的 data pool(除非用 –allow-ec-overwrites,该页未提及)。
  • profile 的 rm 受引用保护,正在被池使用的 profile 删除会 EBUSY。
  • 该页没有解释 plugin=isa / technique=reed_sol_van 的算法细节,也没有讲 jerasure、clay、shec 等插件——文档未明确本册范围内的插件全列表。

引自:Erasure Coded pool


6.3 deduplication.rst:重复数据删除

官方该页共 426 行,是本章信息量最大的一页。它同时包含设计文档与操作手册两部分。官方在「How to use deduplication」一节开头就用斜体强调:「This feature is highly experimental and is subject to change or removal.」(高度实验性,可能变更或移除。)

6.3.1 设计动机与两个核心挑战(Introduction 段)

在既有软件栈上做重删并不容易,原因是额外的元数据管理和原始数据处理流程。

典型的重删系统工作方式:

  1. 输入源(一个数据对象)被**分块算法(chunking algorithm)**切成多个 chunk。
  2. 重删系统把每个 chunk 与已存储的 chunk 比较。
  3. 为了高效比较,系统维护一个指纹索引(fingerprint index),存每个 chunk 的 hash 值——通过比较 hash 而不是搜索全部内容来定位已有 chunk。

在 Ceph 之上实现重删,官方点出两个本质难题:

挑战 官方表述
指纹索引的可扩展性 managing scalability of fingerprint index
与既有元数据的兼容性 complex to ensure compatibility between newly applied deduplication metadata and existing metadata

6.3.2 Key Idea:两个关键设计

关键思想一:内容哈希(Double hashing,双重哈希)

官方给出的链条:

Client 1 -> OID=1 -> HASH(1's content)=K -> OID=K ->
CRUSH(K) -> chunk's location

解读:

  1. 每个客户端可以用 CRUSH 根据 object ID 找到对象数据在 Base tier 的位置。
  2. 在 Base tier 上对对象内容做哈希,得到一个新的 OID(chunk ID)。
  3. Chunk tier 就以这个新 OID 存储原始对象的一部分内容。

也就是说:第一次哈希(CRUSH)定位原对象,第二次哈希(内容哈希)定位 chunk——这就是「double hashing」的含义。

关键思想二:自包含对象(Self-contained object)

官方论证:外部元数据设计难以与既有存储特性集成,因为现有存储特性无法识别这些额外的外部数据结构。如果我们能设计出一个不带任何外部组件的重删系统,原有存储特性就可以被复用。

💡 补充:官方在此引用了一篇论文 https://ieeexplore.ieee.org/document/8416369 作为更多细节来源(本册不联网展开)。

6.3.3 架构设计(Design 段)

官方用 ditaa 图描述,下面按原图结构重绘为文本:

           +-------------+
           | Ceph Client |
           +------+------+
                  ^
     Tiering is   |
    Transparent   |               Metadata
        to Ceph   |           +---------------+
     Client Ops   |           |               |
                  |    +----->+   Base Pool   |
                  |    |      |               |
                  |    |      +-----+---+-----+
                  |    |            |   ^
                  v    v            |   |   Dedup metadata in Base Pool
           +------+----+--+         |   |   (Dedup metadata contains chunk offsets
           |   Objecter   |         |   |    and fingerprints)
           +-----------+--+         |   |
                       ^            |   |   Data in Chunk Pool
                       |            v   |
                       |      +-----+---+-----+
                       |      |               |
                       +----->|  Chunk Pool   |
                              |               |
                              +---------------+
                                    Data

关键点:

  • Tiering 对 Ceph Client 的操作是透明的(图左侧文字:Tiering is Transparent to Ceph Client Ops)。
  • 客户端 → Objecter → 分别访问 Base Pool 与 Chunk Pool。
  • Dedup metadata 放在 Base Pool,其中包含 chunk offsets(块偏移) 和 fingerprints(指纹)。
  • 实际数据(chunk 对象)放在 Chunk Pool。

Pool-based object management(基于池的对象管理)

定义两个池:

池 存储内容 官方说明
metadata pool(即 Base Pool) 元数据对象 —
chunk pool chunk 对象 —

因为两个池按用途和使用方式划分,所以每个池可以根据自身特性更高效地被管理:

  • Base pool 和 chunk pool 可以各自独立选择冗余方案(replication 或 erasure coding),依用途而定。
  • 每个池可以放在不同的存储位置,依所需性能而定。

关于如何使用,官方指向 osd_internals/manifest.rst(本册范围外)。

6.3.4 各接口层的机会与代价(Usage Patterns 段)

接口层 官方判断 理由
RadosGW S3 大数据负载是重删的好机会 这类对象倾向于一次写入、主要读取,不会部分覆盖;因此可以**提前(up front)**做指纹计算与重删
RadosGW(额外优势) 可直接使用 refcounting 机制,不需要 RADOS 直接支持 manifest 与 cephfs / rbd 不同,radosgw 有一套把显式元数据存在 head object 里、用于定位剩余分片的机制(见 osd_internals/refcount.rst)
RBD / CephFS 重删元数据需要作为 RADOS 的一部分( presumably transparently,透明地) 两者都用确定性命名方案把块设备/文件数据划分到 rados 对象上
RBD / CephFS(写入特性) 不希望对这些对象做在线重删,也不想在热路径上付出写延迟代价 与 radosgw 不同,rbd/cephfs 的 rados 对象会被覆盖写;因此后台对冷对象做 tiering + dedup 更合适
RBD / CephFS(快照) manifest 支持需要健壮的快照支持 两者负载中快照使用很常见

6.3.5 RADOS 机制(RADOS Machinery 段)

官方该段只有两句指引,全部指向 osd_internals/ 下不在本册的页面:

  • RADOS 的 redirect / chunk / dedup 支持 → 见 osd_internals/manifest.rst
  • RADOS 的 refcount 支持 → 见 osd_internals/refcount.rst

6.3.6 状态与后续工作(Status and Future Work 段)

官方自述的当前状态:

  • OSD 内部已有一些初步的 manifest 对象支持,以及一个 dedup tool。
  • RadosGW 数据仓库类负载可能代表该特性最大的机会,因此第一优先是给 radosgw 增加直接的指纹计算与 redirect 支持(接入 refcount pool)。
  • 除 radosgw 之外,下一步是完成 OSD 中 manifest 对象支持,特别是与快照相关的部分,以服务 rbd 与 cephfs 负载。

6.3.7 实操:如何使用重删(How to use deduplication)

前置条件(Prerequisite)

若集群是从 Ceph mainline 启动的,用户需要确认安装了包含 ceph-dedup-tool 的 ceph-test 包。

可用操作总览

官方说明用户可以在 ceph-dedup-tool 上使用 estimate、sample-dedup、chunk-scrub、chunk-repair 操作,并鼓励用脚本自由组合这些操作。


步骤 1:用 estimate 估算目标池的空间节省率

ceph-dedup-tool --op estimate
  --pool [BASE_POOL]
  --chunk-size [CHUNK_SIZE]
  --chunk-algorithm [fixed|fastcdc]
  --fingerprint-algorithm [sha1|sha256|sha512]
  --max-thread [THREAD_COUNT]

参数语义(官方说明):

参数 说明
--pool / BASE_POOL 被重删的目标对象所在的池
--chunk-size 官方提醒:需要用不同的 chunk_size 多次运行 ceph-dedup-tool 来寻找最优块大小;在 fastcdc(非 fixed)算法下,最优值可能因每个对象的内容而异
--chunk-algorithm fixed 或 fastcdc
--fingerprint-algorithm sha1 / sha256 / sha512
--max-thread 线程数

输出示例:

{
  "chunk_algo": "fastcdc",
  "chunk_sizes": [
    {
      "target_chunk_size": 8192,
      "dedup_bytes_ratio": 0.4897049
      "dedup_object_ratio": 34.567315
      "chunk_size_average": 64439,
      "chunk_size_stddev": 33620
    }
  ],
  "summary": {
    "examined_objects": 95,
    "examined_bytes": 214968649
  }
}

输出字段释义(官方原文解释):

字段 含义
target_chunk_size 与用户传入的 chunk_size 相同
dedup_bytes_ratio 被检视字节中有多少是冗余的。1 - dedup_bytes_ratio 即为可节省的存储百分比
dedup_object_ratio 生成的 chunk 对象数 / examined_objects
chunk_size_average 执行 CDC 时平均切出的块大小。可能不同于 target_chunk_size,因为 CDC 会依内容生成不同的块边界
chunk_size_stddev 块大小的标准差
examined_objects / examined_bytes 被检查的对象数 / 字节数

官方决策建议:如果节省空间高于用户预期,这个池大概就值得做重删。


步骤 2:创建 chunk pool

ceph osd pool create [CHUNK_POOL]

步骤 3:执行重删(两种方式)

方式 A:sample-dedup

ceph-dedup-tool --op sample-dedup
  --pool [BASE_POOL]
  --chunk-pool [CHUNK_POOL]
  --chunk-size [CHUNK_SIZE]
  --chunk-algorithm [fastcdc]
  --fingerprint-algorithm [sha1|sha256|sha512]
  --chunk-dedup-threshold [THRESHOLD]
  --max-thread [THREAD_COUNT]
  --sampling-ratio [SAMPLE_RATIO]
  --wakeup-period [WAKEUP_PERIOD]
  --loop
  --snap

官方行为说明:

  • sample-dedup 按 THREAD_COUNT 派生线程,对 BASE_POOL 上的对象做重删。
  • 依 sampling-ratio 决定行为:SAMPLE_RATIO 为 100 时做全量搜索;否则在迭代中选择性地对「冗余次数超过 THRESHOLD」的块执行重删。
  • 若设置 --loop,线程会在 WAKEUP_PERIOD 后唤醒;否则一轮迭代后线程退出。

官方给出的完整效果对照:

$ bin/ceph df
--- RAW STORAGE ---
CLASS     SIZE    AVAIL     USED  RAW USED  %RAW USED
ssd    303 GiB  294 GiB  9.0 GiB   9.0 GiB       2.99
TOTAL  303 GiB  294 GiB  9.0 GiB   9.0 GiB       2.99

--- POOLS ---
POOL   ID  PGS   STORED  OBJECTS     USED  %USED  MAX AVAIL
.mgr    1    1  577 KiB        2  1.7 MiB      0     97 GiB
base    2   32  2.0 GiB      517  6.0 GiB   2.02     97 GiB
chunk   3   32   0  B          0    0   B      0     97 GiB

$ bin/ceph-dedup-tool --op sample-dedup --pool base --chunk-pool chunk
  --fingerprint-algorithm sha1 --chunk-algorithm fastcdc --loop --sampling-ratio 100
  --chunk-dedup-threshold 2 --chunk-size 8192 --max-thread 4 --wakeup-period 60

$ bin/ceph df
--- RAW STORAGE ---
CLASS     SIZE    AVAIL     USED  RAW USED  %RAW USED
ssd    303 GiB  298 GiB  5.4 GiB   5.4 GiB       1.80
TOTAL  303 GiB  298 GiB  5.4 GiB   5.4 GiB       1.80

--- POOLS ---
POOL   ID  PGS   STORED  OBJECTS     USED  %USED  MAX AVAIL
.mgr    1    1  577 KiB        2  1.7 MiB      0     98 GiB
base    2   32  452 MiB      262  1.3 GiB   0.50     98 GiB
chunk   3   32  258 MiB   25.91k  938 MiB   0.31     98 GiB

观察:base 池 STORED 从 2.0 GiB 降到 452 MiB,chunk 池新增 258 MiB / 25.91k 个对象;整体 RAW USED 从 9.0 GiB 降到 5.4 GiB。

方式 B:object dedup

ceph-dedup-tool --op object-dedup
  --pool [BASE_POOL]
  --object [OID]
  --chunk-pool [CHUNK_POOL]
  --fingerprint-algorithm [sha1|sha256|sha512]
  --dedup-cdc-chunk-size [CHUNK_SIZE]

官方说明:

  • object-dedup 针对 --object [OID] 指定的那一个 RADOS 对象触发重删。
  • 上述所有参数都必须指定。
  • CHUNK_SIZE 应当取自步骤 1 的结果。
  • 执行该命令时,fastcdc 会被设为默认,其它诸如 fingerprint-algorithm 和 CHUNK_SIZE 的参数会被设为该池的默认值。
  • 重删后的对象出现在 chunk pool。
  • 若对象内容随时间被修改,用户需要重新运行 object-dedup,因为块边界需要基于更新后的内容重新计算。
  • 若目标对象有快照,用户需要指定 snap。
  • 重删完成后,目标对象在 BASE_POOL 中的大小变为 0(被 evicted),同时生成 chunk 对象——它们出现在 CHUNK_POOL。

步骤 4:读写 I/O

官方明确:步骤 3 之后,用户不需要为 I/O 操心任何事——重删后的对象与既有 RADOS 操作完全兼容。


步骤 5:运行 scrub 修复引用计数

官方说明:在为重删后的 RADOS 对象处理引用计数时,极少数情况下会因误报(false positives)产生引用不匹配。这些不匹配由周期性 scrub 修复:

ceph-dedup-tool --op chunk-scrub
  --chunk-pool [CHUNK_POOL]
  --pool [POOL]
  --max-thread [THREAD_COUNT]

chunk-scrub 命令识别元数据对象与 chunk 对象之间的引用不匹配;chunk-pool 参数告诉 ceph-dedup-tool 目标 chunk 对象在哪里。

官方示例(先用 chunk-get-ref 往 chunk 对象 2ac67f70... 里插了一个假引用 dummy-obj):

$ bin/ceph-dedup-tool --op dump-chunk-refs --chunk-pool chunk --object 2ac67f70d3dd187f8f332bb1391f61d4e5c9baae
{
  "type": "by_object",
  "count": 2,
	"refs": [
    {
      "oid": "testfile2",
    	"key": "",
    	"snapid": -2,
    	"hash": 2905889452,
    	"max": 0,
    	"pool": 2,
    	"namespace": ""
  	},
    {
      "oid": "dummy-obj",
    	"key": "",
    	"snapid": -2,
    	"hash": 1203585162,
    	"max": 0,
    	"pool": 2,
    	"namespace": ""
    }
  ]
}

$ bin/ceph-dedup-tool --op chunk-scrub --chunk-pool chunk --max-thread 10
10 seconds is set as report period by default
join
join
2ac67f70d3dd187f8f332bb1391f61d4e5c9baae
--done--
2ac67f70d3dd187f8f332bb1391f61d4e5c9baae ref 10:5102bde2:::dummy-obj:head: referencing pool does not exist
--done--
 Total object : 1
 Examined object : 1
 Damaged object : 1

引用条目(ref)的字段结构(从示例归纳):

字段 示例值 说明
oid testfile2 / dummy-obj 引用方对象名
key "" 键(示例中为空)
snapid -2 快照 id(-2 表示 head)
hash 2905889452 对象名哈希
max 0 —(文档未明确其语义)
pool 2 引用方所在池 id
namespace "" 命名空间

步骤 6:修复不匹配的 chunk 引用

若 chunk-scrub 之后出现引用不匹配,官方建议执行 chunk-repair 修复:

ceph-dedup-tool --op chunk-repair
  --chunk-pool [CHUNK_POOL_NAME]
  --object [CHUNK_OID]
  --target-ref [TARGET_OID]
  --target-ref-pool-id [TARGET_POOL_ID]

chunk-repair 修复的是 --target-ref(即某个 object 的错误引用)。为正确修复,用户必须输入正确的 TARGET_OID 和 TARGET_POOL_ID。

示例:

$ bin/ceph-dedup-tool --op chunk-repair --chunk-pool chunk --object 2ac67f70d3dd187f8f332bb1391f61d4e5c9baae --target-ref dummy-obj --target-ref-pool-id 10
2ac67f70d3dd187f8f332bb1391f61d4e5c9baae has 1 references for dummy-obj
dummy-obj has 0 references for 2ac67f70d3dd187f8f332bb1391f61d4e5c9baae
 fix dangling reference from 1 to 0

$ bin/ceph-dedup-tool --op dump-chunk-refs --chunk-pool chunk --object 2ac67f70d3dd187f8f332bb1391f61d4e5c9baae
{
  "type": "by_object",
  "count": 1,
  "refs": [
    {
      "oid": "testfile2",
    	"key": "",
    	"snapid": -2,
    	"hash": 2905889452,
    	"max": 0,
    	"pool": 2,
    	"namespace": ""
    }
  ]
}

6.3.8 注意事项汇总

  • 高度实验性:官方原文 "This feature is highly experimental and is subject to change or removal."
  • 需要 ceph-test 包提供 ceph-dedup-tool。
  • fastcdc 是变长分块,最优 chunk size 需实测;fixed 是定长分块。
  • 重删后目标对象不再持有数据(size = 0),数据全在 chunk pool;一旦 chunk pool 出问题,base pool 的对象无法恢复(文档未明确此风险的具体处理,但架构上显然如此)。
  • 引用计数可能误报,必须周期性 chunk-scrub + chunk-repair。
  • 对象被修改后必须重跑 object-dedup。
  • 本页未说明 sample-dedup 的 --snap 参数细节,也未说明 estimate 里 max 字段含义——文档未明确。

引自:Deduplication


6.4 delayed-delete.rst:CephFS 延迟删除

官方该页只有 13 行(含标题与空行),是全套 dev/ 文档中最短的一页之一。内容极简,仅解释动机,不涉及任何数据结构或代码路径。如实说明如下。

6.4.1 设计动机

删除一个文件并不会立即移除它的数据——该文件的每个底层对象都必须被独立地移除。

官方给出「立即删」的代价:

问题 官方说明
网络/消息开销 客户端将不得不发送 size_of_file / stripe_size * replication_count 条消息
带宽与延迟 这会消耗可观的带宽(significant bandwith),并把客户端拖慢到不可接受(slow the client unacceptably)
快照阻塞 若存在快照,快照的存在会阻止与之关联的对象的删除

💡 补充:原文写作 significant bandwith(原文即如此拼写,疑为 bandwidth 笔误)。

6.4.2 执行流程(官方两句)

在这类情况下,这样的文件会:

  1. 在 MDS 上被标记为已删除(marked as deleted on the MDS);
  2. 被懒惰地/延迟地删除(deleted lazily)。

6.4.3 注意事项

  • 该页未说明延迟删除的具体实现:没有 PurgeQueue、没有 purge_queue 配置项、没有 MDS 侧的异步删除线程、也没有 stray 目录机制 —— 这些均由文档未明确。
  • 该页未说明「lazy delete」的触发时机与吞吐控制。
  • 快照阻止删除的具体判定逻辑,本页只说「their existence can prevent the deletion」,未展开。

引自:CephFS delayed deletion


6.5 crush-msr.rst:CRUSH 多步重试(Multi-step Retry)

官方该页共 140 行,是一篇设计与实现说明,核心在 src/crush/mapper.c / mapper.h。理解它需要先把「传统 CRUSH 的局限」讲清楚,官方正是按这个顺序组织的。

6.5.1 设计动机:传统 CRUSH 无法回退重试(Motivation 段)

官方开宗明义:传统 CRUSH 有一个重要限制——带多个 choose 步骤的规则,在遇到 out OSD 时无法重试前面的步骤。

对比场景一:chooseleaf(行为良好)

rule replicated_rule_1 {
    ...
    step take default class hdd
    step chooseleaf firstn 3 type host
    step emit
}

官方描述其表现:

  • 若某台 host 上的所有 OSD 都被标记 out,人们会期望包含这些 OSD 的映射最终落到另一台 host 上(前提是 host 数量足够)。事实上正是如此。
  • 若某 host 上 1/8 的 OSD 被标记 out,大致 1/8 映射到该 host 的 PG 会被重映射到其它 host,从而保持每个 OSD 的总体利用率均衡。

对比场景二:choose 两次(行为糟糕)

rule replicated_rule_1 {
    ...
    step take default class hdd
    step choose firstn 3 type host
    step choose firstn 1 type osd
    step emit
}

官方描述:

  • 只要没有 OSD 被标记 out,行为与场景一非常相似。
  • 但一旦某个 OSD 被标记 out,映射到该 OSD 的 PG 会被重映射到同一台 host 上的其它 OSD,导致这些 OSD 相对其它 host 上的 OSD 过度使用(over-utilized)。
  • 更糟:若一台 host 上的所有 OSD 都被标记 out,碰巧命中该 host 的映射会失败,导致 undersized PG。

结论:只要目标是「把 N 个 OSD 分到 N 个故障域」,用 chooseleaf 就够了。

但 chooseleaf 解决不了的场景(官方用例)

想把 8+6 EC 编码分布到 4 台 host 上,以容忍「1 台 host 失效 + 另一台 host 上 1 个 OSD 失效」,存储开销 1.75x。规则必须写成:

rule ecpool-86 {
    ...
    step take default class hdd
    step choose indep 4 type host
    step choose indep 4 type osd
    step emit
}
  • 这条规则确实能把最多 16 个 OSD 分到 4 台 host(8+6 编码下:前 3 台各放 4 个,最后一台放 2 个),并满足故障要求。
  • 但基于前述原因,当 OSD 被标记 out 而又有其它 host 可以重平衡过去时,它表现很差。
  • chooseleaf 在这里不是解法,因为它不支持在指定 type 之下映射多于一个 leaf。

6.5.2 MSR 的解法(MSR 段)

CRUSH MSR(Multi-step Retry)规则通过一种不同的下降算法解决上述问题:在遇到 out OSD 时重试所有步骤。

对比项 传统 CRUSH MSR 规则
下降顺序 广度优先(breadth first):对每一步,先把向量填满,再进入下一步 深度优先(depth first):对每一个 choice,递归地下降穿过所有步骤,再继续下一个 choice

上述用例可写成:

rule ecpool-86 {
    type msr_indep
    ...
    step take default class hdd
    step choosemsr 4 type host
    step choosemsr 4 type osd
    step emit
}

官方说明其效果:与顶部 chooseleaf 的例子一样,当 OSD 被标记 out 时,这些 OSD 会被按比例地重映射到其它 host(只要有富余的 host 可用)。

关于「如何在仍然保持故障域隔离的前提下做到这一点」,官方指向源码注释:mapper.c:crush_msr_choose。

6.5.3 规则结构(Rule Structure 段)

规则类型:CRUSH MSR 规则是 type 为以下之一的 crush rule:

CRUSH_RULE_TYPE_MSR_FIRSTN
CRUSH_RULE_TYPE_MSR_INDEP

(见 mapper.c: rule_type_is_msr)

与传统规则的结构差异(官方列出的三条):

  1. 规则类型决定映射是 FIRSTN 还是 INDEP。因为下降过程可以重试步骤,让每个 step 各自指定输出顺序没有意义,而且官方作者「并不清楚有什么用例能从中受益」。因此与传统 crush 规则不同,单个 step 不指定 firstn 或 indep。
  2. MSR 规则必须被组织为:
    • 一段(可能为空的)config 步骤前缀(CRUSH_RULE_SET_CHOOSE_MSR*),
    • 后面跟着若干 EMIT block,每个 EMIT block 由:
      • 一个 TAKE 步骤,
      • 一串 CHOOSE_MSR 步骤,
      • 以一个 EMIT 步骤结束。
  3. MSR 的步骤必须是 choosemsr。不允许使用 choose 和 chooseleaf。

上表第 2 条中的「config 步骤前缀」在官方原句中写作 CRUSH_RULE_SET_CHOOSE_MSR*,含义是这一类 set 型步骤;文档未明确给出允许的具体 set 步骤完整清单。

6.5.4 工作空间(Working Space 段)

MSR 规则对工作空间的要求也不同:

规则类型 工作空间需求
传统 CRUSH 3 个大小为 result_max 的向量:两个在处理每条规则时交替使用,另外一个额外供 chooseleaf 使用
MSR 规则 N 个向量,N = 最长 EMIT block 中 choosemsr 步骤的数量。因为 MSR 需要保留每次下降过程中做出的所有选择

详见 mapper.h/c:crush_work_size、crush_msr_scan_rule。

6.5.5 实现入口(Implementation 段)

  • mapper.h/c 中的 crush_do_rule 内部会分支到 mapper.c:crush_msr_do_rule,条件是规则类型为 CRUSH_RULE_TYPE_MSR_*(判定见 mapper.c:rule_type_is_msr)。
  • mapper.c 中与 MSR 相关的函数都带有关于该算法的更详细注释。

6.5.6 关键代码路径小结

关注点 位置
是否 MSR 规则判定 mapper.c: rule_type_is_msr
主入口分派 mapper.h/c: crush_do_rule → mapper.c: crush_msr_do_rule
核心下降算法(含故障域隔离说明的注释) mapper.c: crush_msr_choose
工作空间计算 mapper.h/c: crush_work_size
规则扫描 mapper.c: crush_msr_scan_rule

6.5.7 注意事项

  • MSR 规则里不能用 choose / chooseleaf,只能用 choosemsr;反过来,传统规则里也没有 choosemsr。
  • MSR 规则的输出顺序(FIRSTN / INDEP)是整条规则级的,写在 type 上(如 type msr_indep)。
  • 工作空间随 EMIT block 中 choosemsr 步骤数线性增长,这是 MSR 相对传统 CRUSH 的额外代价。
  • 官方未给出 msr_firstn / msr_indep 两类型在失败重试语义上的完整差异,也未给出 crush_msr_choose 的伪代码——文档未明确,需读源码注释。

引自:CRUSH MSR (Multi-step Retry)


七、配置与可观测性内部

本章覆盖 12 个页面,是全套 dev/ 文档中页数最多但深度参差的一章:既有 config.rst 这种讲配置系统内部实现的,也有 perf.rst、cpu-profiler.rst 这种纯操作手册(几行命令),还有 corpus.rst 这种面向开发者的测试基础设施说明。

7.1 config.rst:配置管理系统(Configuration Management System)

官方该页共 341 行,标题为 Configuration Management System。它讲的是配置系统自身的内部实现:配置从哪来、怎么定义、代码怎么读、怎么改、怎么在文档里生成。

7.1.1 设计动机

配置管理系统存在的目的:给每个守护进程提供正确的配置信息。官方把配置抽象为一组 key-value 对。

7.1.2 配置来源(三个)

来源 官方说明 示例
Ceph 配置文件 通常命名为 ceph.conf —
命令行参数 — --debug-ms=1、--debug-monc=10
运行时注入的参数 通过 injectargs 或 config set —

7.1.3 配置文件的查找顺序

官方给出的顺序(依次检查):

  1. 默认位置(default locations)
  2. 环境变量 CEPH_CONF
  3. 命令行参数 -c

7.1.4 配置文件结构与 stanza 语义

配置文件中每个 stanza 描述「对某一子集守护进程生效的 key-value 对」:

stanza 形式 作用范围
global 应用于所有(everything)
mon 所有 monitor
osd 所有 OSD
mds 所有 mds server
mon.$name 指定名字的那个 monitor
osd.$name 指定名字的那个 OSD
mds.$name 指定名字的那个 MDS

覆盖规则:配置文件中出现较晚的值胜过较早的值(Configuration values that appear later in the file win over earlier ones)。

官方提示:示例配置文件位于 src/sample.ceph.conf。

7.1.5 Metavariables(元变量)

配置系统允许把任意配置值替换进另一个值,语法是 $varname,「similar to how bash shell expansion works」。

额外的特殊元变量:

元变量 展开为 官方示例
$host 当前主机名 —
$type mds / osd / mon / client 之一 —
$id 守护进程标识符 osd.0 → 0;mds.a → a;client.admin → admin
$num 同 $id —
$name $type.$id —

7.1.6 读取配置值的两种方式(Reading configuration values)

方式 做法 官方评价
直接读变量 从 g_conf(等价于 g_ceph_ctx->_conf)读 应被视为 deprecated,新代码不要这样做
注册 observer 实现 common/config_obs.h 里的接口;每次相关配置值变化时被调用 新代码应优先使用

observer 的行为特征:

  • 在初始配置被读取后不久会被调用一次;
  • 此后每当相关值之一变化时被调用;
  • 每个 observer 追踪一组 key,只有当相关 key 变化时才被触发。

官方给出偏好 observer 的三个理由:

  1. 更灵活:允许代码执行「实现新配置值所需的任何重新初始化动作」。
  2. 唯一途径:这是创建「可以被 injectargs 修改的 std::string 配置变量」的唯一方式。
  3. 线程安全:即便是整型配置项,在一个线程里改值、另一个线程同时读,也会导致微妙且无法诊断的 bug。

官方强调:不要去修改 g_conf(Do not ever alter g_conf)。

7.1.7 修改配置值(Changing configuration values)

函数 作用 后续动作
g_conf()->set_val 修改配置 需调用 g_conf()->apply_changes 重跑受影响的 observer
g_conf()->apply_changes 重跑受影响的配置 observer —
g_conf()->set_val_or_die 便捷形式:做一次「你认为绝不该失败」的配置变更 内含 apply
injectargs 另一种修改配置的途径 与 set_val 一样,之后要调 apply_changes
parse_argv 解析命令行参数并修改配置 同上
parse_env 解析环境变量并修改配置 同上

7.1.8 定义配置项(Defining config options)

存放位置:配置项定义在 common/options/*.yaml.in,按**消费者(consumers)**分类。若某选项只被 ceph-osd 使用,就放到 osd.yaml.in。

构建流程:所有 .yaml.in 文件在构建时由 y2c.py 翻译成 .cc 和 .h 文件。

注意(官方 note):ceph-mgr 模块与其它 Ceph 组件使用同一套配置系统,但它们的配置项在各模块自身的 Python 实现里定义。

一个典型选项(官方示例):

- name: public_addr
  type: addr
  level: basic
  desc: public-facing address to bind to
  long_desc: The IP address for the public (front-side) network.
    Set for each daemon.
  services:
  - mon
  - mds
  - osd
  - mgr
  flags:
  - startup
  with_legacy: true

7.1.9 选项定义的字段语义(官方逐条说明)

level —— 表示该选项「被运维或开发者调整的概率」:

取值 官方定义
basic 普通运维很可能会调整的基础配置项
advanced 运维可以调整,但除非明白自己在做什么否则不要碰;不当调整会导致问题(性能问题甚至数据丢失)
dev 仅供开发者使用:要么用于测试目的,要么描述「不应被用户调整但我们不想硬编码进代码」的常量

desc / long_desc / fmt_desc

字段 官方定义 官方示例
desc 简短描述,句子片段 desc: Default checksum algorithm to use
long_desc 完整句子,可能是多段,可包含其它详细信息或注意事项 long_desc: crc32c, xxhash32, and xxhash64 are available. The _16 and _8 variants use only a subset of the bits for more compact (but less reliable) checksumming.
fmt_desc 用 reStructuredText 格式化的描述。仅被 confval 指令用于在文档中渲染选项 fmt_desc: The interval for "deep" scrubbing (fully reading all data). The ``osd_scrub_load_threshold`` does not affect this setting.

默认值(Default values)

  • 每个配置项都有默认值。
  • 某些情况下还存在 daemon default——只对「声明自己是守护进程」的代码生效;此时常规默认值只作用于非守护进程。
default: crc32c
  • 对 float / size / secs 类型的选项,允许使用字面量后缀:
- name: mon_scrub_interval
  type: secs
  default: 1_day
- name: osd_journal_size
  type: size
  default: 5_K

官方鼓励在新增或更新默认值时使用这些字面量后缀以提升可读性。

services(Service)

  • Service 是组件名,如 common、osd、rgw、mds 等,也可以是列表。
  • 官方例子:rocksdb 选项同时影响 osd 和 mon。
  • 若某选项被放在某个 service 专属的 .yaml.in 文件中,对应 service 会被自动加入其 services 属性。例如 osd_scrub_begin_hour 位于 osd.yaml.in,即便该文件里没有显式写 services,其 services 属性仍包含 osd。

tags(标签)

标识跨 service 之间有某种关联的选项。官方给出的两个例子:

tag 含义
network 影响网络配置的选项
mkfs 只在 mkfs 时才有意义的选项

enum_values(枚举)

用于「有确定允许取值集合」的选项:

enum_values:
- none
- crc32c
- crc32c_16
- crc32c_8
- xxhash32
- xxhash64

flags(标志位)

flag 官方定义
runtime 该值可以在运行时更新
no_mon_update 守护进程/客户端不从 monitor 配置数据库拉取这个值;我们禁止通过 ceph config set ... 设置它。该选项应通过 ceph.conf 或命令行配置
startup 选项只在守护进程启动期间生效
cluster_create 选项只影响集群创建
create 选项只影响守护进程创建

7.1.10 配置项在文档中的呈现(Documentation of Configuration Values)

官方说明:Ceph 配置项是**按需(on-demand)**用 :confval: 指令文档化的,而不是集中在一个地方。

** documenting 用指令**:

The check interval can be customized by the ``check_interval`` option:

.. confval:: mgr/inbox/check_interval

官方 note:ceph-mgr 模块选项必须包含 mgr/<module>/ 命名空间前缀。上例中 check_interval 属于 inbox 模块,所以文档化时写 mgr/inbox/check_interval。

引用(Referencing)用角色:

With the :confval:`mgr/inbox/check_interval` setting, you can customize the
check interval.

对普通 Ceph 选项(非 mgr 模块),流程相同,只是没有模块前缀:

You can set the initial monitor members with :confval:`mon_initial_members`:

.. confval:: mon_initial_members

命名约定(Naming Conventions)

类别 格式 示例
Mgr 模块选项 mgr/<module>/<option_name> mgr/inbox/check_interval
普通选项 直接用选项名 mon_initial_members

官方称这种方式「确保整个文档中交叉引用的一致性,同时为不同配置上下文保持恰当的命名空间」。

7.1.11 注意事项

  • 不要用 g_conf 直接读,新代码一律用 observer。
  • 改完配置必须 apply_changes,否则 observer 不会被触发。
  • no_mon_update 标志的选项不能通过 ceph config set 设置——这是排查「配置设了但没生效」时的高频原因。
  • 该页未涉及 config-key、ceph config 命令族、配置掩码(mask)匹配算法的实现细节 —— 其中 mask 的形式出现在 config-key.rst(见 7.2)。

引自:Configuration Management System


7.2 config-key.rst:config-key 的键布局

官方该页共 68 行,是一份键命名规范清单。它记录 mon 提供的通用 K/V 存储服务 config-key 中,树内(in-tree)用户目前使用的键布局 schema。

7.2.1 设计动机

config-key 是 mon 提供的一个通用 key/value 存储服务。官方原话:「Generally speaking, you can put whatever you want there.」——原则上什么都能放。因此本页的作用是:把当前树内用户的键布局 schema 记录下来(Current in-tree users should be captured here with their key layout schema)。

7.2.2 OSD dm-crypt 密钥

键格式:

dm-crypt/osd/$OSD_UUID/luks = <json string>

JSON payload 形式:

{ "dm-crypt": <secret> }

其中 secret 是 base64 编码的 LUKS key。

环节 说明
创建者 osd new 命令(见 OSDMonitor.cc)
消费者 ceph-volume 及类似工具
访问控制 通常由一个 client.osd-lockbox.$OSD_UUID 的 cephx key 授权访问 dm-crypt/osd/$OSD_UUID 前缀,使得只有对应的主机能取回 LUKS key

两级加密关系(官方描述):LUKS key(存在 config-key 中)又去解密真正的 raw key,而 raw key 也存放在设备自身上。

7.2.3 ceph-mgr 模块的键约定

约定格式(两种):

mgr/$MODULE/$option = $value
mgr/$MODULE/$MGRID/$option = $value

官方示例:

mgr/dashboard/server_port = 80
mgr/dashboard/foo/server_addr = 1.2.3.4
mgr/dashboard/bar/server_addr = 1.2.3.5

第二种形式用于区分不同 mgr 实例($MGRID)。

7.2.4 客户端与守护进程的配置项(Configuration)

客户端与守护进程的配置选项也存放在 config-key 中。

键的四种形式:

config/$option = $value
config/$type/$option = $value
config/$type.$id/$option = $value
config/$type.$id/$mask[/$mask2...]/$option = $value

字段语义:

字段 说明 官方示例
type 守护进程类型 osd、mon、mds、mgr、client
id 守护进程 id,使得 $type.$id 形如 osd.123 或 mds.foo 0、foo
mask 限制该选项作用于谁;有两种形式 见下
mask 形式 1 $crush_type:$crush_value rack:foorack
mask 形式 2 class:$classname,引用 CRUSH device classes class:ssd

mask 可以串联多个(/$mask2...)。

7.2.5 注意事项

  • 该页只记录 in-tree 用户;第三方放进去的键不在规范内。
  • 官方未说明 config-key 的读写 API、事务语义、大小限制或权限模型(除 dm-crypt 那段提到的 cephx lockbox 授权外)——文档未明确。
  • 从 7.1 可知,带 no_mon_update 标志的选项不会走这条 config-key 路径。

引自:config-key layout


7.3 logging.rst:集群日志的使用规范

官方该页共 106 行,标题是 "Use of the cluster log"。必须注意:官方开篇即声明——「none of this applies to the local "dout" logging. This is about the cluster log that we send through the mon daemons」,即本页规则只适用于经由 mon 守护进程发送的集群日志,不适用于本地 dout 日志(后者见 7.4)。

本页是规范/风格指南,不是实现说明。分三段:Severity、Frequency、Language and formatting。

7.3.1 Severity(严重级别使用规范)

级别 官方定义 官方举例
ERR 用于集群因某种原因无法完成其工作的情况 我们尝试写入但返回了错误;尝试读取但因数据损坏读不出来;我们 scrub 了一个 PG 但数据不一致因而无法恢复
WRN 用于集群能够处理、但带有某种异常/负面因素的事件 服务的临时降级;意外的内部值;可自动修复的元数据错误;慢操作(slow operation)
INFO 用于不表示 Ceph 存在故障的常规集群操作 官方特别强调:INFO 级消息措辞清晰、不引起混淆或恐慌尤为重要

7.3.2 Frequency(频率规范)

核心要求:所有级别的集群日志消息都不得过于频繁。

官方给出的理由与规则:

规则 官方说明
消费者视角 消费者可能在使用一个包含所有级别消息的滚动日志缓冲区,因此即便 DEBUG 消息过于频繁,也会干扰最新 INFO 消息的正常显示
坏状态 vs 坏事件 如果是「坏状态(a bad state)」而非「坏事件(event)」,那是 health check 的职责——不要靠刷集群日志来表示一个持续的不健康状态
禁止随规模增长的消息 不要为「随客户端数量或系统活动水平而扩展」的事件、或「正常操作中规律发生」的事件发送集群日志

官方举的两个反例:

  • 每个新客户端连接都发一条 INFO(随客户端数扩展)
  • 每次 CephFS 子树迁移都发一条 INFO(正常操作中规律发生)

7.3.3 Language and formatting(语言与格式规范)

官方前置说明:这些准则对 DEBUG 级消息的重要性远低于 INFO 及以上;应把精力集中在让 INFO/WRN/ERR 消息尽可能易读上。

准则 官方要求 官方示例
语态 使用被动语态 用 "Object xyz could not be read",而不是 "I could not read the object xyz"
长/大标识符 如 inode 号,以十六进制打印,加 0x 前缀,让用户能看出是十六进制 理由:0x 使其无歧义(十进制没有等价写法),且十六进制形式更可能在一屏内放得下
大小量 以人类可读的 MB/GB 等打印,数字后带单位。例外:若指定的是偏移量(offset),精度对含义至关重要,则可以按字节指定(但要按十六进制打印) —
单行 尽最大努力让消息单行放得下。不要求必然如此,但至少通常应如此。意味着:除非列表中只有少数几项,否则不要打印列表 —
名词选择 使用对用户有意义、且在文档中有定义的名词。常见缩写可以——不要浪费屏幕空间打 "Rados Object Gateway" 而不是 RGW。不要使用内部类名称,如 "MDCache" 或 "Objecter" 用 "OSD client requests" 而不是 "Objecter requests"
内部结构的例外 如果内部结构体是消息的直接主体,可以提及——例如在**损坏(corruption)**的场景 "Corrupt session table" 可以;但不要说 "Corrupt SessionTable"
描述后果 在可能的情况下,描述对系统可用性的后果,而不只是描述底层状态 说 "myfs is degraded, waiting for myfs.0 to finish starting",而不是 "MDS myfs.0 is replaying"
缩写 vs 截断 常见缩写可以,但不要随意截断单词 是 "directory inode",不是 "dir ino"
"不该发生" 的情况 如果记录的是一个「本应 assert 但我们没有崩溃」的情况,要在措辞里说清楚——这大概不是用户能自行修复的情况 —
避免行话 避免 UNIX/程序员黑话。用 "error" 而不是 "errno"(最好给出比数字更具描述性的东西) —
cluster map epoch 不要提及 cluster map epoch,除非它对消息含义至关重要 "OSDMap epoch 123 is corrupt" 可以(epoch 就是消息的重点);"OSD 123 is down in OSDMap epoch 456" 不可以(osdmap 和 epoch 概念是实现细节,OSD 的 down 状态才是真正的消息)。可以把额外细节送到守护进程的本地日志(通过 dout/derr)
问题的消失 如果你记录了一个未来可能消失的问题,要确保当它消失时也记录一条。无论原消息用什么优先级记录,"消失" 消息一律记在 INFO —

7.3.4 注意事项

  • 本页不适用于本地 dout 日志 —— 这是最常见的误解点。
  • 「持续的不健康状态」应走 health check,不要刷集群日志。
  • 需要给开发者看的细节,应走守护进程本地的 dout/derr,而不是集群日志。

引自:Use of the cluster log


7.4 logs.rst:本地调试日志(dout)

官方该页共 55 行,标题 "Debug logs"。它讲两件事:dout/derr 调试日志,以及性能计数器。

7.4.1 dout 日志机制

官方定义:Ceph 的主要调试工具是 dout 和 derr 日志函数,合称 "dout logging"。

faculty(子系统)与级别:

  • dout 有若干 log faculties(日志子系统);
  • 可以通过配置管理系统为不同 faculty 设置不同 log level;
  • 官方例子:只想为 messenger 开启调试,就设 debug_ms = 10。

7.4.2 短路优化:dout 宏的 if 包裹

官方强调一个重要的性能特性:dout 宏会把「不会被使用的日志消息」包在一个 if 语句里,从而连生成都避免。

含义(官方举例):

dout(20) << "myfoo() = " << myfoo() << dendl;

如果 debug level 设为 0,那么 myfoo() 在这里根本不会被调用。

这是一个非常关键的实践点:dout(N) 的参数不总是被求值,因此绝不能把有副作用的代码写进 dout(...) 的参数里。

7.4.3 dout 的性能代价(官方说明)

官方坦承:debug logging 的性能相对较低。原因:

原因 官方说明
全局锁 存在一个进程级(process-wide)的 mutex,每条 debug 输出语句都要获取
系统调用 每条 debug 输出语句都会导致一次 write() 系统调用或一次 syslog() 调用
C++ 流开销 使用 C++ streams 也有计算开销

结论:官方建议 —— 你需要在日志上保持节俭(parsimonious)以获得最佳性能。

7.4.4 调试时的陷阱

官方提醒:有时候开启日志会通过改变事件的时序而掩盖住竞态条件及其它 bug(enabling logging can hide race conditions and other bugs by changing the timing of events)。调试时要把这点记在心里。

7.4.5 Performance counters(性能计数器,本页版本)

官方该段描述:

  • Ceph 守护进程用性能计数器跟踪关键统计信息,例如 pinned inode 数量。
  • 性能计数器本质上是一组整数和浮点数,可通过 PerfCounters API 进行 set / increment / read。

PerfCounters 对象特征:

特征 说明
关联范围 通常关联到单个子系统
内容 包含多个计数器
线程安全 由内部 mutex 保护
数量 可以创建多个 PerfCounters 对象

当前支持的三种计数器类型:

类型 创建函数 语义
u64 counter PerfCountersBuilder::add_u64 单个 u64 值,可原子地更新、递增、读取
float counter PerfCountersBuilder::add_fl 单个 float 值,同上
long-run floating-point average counter PerfCountersBuilder::add_fl_avg 提供两个值:当前总和(current total) 与 总和被改变的次数;目的是提供长期平均值

读取方式:性能计数器信息可从管理套接字(admin_sock) 以 JSON 格式读取。admin_sock 实现为 UNIX domain socket。

官方给出的两个参考实现:

  • Ceph performance counter plugin for collectd:展示了如何访问这些信息;
  • administrative sockets 的单元测试:另一个例子。

7.4.6 注意事项

  • dout 参数可能不被求值,勿写副作用。
  • 日志开销来自「全局锁 + 每条一次 write()/syslog()」,高频热路径上写日志代价很高。
  • 本页对 PerfCounters 的描述是简化版,更完整的 schema / dump / labeled counters 在 perf_counters.rst(见 7.7)。

引自:Debug logs


7.5 health-reports.rst:健康报告

官方该页共 69 行。注意:官方在 "How to Get Reports" 一节写了 "The following diagrams outline the involved parties and how the interact when the clients query for the reports:",但该页在源文件中并没有实际可见的图内容(图表是外链图片,不在 rst 文本里)。因此本册无法复现该图,只能按文字描述说明——如实标注。

7.5.1 获取报告的两种渠道(How to Get Reports)

渠道 具体方式 返回
ceph CLI 发送 health mon 命令 集群健康状态
mgr module 调用 mgr.get('health') 同样的报告,但形式是 JSON 编码的字符串

关于「客户端查询报告时各方如何交互」的示意图,文档中的图未能从 rst 源文中提取(源文仅有引导语,图以图片形式外链)。文档未明确其具体内容。

7.5.2 报告在哪里生成:聚合者的聚合者(Aggregator of Aggregators)

健康报告由多个 Paxos service 聚合而来,官方列出的 7 个:

- AuthMonitor
- HealthMonitor
- MDSMonitor
- MgrMonitor
- MgrStatMonitor
- MonmapMonitor
- OSDMonitor

生成与持久化机制(官方描述):

  1. 上述每个 service 在持久化自己域内的 pending changes 时,会识别健康相关问题;
  2. 然后**用同一个事务(the same transaction)**把它们以 health 前缀存入 monstore;
  3. 这些报告之后被加载和解码,以便按需收集。

官方举的 OSDMonitor 例子:OSDMonitor 检查一个 pending 的新 osdmap 是否存在可能的问题,例如 down OSDs、某个 pool 缺少 scrub flag,然后把编码后的健康报告连同新的 osdmap 一起存储。

官方举的 MDSMonitor 例子:MDSMonitor 把健康指标持久化在 MDS 守护进程发来的 beacon 中,并在存储 pending changes 时准备健康报告。

给开发者的指引:若想新增一个与 cephfs 相关的 warning,最好的起点大概是 MDSMonitor::encode_pending()——健康报告在那里从最新的 FSMap 以及 MDS 守护进程上报的健康指标中收集而来。

重要例外(官方特别指出):MgrStatMonitor 不自己准备报告,它只是存储从 mgr 收到的健康报告!

7.5.3 ceph-mgr:一个被委托的聚合者(A Delegate Aggregator)

动机(官方叙述):

  • Ceph 中创建 mgr 是为了分担 monitor 的负担——monitor 用于就「对维持集群功能至关重要的信息」建立共识。
  • 显然 osdmap、mdsmap、monmap 属于这一类。
  • 但集群的聚合统计信息呢?它们对管理员了解集群状态很关键,但对维持集群运行可能没那么重要。
  • 为应对这个可扩展性问题,Ceph 把收集和聚合指标的工作卸载(offloaded)给 mgr。

mgr 的职责(官方描述):

职责 说明
接收并处理 MPGStats 消息 来自 OSD
通过 MMgrReport 收集指标 官方开发了一套协议,允许守护进程周期性向 mgr 上报其指标与状态
向 mon 发送聚合报告 在 mgr 侧,它周期性地向 mon 上的 MgrStatMonitor service 发送一份聚合报告

如前所述,MgrStatMonitor 只是把聚合报告中的健康报告持久化到 monstore。

7.5.4 完整数据流(按官方叙述整理)

💡 补充:官方未给出一张完整的端到端流程图(图缺失),下面按文字叙述串联,属整理而非官方原图。

OSD ──MPGStats────────────────────────┐
                                      ▼
守护进程 ──MMgrReport──────────────► mgr
                                      │ 周期性聚合报告
                                      ▼
                              MgrStatMonitor (mon)
                                      │ 仅持久化(不生成)
                                      ▼
                                   monstore

AuthMonitor / HealthMonitor / MDSMonitor / MgrMonitor /
MonmapMonitor / OSDMonitor
        │ 在 encode_pending() 时识别健康问题
        │ 以 health 前缀,与自身数据同一事务写入
        ▼
     monstore

monstore ──► (加载并解码) ──► 按需收集 ──► health mon 命令 ──► ceph CLI
                                        └─► mgr.get('health') ──► mgr module (JSON)

7.5.5 注意事项

  • 报告分散在多个 Paxos service 的 encode_pending() 里,排查「某条 health warning 从哪来」时,要先定位是哪个 Monitor。
  • MgrStatMonitor 是透传,不是生成者。
  • 该页未说明 health check 的注册机制(health_check_t、健康检查的编码格式、detail 的合并规则)——文档未明确。
  • 该页未说明 ceph health detail 与 health mon 命令的差异。

引自:Health Reports


7.6 perf.rst:使用 perf 做 CPU 采样

官方该页共 55 行,是纯操作手册,由 4 组命令 + 2 个注意事项构成。没有任何数据结构或内部实现说明。

7.6.1 常用命令

1. Top(实时热点):

sudo perf top -p `pidof ceph-osd`

2. 带调用图采集数据:

sudo perf record -p `pidof ceph-osd` -F 99 --call-graph dwarf -- sleep 60

参数含义(从命令本身读出,-F 99 为 99Hz 采样率,--call-graph dwarf 用 DWARF 调试信息展开调用栈,-- sleep 60 采集 60 秒)。

3. 按 caller 查看(可以看到每个 top 函数调用了什么):

sudo perf report --call-graph caller

4. 按 callee 查看(可以看到谁调用了每个 top 函数):

sudo perf report --call-graph callee

官方 note:如果 caller/callee 视图看起来一样,你可能是撞上了内核 bug——升级到 4.8 或更高版本。

7.6.2 Common Issues:帧指针被优化掉

问题:Ceph 默认使用 RelWithDebInfo 作为 CMAKE_BUILD_TYPE,因此用 -O2 -g 编译整棵树。而 -O2 优化级别默认启用 -fomit-frame-pointer。这会阻止栈 profiler 访问完整的栈信息。

解法(两种,官方给出):

  1. 在启动 cmake 时禁用该选项:
cmake -DCMAKE_CXX_FLAGS="-fno-omit-frame-pointer"
  1. 或者在构建整棵树时:
make CMAKE_CXX_FLAGS="-fno-omit-frame-pointer"

7.6.3 Flamegraphs(火焰图)

1. 准备工作:

cd ~/src
git clone https://github.com/brendangregg/FlameGraph

2. 运行 ceph,然后采集 perf 数据:

sudo perf record -p `pidof ceph-osd` -F 99 --call-graph dwarf -- sleep 60

3. 生成火焰图:

sudo perf script | ~/src/FlameGraph/stackcollapse-perf.pl > /tmp/folded
~/src/FlameGraph/flamegraph.pl /tmp/folded > /tmp/perf.svg
firefox /tmp/perf.svg

7.6.4 注意事项

  • 默认 RelWithDebInfo 构建必须加 -fno-omit-frame-pointer 才能得到完整调用栈(或改用 --call-graph dwarf)。
  • caller/callee 视图相同 → 内核 < 4.8 的问题。
  • 该页未涉及 OProfile / LTTng / Blkin(分别在 7.11 与 7.12)。

引自:Using perf


7.7 perf_counters.rst:性能计数器

官方该页共 253 行。相比 logs.rst 里的简述,本页给出完整的 Access / Collections / Schema / Dump / Labeled Perf Counters 说明。

7.7.1 设计动机

perf counters 提供通用的内部基础设施,用于 gauges(计量器)和 counters(计数器)。被计数的值既可以是整型也可以是浮点型。

还有一种 "average" 类型(通常是 float),它把一个 sum 计数器与一个 num 计数器结合起来,二者相除即可得出平均值。

数据的去向(官方意图):

环节 官方说明
采集 由 collectd 或 statsd 之类的工具采集并聚合
分析 送入 graphite 之类的工具做绘图和分析
官方也指出 参见 mgr/prometheus 与 mgr/telemetry

本地用途(官方列举):用户和开发者也可以在本地访问 perf counter 数据,用于:

  • 检查集群整体健康状况
  • 识别工作负载模式(workload patterns)
  • 按守护进程类型监控集群性能
  • 排查延迟(latency)、限流(throttling)、内存管理等问题

7.7.2 Access(访问方式)

通过 admin socket 访问:

ceph daemon osd.0 perf schema
ceph daemon osd.0 perf dump

7.7.3 Collections(集合)

  • 值被分组进命名的 collection,通常代表一个子系统或一个子系统的一个实例。
  • 官方例子:内部 throttle 机制报告其限流情况的统计信息,每个实例命名形如:
throttle-msgr_dispatch_throttler-hbserver
throttle-msgr_dispatch_throttler-client
throttle-filestore_bytes
...

7.7.4 Schema:type 位域(bitfield)

perf schema 命令导出一份 JSON 描述,说明有哪些值可用、它们的类型是什么。每个命名值都有一个 type 位域,官方定义的位如下:

bit 含义
1 floating point value(浮点值)
2 unsigned 64-bit integer value(无符号 64 位整型值)
4 average (sum + count pair)(平均值:sum + count 对)
8 counter (vs gauge)(计数器,相对于计量器)

解读规则(官方说明):

规则 说明
类型 每个值都会有 bit 1 或 bit 2 之一被置位,用以指示类型(float 或 integer)
bit 8(counter) 若置位,则该值单调递增,读取方可能想要减去上一次读到的值,以得到上一interval 的增量(delta)
bit 4(average) 若置位,则有两个值要读:一个 sum 和一个 count。若它同时是 counter,则上一 interval 的平均值 = sum 的 delta ÷ count 的 delta;另一种做法是直接相除,得到的是生命周期平均值(lifetime average)。官方补充:这些通常用于测量延迟(请求数 + 请求延迟之和),而上一 interval 的平均值才是有意思的那个

比位域更友好的替代属性(官方说明):与其自己解释位域,不如看:

属性 取值
metric type gauge 或 counter
value type real、integer、real-integer-pair(sum + real count 对)、integer-integer-pair(sum + integer count 对)

7.7.5 Schema 输出示例(官方完整示例)

{
  "throttle-bluestore_throttle_bytes": {
      "val": {
          "type": 2,
          "metric_type": "gauge",
          "value_type": "integer",
          "description": "Currently available throttle",
          "nick": ""
      },
      "max": {
          "type": 2,
          "metric_type": "gauge",
          "value_type": "integer",
          "description": "Max value for throttle",
          "nick": ""
      },
      "get_started": {
          "type": 10,
          "metric_type": "counter",
          "value_type": "integer",
          "description": "Number of get calls, increased before wait",
          "nick": ""
      },
      "get": {
          "type": 10,
          "metric_type": "counter",
          "value_type": "integer",
          "description": "Gets",
          "nick": ""
      },
      "get_sum": {
          "type": 10,
          "metric_type": "counter",
          "value_type": "integer",
          "description": "Got data",
          "nick": ""
      },
      "get_or_fail_fail": {
          "type": 10,
          "metric_type": "counter",
          "value_type": "integer",
          "description": "Get blocked during get_or_fail",
          "nick": ""
      },
      "get_or_fail_success": {
          "type": 10,
          "metric_type": "counter",
          "value_type": "integer",
          "description": "Successful get during get_or_fail",
          "nick": ""
      },
      "take": {
          "type": 10,
          "metric_type": "counter",
          "value_type": "integer",
          "description": "Takes",
          "nick": ""
      },
      "take_sum": {
          "type": 10,
          "metric_type": "counter",
          "value_type": "integer",
          "description": "Taken data",
          "nick": ""
      },
      "put": {
          "type": 10,
          "metric_type": "counter",
          "value_type": "integer",
          "description": "Puts",
          "nick": ""
      },
      "put_sum": {
          "type": 10,
          "metric_type": "counter",
          "value_type": "integer",
          "description": "Put data",
          "nick": ""
      },
      "wait": {
          "type": 5,
          "metric_type": "gauge",
          "value_type": "real-integer-pair",
          "description": "Waiting latency",
          "nick": ""
      }
  }
}

对照位域表读数:

type 值 二进制 解读
2 0010 bit 2 → unsigned 64-bit integer,gauge
10 1010 bit 8 + bit 2 → unsigned 64-bit integer,counter
5 0101 bit 4 + bit 1 → average (sum+count),value_type 为 real-integer-pair

7.7.6 Dump 输出示例

官方说明:实际 dump 与 schema 类似,区别是 average 值会被分组。示例:

{
  "throttle-msgr_dispatch_throttler-hbserver" : {
     "get_or_fail_fail" : 0,
     "get_sum" : 0,
     "max" : 104857600,
     "put" : 0,
     "val" : 0,
     "take" : 0,
     "get_or_fail_success" : 0,
     "wait" : {
        "avgcount" : 0,
        "sum" : 0
     },
     "get" : 0,
     "take_sum" : 0,
     "put_sum" : 0
  },
  "throttle-msgr_dispatch_throttler-client" : {
     "get_or_fail_fail" : 0,
     "get_sum" : 82760,
     "max" : 104857600,
     "put" : 2637,
     "val" : 0,
     "take" : 0,
     "get_or_fail_success" : 0,
     "wait" : {
        "avgcount" : 0,
        "sum" : 0
     },
     "get" : 2637,
     "take_sum" : 0,
     "put_sum" : 82760
  }
}

注意 wait 展开为 {"avgcount": ..., "sum": ...} 两个字段,正是 bit 4(average)的 sum + count 对。

7.7.7 Labeled Perf Counters(带标签的性能计数器)

官方 note:Labeled perf counters 在 Ceph 的 Reef 版本中引入。

动机:一个 Ceph 守护进程有能力导出一组带不同 label 的 perf counter 实例。这些计数器旨在用于在 Prometheus 和 Grafana 等第三方工具中可视化特定指标。

官方示例:下面这些计数器展示了不同用户在不同 bucket 上的 put 请求数:

{
    "rgw": [
        {
            "labels": {
                "Bucket: "bkt1",
                "User: "user1",
            },
            "counters": {
                "put": 1,
            },
        },
        {
            "labels": {},
            "counters": {
                "put": 4,
            },
        },
        {
            "labels": {
                "Bucket: "bkt1",
                "User: "user2",
            },
            "counters": {
                "put": 3,
            },
        },
    ]
}

访问命令(官方列出):

命令 作用
ceph daemon {daemon id} counter dump 查看所有 labeled 和 unlabeled perf counters
ceph daemon {daemon id} counter schema 查看所有 labeled 和 unlabeled perf counter 的 schema
ceph daemon {daemon id} perf dump 仅 non-labeled(上例中不带 labels 的那个计数器会出现在这里)
ceph daemon {daemon id} perf schema 同上,仅 non-labeled

上例中的第二个计数器("labels": {})就是那种同时也会出现在 perf dump 中的计数器。

官方的弃用建议(重要):

  • 由于 counter dump 和 counter schema 既能看带标签的也能看不带标签的,因此不推荐再使用 perf dump 和 perf schema 命令——它们仅为向后兼容而保留,且继续只输出 non-labeled 计数器。
  • 某些目前通过 perf dump / perf schema 导出的 perf counter,未来版本中可能变成 labeled,届时它们将不再被 perf dump / perf schema 输出。

7.7.8 注意事项

  • 计算「速率 / 区间平均」时,对 counter 类型要做 delta,不能直接用原值。
  • 新代码/新脚本应优先用 counter dump / counter schema。
  • 该页未给出 PerfCountersBuilder 的完整 API 列表(仅 logs.rst 提到 add_u64 / add_fl / add_fl_avg),也未说明 labeled counter 的 label 基数爆炸风险处理——文档未明确。

引自:Perf counters


7.8 perf_histograms.rst:性能直方图

官方该页共 677 行,但其中约 500 行是 dump 输出示例(大量重复的 0 值数组)。实质内容集中在开头的 Access / Collections / Schema 与结尾的说明段。

7.8.1 设计动机

  • perf histograms 构建在 perf counters 基础设施之上。
  • 直方图是为若干计数器构建的,用于**简化「哪些计数器值分组随时间出现得最频繁」**的数据收集。
  • perf histograms 目前是无符号 64 位整型计数器,因此主要用于时间和大小(time and sizes)。
  • 由 perf histogram 导出的数据可以再喂给其它分析工具/脚本。

7.8.2 Access(访问方式)

ceph daemon osd.0 perf histogram schema
ceph daemon osd.0 perf histogram dump

7.8.3 Collections(集合)

与 perf counters 一样,直方图按命名的 collection 分组,通常代表一个子系统或子系统的一个实例。官方例子:

op_r_latency_out_bytes_histogram
op_rw_latency_in_bytes_histogram
op_rw_latency_out_bytes_histogram
...

7.8.4 Schema:type 位域

perf histogram schema 导出 JSON 描述。每个命名值有一个 type 位域,第 5 位(bit 16,值为 16)总是被置位,其余位定义如下:

bit 含义
1 floating point value
2 unsigned 64-bit integer value
4 average (sum + count pair)
8 counter (vs gauge)

官方给出的解读例子:type "18" 就是一个 unsigned 64-bit integer 值的直方图(16 + 2)。

Schema 输出示例(官方完整给出):

{
    "AsyncMessenger::Worker-0": {},
    "AsyncMessenger::Worker-1": {},
    "AsyncMessenger::Worker-2": {},
    "mutex-WBThrottle::lock": {},
    "objecter": {},
    "osd": {
        "op_r_latency_out_bytes_histogram": {
            "type": 18,
            "description": "Histogram of operation latency (including queue time) +   data read",
            "nick": ""
        },
        "op_w_latency_in_bytes_histogram": {
            "type": 18,
            "description": "Histogram of operation latency (including queue time) +   data written",
            "nick": ""
        },
        "op_rw_latency_in_bytes_histogram": {
            "type": 18,
            "description": "Histogram of rw operation latency (including queue time)   +   data written",
            "nick": ""
        },
        "op_rw_latency_out_bytes_histogram": {
            "type": 18,
            "description": "Histogram of rw operation latency (including queue time)   +   data read",
            "nick": ""
        }
    }
}

观察:AsyncMessenger::Worker-0/1/2、mutex-WBThrottle::lock、objecter 这几个 collection 在示例中是空对象 {}(无直方图),只有 osd collection 下有 4 个直方图。

四个直方图的语义(按 description):

名称 描述
op_r_latency_out_bytes_histogram 读操作延迟(含排队时间)+ 读出数据量
op_w_latency_in_bytes_histogram 写操作延迟(含排队时间)+ 写入数据量
op_rw_latency_in_bytes_histogram 读写操作延迟(含排队时间)+ 写入数据量
op_rw_latency_out_bytes_histogram 读写操作延迟(含排队时间)+ 读出数据量

7.8.5 Dump:二维直方图结构

官方说明:实际 dump 与 schema 类似,区别在于有实际的值分组。

Axis(轴)的字段语义(从官方示例与结尾说明归纳):

字段 示例值 官方说明
name Latency (usec) / Request size (bytes) 轴名
min 0 最小可接受值("Min" is a minimum accepted value)
quant_size 100000 / 512 量化单位(quantization unit)
buckets 32 值分组(buckets)的数量("Buckets" denote amount of value groups)
scale_type log2 / linear log2(对数刻度)或 linear(线性刻度)
ranges 数组 每个值分组的取值边界("Ranges" element denote value bounds for each of value groups)

Latency 轴的 ranges 结构(官方示例,32 buckets):

{ "max": -1 }                                  ← 下溢桶(underflow)
{ "min": 0,           "max": 99999 }           ← bucket 1
{ "min": 100000,      "max": 199999 }          ← bucket 2
{ "min": 200000,      "max": 399999 }          ← bucket 3(宽度开始翻倍)
{ "min": 400000,      "max": 799999 }
{ "min": 800000,      "max": 1599999 }
...(log2 递增)
{ "min": 26843545600000, "max": 53687091199999 }
{ "min": 53687091200000 }                      ← 上溢桶(overflow,只有 min,无 max)

Request size 轴的 ranges(同样 32 buckets,quant_size: 512):

{ "max": -1 }
{ "min": 0,          "max": 511 }
{ "min": 512,        "max": 1023 }
{ "min": 1024,       "max": 2047 }
...(log2 递增)
{ "min": 137438953472, "max": 274877906943 }
{ "min": 274877906944 }                        ← 上溢桶

values 结构:values 是一个二维数组,官方在说明段给出结论:

「This represents the 2D histogram, consisting of 9 history entries and 32 value groups per each history entry.」

即:

维度 数量 说明
第一维 9 history entries(历史条目,即按时间保留的 9 份快照)
第二维 32 value groups(与 buckets 对应的值分组)

💡 补充:官方示例 JSON 中 values 数组实际只展示了 8 个子数组(每个 32 个 0),但该页结尾文字明确写的是 "9 history entries"。以官方文字说明为准,示例 JSON 疑为截断或省略。

7.8.6 可视化工具

官方指出:可以使用 histogram_dump.py 工具(见 src/tools/histogram_dump.py)快速可视化已有的直方图数据。

7.8.7 注意事项

  • 直方图只能是 u64(当前),不适合负值或浮点指标。
  • 横向是 32 个 log2 bucket + 2 个溢出桶,纵向保留 9 个历史条目,因此是「随时间演进的二维分布」。
  • 该页未说明「9 个 history entry」的滚动周期/触发条件 —— 文档未明确。
  • 该页未说明如何在代码中新增一个 perf histogram(无 API 说明)——文档未明确。

引自:Perf histograms


7.9 mempool_accounting.rst:内存池记账

官方该页共 49 行,是一份清单式说明:什么是 mempool、能追踪哪些常见 mempool、怎么查看。

7.9.1 什么是 mempool(官方定义)

一个 memory pool (mempool) 是一种追踪内存消耗的方法。

特征 官方说明
代表对象 mempool 代表 C++ 类和容器的内存消耗
用途 用于评估内存泄漏以及其它关于内存使用的洞察,且开销低(low overhead)
追踪内容 每个 memory pool 追踪它包含的 字节数(bytes) 和 条目数(items)
声明方式 mempool 是静态声明的(statically declared)
特殊用途 它们服务于「在 BlueStore 中识别与内存相关的检查项」这一目的

7.9.2 常见可追踪的 mempool(官方完整清单)

mempool 官方说明
bloom_filter 追踪已在 cache pool 中的对象,以判断池中哪些对象正被访问
bluestore_alloc 统计 allocator 实际完成的分配
bluestore_inline_bl 度量内联 buffer 的编码长度
bluestore_fsck BlueStore 元数据的文件系统一致性检查;在 OSD 修复期间帮助追踪和调试
bluestore_txc 统计已提交事务(committed transactions)计数器
bluestore_writing_deferred 度量被延迟的小写——即先写入 RocksDB WAL、之后再 flush 到磁盘
bluestore_writing 统计在途(in-flight)写 buffer
bluefs 类文件系统接口;提供刚好够用的功能,使 RocksDB 能存储它的「文件」并与 BlueStore 共享同一批裸设备
buffer_anon 存放任意 buffer 数据
buffer_meta 与 buffer anon buffer 关联的所有元数据
bluestore_cache_data 用于 writing 和 writing deferred 的 mempool
bluestore_cache_onode BlueStore cache 中的对象节点(onode)元数据
bluestore_cache_meta PREFIX_OBJ 下我们存放的 key
bluestore_cache_other 目前统计:见下三项
↳ map_t 用于追踪 SharedBlob 的磁盘裸 extent,以及带 blob namespace 的内存 Blob
↳ coll_map collections_map
↳ csum_data 校验和数据
bluestore_cache_buffer 统计 buffer cache shards
bluestore_extent 一个逻辑(同时也是物理)extent,指向某个 blob 的一部分
bluestore_blob 与 cached buffer 关联的内存 blob 元数据
bluestore_shared_blob 内存中的 shared blob 状态;存放它所归属的 collection 集合的引用(包含 cached buffers)
bluefs_file_reader 统计 bluefs file reader buffer
bluefs_file_writer 统计 bluefs file writer buffer

7.9.3 查看方式

$ ceph daemon osd.NNN dump_mempools

官方 note 指向更详细的源码:https://github.com/ceph/ceph/blob/main/src/include/mempool.h

7.9.4 注意事项

  • mempool 是静态声明的——不能运行时新增;新增 mempool 需要改代码(文档未明确具体改法,指向 src/include/mempool.h)。
  • 清单以 BlueStore 为主(这是官方口径:「serve the purpose of identifying memory-related checks in BlueStore」),非 BlueStore 子系统的 mempool 覆盖情况文档未明确。
  • 该页未说明 dump_mempools 的输出格式细节。

引自:What is a mempool?


7.10 cpu-profiler.rst:OProfile 安装与使用

官方该页共 46 行,标题 "Installing Oprofile",是纯操作手册。官方开篇即说:「分析 Ceph CPU 消耗最简单的方法是使用 oprofile 这个系统级 profiler。」

💡 补充:该页内容较旧(引用 do-cmake.sh、git clean -dfx 等老式构建流程),与 perf.rst 的现代做法并存。按原文如实记录。

7.10.1 安装

Debian/Ubuntu 发行版:

sudo apt-get install oprofile oprofile-gui

7.10.2 为 Profiling 编译 Ceph

第一步:先清理一切。

git clean -dfx

第二步:编译 Ceph。

./do-cmake.sh -DCMAKE_CXX_FLAGS="-fno-omit-frame-pointer -O2 -g"
cd build
cmake --build .

官方说明:命令中指定了 CMAKE_CXX_FLAGS,这提供了 callgraph 输出(This provides callgraph output)。

注意与 7.6 的呼应:核心都是为了关掉 -fomit-frame-pointer(-O2 默认开启)以获得完整调用栈。

7.10.3 Ceph 配置

  • 确保禁用 lockdep。
  • 考虑把日志级别设为适合生产集群的水平(官方指向 Ceph Logging and Debugging 文档)。
  • 关于如何使用 OProfile,官方指向 RADOS Troubleshooting 文档的 CPU Profiling 一节。

7.10.4 注意事项

  • 该页未给出 OProfile 的实际采集与分析命令(如 operf、opreport),一律指向 troubleshooting 文档 —— 本页范围内文档未明确。
  • 该页使用的 do-cmake.sh 是较老的构建入口;Tentacle 时代通常用 do_cmake.sh / ./do_cmake.sh(文档未明确此差异)。

引自:Installing Oprofile


7.11 corpus.rst:编码语料库(对象 corpus)

官方该页共 100 行,是面向 Ceph 开发者的测试基础设施说明。它与第 3.5 节的 encoding.rst(序列化兼容性)直接配套:corpus 就是「历史版本的编码样本库」,用来验证新代码还能不能解码旧对象。

7.11.1 设计动机

为了保证跨版本的编码兼容性,需要一个存档(archive):保存来自特定版本的已编码对象样本。测试时拿新代码去解码这些旧样本,就能发现兼容性倒退。

7.11.2 Corpus 结构

ceph.git/ceph-object-corpus 是一个 submodule。其目录布局:

bin/                                    # misc scripts
archive/$version/objects/$type/$hash    # 来自特定版本的一批已编码对象样本

7.11.3 标记已知/故意的不兼容

archive/$version/forward_incompat/$type

官方说明:该文件的存在表示:新版本的代码无法跨过该 $version 解码旧对象(this is normally the case,通常正是如此)。

7.11.4 生成对象 corpus 的完整流程(官方 10 步)

官方说明:可以用 script/gen-corpus.sh 脚本生成,也可以按下面的步骤手工完成。

第 1 步:checkout 一个干净的仓库(最好别在你日常工作的目录里做)

git clone ceph.git
cd ceph
git submodule update --init --recursive --progress

第 2 步:带 dump 对象的 flag 构建(dump 到 /tmp/foo)

rm -rf /tmp/foo ; mkdir /tmp/foo
do_cmake.sh -DCMAKE_CXX_FLAGS="-DENCODE_DUMP_PATH=/tmp/foo"
cd build
make

第 3 步:通过 vstart 启动集群

cd build
MON=3 MGR=2 OSD=3 MDS=3 RGW=1 ../src/vstart.sh -n -x

第 4 步:尽可能多地使用集群功能,以触发尽可能多的对象 encoder 方法

bin/ceph osd pool create mypool
bin/rados -p mypool bench 10 write -b 123
bin/ceph osd out 0
bin/ceph osd in 0
bin/init-ceph restart osd.1
for f in ../qa/workunits/cls/*.sh ; do PATH="bin:$PATH" $f ; done
PATH="bin:$PATH" ../qa/workunits/rados/test.sh
bin/ceph_test_librbd
bin/ceph_test_libcephfs
bin/init-ceph restart mds.a
../qa/workunits/rgw/run-s3tests.sh

第 5 步:停止集群

../src/stop.sh

第 6 步:导入 corpus(这会花几分钟)

../src/test/encoding/import.sh /tmp/foo `bin/ceph-dencoder version` ../ceph-object-corpus/archive
../src/test/encoding/import-generated.sh ../ceph-object-corpus/archive

第 7 步:剪枝(Prune)

官方说明:会有 bazillion 份各种对象的拷贝,我们只想要一个有代表性的样本。

pushd ../ceph-object-corpus
bin/prune-archive.sh
popd

第 8 步:验证测试通过

ctest -R readable.sh

第 9 步:提交到 corpus 仓库并推送

pushd ../ceph-object-corpus
git checkout -b wip-new
git add archive/`../build/bin/ceph-dencoder version`
git commit -m `../build/bin/ceph-dencoder version`
git remote add cc git@github.com:ceph/ceph-object-corpus.git
git push cc wip-new
popd

第 10 步:去测试它

cd my/regular/tree
cd ceph-object-corpus
git fetch origin
git checkout wip-new
cd ../build
ctest -R readable.sh

收尾:如果一切正常,更新 submodule 的 master 分支,并在 ceph.git 中提交该 submodule。

7.11.5 关键路径小结

环节 路径 / 命令
corpus submodule ceph.git/ceph-object-corpus
一键生成脚本 script/gen-corpus.sh
编码 dump 宏 -DENCODE_DUMP_PATH=/tmp/foo
导入脚本 src/test/encoding/import.sh、src/test/encoding/import-generated.sh
剪枝脚本 ceph-object-corpus/bin/prune-archive.sh
验证测试 ctest -R readable.sh
版本串来源 bin/ceph-dencoder version

7.11.6 注意事项

  • corpus 是 submodule,改动要两个仓库分别提交。
  • 第 4 步「尽可能多地使用功能」直接决定了兼容性测试的覆盖面——用得少,样本就少。
  • 官方明确「通常新代码无法解码旧对象」(forward_incompat 是常态),这与 encoding.rst 里 n-2 / n-3 的兼容窗口是两回事:corpus 存档是长期保留,向前不兼容是被显式标记的。
  • 该页未说明 readable.sh 测试的具体判定逻辑 —— 文档未明确。

引自:Corpus structure


7.12 blkin.rst:用 LTTng / Blkin 追踪 Ceph

官方该页共 225 行,实际包含两个主题:上半部是 LTTng 追踪(现代、推荐),下半部是 Blkin(已废弃)。

⚠️ 官方废弃声明(原文):.. deprecated:: This feature was deprecated in the Squid release and will be removed in a later release. —— Blkin 在 Squid 版本被废弃,将在后续版本移除。本页保留 LTTng 部分仍有价值。

7.12.1 第一部分:用 LTTng 追踪 Ceph

编译配置

若自己编译代码,请使用 -DWITH_LTTNG 选项(默认 ON):

./do_cmake -DWITH_LTTNG=ON

包依赖(重要)

如果你的 Ceph 部署是基于包(YUM / DNF / APT)而非容器化,需要根据要追踪的模块安装相应软件包,否则可能因为缺少 *.tp.so library 文件而导致 coredump:

librbd-devel
librgw-devel
librados-devel

配置项(必须在 ceph.conf 中设为 true)

官方列出的当前可用选项:

配置项 备注
bluestore_tracing —
event_tracing 需要 -DWITH_EVENTTRACE
osd_function_tracing 需要 -DWITH_OSD_INSTRUMENT_FUNCTIONS
osd_objectstore_tracing 官方注明:actually filestore tracing(实为 filestore 追踪)
rbd_tracing —
osd_tracing —
rados_tracing —
rgw_op_tracing —
rgw_rados_tracing —

测试追踪的完整流程(官方 7 步)

  1. 启动 LTTng 守护进程:
lttng-sessiond --daemonize
  1. 带 trace 选项运行 vstart 集群:
../src/vstart.sh -d -n -l -e -o "osd_tracing = true"
  1. 列出可用 tracepoint:
lttng list --userspace

输出形如:

UST events:
-------------
PID: 100859 - Name: /path/to/ceph-osd
    pg:queue_op (loglevel: TRACE_DEBUG_LINE (13)) (type: tracepoint)
    osd:do_osd_op_post (loglevel: TRACE_DEBUG_LINE (13)) (type: tracepoint)
    osd:do_osd_op_pre_unknown (loglevel: TRACE_DEBUG_LINE (13)) (type: tracepoint)
    osd:do_osd_op_pre_copy_from (loglevel: TRACE_DEBUG_LINE (13)) (type: tracepoint)
    osd:do_osd_op_pre_copy_get (loglevel: TRACE_DEBUG_LINE (13)) (type: tracepoint)
    ...
  1. 创建 tracing session、启用 tracepoint、开始追踪:
lttng create trace-test
lttng enable-event --userspace osd:*
lttng start
  1. 执行一些 Ceph 操作:
rados bench -p ec 5 write
  1. 停止追踪并查看结果:
lttng stop
lttng view
  1. 销毁 tracing session:
lttng destroy

7.12.2 第二部分:Blkin(已废弃)

Blkin 是什么(官方叙述)

  • Ceph 可以使用 Blkin —— 一个由 Marios Kogias 等人创建的库,它能够追踪一个特定请求:从它在较高层级进入系统开始,一直到最终被 RADOS 服务完成为止。
  • 总体而言,Blkin 实现了 Dapper 追踪语义,用以展示一个 IO 请求可能触发的不同处理阶段之间的因果关系(causal relationships)。
  • 目标是端到端可视化该请求在系统中的路径,并附带每个处理阶段的延迟信息。
  • 得益于 LTTng,这可以以极小的开销、实时地(in realtime)完成。
  • LTTng traces 随后可以用 Twitter 的 Zipkin 进行可视化。

(官方外链:Dapper 论文、Zipkin 官网)

编译配置

使用 -DWITH_BLKIN 选项(需要 -DWITH_LTTNG):

./do_cmake -DWITH_LTTNG=ON -DWITH_BLKIN=ON

blkin 的配置项必须在 ceph.conf 中设为 true,当前可用选项:

rbd_blkin_trace_all
osd_blkin_trace_all
osdc_blkin_trace_all

测试 Blkin(官方完整流程)

官方假设:你还没有运行 Ceph,且你编译了带 Blkin 支持的 Ceph 但没有安装它。那么用 Ceph src 目录下的 vstart.sh 脚本启动 Ceph,以便看到可用的 tracepoint:

OSD=3 MON=3 RGW=1 ../src/vstart.sh -n -o "rbd_blkin_trace_all"
lttng list --userspace

你会看到类似:

UST events:
-------------
PID: 8987 - Name: ./ceph-osd
      zipkin:timestamp (loglevel: TRACE_WARNING (4)) (type: tracepoint)
      zipkin:keyval_integer (loglevel: TRACE_WARNING (4)) (type: tracepoint)
      zipkin:keyval_string (loglevel: TRACE_WARNING (4)) (type: tracepoint)
      lttng_ust_tracelog:TRACE_DEBUG (loglevel: TRACE_DEBUG (14)) (type: tracepoint)

PID: 8407 - Name: ./ceph-mon
      zipkin:timestamp (loglevel: TRACE_WARNING (4)) (type: tracepoint)
      zipkin:keyval_integer (loglevel: TRACE_WARNING (4)) (type: tracepoint)
      zipkin:keyval_string (loglevel: TRACE_WARNING (4)) (type: tracepoint)
      lttng_ust_tracelog:TRACE_DEBUG (loglevel: TRACE_DEBUG (14)) (type: tracepoint)

...

接着,停止 Ceph,以便启用 tracepoint:

../src/stop.sh

启动 LTTng session 并启用 tracepoint:

lttng create blkin-test
lttng enable-event --userspace zipkin:timestamp
lttng enable-event --userspace zipkin:keyval_integer
lttng enable-event --userspace zipkin:keyval_string
lttng start

然后再次启动 Ceph:

OSD=3 MON=3 RGW=1 ../src/vstart.sh -n -o "rbd_blkin_trace_all"

可以检查一下 ceph 是否起来了:

ceph status

现在用 rados 放点东西进去、确认它在、再取回来、再删掉:

ceph osd pool create test-blkin
rados put test-object-1 ../src/vstart.sh --pool=test-blkin
rados -p test-blkin ls
ceph osd map test-blkin test-object-1
rados get test-object-1 ./vstart-copy.sh --pool=test-blkin
md5sum vstart*
rados rm test-object-1 --pool=test-blkin

也可以使用 examples/librados/ 下的例子,或 rados bench。

然后停止 LTTng session 看看收集到了什么:

lttng stop
lttng view

输出形如:

[15:33:08.884275486] (+0.000225472) ubuntu zipkin:timestamp: { cpu_id = 53 }, { trace_name = "op", service_name = "Objecter", port_no = 0, ip = "0.0.0.0", trace_id = 5485970765435202833, span_id = 5485970765435202833, parent_span_id = 0, event = "osd op reply" }
[15:33:08.884614135] (+0.000002839) ubuntu zipkin:keyval_integer: { cpu_id = 10 }, { trace_name = "", service_name = "Messenger", port_no = 6805, ip = "0.0.0.0", trace_id = 7381732770245808782, span_id = 7387710183742669839, parent_span_id = 1205040135881905799, key = "tid", val = 2 }
[15:33:08.884616431] (+0.000002296) ubuntu zipkin:keyval_string: { cpu_id = 10 }, { trace_name = "", service_name = "Messenger", port_no = 6805, ip = "0.0.0.0", trace_id = 7381732770245808782, span_id = 7387710183742669839, parent_span_id = 1205040135881905799, key = "entity type", val = "client" }

Blkin trace 事件的字段(从示例归纳)

字段 说明
trace_name 追踪名,如 "op"
service_name 服务名,如 Objecter、Messenger
port_no / ip 该服务实例的端口与 IP
trace_id 追踪 id(同一次请求的所有 span 共享)
span_id 当前 span id
parent_span_id 父 span id(0 表示根 span)
event 事件名(zipkin:timestamp 中),如 "osd op reply"
key / val 键值注解(zipkin:keyval_integer / keyval_string 中),如 tid=2、entity type=client

7.12.3 安装 Zipkin

官方说明:使用 Blkin 的目的之一就是用 Zipkin 查看 traces。用户应当把 Zipkin 作为 tracepoint collector 兼 web service 运行。可执行的 jar 在 9410 端口跑 collector,在 9411 端口跑 web 界面。

下载 Zipkin 包:

git clone https://github.com/openzipkin/zipkin && cd zipkin
wget -O zipkin.jar 'https://search.maven.org/remote_content?g=io.zipkin.java&a=zipkin-server&v=LATEST&c=exec'
java -jar zipkin.jar

或者启动 docker 镜像:

docker run -d -p 9411:9411 openzipkin/Zipkin

7.12.4 在 Zipkin-web 中展示 Ceph 的 Blkin Traces

下载 babeltrace-zipkin 项目(该项目把 blkin 生成的 traces 通过 scribe 发送到 Zipkin collector):

git clone https://github.com/vears91/babeltrace-zipkin
cd babeltrace-zipkin

把 lttng 数据发给 Zipkin:

python3 babeltrace_zipkin.py ${lttng-traces-dir}/${blkin-test}/ust/uid/0/64-bit/ -p ${zipkin-collector-port(9410 by default)} -s ${zipkin-collector-ip}

示例:

python3 babeltrace_zipkin.py ~/lttng-traces-dir/blkin-test-20150225-160222/ust/uid/0/64-bit/ -p 9410 -s 127.0.0.1

在网页上查看 Ceph traces:

Browse http://${zipkin-collector-ip}:9411
Click "Find traces"

7.12.5 注意事项

  • Blkin 已在 Squid 版本废弃,将移除;新工作应直接用 LTTng tracepoint。
  • 包化部署必须装 -devel 包,否则缺 *.tp.so 会导致 coredump —— 这是最坑的点。
  • osd_objectstore_tracing 官方注明实际上是 filestore 追踪(filestore 早已移除),该选项很可能是历史遗留。
  • 追踪要先停 Ceph 再 enable tracepoint 再启动,否则新启动的守护进程不会被 session 捕获。

引自:Tracing Ceph With LTTng / Tracing Ceph With Blkin


八、其他

本章收录 3 个不属于前面任何主题、但属于「内部实现」范畴的页面:versions.rst(版本编码规则)、osd-class-path.rst(对象类扩展机制与一个典型故障)、context.rst(CephContext)。

8.1 versions.rst:Public OSD Version(两个版本号的语义)

官方该页共 42 行,标题 "Public OSD Version"。它解释 OSD 在磁盘上维护的两个版本号——pg_log.head(PG 版本)与 info.user_version(用户版本)——以及它们的更新规则。这是理解「对象版本 / 重放 / EC 与缓存池」的关键一页。

8.1.1 设计动机:为什么要两个版本号

官方开篇:我们在磁盘上维护两个版本:

版本 类型/位置 官方说明
PG 版本 eversion_t pg_log.head PG 层面的版本
用户版本 version_t info.user_version 用户可见版本

每个对象都被打上「它最后一次被修改时的 pg version 和 user_version」两个标记。

拆成两个的动机,官方在后面给出:这套更新语义使得

  • 对传统池:user_version 将等于过去的 reassert_version;
  • 对缓存池:对象和 PG 的 user-version 能够跨池而不把事情搞得一团糟(without making a total mess of things)。

也就是说:PG 版本服务于内部一致性(日志、peering、重放),user_version 服务于对外可见的语义(可跨池、可跨 tier)。

8.1.2 PG version 的维护方式

官方原文:

The PG version is modified by manipulating OpContext::at_version and then persisting it to the pg log as transactions, and is incremented in all the places it used to be.

要点:

项 说明
操作入口 操纵 OpContext::at_version
持久化方式 通过 pg log 的事务(transactions) 持久化
递增时机 在它过去被递增的所有地方继续递增

8.1.3 user_version 的维护方式(官方三条更新规则)

修改入口:操纵新的 OpContext::user_at_version,同样通过 pg log 的事务持久化。

触发条件(关键):user_at_version 只在 PrimaryLogPG::prepare_transaction 中被修改,且仅当该 op 是一次 "user modify" 时 —— 官方定义为一个非 watch 的写(a non-watch write)。

更新规则(官方原文编号 1/2/3):

1) set user_at_version to the maximum of
     ctx->new_obs.oi.user_version + 1
   and
     info.last_user_version + 1.

2) set user_at_version to the maximum of
     itself
   and
     ctx->at_version.version.

3) ctx->new_obs.oi.user_version = ctx->user_at_version
   (to change the object's user_version)

逐条解读:

规则 作用
规则 1 取「对象自身 user_version + 1」与「PG 记录的 `info.last_user_version + 1」的较大者 —— 保证新版本严格大于两者
规则 2 再取「规则 1 的结果」与「当前 ctx->at_version.version(PG 版本)」的较大者 —— 把 user_version 顶到不低于 PG 版本
规则 3 把结果写回 ctx->new_obs.oi.user_version,即真正改变对象的 user_version

8.1.4 兼容旧客户端:bad_replay_version

为了支持老客户端,官方的做法:

  • 保留旧的 reassert_version,但重命名为 bad_replay_version;
  • 填充方式照旧(we fill it in as before):
场景 bad_replay_version 的取值
写操作(writes) 设为 at_version(这就是正确的 replay version)
watch 设为我们的 user version
ENOENT 回复 设为 replay version 的 epoch,但用 user_version 的 version
  • 另外一个改动:现在在读操作上也填充 bad_replay_version 的 version_t 部分(此前只在写操作上填),官方认为对所有老客户端这都没问题。

8.1.5 面向新客户端:user_version 与 replay_version

对新客户端,官方阻止它们读取 bad_replay_version,并新增两个正式的成员:

成员 填充时机
user_version 在每一个操作上都填充(读操作也包括)
replay_version 只在写操作上填充

8.1.6 objclass 接口的变化

官方说明:objclass 函数 get_current_version() 现在总是返回 pg->info.last_user_version。

其含义(官方解释):这意味着它保证包含该 PG 中最后一次用户更新的版本(包括在读操作时!)。

这一点很关键:以前 get_current_version() 的语义与读/写上下文有关,现在统一为 PG 级的 last_user_version,读操作也能拿到「最后一次用户更新」的版本。

8.1.7 注意事项

  • user_at_version 只对非 watch 的写更新 —— watch/unwatch 操作不推进 user_version。
  • bad_replay_version 是兼容字段,新客户端不应读取它。
  • 该页描述的是 PrimaryLogPG 路径(PrimaryLogPG::prepare_transaction);EC 池走的是 ECBackend / ECPGMeta 之类路径,该页未覆盖 EC 侧的 user_version 维护 —— 文档未明确。
  • 该页未给出 eversion_t / version_t 的位域结构定义(epoch + version 的拼接方式)——文档未明确。

引自:Public OSD Version


8.2 osd-class-path.rst:OSD class 加载路径问题

官方该页共 28 行,结构是「一段报错 → 定位方法 → 原因 → 解法 → 指向 objclass SDK」。它既是一个排障手册,也是RADOS 对象类(objclass)扩展机制的入门。

8.2.1 现象:rbd create 报 (95) Operation not supported

官方给出的完整报错:

$ rbd create rbd/test --size 100M
2021-03-16 01:26:59.012 7fe41426f080 -1 librbd::PoolMetadata: list: failed listing metadata: (95) Operation not supported
2021-03-16 01:26:59.012 7fe41426f080 -1 librbd::Config: apply_pool_overrides: failed to read pool config overrides: (95) Operation not supported
2021-03-16 01:26:59.012 7fe400ff9640 -1 librbd::image::CreateRequest: 0x55d62341bb30 handle_add_image_to_directory: error adding image to directory: (95) Operation not supported
rbd: create error: (95) Operation not supported

三条 -1 级错误分别来自:

组件 失败动作
librbd::PoolMetadata list:列举元数据失败
librbd::Config apply_pool_overrides:读取池级配置覆盖失败
librbd::image::CreateRequest handle_add_image_to_directory:把 image 加入目录失败

8.2.2 定位方法

官方给出两步定位法:

  1. 加上 --debug-ms=1 → 就能看出联系的是哪个 OSD。
  2. 在被联系的那个 OSD 的日志里,你会找到为什么会发生这件事的原因;而原因通常就是:"rados classes" 无法在运行时用 dlopen 加载。

8.2.3 根因(OSD 侧日志)

2021-03-16 01:26:59.013 7f6c6dff3640 10 _load_class rbd from /usr/local/lib64/rados-classes/libcls_rbd.so
2021-03-16 01:26:59.013 7f6c6dff3640  0 _load_class could not stat class /usr/local/lib64/rados-classes/libcls_rbd.so: (2) No such file or directory
2021-03-16 01:26:59.013 7f6c6dff3640 -1 osd.3 112 class rbd open got (2) No such file or directory
2021-03-16 01:26:59.013 7f6c6dff3640  1 -- [...] --> [...] -- osd_op_reply(5 rbd_directory [call rbd.dir_add_image] v0'0 uv0 ondisk = -95 ((95) Operation not supported)) v8 -- 0x7f6c6800fed0 con 0x7f6cb80100c0

从日志可以读出完整的因果链:

行 信息
第 1 行 _load_class rbd from /usr/local/lib64/rados-classes/libcls_rbd.so —— OSD 尝试从该路径加载名为 rbd 的 class
第 2 行 could not stat class ...: (2) No such file or directory —— 文件不存在
第 3 行 osd.3 112 class rbd open got (2) No such file or directory —— 打开失败
第 4 行 osd_op_reply(... [call rbd.dir_add_image] ... ondisk = -95 ((95) Operation not supported)) —— 失败被转换成 -95 返回给客户端

即:OSD 找不到 libcls_rbd.so,于是 [call rbd.dir_add_image] 这个 objclass 方法调用无法执行,最终以 ENOTSUP (95) 回给客户端。

8.2.4 解法

可以在 ceph.conf 中用 osd_class_dir 自定义这些模块的加载路径。

  • 默认值:$libdir/rados-classes
  • 官方提示:因此在开发时,你很可能不得不调整这个路径(因为开发树的 .so 通常在 build 目录里,而不是安装到 $libdir)。

8.2.5 机制说明与延伸

官方最后一句:这些 class 库用于扩展 RADOS(These class libraries are used for extending RADOS),并指向 :ref:rados-objclass-api-sdk``。

💡 补充:本页对 objclass 机制本身只有这一句——它没有讲 cls_method_call_t、没有讲 CLS_MODULE 注册宏、没有讲 class 的版本管理或 cls_hello 之类的接口。完整的 SDK 说明在官方 rados/api/objclass-sdk 页面,不在本册输入文件范围内。

8.2.6 注意事项

  • 报错发生在客户端但根因在 OSD 侧日志,必须 --debug-ms=1 定位到具体 OSD 才能看到。
  • 这是开发环境(源码编译未安装)的典型问题;生产包安装一般不会有。
  • 只要任何 libcls_*.so 缺失,依赖它的高层功能(rbd、rgw、cephfs 的部分元数据操作)都会以 (95) Operation not supported 的形式失败——错误信息本身极具误导性,不会提示「找不到 .so」。

引自:OSD class path issues


8.3 context.rst:CephContext

官方该页共 20 行,是全套 dev/ 文档中最短的页面之一(与 delayed-delete.rst 13 行同量级)。它只讲一件事:CephContext 是什么、有几个、以及它对日志宏的约束。

8.3.1 CephContext 是什么(官方定义)

官方定义:一个 CephContext 代表对 Ceph 集群的单一视图(a single view of the Ceph cluster)。

它完整地带有:

组成部分 官方列举
配置 a configuration
性能计数器 a set of performance counters(PerfCounters)
心跳图 a heartbeat map

更多信息见 src/common/ceph_context.h。

8.3.2 通常有几个 CephContext

场景 数量 官方说明
一般应用 1 个 名为 g_ceph_context
库代码 可能多个 如果库的使用者调用了 rados_create 多于一次,就会初始化多个 CephContext

8.3.3 为什么发日志必须要 CephContext

官方自问自答:

「为什么需要 ceph context 才能发出日志消息?」——「因为没有 CephContext,我们就不知道哪些日志消息被禁用、哪些被启用。」

由此推出的编码约束(重要):

场景 可用的日志宏 原因
守护进程(daemons) 可以用 dout 和 derr —
库代码(library code) 必须用 ldout 和 lderr,并传入你自己的 CephContext 对象 dout() 宏隐式引用 g_ceph_context,因此它不能用在库代码里

官方强调:编译器会强制这个限制(The compiler will enforce this restriction)。

8.3.4 注意事项

  • 在 librados / librbd / libcephfs 等库代码中写 dout 会编译不过——这是有意设计,不是 bug。
  • 一个进程里多个 CephContext 意味着多套配置、多套 PerfCounters、多套 heartbeat map,它们的日志开关各自独立。
  • 该页未说明 CephContext 的生命周期管理(引用计数 / 何时析构),也未说明 g_conf 与 CephContext->_conf 的关系细节(后者在 config.rst 中提到:g_conf 等价于 g_ceph_ctx->_conf)—— 本页范围内文档未明确。

引自:CephContext


文档未明确项

本节汇总本册在撰写过程中遇到的、官方源文没有给出明确说明的点。按章节列出,便于后续补档或对照源码核实。凡属本人补充的内容,正文中均已用 > 💡 补充: 标出。

全局 / 结构性

# 未明确项 所在页 说明
1 「内部实现篇」拆成 4 册的划分依据 — 官方 dev/ 目录并无此 4 分册结构(官方入口是 dev/internals.rst)。此为撰稿/编辑侧的划分,已在「本册导读」中标注
2 object-store.rst 只 62 行且含 .. todo:: write more here object-store 官方自认该页未完成,ObjectStore 的方法语义、事务模型、各实现差异均未写
3 messenger.rst 只 33 行 messenger 除 ceph_perf_msgr 的 server/client 参数外,Messenger 的架构(Policy、Connection、DispatchQueue 等)该页完全未覆盖
4 delayed-delete.rst 只 13 行 delayed-delete 延迟删除的具体实现(PurgeQueue、stray 目录、触发时机、速率控制)全部未写
5 context.rst 只 20 行 context CephContext 的生命周期、引用计数、与 g_conf 的关系细节未写

一、对象存储抽象与实现

# 未明确项 说明
6 BlueStore 的 7 种小写策略对应的具体代码选择逻辑 bluestore.rst 给了策略表(U/P/W/N/R+W/C + 未来的 F/X)与 7×5 映射矩阵,但未说明OSD 运行时依据什么条件(大小阈值、配置项)落到哪个策略
7 SeaStore 的 GC 算法细节 seastore.rst 的 GC 小节带 TODO,官方自认未写完
8 Zoned Storage 场景下 BlueStore 与 SeaStore 的选择建议 zoned-storage.rst 给了 vstart 与 benchmark,但未给生产选型建议
9 ZonedFreelistManager 与 BitmapFreelistManager 的完整状态机 官方只对比了 3-state 与 2-state 的差异

二、PG 与 peering

# 未明确项 说明
10 peering 状态机的完整状态转移图 peering.rst 中该图是 SVG 图片,无法从 rst 源文提取;官方只给出 gen_state_diagram.py 的再生成方法
11 28 个 PG 状态之间的互斥/共存关系 placement-group.rst 列表格给出各状态含义,但未给状态间的转移与组合规则
12 omap 统计项的计算口径 官方只说明有 omap 统计,未给公式与更新时机

三、Messenger 与网络

# 未明确项 说明
13 msgr2 各 TAG 的完整取值表 msgr2.rst 覆盖了大量 TAG_*,但未给一份完整的 tag → 值 → 方向对照表
14 压缩(compression)在 secure 模式下的确切限制 官方只给了 caveat,未展开
15 DPDK 的性能收益量化数据 dpdk.rst 给了配置方法,未给基准测试结果
16 encoding.rst 中 n-2 / n-3 兼容规则的具体判定代码 官方给了规则与宏,未给判定函数实现

四、协议与认证

# 未明确项 说明
17 cephx_protocol.rst(2012 年快照)中引用的代码路径是否已漂移 该页是历史文档,其中的文件名/函数名可能与 Tentacle 现状不一致,官方未做同步标注
18 ceph_krb_auth.rst(1096 行)中大量 LDAP/Kerberos 运维配置细节 该页以背景知识与部署步骤为主,与 Ceph 内部实现的对照关系未系统说明
19 cephx_sign_messages 默认 false 的安全影响评估 session_authentication.rst 只说明默认关闭,未给开启后的性能代价或兼容性影响
20 backoff 机制中 OSD 侧三状态(new/acked/deleting)的超时与清理策略 rados-client-protocol.rst 只描述状态,未给超时值

五、集群映射与元数据(mon 内部)

# 未明确项 说明
21 各 PaxosService 的 format_version 完整演进表 mon-on-disk-formats.rst 只以 AuthMonitor(0~3)为例,其它 service 未列
22 mon 选举 connectivity 算法中连接评分的具体衰减公式常数来源 官方给了公式与默认值,未给调优建议
23 mon-osdmap-prune.rst 中 pinned maps 的pin/unpin 触发点全集 官方给了算法与不变量,未给所有调用点

六、数据路径与特性

# 未明确项 说明
24 file-striping.rst 的布局换算公式 官方只有文字描述 + 一个数值例子,没有给伪代码或公式
25 已有文件 layout 变更后的数据重排语义 官方未讨论
26 erasure-coded-pool.rst 的编解码实现 该页是纯使用说明,无 ErasureCodeInterface / ECBackend 内容;插件全列表(jerasure/clay/shec 等)也未给
27 deduplication.rst 中 ref 条目 max 字段的语义 示例中恒为 0,官方未解释
28 deduplication.rst 中 sample-dedup --snap 的具体行为 官方只说「需要指定」,未展开
29 chunk pool 故障对 base pool 对象的影响与恢复手段 官方未讨论(架构上显然存在强依赖)
30 CRUSH MSR 中 CRUSH_RULE_SET_CHOOSE_MSR* 允许的具体 set 步骤清单 crush-msr.rst 只给了通配写法
31 crush_msr_choose 的伪代码 / 完整算法 官方指向源码注释,页面本身未给
32 msr_firstn 与 msr_indep 在失败重试语义上的完整差异 官方未展开

七、配置与可观测性内部

# 未明确项 说明
33 health-reports.rst 中的交互示意图 源文只有引导语 "The following diagrams outline...",图为外链图片,rst 文本中无内容,无法复现
34 health check 的注册机制(health_check_t、编码格式、detail 合并规则) 官方未写
35 ceph health detail 与 health mon 命令的差异 官方未写
36 config-key 的读写 API、事务语义、大小限制、权限模型 config-key.rst 只记录键布局 schema
37 PerfCountersBuilder 的完整 API 列表 logs.rst 提到 add_u64/add_fl/add_fl_avg,perf_counters.rst 未补全
38 labeled perf counter 的 label 基数爆炸风险处理 官方未讨论
39 perf histogram 中「9 个 history entry」的滚动周期 / 触发条件 官方只说有 9 个,未说何时滚动。且示例 JSON 中 values 只展示了 8 个子数组,与文字说明不一致(疑为示例截断)
40 如何在代码中新增一个 perf histogram 无 API 说明
41 mempool 的新增方法与非 BlueStore 子系统的覆盖情况 mempool_accounting.rst 只给清单 + 指向 src/include/mempool.h
42 dump_mempools 的输出格式细节 官方只给命令
43 cpu-profiler.rst 中 OProfile 的实际采集/分析命令 一律指向 troubleshooting 文档;且该页用 do-cmake.sh,与 Tentacle 常用 do_cmake.sh 的差异未说明
44 corpus.rst 中 readable.sh 测试的判定逻辑 官方只说 ctest -R readable.sh
45 osd_objectstore_tracing 的现状 官方注明 "actually filestore tracing",而 filestore 已移除,该选项是否为历史遗留未说明
46 blkin.rst 中 Blkin 的替代方案(除直接用 LTTng 外) 官方只声明废弃,未给迁移路径

八、其他

# 未明确项 说明
47 eversion_t / version_t 的位域结构定义 versions.rst 未给(epoch 与 version 的拼接方式)
48 EC 池路径下 user_version 的维护方式 versions.rst 只覆盖 PrimaryLogPG::prepare_transaction
49 objclass 的完整 SDK(注册宏、方法签名、版本管理) osd-class-path.rst 只一句带过,指向 rados-objclass-api-sdk(不在本册输入范围)
50 osd-class-path.rst 中 .so 缺失时为何统一报 95 而非 ENOENT 官方未解释该错误码转换的动因

附:本册覆盖的源文清单

章节 源文文件 官方页标题
一 object-store.rst Object Store
一 bluestore.rst BlueStore
一 seastore.rst SeaStore
一 zoned-storage.rst Zoned Storage
二 placement-group.rst Placement Group
二 peering.rst Peering
三 messenger.rst Messenger
三 msgr2.rst msgr2 protocol
三 network-protocol.rst Network Protocol
三 network-encoding.rst Network Encoding
三 encoding.rst Encoding
三 dpdk.rst DPDK
四 rados-client-protocol.rst RADOS client protocol
四 cephx.rst Cephx
四 cephx_protocol.rst Cephx protocol (2012)
四 ceph_krb_auth.rst Kerberos auth
四 session_authentication.rst Session authentication
五 mon-on-disk-formats.rst Monitor on-disk formats
五 mon-bootstrap.rst Monitor bootstrap
五 mon-elections.rst Monitor elections
五 mon-osdmap-prune.rst Full osdmap version pruning
六 file-striping.rst File striping
六 erasure-coded-pool.rst Erasure Coded pool
六 deduplication.rst Deduplication
六 delayed-delete.rst CephFS delayed deletion
六 crush-msr.rst CRUSH MSR
七 config.rst Configuration Management System
七 config-key.rst config-key layout
七 logging.rst Use of the cluster log
七 logs.rst Debug logs
七 health-reports.rst Health Reports
七 perf.rst Using perf
七 perf_counters.rst Perf counters
七 perf_histograms.rst Perf histograms
七 mempool_accounting.rst What is a mempool?
七 cpu-profiler.rst Installing Oprofile
七 corpus.rst Corpus structure
七 blkin.rst Tracing Ceph With LTTng / Blkin
八 versions.rst Public OSD Version
八 osd-class-path.rst OSD class path issues
八 context.rst CephContext

说明:上表 41 项中,mon-on-disk-formats.rst、mon-bootstrap.rst、mon-elections.rst、mon-osdmap-prune.rst 这 4 个文件不在本册指定的 37 个输入文件列表内,但存在于 raw/dev/ 目录中。依据撰稿要求「若文件存在则讲」,已作为第五章写入本册,此处一并登记。