从零实现一个简易的优雅停机
前段时间部署服务的时候遇到一个很烦的问题:每次发新版,kill 掉旧进程的瞬间,正在处理的请求直接被掐断了,客户端那边时不时收到连接被重置的报错。一开始还以为是网络问题,排查了半天才发现,就是停机的时候太粗暴了。所以这次干脆花点时间,自己动手实现一个简易的优雅停机,顺便把过程记录下来。
什么是优雅停机
简单说就是:进程收到退出信号后,不再接收新的请求,但是把手头正在处理的请求处理完,该释放的资源释放掉,然后再退出。而不是像 kill -9 那样直接被系统干掉,什么都不管。
比如一个 HTTP 服务,优雅停机大概是这么个流程:
- 收到
SIGTERM或SIGINT信号 - 关闭监听的端口,不再接受新连接
- 等待已经在处理的请求完成(或者超时后强制退出)
- 关闭数据库连接、刷掉日志之类的收尾工作
- 进程退出
看起来不复杂,但坑还是不少的。
一个最朴素的版本
先看一个最原始的服务,用 Go 写的,就一个 /hello 接口:
func main() {
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second) // 模拟慢请求
w.Write([]byte("hello"))
})
http.ListenAndServe(":8080", nil)
}
这种写法在部署的时候问题就很明显了:kill 一发过去,ListenAndServe 直接返回错误,进程退出,那些还在 Sleep 的请求全部夭折。要验证也简单,curl 一个请求过去,然后马上 kill 进程,curl 那边基本必挂。
换成 http.Server 并配合信号
第一步,把 ListenAndServe 换成自己创建的 http.Server,然后监听系统信号:
func main() {
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
}
这里有几个小细节要注意。ListenAndServe 一定要放在 goroutine 里跑,不然主协程会阻塞在那一行,根本走不到监听信号的地方。另外判断错误的时候要把 http.ErrServerClosed 排除掉,因为后面主动调用 Shutdown 的时候,它返回的就是这个错误,不排除的话日志会看着像出了什么大事。
signal.Notify 的 channel 记得带个缓冲,容量给 1 就够了,不然信号发过来的时候如果没人读,信号会被丢弃(虽然这个场景下问题不大,但习惯还是要养好)。
等待在途请求完成
收到信号之后,就该调用 Shutdown 了。但是直接调还不够,实际生产中我们一般会加一个超时兜底,防止某个请求卡死导致进程永远退不出去:
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("forced shutdown: %v", err)
}
Shutdown 的行为是:关闭所有监听的 listener,然后等所有活跃连接变成 idle 状态。如果超时了还没等完,就返回 context 错误,这时候就需要你决定要不要更暴力地关掉。可以用 srv.Close() 强杀,虽然会掐断在途请求,但至少进程能退出去,不然 Kubernetes 那边等着超时后直接 kill -9,效果差不多还更难看。
别忘了非 HTTP 的资源
到上面为止,HTTP 部分的优雅停机算是齐了,但很多人容易漏掉另一类东西:后台任务和数据库连接。
比如我的服务里还有个每十秒跑一次的定时任务,用 goroutine 加 ticker 实现的。停机的时候如果直接退出,任务跑到一半可能就断了。我的做法是用一个 sync.WaitGroup 把所有后台 goroutine 管起来:
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
doJob()
case <-quit:
return
}
}
}()
// 收到信号后
wg.Wait()
wg.Wait() 放在 srv.Shutdown 之后,这样 HTTP 请求先处理完,后台任务也跑完当前这一轮,最后再关数据库连接:
if err := db.Close(); err != nil {
log.Printf("db close error: %v", err)
}
log.Println("server exited")
顺序上我个人的习惯是:先停流量,再等后台任务,最后关资源。反过来的话,后台任务可能在数据库已经关了之后还在跑,然后报一堆错。
在 Kubernetes 里验证
部署到 k8s 之后还有一个点要注意,就是 terminationGracePeriodSeconds,默认是 30 秒。如果你的请求平均要跑十几秒,这个值最好调大一点,不然 pod 会在你的 Shutdown 还没等完的时候就被 kill -9 了,前面全白搭。
验证方法很简单:kubectl 里滚动更新一次,同时用 hey 或者 wrk 压一段请求,观察有没有失败。我这边调整之后,滚动更新期间的错误率直接归零了,效果还是很明显的。
总结
整个实现下来其实就百来行代码,核心就是 signal.Notify + http.Server.Shutdown + 超时兜底这三板斧。但是真正踩坑的地方都在细节上:错误判断要排除 ErrServerClosed,后台任务要有退出机制,k8s 的宽限期要和你的停机时间对齐。
建议每个写服务的都自己动手实现一遍,不复杂,但比出了线上问题再排查要划算得多。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。