结论:核心套路:监听 SIGINT/SIGTERM 信号 → 调 server.Shutdown(ctx)(停止接收新连接,等待存量请求处理完,带超时兜底)→ 超时后 server.Close() 强制断开。关键是把信号处理、服务启动、退出等待组织成可组合的结构。

展开:标准实现:

srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatal(err) // 非正常退出
    }
}()

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    srv.Close() // 超时兜底:强制关闭
}

细节清单:1)ListenAndServe 在 Shutdown 后返回 ErrServerClosed,这是正常路径不算错误;2)Shutdown 只等 handler 返回,handler 内部起的 goroutine(后台任务、长轮询、WebSocket)不受管——WebSocket 连接会阻止 Shutdown 完成,要单独维护连接注册表主动关闭;3)请求内 goroutine 应服从 r.Context() 的取消信号,否则 Shutdown 等不回来;4)K8s 场景注意 preStop 与 terminationGracePeriodSeconds 的配合,以及 Pod 删除时 endpoint 摘除与 SIGTERM 送达存在时间差,常需 sleep 几秒再开始退出。工程上多组件(HTTP + 定时任务 + 消费者)用 errgroup 统一管理生命周期。易错点:信号 channel 缓冲至少为 1,否则可能丢信号。

追问方向:连接排空(connection draining)在 LB/Ingress 层的配合、热重载(endless/overseer 方案)与优雅退出的差异。