太长不看。
rdc repo diff以git status --short语法(A/M/D/R)展示两个 fork 仓库之间的文件级差异,且不会解密任何一个。
- 它在块级别使用 FIEMAP ioctl 对比两个 LUKS 镜像文件,该调用只读取 extent 映射元数据,不加载任何密钥,不读取任何明文。
- aes-xts 是长度保持加密,并对每个 512 字节扇区独立加密。因此,变更的明文扇区就是同一偏移量处变更的密文扇区(偏移量由 16 MiB 的 LUKS 数据偏移量调整)。减去该偏移量,通过 ext4 extent 映射将设备范围映射到文件名,就得到了文件列表。
- 成本取决于变更的块数,而非仓库大小。1 GB 的 fork 和 100 GB 的 fork 对比所需时间相同,因为对比只涉及元数据。
说来也简单,在 Rediacc 中,fork 就是对仓库 LUKS 镜像执行 cp --reflink=always。即时完成,不在乎大小。100 GB 的仓库 fork 速度和 1 GB 的仓库一样快。我知道这听起来像营销话术,但这就是 reflink 的工作方式:btrfs 复制 extent 映射并共享底层数据块。我们充分利用了这一特性。fork 是测试沙箱,是用完即弃的分支,是用完就扔的预发布副本。
我们没有的是对这个显而易见的下一个问题的低成本答案:这个 fork 究竟改变了什么。朴素的路线:挂载 fork,解锁 LUKS 容器,遍历内层 ext4,将每个文件与父仓库进行哈希比较。这个方案的读取和解密成本随仓库大小而增长,需要密钥在对比路径上就位,而且丢弃了存储层本来就免费知道的那一件事:哪些块发生了分歧。rdc repo diff 选择了另一条路线。它随变更的块数而增长,不加载任何密钥,通过对比两个加密镜像来获得文件列表。
你正在对比的层次结构
让我精确说明”两个仓库”在磁盘上意味着什么。整个技巧都依赖于此。从底层往上:一块 SSD,宿主机存储,一个 btrfs 存储池。在此之上,每个仓库一个 LUKS2 镜像文件。解锁它就得到一个 dm-crypt 设备。里面住着容器使用的 ext4 文件系统。一个仓库就是 btrfs 存储池上的一个文件。
fork 是该文件的 reflink。fork 之后,两个镜像文件逐字节相同,共享所有物理块。父仓库和 fork 不是数据的两份副本,而是指向同一批数据块的两个 extent 映射。当你在 fork 内写入时,存储层为变更的区域分配一个新块,只有该 fork 的 extent 映射被重写,父仓库的块保持不变。
因此,“对比两个仓库”就简化为”对比两个共享大部分 extent 的文件”。内核已经能够回答这个问题,没有人需要读取任何一个文件的任何一个字节。
FIEMAP:在不读取内容的情况下向内核询问变更
FIEMAP ioctl 返回文件的 extent 映射:一个(逻辑偏移,物理偏移,长度)元组的列表。每个元组说明文件的某个部分位于磁盘的哪个位置。这是纯粹的文件系统元数据,不读取文件数据。对于加密镜像,它不需要任何密钥,密文只是内核永远不需要解读的字节。
对比两个 extent 映射。两个 fork 指向同一物理块的任何逻辑范围都是共享的,共享意味着相同,因为设备上那就是同一个块。fork 拥有自己私有块的范围就是写入的地方,也就是变更的块。这些信息来自存储层本来就维护的元数据。
成本故事由此而来。FIEMAP 对比读取的是 extent 记录,而非数据。其工作量随变更的 extent 数量而增长,而非随仓库大小增长。1 GB 的 fork 和 100 GB 的 fork 返回同样短的私有 extent 列表,如果它们改变了相同的文件,花费的毫秒数也相同。诚实的警告:extent 遍历时间随镜像碎片化程度而增长,而非随大小增长。在我测量过的碎片化最严重的生产镜像上,完整的 filefrag 遍历耗时 3.19 秒。详见碎片化基准测试文章。这是元数据侧的上限,是后台扫描,不是数据读取。
从变更的块到文件名:穿越两层加密
加密镜像中变更字节范围的列表还没有用处。这些范围是密文中的位置,你需要的名称在两层之上的内层 ext4 中。连接它们的是地址运算,而非解密。
LUKS 使用 aes-xts 加密。它是长度保持的,并对每个 512 字节扇区独立加密。变更的明文扇区在同一偏移量处产生变更的密文扇区,唯一的位移是 LUKS 数据偏移量,即加密负载前面 16 MiB 的头部和密钥槽。从每个变更的镜像范围中减去该偏移量,就得到了 dm-crypt 设备上对应的范围,也就是内层 ext4 所在的块设备。没有使用任何密钥,只是减法。
现在将设备范围映射到文件。ext4 每个 inode 也有一个 extent 映射,结构相同(逻辑、物理、长度)。通过在挂载的内层文件系统上执行 FIEMAP 来访问它。遍历一次 inode 以构建块到文件的索引,然后在该索引中查找每个变更的设备范围。与 inode 1234 的数据 extent 重叠的范围属于该 inode 的路径,该路径就是发生变更的文件。
让我直白说明这个过程永远不会做什么。它不会从变更的镜像中推导出明文。它在已知偏移量处读取文件系统结构,分别在加密侧和解密侧执行,然后通过地址将两者连接。块过滤器说明哪些设备区域发生了移动,ext4 extent 映射说明每个区域属于哪个文件,两个步骤都不检查变更块的内容来判断其是否发生了变更。
新增、删除和重命名:inode 身份遍历
修改可以直接从块对比中得出。新增、删除和重命名需要多一个观察。reflink 为我们免费提供了这个观察:fork 保留 inode 编号。对整个镜像进行 reflink 克隆会在任何内容分歧之前逐字节克隆整个内层文件系统。因此,父仓库中存在的 inode 在 fork 中具有相同的编号。
这使得身份成为一个集合比较。两侧都有但路径不同的 inode 是重命名;只在新侧有的 inode 是新增;只在旧侧有的 inode 是删除。重命名通过设备 extent 重叠来确认:重命名文件的数据块在两个 fork 中位于相同的设备偏移量,两个 fork 共享同一坐标系,这种重叠也排除了 inode 编号被重用于无关数据的可能。纯重命名只有目录条目移动,文件的数据块保持不变。
这是默认的名称状态形式,与你从 git status --short 读到的 A/M/D/R 语法相同:
$ rdc repo diff --name test-1gb:fork1 -m hostinger
M hello.txt
1 file changed: 0 added, 1 modified, 0 deleted, 0 renamed
一个 1 GB 仓库中的一个修改文件。通过不读取任何文件数据的块对比报告出来,没有任何内容被解锁。
默认模式还做了一件为正确性考虑的额外工作。块过滤器是一个超集,btrfs extent 可以覆盖比实际变更字节更多的范围,因此对一个文件的写入可能会标记共享 extent 的邻居。为了避免报告未实际变更的文件,默认模式会确认每个被块标记的候选文件,只对两侧的该文件进行哈希,对候选文件而非整个仓库进行哈希,因此确认成本仍然随变更集增长。--fast 信任块过滤器并跳过确认,当你想要快速答案且可以接受偶尔误报时使用。
为何 AI 智能体需要这个功能
这个命令存在的全部原因是智能体工作流。我一直看到智能体 fork 生产环境,跑完变更,然后却没有干净的方式报告它们实际触碰了什么。AI 智能体可以即时 fork 生产环境,在隔离的 fork 内运行高风险变更,然后在将任何内容推广回去之前需要准确了解它触碰了什么。fork 是分支,diff 是审查。
智能体不读取名称状态,它读取 --json:
$ rdc repo diff --name prod:experiment --json -m hostinger
结构化输出为智能体提供了精确的变更集:它修改、创建、删除了哪些路径。通过 --stat 还能获得每个文件的字节和块级别的变更大小。一个在推广之前能看到自己 diff 的智能体,才是你可以让其接近生产环境的智能体。爆炸半径是可检查的,而非仅凭断言。其他模式服务于同样的审查循环:--name-only 用于纯路径列表;--content <path> 用于单个文件的统一文本 diff(仅文本,二进制文件报告 Binary files differ);--stat 用于智能体需要了解变更内容和变更量的场景。
为何灾难恢复测试需要这个功能
同样的基础能力回答了一个过去不冒风险就很难问出的灾难恢复问题。fork 生产环境,将备份恢复进 fork,对 fork 与生产进行 diff。diff 会告诉你恢复是否重现了你期望的文件集,无需停机生产,且不会在对比路径上解密任何内容。
这是一个可以定期运行的演练。恢复落在隔离的 fork 中,diff 以 git 语法报告增量。一次干净的演练:变更集与备份应该包含的内容一致。你正在对照实时生产验证恢复能力。创建这份副本和丢弃它都没有任何成本。
诚实的限制
内容 diff 仅限文本。--content 为文本文件生成统一 diff,对其他所有内容报告 Binary files differ,与 git 的方式相同。对加密后再压缩的 blob 进行面向行的 diff 毫无意义。
它对比的是相关的 fork,而非任意仓库。整个机制建立在共享坐标系之上:共享 extent 证明相等,保留的 inode 编号锚定身份,公共数据偏移量将其联系在一起。两个从未从共同祖先 fork 的仓库不共享这些,它们之间没有低成本的 diff。这是特性,而非缺陷,就像 git diff 在两个不相关的历史之间没有意义一样。
重命名检测基于 inode,对于文件系统实际记录为重命名的操作是精确的。相同内容的删除后在新名称下创建?这是对 inode 表的两次操作,因此报告为一次删除和一次新增,而非重命名。git 的内容相似性启发式会称之为重命名,inode 遍历不会,这是关于文件系统做了什么的正确答案,即使它不是关于人类意图的答案。
元数据遍历的成本随碎片化程度而增长。在严重碎片化的镜像上,extent 枚举需要秒级而非毫秒级时间。它仍然与仓库大小无关,仍然不涉及任何数据读取,但在碎片化最严重的镜像上并非字面意义上的即时。
结语
rdc repo diff 为加密的运行中基础设施带来了版本控制的人体工程学。接口刻意设计成 git 风格:A/M/D/R、统一 diff、--stat。没有什么新东西需要学习。如果你能读懂 git status --short,你就能读懂两个 LUKS 镜像之间的 diff。底层工程才是值得关注的部分,它归结为两个拒绝。永不解密:aes-xts 让块级 FIEMAP 对比通过地址定位每个变更的扇区。永不为未变更的数据付出代价:存储层已经记录了哪些块发生了分歧。fork 是分支,diff 是审查,审查的成本与变更的成本相当,而非与仓库的重量相当。