Go 用 signal.NotifyContext 优雅关停 HTTP 服务:CtrlC 后把在途请求处理完再退出线上服务发新版本,滚动更新时总有几个请求报connection reset。查了半天,发现是进程收到SIGTERM后os.Exit(0)直接退了,那些还在处理的请求被硬生生掐断。这篇就把 Go HTTP 服务的优雅关停一次讲透:收到信号后停止接收新连接,把在途请求处理完再退出。朴素写法:根本没管信号最常见的服务启动代码长这样:funcmain(){mux:http.NewServeMux()mux.HandleFunc(/slow,func(w http.ResponseWriter,r*http.Request){time.Sleep(5*time.Second)// 模拟一个慢请求w.Write([]byte(done))})http.ListenAndServe(:8080,mux)// 阻塞在这里}按 CtrlC,进程立刻死。如果这时有个/slow请求正处理到第 2 秒,客户端直接拿到连接重置。Kubernetes 滚动更新发SIGTERM也是同样结局。问题的根源是:ListenAndServe一旦返回,进程就退出了,它没有任何机会去收尾。第一步:手动接信号 ShutdownGo 的http.Server提供了Shutdown方法:它会先关闭监听端口(不再接新请求),然后等所有在途请求处理完再返回。配合signal.Notify接收系统信号:funcmain(){srv:http.Server{Addr::8080,Handler:buildHandler()}gofunc(){// ListenAndServe 正常关停时返回 ErrServerClosed,不是错误iferr:srv.ListenAndServe();err!nilerr!http.ErrServerClosed{log.Fatalf(listen: %v,err)}}()// 缓冲区至少为 1:信号在我们开始接收前到达也不会丢quit:make(chanos.Signal,1)signal.Notify(quit,syscall.SIGINT,syscall.SIGTERM)-quit// 阻塞直到收到信号log.Println(shutting down...)srv.Shutdown(context.Background())}这已经能用了,但有两个隐患:一是Shutdown传了context.Background(),万一某个请求卡死(比如慢查询没超时),关停会永远等下去;二是信号处理逻辑和业务代码耦合,不好复用。正确写法:signal.NotifyContext 超时兜底Go 1.16 起有了signal.NotifyContext,它把接收信号直接封装成一个会在收到信号时自动取消的context。代码一下清爽很多,而且天然能和其他 context 组合:packagemainimport(contexterrorslognet/httpos/signalsyscalltime)funcmain(){// 收到 SIGINT/SIGTERM 时,ctx 自动被取消;stop 用于释放信号监听ctx,stop:signal.NotifyContext(context.Background(),syscall.SIGINT,syscall.SIGTERM)deferstop()srv:http.Server{Addr::8080,Handler:buildHandler()}gofunc(){iferr:srv.ListenAndServe();err!nil!errors.Is(err,http.ErrServerClosed){log.Fatalf(listen: %v,err)}}()log.Println(server started on :8080)-ctx.Done()// 等信号stop()// 立刻恢复默认信号行为:再按一次 CtrlC 能强制杀掉log.Println(shutting down, draining in-flight requests...)// 给在途请求最多 10 秒收尾,超了就强退,别让关停无限期挂着shutdownCtx,cancel:context.WithTimeout(context.Background(),10*time.Second)defercancel()iferr:srv.Shutdown(shutdownCtx);err!nil{log.Printf(graceful shutdown timed out, forcing exit: %v,err)srv.Close()// 强制关闭所有连接}log.Println(bye)}funcbuildHandler()http.Handler{mux:http.NewServeMux()mux.HandleFunc(/slow,func(w http.ResponseWriter,r*http.Request){time.Sleep(5*time.Second)w.Write([]byte(done\n))})returnmux}跑起来验证一下:启动服务,另开终端curl localhost:8080/slow,趁 5 秒没结束按 CtrlC。你会看到日志打出 “draining in-flight requests”,而那个curl依然正常拿到了done——请求没被掐断,进程等它跑完才退出。这里stop()调用了两次是有意为之:第一次defer stop()保证退出前一定释放;-ctx.Done()之后立刻再stop()一次,是为了恢复信号的默认处理。这样如果关停过程本身卡住了(比如某请求死循环),用户再按一次 CtrlC 就能强制杀进程,而不是被我们的 handler 一直吞掉信号。坑:Shutdown 不会中断长连接和 WebSocketShutdown只等待空闲连接关闭和活跃 HTTP 请求返回,它管不了 WebSocket 这种被 Hijack 走的长连接——那些连接会让Shutdown一直等到超时。正确做法是把服务端主动通知长连接关闭的逻辑挂到RegisterOnShutdown:srv.RegisterOnShutdown(func(){// 在 Shutdown 开始时被调用,这里主动给所有 ws 连接发关闭帧hub.CloseAll()})另一个常见坑:SIGKILL(kill -9)是捕获不到的,signal.Notify对它无效。所以别指望代码能优雅处理 kill -9,那是内核直接干掉进程。Kubernetes 的terminationGracePeriodSeconds就是留给你响应SIGTERM的窗口,超时后才补SIGKILL——把关停超时设得比这个宽限期短一点,才能真正跑完收尾逻辑。小结ListenAndServe正常关停返回的是http.ErrServerClosed,要用errors.Is排除掉,别当错误log.Fatal。用signal.NotifyContext把信号变成一个 context,比手动signal.Notify channel 更清爽,还能和业务 context 组合。Shutdown一定要带超时 context,并在超时后用Close()兜底,否则一个卡死的请求能让关停永远挂着。收到信号后立刻再stop()一次恢复默认行为,给用户再按一次强杀的逃生通道。WebSocket 等 Hijack 连接不受Shutdown管,得靠RegisterOnShutdown主动断开。一句话记忆点:优雅关停 先停监听、再等在途、超时强退,三步缺一不可。