[譯]Golang中的優雅重啟
作者grisha
宣告:本文目的僅僅作為個人mark,所以在翻譯的過程中參雜了自己的思想甚至改變了部分內容,其中有下劃線的文字為譯者新增。但由於譯者水平有限,所寫文字或者程式碼可能會誤導讀者,如發現文章有問題,請儘快告知,不勝感激。
前言
Update (Apr 2015):Florian von Bock 已經根據本文實現了一個叫做endless 的Go package
大家知道,當我們用Go寫的web伺服器需要修改配置或者需要升級程式碼的時候我們需要重啟伺服器,如果你(像我一樣)已經將優雅的重啟視為理所當然,因為使用Golang你需要自己動手來做這些操作,所以你可能會發現這個方式非常方便。
什麼是優雅重啟
本文中的優雅重啟表現為兩點
- 程序在不關閉其所監聽的埠的情況下重啟
- 重啟過程中保證所有請求能被正確的處理
1.程序在不關閉其所監聽的埠的情況下重啟
- fork一個子程序,該子程序繼承了父程序所監聽的socket
- 子程序執行初始化等操作,並最終開始接收該socket的請求
- 父程序停止接收請求並等待當前處理的請求終止
fork一個子程序
有不止一種方法fork一個子程序,但在這種情況下推薦exec.Command
,因為Cmd
結構提供了一個欄位ExtraFiles
,該欄位(注意不支援windows)為子程序額外地指定了需要繼承的額外的檔案描述符,不包含std_in, std_out, std_err
。
需要注意的是,ExtraFiles
描述中有這樣一句話:
If non-nil, entry i becomes file descriptor 3+i
這句是說,索引位置為i的檔案描述符傳過去,最終會變為值為i+3的檔案描述符。ie: 索引為0的檔案描述符565, 最終變為檔案描述符3
file := netListener.File() // this returns a Dup() path := "/path/to/executable" args := []string{ "-graceful"} cmd := exec.Command(path, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.ExtraFiles = []*os.File{file} err := cmd.Start() if err != nil { log.Fatalf("gracefulRestart: Failed to launch, error: %v", err) }
上面的程式碼中,netListener
是一個net.Listener
型別的指標,path變數則是我們要更新的新的可執行檔案的路徑。
需要注意的是:上面netListener.File()
與dup
函式類似,返回的是一個拷貝的檔案描述符。另外,該檔案描述符不應該設定FD_CLOEXEC
標識,這將會導致出現我們不想要的結果:子程序的該檔案描述符被關閉。
你可能會想到可以使用命令列引數把該檔案描述符的值傳遞給子程序,但相對來說,我使用的這種方式更為簡單
最終,args
陣列包含了一個-graceful
選項,你的程序需要以某種方式通知子程序要複用父程序的描述符而不是新開啟一個。
子程序初始化
server := &http.Server{Addr: "0.0.0.0:8888"} var gracefulChild bool var l net.Listever var err error flag.BoolVar(&gracefulChild, "graceful", false, "listen on fd open 3 (internal use only)") if gracefulChild { log.Print("main: Listening to existing file descriptor 3.") f := os.NewFile(3, "") l, err = net.FileListener(f) } else { log.Print("main: Listening on a new file descriptor.") l, err = net.Listen("tcp", server.Addr) }
通知父程序停止
if gracefulChild { parent := syscall.Getppid() log.Printf("main: Killing parent pid: %v", parent) syscall.Kill(parent, syscall.SIGTERM) } server.Serve(l)
父程序停止接收請求並等待當前所處理的所有請求結束
為了做到這一點我們需要使用sync.WaitGroup 來保證對當前開啟的連線的追蹤,基本上就是:每當接收一個新的請求時,給wait group做原子性加法,當請求結束時給wait group做原子性減法。也就是說wait group儲存了當前正在處理的請求的數量
var httpWg sync.WaitGroup
匆匆一瞥,我發現go中的http標準庫並沒有為Accept()和Close()提供鉤子函式,但這就到了interface
展現其魔力的時候了(非常感謝Jeff R. Allen
的這篇文章
)
下面是一個例子,該例子實現了每當執行Accept()的時候會原子性增加wait group。首先我們先繼承net.Listener
實現一個結構體
type gracefulListener struct { net.Listener stopchan error stopped bool } func (gl *gracefulListener) File() *os.File { tl := gl.Listener.(*net.TCPListener) fl, _ := tl.File() return fl }
接下來我們覆蓋Accept方法(暫時先忽略gracefulConn
)
func (gl *gracefulListener) Accept() (c net.Conn, err error) { c, err = gl.Listener.Accept() if err != nil { return } c = gracefulConn{Conn: c} httpWg.Add(1) return }
我們還需要一個建構函式以及一個Close方法,建構函式中另起一個goroutine關閉,為什麼要另起一個goroutine關閉,請看refer^{[1]}
func newGracefulListener(l net.Listener) (gl *gracefulListener) { gl = &gracefulListener{Listener: l, stop: make(chan error)} // 這裡為什麼使用go 另起一個goroutine關閉請看文章末尾 go func() { _ = <-gl.stop gl.stopped = true gl.stop <- gl.Listener.Close() }() return } func (gl *gracefulListener) Close() error { if gl.stopped { return syscall.EINVAL } gl.stop <- nil return <-gl.stop }
我們的Close
方法簡單的向stop chan中傳送了一個nil,讓建構函式中的goroutine解除阻塞狀態並執行Close操作。最終,goroutine執行的函式釋放了net.TCPListener
檔案描述符。
接下來,我們還需要一個net.Conn
的變種來原子性的對wait group做減法
type gracefulConn struct { net.Conn } func (w gracefulConn) Close() error { httpWg.Done() return w.Conn.Close() }
為了讓我們上面所寫的優雅啟動方案生效,我們需要替換server.Serve(l)
行為:
netListener = newGracefulListener(l) server.Serve(netListener)
最後補充:我們還需要避免客戶端長時間不關閉連線的情況,所以我們建立server的時候可以指定超時時間:
server := &http.Server{ Addr:"0.0.0.0:8888", ReadTimeout:10 * time.Second, WriteTimeout:10 * time.Second, MaxHeaderBytes: 1 << 16}
譯者總結
譯者注:
-
refer^{[1]}
在上面的程式碼中使用goroutine的原因作者寫了一部分,但我並沒有讀懂,但幸好在評論中,jokingus 問道:如果用下面的方式,是否就不需要在newGracefulListener
中使用那個goroutine函數了
func (gl *gracefulListener) Close() error { // some code gl.Listener.Close() }
作者回複道:
Honestly, I cannot fathom why there would need to be a goroutine for this, and simply doing gl.Listener.Close() like you suggest wouldn't work.... May be there is some reason that is escaping me presently, or perhaps I just didn't know what I was doing? If you get to the bottom of it, would you post here, so I can correct the post if this goroutine business is wrong?
作者自己也較為疑惑,但表示像jokingus 所提到的這種方式是行不通的
譯者的個人理解:在絕大多數情況下,需要一個goroutine(可以稱之為主goroutine)來建立socket,監聽該socket,並accept直到有請求到達,當請求到來之後再另起goroutine進行處理。首先因為accept一般處於主goroutine中,且其是一個阻塞操作,如果我們想在accept執行後關閉socket一般來說有兩個方法:
- 為accept設定一個超時時間,到達超時時間後,檢測是否需要close socket,如果需要就關閉。但這樣的話我們的超時時間可定不能設定太大,這樣結束就不夠靈敏,但設定的太小,就會對效能影響很大,總之來說不夠優雅。
- accept方法可以一直阻塞,當我們需要close socket的時候,在另一個goroutine執行流中關閉socket,這樣相對來說就比較優雅了,作者所使用的方法就是這種
另外,也可以參考:Go中如何優雅地關閉net.Listener