go平滑重啟調(diào)研選型和項目實踐

原文鏈接
github

什么是平滑重啟

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

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

本文主要結(jié)合linux和Golang中相關(guān)實現(xiàn)來介紹如何選型與實踐過程.

優(yōu)雅退出

在實現(xiàn)優(yōu)雅重啟之前首先需要解決的一個問題是如何優(yōu)雅退出:
我們知道在go 1.8.x后,golang在http里加入了shutdown方法,用來控制優(yōu)雅退出。
社區(qū)里不少http graceful動態(tài)重啟,平滑重啟的庫,大多是基于http.shutdown做的。

http shutdown 源碼分析

先來看下http shutdown的主方法實現(xiàn)邏輯。用atomic來做退出標記的狀態(tài),然后關(guān)閉各種的資源,然后一直阻塞的等待無空閑連接,每500ms輪詢一次。

var shutdownPollInterval = 500 * time.Millisecond

func (srv *Server) Shutdown(ctx context.Context) error {
    // 標記退出的狀態(tài)
    atomic.StoreInt32(&srv.inShutdown, 1)
    srv.mu.Lock()
    // 關(guān)閉listen fd,新連接無法建立。
    lnerr := srv.closeListenersLocked()
    
    // 把server.go的done chan給close掉,通知等待的worekr退出
    srv.closeDoneChanLocked()

    // 執(zhí)行回調(diào)方法,我們可以注冊shutdown的回調(diào)方法
    for _, f := range srv.onShutdown {
        go f()
    }

    // 每500ms來檢查下,是否沒有空閑的連接了,或者監(jiān)聽上游傳遞的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
}

關(guān)閉server.doneChan和監(jiān)聽的文件描述符

// 關(guān)閉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)
    }
}

// 關(guān)閉監(jiān)聽的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
}

// 關(guān)閉連接
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主監(jiān)聽方法也就退出了。

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)
    }
}

那么如何保證用戶在請求完成后,再關(guān)閉連接的?

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() {
                        // 關(guān)閉連接,并且標記退出
            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)
        }
                ...
                ...
                // 匹配路由及回調(diào)處理方法
        serverHandler{c.server}.ServeHTTP(w, w.req)
        w.cancelCtx()
        if c.hijacked() {
            return
        }
                ...
                // 判斷是否在shutdown mode, 選擇退出
        if !w.conn.server.doKeepAlives() {
            return
        }
    }
    ...

優(yōu)雅重啟

方法演進

從linux系統(tǒng)的角度

  • 直接使用exec,把代碼段替換成新的程序的代碼, 廢棄原有的數(shù)據(jù)段和堆棧段并為新程序分配新的數(shù)據(jù)段與堆棧段,唯一留下的就是進程號。

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

image.png
  • 通過forkexec創(chuàng)建新進程, exec前在老進程中通過fcntl(fd, F_SETFD, 0);清除FD_CLOEXEC標志,之后exec新進程就會繼承老進程 的fd并可以直接使用。
    之后新進程和老進程listen相同的fd同時提供服務, 在新進程正常啟動服務后發(fā)送信號給老進程, 老進程優(yōu)雅退出。
    之后所有請求 都到了新進程也就完成了本次優(yōu)雅重啟。
    結(jié)合實際線上環(huán)境存在的問題: 這時新的子進程由于父進程的退出, 系統(tǒng)會把它的父進程改成1號進程,由于線上環(huán)境大多數(shù)服務都是通過 supervisor進行管理的,這就會存在一個問題, supervisor會認為服務異常退出, 會重新啟動一個新進程.
  • 通過給文件描述符設置SO_REUSEPORT標志讓兩個進程監(jiān)聽同一個端口, 這里存在的問題是這里使用的是兩個不同的FD監(jiān)聽同一個端口,老進程退出的時候。 syn queue隊列中還未被accept的連接會被內(nèi)核kill掉。

  • 通過ancilliary data系統(tǒng)調(diào)用使用UNIX域套接字在進程之間傳遞文件描述符, 這樣也可以實現(xiàn)優(yōu)雅重啟。但是這樣的實現(xiàn)會比較復雜, HAProxy中 實現(xiàn)了該模型。

  • 直接fork然后exec調(diào)用,子進程會繼承所有父進程打開的文件描述符, 子進程拿到的文件描述符從3遞增, 順序與父進程打開順序一致。子進程通過epoll_ctl 注冊fd并注冊事件處理函數(shù)(這里以epoll模型為例), 這樣子進程就能和父進程監(jiān)聽同一個端口的請求了(此時父子進程同時提供服務), 當子進程正常啟動并提供服務后 發(fā)送SIGHUP給父進程, 父進程優(yōu)雅退出此時子進程提供服務, 完成優(yōu)雅重啟。

Golang中的實現(xiàn)

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

我們知道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會被系統(tǒng)關(guān)閉,但是我們可以直接通過os.Command來實現(xiàn)。
這里有些人可能有點疑惑了不是FD_CLOEXEC標志的設置,新起的子進程繼承的fd會被關(guān)閉。
事實是os.Command啟動的子進程可以繼承父進程的fd并且使用, 閱讀源碼我們可以知道os.Command中通過Stdout,Stdin,Stderr以及ExtraFiles 傳遞的描述符默認會被Golang清除FD_CLOEXEC標志, 通過Start方法追溯進去我們可以確認我們的想法。(syscall/exec_{GOOS}.go我這里是macos的源碼實現(xiàn)參考源碼)

// 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
}

結(jié)合supervisor時的問題

實際項目中, 線上服務一般是被supervisor啟動的, 如上所說的我們?nèi)绻ㄟ^父子進程, 子進程啟動后退出父進程這種方式的話存在的問題就是子進程會被1號進程接管, 導致supervisor 認為服務掛掉重啟服務,為了避免這種問題我們可以使用master, worker的方式。
這種方式基本思路就是: 項目啟動的時候程序作為master啟動并監(jiān)聽端口創(chuàng)建socket描述符但是不對外提供服務, 然后通過os.Command創(chuàng)建子進程通過Stdin, Stdout, Stderr,ExtraFilesEnv傳遞標椎輸入輸出錯誤和文件描述符以及環(huán)境變量. 通過環(huán)境變量子進程可以知道自己是子進程并通過os.NewFile將fd注冊到epoll中, 通過fd創(chuàng)建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進程并提供服務, 真正的優(yōu)雅重啟, 可以通過接口(由于線上環(huán)境發(fā)布機器可能沒有權(quán)限,只能曲線救國)或者發(fā)送信號給worker進程,worker 發(fā)送信號給master, master進程收到信號后起一個新worker, 新worker啟動并正常提供服務后發(fā)送一個信號給master,master發(fā)送退出信號給老worker,老worker退出.

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

原文鏈接
github

參考文章

談談golang網(wǎng)絡庫的入門認識
深入理解Linux TCP backlog
go優(yōu)雅升級/重啟工具調(diào)研
記一次驚心的網(wǎng)站TCP隊列問題排查經(jīng)歷
accept和accept4的區(qū)別

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容