1. 程式人生 > 程式設計 >go平滑重啟調研選型和專案實踐

go平滑重啟調研選型和專案實踐

原文連結 github

什麼是平滑重啟

當線上程式碼需要更新時,我們平時一般的做法需要先關閉服務然後再重啟服務. 這時線上可能存在大量正在處理的請求,這時如果我們直接關閉服務會造成請求全部 中斷,影響使用者體驗; 在重啟重新提供服務之前,新請求進來也會502. 這時就出現兩個需要解決的問題:

  • 老服務正在處理的請求必須處理完才能退出(優雅退出)
  • 新進來的請求需要正常處理,服務不能中斷(平滑重啟)

本文主要結合linux和Golang中相關實現來介紹如何選型與實踐過程.

優雅退出

在實現優雅重啟之前首先需要解決的一個問題是如何優雅退出:
我們知道在go 1.8.x後,golang在http里加入了shutdown方法,用來控制優雅退出。
社群裡不少http graceful動態重啟,平滑重啟的庫,大多是基於http.shutdown做的。

http shutdown 原始碼分析

先來看下http shutdown的主方法實現邏輯。用atomic來做退出標記的狀態,然後關閉各種的資源,然後一直阻塞的等待無空閒連線,每500ms輪詢一次。

var shutdownPollInterval = 500 * time.Millisecond

func (srv *Server) Shutdown(ctx context.Context) error {
    // 標記退出的狀態
    atomic.StoreInt32(&srv.inShutdown,1)
    srv.mu.Lock()
    // 關閉listen fd,新連線無法建立。
    lnerr := srv.closeListenersLocked()
    
    // 把server.go的done
chan給close掉,通知等待的worekr退出 srv.closeDoneChanLocked() // 執行回撥方法,我們可以註冊shutdown的回撥方法 for _,f := range srv.onShutdown { go f() } // 每500ms來檢查下,是否沒有空閒的連線了,或者監聽上游傳遞的ctx上下文。 ticker := time.NewTicker(shutdownPollInterval) defer ticker.Stop() for { if srv.closeIdleConns
() { return lnerr } select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: } } } … 是否沒有空閒的連線 func (s *Server) closeIdleConns() bool { s.mu.Lock() defer s.mu.Unlock() quiescent := true for c := range s.activeConn { st,unixSec := c.getState() if st == StateNew && unixSec < time.Now().Unix()-5 { st = StateIdle } if st != StateIdle || unixSec == 0 { quiescent = false continue } c.rwc.Close() delete(s.activeConn,c) } return quiescent } 複製程式碼

關閉server.doneChan和監聽的檔案描述符

// 關閉doen chan
func (s *Server) closeDoneChanLocked() {
    ch := s.getDoneChanLocked()
    select {
    case <-ch:
        // Already closed. Don't close again.
    default:
        // Safe to close here. We're the only closer,guarded
        // by s.mu.
        close(ch)
    }
}

// 關閉監聽的fd
func (s *Server) closeListenersLocked() error {
    var err error
    for ln := range s.listeners {
        if cerr := (*ln).Close(); cerr != nil && err == nil {
            err = cerr
        }
        delete(s.listeners,ln)
    }
    return err
}

// 關閉連線
func (c *conn) Close() error {
    if !c.ok() {
        return syscall.EINVAL
    }
    err := c.fd.Close()
    if err != nil {
        err = &OpError{Op: "close",Net: c.fd.net,Source: c.fd.laddr,Addr: c.fd.raddr,Err: err}
    }
    return err
}
複製程式碼

這麼一系列的操作後,server.go的serv主監聽方法也就退出了。

func (srv *Server) Serve(l net.Listener) error {
    ...
    for {
        rw,e := l.Accept()
        if e != nil {
            select {
             // 退出
            case <-srv.getDoneChan():
                return ErrServerClosed
            default:
            }
            ...
            return e
        }
        tempDelay = 0
        c := srv.newConn(rw)
        c.setState(c.rwc,StateNew) // before Serve can return
        go c.serve(ctx)
    }
}
複製程式碼

那麼如何保證使用者在請求完成後,再關閉連線的?

func (s *Server) doKeepAlives() bool {
	return atomic.LoadInt32(&s.disableKeepAlives) == 0 && !s.shuttingDown()
}


// Serve a new connection.
func (c *conn) serve(ctx context.Context) {
	defer func() {
                ... xiaorui.cc ...
		if !c.hijacked() {
                        // 關閉連線,並且標記退出
			c.close()
			c.setState(c.rwc,StateClosed)
		}
	}()
        ...
	ctx,cancelCtx := context.WithCancel(ctx)
	c.cancelCtx = cancelCtx
	defer cancelCtx()

	c.r = &connReader{conn: c}
	c.bufr = newBufioReader(c.r)
	c.bufw = newBufioWriterSize(checkConnErrorWriter{c},4<<10)

	for {
                // 接收請求
		w,err := c.readRequest(ctx)
		if c.r.remain != c.server.initialReadLimitSize() {
			c.setState(c.rwc,StateActive)
		}
                ...
                ...
                // 匹配路由及回撥處理方法
		serverHandler{c.server}.ServeHTTP(w,w.req)
		w.cancelCtx()
		if c.hijacked() {
			return
		}
                ...
                // 判斷是否在shutdown mode,選擇退出
		if !w.conn.server.doKeepAlives() {
			return
		}
    }
    ...
複製程式碼

優雅重啟

方法演進

從linux系統的角度

  • 直接使用exec,把程式碼段替換成新的程式的程式碼, 廢棄原有的資料段和堆疊段併為新程式分配新的資料段與堆疊段,唯一留下的就是程式號。

這樣就會存在的一個問題就是老程式無法優雅退出,老程式正在處理的請求無法正常處理完成後退出。
並且新程式服務的啟動並不是瞬時的,新程式在listen之後accept之前,新連線可能因為syn queue佇列滿了而被拒絕(這種情況很少,但在併發很高的情況下是有可能出現)。這裡結合下圖與TCP三次握手的過程來看可能會好理解很多,個人感覺有種豁然開朗的感覺.

image.png

  • 通過forkexec建立新程式, exec前在老程式中通過fcntl(fd,F_SETFD,0);清除FD_CLOEXEC標誌,之後exec新程式就會繼承老程式 的fd並可以直接使用。
    之後新程式和老程式listen相同的fd同時提供服務, 在新程式正常啟動服務後傳送訊號給老程式,老程式優雅退出。
    之後所有請求 都到了新程式也就完成了本次優雅重啟。 結合實際線上環境存在的問題: 這時新的子程式由於父程式的退出,系統會把它的父程式改成1號程式,由於線上環境大多數服務都是通過 supervisor進行管理的,這就會存在一個問題, supervisor會認為服務異常退出,會重新啟動一個新程式.
  • 通過給檔案描述符設定SO_REUSEPORT標誌讓兩個程式監聽同一個埠,這裡存在的問題是這裡使用的是兩個不同的FD監聽同一個埠,老程式退出的時候。 syn queue佇列中還未被accept的連線會被核心kill掉。

  • 通過ancilliary data系統呼叫使用UNIX域套接字在程式之間傳遞檔案描述符, 這樣也可以實現優雅重啟。但是這樣的實現會比較複雜, HAProxy中 實現了該模型。

  • 直接fork然後exec呼叫,子程式會繼承所有父程式開啟的檔案描述符, 子程式拿到的檔案描述符從3遞增, 順序與父程式開啟順序一致。子程式通過epoll_ctl 註冊fd並註冊事件處理函式(這裡以epoll模型為例), 這樣子程式就能和父程式監聽同一個埠的請求了(此時父子程式同時提供服務), 當子程式正常啟動並提供服務後 傳送SIGHUP給父程式, 父程式優雅退出此時子程式提供服務, 完成優雅重啟。

Golang中的實現

從上面看, 相對來說比較容易的實現是直接forkandexec的方式最簡單, 那麼接下來討論下在Golang中的具體實現。

我們知道Golang中socket的fd預設是設定了FD_CLOEXEC標誌的(net/sys_cloexec.go參考原始碼)

// Wrapper around the socket system call that marks the returned file
// descriptor as nonblocking and close-on-exec.
func sysSocket(family,sotype,proto int) (int,error) {
	// See ../syscall/exec_unix.go for description of ForkLock.
	syscall.ForkLock.RLock()
	s,err := socketFunc(family,proto)
	if err == nil {
		syscall.CloseOnExec(s)
	}
	syscall.ForkLock.RUnlock()
	if err != nil {
		return -1,os.NewSyscallError("socket",err)
	}
	if err = syscall.SetNonblock(s,true); err != nil {
		poll.CloseFunc(s)
		return -1,os.NewSyscallError("setnonblock",err)
	}
	return s,nil
}
複製程式碼

所以在exec後fd會被系統關閉,但是我們可以直接通過os.Command來實現。
這裡有些人可能有點疑惑了不是FD_CLOEXEC標誌的設定,新起的子程式繼承的fd會被關閉。
事實是os.Command啟動的子程式可以繼承父程式的fd並且使用,閱讀原始碼我們可以知道os.Command中通過Stdout,Stdin,Stderr以及ExtraFiles 傳遞的描述符預設會被Golang清除FD_CLOEXEC標誌,通過Start方法追溯進去我們可以確認我們的想法。(syscall/exec_{GOOS}.go我這裡是macos的原始碼實現參考原始碼)

// dup2(i,i) won't clear close-on-exec flag on Linux,// probably not elsewhere either.
_,_,err1 = rawSyscall(funcPC(libc_fcntl_trampoline),uintptr(fd[i]),0)
if err1 != 0 {
	goto childerror
}
複製程式碼

結合supervisor時的問題

實際專案中,線上服務一般是被supervisor啟動的,如上所說的我們如果通過父子程式,子程式啟動後退出父程式這種方式的話存在的問題就是子程式會被1號程式接管,導致supervisor 認為服務掛掉重啟服務,為了避免這種問題我們可以使用master,worker的方式。 這種方式基本思路就是: 專案啟動的時候程式作為master啟動並監聽埠建立socket描述符但是不對外提供服務,然後通過os.Command建立子程式通過StdinStdoutStderr,ExtraFilesEnv傳遞標椎輸入輸出錯誤和檔案描述符以及環境變數. 通過環境變數子程式可以知道自己是子程式並通過os.NewFile將fd註冊到epoll中,通過fd建立TCPListener物件,繫結handle處理器之後accept接受請求並處理, 參考虛擬碼:

f := os.NewFile(uintptr(3+i),"")
l,err := net.FileListener(f)
if err != nil {
	return fmt.Errorf("failed to inherit file descriptor: %d",i)
}

server:=&http.Server{Handler: handler}
server.Serve(l)
複製程式碼

上述過程只是啟動了worker程式並提供服務,真正的優雅重啟,可以通過介面(由於線上環境釋出機器可能沒有許可權,只能曲線救國)或者傳送訊號給worker程式,worker 傳送訊號給master,master程式收到訊號後起一個新worker,新worker啟動並正常提供服務後傳送一個訊號給master,master傳送退出訊號給老worker,老worker退出.

日誌收集的問題, 如果專案本身日誌是直接打到檔案,可能會存在fd滾動等問題(目前沒有研究透徹). 目前的解決方案是專案log全部輸出到stdout由supervisor來收集到日誌檔案, 建立worker的時候stdout,stderr是可以繼承過去的,這就解決了日誌的問題, 如果有更好的方式環境一起探討。

原文連結 github

參考文章

談談golang網路庫的入門認識 深入理解Linux TCP backlog go優雅升級/重啟工具調研 記一次驚心的網站TCP佇列問題排查經歷 accept和accept4的區別