HTTP 服务发布或重启时,如果直接杀进程,正在处理的请求可能被中断,用户看到连接错误,后台写入也可能只完成一半。Go 的 http.Server.Shutdown 可以帮助服务优雅关闭:停止接收新连接,等待已有请求完成,直到超时。
本文用一个标准 HTTP 服务讲关闭流程、信号处理和常见边界。
基本结构
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthz)
mux.HandleFunc("/api/items", items)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
errCh <- server.ListenAndServe()
}()
select {
case err := <-errCh:
if err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown failed: %v", err)
}
}
}
注意 Shutdown 使用的是新的 context.Background() 派生出的 context。原来的 ctx 已经被信号取消了,如果直接传进去,Shutdown 会立刻返回。
Shutdown 做了什么
Shutdown 会关闭监听器,停止接收新连接,然后等待已有连接处理完成。它不会强行杀正在执行的 handler,除非 shutdown context 超时。handler 自己也应该尊重 r.Context(),在客户端断开或服务关闭时尽快停止。
func items(w http.ResponseWriter, r *http.Request) {
items, err := store.List(r.Context())
if err != nil {
http.Error(w, "query failed", http.StatusInternalServerError)
return
}
writeJSON(w, items)
}
如果 handler 内部调用数据库、外部 HTTP、队列,都应该传递 r.Context()。
健康检查和滚动发布
在容器或负载均衡环境里,优雅关闭通常还需要“先从流量里摘除,再 shutdown”。比如收到 SIGTERM 后,健康检查先返回失败,让负载均衡不再转发新请求,然后等待一小段时间,再关闭 server。
可以维护一个原子状态:
var shuttingDown atomic.Bool
func healthz(w http.ResponseWriter, r *http.Request) {
if shuttingDown.Load() {
http.Error(w, "shutting down", http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "ok")
}
收到信号后:
shuttingDown.Store(true)
time.Sleep(3 * time.Second)
server.Shutdown(shutdownCtx)
这段等待要和部署平台配合。不是所有环境都需要,但滚动发布时很常见。
后台 goroutine 也要停
HTTP server 关闭不等于所有后台任务都自动停。你自己启动的 worker、ticker、消费者都要监听 context:
func runCleaner(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
cleanOnce(ctx)
case <-ctx.Done():
return
}
}
}
服务关闭流程应该统一管理这些 goroutine。否则 HTTP 端口已经关了,进程却因为后台 goroutine 卡住不退出。
测试 handler 是否尊重 context
func TestHandlerContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/api/items", nil).WithContext(ctx)
rec := httptest.NewRecorder()
items(rec, req)
// 根据你的 handler 语义断言
}
更常见的是在 store 层测试:传入已取消 context,确认查询或循环能尽快返回。优雅关闭不是只测 main 函数,而是整个调用链都要传播取消。
RegisterOnShutdown
http.Server 还提供了 RegisterOnShutdown,可以注册一些关闭回调。它适合通知旁路组件,比如告诉 WebSocket hub 不再接收新连接、给指标系统打一个状态标记。但不要把耗时清理都塞进去,因为真正控制等待时间的仍然是外层 Shutdown(ctx) 的 context。
srv := &http.Server{Addr: ":8080", Handler: mux}
srv.RegisterOnShutdown(func() {
log.Println("server is shutting down, stop accepting live sessions")
})
如果清理逻辑需要返回错误,建议放在主流程里显式调用。回调没有返回值,失败只能记录日志,调用方不容易做决策。
Close 和 Shutdown 的区别
Close 会立即关闭监听器和活动连接,更像“立刻停”。Shutdown 会先关闭监听器,不再接收新连接,然后等待已有连接处理完。线上服务默认应该优先使用 Shutdown。
if fastExit {
_ = srv.Close()
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
Close 不是没用。测试里为了快速释放端口、进程收到第二次退出信号、或者服务已经处于不可恢复状态时,它可以作为最后手段。关键是不要把两者混着用,也不要在优雅关闭流程刚开始就调用 Close。
长连接和流式接口
如果服务里有 SSE、长轮询、流式下载,优雅关闭要额外设计。因为这些请求可能持续几十秒甚至几分钟,Shutdown 会一直等到超时。更好的做法是收到关闭信号后通知这些 handler 主动结束。
func stream(w http.ResponseWriter, r *http.Request, closing <-chan struct{}) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case <-closing:
return
case t := <-ticker.C:
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
}
}
这样发布时服务不会被少数长连接拖住。用户看到的是连接自然结束,而不是部署系统强杀进程。
小结
Go HTTP 服务优雅关闭的基本流程是:监听信号,停止健康检查,停止接收新请求,使用 Server.Shutdown 等待旧请求完成,并给关闭过程设置超时。handler 内部要传递 r.Context(),后台 goroutine 也要监听取消。
优雅关闭不是一行 Shutdown 就结束,它和部署平台、健康检查、后台任务、数据库调用一起工作。把这些边界连起来,滚动发布才会更平滑。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。