這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
前段時間用Golang在做一個HTTP的介面,因編譯型語言的特性,修改了代碼需要重新編譯可執行檔,關閉正在啟動並執行老程式,並啟動新程式。對於訪問量較大的面向使用者的產品,關閉、重啟的過程中勢必會出現無法訪問的情況,從而影響使用者體驗。
使用Golang的系統包開發HTTP服務,是無法支援平滑升級(優雅重啟)的,本文將探討如何解決該問題。
一、平滑升級(優雅重啟)的一般思路
一般情況下,要實現平滑升級,需要以下幾個步驟:
-
用新的可執行檔替換老的可執行檔(如只需優雅重啟,可以跳過這一步)
-
通過pid給正在啟動並執行老進程發送 特定的訊號(kill -SIGUSR2 $pid)
-
正在啟動並執行老進程,接收到指定的訊號後,以子進程的方式啟動新的可執行檔並開始處理新請求
-
老進程不再接受新的請求,等待未完成的服務處理完畢,然後正常結束
-
新進程在父進程退出後,會被init進程領養,並繼續提供服務
二、Golang Socket 網路編程
Socket是程式員層面上對傳輸層協議TCP/IP的封裝和應用。Golang中Socket相關的函數與結構體定義在net包中,我們從一個簡單的例子來學習一下Golang Socket 網路編程,關鍵說明直接寫在注釋中。
1、服務端程式 server.go
package mainimport ("fmt""log""net""time")func main() {// 監聽8086連接埠listener, err := net.Listen("tcp", ":8086")if err != nil {log.Fatal(err)}defer listener.Close()for {// 迴圈接收用戶端的串連,沒有串連時會阻塞,出錯則跳出迴圈conn, err := listener.Accept()if err != nil {fmt.Println(err)break}fmt.Println("[server] accept new connection.")// 啟動一個goroutine 處理串連go handler(conn)}}func handler(conn net.Conn) {defer conn.Close()for {// 迴圈從串連中 讀取請求內容,沒有請求時會阻塞,出錯則跳出迴圈request := make([]byte, 128)readLength, err := conn.Read(request)if err != nil {fmt.Println(err)break}if readLength == 0 {fmt.Println(err)break}// 控制台輸出讀取到的請求內容,並在請求內容前加上hello和時間後向用戶端輸出fmt.Println("[server] request from ", string(request))conn.Write([]byte("hello " + string(request) + ", time: " + time.Now().Format("2006-01-02 15:04:05")))}}
2、用戶端程式 client.go
package mainimport ("fmt""log""net""os""time")func main() {// 從命令列中讀取第二個參數作為名字,如果不存在第二個參數則報錯退出if len(os.Args) != 2 {fmt.Fprintf(os.Stderr, "Usage: %s name ", os.Args[0])os.Exit(1)}name := os.Args[1]// 串連到服務端的8086連接埠conn, err := net.Dial("tcp", "127.0.0.1:8086")checkError(err)for {// 迴圈往串連中 寫入名字_, err = conn.Write([]byte(name))checkError(err)// 迴圈從串連中 讀取響應內容,沒有響應時會阻塞response := make([]byte, 256)readLength, err := conn.Read(response)checkError(err)// 將讀取響應內容輸出到控制台,並sleep一秒if readLength > 0 {fmt.Println("[client] server response:", string(response))time.Sleep(1 * time.Second)}}}func checkError(err error) {if err != nil {log.Fatal("fatal error: " + err.Error())}}
3、運行樣本程式
# 運行服務端程式go run server.go# 在另一個命令列視窗運行用戶端程式go run client.go "tabalt"
三、Golang HTTP 編程
HTTP是基於傳輸層協議TCP/IP的應用程式層協議。Golang中HTTP相關的實現在net/http包中,直接用到了net包中Socket相關的函數和結構體。
我們再從一個簡單的例子來學習一下Golang HTTP 編程,關鍵說明直接寫在注釋中。
1、http服務程式 http.go
package mainimport ("log""net/http""os")// 定義http請求的處理方法func handlerHello(w http.ResponseWriter, r *http.Request) {w.Write([]byte("http hello on golang\n"))}func main() {// 註冊http請求的處理方法http.HandleFunc("/hello", handlerHello)// 在8086連接埠啟動http服務,會一直阻塞執行err := http.ListenAndServe("localhost:8086", nil)if err != nil {log.Println(err)}// http服務因故停止後 才會輸出如下內容log.Println("Server on 8086 stopped")os.Exit(0)}
2、運行樣本程式
# 運行HTTP服務程式go run http.go# 在另一個命令列視窗curl請求測試頁面curl http://localhost:8086/hello/# 輸出如下內容:http hello on golang
四、Golang net/http包中 Socket操作的實現
從上面的簡單樣本中,我們看到在Golang中要啟動一個http服務,只需要簡單的三步:
-
定義http請求的處理方法
-
註冊http請求的處理方法
-
在某個連接埠啟動HTTP服務
而最關鍵的啟動http服務,是調用http.ListenAndServe()函數實現的。下面我們找到該函數的實現:
func ListenAndServe(addr string, handler Handler) error {server := &Server{Addr: addr, Handler: handler}return server.ListenAndServe()}
這裡建立了一個Server的對象,並調用它的ListenAndServe()方法,我們再找到結構體Server的ListenAndServe()方法的實現:
func (srv *Server) ListenAndServe() error {addr := srv.Addrif addr == "" {addr = ":http"}ln, err := net.Listen("tcp", addr)if err != nil {return err}return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})}
從代碼上看到,這裡監聽了tcp連接埠,並將監聽者封裝成了一個結構體 tcpKeepAliveListener,再調用srv.Serve()方法;我們繼續跟蹤Serve()方法的實現:
func (srv *Server) Serve(l net.Listener) error {defer l.Close()var tempDelay time.Duration // how long to sleep on accept failurefor {rw, e := l.Accept()if e != nil {if ne, ok := e.(net.Error); ok && ne.Temporary() {if tempDelay == 0 {tempDelay = 5 * time.Millisecond} else {tempDelay *= 2}if max := 1 * time.Second; tempDelay > max {tempDelay = max}srv.logf("http: Accept error: %v; retrying in %v", e, tempDelay)time.Sleep(tempDelay)continue}return e}tempDelay = 0c, err := srv.newConn(rw)if err != nil {continue}c.setState(c.rwc, StateNew) // before Serve can returngo c.serve()}}
可以看到,和我們前面Socket編程的範例程式碼一樣,迴圈從監聽的連接埠上Accept串連,如果返回了一個net.Error並且這個錯誤是臨時性的,則會sleep一個時間再繼續。 如果返回了其他錯誤則會終止迴圈。成功Accept到一個串連後,調用了方法srv.newConn()對串連做了一層封裝,最後啟了一個goroutine處理http請求。
五、Golang 平滑升級(優雅重啟)HTTP服務的實現
我建立了一個新的包gracehttp來實現支援平滑升級(優雅重啟)的HTTP服務,為了少寫代碼和降低使用成本,新的包儘可能多地利用net/http包的實現,並和net/http包保持一致的對外方法。現在開始我們來看gracehttp包支援平滑升級 (優雅重啟)Golang HTTP服務涉及到的細節如何?。
1、Golang處理訊號
Golang的os/signal包封裝了對訊號的處理。簡單用法請看樣本:
package mainimport ("fmt""os""os/signal""syscall")func main() {signalChan := make(chan os.Signal)// 監聽指定訊號signal.Notify(signalChan,syscall.SIGHUP,syscall.SIGUSR2,)// 輸出當前進程的pidfmt.Println("pid is: ", os.Getpid())// 處理訊號for {sig := <-signalChanfmt.Println("get signal: ", sig)}}
2、子進程啟動新程式,監聽相同的連接埠
在第四部分的ListenAndServe()方法的實現代碼中可以看到,net/http包中使用net.Listen函數來監聽了某個連接埠,但如果某個運行中的程式已經監聽某個連接埠,其他程式是無法再去監聽這個連接埠的。解決的辦法是使用子進程的方式啟動,並將監聽連接埠的檔案描述符傳遞給子進程,子進程裡從這個檔案描述符實現對連接埠的監聽。
具體實現需要藉助一個環境變數來區分進程是正常啟動,還是以子進程方式啟動的,相關代碼摘抄如下:
// 啟動子進程執行新程式func (this *Server) startNewProcess() error {listenerFd, err := this.listener.(*Listener).GetFd()if err != nil {return fmt.Errorf("failed to get socket file descriptor: %v", err)}path := os.Args[0]// 設定標識優雅重啟的環境變數environList := []string{}for _, value := range os.Environ() {if value != GRACEFUL_ENVIRON_STRING {environList = append(environList, value)}}environList = append(environList, GRACEFUL_ENVIRON_STRING)execSpec := &syscall.ProcAttr{Env: environList,Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), listenerFd},}fork, err := syscall.ForkExec(path, os.Args, execSpec)if err != nil {return fmt.Errorf("failed to forkexec: %v", err)}this.logf("start new process success, pid %d.", fork)return nil}func (this *Server) getNetTCPListener(addr string) (*net.TCPListener, error) {var ln net.Listenervar err errorif this.isGraceful {file := os.NewFile(3, "")ln, err = net.FileListener(file)if err != nil {err = fmt.Errorf("net.FileListener error: %v", err)return nil, err}} else {ln, err = net.Listen("tcp", addr)if err != nil {err = fmt.Errorf("net.Listen error: %v", err)return nil, err}}return ln.(*net.TCPListener), nil}
3、父進程等待已有串連中未完成的請求處理完畢
這一塊是最複雜的;首先我們需要一個計數器,在成功Accept一個串連時,計數器加1,在串連關閉時計數減1,計數器為0時則父進程可以正常退出了。Golang的sync的包裡的WaitGroup可以很好地實現這個功能。
然後要控制串連的建立和關閉,我們需要深入到net/http包中Server結構體的Serve()方法。重溫第四部分Serve()方法的實現,會發現如果要重新寫一個Serve()方法幾乎是不可能的,因為這個方法裡調用了好多個不可匯出的內部方法,重寫Serve()方法幾乎要重寫整個net/http包。
幸運的是,我們還發現在 ListenAndServe()方法裡傳遞了一個listener給Serve()方法,並最終調用了這個listener的Accept()方法,這個方法返回了一個Conn的樣本,最終在串連斷開的時候會調用Conn的Close()方法,這些結構體和方法都是可匯出的!
我們可以定義自己的Listener結構體和Conn結構體,組合net/http包中對應的結構體,並重寫Accept()和Close()方法,實現對串連的計數,相關代碼摘抄如下:
type Listener struct {*net.TCPListenerwaitGroup *sync.WaitGroup}func (this *Listener) Accept() (net.Conn, error) {tc, err := this.AcceptTCP()if err != nil {return nil, err}tc.SetKeepAlive(true)tc.SetKeepAlivePeriod(3 * time.Minute)this.waitGroup.Add(1)conn := &Connection{Conn: tc,listener: this,}return conn, nil}func (this *Listener) Wait() {this.waitGroup.Wait()}type Connection struct {net.Connlistener *Listenerclosed bool}func (this *Connection) Close() error {if !this.closed {this.closed = truethis.listener.waitGroup.Done()}return this.Conn.Close()}
4、gracehttp包的用法
gracehttp包已經應用到每天幾億PV的項目中,也開源到了github上:github.com/tabalt/gracehttp,使用起來非常簡單。
如以下範例程式碼,引入包後只需修改一個關鍵字,將http.ListenAndServe 改為 gracehttp.ListenAndServe即可。
package mainimport ( "fmt" "net/http" "github.com/tabalt/gracehttp")func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "hello world") }) err := gracehttp.ListenAndServe(":8080", nil) if err != nil { fmt.Println(err) }}
測試平滑升級(優雅重啟)的效果,可以參考下面這個頁面的說明:
https://github.com/tabalt/gracehttp#demo
使用過程中有任何問題和建議,歡迎提交issue反饋,也可以Fork到自己名下修改之後提交pull request。