本文參考 GRACEFULLY RESTARTING A GOLANG 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 開始。
這時候,我們 獲取 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 一下。
希望我上面簡單的講解能夠幫到你,如果有錯誤的話請及時指出,我會更正的。
你也可以從這裏拿到添加備註的代碼版本。
我做了下分割,方便你能看懂。