3. XFS 自描述元数据

3.1. 简介

XFS 面临的最大可扩展性问题不是算法的可扩展性,而是文件系统结构的验证。磁盘上结构和索引的可扩展性以及迭代它们的算法足以支持具有数十亿个 inode 的 PB 级文件系统,但正是这种可扩展性导致了验证问题。

XFS 上的几乎所有元数据都是动态分配的。唯一固定位置的元数据是分配组头(SB、AGF、AGFL 和 AGI),而所有其他元数据结构都需要通过以不同方式遍历文件系统结构来发现。虽然用户空间工具已经这样做了来验证和修复结构,但它们可以验证的内容是有限的,这反过来限制了 XFS 文件系统可支持的大小。

例如,当试图确定损坏问题的根本原因时,完全可以使用 xfs_db 和一些脚本手动分析 100TB 文件系统的结构,但这仍然主要是一项手动任务,验证诸如单比特错误或错位写入是否不是导致损坏事件的最终原因。执行此类取证分析可能需要几个小时到几天的时间,因此在这个规模下,根本原因分析是完全可能的。

但是,如果我们将文件系统扩展到 1PB,我们现在有 10 倍的元数据需要分析,因此分析工作将扩展到数周/数月的取证工作。大多数分析工作都很缓慢且乏味,因此随着分析量的增加,原因更有可能在噪声中丢失。因此,支持 PB 级文件系统的主要关注点是最大限度地减少对文件系统结构进行基本取证分析所需的时间和精力。

3.2. 自描述元数据

当前元数据格式的问题之一是,除了元数据块中的魔数之外,我们没有其他方法来识别它应该是什么。我们甚至无法识别它是否在正确的位置。简而言之,您无法孤立地查看单个元数据块并说“是的,它应该在那里,并且内容是有效的”。

因此,大部分时间都花在了对元数据值进行基本验证上,寻找在范围内(因此未被自动验证检查检测到)但不正确的值。查找和理解诸如交叉链接块列表(例如,btree 中的兄弟指针最终形成循环)之类的事情是如何发生的,是理解哪里出错的关键,但是无法判断块被链接在一起的顺序或事后写入磁盘的顺序。

因此,我们需要在元数据中记录更多信息,以便我们能够快速确定元数据是否完好无损,并且可以忽略不计以进行分析。我们无法防范每一种可能的错误类型,但我们可以确保可以轻松检测到常见的错误类型。因此,提出了自描述元数据的概念。

自描述元数据的第一个基本要求是,元数据对象在众所周知的位置包含某种形式的唯一标识符。这允许我们识别块的预期内容,从而解析和验证元数据对象。如果我们无法独立识别对象中元数据的类型,那么该元数据根本无法很好地描述自己!

幸运的是,几乎所有的 XFS 元数据都已嵌入魔数 - 只有 AGFL、远程符号链接和远程属性块不包含标识魔数。因此,我们可以更改所有这些对象的磁盘格式,以添加更多标识信息,并通过简单地更改元数据对象中的魔数来检测它。也就是说,如果它具有当前的魔数,则元数据不是自识别的。如果它包含新的魔数,则它是自识别的,我们可以在运行时、取证分析或修复期间对元数据对象进行更广泛的自动验证。

作为主要关注点,自描述元数据需要某种形式的整体完整性检查。如果我们无法验证元数据是否由于外部影响而发生更改,我们就无法信任元数据。因此,我们需要某种形式的完整性检查,这通过将 CRC32c 验证添加到元数据块来完成。如果我们能够验证该块包含它原本打算包含的元数据,则可以跳过大量的 手动验证工作。

选择 CRC32c 是因为 XFS 中元数据的长度不能超过 64k,因此 32 位 CRC 足以检测元数据块中的多位错误。CRC32c 现在也在常见的 CPU 上进行了硬件加速,因此速度很快。因此,虽然 CRC32c 不是可以使用的最强大的完整性检查,但它足以满足我们的需求并且开销相对较小。添加对更大的完整性字段和/或算法的支持实际上并没有提供任何超出 CRC32c 的额外价值,但它确实增加了大量的复杂性,因此没有提供更改完整性检查机制的规定。

自描述元数据需要包含足够的信息,以便可以在不需要查看任何其他元数据的情况下验证元数据块是否在正确的位置。这意味着它需要包含位置信息。仅将块号添加到元数据不足以防止误导写入 - 写入可能被误导到错误的 LUN,因此被写入错误文件系统的“正确块”。因此,位置信息必须包含文件系统标识符以及块号。

取证分析中的另一个关键信息点是了解元数据块属于谁。我们已经知道类型、位置、它是有效和/或已损坏的,以及它上次修改的时间。了解块的所有者非常重要,因为它允许我们找到其他相关的元数据来确定损坏的范围。例如,如果我们有一个扩展 btree 对象,我们不知道它属于哪个 inode,因此必须遍历整个文件系统才能找到该块的所有者。更糟糕的是,损坏可能意味着找不到所有者(即,这是一个孤立的块),因此如果没有元数据中的所有者字段,我们就不知道损坏的范围。如果我们在元数据对象中有一个所有者字段,我们可以立即进行自上而下的验证以确定问题的范围。

不同类型的元数据具有不同的所有者标识符。例如,目录、属性和扩展树块都由 inode 拥有,而空闲空间 btree 块由分配组拥有。因此,所有者字段的大小和内容由我们正在查看的元数据对象的类型确定。所有者信息还可以识别错位的写入(例如,写入错误 AG 的空闲空间 btree 块)。

自描述元数据还需要包含一些关于何时写入文件系统的指示。进行取证分析时,关键信息点之一是块最近修改的时间。基于修改时间关联一组损坏的元数据块非常重要,因为它可以指示损坏是否相关,是否发生了导致最终失败的多次损坏事件,甚至是否存在运行时验证未检测到的损坏。

例如,如果空闲空间 btree 块包含该块的最后一次写入时间与元数据对象本身最后一次写入时间相比,并且该对象仍被其所有者引用,则我们可以确定元数据对象应该是空闲空间还是仍然已分配。如果空闲空间块比对象和对象的所有者更新,那么该块很有可能应该已从所有者中删除。

为了提供这个“写入时间戳”,每个元数据块都会获得最近一次修改它的事务的日志序列号 (LSN)。这个数字在文件系统的生命周期中总是会增加,唯一重置它的是在文件系统上运行 xfs_repair。此外,通过使用 LSN,我们可以判断损坏的元数据是否都属于同一个日志检查点,从而了解在磁盘上第一次和最后一次出现损坏的元数据之间发生了多少修改,以及在写入损坏和检测到损坏之间发生了多少修改。

3.3. 运行时验证

自描述元数据的验证在运行时的两个位置进行

  • 从磁盘成功读取后立即进行

  • 在写入 IO 提交之前立即进行

验证是完全无状态的 - 它独立于修改过程完成,并且仅旨在检查元数据是否是它所说的内容以及元数据字段是否在界限内并且内部一致。因此,我们无法捕获块内可能发生的所有类型的损坏,因为操作状态可能对元数据施加某些限制,或者可能存在块间关系的损坏(例如,损坏的兄弟指针列表)。因此,我们仍然需要在主代码体中进行有状态的检查,但通常大多数每个字段的验证都由验证器处理。

对于读取验证,调用者需要指定它应该看到的预期元数据类型,并且 IO 完成过程会验证元数据对象是否与预期匹配。如果验证过程失败,则将其读取的对象标记为 EFSCORRUPTED。调用者需要捕获此错误(与 IO 错误相同),并且如果需要由于验证错误而采取特殊操作,可以通过捕获 EFSCORRUPTED 错误值来执行此操作。如果我们需要在更高层对错误类型进行更多区分,我们可以根据需要为不同的错误定义新的错误编号。

读取验证的第一步是检查幻数,并确定是否需要进行 CRC 校验。如果需要,则计算 CRC32c,并将其与对象本身存储的值进行比较。一旦验证通过,将进一步检查位置信息,然后进行广泛的特定于对象的元数据验证。如果其中任何一项检查失败,则认为缓冲区已损坏,并设置相应的 EFSCORRUPTED 错误。

写入验证与读取验证相反 - 首先对对象进行广泛验证,如果验证通过,则更新上次修改对象时的 LSN。之后,我们计算 CRC 并将其插入到对象中。完成此操作后,允许继续进行写入 IO。如果在此过程中发生任何错误,则再次使用 EFSCORRUPTED 错误标记缓冲区,以便更高层捕获。

3.4. 结构

典型的磁盘结构需要包含以下信息

struct xfs_ondisk_hdr {
        __be32  magic;              /* magic number */
        __be32  crc;                /* CRC, not logged */
        uuid_t  uuid;               /* filesystem identifier */
        __be64  owner;              /* parent object */
        __be64  blkno;              /* location on disk */
        __be64  lsn;                /* last modification in log, not logged */
};

根据元数据的不同,此信息可能包含在与元数据内容分开的头部结构中,也可能分布在现有结构中。后者发生在已经包含部分此信息的元数据中,例如超级块和 AG 头部。

其他元数据可能具有不同的信息格式,但通常会提供相同级别的信息。例如

  • 短 btree 块具有 32 位所有者(ag 编号)和 32 位块号用于定位。这两者结合起来提供与上述 eh 结构中的 @owner 和 @blkno 相同的信息,但在磁盘上使用的空间少了 8 个字节。

  • 目录/属性节点块具有 16 位幻数,并且包含幻数的头部也包含其他信息。因此,附加的元数据头部会更改元数据的整体格式。

典型的缓冲区读取验证器结构如下

#define XFS_FOO_CRC_OFF             offsetof(struct xfs_ondisk_hdr, crc)

static void
xfs_foo_read_verify(
        struct xfs_buf      *bp)
{
    struct xfs_mount *mp = bp->b_mount;

        if ((xfs_sb_version_hascrc(&mp->m_sb) &&
            !xfs_verify_cksum(bp->b_addr, BBTOB(bp->b_length),
                                        XFS_FOO_CRC_OFF)) ||
            !xfs_foo_verify(bp)) {
                XFS_CORRUPTION_ERROR(__func__, XFS_ERRLEVEL_LOW, mp, bp->b_addr);
                xfs_buf_ioerror(bp, EFSCORRUPTED);
        }
}

代码通过检查功能位的超级块来确保仅在文件系统启用了 CRC 时才检查 CRC,然后,如果 CRC 验证通过(或不需要),它将验证块的实际内容。

验证器函数将采用几种不同的形式,具体取决于是否可以使用幻数来确定块的格式。如果不能,则代码结构如下

static bool
xfs_foo_verify(
        struct xfs_buf              *bp)
{
        struct xfs_mount    *mp = bp->b_mount;
        struct xfs_ondisk_hdr       *hdr = bp->b_addr;

        if (hdr->magic != cpu_to_be32(XFS_FOO_MAGIC))
                return false;

        if (!xfs_sb_version_hascrc(&mp->m_sb)) {
                if (!uuid_equal(&hdr->uuid, &mp->m_sb.sb_uuid))
                        return false;
                if (bp->b_bn != be64_to_cpu(hdr->blkno))
                        return false;
                if (hdr->owner == 0)
                        return false;
        }

        /* object specific verification checks here */

        return true;
}

如果不同格式有不同的幻数,则验证器将如下所示

static bool
xfs_foo_verify(
        struct xfs_buf              *bp)
{
        struct xfs_mount    *mp = bp->b_mount;
        struct xfs_ondisk_hdr       *hdr = bp->b_addr;

        if (hdr->magic == cpu_to_be32(XFS_FOO_CRC_MAGIC)) {
                if (!uuid_equal(&hdr->uuid, &mp->m_sb.sb_uuid))
                        return false;
                if (bp->b_bn != be64_to_cpu(hdr->blkno))
                        return false;
                if (hdr->owner == 0)
                        return false;
        } else if (hdr->magic != cpu_to_be32(XFS_FOO_MAGIC))
                return false;

        /* object specific verification checks here */

        return true;
}

写入验证器与读取验证器非常相似,它们只是以与读取验证器相反的顺序执行操作。一个典型的写入验证器

static void
xfs_foo_write_verify(
        struct xfs_buf      *bp)
{
        struct xfs_mount    *mp = bp->b_mount;
        struct xfs_buf_log_item     *bip = bp->b_fspriv;

        if (!xfs_foo_verify(bp)) {
                XFS_CORRUPTION_ERROR(__func__, XFS_ERRLEVEL_LOW, mp, bp->b_addr);
                xfs_buf_ioerror(bp, EFSCORRUPTED);
                return;
        }

        if (!xfs_sb_version_hascrc(&mp->m_sb))
                return;


        if (bip) {
                struct xfs_ondisk_hdr       *hdr = bp->b_addr;
                hdr->lsn = cpu_to_be64(bip->bli_item.li_lsn);
        }
        xfs_update_cksum(bp->b_addr, BBTOB(bp->b_length), XFS_FOO_CRC_OFF);
}

这将验证元数据的内部结构,然后再进一步操作,检测在内存中修改元数据时发生的损坏。如果元数据验证通过,并且启用了 CRC,则我们会更新 LSN 字段(上次修改的时间)并计算元数据的 CRC。完成此操作后,我们可以发出 IO。

3.5. Inode 和 Dquot

Inode 和 dquot 是特殊的“雪花”。它们具有每个对象的 CRC 和自我标识符,但它们被打包在一起,因此每个缓冲区有多个对象。因此,我们不使用每个缓冲区的验证器来执行每个对象的验证和 CRC 计算工作。每个缓冲区的验证器仅执行缓冲区的基本识别 - 它们包含 inode 或 dquot,并且在所有预期位置都有幻数。当每个 inode 从缓冲区读取或写回缓冲区时,将完成所有进一步的 CRC 和验证检查。

验证器和标识符检查的结构与上述缓冲区代码非常相似。唯一的区别是它们的调用位置。例如,inode 读取验证在 xfs_inode_from_disk() 中完成,当 inode 首次从缓冲区中读出并实例化 struct xfs_inode 时。inode 在 xfs_iflush_int 的回写过程中已经进行了广泛的验证,因此这里唯一要添加的是将 LSN 和 CRC 添加到 inode,因为它被复制回缓冲区。

XXX:inode 未链接列表修改不会重新计算 inode CRC!在取消链接期间或日志恢复期间,未链接列表修改均不会检查或更新 CRC。因此,直到现在才被注意到。这不会立即产生影响 - 修复程序可能会抱怨它 - 但需要修复。