Chaos Mesh® 技術內幕 | 如何注入 I/O 故障?如何注入 I/O 故障?

在生產環境中,時常會因爲磁盤故障、誤操作等原因出現文件系統的錯誤。Chaos Mesh 很早就提供了注入文件系統錯誤的能力。用戶只需要添加一個 IOChaos 資源,就能夠讓對指定文件的文件系統操作失敗或返回錯誤的數據。在 Chaos Mesh 1.0 之前,使用 IOChaos 需要對 Pod 注入 sidecar 容器,並且需要改寫啓動命令;哪怕沒有注入錯誤,被注入 sidecar 的容器也總是有較大的性能開銷。隨着 Chaos Mesh 1.0 的發佈,提供了運行時注入文件系統錯誤的功能,使得 IOChaos 的使用和其他所有類型的 Chaos 一樣簡單方便。這篇文章將會介紹它的實現方式。

前置

本文的內容假定你已經掌握以下知識。當然,你不必在此時就去閱讀;但當遇到沒見過的名詞的時可以回過頭來搜索學習。

我會盡我所能提供相關的學習資料,但我不會將它們提煉和複述,一是因爲這些知識通過簡單的 Google 就能學到;二是因爲大部分時候學習一手的知識效果遠比二手要好,學習 n 手的知識效果遠比(n+1)手的要好。

  1. FUSE. Wikipedia, man(4)

  2. mount_namespaces. man, k8s Mount propagation

  3. x86 assembly language. Wikipedia

  4. mount. man(2) 特別是 MS_MOVE

  5. Mutating admission webhooks. k8s Document

  6. syscall. man(2) 注意瀏覽一下調用約定

  7. ptrace. man(2)

  8. Device node, char devices Device names, device nodes, and major/minor numbers

閱讀與 TimeChaos 相關的 文章 對理解本文也有很大的幫助,因爲它們使用着相似的技術。

此外,我希望在閱讀這份文檔時,讀者能夠主動地思考每一步的原因和效果。這之中沒有複雜的需要頭腦高速運轉的知識,只有一步一步的(給計算機的)行動指南。也希望你能夠在大腦裏不斷地構思“如果我自己要實現運行時文件系統注入,應該怎樣做?”,這樣這篇文章就從單純的灌輸變爲了見解的交流,會有趣很多。

錯誤注入

尋找錯誤注入方式的一個普遍方法就是先觀察未注入時的調用路徑:我們在 TimeChaos 的實現過程當中,通過觀察應用程序獲取時間的方式,瞭解到大部分程序會通過 vDSO 訪問時間,從而選取了修改目標程序 vDSO 部分內存來修改時間的方式。

那麼在應用程序發起 read, write 等系統調用,到這些請求到達目標文件系統,這之間是否存在可供注入的突破口呢?事實上是存在的,你可以使用 bpf 的方式注入相關的系統調用,但它無法被用於注入延遲。另一種方式就是在目標文件系統前再加一層文件系統,我們暫且稱之爲 ChaosFS:

ChaosFS 以本來的目標文件系統作爲後端,接受來自操作系統的寫入請求,使得整個調用鏈路變爲 Targer Program syscall -> Linux Kernel -> ChaosFS -> Target Filesystem. 由於我們可以自定義 ChaosFS 文件系統的實現,所以可以任意地添加延遲、返回錯誤。

如果你在此時已經開始構思自己的文件系統錯誤注入實現,聰明的你一定已經發現了一些問題:

  1. ChaosFS 如果也要往目標文件系統裏讀寫文件,這意味着它的掛載路徑與目標文件夾不同。因爲掛載路徑幾乎是訪問一個文件系統唯一的方式了。

即,如果目標程序想要寫入 /mnt/a,於是 ChaosFS 也得掛載於 /mnt/a,那麼目標文件夾就不能是 /mnt/a 了!但是 pod 的配置裏寫了要把目標文件系統掛載在 /mnt 呀,這可怎麼辦。

  1. 這不能滿足運行時注入的要求。因爲如果目標程序已經打開了一些原目標系統的文件,那麼新掛載的文件系統只對新 open 的文件有效。(更何況還有上述文件系統路徑覆蓋的問題)。想要能夠對目標程序注入文件系統錯誤,必須得在目標進程啓動之前將 ChaosFS 掛載好。

  2. 還得想辦法把文件系統給掛載進目標容器的 mnt namespace 中去。

對於這三個問題,原初的 IOChaos 都是使用 Mutating Webhook 來達成的:

  1. 使用 Mutating Webhook 在目標容器中先運行腳本移動目錄。比如將 /mnt/a 移動至 /mnt/a_bak。這樣一來 ChaosFS 的存儲後端就可以是 /mnt/a_bak 目錄,而自己掛載在 /mnt/a 下了。

  2. 使用 Mutating Webhook 修改 Pod 的啓動命令,比如本身啓動命令是 /app,我們要將它修改成 /waitfs.sh /app,而我們提供的 waitfs.sh 會不斷檢查文件系統是否已經掛載成功,如果已經成功就再啓動 /app

  3. 自然的,我們依舊使用 Mutating Webhook 來在 Pod 中多加入一個容器用於運行 ChaosFS。運行 ChaosFS 的容器需要與目標容器共享某個 volume,比如 /mnt。然後將它掛載至目標目錄,比如 /mnt/a。同時開啓適當的 mount propagation ,來讓 ChaosFS 容器的 volume 中的掛載穿透(share)至 host,再由 host 穿透(slave)至目標。(如果你瞭解 mnt namespace 和 mount ,那麼一定知道 share 和 slave 是什麼意思)。

這樣一來,就完成了對目標程序 IO 過程的注入。但它是如此的不好用:

  1. 只能對某個 volume 的子目錄注入,而無法對整個 volume 注入。

  2. 要求 Pod 中明文寫有 command,而不能是隱含使用鏡像的 command 。因爲如果使用鏡像隱含的 command 的話,/waitfs.sh 就不知道在掛載成功之後應該如何啓動應用了。

  3. 要求對應容器有足夠的 mount propagation 的配置。當然我們可以在 Mutating Webhook 裏偷偷摸摸加上,但動用戶的容器總是不太妙的(甚至可能引發安全問題)。

  4. 注入配置要填的東西太多啦!配置起來真麻煩。而且在配置完成之後還得新建 pod 才能被注入。

  5. 無法在運行時撤出 ChaosFS,所以哪怕不施加延遲或錯誤,仍然對性能有不小的影響。

其中第一個問題是可以克服的,只要用 mount move 來代替 mv(rename),就可以移動目標 volume 的掛載點。而後面幾個問題就不那麼好克服了。

運行時注入錯誤

結合使用你擁有的其他知識(比如 namespace 的知識和 ptrace 的用法),重新審視這兩點,就能找到解決的辦法。我們完全依賴 Mutating Webhook 來構造了這個實現,但大部分的糟糕之處也都是由 Mutating Webhook 的方法帶來的。(如果你喜歡,可以管這種方法叫做 Sidecar 的方法。很多項目都這麼叫,但是這種稱呼將實現給隱藏了,也沒省太多字,我不是很喜歡)。接下來我們將展示如何不使用 Mutating Webhook 來達到以上目的。

侵入命名空間

我們使用 Mutating Webhook 添加一個用於運行 ChaosFS 的容器的目的是爲了通過 mount propagation 的機制將文件系統掛載至目標容器內。而要達到這個目的並非只有這一種選擇 —— 我們還可以直接使用 Linux 提供的 setns 系統調用來修改當前進程的 namespace。事實上在 Chaos Mesh 的大部分實現中都使用了 nsenter 命令、setns 系統調用等方式來進入目標容器的 namespace,而非向 Pod 中添加容器。這是因爲前者在使用時更加方便,開發時也更加靈活。

也就是說可以先通過 setns 來讓當前線程進入目標容器的 mnt namespace,然後在這個 namespace 中調用 mount 等系統調用完成 ChaosFS 的掛載。

假定我們需要注入的文件系統是 /mnt

  1. 通過 setns 讓當前線程進入目標容器的 mnt namespace;

  2. 通過 mount --move 將 /mnt 移動至 /mnt_bak

  3. 將 ChaosFS 掛載至 /mnt,並以 /mnt_bak 爲存儲後端。

可以看到,這時注入流程已經大致完成了,目標容器如果再次打開、讀寫 /mnt 中的文件,就會通過 ChaosFS,從而被它注入延遲或錯誤。

而它還剩下兩個問題:

  1. 目標進程已經打開的文件該怎麼辦?

  2. 該如何恢復?畢竟在有文件被打開的情況下是無法 umount 的。

後文將用同一個手段解決這兩個問題:使用 ptrace 的方法在運行時替換已經打開的 fd。(本文以 fd 爲例,事實上除了 fd 還有 cwd,mmap 等需要替換,實現方式是相似的,就不單獨描述了)

動態替換 fd

我們主要使用 ptrace 來對 fd 進行動態地替換,在介紹具體的方法之前,不妨先感受一下 ptrace 的威力:

  1. 使用 ptrace 能夠讓 tracee(被 ptrace 的線程) 運行任意系統調用這是怎麼做到的呢?綜合運用 ptrace 和 x86_64 的知識來看這個問題並不算難。由於 ptrace 可以修改寄存器,同時 x86_64 架構中 rip 寄存器(instruction pointer)總是指向下一個要運行的指令的地址,所以只需要將當前 rip 指向的部分內存修改爲 0x050f (對應 syscall 指令),再依照系統調用的調用約定將各個寄存器的值設爲對應的系統調用編號或參數,然後使用 ptrace 單步執行,就能從 rax 寄存器中拿到系統調用的返回值。在完成調用之後記得將寄存器和修改的內存都復原。

在以上過程中使用了 ptrace 的 POKE_TEXTSETREGSGETREGSSINGLESTEP 等功能,如果不熟悉可以查閱 ptrace 的手冊。

  1. 使用 ptrace 能夠讓 tracee(指 ptrace 的目標進程) 運行任意二進制程序。

    運行任意二進制程序的思路是類似的。可以與運行系統調用一樣,將 rip 後一部分的內訓修改爲自己想要運行的程序,並在程序末尾加上 int3 指令以觸發斷點。在執行完成之後恢復目標程序的寄存器和內存就好了。

    而事實上我們可以選用一種稍稍乾淨些的方式:使用 ptrace 在目標程序中調用 mmap,分配出需要的內存,然後將二進制程序寫入新分配出的內存區域中,將 rip 指向它。在運行結束之後調用 munmap 就能保持內存區域的乾淨。

在實踐中,我們使用 process_vm_writev 代替了使用 ptrace POKE_TEXT 寫入,在寫入大量內容的時候它更加穩定高效一些。

在擁有以上手段之後,如果一個進程自己有辦法替換自己的 fd,那麼通過 ptrace,就能讓它運行同樣的一段程序來替換 fd。這樣一來問題就簡單了:我們只需要找到一個進程自己替換自己的 fd 的方法。如果對 Linux 的系統調用較爲熟悉的話,馬上就能找到答案:dup2。

使用 dup2 替換 fd

dup2 的函數簽名是 int dup2(int oldfd, int newfd);,它的作用是創建一份 oldfd 的拷貝,並且這個拷貝的 fd 號是 newfd。如果 newfd 原本就有打開着的 fd ,它會被自動地 close。

假定現在進程正打開着 /var/run/__chaosfs__test__/a ,fd 爲 1 ,希望替換成 /var/run/test/a,那麼它需要做的事情有:

  1. 使用通過 fcntl 系統調用獲取 /var/run/__chaosfs__test__/a 的 OFlags(即 open 調用時的參數,比如 O_WRONLY );

  2. 使用 lseek 系統調用獲取當前的 seek 位置;

  3. 使用 open 系統調用,以相同的 OFlags 打開 /var/run/test/a,假設 fd 爲 2;

  4. 使用 lseek 改變新打開的 fd 2 的 seek 位置;

  5. 使用 dup2(2, 1) 用新打開的 fd 2 來替換 /var/run/__chaosfs__test__/a 的 fd 1;

  6. 將 fd 2 關掉。

這樣之後,當前進程的 fd 1 就會指向 /var/run/test/a,任何對於它的操作都會通過 FUSE,能夠被注入錯誤了。

使用 ptrace 讓目標進程運行替換 fd 的程序

那麼只要結合“使用 ptrace 能夠讓 tracee 運行任意二進制程序”的知識和“使用dup2替換自己已經打開的fd”的方法,就能夠讓 tracee 自己把已經打開的 fd 給替換掉啦!

對照前文描述的步驟,結合 syscall 指令的用法,寫出對應的彙編代碼是容易的,你可以在這裏看到對應的源碼,使用匯編器可以將它輸出爲可供使用的二進制程序(我們使用的是 dynasm-rs)。然後用 ptrace 讓目標進程運行這段程序,就完成了在運行時對 fd 的替換。

讀者可以稍稍思考如何使用類似的方式來改換 cwd,替換 mmap 呢?它們的流程完全是類似的。

注:實現中假定了目標程序依照 Posix Thread,目標進程與它的線程之間共享打開的文件,即 clone 創建線程時指定了 CLONE_FILES。所以將只會對一個線程組的第一個線程進行 fd 替換。

流程總覽

在瞭解了這一切技術之後,實現運行時文件系統的思路應當已經逐漸清晰了起來。在這一節我將直接展示出整個注入實現的流程圖:

平行的數條線表示不同的線程,從左至右依照時間先後順序。可以看到對 “掛載/卸載文件系統 ”和 “進行 fd 等資源的替換” 這兩個任務進行了較爲精細的時間順序的安排,這是有必要的。爲什麼呢?如果讀者對整個過程的瞭解已經足夠清晰,不妨試着自己思考它的答案。

細枝末節的問題

mnt namespace 可能引發的 mmap 失效

在 mnt namespace 切換之後,已經創建完成的 mmap 是否還有效呢?比如一個 mmap 指向 /a/b,而在切換 mnt namespace 之後 /a/b 消失了,再訪問這個 mmap 時是否會造成意料之外的崩潰呢?值得注意的是,動態鏈接庫全是通過 mmap 載入進內存的,訪問它們是否會有問題呢?

事實上,是不會有問題的。這涉及到 mnt namespace 的方式和目的。mnt namespace 只涉及到對線程可見性的控制,具體的做法,則是在調用 setns 時修改內核中某一線程 task_struct 內 vfsmount 指針的修改,從而當線程使用任何傳入路徑的系統調用的時候(比如 open、rename 等)的時候,Linux 內核內通過 vfsmount 從路徑名查詢到文件(作爲 file 結構體),會受到 namespace 的影響。而對於已經打開的 fd(指向一個 file 結構體),它的 open、write、read 等操作直接指向對應文件系統的函數指針,不會受到 namespace 的影響;對於一個已經打開的 mmap (指向一個 address_space 結構體),它的 writepage, readpage 等操作也直接指向對應文件系統的函數指針,也不受到 namespace 的影響。

注入的範圍

由於在注入過程中,不可能將機器上運行的所有進程暫停並檢查它們已經打開的 fd 和 mmap 等資源,這樣做的開銷不可接受。在實踐中,我們選擇預先進入目標容器的 pid namespace,並對這個 namespace 中能看見的所有進程進行暫停和檢查。

所以注入和恢復的範圍是全部 pid namespace 中的進程。而切換 pid namespace 意味着需要預先設定子進程的 pid namespace 再 clone(因爲 Linux 並不允許切換當前進程的 pid namespace ),這又將帶來諸多問題。

切換 namespace 對 clone flag 有些限制

切換 mnt namespace 將不允許 clone 時攜帶參數 CLONE_FS。而預先設定好子進程 pid namespace 的情況下,將不允許 clone 時攜帶參數 CLONE_THREAD。爲了應對這個問題,我們選擇修改 glibc 的源碼,能夠在 chaos-mesh/toda-glibc 中找到修改後的 glibc 的源碼。修改的只有 pthread 部分 clone 時傳入的參數。

在去掉 CLONE_THREADCLONE_FS 之後,pthread 的表現與原先有較大差異。其中最大的差異便是新建的 pthread 線程不再是原有進程的 tasks,而是一個新的進程,它們的 tgid 是不同的。這樣 pthread 線程之間的關係從進程與tasks變成了進程與子進程。這又會帶來一些麻煩,比如在退出時可能需要對子進程進行額外的清理。

在更低版本的內核中,也不允許不同 pid namespace 的進程共享 SIGHAND,所以還需要把 CLONE_SIGHAND 去掉。

爲什麼不使用nsenter

在 chaos-daemon 中,很多需要在目標命名空間中的操作都是通過 nsenter 完成的,比如 nsenter iptables 這樣聯合使用。而 nsenter 卻無法應對 IOChaos 的場景,因爲如果在進程啓動時就已進入目標 mnt namespace,那將找不到合適的動態鏈接庫(比如 libfuse.so 和自制的 glibc)。

構造 /dev/fuse

由於目標容器中不一定有 /dev/fuse (事實上更可能沒有),所以在進入目標容器的 mnt namespace 後掛載 FUSE 時會遇到錯誤。所以在進入目標的 mnt namespace 後需要構造 /dev/fuse。這個構造的過程還是很容易的,因爲 fuse 的 major number 和 minor number 是固定的 10 和 229。所以只要使用 makedev 函數和 mknod 系統調用,就能夠創造出 /dev/fuse 。

去掉 CLONE_THREAD 之後等待子進程死亡的問題

在子進程死亡時,會向父進程發送 SIGCHLD 信號通知自己的死亡。如果父進程沒有妥善的處理這個信號(顯式地忽略或是在信號處理中 wait ),那麼子進程就會持續處於 defunct 狀態。

而在我們的場景下,這個問題變得更加複雜了:因爲當一個進程的父進程死亡之後,它的父進程會被重新置爲它所在的 pid namespace 的 1 號進程。通常來說一個好的 init 進程(比如 systemd )會負責清理這些 defunct 進程,但在容器的場景下,作爲 pid 1 的應用通常並沒有被設計爲一個好的 init 進程,不會負責處理掉這些 defunct 進程。

爲了解決這個問題,我們使用 subreaper 的機制來讓一個進程的父進程死亡時並不是直接將父進程置爲 1,而是進程樹上離得最近的 subreaper。然後使用 wait 來等待所有子進程死亡再退出。

waitpid 在不同內核版本下表現不一致

waitpid 在不同版本內核下表現不一致,在較低版本的內核中,對一個作爲子線程(指並非主線程的線程)的 tracee 使用 waitpid 會返回 ECHILD ,還沒有確定這樣的原因是什麼,也沒有找到相關的文檔。

歡迎貢獻

在完成了以上描述的實現之後,運行時文件系統注入的功能就大致實現了,我們的實現在 chaos-mesh/toda 項目裏。但是離完美仍然還有很長的路要走:

  1. 對 generation number 沒有支持;

  2. 對 ioctl 等操作沒有提供支持;

  3. 在掛載文件系統之後沒有主動判斷它是否完成,而是等待 1s。

如果讀者對這項功能的實現感興趣,或是願意和我們一同改進它,歡迎加入我們的 slack 頻道參與討論或提交 issue 和 PR 😉

本篇爲 Chaos Mesh 技術內幕系列文章的第一篇,如果讀者還想了解種種其他錯誤注入的實現和背後的技術,還請期待同系列之後的文章喲。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章