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) 方法。此方法按顺序执行三件事:
- 关闭所有 listener,使服务器不再接收新连接。
- 关闭处于 idle 状态的连接。
- 等待处于 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 流程时及早发现。