前言

最近在學習Golang,在架設http server時,發現Golang在v1.8推出了一個叫做Graceful Shutdown的功能,說來慚愧,先前寫的幾個小專案雖然也都是http server,卻沒有使用過像這樣的功能,所以用一篇小文章來記錄一下。

什麼是Graceful Shutdown?

先想像一下今天已經有一個已經上線的電商網站服務,使用者會在這個網站上面瀏覽、購買商品,當這個網站要升版時,服務就必須暫停 — — 意思是,所有還在網站上進行的交易、連線都會被中斷。若是像這樣強制關閉服務,待下一次服務啟動時,我們可能就會發現資料上會有預期外的錯誤與差異。

Graceful Shutdown直譯來說就是「優雅地關機」,這個意思是,當伺服器收到終止的指令後,如果手上還有正在執行的process,它會先處理完,之後才會真的關閉服務,這麼作不僅可以保障資料的一致與完整性,我們也不需要害怕突然中止程式可能會導致非預期的錯誤。

普通的HTTP server

以下我會先介紹「沒有」Graceful Shutdown機制且強制關閉服務的觀察現象,在這邊,HTTP server的實作會透過Gin framework來實現。

package main

import (
 "log"
 "net/http"
 "time"

 "github.com/gin-gonic/gin"
)

func main() {
   log.Println("starting server...")
  
   router := gin.Default()
  
   router.GET("/", func(c *gin.Context) {
       time.Sleep(10 * time.Second)
       c.String(http.StatusOK, "hello there")
   })
  
   srv := &http.Server{
      Addr:  ":8080",
      Handler: router,
   }
  
   srv.ListenAndServe()
}

根據以上設計,當我們前往localhost:8080後,等十秒會收到從server回傳的字串;若我們在這十秒內強制中止server,client連線也就會跟著被強制中斷,導致終端機噴出以下錯誤:

* Empty reply from server
* Closing connection 0
curl: (52) Empty reply from server

Graceful Shutdown in HTTP server

前一次實驗的錯誤是因為當我們結束server服務時,還有一個client連線還沒有收到預期的回覆,卻被強制中斷連線所導致的錯誤。

而Golang推出的Graceful Shutdown功能,則是當我們結束server服務時,他會先把連接阜給關閉,接著,對於剩下還沒執行完的連線,會等待他們執行完畢後並一一關閉。

首先我們一樣先定義好server的連接阜以及router。

 log.Println("starting server...")

 router := gin.Default()

 router.GET("/", func(c *gin.Context) {
  c.JSON(http.StatusOK, gin.H{
   "hello": "sophie",
  })
 })

 srv := &http.Server{
  Addr:  ":8080",
  Handler: router,
 }

創建一個在背景運行的goroutine,並在goroutine裡執行server 服務。這麼作是為了不要讓server的運行與後續我們要讓client連線執行完畢的後續流程互相干擾。

 go func() {
  if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
   log.Fatalf("Failed to initialize server: %v\n", err)
  }
 }()

宣告一個接收os.Signal訊號的channel,如果系統的SIGINT或者SIGTERM訊號被發出,就會關閉quit通道。

換句話說,如果關閉服務的訊號一直沒有被發出,channel就會一直卡在那邊,以維持正常運作的狀態;當訊號發出後,通道被關閉,後續流程就會被啟動。

編按:當我們直接kill process時,會收到SIGINT訊號;若這個服務包在docker中,而我們執行docker rm 時,會收到SIGTERM訊號。

quit := make(chan os.Signal) 
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

<-quit

當關閉訊號發出後,我們傳入一個context,讓程式等待十秒的時間(這邊的等待時間可以根據自己需求作更新),同時,我們將這個context傳入Shutdown函式,讓程式可以在規定的時間內完成所有正在執行的請求,如果規定的時間到了,仍有請求尚未完成,那麼就會強制關閉連線。

也就是說,假設我們設定的時間不夠久,整個作法就還是強制關閉連線,因此在時間設定上,必須考量到自身server處理資料的時間。

 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 
 //確保程式結束後,釋放相關資源
 defer cancel()

 log.Println("Shutting down server...")
 if err := srv.Shutdown(ctx); err != nil {
  log.Fatalf("Server forced to shutdown: %v\n", err)
 }

大功告成,整體的程式碼可以參照以下:

package main

import (
 "context"
 "log"
 "net/http"
 "os"
 "os/signal"
 "syscall"
 "time"

 "github.com/gin-gonic/gin"
)

func main() {
   log.Println("starting server...")
  
   router := gin.Default()
  
    router.GET("/", func(c *gin.Context) {
       time.Sleep(10 * time.Second)
       c.JSON(http.StatusOK, gin.H{
         "hello": "sophie",
       })
   })
  
   srv := &http.Server{
      Addr:  ":8080",
      Handler: router,
   }
  
   go func() {
      if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
         log.Fatalf("Failed to initialize server: %v\n", err)
      }
   }()
  
   log.Printf("Listening on port %v\n", srv.Addr)
  
   quit := make(chan os.Signal)
  
   signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
  
   <-quit
  
   ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   defer cancel()
  
   log.Println("Shutting down server...")
   if err := srv.Shutdown(ctx); err != nil {
      log.Fatalf("Server forced to shutdown: %v\n", err)
   }
}

使用改版後的程式碼運行後,會發現第一次實驗出現的錯誤消失了,並且成功拿到從server端回傳的字串。

shutdown_terminal

server 中途shutdown後,client端還是有拿到server回傳的字串

shutdown_server

server端接收到關閉訊號後,有多等待10秒,並處理完來自client端的request

Reference

Go by Example: Signals https://gobyexample.com/signals

[Go 教學] 什麼是 graceful shutdown? https://blog.wu-boy.com/2020/02/what-is-graceful-shutdown-in-golang/

Gin Web Framework: Graceful restart or stop https://gin-gonic.com/docs/examples/graceful-restart-or-stop/