Go 优雅停机实战——K8s 场景下的请求排空和资源释放完整方案
Go 优雅停机实战——K8s 场景下的请求排空和资源释放完整方案
适读人群:在 K8s 上部署 Go 服务的工程师 | 阅读时长:约16分钟 | 核心价值:彻底搞清楚 Go 服务在 K8s 滚动更新时为什么会出现请求中断,以及完整解决方案
从一次上线事故说起
去年我们做了一次例行版本更新,Go 服务部署在 K8s 上,滚动更新,一切看起来很正常。但用户反馈说,每次发布之后的2-3分钟内,有概率收到一些莫名其妙的错误——HTTP 500,或者直接连接被重置。
我开始查监控,发现错误率确实有一个尖刺,峰值出现在 Pod 切换的时间点,大概持续了 137 秒,出错请求大约 6300 个。
对于我们这种 B2B SaaS,客户都是企业,这种在发布窗口期出现的错误是绝对不能接受的。
问题的根本原因我排查了大半天,最后发现不是一个问题,而是三个独立的问题叠加在一起:
- 应用层没有正确处理 SIGTERM
- K8s 的 preStop 钩子没配
- 数据库连接在 shutdown 时没有等待进行中的事务完成
这篇文章把这三个问题以及完整的解决方案一次讲清楚。
先搞清楚 K8s 杀 Pod 的完整流程
很多人以为 K8s 删除 Pod 就是直接发个 SIGTERM,其实不是。完整的流程是这样的:
- K8s 把 Pod 状态标记为 Terminating
- K8s 从所有 Service 的 Endpoints 中移除这个 Pod(这一步是异步的)
- 如果配置了 preStop 钩子,先执行 preStop
- 然后发送 SIGTERM 给容器
- 等待 terminationGracePeriodSeconds(默认30秒)
- 如果容器还没退出,发送 SIGKILL 强杀
这里有一个关键的竞态条件:步骤2(从 Endpoints 移除)和步骤3/4(发信号给容器)是并发的,而 Endpoints 变更通知传播到 kube-proxy 和 ingress controller 有延迟(通常1-15秒,取决于集群规模和网络)。
这就意味着:即使你的服务收到 SIGTERM 之后立刻停止监听新请求,在那 1-15 秒的传播延迟里,负载均衡仍然可能把新请求路由到这个正在退出的 Pod 上,然后连接被拒绝,用户报错。
这是 K8s 优雅停机问题的根本所在,很多文章没说清楚这一点。
踩坑实录
坑一:以为捕获了 SIGTERM 就完事了
现象: 我在代码里写了信号处理,接到 SIGTERM 就调用 srv.Shutdown(ctx),看起来很完整,但线上还是有请求中断。
原因: 忽略了 Endpoints 传播延迟。srv.Shutdown() 会停止接受新连接,但在负载均衡还没把 Pod 从路由表删掉的那段时间里,新连接仍然被路由过来,然后被拒绝。
解法: 在 preStop 钩子里加一个 sleep,给 Endpoints 传播留出时间。
坑二:preStop sleep 和 SIGTERM 的时序关系搞反了
现象: 加了 preStop sleep 5秒,但发现有时候服务已经在处理 Shutdown 了,5秒还没过去。
原因: 我搞错了执行顺序。根据 K8s 文档,preStop 执行完之后,才会发送 SIGTERM。但 terminationGracePeriodSeconds 是从 Pod 开始终止就开始计时的,不是从 SIGTERM 开始计时。
所以如果 terminationGracePeriodSeconds=30,preStop sleep 5秒,那应用处理优雅停机实际上只有25秒。
解法: 把 terminationGracePeriodSeconds 设得比 preStop sleep + 应用 shutdown 时间之和更大,留出余量。我的配置是:terminationGracePeriodSeconds=60,preStop sleep 10秒,应用 shutdown timeout 45秒。
坑三:数据库连接关闭时没等待进行中的请求
现象: shutdown 之后,偶尔在日志里看到 "sql: database is closed" 的错误,说明有 goroutine 在 db 关闭之后还在使用数据库连接。
原因: 我在 srv.Shutdown() 返回之后直接调用了 db.Close()。但 srv.Shutdown() 只是等待所有 HTTP 连接结束,不负责等待这些 HTTP handler 里异步启动的 goroutine。如果某个 handler 里有 go doSomethingWithDB(db) 这种调用,srv.Shutdown() 不会等它。
解法: 用 sync.WaitGroup 追踪所有 background goroutine,shutdown 时等待它们完成。
完整实现方案
K8s Deployment 配置
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
template:
spec:
terminationGracePeriodSeconds: 60 # 给够时间
containers:
- name: api-server
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"] # 等 Endpoints 传播
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3Go 应用优雅停机完整实现
package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
// App 封装应用的所有资源和生命周期
type App struct {
httpServer *http.Server
db *sql.DB
wg sync.WaitGroup // 追踪所有后台 goroutine
logger *slog.Logger
}
// Run 启动应用并阻塞直到收到退出信号
func (a *App) Run() error {
// 启动 HTTP 服务器
errCh := make(chan error, 1)
go func() {
a.logger.Info("server starting", "addr", a.httpServer.Addr)
if err := a.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- fmt.Errorf("server error: %w", err)
}
}()
// 等待信号或服务器错误
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
return err
case sig := <-quit:
a.logger.Info("received signal, starting graceful shutdown", "signal", sig)
}
return a.shutdown()
}
// shutdown 按正确顺序释放所有资源
func (a *App) shutdown() error {
// 步骤1:停止接受新的 HTTP 请求,等待进行中的请求完成
// 这里的 timeout 要考虑:最长的业务请求大概需要多久
// 我们的 SLA 要求 API 超时是 30s,所以给 35s
httpCtx, httpCancel := context.WithTimeout(context.Background(), 35*time.Second)
defer httpCancel()
a.logger.Info("shutting down HTTP server...")
if err := a.httpServer.Shutdown(httpCtx); err != nil {
a.logger.Error("HTTP server shutdown error", "err", err)
// 不 return,继续清理其他资源
}
a.logger.Info("HTTP server stopped")
// 步骤2:等待所有后台 goroutine 完成
// 用带超时的等待,避免死等
bgDone := make(chan struct{})
go func() {
a.wg.Wait()
close(bgDone)
}()
bgCtx, bgCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer bgCancel()
select {
case <-bgDone:
a.logger.Info("all background goroutines finished")
case <-bgCtx.Done():
a.logger.Warn("timeout waiting for background goroutines, proceeding anyway")
}
// 步骤3:关闭数据库连接(必须在 HTTP 请求全部完成后)
a.logger.Info("closing database connections...")
if err := a.db.Close(); err != nil {
a.logger.Error("database close error", "err", err)
}
a.logger.Info("database connections closed")
a.logger.Info("graceful shutdown complete")
return nil
}
// GoBackground 启动一个受追踪的后台 goroutine
// 在 handler 里需要启动后台任务时,应该用这个方法而不是直接 go func
func (a *App) GoBackground(f func()) {
a.wg.Add(1)
go func() {
defer a.wg.Done()
f()
}()
}
// 示例 handler,展示如何正确处理后台任务
func (a *App) handleCreateOrder(w http.ResponseWriter, r *http.Request) {
// ... 处理订单创建逻辑 ...
// 如果需要发送异步通知,用 GoBackground 而不是裸 go
a.GoBackground(func() {
// 这个 goroutine 会被 shutdown 等待
if err := a.sendOrderNotification(r.Context()); err != nil {
a.logger.Error("send notification failed", "err", err)
}
})
w.WriteHeader(http.StatusCreated)
}
func (a *App) sendOrderNotification(ctx context.Context) error {
// 实际的通知发送逻辑
return nil
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
logger.Error("failed to open database", "err", err)
os.Exit(1)
}
app := &App{
db: db,
logger: logger,
}
mux := http.NewServeMux()
mux.HandleFunc("/api/orders", app.handleCreateOrder)
mux.HandleFunc("/healthz/ready", func(w http.ResponseWriter, r *http.Request) {
// readiness 探针:检查真实的服务能力
if err := db.PingContext(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/healthz/live", func(w http.ResponseWriter, r *http.Request) {
// liveness 探针:只检查进程是否存活
w.WriteHeader(http.StatusOK)
})
app.httpServer = &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 35 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := app.Run(); err != nil {
logger.Error("application error", "err", err)
os.Exit(1)
}
}一个容易被忽略的细节:ReadinessProbe 的作用
优雅停机的另一个关键是 ReadinessProbe。当你的服务正在关闭时,readiness 探针会失败,K8s 会把 Pod 从 Endpoints 中移除,从而停止向它路由流量。
但是有个顺序问题:ReadinessProbe 失败到真正从 Endpoints 移除,需要 failurethreshold * periodSeconds 的时间(例如 3次 * 5秒 = 15秒)。
所以在 Shutdown() 开始的时候,你可以主动让 readiness 探针立刻失败,而不是等 K8s 主动检测到。这样可以加快 Endpoints 更新的速度:
type App struct {
// ...
ready atomic.Bool // 控制 readiness 状态
}
func (a *App) shutdown() error {
// 第一步:立刻标记为 not ready,让探针失败
a.ready.Store(false)
// 给 K8s 时间检测到 readiness 失败并更新 Endpoints
// 这个等待时间 = failureThreshold * periodSeconds = 3 * 5 = 15s
// 但我们有 preStop sleep 10s,所以这里再等5s就够了
time.Sleep(5 * time.Second)
// 然后才开始 HTTP Shutdown
// ...
}
func (a *App) readinessHandler(w http.ResponseWriter, r *http.Request) {
if !a.ready.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
// 真实健康检查
if err := a.db.PingContext(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}Go vs Java 在这个问题上的差异
Spring Boot 有 server.shutdown=graceful 配置,加上之后 Spring 会自动处理请求排空。Actuator 的 /health 端点在 shutdown 期间也会自动返回 OUT_OF_SERVICE。
这些"自动"的东西在开发体验上确实好,但它们掩盖了很多细节。我在用 Spring Boot 的时候,从来没想过 Endpoints 传播延迟这个问题,因为框架处理了(其实也没有完全处理,只是大多数情况下凑合)。
用 Go 之后,因为什么都要自己写,反而把每个细节都搞清楚了。现在我们 Go 服务的滚动更新,出错率基本上是 0——偶尔在极端情况下(某个请求恰好在 shutdown 窗口内进来)有个位数的重试。
代价是:你要真正理解你在做什么,不能靠框架兜底。
