部署时你的用户正在被踢下线
每次 kubectl rollout 或者 systemctl restart,你的服务收到 SIGTERM 信号后会发生什么?如果答案是"进程直接退出",那你的用户正在经历连接中断——正在处理的请求被截断,正在上传的文件丢失,数据库事务回滚,甚至触发客户端的重试风暴。
优雅退出(Graceful Shutdown) 的目标很简单:收到退出信号后,停止接收新请求,等待已有请求处理完毕,然后才退出进程。听起来简单,但生产环境的坑远比你想象的多。
核心机制:信号 + Context + WaitGroup
Go 标准库从 1.8 起就内置了 http.Server.Shutdown() 方法,它会优雅地关闭所有监听的端口,并等待所有活跃连接处理完毕。但光有这个还不够,你需要一套完整的退出流程。
完整的可运行示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
|
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
func main() {
var wg sync.WaitGroup
mux := http.NewServeMux()
mux.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
wg.Add(1)
defer wg.Done()
// 模拟耗时请求
select {
case <-time.After(5 * time.Second):
w.Write([]byte("done"))
case <-r.Context().Done():
log.Println("客户端已断开连接")
return
}
})
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
// 启动 HTTP 服务
go func() {
log.Printf("服务启动,监听 %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务异常: %v", err)
}
}()
// 等待退出信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
log.Printf("收到信号: %v,开始优雅退出...", sig)
// 第一步:立即从负载均衡摘除(健康检查返回 503)
// 实际项目中可以替换为主动调用负载均衡 API 注销
shutdownHealthCheck()
// 第二步:给负载均衡一点时间感知摘除
time.Sleep(2 * time.Second)
// 第三步:停止接收新连接,等待已有请求完成
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("优雅退出超时,强制关闭: %v", err)
}
// 第四步:等待后台 goroutine 完成
wg.Wait()
log.Println("服务已安全退出")
}
var healthOK = true
func shutdownHealthCheck() {
healthOK = false
log.Println("健康检查已切换为不可用状态")
}
|
注意 /health 端点在实际代码中应该返回 healthOk 的值,这里为了简洁省略了判断逻辑。
每一步都在解决一个真实问题
第一步:信号捕获
1
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
关键点:不要捕获所有信号。只处理 SIGINT(Ctrl+C)和 SIGTERM(容器/k8s 发送的标准退出信号)。SIGHUP 在某些场景下用于重载配置而非退出,混在一起会出问题。
第二步:健康检查降级
这是最容易被忽略的一步。收到退出信号后,第一时间把健康检查端点返回 503。这样负载均衡器在下一轮探测时就会把你的实例从轮询池中摘除,新的请求不会再进来。
如果你跳过这步直接 Shutdown(),在 Shutdown 等待期间仍然可能有新请求涌入,导致退出时间变长甚至超时。
第三步:等待摘除传播
1
|
time.Sleep(2 * time.Second)
|
负载均衡器的健康检查有间隔周期(通常 3~5 秒)。你把健康检查切成 503 后,负载均衡器可能还需要一两个周期才能感知到。这个 sleep 就是给它一个反应窗口。具体时间取决于你的负载均衡配置。
第四步:Shutdown + 超时兜底
1
2
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
srv.Shutdown(ctx)
|
Shutdown 会停止接受新连接,并等待所有活跃请求完成。但如果某个请求卡住了怎么办?context.WithTimeout 就是兜底——超过 15 秒还没完成,就强制返回错误,进程退出。
这个超时时间应该大于你最慢请求的处理时间。如果你的 API 最慢 30 秒,这里设 15 秒就会杀掉正在处理的请求。
生产环境踩坑清单
坑 1:忘了处理后台 goroutine
http.Server.Shutdown() 只管 HTTP 连接,不管你启动的后台 goroutine。如果你有后台消费队列的 worker、定时任务、数据库连接池,它们不会自动退出。
解决方案:用 WaitGroup 跟踪所有后台任务,在 Shutdown 完成后 wg.Wait()。
坑 2:k8s 的 terminationGracePeriodSeconds 太短
k8s 发送 SIGTERM 后,默认等待 30 秒就会发 SIGKILL 强杀。如果你在代码里设了 15 秒 Shutdown 超时 + 2 秒摘除等待 + 后台任务清理时间,很容易超过 30 秒。
1
2
3
4
5
|
# k8s deployment 需要调大这个值
spec:
template:
spec:
terminationGracePeriodSeconds: 45
|
坑 3:数据库连接没关
Shutdown 完成后别忘了关闭数据库连接池和其他资源:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
if err := srv.Shutdown(ctx); err != nil {
log.Printf("关闭异常: %v", err)
}
// 关闭数据库连接
if err := db.Close(); err != nil {
log.Printf("关闭数据库连接失败: %v", err)
}
// 关闭 Redis 连接
_ = redisClient.Close()
// 等待后台任务
wg.Wait()
|
坑 4:用了 http.ListenAndServe 而不是 http.Server
1
2
3
4
5
6
7
|
// ❌ 这样无法优雅退出
http.ListenAndServe(":8080", mux)
// ✅ 必须用 http.Server 结构体
srv := &http.Server{Addr: ":8080", Handler: mux}
srv.ListenAndServe()
srv.Shutdown(ctx)
|
http.ListenAndServe 内部创建了一个无法从外部控制的 Server 实例,你拿不到引用就没法调 Shutdown。
用脚本验证你的退出流程
写个简单的验证脚本,部署前先测一遍:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
#!/bin/bash
# 启动服务
go run main.go &
PID=$!
sleep 1
# 发起一个耗时请求(后台运行)
curl -s http://localhost:8080/work &
CURL_PID=$!
# 等待 1 秒让请求进入处理
sleep 1
# 发送 SIGTERM
kill -TERM $PID
# 等待 curl 完成
wait $CURL_PID
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo "✅ 优雅退出验证通过:耗时请求正常完成"
else
echo "❌ 优雅退出验证失败:请求被中断 (exit=$EXIT_CODE)"
fi
|
如果看到 ✅,说明你的 Shutdown 逻辑是对的。如果看到 ❌,说明请求在完成之前就被杀了,需要排查。
总结
优雅退出不是一行 srv.Shutdown() 就能搞定的事。完整流程是:
- 捕获信号 → 只处理 SIGINT 和 SIGTERM
- 健康检查降级 → 立即返回 503,让负载均衡摘除
- 等待摘除传播 → 给负载均衡 2~5 秒反应时间
- Shutdown 等待 → 设合理的超时,等活跃请求完成
- 清理后台资源 → WaitGroup 等待 goroutine,关闭数据库/Redis 连接
- k8s 配置兜底 →
terminationGracePeriodSeconds 要大于上述所有时间之和
把这套流程落地到你的每个服务里,部署时的连接中断问题就彻底解决了。