(轉載 --- 中篇) 分散式系統測試那些事兒 - 錯誤注入

SineIO發表於2020-12-16

本話題系列文章整理自 PingCAP Infra Meetup 第 26 期劉奇分享的《深度探索分散式系統測試》議題現場實錄。文章較長,為方便大家閱讀,會分為上中下三篇,本文為中篇。

-接上篇-
當然測試可能會讓你程式碼變得沒有那麼漂亮,舉個例子:

這是知名的 Kubernetes 的程式碼,就是說它有一個 DaemonSetcontroller,這 controller 裡面注入了三個測試點,比如這個地方注入了一個 handler ,你可以認為所有的注入都是 interface。比如說你寫一個簡單的 1+1=2 的程式,假設我們寫一個計算器,這個計算器的功能就是求和,那這就很難注入錯誤。所以你必須要在你正確的程式碼裡面去注入測試邏輯。再比如別人 call 你的這個 add 的 function,然後你是不是有一個 error?這個 error 的問題是它可能永遠不會返回一個 error,所以你必須要人肉的注進去,然後看應用程式是不是正確的行為。說完了加法,再說我們做一個除法。除法大家知道可能有處理異常,那上面是不是能正常處理呢?上面沒有,上面寫著一個比如說 6 ÷ 3,然後寫了一個 test,coverage 100%,但是一個除零異常,系統就崩掉了,所以這時候就需要去注入錯誤。大名鼎鼎的 Kubernetes 為了測試各種異常邏輯也採用類似的方式,這個結構體不算長,大概是十幾個成員,然後裡面就注入了三個點,可以在裡面注入錯誤。

那麼在設計 TiDB 的時候,我們當時是怎麼考慮 test 這個事情的?首先一個百萬級的 test 不可能由人肉來寫,也就是說你如果重新定義一個自己的所謂的 SQL 語法,或者一個 query language,那這個時候你需要構建百萬級的 test,即使全公司去寫,寫個兩年都不夠,所以這個事情顯然是不靠譜的。但是除非說我的 query language 特別簡單,比如像 MongoDB 早期的那種,那我一個“大於多少”的這種,或者 equal 這種條件查詢特別簡單的,那你確實是不需要構建這種百萬級的 test。但是如果做一個 SQL 的 database 的話,那是需要構建這種非常非常複雜的 test 的。這時候這個 test 又不能全公司的人寫個兩年,對吧?所以有什麼好辦法呢?MySQL 相容的各種系統都是可以用來 test 的,所以我們當時相容 MySQL 協議,那意味著我們能夠取得大量的 MySQL test。不知道有沒有人統計過 MySQL 有多少個 test,產品級的 test 很嚇人的,千萬級。然後還有很多 ORM, 支援 MySQL 的各種應用都有自己的測試。大家知道,每個語言都會 build 自己的 ORM,然後甚至是一個語言的 ORM 都有好幾個。比如說對於 MySQL 可能有排第一的、排第二的,那我們可以把這些全拿過來用來測試我們的系統。

但對於有些應用程式而言,這時候就比較坑了。就是一個應用程式你得把它 setup 起來,然後操作這個應用程式,比如 WordPress,而後再看那個結果。所以這時候我們為了避免剛才人肉去測試,我們做了一個程式來自動化的 Record—Replay。就是你在首次執行的時候,我們會記錄它所有執行的 SQL 語句,那下一次我再需要重新執行這個程式的時候怎麼辦?我不需要執行這個程式了,我不需要起來了,我只需要把它前面記錄的 SQL record 重新回放一遍,就相當於是我模擬了程式的整個行為。所以我們在這部分是這樣做的自動化。

那麼剛剛說了那麼多,實際上做的是什麼?實際上做的都是正確路徑的測試,那幾百萬個 test 也都是做的正確的路徑測試,但是錯誤的路徑怎麼辦?很典型的一個例子就是怎麼做 Fault injection。硬體比較簡單粗暴的模擬網路故障可以拔網線,比如說測網路的時候可以把這個網線拔掉,但是這個做法是極其低效的,而且它是沒法 scale 的,因為這個需要人的參與。

然後還有比如說 CPU,這個 CPU 的損壞概率其實也挺高的,特別是對於過保了的機器。然後還有磁碟,磁碟大概是三年百分之八點幾的損壞率,這是一篇論文裡面給出的資料。我記得 Google 好像之前給過一個資料,就是 CPU、網路卡還有磁碟在多少年之內的損壞率大概是什麼樣的。

還有一個大家不太關注的就是時鐘。先前,我們發現系統時鐘是有回跳的,然後我們果斷在程式裡面加個監測模組,一旦系統時鐘回跳,我們馬上把這個檢測出來。當然我們最初監測出這個東西的時候,使用者是覺得不可能吧,時鐘還會有回跳?我說沒關係,先把我們程式開了監測一下,然後過段時間就檢測到,系統時鐘最近回跳了。所以怎麼配 NTP 很重要。然後還有更多的,比如說檔案系統,大家有沒有考慮過你寫磁碟的時候,磁碟出錯會怎麼辦?好,寫磁碟的時候沒有出錯,成功了,然後磁碟一個扇區壞了,讀出來的資料是損壞的,怎麼辦?大家有沒有 checksum ?沒有 checksum 然後我們直接用了這個資料,然後直接給使用者返回了,這個時候可能是很要命的。如果這個資料剛好存的是個後設資料,而後設資料又指向別的資料,然後你又根據後設資料的資訊去寫入另外一份資料,那就更要命了,可能資料被進一步破壞了。

所以比較好的做法是什麼?

  • Fault injection
    • Hardware
      • disk error
      • network card
      • cpu
      • clock
    • Software
      • file system
      • network & protocol
  • Simulate everything

模擬一切東西。就是磁碟是模擬的,網路是模擬的,那我們可以監控它,你可以在任何時間、任何的場景下去注入各種錯誤,你可以注入任何你想要的錯誤。比如說你寫一個磁碟,我就告訴你磁碟滿了,我告訴你磁碟壞了,然後我可以讓你 hang 住,比如 sleep 五十幾秒。我們確實在雲上面出現過這種情況,就是我們一次寫入,然後被 hang 了為 53 秒,最後才寫進去,那肯定是網路磁碟,對吧?這種事情其實是很嚇人的,但是肯定沒有人會想說我一次磁碟寫入然後要耗掉 53 秒,但是當 53 秒出現的時候,整個程式的行為是什麼?TiDB 裡面用了大量的 Raft,所以當時出現一個情況就是 53 秒,然後所有的機器就開始選舉了,說這肯定是哪兒不對,重新把 leader 都選出來了,這時候卡 53 秒的哥們說“我寫完了”,然後整個系統狀態就做了一次全新的遷移。這種錯誤注入的好處是什麼?就是知道當出錯的時候,你的錯誤能嚴重到什麼程度,這個事情很重要,就是 predictable,整個系統要可預測的。如果沒有做錯誤路徑的測試,那很簡單的一個問題,現在假設走到其中一條錯誤路徑了,整個系統行為是什麼?這一點不知道是很嚇人的。你不知道是否可能破壞資料;還是業務那邊會 block 住;還是業務那邊會 retry?

以前我遇到一個問題很有意思,當時我們在做一個訊息系統,有大量連線會連這個,一個單機大概是連八十萬左右的連線,就是做訊息推送。然後我記得,當時的 swap 分割槽開了,開了是什麼概念?當你有更多連線打進來的時候,然後你記憶體要爆了對吧?記憶體爆的話會自動啟用 swap 分割槽,但一旦你啟用 swap 分割槽,那你係統就卡成狗了,外面使用者斷連之後他就失敗了,他得重連,但是重連到你正常程式能響應,可能又需要三十秒,然後那個使用者肯定覺得超時了,又切斷連線又重連,就造成一個什麼狀態呢?就是系統永遠在重試,永遠沒有一次成功。那這個行為是不是可以預測?這種錯誤當時有沒有做很好的測試?這都是非常重要的一些教訓。

硬體測試以前的辦法是這樣的(Joke):

假設我一個磁碟壞了,假設我一個機器掛了,還有一個假設它不一定壞了也不一定掛了,比如說它著火了會怎麼樣?前兩個月吧,是瑞士還是哪個地方的一個銀行做測試,那哥們也挺逗的,人肉對著伺服器這樣吹氣,來看監控資料那個變化,然後那邊馬上開始報警。這還只是吹氣而已,那如果更復雜的測試,比如說你著火從哪個地方開始燒,先燒到硬碟、或者先燒到網路卡,這個結果可能也是不一樣的。當然這個成本很高,然後也不是能 scale 的一種方案,同時也很難去複製。

這不僅僅是硬體的監控,也可以認為是做錯誤的注入。比如說一個叢集我現在燒掉一臺會怎麼樣?著火了,很典型的嘛,雖然重要的機房都會有這種防火、防水等各種的策略,但是真的著火的時候怎麼辦?當然你不能真去燒,這一燒可能就不止壞一臺機器了,但我們需要使用 Fault injection 來模擬。

我介紹一下到底什麼是 Fault injection。給一個直觀的例子,大家知道所有人都用過 Unix 或者 Linux 的系統,大家都知道,很多人習慣開啟這個系統第一行命令就是 ls 來列出目錄裡面的檔案,但是大家有沒有想過一個有意思的問題,如果你要測試 ls 命令實現的正確性,怎麼測?如果沒有原始碼,這個系統該怎麼測?如果把它當成一黑盒這個系統該怎麼測?如果你 ls 的時候磁碟出現錯誤怎麼辦?如果讀取一個扇區讀取失敗會怎麼辦?

這個是一個很好玩的工具,推薦大家去玩一下。就是當你還沒有做更深入的測試之前,可以先去理解一下到底什麼是 Fault injection,你就可以體驗到它的強大,一會我們用它來找個 MySQL 的 bug。

libfiu - Fault injection in userspace

It can be used to perform fault injection in the POSIX API without having to modify the application's source code, that can help to test failure handling in an easy and reproducible way.

那這個東西主要是用來 Hook 這些 API 的,它很重要的一點就是它提供了一個 library ,這個 library 也可以嵌到你的程式裡面去 hook 那些 API。就比如說你去讀檔案的時候,它可以給你返回這個檔案不存在,可以給你返回磁碟錯誤等等。最重要的是,它是可以重來的。

舉一個例子,正常來講我們敲 ls 命令的時候,肯定是能夠把當前的目錄顯示出來。

這個程式乾的是什麼呢?就是 run,指定一個引數,現在是要有一個 enable_random,就是後面所有的對於 IO 下面這些 API 的操作,有 5% 的失敗率。那第一次是運氣比較好,沒有遇到失敗,所以我們把整個目錄列出來了。然後我們重新再跑一次,這時候它告訴我有一次讀取失敗了,就是它 read 這個 directory 的時候,遇到一個 Bad file descriptor,這時候可以看到,列出來的檔案就比上面的要少了,因為有一條路徑讓它失敗了。接下來,我們進一步再跑,發現剛列出來一個目錄,然後下次讀取就出錯了。然後後面再跑一次的時候,這次運氣也比較好,把這整個都列出來了,這個還只是模擬的 5% 的失敗率。就是有 5% 的概率你去 read、去 open 的時候會失敗,那麼這時候可以看到 ls 命令的行為還是很 stable 的,就是沒有什麼常見的 segment fault 這些。

大家可能會說這個還不太好玩,也就是找找 ls 命令是否有 bug 嘛,那我們復現 MySQL bug 玩一下。

Bug #76020

InnoDB does not report filename in I/O error message for reads

fiu-run -x -c “enable_random name=posix/io/*,probability=0.05” bin/mysqld –

basedir=/data/ushastry/server/mysql-5.6.24 –datadir=/data/ushastry/server/mysql-5.6.24/76020 –core-file –socket=/tmp/mysql_ushastry.sock –port=15000

2015-05-20 19:12:07 31030 [ERROR] InnoDB: Error in system call pread(). The operating system error number is 5.

2015-05-20 19:12:07 7f7986efc720 InnoDB: Operating system error number 5 in a file operation.

InnoDB: Error number 5 means ‘Input/output error’.

2015-05-20 19:12:07 31030 [ERROR] InnoDB: File (unknown):

‘read’ returned OS error 105. Cannot continue operation

這是用 libfiu 找到的 MySQL 的一個 bug,這個 bug 是這樣的,bug 編號是 76020,是說 InnoDB 在出錯的時候沒有報檔名,那使用者給你報了錯,你這時候就傻了對吧?這個到底是什麼地方出錯了呢?然後這個地方它怎麼出來的?你可以看到它還是用我們剛才提到的 fiu-run,然後來模擬,模擬的失敗概率還是這麼多,可以看到,我們的引數一個沒變,這時把 MySQL 啟動,然後跑一下,出現了,可以看到 InnoDB 在報的時候確實沒有報 filename ,File : ‘read’ returned OS error,然後這邊是 auto error,你不知道是哪一個檔名。

換一個思路來看,假設沒有這個東西,你復現這個 bug 的成本是什麼?大家可以想想,如果沒有這個東西,這個 bug 應該怎麼復現,怎麼讓 MySQL 讀取的東西出錯?正常路徑下你讓它讀取出錯太困難了,可能好多年沒出現過。這時我們進一步再放大一下,這個在 5.7 裡面還有,也是在 MySQL 裡面很可能有十幾年大家都沒怎麼遇到過的,但這種 bug 在這個工具的輔助下,馬上就能出來。所以 Fault injection 它帶來了很重要的一個好處就是讓一個東西可以變得更加容易重現。這個還是模擬的 5% 的概率。這個例子是我昨天晚上做的,就是我要給大家一個直觀的理解,但是分散式系統裡面錯誤注入比這個要複雜。而且如果你遇到一個錯誤十年都沒出現,你是不是太孤獨了? 這個電影大家可能還有印象,威爾史密斯主演的,全世界就一個人活著,唯一的夥伴是一條狗。

實際上不是的,比我們痛苦的人大把的存在著。

舉 Netflix 的一個例子,下圖是 Netflix 的系統。

他們在 2014 年 10 月份的時候寫了一篇部落格,叫《 Failure Injection Testing 》,是講他們整個系統怎麼做錯誤注入,然後他們的這個說法是 Internet Scale,就是整個多資料中心網際網路的這個級別。大家可能記得 Spanner 剛出來的時候他們叫做 Global Scale,然後這地方可以看到,藍色是注射點,黑色的是網路呼叫,就是所有這些請求在這些情況下面,所有這些藍色的框框都有可能出錯。大家可以想一想,在 Microservice 系統上,一個業務呼叫可能涉及到幾十個系統的呼叫,如果其中一個失敗了會怎麼樣?如果是第一次第一個失敗,第二次第二個失敗,第三次第三個失敗是怎麼樣的?有沒有系統做過這樣的測試?有沒有系統在自己的程式裡面去很好的驗證過是不是每一個可以預期的錯誤都是可預測的,這個變得非常的重要。這裡以 cache 為例,就說每一次訪問 Cassandra 的時候可能出錯,那麼也就給了我們一個錯誤的注入點。

然後我們談談 OpenStack.

OpenStack fault-injection library:

https://pypi.org/project/os-faults/

大名鼎鼎的 OpenStack 其實也有一個 Failure Injection Library,然後我把這個例子也貼到這裡,大家有興趣可以看一下這個 OpenStack 的 Failure Injection。這以前大家可能不太關注,其實大家在這一點上都很痛苦, OpenStack 現在還有一堆人在罵,說穩定性太差了,其實他們已經很努力了。但是整個系統確實是做的異乎尋常的複雜,因為元件太多。如果你出錯的點特別多,那可能會帶來另外一個問題,就是出錯的點之間還能組合,就是先 A 出錯,再 B 出錯,或者 AB 都出錯,這也就幾種情況,還好。那你要是有十萬個錯誤的點,這個組合怎麼弄?當然現在還有新的論文在研究這個,2015 年的時候好像有一篇論文,講的就是會探測你的程式的路徑,然後在對應的路徑下面去注入錯誤。

再來說 Jepsen.

Jepsen: Distributed Systems Safety Analysis

大家所有聽過的知名的開源分散式系統基本上都被它找出來過 bug。但是在這之前大家都覺得自己還是很 OK 的,我們的系統還是比較穩定的,所以當新的這個工具或者新的方法出現的時候,就比如說我剛才提到的那篇能夠線性 Scale 的去查錯的那篇論文,那個到時候查錯力就很驚人了,因為它能夠自動幫你探測。另外我介紹一個工具 Namazu,後面講,它也很強大。這裡先說Jepsen, 這貨算是重型武器了,無論是 ZooKeeper、MongoDB 以及 Redis 等等,所有這些全部都被找出了 bug,現在用的所有資料庫都是它找出的 bug,最大的問題是小眾語言 closure 編寫的,擴充套件起來有點麻煩。我先說說 Jepsen 的基本原理,一個典型使用 Jepsen 的測試通過會在一個 control node上面執行相關的 clojure 程式,control node 會使用 ssh 登陸到相關的系統 node(jepsen 叫做 db node)進行一些測試操作。

當我們的分散式系統啟動起來之後,control node 會啟動很多程式,每一個程式都能使用特定的 client 訪問到我們的分散式系統。一個 generator 為每一個程式生成一系列的操作,比如 get/set/cas,讓其執行。每一個操作都會被記錄到 history 裡面。在執行操作的同時,另一個 nemesis 程式會嘗試去破壞這個分散式系統,譬如使用 iptable 斷開網路連線等,當所有操作執行完畢之後,jepsen 會使用一個 checker 來分析驗證系統的行為是否符合預期。PingCAP 的首席架構師唐劉寫過兩篇文章介紹我們實際怎麼用 Jepsen 來測試 TiDB,大家可以搜尋一下,我這裡就不詳細展開了。

  • FoundationDB
    • It is difficult to be deterministic
      • Random
      • Disk Size
      • File Length
      • Time
      • Multithread

FoundationDB 這就是前輩了,2015 年被 Apple 收購了。他們為了解決錯誤注入的問題,或者說怎麼去讓它重現的這個問題,做了很多事情,很重要的一個事情就是 deterministic 。如果我給你一樣的輸入,跑幾遍,是不是能得到一樣的輸出?這個聽起來好像很科學、很自然,但是實際上我們絕大多數程式都是做不到的,比如說你們有判斷程式裡面有隨機數嗎?有多執行緒嗎?有判斷磁碟空間嗎?有判斷時間嗎?你再一次判斷的時候還是一樣的嗎?你再跑一次,同樣的輸入,但行為已經不一樣了,比如你生了一個隨機數,比如你判斷磁碟空間,這次判斷和下次判斷可能是不一樣的。

所以他們為了做到“我給你一樣的輸入,一定能得到一樣的輸出”,花了大概兩年的時間做了一個庫。這個庫有以下特性:它是個單執行緒的,然後是個偽併發的。為什麼?因為如果用多執行緒你怎麼讓它這個相同的輸入變成相同的輸出,誰先拿到鎖呢?這裡面的問題很多,所以他們選擇使用單執行緒,但是單執行緒本身有單執行緒的問題。而且比如你用 Go 語言,那你單執行緒它也是個併發的。然後它的語言規範就告訴我們說,如果一個 select 作用在兩個 channel 上,兩個 channel 都 ready 的時候,它會隨機的一個,就是在語言定義的規範上面,就已經不可能讓你得到一個 deterministic 了。但還好 FoundationDB 是用 C++ 寫的。

  • FoundationDB
    • Single-threaded pseudo-concurrency
    • Simulated the implementation of all the external communication
    • Determinism
    • Disasters happen more frequently here than in the real world.

另外 FoundationDB 模擬了所有的網路,就是兩個之間認為通過網路通訊,對吧?實際上是通過它自己模擬的一套東西在通訊。它裡面有一個很重要的觀點就是說,如果磁碟損壞,出現的概率是三年百分之八的話,那麼在使用者那出現的概率是三年百分之八。但是在使用者那一旦出現了,那證明就很嚴重了,所以他們對待這個問題的辦法是什麼?就是我通過自己的模擬系統讓它每時每刻都在產生。它們大概是每兩分鐘產生一次磁碟損壞,也就是說它比現實中的概率要高几十萬倍,所以它就覺得它調的技術 more frequently,就是我這種錯誤出現的更加頻繁,那網路卡損壞的概率是多少?這都是極低的,但是你可以用這個系統讓它每分每秒都產生,這樣一來你就讓你的系統遇到這種錯誤的概率是比現實中要大非常非常多。那你重現,比如說現實中跑三年能重現一次,你可能跑三十秒就能重現一次。

但對於一個 bug 來說最可怕的是什麼?就是它不能重現。發現一個 bug,後來說我 fix 了,然後不能重現了,那你到底 fix 了沒有?不知道,這個事情就變得非常的恐怖。所以通過 deterministic 肯定能保證重現,我只要把我的輸入重放一次,我把它錄下來,每一次我把它錄下來一次,然後只要是曾經出現過,我重放,一定能出現。當然這個代價太大了,所以現在學術界走的是另外一條路,不是完全 deterministic,但是我只需要它 reasonable。比如說我在三十分鐘內能把它重現也是不錯的,我並不需要在三秒內把它重現。所以,每前一步要付出相應的成本代價。

未完待續…

相關文章