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

cookedsteak發表於2019-02-16

本文參考 GRACEFULLY RESTARTING A GOLANG WEB SERVER
進行歸納和說明。

你也可以從這裡拿到新增備註的程式碼版本。
我做了下分割,方便你能看懂。

問題

因為 golang 是編譯型的,所以當我們修改一個用 go 寫的服務的配置後,需要重啟該服務,有的甚至還需要重新編譯,再發布。如果在重啟的過程中有大量的請求湧入,能做的無非是分流,或者堵塞請求。不論哪一種,都不優雅~,所以slax0r以及他的團隊,就試圖探尋一種更加平滑的,便捷的重啟方式。

原文章中除了排版比較帥外,文字內容和說明還是比較少的,所以我希望自己補充一些說明。

原理

上述問題的根源在於,我們無法同時讓兩個服務,監聽同一個埠。
解決方案就是複製當前的 listen 檔案,然後在新老程式之間通過 socket 直接傳輸引數和環境變數。
新的開啟,老的關掉,就這麼簡單。

防看不懂須知

Unix domain socket

一切皆檔案

先玩一下

執行程式,過程中開啟一個新的 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. 關閉程式
    }
}

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

定義結構體

我們抽象出兩個結構體,描述程式中公用的資料結構

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,就是我們程式之間所要傳輸的資料了。

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
", 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 來限制會產生響應的訊號,這裡有:

  • SIGTERM
  • SIGINT
  • SIGHUP

關於訊號

如果實在搞不清這三個訊號的區別,只要明白我們通過區分訊號,留給了程式自己判斷處理的餘地。

然後我們開啟了一個迴圈監聽,顯而易見地,監聽的就是系統訊號。
當訊號為 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
", 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
", 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
",
                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
", 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
 寫入連線
", 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
,開始建立新程式
", 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 一下。
希望我上面簡單的講解能夠幫到你,如果有錯誤的話請及時指出,我會更正的。

你也可以從這裡拿到新增備註的程式碼版本。
我做了下分割,方便你能看懂。

相關文章