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,無論是取消連結還是日誌恢復期間。因此,直到現在才被注意到。這不會立即產生影響——修復可能會抱怨它——但需要修復。