PouchContainer Goroutine Leak 檢測實踐

0. 引言

PouchContainer 是阿里巴巴集團開源的一款容器運行時產品,它具有強隔離和可移植性等特色,可用來幫助企業快速實現存量業務容器化,以及提升企業內部物理資源的利用率。git

PouchContainer 同時仍是一款 golang 項目。在此項目中,大量運用了 goroutine 來實現容器管理、鏡像管理和日誌管理等模塊。goroutine 是 golang 在語言層面就支持的用戶態 「線程」,這種原生支持併發的特性可以幫助開發者快速構建高併發的服務。github

雖然 goroutine 容易完成併發或者並行的操做,但若是出現 channel 接收端長時間阻塞卻沒法喚醒的狀態,那麼將會出現 goroutine leak 。 goroutine leak 同內存泄漏同樣可怕,這樣的 goroutine 會不斷地吞噬資源,致使系統運行變慢,甚至是崩潰。爲了讓系統能健康運轉,須要開發者保證 goroutine 不會出現泄漏的狀況。 接下來本文將從什麼是 goroutine leak, 如何檢測以及經常使用的分析工具來介紹 PouchContainer 在 goroutine leak 方面的檢測實踐。golang

1. Goroutine Leak

在 golang 的世界裏,你能支配的土撥鼠有不少,它們既能夠同時處理一大波一樣的問題,也能夠協做處理同一件事,只要你指揮得當,問題就能很快地處理完畢。沒錯,土撥鼠就是咱們常說的 goroutine ,你只要輕鬆地 go 一下,你就擁有了一隻土撥鼠,它便會執行你所指定的任務:shell

func main() { waitCh := make(chan struct{}) go func() { fmt.Println("Hi, Pouch. I'm new gopher!") waitCh <- struct{}{} }() <-waitCh }

正常狀況下,一隻土撥鼠完成任務以後,它將會回籠,而後等待你的下一次召喚。可是也有可能出現這隻土撥鼠很長時間沒有回籠的狀況。api

func main() { // /exec?cmd=xx&args=yy runs the shell command in the host http.HandleFunc("/exec", func(w http.ResponseWriter, r *http.Request) { defer func() { log.Printf("finish %v\n", r.URL) }() out, err := genCmd(r).CombinedOutput() if err != nil { w.WriteHeader(500) w.Write([]byte(err.Error())) return } w.Write(out) }) log.Fatal(http.ListenAndServe(":8080", nil)) } func genCmd(r *http.Request) (cmd *exec.Cmd) { var args []string if got := r.FormValue("args"); got != "" { args = strings.Split(got, " ") } if c := r.FormValue("cmd"); len(args) == 0 { cmd = exec.Command(c) } else { cmd = exec.Command(c, args...) } return }

上面這段代碼會啓動 HTTP Server,它將容許客戶端經過 HTTP 請求的方式來遠程執行 shell 命令,好比可使用 curl "{ip}:8080/exec?cmd=ps&args=-ef" 來查看 Server 端的進程狀況。執行完畢以後,土撥鼠會打印日誌,並說明該指令已執行完畢。併發

可是有些時候,請求須要土撥鼠花很長的時間處理,而請求者卻沒有等待的耐心,好比 curl -m 3 "{ip}:8080/exec?cmd=dosomething",即在 3 秒內執行完某一條命令,否則請求者將會斷開連接。因爲上述代碼並無檢測連接斷開的功能,若是請求者不耐心等待命令完成而是中途斷開連接,那麼這個土撥鼠也只有在執行完畢後纔會回籠。可怕的是,遇到這種 curl -m 1 "{ip}:8080/exec?cmd=sleep&args=10000" ,無法及時回籠的土撥鼠會佔用系統的資源。curl

這些流離在外、不受控制的土撥鼠,就是咱們常說的 goroutine leak 。形成 goroutine leak 的緣由有不少,好比 channel 沒有發送者。運行下面的代碼以後,你會發現 runtime 會穩定地顯示目前共有 2 個 goroutine,其中一個是 main 函數本身,另一個就是一直在等待數據的土撥鼠。函數

func main() { logGoNum() // without sender and blocking.... var ch chan int go func(ch chan int) { <-ch }(ch) for range time.Tick(2 * time.Second) { logGoNum() } } func logGoNum() { log.Printf("goroutine number: %d\n", runtime.NumGoroutine()) }

形成 goroutine leak 有不少種不一樣的場景,本文接下來會經過描述 Pouch Logs API 場景,介紹如何對 goroutine leak 進行檢測並給出相應的解決方案。高併發

2. Pouch Logs API 實踐

2.1 具體場景

爲了更好地說明問題,本文將 Pouch Logs HTTP Handler 的代碼進行簡化:工具

func logsContainer(ctx context.Context, w http.ResponseWriter, r *http.Request) { ... writeLogStream(ctx, w, msgCh) return } func writeLogStream(ctx context.Context, w http.ResponseWriter, msgCh <-chan Message) { for { select { case <-ctx.Done(): return case msg, ok := <-msgCh: if !ok { return } w.Write(msg.Byte()) } } }

Logs API Handler 會啓動 goroutine 去讀取日誌,並經過 channel 的方式將數據傳遞給 writeLogStream ,writeLogStream 便會將數據返回給調用者。這個 Logs API 具備 跟隨 功能,它將會持續地顯示新的日誌內容,直到容器中止。可是對於調用者而言,它隨時都會終止請求。那麼咱們怎麼檢測是否存在遺留的 goroutine 呢?

當連接斷開以後,Handler 還想給 Client 發送數據,那麼將會出現 write: broken pipe 的錯誤,一般狀況下 goroutine 會退出。可是若是 Handler 還在長時間等待數據的話,那麼就是一次 goroutine leak 事件。

2.2 如何檢測 goroutine leak?

對於 HTTP Server 而言,咱們一般會經過引入包 net/http/pprof 來查看當前進程運行的狀態,其中有一項就是查看 goroutine stack 的信息,{ip}:{port}/debug/pprof/goroutine?debug=2 。咱們來看看調用者主動斷開連接以後的 goroutine stack 信息。

# step 1: create background job pouch run -d busybox sh -c "while true; do sleep 1; done" # step 2: follow the log and stop it after 3 seconds curl -m 3 {ip}:{port}/v1.24/containers/{container_id}/logs?stdout=1&follow=1 # step 3: after 3 seconds, dump the stack info curl -s "{ip}:{port}/debug/pprof/goroutine?debug=2" | grep -A 10 logsContainer github.com/alibaba/pouch/apis/server.(*Server).logsContainer(0xc420330b80, 0x251b3e0, 0xc420d93240, 0x251a1e0, 0xc420432c40, 0xc4203f7a00, 0x3, 0x3) /tmp/pouchbuild/src/github.com/alibaba/pouch/apis/server/container_bridge.go:339 +0x347 github.com/alibaba/pouch/apis/server.(*Server).(github.com/alibaba/pouch/apis/server.logsContainer)-fm(0x251b3e0, 0xc420d93240, 0x251a1e0, 0xc420432c40, 0xc4203f7a00, 0x3, 0x3) /tmp/pouchbuild/src/github.com/alibaba/pouch/apis/server/router.go:53 +0x5c github.com/alibaba/pouch/apis/server.withCancelHandler.func1(0x251b3e0, 0xc420d93240, 0x251a1e0, 0xc420432c40, 0xc4203f7a00, 0xc4203f7a00, 0xc42091dad0) /tmp/pouchbuild/src/github.com/alibaba/pouch/apis/server/router.go:114 +0x57 github.com/alibaba/pouch/apis/server.filter.func1(0x251a1e0, 0xc420432c40, 0xc4203f7a00) /tmp/pouchbuild/src/github.com/alibaba/pouch/apis/server/router.go:181 +0x327 net/http.HandlerFunc.ServeHTTP(0xc420a84090, 0x251a1e0, 0xc420432c40, 0xc4203f7a00) /usr/local/go/src/net/http/server.go:1918 +0x44 github.com/alibaba/pouch/vendor/github.com/gorilla/mux.(*Router).ServeHTTP(0xc4209fad20, 0x251a1e0, 0xc420432c40, 0xc4203f7a00) /tmp/pouchbuild/src/github.com/alibaba/pouch/vendor/github.com/gorilla/mux/mux.go:133 +0xed net/http.serverHandler.ServeHTTP(0xc420a18d00, 0x251a1e0, 0xc420432c40, 0xc4203f7800)

咱們會發現當前進程中還存留着 logsContainer goroutine。由於這個容器沒有輸出任何日誌的機會,因此這個 goroutine 沒辦法經過 write: broken pipe 的錯誤退出,它會一直佔用着系統資源。那咱們該怎麼解決這個問題呢?

2.3 怎麼解決?

golang 提供的包 net/http 有監控連接斷開的功能:

// HTTP Handler Interceptors func withCancelHandler(h handler) handler { return func(ctx context.Context, rw http.ResponseWriter, req *http.Request) error { // https://golang.org/pkg/net/http/#CloseNotifier if notifier, ok := rw.(http.CloseNotifier); ok { var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) waitCh := make(chan struct{}) defer close(waitCh) closeNotify := notifier.CloseNotify() go func() { select { case <-closeNotify: cancel() case <-waitCh: } }() } return h(ctx, rw, req) } }

當請求還沒執行完畢時,客戶端主動退出了,那麼 CloseNotify() 將會收到相應的消息,並經過 context.Context 來取消,這樣咱們就能夠很好地處理 goroutine leak 的問題了。在 golang 的世界裏,你會常常看到 讀 和 寫 的 goroutine,它們這種函數的第一個參數通常會帶有 context.Context , 這樣就能夠經過 WithTimeout 和 WithCancel 來控制 goroutine 的回收,避免出現泄漏的狀況。

CloseNotify 並不適用於 Hijack 連接的場景,由於 Hijack 以後,有關於連接的全部處理都交給了實際的 Handler,HTTP Server 已經放棄了數據的管理權。

那麼這樣的檢測能夠作成自動化嗎?下面會結合經常使用的分析工具來進行說明。

3. 經常使用的分析工具

3.1 net/http/pprof

在開發 HTTP Server 的時候,咱們能夠引入包 net/http/pprof 來打開 debug 模式,而後經過 /debug/pprof/goroutine 來訪問 goroutine stack 信息。通常狀況下,goroutine stack 會具備如下樣式。

goroutine 93 [chan receive]: github.com/alibaba/pouch/daemon/mgr.NewContainerMonitor.func1(0xc4202ce618) /tmp/pouchbuild/src/github.com/alibaba/pouch/daemon/mgr/container_monitor.go:62 +0x45 created by github.com/alibaba/pouch/daemon/mgr.NewContainerMonitor /tmp/pouchbuild/src/github.com/alibaba/pouch/daemon/mgr/container_monitor.go:60 +0x8d goroutine 94 [chan receive]: github.com/alibaba/pouch/daemon/mgr.(*ContainerManager).execProcessGC(0xc42037e090) /tmp/pouchbuild/src/github.com/alibaba/pouch/daemon/mgr/container.go:2177 +0x1a5 created by github.com/alibaba/pouch/daemon/mgr.NewContainerManager /tmp/pouchbuild/src/github.com/alibaba/pouch/daemon/mgr/container.go:179 +0x50b

goroutine stack 一般第一行包含着 Goroutine ID,接下來的幾行是具體的調用棧信息。有了調用棧信息,咱們就能夠經過 關鍵字匹配 的方式來檢索是否存在泄漏的狀況了。

在 Pouch 的集成測試裏,Pouch Logs API 對包含 (*Server).logsContainer 的 goroutine stack 比較感興趣。所以在測試跟隨模式完畢後,會調用 debug 接口檢查是否包含 (*Server).logsContainer 的調用棧。一旦發現包含便說明該 goroutine 尚未被回收,存在泄漏的風險。

總的來講,debug 接口的方式適用於 集成測試 ,由於測試用例和目標服務不在同一個進程裏,須要 dump 目標進程的 goroutine stack 來獲取泄漏信息。

3.2 runtime.NumGoroutine

當測試用例和目標函數/服務在同一個進程裏時,能夠經過 goroutine 的數目變化來判斷是否存在泄漏問題。

func TestXXX(t *testing.T) { orgNum := runtime.NumGoroutine() defer func() { if got := runtime.NumGoroutine(); orgNum != got { t.Fatalf("xxx", orgNum, got) } }() ... }

3.3 github.com/google/gops

gops 與包 net/http/pprof 類似,它是在你的進程內放入了一個 agent ,並提供命令行接口來查看進程運行的狀態,其中 gops stack ${PID} 能夠查看當前 goroutine stack 狀態。

4. 小結

開發 HTTP Server 時,net/http/pprof 有助於咱們分析代碼狀況。若是代碼邏輯複雜、存在可能出現泄漏的狀況時,不妨標記一些可能泄漏的函數,並將其做爲測試中的一個環節,這樣自動化 CI 就能在代碼審閱前發現問題。

5. 相關連接

相關文章
相關標籤/搜索