Go優雅重啟Web server示例-講解版
因為 golang 是編譯型的,所以當我們修改一個用 go 寫的服務的配置後,需要重啟該服務,有的甚至還需要重新編譯,再發布。如果在重啟的過程中有大量的請求湧入,能做的無非是分流,或者堵塞請求。不論哪一種,都不優雅~,所以slax0r以及他的團隊,就試圖探尋一種更加平滑的,便捷的重啟方式。
原文章中除了排版比較帥外,文字內容和說明還是比較少的,所以我希望自己補充一些說明。
原理
上述問題的根源在於,我們無法同時讓兩個服務,監聽同一個埠。 解決方案就是複製當前的 listen 檔案,然後在新老程序之間通過 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\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 來限制會產生響應的訊號,這裡有:
- 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\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 通道,同時監聽通道的情況,並做相應的處理。 處理的情況說白了就只有兩種:
- 通道開了,說明我可以造兒子了(fork),兒子來接爸爸的資訊
- 爸爸把監聽物件檔案都傳給兒子了,爸爸完成使命
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 開始。
這時候,我們中的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 通道後。
至此,子程序開始了新的一輪監聽,服務...