技术解析系列 PouchContainer Goroutine Leak 检测实践
?
?
劃重點(diǎn)
本文將從什么是 goroutine leak,如何檢測(cè)以及常用的分析工具來(lái)介紹 PouchContainer 在 goroutine leak 方面的檢測(cè)實(shí)踐。本文作者聿歌。
0. 引言
PouchContainer?是阿里巴巴集團(tuán)開源的一款容器運(yùn)行時(shí)產(chǎn)品,它具備強(qiáng)隔離和可移植性等特點(diǎn),可用來(lái)幫助企業(yè)快速實(shí)現(xiàn)存量業(yè)務(wù)容器化,以及提高企業(yè)內(nèi)部物理資源的利用率。
PouchContainer 同時(shí)還是一款 golang 項(xiàng)目。在此項(xiàng)目中,大量運(yùn)用了 goroutine 來(lái)實(shí)現(xiàn)容器管理、鏡像管理和日志管理等模塊。goroutine 是 golang 在語(yǔ)言層面就支持的用戶態(tài) “線程”,這種原生支持并發(fā)的特性能夠幫助開發(fā)者快速構(gòu)建高并發(fā)的服務(wù)。
雖然 goroutine 容易完成并發(fā)或者并行的操作,但如果出現(xiàn) channel 接收端長(zhǎng)時(shí)間阻塞卻無(wú)法喚醒的狀態(tài),那么將會(huì)出現(xiàn)?goroutine leak?。 goroutine leak 同內(nèi)存泄漏一樣可怕,這樣的 goroutine 會(huì)不斷地吞噬資源,導(dǎo)致系統(tǒng)運(yùn)行變慢,甚至是崩潰。為了讓系統(tǒng)能健康運(yùn)轉(zhuǎn),需要開發(fā)者保證 goroutine 不會(huì)出現(xiàn)泄漏的情況。 接下來(lái)本文將從什么是 goroutine leak, 如何檢測(cè)以及常用的分析工具來(lái)介紹 PouchContainer 在 goroutine leak 方面的檢測(cè)實(shí)踐。
1. Goroutine Leak
在 golang 的世界里,你能支配的土撥鼠有很多,它們既可以同時(shí)處理一大波同樣的問題,也可以協(xié)作處理同一件事,只要你指揮得當(dāng),問題就能很快地處理完畢。沒錯(cuò),土撥鼠就是我們常說(shuō)的?goroutine?,你只要輕松地?go?一下,你就擁有了一只土撥鼠,它便會(huì)執(zhí)行你所指定的任務(wù):
func main() {waitCh := make(chan struct{})go func() {fmt.Println("Hi, Pouch. I'm new gopher!")waitCh <- struct{}{}}()<-waitCh }正常情況下,一只土撥鼠完成任務(wù)之后,它將會(huì)回籠,然后等待你的下一次召喚。但是也有可能出現(xiàn)這只土撥鼠很長(zhǎng)時(shí)間沒有回籠的情況。
func main() {// /exec?cmd=xx&args=yy runs the shell command in the hosthttp.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 []stringif 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 }上面這段代碼會(huì)啟動(dòng) HTTP Server,它將允許客戶端通過(guò) HTTP 請(qǐng)求的方式來(lái)遠(yuǎn)程執(zhí)行 shell 命令,比如可以使用?curl "{ip}:8080/exec?cmd=ps&args=-ef"?來(lái)查看 Server 端的進(jìn)程情況。執(zhí)行完畢之后,土撥鼠會(huì)打印日志,并說(shuō)明該指令已執(zhí)行完畢。
但是有些時(shí)候,請(qǐng)求需要土撥鼠花很長(zhǎng)的時(shí)間處理,而請(qǐng)求者卻沒有等待的耐心,比如?curl -m 3 "{ip}:8080/exec?cmd=dosomething",即在 3 秒內(nèi)執(zhí)行完某一條命令,不然請(qǐng)求者將會(huì)斷開鏈接。由于上述代碼并沒有檢測(cè)鏈接斷開的功能,如果請(qǐng)求者不耐心等待命令完成而是中途斷開鏈接,那么這個(gè)土撥鼠也只有在執(zhí)行完畢后才會(huì)回籠。可怕的是,遇到這種?curl -m 1 "{ip}:8080/exec?cmd=sleep&args=10000"?,沒法及時(shí)回籠的土撥鼠會(huì)占用系統(tǒng)的資源。
這些流離在外、不受控制的土撥鼠,就是我們常說(shuō)的?goroutine leak?。造成 goroutine leak 的原因有很多,比如 channel 沒有發(fā)送者。運(yùn)行下面的代碼之后,你會(huì)發(fā)現(xiàn) runtime 會(huì)穩(wěn)定地顯示目前共有 2 個(gè) goroutine,其中一個(gè)是?main?函數(shù)自己,另外一個(gè)就是一直在等待數(shù)據(jù)的土撥鼠。
func main() {logGoNum()// without sender and blocking....var ch chan intgo 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 有很多種不同的場(chǎng)景,本文接下來(lái)會(huì)通過(guò)描述 Pouch Logs API 場(chǎng)景,介紹如何對(duì) goroutine leak 進(jìn)行檢測(cè)并給出相應(yīng)的解決方案。
2. Pouch Logs API 實(shí)踐
2.1 具體場(chǎng)景
為了更好地說(shuō)明問題,本文將 Pouch Logs HTTP Handler 的代碼進(jìn)行簡(jiǎn)化:
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():returncase msg, ok := <-msgCh:if !ok {return}w.Write(msg.Byte())}} }Logs API Handler 會(huì)啟動(dòng) goroutine 去讀取日志,并通過(guò) channel 的方式將數(shù)據(jù)傳遞給?writeLogStream?,writeLogStream?便會(huì)將數(shù)據(jù)返回給調(diào)用者。這個(gè) Logs API 具有?跟隨?功能,它將會(huì)持續(xù)地顯示新的日志內(nèi)容,直到容器停止。但是對(duì)于調(diào)用者而言,它隨時(shí)都會(huì)終止請(qǐng)求。那么我們?cè)趺礄z測(cè)是否存在遺留的 goroutine 呢?
當(dāng)鏈接斷開之后,Handler 還想給 Client 發(fā)送數(shù)據(jù),那么將會(huì)出現(xiàn) write: broken pipe 的錯(cuò)誤,通常情況下 goroutine 會(huì)退出。但是如果 Handler 還在長(zhǎng)時(shí)間等待數(shù)據(jù)的話,那么就是一次 goroutine leak 事件。
2.2 如何檢測(cè) goroutine leak?
對(duì)于 HTTP Server 而言,我們通常會(huì)通過(guò)引入包?net/http/pprof?來(lái)查看當(dāng)前進(jìn)程運(yùn)行的狀態(tài),其中有一項(xiàng)就是查看 goroutine stack 的信息,{ip}:{port}/debug/pprof/goroutine?debug=2?。我們來(lái)看看調(diào)用者主動(dòng)斷開鏈接之后的 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)我們會(huì)發(fā)現(xiàn)當(dāng)前進(jìn)程中還存留著?logsContainer?goroutine。因?yàn)檫@個(gè)容器沒有輸出任何日志的機(jī)會(huì),所以這個(gè) goroutine 沒辦法通過(guò)?write: broken pipe?的錯(cuò)誤退出,它會(huì)一直占用著系統(tǒng)資源。那我們?cè)撛趺唇鉀Q這個(gè)問題呢?
2.3 怎么解決?
golang 提供的包?net/http?有監(jiān)控鏈接斷開的功能:
// 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/#CloseNotifierif notifier, ok := rw.(http.CloseNotifier); ok {var cancel context.CancelFuncctx, 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)} }當(dāng)請(qǐng)求還沒執(zhí)行完畢時(shí),客戶端主動(dòng)退出了,那么?CloseNotify()?將會(huì)收到相應(yīng)的消息,并通過(guò)?context.Context?來(lái)取消,這樣我們就可以很好地處理 goroutine leak 的問題了。在 golang 的世界里,你會(huì)經(jīng)常看到?讀_ 和 _寫?的 goroutine,它們這種函數(shù)的第一個(gè)參數(shù)一般會(huì)帶有?context.Context?, 這樣就可以通過(guò)?WithTimeout?和?WithCancel?來(lái)控制 goroutine 的回收,避免出現(xiàn)泄漏的情況。
CloseNotify 并不適用于 Hijack 鏈接的場(chǎng)景,因?yàn)?Hijack 之后,有關(guān)于鏈接的所有處理都交給了實(shí)際的 Handler,HTTP Server 已經(jīng)放棄了數(shù)據(jù)的管理權(quán)。
那么這樣的檢測(cè)可以做成自動(dòng)化嗎?下面會(huì)結(jié)合常用的分析工具來(lái)進(jìn)行說(shuō)明。
3. 常用的分析工具
3.1 net/http/pprof
在開發(fā) HTTP Server 的時(shí)候,我們可以引入包?net/http/pprof?來(lái)打開 debug 模式,然后通過(guò)?/debug/pprof/goroutine?來(lái)訪問 goroutine stack 信息。一般情況下,goroutine stack 會(huì)具有以下樣式。
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 +0x8dgoroutine 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 +0x50bgoroutine stack 通常第一行包含著 Goroutine ID,接下來(lái)的幾行是具體的調(diào)用棧信息。有了調(diào)用棧信息,我們就可以通過(guò)?關(guān)鍵字匹配?的方式來(lái)檢索是否存在泄漏的情況了。
在 Pouch 的集成測(cè)試?yán)?#xff0c;Pouch Logs API 對(duì)包含?(*Server).logsContainer?的 goroutine stack 比較感興趣。因此在測(cè)試跟隨模式完畢后,會(huì)調(diào)用?debug?接口檢查是否包含?(*Server).logsContainer?的調(diào)用棧。一旦發(fā)現(xiàn)包含便說(shuō)明該 goroutine 還沒有被回收,存在泄漏的風(fēng)險(xiǎn)。
總的來(lái)說(shuō),debug?接口的方式適用于?集成測(cè)試?,因?yàn)闇y(cè)試用例和目標(biāo)服務(wù)不在同一個(gè)進(jìn)程里,需要 dump 目標(biāo)進(jìn)程的 goroutine stack 來(lái)獲取泄漏信息。
3.2 runtime.NumGoroutine
當(dāng)測(cè)試用例和目標(biāo)函數(shù)/服務(wù)在同一個(gè)進(jìn)程里時(shí),可以通過(guò) goroutine 的數(shù)目變化來(lái)判斷是否存在泄漏問題。
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?相似,它是在你的進(jìn)程內(nèi)放入了一個(gè) agent ,并提供命令行接口來(lái)查看進(jìn)程運(yùn)行的狀態(tài),其中?gops stack ${PID}?可以查看當(dāng)前 goroutine stack 狀態(tài)。
4. 小結(jié)
開發(fā) HTTP Server 時(shí),net/http/pprof?有助于我們分析代碼情況。如果代碼邏輯復(fù)雜、存在可能出現(xiàn)泄漏的情況時(shí),不妨標(biāo)記一些可能泄漏的函數(shù),并將其作為測(cè)試中的一個(gè)環(huán)節(jié),這樣自動(dòng)化 CI 就能在代碼審閱前發(fā)現(xiàn)問題。
?
總結(jié)
以上是生活随笔為你收集整理的技术解析系列 PouchContainer Goroutine Leak 检测实践的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 技术解析系列 阿里 PouchCont
- 下一篇: 技术解析系列 | PouchContai