[TOC]
# 簡介
按照一般的設計原則, 每個 HTTP 請求都是無狀態的,因此大多情況下 Web 應用都很容易做水平擴展。“無狀態”也意味著 HTTP 請求發起重試的成本是很低的,從而使得 Web 接口的開發很少關注優雅中止(一部分也因為 Web 框架做了這部分的考慮)。
不過,業務中 ① 總會存在對中止比較敏感的接口(比如支付相關),并且 ② 總會存在一些帶狀態的服務,此時優雅中止就顯得比較重要了。
本文通過一個Go 定時任務示例來簡單介紹 Go 技術棧中優雅中止的處理思路。
# k8s中pod的終止機制
作為高可靠的服務平臺,k8s 定義了終止 Pod (業務進程在 Pod 中運行)的基本步驟:當主動刪除 pod 時,系統會在強制終止 Pod 之前將 TERM 信號發送到每個容器中的主進程,過一段時間后(默認為 30 秒),再把 KILL 信號發送到這些進程。除此之外, k8s 還通過鉤子方法提供了對 容器生命周期 的管理能力,允許用戶通過自定義的方式配置容器啟動后或終止前執行的操作。
當打包進鏡像的應用運行在 k8s 中的時候,如果應用實現了優雅中止的機制,就可以充分利用上面提到的 k8s 的能力,在升級應用(發新版本)和管理 Pod (宿主機維護時把 Pod 漂移到另一個宿主機,或者在閑時動態地收縮 Pod 數量從而把資源省出來另作他用)的過程中實現服務的零中斷。
# 優雅中止的 Go 代碼示例
下面的代碼定義了兩個定時任務:mySecondJobs 每秒鐘會觸發一次,每次持續約 1 秒鐘;myMinuteJobs 每分鐘會觸發一次,每次持續約 2 秒鐘。具體地可以閱讀下面的代碼(可以直接復制下面的代碼到自己的環境中運行):
~~~
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
c := make(chan os.Signal)
// Go 不允許監聽 SIGKILL/SIGSTOP 信號
// 參考 https://github.com/golang/go/issues/9463
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
second := time.NewTicker(time.Second)
minute := time.NewTicker(time.Minute)
A: // 由于 for-select 嵌套使用,設置跳出 for 循環的標記
for {
select {
case s := <-c:
// 收到 SIGTERM/SIGINT 信號,跳出 for 循環結束進程
fmt.Printf("get signal %s, graceful ending...\n", s)
break A
case <-second.C:
go mySecondJobs()
case <-minute.C:
go myMinuteJobs()
}
}
fmt.Println("graceful ending")
// 做一些操作讓異步任務正常結束,這里偷懶地采取簡單等待的方式 ??
time.Sleep(time.Second * 10)
fmt.Println("graceful ended.")
}
func mySecondJobs() {
tS := time.Now().String()
fmt.Printf("starting second job: %s \n", tS)
time.Sleep(time.Second * 1) // 假設每個任務消耗 1 秒時間
fmt.Printf("second job %s are done. \n", tS)
}
func myMinuteJobs() {
tS := time.Now().String()
fmt.Printf("starting minute job: %s \n", tS)
time.Sleep(time.Second * 2) // 假設每個任務消耗 2 秒時間
fmt.Printf("minute job %s are done. \n", tS)
}
~~~
# 源碼解讀-優雅中止的處理思路
* 通過 signal.Notify 捕獲特定的信號;
* 通過 for + select 來實現循環任務,同時檢測上步中欲捕獲的信號;
* 如果定時器被觸發,則執行對應的任務;
* 如果發現收到了指定的信號,則跳出 for 循環,并采取一定措施結束異步任務。
# 源碼解讀-值得關注的幾個點
代碼中采用了 go mySecondJobs() 和 go myMinuteJobs() 異步任務的方式;如果采用同步的方式將無法捕獲信號,因為此時主線程在處理業務邏輯,沒有空閑處理信號捕獲邏輯。
源碼中偷懶地采取簡單等待的方式來保證異步任務正常結束,非普適方法,實際開發中需要根據情況做定制。
time.Ticker 的使用是有注意事項的,當 select 語句中同一時刻有多個分支滿足條件時會隨機取一個執行,從而導致信息丟失,不過本文的代碼不會觸發這個問題,大家可以思考一下原因。
# http的shutdown
如何優雅的關閉http服務在Go Web開發中一直被提及和討論的話題,今天Go 1.8的發布終于為我們帶來了這個特性。
文檔中是這樣介紹的:
~~~
func (srv *Server) Shutdown(ctx context.Context) error
~~~
`Shutdown`將無中斷的關閉正在活躍的連接,然后平滑的停止服務。處理流程如下:
* 首先關閉所有的監聽
* 然后關閉所有的空閑連接
* 然后無限期等待連接處理完畢轉為空閑,并關閉
* 如果提供了 帶有超時的`Context`,將在服務關閉前返回`Context`的超時錯誤
需要注意的是,`Shutdown`并不嘗試關閉或者等待`hijacked`連接,如`WebSockets`。如果需要的話調用者需要分別處理諸如長連接類型的等待和關閉。
其實,你只要調用`Shutdown`方法就好了。
~~~
// main.go
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World, %v\n", time.Now())
})
s := &http.Server{
Addr: ":8080",
Handler: http.DefaultServeMux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
go func() {
log.Println(s.ListenAndServe())
log.Println("server shutdown")
}()
// Handle SIGINT and SIGTERM.
ch := make(chan os.Signal)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
log.Println(<-ch)
// Stop the service gracefully.
log.Println(s.Shutdown(nil))
// Wait gorotine print shutdown message
time.Sleep(time.Second * 5)
log.Println("done.")
}
~~~
運行程序:
~~~
go run main.go
~~~
然后`ctrl + c`終止該程序,會打印出如下信息:
~~~
2017/02/17 11:36:28 interrupt
2017/02/17 11:36:28 <nil>
2017/02/17 11:36:28 http: Server closed
2017/02/17 11:36:28 server shutdown
~~~
可以看到,服務被正確關閉了。
在沒有`Shutdown`方法之前,`ctrl + c`就硬生生的終止了,然后就沒有然后了
# 小結
默認情況下,Go 應用在接收到 TERM 信號后直接退出主進程,如果此時有過程沒處理完(比如 接收到外部請求后尚未返回響應,或者內部的異步任務尚未結束),則會導致過程的異常中斷,影響服務質量。通過在代碼中顯式地捕獲 TERM 信號及其他信號,感知操作系統對進程的處理,可以主動采取措施優雅地結束應用進程。
隨著 k8s 的普及,考慮到其對進程生命周期的規范化管理,應用支持代碼級的優雅中止(尤其是容器化的應用)有必要成為一種開發規范,值得引起每一位開發者的注意
- 基礎
- 簡介
- 主要特征
- 變量和常量
- 編碼轉換
- 數組
- byte與rune
- big
- sort接口
- 和mysql類型對應
- 函數
- 閉包
- 工作區
- 復合類型
- 指針
- 切片
- map
- 結構體
- sync.Map
- 隨機數
- 面向對象
- 匿名組合
- 方法
- 接口
- 權限
- 類型查詢
- 異常處理
- error
- panic
- recover
- 自定義錯誤
- 字符串處理
- 正則表達式
- json
- 文件操作
- os
- 文件讀寫
- 目錄
- bufio
- ioutil
- gob
- 棧幀的內存布局
- shell
- 時間處理
- time詳情
- time使用
- new和make的區別
- container
- list
- heap
- ring
- 測試
- 單元測試
- Mock依賴
- delve
- 命令
- TestMain
- path和filepath包
- log日志
- 反射
- 詳解
- plugin包
- 信號
- goto
- 協程
- 簡介
- 創建
- 協程退出
- runtime
- channel
- select
- 死鎖
- 互斥鎖
- 讀寫鎖
- 條件變量
- 嵌套
- 計算單個協程占用內存
- 執行規則
- 原子操作
- WaitGroup
- 定時器
- 對象池
- sync.once
- 網絡編程
- 分層模型
- socket
- tcp
- udp
- 服務端
- 客戶端
- 并發服務器
- Http
- 簡介
- http服務器
- http客戶端
- 爬蟲
- 平滑重啟
- context
- httptest
- 優雅中止
- web服務平滑重啟
- beego
- 安裝
- 路由器
- orm
- 單表增刪改查
- 多級表
- orm使用
- 高級查詢
- 關系查詢
- SQL查詢
- 元數據二次定義
- 控制器
- 參數解析
- 過濾器
- 數據輸出
- 表單數據驗證
- 錯誤處理
- 日志
- 模塊
- cache
- task
- 調試模塊
- config
- 部署
- 一些包
- gjson
- goredis
- collection
- sjson
- redigo
- aliyunoss
- 密碼
- 對稱加密
- 非對稱加密
- 單向散列函數
- 消息認證
- 數字簽名
- mysql優化
- 常見錯誤
- go run的錯誤
- 新手常見錯誤
- 中級錯誤
- 高級錯誤
- 常用工具
- 協程-泄露
- go env
- gometalinter代碼檢查
- go build
- go clean
- go test
- 包管理器
- go mod
- gopm
- go fmt
- pprof
- 提高編譯
- go get
- 代理
- 其他的知識
- go內存對齊
- 細節總結
- nginx路由匹配
- 一些博客
- redis為什么快
- cpu高速緩存
- 常用命令
- Go 永久阻塞的方法
- 常用技巧
- 密碼加密解密
- for 循環迭代變量
- 備注
- 垃圾回收
- 協程和纖程
- tar-gz
- 紅包算法
- 解決golang.org/x 下載失敗
- 逃逸分析
- docker
- 鏡像
- 容器
- 數據卷
- 網絡管理
- 網絡模式
- dockerfile
- docker-composer
- 微服務
- protoBuf
- GRPC
- tls
- consul
- micro
- crontab
- shell調用
- gorhill/cronexpr
- raft
- go操作etcd
- mongodb