深入理解Go標準庫-http server的優雅關閉
還記得怎么啟動一個HTTP Server么?
package main
import (
"net"
"net/http"
)
func main() {
// 方式1
err := http.ListenAndServe(":8080", nil)
if err != nil {
panic(err)
}
// 方式2
// server := &http.Server{Addr: ":8080"}
// err := server.ListenAndServe()
// if err != nil {
// panic(err)
// }
}
ListenAndServe在不出錯的情況下,會一直阻塞在這個位置,如何停止這樣的一個HTTP Server呢?
CTRL+C是結束一個進程常用的方式,它和kill pid或者kill -l 15 pid命令本質上沒有任何區別,他們都是向進程發送了SIGTERM信號。因為程序沒有設置對SIGTERM信號的處理程序,所以系統默認的信號處理程序結束了我們的進程
這會帶來什么問題?
在服務器的進程被殺死時,我們的服務器可能正在處理請求并未完成。因此對于客戶端產生了一個預期外的錯誤
curl -v --max-time 4 127.0.0.1:8009/foo
* Connection #0 to host 127.0.0.1 left intact
* Trying 127.0.0.1:8009...
* Connected to 127.0.0.1 (127.0.0.1) port 8009 (#0)
> GET /foo HTTP/1.1
> Host: 127.0.0.1:8009
> User-Agent: curl/7.86.0
> Accept: */*
>
* Empty reply from server
* Closing connection 0
curl: (52) Empty reply from server
如果有nginx代理,因為upstream的中斷,nginx會產生502的響應
curl -v --max-time 11 127.0.0.1:8010/foo
* Trying 127.0.0.1:8010...
* Connected to 127.0.0.1 (127.0.0.1) port 8010 (#0)
> GET /foo HTTP/1.1
> Host: 127.0.0.1:8010
> User-Agent: curl/7.86.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 502 Bad Gateway
< Server: nginx/1.25.3
< Date: Sat, 02 Dec 2023 10:14:33 GMT
< Content-Type: text/html
< Content-Length: 497
< Connection: keep-alive
< ETag: "6537cac7-1f1"
優雅關閉的初步實現
優雅關閉(graceful shutdown)指的是我們的HTTP Server關閉前既拒絕新來的請求,又正確的處理完正在進行中的請求,隨后進程退出。如何實現?
?? 異步啟動HTTP server
因為ListenAndServe會阻塞goroutine,如果還需要讓代碼繼續執行,我們需要把它放到一個異步的goroutine中
go func() {
if err := srv.ListenAndServe(); err != nil {
panic(err)
}
}()
?? 第二步:設置SIGTERM信號處理程序
操作系統默認的信號處理程序是直接結束進程,因此要實現graceful shutdown,要設置程序自己的信號處理程序。
Go中可以使用如下的方式來處理信號
- signal.Notify來設置我們要監聽的信號,一旦有程序設定的信號發生時,信號會被寫入channel中
- signalCh chan os.Signal我們定義的是一個帶緩沖的channel,當channel中沒有數據時讀操作會阻塞
signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-signalCh
log.Printf("Received signal: %v\n", sig)
?? 第三步:平滑的關閉HTTP Server
在自定義的信號處理程序中處理什么呢?
1、首先需要關閉端口的監聽,此時新的請求就無法建立連接
2、對空閑的連接進行關閉
3、對進行中的連接等待處理完成,變成空閑連接后進行關閉
在Go 1.8以前實現上述操作需要編寫大量的代碼,也有一些第三方的庫(tylerstillwate/graceful、facebookarchive/grace等)可供使用。但Go1.8之后標準庫提供了 Shutdown()方法
?? 實現:綜合上面三步有如下實現
func main() {
mx := http.NewServeMux()
mx.HandleFunc("/foo", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Duration(rand.Intn(10)) * time.Second)
w.Write([]byte("Receive path foo\n"))
})
srv := http.Server{
Addr: ":8009",
Handler: mx,
}
go func() {
if err := srv.ListenAndServe(); err != nil {
panic(err)
}
}()
signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-signalCh
log.Printf("Received signal: %v\n", sig)
if err := srv.Shutdown(context.Background()); err != nil {
log.Fatalf("Server shutdown failed: %v\n", err)
}
log.Println("Server shutdown gracefully")
}
沒有收到SIGINT、SIGTERM信號前,main goroutine被signalCh的讀阻塞
一旦收到信號,signalCh的阻塞被解除會往下執行server的Shutdown(),Shutdown()函數會處理好活躍和非活躍的連接,并返回結果
上述代碼有什么問題么?
優雅關閉實現的細節
?? 當Shutdown被調用時ListenAndServe會立刻返回http.ErrServerClosed的錯誤
go func() {
if err := srv.ListenAndServe(); err != nil {
panic(err)
}
}()
對于上文的代碼,Shutdown()剛被調用,ListenAndServe所在的goroutine就拋出了panic,因而也導致main goroutine被退出,并沒有達到運行Shutdown()預期的效果
如果依舊想對ListenAndServe的錯誤拋出painc,需要忽略http.ErrServerClosed的錯誤
go func() {
err := srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
panic(err)
}
}()
?? 在有限的時間內關閉服務器
優雅關閉過程中會等待進行中的請求完成。但請求處理的過程可能非常耗時,或者請求本身已經陷入了無法結束的狀態,我們不可能無限的等待下去,因此設定一個關閉的上限時間會更穩妥。
Shutdown()接受一個context.Context類型的參數,我們可以用來設定超時時間
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server shutdown failed: %v\n", err)
}
log.Println("Server shutdown gracefully")
通過ctx.Done()可以區分是否因為超時導致的服務器關閉,因而可以對不同的退出原因進行區分
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
select {
case <-ctx.Done():
// 由于達到超時時間服務器關閉,未完成優雅關閉
log.Println("timeout of 5 seconds.")
default:
// 其他原因導致的服務關閉異常,未完成優雅關閉
log.Fatalf("Server shutdown failed: %v\n", err)
}
return
}
// 正確執行優雅關閉服務器
log.Println("Server shutdown gracefully")
?? 釋放其他資源
除了顯式的釋放資源,main goroutine也有必要通知其他goroutine進程即將退出,做必要的處理
例如,我們的服務在啟動后會向服務中心進行注冊,之后異步定時上報自身狀態。
為了讓注冊中心第一時間感知到服務已下線,需要主動注銷服務。在注銷服務前,需要先暫停異步的定時上報
context.Context讓我們可以很輕松的做到這件事
ctx, cancel := context.WithCancel(context.Background())
defer func() {
cancel()
}()
// 需要在服務啟動后才在注冊中心注冊
go func() {
tc := time.NewTicker(5 * time.Second)
for {
select {
case <-tc.C:
// 上報狀態
log.Println("status update success")
case <-ctx.Done():
// server closed, return
tc.Stop()
log.Println("stop update success")
return
}
}
}()
示例倉庫中還有一個更復雜的利用context.Context退出子goroutine的例子
?? 全貌
結合上面的所有的細節,一個優雅關閉的http server代碼如下
func registerService(ctx context.Context) {
tc := time.NewTicker(5 * time.Second)
for {
select {
case <-tc.C:
// 上報狀態
log.Println("status update success")
case <-ctx.Done():
tc.Stop()
log.Println("stop update success")
return
}
}
}
func destroyService() {
log.Println("destroy success")
}
func gracefulShutdown() {
mainCtx, mainCancel := context.WithCancel(context.Background())
// 用ctx初始化資源,mysql,redis等
// ...
defer func() {
mainCancel()
// 主動注銷服務
destroyService()
// 清理資源,mysql,redis等
// ...
}()
mx := http.NewServeMux()
mx.HandleFunc("/foo", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Duration(rand.Intn(10)) * time.Second)
w.Write([]byte("Receive path foo\n"))
})
srv := http.Server{
Addr: ":8009",
Handler: mx,
}
// ListenAndServe也會阻塞,需要把它放到一個goroutine中
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(err)
}
}()
// 需要在服務啟動后才在注冊中心注冊
go registerService(mainCtx)
signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
// 等待信號
sig := <-signalCh
log.Printf("Received signal: %v\n", sig)
ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelTimeout()
if err := srv.Shutdown(ctxTimeout); err != nil {
select {
case <-ctxTimeout.Done():
// 由于達到超時時間服務器關閉,未完成優雅關閉
log.Println("timeout of 5 seconds.")
default:
// 其他原因導致的服務關閉異常,未完成優雅關閉
log.Fatalf("Server shutdown failed: %v\n", err)
}
return
}
// 正確執行優雅關閉服務器
log.Println("Server shutdown gracefully")
}