229 lines
6.3 KiB
Markdown
229 lines
6.3 KiB
Markdown
|
|
---
|
|||
|
|
tags: [后端, Go, Gin, 优雅停止, 部署]
|
|||
|
|
create time: 2026-04-28 00:00
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 优雅停止与重启
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
生产环境的 Go 服务不能简单地 `kill -9`——正在处理的请求可能会丢失数据、数据库事务可能中断。Gin 基于 `http.Server.Shutdown()` 实现了优雅的停机流程:停止接受新请求,等待已有请求处理完毕后再退出。
|
|||
|
|
|
|||
|
|
思考题:如果一个长时间运行的 WebSocket 连接一直不关闭,`server.Shutdown()` 会因为等它而永远卡住吗?怎么解决?
|
|||
|
|
|
|||
|
|
## 正文
|
|||
|
|
|
|||
|
|
### 1. 信号监听 + `Shutdown()`
|
|||
|
|
|
|||
|
|
这是最常见的优雅停止模式:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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() ← ✅ 收到信号后执行
|
|||
|
|
> ```
|
|||
|
|
|
|||
|
|
**流程图:**
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
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()` 而自动关闭:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func cleanupLongConnections(srv *http.Server) {
|
|||
|
|
// Server 没有内置清理长连接的方法
|
|||
|
|
// 需要自己在中间件中跟踪活跃连接,Shutdown 时主动关闭
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**解决方案:**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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 流程中调用:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 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. 旧进程退出,新进程继续服务(无感知的版本切换)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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 秒:
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
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 略早一点,留缓冲 |
|
|||
|
|
| 记录关闭日志 | 确认关机是否正常完成 |
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[GIN/12-server-config]] — http.Server 的配置和启动方式
|
|||
|
|
- [[GIN/15-advanced-running]] — 多服务运行和特殊场景
|
|||
|
|
- [[部署与运维基础]] — K8s 滚动发布与优雅停机
|