歡迎光臨
每天分享高質量文章

golang在阿裡開源容器專案Pouch中的應用實踐

傅偉(聿歌):阿裡巴巴高級研發工程師。熱衷golang ,目前負責研發阿裡巴巴開源容器 PouchContainer 專案的建設。

前言

       可測試的功能模塊,以及在沒有異常機制的情況下,PouchContainer 又是如何優雅地處理底層傳回的錯誤。除此之外,還會分享 PouchContainer 在代碼風格規範和 golang 單元測試用例上的實踐。最後分享一些在生產環境下排查 golang 問題的實踐。我會從五個方面介紹PouchContainer,如下圖所示:

What is PouchContainer?

     首先,我們來介紹一下什麼是 PouchContainer 。PouchContainer 是阿裡巴巴集團開源的高效、輕量級企業級富容器引擎技術,擁有隔離性強、對低內核版本友好,可移植性高、資源占用少等特性。可以幫助企業快速實現存量業務容器化改造,同時提高超大規模下資料中心的物理資源利用率。這些特性在阿裡巴巴內部都有大量的實踐驗證的。

   我們看這張圖,可以發現 PouchContainer 對社區生態支持還是比較好,比如調度方面的 k8s 和 docker swarm,分佈式儲存的 ceph,以及不同容器運行時 runc/runv/kata 的支持,除此之外,阿裡巴巴集團還開源了一款 p2p 鏡像分發工具,可以快速下載超大容量的鏡像。因為現在企業內部的鏡像都比較大,假如不能夠快速下載、快速分發,你的構建部署都會非常慢,在這種場景下可以通過 P2P 的方式來加快分發。

       關於 PouchContainer 的介紹就這麼多,當前已經開源了,開源地址也在這裡,門口也放了一些宣傳冊,大家可以自取看一下。除了這些之外 PouchContainer 還有一個比較切題的屬性,就是它是 golang 所開發出來的,接下我會介紹 PouchContainer 是如何使用 golang 來構建專案。

pouchContainer HTTP API Code Design

單體應用&微服務應用的區別

       在講這部分內容之前呢,我們先來簡單回顧一下 單體應用 和 微服務之間的區別。

      首先我們來看下單體應用的特點,一個單體應用包含了很多個功能模塊,很顯然,隨著業務的發展,模塊的數量會逐漸增多,應用的規模也會不斷變大,這會讓構建和維護應用程式代碼庫的開發者不堪重負。當其中一個模塊出問題之後,可能會直接讓這個應用崩潰,導致其他模塊也不可使用,這種場景很難讓人能接受。

       現在社區會推崇微服務的做法,將單體應用拆分成多個模塊,一個大的應用變成多個小應用,構建,部署,應用的水平拓展和維護方面都得改善。常見的操作是將模塊變成 RPC 服務模塊,對外由 HTTP API Server 來暴露接口,右邊這張圖是一個典型的 BFF backend for frontend 的組織架構。因為應用在組織層面的變化,也導致模塊依賴的方式也發生了變化。在單體應用里,模塊之間可以通過 share code 的方式來使用相關的業務邏輯。但是模塊拆分之後,模塊的開發語言可能會發現變化,依賴方式不再是 share code 的方式,而更多是通過 component client 的方式。

     那麼從代碼的角度看,那我們怎麼去使用這些依賴呢?

Server&業務邏輯

        首先,比較簡單的方式是直接使用依賴,如圖所示 FooServer 直接使用了 Bar 服務的客戶端,簡單看起來沒什麼問題哈。其實想一想,這種方式可能會面臨著 FooServer 並不能直接消費 Bar 客戶端傳回的結果,那麼我們需要在 Echo 里嵌入一些適配的工作,這樣會 Echo 業務邏輯閱讀起來不流暢,難以維護。除此之外,如果我們想要對 Bar Client 的鏈接數進行優化等操作,難道我們要將優化的邏輯暴露在實際的業務邏輯裡面嗎?我們是不是可以把這些細節同邏輯分離開?

By interface

      所以我們會在剛剛的基礎上,通過一個 interface 做隔離,業務邏輯裡面不會嵌入具體實現。你所依賴的邏輯全在 interface 裡面描述了,它描述的是行為和具體的邏輯。

     首先 Server 業務邏輯里的依賴由 Component interface 來描述,它描述的是行為和邏輯,這樣在 Server 業務代碼里並看不見邏輯背後的實現細節這樣我們在做優化,在做調整的時候,並不需要調整業務邏輯代碼,保證對上層邏輯不可見。

       看下右邊的表格,我列了三種情況,一種是什麼都不做,直接使用依賴;第二種是做配接器,比如調整引數結構,建立公用的鏈接池等優化;還有一種就是測試,golang 並不想ruby, python那樣,可以動態地修改一個函式的行為,讓其傳回我們想要的結果。

       對於那些依賴外部 component 的邏輯,我們在單元測試階段並不會啟動這些服務來輔助測試,我們只能通過mock的方式來解決依賴關係,沒有 interface 作為中間層,我們是很難去測試我們的業務邏輯的。

       除了這部分設計之後,還有一個重要問題需要處理的便是 http error code。當上層 http handler 拿到一個error的時候,我們該如何正確地傳回錯誤碼呢?

       Golang 同其他oop語言不同,很多語言都會通過raise 不同型別的 exception,通過統一的 try-catch 來識別不同型別的錯誤。回想下 golang error 定義,就一個 Error() string ,它只能傳回一個字串。我們總不能在系統里通過字串的判斷來決定傳回什麼樣的錯誤碼,這樣就太脆弱了。那我們需要怎麼做呢?

Assert ErrotType

      目前我們的實現方式是通過斷言錯誤型別的方式。通過引入一個特別的 error type,其中 code 用來儲存錯誤碼。就目前來說,這種方式能解決http error code的問題,但是我們還可以做的更好,那就是用過判斷行為的方式,傳回的error除了基礎的行為外,它還會具備不同的行為,比如 not found。http handler 會判斷error是否具備 NOTFound 行為。

       這種方式同斷言錯誤型別來說,並沒有太大的差別。而斷言錯誤型別而言呢,當你在構建第三方package的時候,錯誤型別必須要公開,而且還需要註意是否為指標型別。斷言行為的方式並不需要這些細節,總的來說還是比較推薦斷言行為的方式。

PouchContainer Test Practice

       接下來,我們來看看一下測試的實踐。


遵循契約

      首先你得做代碼檢查,就像前面講師說的,各個社區有很多開源靜態觀察的工具,如果這些都不能滿足你的話,golang 還有一個包叫 AST,能夠幫你解析語法,你也可以定製一些靜態檢查的東西。這些都通過了之後,才能保持專案的代碼風格一致。

       在review代碼過程中,代碼邏輯看起來是正確的,那麼怎麼保證代碼邏輯正確?那就需要開發者編寫測試代碼來驗證。

DRY

      一個函式可能對應多組輸出,Table-Driven 會用陣列來組織測試用例,通過遍歷迴圈來不斷驗證你的輸入,驗證你的函式是否經得起各種場景的驗證。但是這個函式不一定會這麼簡單,可能有外部的依賴,在做這個之前可能需要做一些處理化的工作。

    PouchContainer 內部使用 Mock interface 來解決外部依賴關係。 golang 不像做 ruby 等其他腳本語言那樣,可以在運行時修改函式行為,所以 PouchContainer 使用 interface 來組織依賴關係。

      如圖所示,這個 Server 會依賴於 ImageServiceComponent interface,在實際運行的時候會向遠端發服務請求,但是我們測試代碼並不需要發出真實的服務請求,只需要驗證自己的輸入是否正確即可。在這種情況下,使用 Mock interface 來解決依賴會很輕鬆。

       上面提到的 monkeypatch 第三方庫需要在編譯代碼的時候關閉 inline,這樣它在運行的時候可以找到你函式的地址,然後把新的地址複製過去,通過這種方式修改函式的邏輯。不過還是推薦使用 interface 來組織代碼結構,這方式太 hack 了。

  

Inspect too many open files issue


action takes long

      關於代碼組織架構測試就講這些,最後再向大家分享一個案例,我們前段時間遇到的,too many open files issue。

      首先它的處理時間比較長,當客戶端斷開連接之後服務端沒法感知到,為了模擬這個場景我用1024個請求同時打到這個 API 上來模擬這個場景。我們得到了什麼樣的結果呢?發現那個客戶端沒法兒連接到 Server。根據這個錯誤信息,我們查改行程的 limitation,發現打開檔案句柄個數的軟限制是 1024,但實際上它已經超了,所以新來的請求沒法建立連接。

run into the problem

       這個問題首先我們需要知道這個請求卡在哪兒。前面的導師也說了,說 golang 有很多種測試的框架和工具,比如 pprof 。但是在這個場景下用 pprof 解決不了問題,因為請求發不出去。我們嘗試使用 gdb 去 dump 呼叫棧信息,發現全是 runtime.findrunnable 。當目前為止還是看不見具體的函式呼叫卡在什麼位置了。

Kill-USR1

      為了更讓服務正常退出,一般會在啟動的時候監聽信號量。假如獲取到了Kill信號量,服務就會做一些清理工作,就很優雅的正常退出。所以我們在啟動服務的時候,起一個 goroutine 去監聽 USR1 的信號量,一旦接受到信號就會 dump goroutine stack.

      通過這種方式,只要你發一個 Kill-USR1 就會打到你的日誌檔案裡面,就能知道當時哪個 API 出了問題這種方式的好處,在於你用 pprof 會暴露接口,暴露接口可能會遇到連接數過多的問題,可能很難定位到當時的問題。這種方式有的優點,在於你不需要暴露接口,你必須有權限登陸到機器上才能看到當時運行時的狀態。所以我們比較推薦這種方式去操作,比較安全一些

Q&A;

       提問:沒有加信號量方法的時候,是怎麼發現和定位這個問題的?

       傅偉:當時發現這個服務 ping 不通了,在遷移完業務行程之後,嘗試了各種方法之後決定重啟,並加入上述代碼,等到它第二次出現的時候我們才拿到結果。

        提問:你這個做法我以前也試過。但有時候出了問題,你很難找到具體的問題。

        傅偉: 對,第一現場比較重要。因為這次出問題不在於系統呼叫,不然的話 dump stack 還是可以發現問題的。多說一句,golang 不會去回收空閑的執行緒,當系統呼叫時間比較長的時候,golang 會創建新的執行緒去執行任務。當你的執行緒漲到200的時候是很恐怖的,但實際上 golang 現在的模型只需要幾十個,不需要上百個,在這個角度看,golang也有它的一些調度問題。

       提問:還是回到那個問題,線上使用 gdb attach,這是嚴格禁止的。還有是怎麼發現哪個打開檔案太多?它可以看行程,打開了什麼檔案可以看出來。我們以前也是碰到程式運行了大半年,但檔案打開太多出現了問題,最終問題還是沒有寫好,通過改定位打開什麼檔案之後,然後定位到這個程式裡面去,最後修改掉。

      傅偉:首先我們做的事情是把容器遷進去,我們已經把一些生產中的東西遷移出去了,所以操作都是得到業務方,包括應用方允許之後才這麼做。當時這個行程已經不再服務了,只用來看問題。問題是因為打開檔案句柄太多了,使用 dump stack 的原因也是想要看到第一現場,不會在線上直接這麼操作,操作的時候已經是一個完全隔離的環境了。

       提問:剛剛那個問題你是模擬的,我想知道真正線上是什麼樣的問題。

       傅偉:因為當時是一個鎖的問題,因為 IO 處理需要等待它把資料全吐出來之後然後才能正常關閉,但是這個關閉一直等在那兒。因為我們當時有一個漏洞,會導致這個資料一直沒有吐出來。

       提問:是不是用檢測機制也可以?

      傅偉:檢測不出,它跟競爭沒有關係,是一個漏洞導致這個資料沒法兒正常退出。

赞(0)

分享創造快樂