Go1.13 標準庫的 http 包爆出重大 bug,你的項目中招了嗎?

2019-12-26     Go語言中文網

本文作者:王亞樓,首發於 Go語言中文網 公眾號

概述

2019 年 11 月 21 日,golang 的官方 github 倉庫提交了一個 https://github.com/golang/go/issues/35750 ,該 issue 指出如果初始化 http.Server 結構體時指定了一個非空的 ConnContext 成員函數,且如果在該函數內使用了 context.Value 方法寫入數據到上下文並返回,則 Context 將會以鍊表的方式泄漏。

這是一個很恐怖的 bug,因為一方面 http.Server 是幾乎所有市面上流行 web 框架如 gin,beego 的底層依賴,一旦發生問題則全部中招,一個都跑不了;另一方面則 ConnContext 函數在每一個請求初始化時都會被調用,這意味著如果一旦發生泄漏,則服務端程序幾乎一定會溢出。

據官方開發人員表示,該 bug 於 1.13 版本引進,目前已經在 1.13.5 修復。

影響範圍

所有 1.13~1.13.4 版本,使用原生 http.Server 指定了 ConnContext 成員函數,且在該函數中使用 With*方法寫入數據並返回新 Context;或者使用了上層框架的相應功能。

現象

內存根據訪問量持續上升,且 pprof 分析發現 cpu 大量耗費在 Context 的底層方法上。

故障原理

問題出在 Server.Serve 方法,該方法是 http.Server 啟動的底層唯一入口,負責循環 Accept 連接以及為每個新連接開啟 goroutine 做下一步處理。

來看看出問題的代碼,為了簡潔這裡略去不必要代碼:

type Server struct {    ...// ConnContext optionally specifies a function that modifies// the context used for a new connection c. The provided ctx// is derived from the base context and has a ServerContextKey// value.ConnContext func(ctx context.Context, c net.Conn) context.Context    ...}func (srv *Server) Serve(l net.Listener) error {        ...baseCtx := context.Background()        ...ctx := context.WithValue(baseCtx, ServerContextKey, srv)for {rw, e := l.Accept()        ...if cc := srv.ConnContext; cc != nil {ctx = cc(ctx, rw)if ctx == nil {panic("ConnContext returned nil")}}                ...go c.serve(ctx)}}

我們知道 Context 是一個反向鍊表結構,從最初的 Background 通過各種 With 方法推入表頭節點,而 With 方法返回的則是新的表頭節點。

從上邊的代碼中我們看到,如果 srv.ConnContext 不為空,則每次 Accept 連接後都會調用此函數並傳入 ctx,然後將返回的結果存入 ctx 中,這意味著如果在此函數中使用 With 函數寫入節點並返回,則該節點將被緩存到全局的 ctx,從而造成泄漏。

復現

這個 bug 非常容易復現,下面我們復現一下:

// go version:1.13.4func main() {var count int32 = 0server := &http.Server{Addr: ":4444",Handler: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {rw.Header().Set("Connection", "close")}),ConnContext: func(ctx context.Context, c net.Conn) context.Context {atomic.AddInt32(&count, 1)if c2 := ctx.Value("count"); c2 != nil {fmt.Printf("發現了遺留數據: %d\\n", c2.(int32))}fmt.Printf("本次數據: %d\\n", count)return context.WithValue(ctx, "count", count)},}go func() {panic(server.ListenAndServe())}()var err errorfmt.Println("第一次請求")_, err = http.Get("http://localhost:4444/")if err != nil {panic(err)}fmt.Println("\\n第二次請求")_, err = http.Get("http://localhost:4444/")if err != nil {panic(err)}}

結果:

第一次請求本次數據: 1第二次請求發現了遺留數據: 1本次數據: 2

可以看到,第二個從請求的 Context 中能讀取到第一個請求的 Context 中寫入的數據,確實發生了泄漏。

修復

我們首先要理解 ConnContext 這個函數的作用,按照設計它應該是為每個請求的 Context 做一些初始化處理,然後將這個處理後的 Context 鏈傳入go c.serve(ctx),而不應該緩存到全局;下一個請求過來後應該將原始的 Context 傳入 ConnContext 進行處理,從而得到新的 Context 鏈。

明白了目的,再看看問題代碼,我們發現罪魁禍首在這裡

ctx = cc(ctx, rw)

這一行錯誤地將 cc 方法生成的新鏈緩存到了全局,導致泄漏(ps:實在是搞不懂 google 的大神居然會犯這麼低級且致命的錯誤...)。

修復後的代碼如下:

func (srv *Server) Serve(l net.Listener) error {...baseCtx := context.Background()...ctx := context.WithValue(baseCtx, ServerContextKey, srv)for {rw, e := l.Accept()...connCtx = ctxif cc := srv.ConnContext; cc != nil {connCtx = cc(connCtx, rw)if connCtx == nil {panic("ConnContext returned nil")}}...go c.serve(connCtx)}}


文章來源: https://twgreatdaily.com/zh-mo/KBq5T28BMH2_cNUgYZft.html