1. 程式人生 > >Golang開發支持平滑升級(優雅重啟)的HTTP服務

Golang開發支持平滑升級(優雅重啟)的HTTP服務

response def files all 結束 wait stop error: 如何

Golang開發支持平滑升級(優雅重啟)的HTTP服務

前段時間用Golang在做一個HTTP的接口,因編譯型語言的特性,修改了代碼需要重新編譯可執行文件,關閉正在運行的老程序,並啟動新程序。對於訪問量較大的面向用戶的產品,關閉、重啟的過程中勢必會出現無法訪問的情況,從而影響用戶體驗。

使用Golang的系統包開發HTTP服務,是無法支持平滑升級(優雅重啟)的,本文將探討如何解決該問題。

一、平滑升級(優雅重啟)的一般思路

一般情況下,要實現平滑升級,需要以下幾個步驟:

  1. 用新的可執行文件替換老的可執行文件(如只需優雅重啟,可以跳過這一步)

  2. 通過pid給正在運行的老進程發送 特定的信號(kill -SIGUSR2 $pid)

  3. 正在運行的老進程,接收到指定的信號後,以子進程的方式啟動新的可執行文件並開始處理新請求

  4. 老進程不再接受新的請求,等待未完成的服務處理完畢,然後正常結束

  5. 新進程在父進程退出後,會被init進程領養,並繼續提供服務

二、Golang Socket 網絡編程

Socket是程序員層面上對傳輸層協議TCP/IP的封裝和應用。Golang中Socket相關的函數與結構體定義在net包中,我們從一個簡單的例子來學習一下Golang Socket 網絡編程,關鍵說明直接寫在註釋中。

1、服務端程序 server.go

package main

import (
	"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 main

import (
	"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 main

import (
	"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服務,只需要簡單的三步:

  1. 定義http請求的處理方法

  2. 註冊http請求的處理方法

  3. 在某個端口啟動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.Addr
	if 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 failure
	for {
		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 = 0
		c, err := srv.newConn(rw)
		if err != nil {
			continue
		}
		c.setState(c.rwc, StateNew) // before Serve can return
		go 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 main

import (
	"fmt"
	"os"
	"os/signal"
	"syscall"
)

func main() {

	signalChan := make(chan os.Signal)

	// 監聽指定信號
	signal.Notify(
		signalChan,
		syscall.SIGHUP,
		syscall.SIGUSR2,
	)

	// 輸出當前進程的pid
	fmt.Println("pid is: ", os.Getpid())

	// 處理信號
	for {
		sig := <-signalChan
		fmt.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.Listener
	var err error

	if 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.TCPListener

	waitGroup *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.Conn
	listener *Listener

	closed bool
}

func (this *Connection) Close() error {

	if !this.closed {
		this.closed = true
		this.listener.waitGroup.Done()
	}

	return this.Conn.Close()
}

4、gracehttp包的用法

gracehttp包已經應用到每天幾億PV的項目中,也開源到了github上:github.com/tabalt/gracehttp,使用起來非常簡單。

如以下示例代碼,引入包後只需修改一個關鍵字,將http.ListenAndServe 改為 gracehttp.ListenAndServe即可。

package main

import (
    "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

Golang開發支持平滑升級(優雅重啟)的HTTP服務