Files
cs-note/hzh/GIN/13-graceful-shutdown.md
T
2026-05-24 11:42:38 +08:00

6.3 KiB
Raw Blame History

tags, create time
tags create time
后端
Go
Gin
优雅停止
部署
2026-04-28 00:00

优雅停止与重启

概述

生产环境的 Go 服务不能简单地 kill -9——正在处理的请求可能会丢失数据、数据库事务可能中断。Gin 基于 http.Server.Shutdown() 实现了优雅的停机流程:停止接受新请求,等待已有请求处理完毕后再退出。

思考题:如果一个长时间运行的 WebSocket 连接一直不关闭,server.Shutdown() 会因为等它而永远卡住吗?怎么解决?

正文

1. 信号监听 + Shutdown()

这是最常见的优雅停止模式:

func main() {
    r := gin.Default()

    r.GET("/health", healthCheck)
    r.GET("/api/users", listUsers)

    srv := &http.Server{
        Addr:    ":8080",
        Handler: r,
    }

    // 在后台 goroutine 中启动服务
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen failed: %v", err)
        }
    }()

    // 监听终止信号
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit // 阻塞直到收到信号

    log.Println("shutting down...")

    // 创建一个带超时的 context,给请求处理留出时间
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    // 停止接收新连接,等待活跃连接完成
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("server forced to shutdown: %v", err)
    }

    log.Println("server exited properly")
}

为什么必须用 goroutine? ListenAndServe() 是永久阻塞调用——它会一直 accept 连接,永不返回。 如果直接放在 main() 中执行,后面的信号监听和 Shutdown 逻辑永远走不到:

main()
  ├─ srv.ListenAndServe()  ← 阻塞!永不返回
  ├─ <-quit                ← 永远不会执行 ❌
  └─ srv.Shutdown()        ← 永远不会执行 ❌

放进 goroutine 后,"服务运行"和"信号监听"两个任务并行:

main() 主协程
  ├─ [goroutine] srv.ListenAndServe()  ← 后台阻塞,不挡路
  ├─ <-quit                            ← ✅ 正常阻塞等待信号
  └─ srv.Shutdown()                    ← ✅ 收到信号后执行

流程图:

flowchart TD
    A["服务正常运行"] -->|"收到 SIGINT/SIGTERM"| B["停止 Accept 新连接"]
    B --> C["已有请求继续处理"]
    C --> D{"所有请求完成?"}
    D -->|"是"| E["srv.Shutdown 返回 ✅"]
    D -->|"否, 超时"| F["强制退出 ⚠️"]

    style E fill:#e8f5e9,stroke:#2e7d32
    style F fill:#fff3e9,stroke:#e65100

2. Shutdown 内部发生了什么

signal.Notify 捕获 SIGTERM
  ↓
srv.Shutdown(ctx) 调用
  ↓
1. StopListener.Accept() — 不再接受新连接
  ↓
2. 遍历所有活跃连接
  ├─ 没有活跃请求 → 立即关闭
  └─ 有活跃请求 → 等待 context 超时
     ├─ 请求在超时内完成 → 正常关闭连接
     └─ 超时未完成 → 强制关闭连接

关键细节:

  • Shutdown() 是同步阻塞调用——必须放在 goroutine 中,否则会卡死主流程
  • 超时期间不会断开已有连接的 TCP socket——只是不在上面调度新请求
  • 已经分配给请求的 Context(c.Request.Context())不会被取消

3. 长连接的处理问题

像 WebSocket、SSE(Server-Sent Events)这类长连接不会因为 Shutdown() 而自动关闭:

func cleanupLongConnections(srv *http.Server) {
    // Server 没有内置清理长连接的方法
    // 需要自己在中间件中跟踪活跃连接,Shutdown 时主动关闭
}

解决方案:

type ConnectionManager struct {
    conns map[*websocket.Conn]bool
    mu    sync.RWMutex
}

func (m *ConnectionManager) CloseAll() {
    m.mu.Lock()
    defer m.mu.Unlock()
    for conn := range m.conns {
        conn.Close()  // 主动关闭每个 WebSocket
    }
    m.conns = make(map[*websocket.Conn]bool)
}

在 Shutdown 流程中调用:

// 1. 先关闭所有长连接
wsManager.CloseAll()

// 2. 再等 HTTP 请求处理完
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
srv.Shutdown(ctx)

4. 优雅重启(fork + exec)

某些场景下不只是"停",还需要"换"——比如更新了二进制文件。经典的 Unix 优雅重启模式:

旧进程接收 SIGHUP
  ↓
1. 启动新进程
  ↓
2. 旧进程等待新进程就绪
  ↓
3. 旧进程 Shutdown(等现有请求完成)
  ↓
4. 新进程接管端口
  ↓
5. 旧进程退出,新进程继续服务(无感知的版本切换)
func gracefulRestart() error {
    args := os.Args[1:]
    args = append([]string{"-fork"}, args...)

    cmd := exec.Command(os.Args[0], args...)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    if err := cmd.Start(); err != nil {
        return err
    }

    // 等待新进程就绪(简单做法:轮询 health endpoint)
    time.Sleep(2 * time.Second)

    // 旧进程开始优雅关停
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    return server.Shutdown(ctx)
}

实际建议: 大多数现代部署(Kubernetes Docker)使用滚动发布(Rolling Update),不需要自己实现 fork-restart。SIGTERM 信号已由 K8s 自动处理。

5. Kubernetes 中的优雅停止

K8s 的优雅停止默认给予 30 秒:

spec:
  template:
    spec:
      terminationGracePeriodSeconds: 30  # 秒,默认 30

K8s 行为:

  1. 发送 SIGTERM 给 Pod 主进程
  2. 开始倒计时
  3. 倒数为 0 时发送 SIGKILL(强制杀)

你的 Go 服务需要在 SIGTERM 后 30 秒内完成 Shutdown(),否则会被强杀。

最佳实践清单:

事项 说明
设置合理的 WriteTimeout 防止某个慢请求拖垮整个关机过程
提前关闭长连接 WebSocket/SSE 不会自动关
设置 context.WithTimeout 比 K8s deadline 略早一点,留缓冲
记录关闭日志 确认关机是否正常完成

关联笔记