Xiaojun 的技术笔记

Go HTTP 服务优雅退出:从标准库到 Kubernetes

Go约 4 分钟读完
目录

为什么需要优雅退出

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 流量摘除时序的理解,以及对后台任务的预算管理。