一文講懂服務的優雅重啟和更新

在服務端程式更新或重啟時,如果我們直接 kill -9 殺掉舊進程並啟動新進程,會有以下幾個問題:

  1. 舊的請求未處理完,如果服務端進程直接退出,會造成客戶端鏈接中斷(收到 RST
  2. 新請求打過來,服務還沒重啟完畢,造成 connection refused
  3. 即使是要退出程式,直接 kill -9 仍然會讓正在處理的請求中斷

很直接的感受就是:在重啟過程中,會有一段時間不能給用戶提供正常服務;同時粗魯關閉服務,也可能會對業務依賴的資料庫等狀態服務造成污染。

所以我們服務重啟或者是重新發布過程中,要做到新舊服務無縫切換,同時可以保障變更服務 零宕機時間

作為一個微服務框架,那 go-zero 是怎麼幫開發者做到優雅退出的呢?下面我們一起看看。

優雅退出

在實現優雅重啟之前首先需要解決的一個問題是 如何優雅退出

對 http 服務來說,一般的思路就是關閉對 fdlisten , 確保不會有新的請求進來的情況下處理完已經進入的請求, 然後退出。

go 原生中 http 中提供了 server.ShutDown(),先來看看它是怎麼實現的:

  1. 設置 inShutdown 標誌
  2. 關閉 listeners 保證不會有新請求進來
  3. 等待所有活躍鏈接變成空閑狀態
  4. 退出函數,結束

分別來解釋一下這幾個步驟的含義:

inShutdown

func (srv *Server) ListenAndServe() error {
    if srv.shuttingDown() {
        return ErrServerClosed
    }
    ....
    // 實際監聽埠;生成一個 listener
    ln, err := net.Listen("tcp", addr)
    if err != nil {
        return err
    }
    // 進行實際邏輯處理,並將該 listener 注入
    return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}

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

ListenAndServe 是http啟動伺服器的必經函數,裡面的第一句就是判斷 Server 是否被關閉了。

inShutdown 就是一個原子變數,非0表示被關閉。

listeners

func (srv *Server) Serve(l net.Listener) error {
    ...
    // 將注入的 listener 加入內部的 map 中
    // 方便後續控制從該 listener 鏈接到的請求
    if !srv.trackListener(&l, true) {
        return ErrServerClosed
    }
    defer srv.trackListener(&l, false)
    ...
}

Serve 中註冊到內部 listeners maplistener,在 ShutDown 中就可以直接從 listeners 中獲取到,然後執行 listener.Close(),TCP四次揮手後,新的請求就不會進入了。

closeIdleConns

簡單來說就是:將目前 Server 中記錄的活躍鏈接變成變成空閑狀態,返回。

關閉

func (srv *Server) Serve(l net.Listener) error {
  ...
  for {
    rw, err := l.Accept()
    // 此時 accept 會發生錯誤,因為前面已經將 listener close了
    if err != nil {
      select {
      // 又是一個標誌:doneChan
      case <-srv.getDoneChan():
        return ErrServerClosed
      default:
      }
    }
  }
}

其中 getDoneChan 中已經在前面關閉 listener 時,對 doneChan 這個channel中push。

總結一下:Shutdown 可以優雅的終止服務,期間不會中斷已經活躍的鏈接

但服務啟動後的某一時刻,程式如何知道服務被中斷了呢?服務被中斷時如何通知程式,然後調用Shutdown作處理呢?接下來看一下系統訊號通知函數的作用

服務中斷

這個時候就要依賴 OS 本身提供的 signal。對應 go 原生來說,signalNotify 提供系統訊號通知的能力。

//github.com/tal-tech/go-zero/blob/master/core/proc/signals.go

func init() {
  go func() {
    var profiler Stopper
    
    signals := make(chan os.Signal, 1)
    signal.Notify(signals, syscall.SIGUSR1, syscall.SIGUSR2, syscall.SIGTERM)

    for {
      v := <-signals
      switch v {
      case syscall.SIGUSR1:
        dumpGoroutines()
      case syscall.SIGUSR2:
        if profiler == nil {
          profiler = StartProfile()
        } else {
          profiler.Stop()
          profiler = nil
        }
      case syscall.SIGTERM:
        // 正在執行優雅關閉的地方
        gracefulStop(signals)
      default:
        logx.Error("Got unregistered signal:", v)
      }
    }
  }()
}
  • SIGUSR1 -> 將 goroutine 狀況,dump下來,這個在做錯誤分析時還挺有用的

  • SIGUSR2 -> 開啟/關閉所有指標監控,自行控制 profiling 時長

  • SIGTERM -> 真正開啟 gracefulStop,優雅關閉

gracefulStop 的流程如下:

  1. 取消監聽訊號,畢竟要退出了,不需要重複監聽了
  2. wrap up,關閉目前服務請求,以及資源
  3. time.Sleep() ,等待資源處理完成,以後關閉完成
  4. shutdown ,通知退出
  5. 如果主goroutine還沒有退出,則主動發送 SIGKILL 退出進程

這樣,服務不再接受新的請求,服務活躍的請求等待處理完成,同時也等待資源關閉(資料庫連接等),如有超時,強制退出。

整體流程

我們目前 go 程式都是在 docker 容器中運行,所以在服務發布過程中,k8s 會向容器發送一個 SIGTERM 訊號,然後容器中程式接收到訊號,開始執行 ShutDown

到這裡,整個優雅關閉的流程就梳理完畢了。

但是還有平滑重啟,這個就依賴 k8s 了,基本流程如下:

  • old pod 未退出之前,先啟動 new pod
  • old pod 繼續處理完已經接受的請求,並且不再接受新請求
  • new pod接受並處理新請求的方式
  • old pod 退出

這樣整個服務重啟就算是成功了,如果 new pod 沒有啟動成功,old pod 也可以提供服務,不會對目前線上的服務造成影響。

項目地址

//github.com/tal-tech/go-zero

歡迎使用 go-zero 並 star 支援我們!

微信交流群

關注『微服務實踐』公眾號並點擊 交流群 獲取社區群二維碼。