Go優雅重啓Web server示例-講解版

本文參考 GRACEFULLY RESTARTING A GOLANG WEB SERVER 進行概括和說明。 你也能夠從這裏拿到添加備註的代碼版本。 我作了下分割,方便你能看懂。linux

問題

由於 golang 是編譯型的,因此當咱們修改一個用 go 寫的服務的配置後,須要重啓該服務,有的甚至還須要從新編譯,再發布。若是在重啓的過程當中有大量的請求涌入,能作的無非是分流,或者堵塞請求。不論哪種,都不優雅~,因此slax0r以及他的團隊,就試圖探尋一種更加平滑的,便捷的重啓方式。git

原文章中除了排版比較帥外,文字內容和說明仍是比較少的,因此我但願本身補充一些說明。github

原理

上述問題的根源在於,咱們沒法同時讓兩個服務,監聽同一個端口。 解決方案就是複製當前的 listen 文件,而後在新老進程之間經過 socket 直接傳輸參數和環境變量。 新的開啓,老的關掉,就這麼簡單。golang

防看不懂須知

Unix domain socketjson

一切皆文件api

先玩一下

運行程序,過程當中打開一個新的 console,輸入 kill -1 [進程號],你就能看到優雅重啓的進程了。數據結構

代碼思路

func main() {
    主函數,初始化配置
    調用serve()
}

func serve() {
    核心運行函數
    getListener()   // 1. 獲取監聽 listener
    start()         // 2. 用獲取到的 listener 開啓 server 服務
    waitForSignal() // 3. 監聽外部信號,用來控制程序 fork 仍是 shutdown
}

func getListener() {
    獲取正在監聽的端口對象
    (第一次運行新建)
}

func start() {
    運行 http server
}

func waitForSignal() {
    for {
        等待外部信號
        1. fork子進程
        2. 關閉進程
    }
}
複製代碼

上面是代碼思路的說明,基本上咱們就圍繞這個大綱填充完善代碼。less

定義結構體

咱們抽象出兩個結構體,描述程序中公用的數據結構dom

var cfg *srvCfg
type listener struct {
	// Listener address
	Addr string `json:"addr"`
	// Listener file descriptor
	FD int `json:"fd"`
	// Listener file name
	Filename string `json:"filename"`
}

type srvCfg struct {
	sockFile string
	addr string
	ln net.Listener
	shutDownTimeout time.Duration
	childTimeout time.Duration
}
複製代碼

listener 是咱們的監聽者,他包含了監聽地址,文件描述符,文件名。 文件描述符其實就是進程所須要打開的文件的一個索引,非負整數。 咱們平時建立一個進程時候,linux都會默認打開三個文件,標準輸入stdin,標準輸出stdout,標準錯誤stderr, 這三個文件各自佔用了 0,1,2 三個文件描述符。因此以後你進程還要打開文件的話,就得從 3 開始了。 這個listener,就是咱們進程之間所要傳輸的數據了。socket

srvCfg 是咱們的全局環境配置,包含 socket file 路徑,服務監聽地址,監聽者對象,父進程超時時間,子進程超時時間。 由於是全局用的配置數據,咱們先 var 一下。

入口

看看咱們的 main 長什麼樣子

func main() {
	serve(srvCfg{
		sockFile: "/tmp/api.sock",
		addr:     ":8000",
		shutDownTimeout: 5*time.Second,
		childTimeout: 5*time.Second,
	}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`Hello, world!`))
	}))
}

func serve(config srvCfg, handler http.Handler) {
	cfg = &config
	var err error
	// get tcp listener
	cfg.ln, err = getListener()
	if err != nil {
		panic(err)
	}

	// return an http Server
	srv := start(handler)

	// create a wait routine
	err = waitForSignals(srv)
	if err != nil {
		panic(err)
	}
}
複製代碼

很簡單,咱們把配置都準備好了,而後還註冊了一個 handler--輸出 Hello, world!

serve 函數的內容就和咱們以前的思路同樣,只不過多了些錯誤判斷。

接下去,咱們一個一個看裏面的函數...

獲取 listener

也就是咱們的 getListener() 函數

func getListener() (net.Listener, error) {
    // 第一次執行不會 importListener
	ln, err := importListener()
	if err == nil {
		fmt.Printf("imported listener file descriptor for addr: %s\n", cfg.addr)
		return ln, nil
	}
    // 第一次執行會 createListener
	ln, err = createListener()
	if err != nil {
		return nil, err
	}

	return ln, err
}

func importListener() (net.Listener, error) {
    ...
}

func createListener() (net.Listener, error) {
	fmt.Println("首次建立 listener", cfg.addr)
	ln, err := net.Listen("tcp", cfg.addr)
	if err != nil {
		return nil, err
	}

	return ln, err
}
複製代碼

由於第一次不會執行 importListener, 因此咱們暫時不須要知道 importListener 裏是怎麼實現的。 只肖明白 createListener 返回了一個監聽對象。

然後就是咱們的 start 函數

func start(handler http.Handler) *http.Server {
	srv := &http.Server{
		Addr: cfg.addr,
		Handler: handler,
	}
	// start to serve
	go srv.Serve(cfg.ln)
	fmt.Println("server 啓動完成,配置信息爲:",cfg.ln)
	return srv
}
複製代碼

很明顯,start 傳入一個 handler,而後協程運行一個 http server。

監聽信號

監聽信號應該是咱們這篇裏面重頭戲的入口,咱們首先來看下代碼:

func waitForSignals(srv *http.Server) error {
	sig := make(chan os.Signal, 1024)
	signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP)
	for {
		select {
		case s := <-sig:
			switch s {
			case syscall.SIGHUP:
				err := handleHangup() // 關閉
				if err == nil {
					// no error occured - child spawned and started
					return shutdown(srv)
				}
			case syscall.SIGTERM, syscall.SIGINT:
				return shutdown(srv)
			}
		}
	}
}
複製代碼

首先創建了一個通道,這個通道用來接收系統發送到程序的命令,好比kill -9 myprog, 這個 9 就是傳到通道里的。咱們用 Notify 來限制會產生響應的信號,這裏有:

若是實在搞不清這三個信號的區別,只要明白咱們經過區分信號,留給了進程本身判斷處理的餘地。

而後咱們開啓了一個循環監聽,顯而易見地,監聽的就是系統信號。 當信號爲 syscall.SIGHUP ,咱們就要重啓進程了。 而當信號爲 syscall.SIGTERM, syscall.SIGINT 時,咱們直接關閉進程。

因而乎,咱們就要看看,handleHangup 裏面到底作了什麼。

父子間的對話

進程之間的優雅重啓,咱們能夠看作是一次愉快的父子對話, 爸爸給兒子開通了一個熱線,爸爸經過熱線把如今正在監聽的端口信息告訴兒子, 兒子在接受到必要的信息後,子承父業,開啓新的空進程,告知爸爸,爸爸正式退休。

func handleHangup() error {
	c := make(chan string)
	defer close(c)
	errChn := make(chan error)
	defer close(errChn)
    // 開啓一個熱線通道
	go socketListener(c, errChn)

	for {
		select {
		case cmd := <-c:
			switch cmd {
			case "socket_opened":
				p, err := fork()
				if err != nil {
					fmt.Printf("unable to fork: %v\n", err)
					continue
				}
				fmt.Printf("forked (PID: %d), waiting for spinup", p.Pid)

			case "listener_sent":
				fmt.Println("listener sent - shutting down")

				return nil
			}

		case err := <-errChn:
			return err
		}
	}

	return nil
}
複製代碼

socketListener 開啓了一個新的 unix socket 通道,同時監聽通道的狀況,並作相應的處理。 處理的狀況說白了就只有兩種:

  1. 通道開了,說明我能夠造兒子了(fork),兒子來接爸爸的信息
  2. 爸爸把監聽對象文件都傳給兒子了,爸爸完成使命

handleHangup 裏面的東西有點多,不要慌,咱們一個一個來看。 先來看 socketListener

func socketListener(chn chan<- string, errChn chan<- error) {
	// 建立 socket 服務端
	fmt.Println("建立新的socket通道")
	ln, err := net.Listen("unix", cfg.sockFile)
	if err != nil {
		errChn <- err
		return
	}
	defer ln.Close()

	// signal that we created a socket
	fmt.Println("通道已經打開,能夠 fork 了")
	chn <- "socket_opened"

	// accept
	// 阻塞等待子進程鏈接進來
	c, err := acceptConn(ln)
	if err != nil {
		errChn <- err
		return
	}

	// read from the socket
	buf := make([]byte, 512)
	nr, err := c.Read(buf)
	if err != nil {
		errChn <- err
		return
	}

	data := buf[0:nr]
	fmt.Println("得到消息子進程消息", string(data))
	switch string(data) {
	case "get_listener":
		fmt.Println("子進程請求 listener 信息,開始傳送給他吧~")
		err := sendListener(c) // 發送文件描述到新的子進程,用來 import Listener
		if err != nil {
			errChn <- err
			return
		}
		// 傳送完畢
		fmt.Println("listener 信息傳送完畢")
		chn <- "listener_sent"
	}
}
複製代碼

sockectListener建立了一個 unix socket 通道,建立完畢後先發送了 socket_opened 這個信息。 這時候 handleHangup 裏的 case "socket_opened" 就會有反應了。 同時,socketListener 一直在 accept 阻塞等待新程序的信號,從而發送原 listener 的文件信息。 直到發送完畢,纔會再告知 handlerHangup listener_sent

下面是 acceptConn 的代碼,並無複雜的邏輯,就是等待子程序請求、處理超時和錯誤。

func acceptConn(l net.Listener) (c net.Conn, err error) {
	chn := make(chan error)
	go func() {
		defer close(chn)
		fmt.Printf("accept 新鏈接%+v\n", l)
		c, err = l.Accept()
		if err != nil {
			chn <- err
		}
	}()

	select {
	case err = <-chn:
		if err != nil {
			fmt.Printf("error occurred when accepting socket connection: %v\n",
				err)
		}

	case <-time.After(cfg.childTimeout):
		fmt.Println("timeout occurred waiting for connection from child")
	}

	return
}
複製代碼

還記的咱們以前定義的 listener 結構體嗎?這時候就要派上用場了:

func sendListener(c net.Conn) error {
	fmt.Printf("發送老的 listener 文件 %+v\n", cfg.ln)
	lnFile, err := getListenerFile(cfg.ln)
	if err != nil {
		return err
	}
	defer lnFile.Close()

	l := listener{
		Addr:     cfg.addr,
		FD:       3, // 文件描述符,進程初始化描述符爲0 stdin 1 stdout 2 stderr,因此咱們從3開始
		Filename: lnFile.Name(),
	}

	lnEnv, err := json.Marshal(l)
	if err != nil {
		return err
	}
	fmt.Printf("將 %+v\n 寫入鏈接\n", string(lnEnv))
	_, err = c.Write(lnEnv)
	if err != nil {
		return err
	}

	return nil
}

func getListenerFile(ln net.Listener) (*os.File, error) {
	switch t := ln.(type) {
	case *net.TCPListener:
		return t.File()
	case *net.UnixListener:
		return t.File()
	}

	return nil, fmt.Errorf("unsupported listener: %T", ln)
}
複製代碼

sendListener 先將咱們正在使用的tcp監聽文件(一切皆文件)作了一份拷貝,並把必要的信息塞進了 listener 結構體中,序列化後用 unix socket 傳輸給新的子進程。

說了這麼多都是爸爸進程的代碼,中間咱們跳過了建立子進程, 那下面咱們來看看 fork,也是一個重頭戲:

func fork() (*os.Process, error) {
	// 拿到原監聽文件描述符並打包到元數據中
	lnFile, err := getListenerFile(cfg.ln)
	fmt.Printf("拿到監聽文件 %+v\n,開始建立新進程\n", lnFile.Name())
	if err != nil {
		return nil, err
	}
	defer lnFile.Close()

	// 建立子進程時必需要塞的幾個文件
	files := []*os.File{
		os.Stdin,
		os.Stdout,
		os.Stderr,
		lnFile,
	}

	// 拿到新進程的程序名,由於咱們是重啓,因此就是當前運行的程序名字
	execName, err := os.Executable()
	if err != nil {
		return nil, err
	}
	execDir := filepath.Dir(execName)

	// 生孩子了
	p, err := os.StartProcess(execName, []string{execName}, &os.ProcAttr{
		Dir:   execDir,
		Files: files,
		Sys:   &syscall.SysProcAttr{},
	})
	fmt.Println("建立子進程成功")
	if err != nil {
		return nil, err
	}
	// 這裏返回 nil 後就會直接 shutdown 爸爸進程
	return p, nil
}
複製代碼

當執行 StartProcess 的那一刻,你會意識到,子進程的執行會回到最初的地方,也就是 main 開始。 這時候,咱們 獲取 listener中的 importListener 方法就會被激活:

func importListener() (net.Listener, error) {
	// 向已經準備好的 unix socket 創建鏈接,這個是爸爸進程在以前就創建好的
	c, err := net.Dial("unix", cfg.sockFile)
	if err != nil {
		fmt.Println("no unix socket now")
		return nil, err
	}
	defer c.Close()
	fmt.Println("準備導入原 listener 文件...")
	var lnEnv string
	wg := sync.WaitGroup{}
	wg.Add(1)
	go func(r io.Reader) {
		defer wg.Done()
		// 讀取 conn 中的內容
		buf := make([]byte, 1024)
		n, err := r.Read(buf[:])
		if err != nil {
			return
		}

		lnEnv = string(buf[0:n])
	}(c)
	// 寫入 get_listener
	fmt.Println("告訴爸爸我要 'get-listener' 了")
	_, err = c.Write([]byte("get_listener"))
	if err != nil {
		return nil, err
	}

	wg.Wait() // 等待爸爸傳給咱們參數

	if lnEnv == "" {
		return nil, fmt.Errorf("Listener info not received from socket")
	}

	var l listener
	err = json.Unmarshal([]byte(lnEnv), &l)
	if err != nil {
		return nil, err
	}
	if l.Addr != cfg.addr {
		return nil, fmt.Errorf("unable to find listener for %v", cfg.addr)
	}

	// the file has already been passed to this process, extract the file
	// descriptor and name from the metadata to rebuild/find the *os.File for
	// the listener.
	// 咱們已經拿到了監聽文件的信息,咱們準備本身建立一份新的文件並使用
	lnFile := os.NewFile(uintptr(l.FD), l.Filename)
	fmt.Println("新文件名:", l.Filename)
	if lnFile == nil {
		return nil, fmt.Errorf("unable to create listener file: %v", l.Filename)
	}
	defer lnFile.Close()

	// create a listerer with the *os.File
	ln, err := net.FileListener(lnFile)
	if err != nil {
		return nil, err
	}

	return ln, nil
}
複製代碼

這裏的 importListener 執行時間,就是在父進程建立完新的 unix socket 通道後。

至此,子進程開始了新的一輪監聽,服務...

結束

代碼量雖然不大,可是傳遞了一個很好的優雅重啓思路,有些地方仍是要實踐一下才能理解(對於我這種新手而言)。 其實網上還有不少其餘優雅重啓的方式,你們能夠 Google 一下。 但願我上面簡單的講解可以幫到你,若是有錯誤的話請及時指出,我會更正的。

你也能夠從這裏拿到添加備註的代碼版本。 我作了下分割,方便你能看懂。

相關文章
相關標籤/搜索