为什么需要优雅退出
Go 程序收到 SIGTERM 时,如果没有注册信号处理,runtime 会直接终止进程。后果是:
- 在途请求被切断,客户端收到 connection reset。
- 数据库事务、消息消费进行到一半就中止。
- 日志、trace 等缓冲数据丢失。
滚动发布、节点驱逐和 HPA 缩容在 k8s 里每天都会发生,所以优雅退出是服务的基础能力。
核心流程
收到 SIGTERM
│
├─ 1. 停止接收新请求(关闭监听)
├─ 2. 等待在途请求完成(带超时)
├─ 3. 停止后台任务 / 消费者
└─ 4. 释放资源(DB、Redis、日志、trace)→ 退出
原则:先断入口,再排空在途,最后释放资源。顺序反了,就会出现“DB 已关闭,请求还在用”的问题。
最小可用实现
func main() {
srv := &http.Server{Addr: ":8080", Handler: newRouter()}
// 1. 监听信号
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
// 2. 异步启动服务
srvErr := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
srvErr <- err
}
}()
// 3. 等待信号或启动失败
select {
case err := <-srvErr:
log.Fatalf("server failed: %v", err)
case <-ctx.Done():
}
stop() // 恢复默认行为:收尾卡住时,再按一次 Ctrl+C 可强杀
// 4. 带超时的优雅关闭
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown timeout, force closing: %v", err)
_ = srv.Close()
}
}
几个容易写错的点:
ListenAndServe在Shutdown后会立即返回http.ErrServerClosed,不能把它当错误处理,也不能让main在此时退出,否则Shutdown还没等完进程就结束了。Shutdown的 ctx 必须是新建的。从已取消的信号 ctx 派生,会让它立刻超时。Shutdown不会等待被Hijack的连接(WebSocket、SSE),也不会通知它们。需要用srv.RegisterOnShutdown自行关闭。
在途请求与请求上下文
Shutdown 只是“等 handler 返回”。如果 handler 里有长耗时操作,需要让它感知退出。可以通过 BaseContext 把一个服务级 ctx 注入每个请求:
baseCtx, cancelBase := context.WithCancel(context.Background())
srv := &http.Server{
Addr: ":8080",
Handler: r,
BaseContext: func(net.Listener) context.Context { return baseCtx },
}
// 超时仍未排空时,取消 baseCtx 通知所有 handler 尽快收尾
注意:正常排空阶段不要取消它,否则会把本可以正常完成的请求也打断。只在超时兜底时取消。
长连接:RegisterOnShutdown
被 Hijack 的连接(WebSocket)和 SSE 这类长连接,Shutdown 既不会通知,也不会等待。srv.RegisterOnShutdown(f) 提供了一个钩子:Shutdown 被调用时,会在独立 goroutine 中异步执行所有已注册的回调,并且不等待它们返回。
示例 1:WebSocket 统一关闭
type Hub struct {
mu sync.Mutex
conns map[*websocket.Conn]struct{}
}
func (h *Hub) CloseAll() {
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.conns {
// 1001 Going Away:客户端据此重连到新实例
_ = c.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseGoingAway, "server shutting down"),
time.Now().Add(time.Second))
_ = c.Close()
}
}
func main() {
hub := newHub()
srv := &http.Server{Addr: ":8080", Handler: newRouter(hub)}
srv.RegisterOnShutdown(hub.CloseAll) // 必须在 Shutdown 之前注册
// ... 之后正常 srv.Shutdown(ctx)
}
示例 2:SSE 监听关闭信号
shutdownCh := make(chan struct{})
srv.RegisterOnShutdown(func() { close(shutdownCh) })
r.GET("/events", func(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
for {
select {
case msg := <-events:
fmt.Fprintf(c.Writer, "data: %s\n\n", msg)
c.Writer.Flush()
case <-shutdownCh: // 服务关闭,主动结束
return
case <-c.Request.Context().Done():
return
}
}
})
handler 返回后,Shutdown 对普通请求的等待才能正常结束,不会被长连接拖到超时。
注意事项:
- 回调不被等待。清理耗时的话,需要自行用
WaitGroup或 channel 同步,并在后续收尾步骤里等待。 - 多个回调各占一个 goroutine,无执行顺序,共享状态要加锁。
- 只有
Shutdown会触发回调,srv.Close()不会。 - 它与
BaseContext可以配合:先用RegisterOnShutdown温和通知长连接结束,超时兜底时再取消baseCtx强制打断。
后台任务:按依赖反序停止
服务通常还有 cron、消息消费者、异步任务池。它们的停止函数往往内部是无超时的 wg.Wait(),一个卡住的任务就会拖过 k8s 的宽限期,导致 SIGKILL,后面的清理全部丢失。用一个带预算的等待包装:
// waitOrAbandon 在 ctx 预算内等待 fn 返回,超时则放弃并继续后续清理
func waitOrAbandon(ctx context.Context, name string, fn func()) {
done := make(chan struct{})
go func() { defer close(done); fn() }()
select {
case <-done:
case <-ctx.Done():
log.Printf("%s did not stop within budget, abandoning", name)
}
}
func gracefulShutdown(ctx context.Context, srv *http.Server) {
cronDone := stopCron() // 1. 断定时任务源(非阻塞)
_ = srv.Shutdown(ctx) // 2. 停 HTTP
waitOrAbandon(ctx, "consumer", stopConsumer) // 3. 停消费者
select { // 4. 等在跑的 cron 任务
case <-cronDone.Done():
case <-ctx.Done():
}
// 5. 最后才关 DB / Redis / 日志
}
所有步骤共享同一个超时预算,任一步超时只告警不阻塞,保证后面的清理仍有机会执行。
消费者类任务还要靠消息语义兜底:采用至少一次投递(ack 之后才删除,或可见性超时后重投),这样放弃等待时消息不会丢,只会被重新处理。因此消费逻辑需要幂等。
Kubernetes 的关键细节
1. SIGTERM 与摘流量是并行的
Pod 进入 Terminating 后,kubelet 发送 SIGTERM,同时 EndpointSlice 把 Pod 摘除。但 kube-proxy、Ingress、云 LB 感知摘除有延迟(几秒到十几秒)。如果收到 SIGTERM 就立刻关闭监听,这段时间内到达的请求会得到 connection refused 或 502。
解决方案有两种,任选其一:
方案 A:preStop 延迟
lifecycle:
preStop:
exec:
command: ["sleep", "8"]
方案 B:应用内先摘 readiness,再等待
var draining atomic.Bool
r.GET("/readyz", func(c *gin.Context) {
if draining.Load() {
c.Status(http.StatusServiceUnavailable)
return
}
c.Status(http.StatusOK)
})
// 收到信号后
draining.Store(true)
time.Sleep(8 * time.Second) // 等待上游摘除,期间仍正常处理请求
// 然后再 srv.Shutdown(...)
两种方式的本质相同:让 Pod 在仍能服务的状态下,先等流量切走,再开始关闭。
2. 时间预算必须算清
terminationGracePeriodSeconds > preStop 延迟 + Shutdown 超时 + 日志落盘余量
(默认 30s) 8s ~20s 2~3s
宽限期从 Pod 进入 Terminating 开始计时,preStop 的耗时也算在内。超出后 kubelet 发送 SIGKILL。SIGKILL 无法被捕获、阻塞或忽略,进程会被内核立即终止(退出码 137),defer、日志 flush 等收尾逻辑全部不会执行。长任务多的服务应该调大这个值,而不是在代码里硬撑;同时要保证消息消费幂等、分布式锁带 TTL,即使被强杀也不产生脏数据。
3. 确保信号能传到 Go 进程
# ✅ exec 形式,Go 进程是 PID 1,一定能直接收到信号
ENTRYPOINT ["./app"]
# ⚠️ shell 形式,等价于 ["/bin/sh", "-c", "./app"],sh 是 PID 1,
# 信号能否到达 Go 进程取决于 shell 是否做了 exec 优化,没有保证
ENTRYPOINT ./app
shell 形式的风险在于:部分 shell(如 busybox ash、dash)对 sh -c "单个命令" 会直接 exec 替换自身,信号恰好能收到;但一旦命令变复杂(&&、;、管道),shell 就会常驻并成为 PID 1,不转发 SIGTERM,服务只能等宽限期结束被 SIGKILL,日志里没有任何报错。
若必须经过 shell(例如需要展开环境变量),在命令前加 exec:
CMD ["/bin/sh", "-c", "exec ./app"]
可以用 kubectl exec <pod> -- cat /proc/1/cmdline 确认 PID 1 是不是 Go 进程。
4. 探针配置
- readiness:控制是否接收流量,退出阶段应当失败。
- liveness:不要在退出阶段让它失败,否则 kubelet 会重启一个正在优雅退出的容器。
- 重新部署时配合
maxUnavailable/maxSurge,保证新 Pod Ready 后才终止旧 Pod。
检查清单
| 项目 | 说明 |
|---|---|
| 注册 SIGTERM | signal.NotifyContext |
区分 ErrServerClosed |
不当作错误 |
Shutdown 使用独立超时 ctx |
并设置兜底 Close() |
| 后台任务按依赖反序停止 | 共享预算,超时放弃 |
| 消费者幂等 + 至少一次投递 | 放弃等待时不丢消息 |
| 先摘流量再关监听 | preStop 或 readiness 翻转 |
| 宽限期 > 所有等待之和 | 含 preStop |
| 容器 PID 1 能收到信号 | exec 形式 ENTRYPOINT |
| 长连接单独处理 | RegisterOnShutdown |
验证
不要只相信代码,实际压测一次:
# 持续发压
hey -z 60s -c 50 http://svc/api &
# 同时触发滚动更新
kubectl rollout restart deploy/svc
观察 hey 的错误数。优雅退出做对时,全程应当是 0 个非 2xx 响应。
小结
优雅退出的本质是三件事:不丢请求(摘流量 + 排空)、不卡死(统一超时预算)、不丢数据(顺序正确 + 消息幂等)。标准库的 Shutdown 解决了前半部分,剩下的在于对 k8s 流量摘除时序的理解,以及对后台任务的预算管理。