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

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

前置

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

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

  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 相關的 文章 對理解本文也有很大的幫助,由於它們使用着類似的技術。git

此外,我但願在閱讀這份文檔時,讀者可以主動地思考每一步的緣由和效果。這之中沒有複雜的須要頭腦高速運轉的知識,只有一步一步的(給計算機的)行動指南。也但願你可以在大腦裏不斷地構思「若是我本身要實現運行時文件系統注入,應該怎樣作?」,這樣這篇文章就從單純的灌輸變爲了看法的交流,會有趣不少。github

錯誤注入

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

那麼在應用程序發起 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 呀,這可怎麼辦。app

  2. 這不能知足運行時注入的要求。由於若是目標程序已經打開了一些原目標系統的文件,那麼新掛載的文件系統只對新 open 的文件有效。(更況且還有上述文件系統路徑覆蓋的問題)。想要可以對目標程序注入文件系統錯誤,必須得在目標進程啓動以前將 ChaosFS 掛載好。
  3. 還得想辦法把文件系統給掛載進目標容器的 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 的手冊。

  2. 使用 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 技術內幕系列文章的第一篇,若是讀者還想了解種種其餘錯誤注入的實現和背後的技術,還請期待同系列以後的文章喲。

相關文章
相關標籤/搜索