go平滑重啓調研選型和項目實踐

原文連接 githublinux

什麼是平滑重啓

當線上代碼須要更新時,咱們平時通常的作法須要先關閉服務而後再重啓服務. 這時線上可能存在大量正在處理的請求, 這時若是咱們直接關閉服務會形成請求所有 中斷, 影響用戶體驗; 在重啓從新提供服務以前, 新請求進來也會502. 這時就出現兩個須要解決的問題:git

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

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

優雅退出

在實現優雅重啓以前首先須要解決的一個問題是如何優雅退出:
咱們知道在go 1.8.x後,golang在http里加入了shutdown方法,用來控制優雅退出。
社區裏很多http graceful動態重啓,平滑重啓的庫,大可能是基於http.shutdown作的。golang

http shutdown 源碼分析

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

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三次握手的過程來看可能會好理解不少,我的感受有種豁然開朗的感受.bash

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給父進程, 父進程優雅退出此時子進程提供服務, 完成優雅重啓。app

Golang中的實現

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

咱們知道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, sotype, 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]), F_SETFD, 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的區別

相關文章
相關標籤/搜索