6.3 KiB
6.3 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
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 行为:
- 发送
SIGTERM给 Pod 主进程 - 开始倒计时
- 倒数为 0 时发送
SIGKILL(强制杀)
你的 Go 服务需要在 SIGTERM 后 30 秒内完成 Shutdown(),否则会被强杀。
最佳实践清单:
| 事项 | 说明 |
|---|---|
设置合理的 WriteTimeout |
防止某个慢请求拖垮整个关机过程 |
| 提前关闭长连接 | WebSocket/SSE 不会自动关 |
设置 context.WithTimeout |
比 K8s deadline 略早一点,留缓冲 |
| 记录关闭日志 | 确认关机是否正常完成 |
关联笔记
- GIN/12-server-config — http.Server 的配置和启动方式
- GIN/15-advanced-running — 多服务运行和特殊场景
- 部署与运维基础 — K8s 滚动发布与优雅停机