小Cの已经记不起来的博客

从零实现一个简易的优雅停机

前段时间部署服务的时候遇到一个很烦的问题:每次发新版,kill 掉旧进程的瞬间,正在处理的请求直接被掐断了,客户端那边时不时收到连接被重置的报错。一开始还以为是网络问题,排查了半天才发现,就是停机的时候太粗暴了。所以这次干脆花点时间,自己动手实现一个简易的优雅停机,顺便把过程记录下来。

什么是优雅停机

简单说就是:进程收到退出信号后,不再接收新的请求,但是把手头正在处理的请求处理完,该释放的资源释放掉,然后再退出。而不是像 kill -9 那样直接被系统干掉,什么都不管。

比如一个 HTTP 服务,优雅停机大概是这么个流程:

  1. 收到 SIGTERM 或 SIGINT 信号
  2. 关闭监听的端口,不再接受新连接
  3. 等待已经在处理的请求完成(或者超时后强制退出)
  4. 关闭数据库连接、刷掉日志之类的收尾工作
  5. 进程退出

看起来不复杂,但坑还是不少的。

一个最朴素的版本

先看一个最原始的服务,用 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 的宽限期要和你的停机时间对齐。

建议每个写服务的都自己动手实现一遍,不复杂,但比出了线上问题再排查要划算得多。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放