golang程式優雅關閉與重啟

silsuer在掘金發表於2019-03-03

golang程式優雅關閉與重啟

何謂優雅

當線上程式碼有更新時,我們要首先關閉服務,然後再啟動服務,如果訪問量比較大,當關閉服務的時候,當前伺服器很有可能有很多
連線,那麼如果此時直接關閉服務,這些連線將全部斷掉,影響使用者體驗,絕對稱不上優雅

所以我們要想出一種可以平滑關閉或者重啟程式的方式

是謂優雅。

思路

  1. 服務端啟動時多開啟一個協程用來監聽關閉訊號
  2. 當協程接收到關閉訊號時,將拒絕接收新的連線,並處理好當前所有連線後斷開
  3. 啟動一個新的服務端程式來接管新的連線
  4. 關閉當前程式

實現

siluser/bingo框架為例

關於這個框架的系列文章:

我使用了tim1020/godaemon這個包來實現平滑重啟的功能(對於大部分專案來說,直接使用可以滿足大部分需求,無需改造)

期望效果:

在控制檯輸入 bingo run daemon [start|restart|stop] 可以令伺服器 啟動|重啟|停止

  1. 先看如何開啟一個伺服器 (bingo run dev)

關於 bingo 命令的實現可以看我以前的部落格: 仿照laravel-artisan實現簡易go開發腳手架

因為是開發環境嘛,大體的思路就是吧 bingo run命令轉換成令 go run start.go 這種 shell命令

所以 bingo run dev就等於 go run start.go dev

//處理http.Server,使支援graceful stop/restart
func Graceful(s http.Server) error {
	// 設定一個環境變數
	os.Setenv("__GRACEFUL", "true")
	// 建立一個自定義的server
	srv = &server{
		cm:     newConnectionManager(),
		Server: s,
	}

	// 設定server的狀態
	srv.ConnState = func(conn net.Conn, state http.ConnState) {
		switch state {
		case http.StateNew:
			srv.cm.add(1)
		case http.StateActive:
			srv.cm.rmIdleConns(conn.LocalAddr().String())
		case http.StateIdle:
			srv.cm.addIdleConns(conn.LocalAddr().String(), conn)
		case http.StateHijacked, http.StateClosed:
			srv.cm.done()
		}
	}
	l, err := srv.getListener()
	if err == nil {
		err = srv.Server.Serve(l)
	} else {
		fmt.Println(err)
	}
	return err
}
複製程式碼

這樣就可以啟動一個伺服器,並且在連線狀態變化的時候可以監聽到

  1. 以守護程式啟動伺服器

當使用 bingo run daemon或者 bingo run daemon start的時候,會觸發 DaemonInit()函式,內容如下:

func DaemonInit() {
	// 得到存放pid檔案的路徑
	dir, _ := os.Getwd()
	pidFile = dir + "/" + Env.Get("PID_FILE")
	if os.Getenv("__Daemon") != "true" { //master
		cmd := "start" //預設為start
		if l := len(os.Args); l > 2 {
			cmd = os.Args[l-1]
		}
		switch cmd {
		case "start":
			if isRunning() {
				fmt.Printf("
 %c[0;48;34m%s%c[0m", 0x1B, "["+strconv.Itoa(pidVal)+"] Bingo is running", 0x1B)
			} else { //fork daemon程式
				if err := forkDaemon(); err != nil {
					fmt.Println(err)
				}
			}
		case "restart": //重啟:
			if !isRunning() {
				fmt.Printf("
 %c[0;48;31m%s%c[0m", 0x1B, "[Warning]bingo not running", 0x1B)
				restart(pidVal)
			} else {
				fmt.Printf("
 %c[0;48;34m%s%c[0m", 0x1B, "["+strconv.Itoa(pidVal)+"] Bingo restart now", 0x1B)
				restart(pidVal)
			}
		case "stop": //停止
			if !isRunning() {
				fmt.Printf("
 %c[0;48;31m%s%c[0m", 0x1B, "[Warning]bingo not running", 0x1B)
			} else {
				syscall.Kill(pidVal, syscall.SIGTERM) //kill
			}
		case "-h":
			fmt.Println("Usage: " + appName + " start|restart|stop")
		default:   //其它不識別的引數
			return //返回至呼叫方
		}
		//主程式退出
		os.Exit(0)
	}
	go handleSignals()
}
複製程式碼

首先要獲取pidFile 這個檔案主要是儲存令程式執行時候的程式pid,為什麼要持久化pid呢?是為了讓多次程式執行過程中,判定是否有相同程式啟動等操作

之後要獲取對應的操作 (start|restart|stop),一個一個說

case start:

首先使用 isRunning()方法判斷當前程式是否在執行,如何判斷?就是從上面提到的 pidFile 中取出程式號

然後判斷當前系統是否執行令這個程式,如果有,證明正在執行,返回 true,反之返回 false

如果沒有執行的話,呼叫 forkDaemon() 函式啟動程式,這個函式是整個功能的核心

func forkDaemon() error {
	args := os.Args
	os.Setenv("__Daemon", "true")
	procAttr := &syscall.ProcAttr{
		Env:   os.Environ(),
		Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
	}
	pid, err := syscall.ForkExec(args[0], []string{args[0], "dev"}, procAttr)
	if err != nil {
		panic(err)
	}
	savePid(pid)
	fmt.Printf("
 %c[0;48;32m%s%c[0m", 0x1B, "["+strconv.Itoa(pid)+"] Bingo running...", 0x1B)
	fmt.Println()
	return nil
}
複製程式碼

syscall包不支援win系統,也就意味著如果想在 windows上做開發的話,只能使用虛擬機器或者 docker

這裡的主要功能就是,使用 syscall.ForkExec()fork 一個程式出來

執行這個程式所執行的命令就是這裡的引數(因為我們的原始命令是 go run start.go dev,所以這裡的args[0]實際上是 start.go編譯之後的二進位制檔案)

然後再把 fork出來的程式號儲存在 pidFile

所以最終執行的效果就是我們第一步時候說到的 bingo run dev 達到的效果

case restart:

這個比較簡單,通過 pidFile判定程式是否正在執行,如果正在執行,才會繼續向下執行

函式體也比較簡單,只有兩行

syscall.Kill(pid, syscall.SIGHUP) //kill -HUP, daemon only時,會直接退出
forkDaemon()
複製程式碼

第一行殺死這個程式
第二行開啟一個新程式

case stop:

這裡就一行程式碼,就是殺死這個程式

額外的想法

在開發過程中,每當有一丁點變動(比如更改來一丁點控制器),就需要再次執行一次 bingo run daemon restart 命令,讓新的改動生效,十分麻煩

所以我又開發了 bingo run watch 命令,監聽改動,自動重啟server伺服器

我使用了github.com/fsnotify/fs…包來實現監聽


func startWatchServer(port string, handler http.Handler) {
	// 監聽目錄變化,如果有變化,重啟服務
	// 守護程式開啟服務,主程式阻塞不斷掃描當前目錄,有任何更新,向守護程式傳遞訊號,守護程式重啟服務
	// 開啟一個協程執行服務
	// 監聽目錄變化,有變化執行 bingo run daemon restart
	f, err := fsnotify.NewWatcher()
	if err != nil {
		panic(err)
	}
	defer f.Close()
	dir, _ := os.Getwd()
	wdDir = dir
	fileWatcher = f
	f.Add(dir)

	done := make(chan bool)

	go func() {
		procAttr := &syscall.ProcAttr{
			Env:   os.Environ(),
			Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
		}
		_, err := syscall.ForkExec(os.Args[0], []string{os.Args[0], "daemon", "start"}, procAttr)
		if err != nil {
			fmt.Println(err)
		}
	}()

	go func() {
		for {
			select {
			case ev := <-f.Events:
				if ev.Op&fsnotify.Create == fsnotify.Create {
					fmt.Printf("
 %c[0;48;33m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]created file:"+ev.Name, 0x1B)
				}
				if ev.Op&fsnotify.Remove == fsnotify.Remove {
					fmt.Printf("
 %c[0;48;31m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]deleted file:"+ev.Name, 0x1B)
				}
				if ev.Op&fsnotify.Rename == fsnotify.Rename {
					fmt.Printf("
 %c[0;48;34m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]renamed file:"+ev.Name, 0x1B)
				} else {
					fmt.Printf("
 %c[0;48;32m%s%c[0m", 0x1B, "["+time.Now().Format("2006-01-02 15:04:05")+"]modified file:"+ev.Name, 0x1B)
				}
				// 有變化,放入重啟陣列中
				restartSlice = append(restartSlice, 1)
			case err := <-f.Errors:
				fmt.Println("error:", err)
			}
		}
	}()

	// 準備重啟守護程式
	go restartDaemonServer()

	<-done
}
複製程式碼

首先按照 fsnotify的文件,建立一個 watcher,然後新增監聽目錄(這裡只是監聽目錄下的檔案,不能監聽子目錄)

然後開啟兩個協程:

  1. 監聽檔案變化,如果有檔案變化,把變化的個數寫入一個 slice 裡,這是一個阻塞的 for迴圈

  2. 每隔1s中檢視一次記錄檔案變化的 slice, 如果有的話,就重啟伺服器,並重新設定監聽目錄,然後清空 slice ,否則跳過

    遞迴遍歷子目錄,達到監聽整個工程目錄的效果:

func listeningWatcherDir(dir string) {
	filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		dir, _ := os.Getwd()
		pidFile = dir + "/" + Env.Get("PID_FILE")
		fileWatcher.Add(path)
		
		// 這裡不能監聽 pidFile,否則每次重啟都會導致pidFile有更新,會不斷的觸發重啟功能
		fileWatcher.Remove(pidFile)
		return nil
	})
}
複製程式碼

這裡這個 slice 的作用也就是為了避免當一次儲存更新了多個檔案的時候,也重啟了多次伺服器

下面看看重啟伺服器的程式碼:

	go func() {
				// 執行重啟命令
				cmd := exec.Command("bingo", "run", "daemon", "restart")
				stdout, err := cmd.StdoutPipe()
				if err != nil {
					fmt.Println(err)
				}
				defer stdout.Close()

				if err := cmd.Start(); err != nil {
					panic(err)
				}
				reader := bufio.NewReader(stdout)
				//實時迴圈讀取輸出流中的一行內容
				for {
					line, err2 := reader.ReadString(`
`)
					if err2 != nil || io.EOF == err2 {
						break
					}
					fmt.Print(line)
				}

				if err := cmd.Wait(); err != nil {
					fmt.Println(err)
				}
				opBytes, _ := ioutil.ReadAll(stdout)
				fmt.Print(string(opBytes))

			}()
複製程式碼

使用 exec.Command() 方法得到一個 cmd

呼叫 cmd.Stdoutput() 得到一個輸出管道,命令列印出來的資料都會從這個管道流出來

然後使用 reader := bufio.NewReader(stdout) 從管道中讀出資料

用一個阻塞的for迴圈,不斷的從管道中讀出資料,以
為一行,一行一行的讀

並列印在控制檯裡,達到輸出的效果,如果這幾行不寫的話,在新的程式裡的 fmt.Println()方法列印出來的資料將無法顯示在控制檯上.

就醬,最後貼下專案連結 silsuer/bingo ,歡迎star,歡迎PR,歡迎提意見

相關文章