Go HTTP 服务器的优雅关闭

1. 概述

当部署新版本或按比例缩减时,您的进程会收到 SIGTERM。如果服务器立即退出,正在处理的请求会被中断。客户端会收到 connection reset 错误,订单可能只被写入一半。

本文将指导您如何正确关闭 Go HTTP 服务器。服务器会停止接收新连接,在有限的时间内等待正在运行的请求完成,然后按正确的顺序关闭资源。所有示例仅使用标准库,需要 Go 1.22 或更高版本。

2. 突然关闭服务器的问题

让我们从最熟悉的写法开始:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {
	http.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second) // 模拟缓慢的数据库查询
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

运行服务器,然后调用 curl localhost:8080/orders/42。在请求等待时,在服务器终端按 Ctrl+C。客户端的结果:

curl: (52) Empty reply from server

Go 不会自动为 SIGINT 或 SIGTERM 安装处理程序。收到信号时,runtime 立即终止进程。任何 defer 都不会被执行,任何请求都不会被等待。

3. 使用 http.Server.Shutdown 实现优雅关闭

http.Server 有一个 Shutdown(ctx) 方法。此方法按顺序执行三件事:

  1. 关闭所有 listener,使服务器不再接收新连接。
  2. 关闭处于 idle 状态的连接。
  3. 等待处于 active 状态的连接变为 idle,然后关闭它们。如果 ctx 先过期,方法返回 context 的错误。

要使用此方法,您需要自己捕获操作系统信号。

3.1. 使用 signal.NotifyContext 捕获信号

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second)
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	serverErr := make(chan error, 1)
	go func() {
		log.Println("listening on", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			serverErr <- err
		}
	}()

	select {
	case err := <-serverErr:
		log.Fatalf("server error: %v", err)
	case <-ctx.Done():
		log.Println("shutdown signal received")
	}
	stop() // 第二次按 Ctrl+C 将立即杀死进程

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("graceful shutdown failed: %v, forcing close", err)
		_ = srv.Close()
	}
	log.Println("server stopped")
}

代码中的关键点:

  • signal.NotifyContext 返回一个 context。当进程收到 SIGINT (Ctrl+C) 或 SIGTERM (Docker 和 Kubernetes 发送的信号)时,此 context 会被取消。
  • ListenAndServe 运行在单独的 goroutine 中。调用 Shutdown 后,它会立即返回 http.ErrServerClosed。这是正常错误,所以我们将其过滤掉。
  • channel serverErr 有缓冲区 1。它有助于捕获启动错误,例如端口已被占用。这样 main 不会被卡住等待永远不会到达的信号。
  • stop() 在收到信号后立即被调用。此函数恢复默认行为,因此如果再次按 Ctrl+C,进程会立即退出。当关闭卡住时,这种方式非常方便。
  • shutdownCtx 使用 context.Background() 作为基础,而不是使用 ctx。原因是 ctx 已被取消,如果使用它,Shutdown 会立即返回错误。
  • 如果 10 秒后仍有请求,srv.Close() 会强制关闭所有连接。

重复第 2 部分的测试:调用 curl,然后在请求等待时按 Ctrl+C。服务器日志:

2026/10/24 10:00:00 listening on :8080
^C2026/10/24 10:00:01 shutdown signal received
2026/10/24 10:00:03 server stopped

客户端收到完整的响应:

order 42

服务器等待请求的剩余 2 秒,然后才退出。

注意: 一个常见错误是交换两个 goroutine 的角色。有些人在 main 中调用 ListenAndServe,在另一个 goroutine 中调用 Shutdown。因为 ListenAndServe 在 Shutdown 开始时立即返回,main 会在 Shutdown 仍在等待请求时结束。进程退出,请求仍被中断。规则是 main 必须等待 Shutdown 返回。

4. Shutdown 不自动处理的事项

4.1. 被劫持的连接:WebSocket、HTTP/1.1 升级

Shutdown 不跟踪已被劫持的连接,例如 WebSocket。服务器也不会等待它们。要关闭这些连接,您需要使用 RegisterOnShutdown 注册回调:

type Hub struct {
	mu    sync.Mutex
	conns map[*websocketConn]struct{}
}

func (h *Hub) CloseAll() {
	h.mu.Lock()
	defer h.mu.Unlock()
	for c := range h.conns {
		c.SendCloseFrame("server shutting down")
		c.Close()
	}
}

// 在 main 中:
hub := &Hub{conns: make(map[*websocketConn]struct{})}
srv.RegisterOnShutdown(hub.CloseAll)

websocketConn 在这里代表您使用的 WebSocket 库的连接类型。RegisterOnShutdown 在 Shutdown 开始时在单独的 goroutine 中运行。Shutdown 不会等待此回调完成,因此如果您需要等待,请使用 sync.WaitGroup 自己同步。

4.2. 运行时间长的 handler:streaming、long polling

流式传输无限数据的 handler,例如 Server-Sent Events,永远不会变为 idle。在这种情况下,Shutdown 总是等待到超时。您可以使用 BaseContext 在服务器开始关闭时取消每个请求的 context:

appCtx, cancelApp := context.WithCancel(context.Background())

srv := &http.Server{
	Addr:    ":8080",
	Handler: mux,
	BaseContext: func(net.Listener) context.Context {
		return appCtx
	},
}
srv.RegisterOnShutdown(cancelApp)

mux.HandleFunc("GET /orders/stream", func(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-r.Context().Done():
			return // 服务器正在关闭或客户端断开连接
		case t := <-ticker.C:
			fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
			w.(http.Flusher).Flush()
		}
	}
})

每个请求 context 都是从 appCtx 创建的。当 Shutdown 运行时,cancelApp 被调用,所有 r.Context() 都被取消。流式 handler 看到 Done() 并返回。然后 Shutdown 尽早完成。

注意: 不应该对所有 handler 应用这种方法。如果您取消正在将订单写入数据库的请求的 context,该写入命令本身也会被取消。无法达到“等待请求完成”的目标。只有当所有 handler 都能安全地在中间停止时,才以这种方式使用 BaseContext。

4.3. 后台 goroutine 和辅助资源

Shutdown 只管理 HTTP 连接。发送电子邮件的 worker、Kafka 消费者和数据库连接池是您的责任。关闭顺序应与初始化顺序相反:

if err := srv.Shutdown(shutdownCtx); err != nil {
	log.Printf("http shutdown: %v", err)
}

cancelWorkers() // 告诉后台 goroutine 停止
workersDone := make(chan struct{})
go func() { wg.Wait(); close(workersDone) }()

select {
case <-workersDone:
case <-shutdownCtx.Done():
	log.Println("workers did not stop in time")
}

if err := db.Close(); err != nil {
	log.Printf("db close: %v", err)
}

HTTP 服务器首先关闭,因为 handler 仍需要数据库。数据库最后关闭。整个过程共享 shutdownCtx,因此总时间不会超过 10 秒的预算。

5. 在 Kubernetes 上运行

当 pod 被删除时,Kubernetes 会做两件事并行:向容器发送 SIGTERM 并从 Service 的 endpoint 列表中删除 pod。删除 endpoint 需要几秒钟才能传播到 kube-proxy 和 ingress。如果服务器在收到 SIGTERM 时立即关闭 listener,仍在被路由到 pod 的请求将被拒绝。

解决方法是让 readiness probe 先报告失败,等待片刻,然后再调用 Shutdown:

var shuttingDown atomic.Bool

mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
	if shuttingDown.Load() {
		http.Error(w, "shutting down", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

// 收到信号后:
<-ctx.Done()
shuttingDown.Store(true)
time.Sleep(5 * time.Second) // 等待负载均衡器删除 pod
srv.Shutdown(shutdownCtx)

您还需要配置 terminationGracePeriodSeconds 大于等待时间加上 shutdown 超时的总和。对于此示例,是 5 + 10 = 15 秒:

spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: order-service
      image: order-service:1.4.0
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8080
        periodSeconds: 2

宽限期结束后,Kubernetes 发送 SIGKILL。此信号无法捕获,您的所有 shutdown 逻辑都会被忽略。

注意: 如果您通过 shell 脚本运行二进制文件,例如 CMD ./start.sh,则 shell 是 PID 1,可能不会将 SIGTERM 转发给 Go 进程。使用 exec 形式 CMD ["/app/server"] 或在脚本末尾调用 exec /app/server。

6. 测试 shutdown 行为

您可以编写测试来确认 Shutdown 等待正在运行的请求完成。此测试只需要 testing 包:

package main

import (
	"context"
	"io"
	"net"
	"net/http"
	"testing"
	"time"
)

func TestShutdownWaitsForInFlightRequest(t *testing.T) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}

	started := make(chan struct{})
	srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		close(started)
		time.Sleep(500 * time.Millisecond)
		io.WriteString(w, "order 42")
	})}
	go srv.Serve(ln)

	type result struct {
		body string
		err  error
	}
	resCh := make(chan result, 1)
	go func() {
		resp, err := http.Get("http://" + ln.Addr().String() + "/orders/42")
		if err != nil {
			resCh <- result{err: err}
			return
		}
		defer resp.Body.Close()
		b, err := io.ReadAll(resp.Body)
		resCh <- result{body: string(b), err: err}
	}()

	<-started // 确保请求已进入 handler
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		t.Fatalf("Shutdown returned error: %v", err)
	}

	res := <-resCh
	if res.err != nil {
		t.Fatalf("request failed: %v", res.err)
	}
	if res.body != "order 42" {
		t.Fatalf("got body %q, want %q", res.body, "order 42")
	}

	if _, err := http.Get("http://" + ln.Addr().String()); err == nil {
		t.Fatal("expected new connections to be refused after shutdown")
	}
}

测试中的关键点:

  • net.Listen 在端口 0 上,让操作系统选择一个空闲端口。这样多个测试可以并行运行而不会冲突。
  • channel started 确保 Shutdown 仅在请求实际进入 handler 时才被调用。
  • 测试检查三件事:Shutdown 返回 nil,客户端收到完整的 body,以及新连接被拒绝。
go test -v -run TestShutdown
=== RUN   TestShutdownWaitsForInFlightRequest
--- PASS: TestShutdownWaitsForInFlightRequest (0.50s)
PASS

运行时间约为 0.5 秒,正好等于 handler 的睡眠时间。这表明 Shutdown 已经等待了请求。

7. 常见错误

  • 对 Shutdown 使用已取消的 context。 直接传递 signal.NotifyContext 的 ctx 会导致 Shutdown 立即返回 context canceled。
  • 将 http.ErrServerClosed 视为错误。 使用此错误调用 log.Fatal 会导致进程在 shutdown 完成之前退出。
  • 不设置超时。 使用没有 deadline 的 context.Background() 意味着被卡住的 handler 会永远保持进程,直到 Kubernetes 发送 SIGKILL。
  • 忘记 ReadHeaderTimeout。 缓慢的客户端可能会将连接保持在活跃状态,导致 shutdown 总是运行到超时。
  • 在开始 shutdown 后使用 os.Exit 或 log.Fatal。 两者都会跳过所有 defer,导致数据库和文件无法关闭。

8. 结论

Go 中的优雅关闭需要三个部分:用于捕获 SIGTERM 的 signal.NotifyContext,带有超时 context 的 srv.Shutdown,以及 main 必须等待 Shutdown 返回。在此基础上,您可以添加 Shutdown 不自动处理的部分。这些包括:WebSocket 的 RegisterOnShutdown、streaming handler 的 BaseContext,以及按照初始化顺序相反的顺序关闭 worker 和数据库。在 Kubernetes 上运行时,让 readiness 报告失败并在关闭 listener 之前等待几秒钟,同时设置足够大的 terminationGracePeriodSeconds。最后,保留第 6 部分的测试,以便在有人无意中破坏 shutdown 流程时及早发现。

golanghttpgraceful-shutdownkubernetesconcurrency