3. XFS 自描述元数据

3.1. 引言

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

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

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

然而,如果我们将文件系统扩展到 1PB,我们现在需要分析 10 倍的元数据,因此分析会扩展到数周/数月的取证工作。大部分分析工作缓慢而乏味,因此随着分析量的增加,原因越有可能淹没在噪音中。因此,支持 PB 级文件系统的首要考虑是最大限度地减少对文件系统结构进行基本取证分析所需的时间和精力。

3.2. 自描述元数据

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

因此,大部分取证分析时间都花在了元数据值的基本验证上,寻找在范围内(因此未被自动化验证检查检测到)但不正确的值。找出并理解诸如交叉链接块列表(例如,B 树中的兄弟指针最终形成循环)之类的问题是理解哪里出了错的关键,但在事后无法确定块是如何相互链接或写入磁盘的顺序。

因此,我们需要在元数据中记录更多信息,以便我们能够快速确定元数据是否完整,并且为了分析目的可以忽略。我们无法防范所有可能的错误类型,但我们可以确保常见类型的错误易于检测。因此就有了自描述元数据的概念。

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

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

作为一个主要关注点,自描述元数据需要某种形式的整体完整性检查。如果不能验证元数据未因外部影响而更改,我们就不能信任它。因此,我们需要某种形式的完整性检查,这通过向元数据块添加 CRC32c 验证来完成。如果我们可以验证块包含它打算包含的元数据,那么大量的手动验证工作就可以跳过。

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

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

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

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

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

例如,我们可以通过查看包含该块的空闲空间 B 树块上次写入的时间与元数据对象本身上次写入的时间进行比较,来确定元数据对象是应该为空闲空间还是仍被其所有者引用。如果空闲空间块比对象和对象的拥有者更近,那么该块很有可能应该从拥有者中移除。

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

3.3. 运行时验证

自描述元数据的验证在运行时发生在两个地方:

  • 从磁盘成功读取后立即

  • 写入 IO 提交之前立即

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

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

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

写入验证与读取验证相反——首先对对象进行广泛验证,如果验证通过,我们 then 更新对象上次修改的 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 头。

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

  • 短 B 树块有一个 32 位所有者(ag number)和一个 32 位块号用于位置。这两个结合起来提供了与上述结构中 @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,并且所有预期位置都有魔数。所有进一步的 CRC 和验证检查都在每个 inode 从缓冲区读取或写入回缓冲区时进行。

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

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