天天看點

如何優雅地重新開機go程式--endless篇前言endless總結

前言

當go語言開發的server應用已經在運作時,如果更新了代碼,直接編譯并運作,那麼不好意思,端口已經在使用中:

listen tcp :8000: bind: address already in use

看到這樣的錯誤資訊,我們通常都是一通下意識的操作:

lsof -i:8000

kill -9 …

這樣做端口被占用的問題是解決了,go程式也成功更新了。但是這裡面還隐藏着兩個問題:

  1. kill程式時可能把正在處理的使用者請求給中斷了
  2. 從kill到重新運作程式這段時間裡沒有應用在處理使用者請求

關于如何解決這兩個問題,網上有多種解決方案,今天我們談談endless的解決方案。

endless

endless的github位址為:https://github.com/fvbock/endless

她的解決方案是fork一個程序運作新編譯的應用,該子程序接收從父程序傳來的相關檔案描述符,直接複用socket,同時父程序關閉socket。父程序留在背景處理未處理完的使用者請求,這樣一來問題1解決了。且複用soket也直接解決了問題2,實作0切換時間差。複用socket可以說是endless方案的核心。

使用

endless可以很友善的接入已經寫好的程式,對于原生api,直接替換ListenAndServe為endless的方法,如下。并在編譯完新的程式後,執行kill -1 舊程序id,舊程序便會fork一個程序運作新編譯的程式。注:此處需要保證新編譯的程式的路徑和程式名和舊程式的一緻。

func handler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("WORLD!"))
}

func main() {
	mux1 := mux.NewRouter()
	mux1.HandleFunc("/hello", handler).
		Methods("GET")

	err := endless.ListenAndServe("localhost:4242", mux1)
	if err != nil {
		log.Println(err)
	}
	log.Println("Server on 4242 stopped")

	os.Exit(0)
}
           

對于使用gin架構的程式,可以以下面的方式接入:

r := gin.New()
	r.GET("/", func(c *gin.Context) {
		c.String(200, config.Config.Server.AppId)
	})
	s := endless.NewServer(":8080", r)
	err := s.ListenAndServe()
	if err != nil {
		log.Printf("server err: %v", err)
	}
           

原理

其使用非常簡單,實作代碼也很少,但是很強大,下面我們看看她的實作:

kill -1

endless的使用方法是先編譯新程式,并執行"kill -1 舊程序id",我們看看舊程式接收到-1信号之後作了什麼:

func (srv *endlessServer) handleSignals() {
	...
	for {
		sig = <-srv.sigChan
		srv.signalHooks(PRE_SIGNAL, sig)
		switch sig {
		case syscall.SIGHUP:	//接收到-1信号之後,fork一個程序,并運作新編譯的程式
			log.Println(pid, "Received SIGHUP. forking.")
			err := srv.fork()
			if err != nil {
				log.Println("Fork err:", err)
			}
		...
		default:
			log.Printf("Received %v: nothing i care about...\n", sig)
		}
		srv.signalHooks(POST_SIGNAL, sig)
	}
}

func (srv *endlessServer) fork() (err error) {
	...
	path := os.Args[0]	//擷取目前程式的路徑,在子程序執行。是以要保證新編譯的程式路徑和舊程式的一緻。
	var args []string
	if len(os.Args) > 1 {
		args = os.Args[1:]
	}

	cmd := exec.Command(path, args...)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.ExtraFiles = files	//socket在此處傳給子程序,windows系統不支援擷取socket檔案,是以endless無法在windows上用。windows擷取socket檔案時報錯:file tcp [::]:9999: not supported by windows。
	cmd.Env = env	//env有一個ENDLESS_SOCKET_ORDER變量存儲了socket傳遞的順序(如果有多個socket)
	...

	err = cmd.Start()	//運作新程式
	if err != nil {
		log.Fatalf("Restart: Failed to launch, error: %v", err)
	}

	return
}
           

接下來我們看看程式啟動之後做了什麼。

ListenAndServe

新程序啟動之後會執行ListenAndServe這個方法,這個方法主要做了系統信号監聽,并且判斷自己所在程序是否是子程序,如果是,則發送中斷信号給父程序,讓其退出。最後調用Serve方法給socket提供新的服務。

func (srv *endlessServer) ListenAndServe() (err error) {
    ...
	go srv.handleSignals()
	l, err := srv.getListener(addr)
	if err != nil {
		log.Println(err)
		return
	}
	srv.EndlessListener = newEndlessListener(l, srv)
	if srv.isChild {
		syscall.Kill(syscall.Getppid(), syscall.SIGTERM)		//給父程序發出中斷信号
	}
	...
	return srv.Serve()	//為socket提供新的服務
}
           

複用socket

前面提到複用socket是endless的核心,必須在Serve前準備好,否則會導緻端口已使用的異常。複用socket的實作在上面的getListener方法中:

func (srv *endlessServer) getListener(laddr string) (l net.Listener, err error) {
	if srv.isChild {//如果此方法運作在子程序中,則複用socket
		var ptrOffset uint = 0
		runningServerReg.RLock()
		defer runningServerReg.RUnlock()
		if len(socketPtrOffsetMap) > 0 {
			ptrOffset = socketPtrOffsetMap[laddr]//擷取和addr相對應的socket的位置
		}

		f := os.NewFile(uintptr(3+ptrOffset), "")//建立socket檔案描述符
		l, err = net.FileListener(f)//建立socket檔案監聽器
		if err != nil {
			err = fmt.Errorf("net.FileListener error: %v", err)
			return
		}
	} else {//如果此方法不是運作在子程序中,則建立一個socket
		l, err = net.Listen("tcp", laddr)
		if err != nil {
			err = fmt.Errorf("net.Listen error: %v", err)
			return
		}
	}
	return
}
           

但是父程序關閉socket和子程序綁定socket并不可能同時進行,如果這段時間有請求進來,這個請求會到哪裡去呢?關于這個問題,我做了個實驗,實驗代碼如下:

func main() {
	isChild := os.Getenv("child") != ""

	http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
		writer.Write([]byte(fmt.Sprintf("hello world from child?  %v", isChild)))
	})

	var ln net.Listener
	var err error

	if isChild {
		f := os.NewFile(uintptr(3+0), "")//由于隻傳一個檔案,是以此處直接為3
		ln, err = net.FileListener(f)
	} else {
		ln, err = net.Listen("tcp", ":9999")
	}
	if err != nil {
		fmt.Println("listener create", err)
		os.Exit(1)
	}

	go func() {
		c := make(chan os.Signal)
		signal.Notify(c, os.Interrupt)
		<-c

		path := os.Args[0]
		var args []string
		if len(os.Args) > 1 {
			args = os.Args[1:]
		}

		f, err := ln.(*net.TCPListener).File()
		if err != nil {
			fmt.Println("get socket file", err)
			os.Exit(1)
		}

		cmd := exec.Command(path, args...)
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		cmd.ExtraFiles = []*os.File{f}
		cmd.Env = []string{"child=1"}

		err = cmd.Start()
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}
	}()

	http.Serve(ln, nil)
}
           

在centos7上試驗結果如下:

  • 第一種情況:如果某個終端跟伺服器建立了長連接配接(應該是設定了keepalive屬性),那麼該終端的所有請求都會發到建立長連接配接的程序去,如下資訊,所有computerName的請求都會被轉發到父程序去(父程序id為13603):

[[email protected] care_watch_deploy]# lsof -i:9999

COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME

care_watc 13603 root 3u IPv6 17537280 0t0 TCP *:distinct (LISTEN)

care_watc 13603 root 5u IPv6 17528589 0t0 TCP 10.100.21.105:distinct->computerName:58776 (ESTABLISHED)

care_watc 13603 root 6u IPv6 17528593 0t0 TCP 10.100.21.105:distinct->computerName:58780 (ESTABLISHED)

care_watc 13603 root 7u IPv6 17537280 0t0 TCP *:distinct (LISTEN)

care_watc 13617 root 3u IPv6 17537280 0t0 TCP *:distinct (LISTEN)

care_watc 13617 root 4u IPv6 17537280 0t0 TCP *:distinct (LISTEN)

  • 第二種情況:如果有新的請求進來,會随機配置設定到父程序或者子程序,不知道為什麼,我多次試驗的結果是,20%的請求會被轉發到子程序,80%的請求會被轉發到父程序。測試的python代碼如下,不管運作幾次count_child的值永遠都是100左右:
import requests

count_child = 0

for i in range(500):
    resp = requests.get("http://10.100.21.105:9999/")
    result = resp.content.decode("utf8")
    if result == "hello world from child?  true":
        count_child += 1

print(count_child)
           
  • 第三種情況,父程序或者子程序任意一個退出之後,所有請求都會轉發到另一個程序進行處理。

從以上三種情況看,endless的做法不會落下任何請求,因為請求不是被父程序處理了就是被子程序處理了,是以endless是個可放心使用的熱更新方案。

最終endless的整個執行過程如其日志:

2015/03/22 20:04:10 2710 Received SIGHUP. forking.	//接收到kill -1信号,fork程序運作新程式
2015/03/22 20:04:10 2710 Received SIGTERM.	//父程序接收到子程序發出的中斷信号,關閉socket監聽器
2015/03/22 20:04:10 2710 Waiting for connections to finish...	//父程序等待請求處理完成
2015/03/22 20:04:10 PID: 2726 localhost:4242	//新程序啟動服務
2015/03/22 20:04:10 accept tcp 127.0.0.1:4242: use of closed network connection	//新的使用者請求進入到新程式
2015/03/22 20:04:10 Server on 4242 stopped	//父程序處理完所有請求并退出
           

總結

其實linux kernel 3.9開始socket是支援SO_REUSEPORT設定項的,即多個程序可通知綁定一個socket端口,由核心分發請求,但是目前的Go(1.12版本)不支援socket設定項。是以在目前的條件下,endless确實如作者描述,是一個0 down time的非常好的方案。下期再看看是不是有更優雅的方案。