Go 服务优雅关闭入门:让 HTTP 服务收到信号后稳稳退出

本文讲解 Go HTTP 服务如何处理 SIGINT、SIGTERM、Server.Shutdown、context 超时和后台任务退出,让服务停止过程更可靠。

服务退出也需要设计

很多入门 HTTP 服务最后都是这样:

log.Fatal(http.ListenAndServe(":8080", mux))

它能启动服务,但没有认真处理停止过程。你按 Ctrl+C,或者部署平台发送 SIGTERM,进程可能立刻退出,正在处理的请求被中断,日志没来得及刷,后台任务也不知道该停。小练习问题不大,真实服务就不够稳。

Go 的 http.Server 提供了 Shutdown 方法,可以停止接收新连接,并给正在处理的请求一点时间完成。配合 os/signalcontext.WithTimeout,我们能写出一个清楚的优雅关闭流程。

这篇文章会从最小 HTTP 服务改起,逐步加入信号监听、关闭超时和后台任务退出。

使用 http.Server

先不要直接调用 http.ListenAndServe,而是创建 server:

mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "ok")
})

server := &http.Server{
	Addr:    ":8080",
	Handler: mux,
}

启动:

if err := server.ListenAndServe(); err != nil {
	log.Fatal(err)
}

有了 server 变量,后面就能调用 server.Shutdown(ctx)

监听退出信号

常见退出信号是 os.Interruptsyscall.SIGTERM。前者通常来自 Ctrl+C,后者常见于容器或进程管理器停止服务。

stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

<-stop
log.Println("shutdown signal received")

这段代码会阻塞,直到收到信号。实际服务中,HTTP server 需要同时运行,所以通常放到 goroutine 里启动服务,主 goroutine 等信号。

完整优雅关闭骨架

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthHandler)

	server := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}

	errCh := make(chan error, 1)
	go func() {
		log.Println("listening on :8080")
		errCh <- server.ListenAndServe()
	}()

	stop := make(chan os.Signal, 1)
	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

	select {
	case sig := <-stop:
		log.Printf("received signal: %s", sig)
	case err := <-errCh:
		if err != nil && err != http.ErrServerClosed {
			log.Fatalf("server error: %v", err)
		}
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := server.Shutdown(ctx); err != nil {
		log.Printf("graceful shutdown failed: %v", err)
		if err := server.Close(); err != nil {
			log.Printf("force close failed: %v", err)
		}
	}

	log.Println("server stopped")
}

Shutdown 会让 server 停止接收新请求,并等待已有请求结束,直到 context 超时。如果超时还没结束,可以调用 Close 强制关闭。

ListenAndServe 在正常关闭后会返回 http.ErrServerClosed,这不是错误,不应该当成 fatal。

给请求处理留出退出路径

优雅关闭不只是 server 的事,handler 也要尊重请求 context。

func slowHandler(w http.ResponseWriter, r *http.Request) {
	select {
	case <-time.After(3 * time.Second):
		fmt.Fprintln(w, "done")
	case <-r.Context().Done():
		log.Printf("request cancelled: %v", r.Context().Err())
		return
	}
}

如果客户端断开,或者 server 正在关闭,请求 context 会被取消。Handler 里做外部调用、数据库查询、长任务时,也应该把 r.Context() 传下去。

不要在 handler 深处随便使用 context.Background(),否则请求取消信号就断了。正确做法是:

user, err := service.GetUser(r.Context(), id)

这样关闭时,内部调用也有机会停止。

后台任务也要停

如果服务里有定时任务,可以共享一个根 context:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go runCleaner(ctx)

任务:

func runCleaner(ctx context.Context) {
	ticker := time.NewTicker(1 * time.Minute)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			log.Println("clean expired data")
		case <-ctx.Done():
			log.Println("cleaner stopped")
			return
		}
	}
}

收到信号后先 cancel(),再关闭 server:

cancel()

shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
server.Shutdown(shutdownCtx)

后台 goroutine 必须有退出路径。否则服务看似停止了 HTTP,但进程可能还挂着,或者任务在关闭过程中继续写数据。

小结

Go HTTP 服务的优雅关闭核心流程是:使用 http.Server,单独 goroutine 启动服务,主 goroutine 监听 SIGINTSIGTERM,收到信号后创建带超时的 context,调用 server.Shutdown(ctx),必要时再 Close 强制关闭。

同时,handler 和后台任务要尊重 context。请求内部调用传 r.Context(),定时任务监听 ctx.Done()。这样服务停止时,不会粗暴中断所有事情,也不会无限等待。

入门项目也可以从一开始写好关闭逻辑。它不复杂,却能让程序更像真实服务,也能帮助你理解 Go 里 context、signal 和 HTTP server 如何协作。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页