为什么优雅停机不是“可选项”
很多团队第一次在线上遇到发布问题,往往是因为一个简单的操作:重启服务。直接kill -9或者容器被K8s终止时,那些正在处理的支付回调、文件上传或者一个复杂的聚合查询,会像被剪刀剪断的线头一样,戛然而止。客户端看到的可能是502错误,也可能是数据库里只写了一半的脏数据。
优雅停机的目标非常明确:在服务生命周期结束时,不再接收新的请求,同时给正在处理的请求一个合理的完成时间窗口,最后在超时后强制退出。它不是让服务无限期等待,而是在发布流程可控和用户体验之间找一个平衡点。
一个容易被忽略的完整流程
优雅停机远不止调用http.Server.Shutdown()那么简单。把它拆解开,你会发现一条必须串联起来的链:
- 信号捕获:服务需要知道“该停了”。本地开发是Ctrl+C (
os.Interrupt),K8s等编排系统发的是syscall.SIGTERM。 - 流量摘除:告诉负载均衡或服务发现“别再给我新流量了”。这通常通过将健康检查(如
/ready端点)标记为失败来实现。 - 摘流缓冲:这是最容易漏掉的一步。健康检查失败后,网关、路由表和客户端连接池的同步需要几秒时间。如果立刻关闭监听,这几秒内到达的请求就成了“漏网之鱼”。
- 连接排空:调用
Shutdown,等待现有HTTP连接自然完成。 - 资源清理:关闭数据库连接池、Redis客户端、消息队列消费者等下游依赖。
- 超时兜底:为整个流程设置一个总超时,防止某个慢请求或资源关闭卡住整个发布。
很多线上问题就出在第2步和第3步的衔接上,或者第5步的顺序不对。
核心实现:信号、摘流与Shutdown
下面是一段可直接用于生产的Go代码骨架。它包含了健康检查、摘流窗口和带超时的关闭。
package main
import (
"context"
"fmt"
"log"
"net/http"
"os/signal"
"sync/atomic"
"syscall"
"time"
)
func main() {
// 1. 状态标志
var isShuttingDown atomic.Bool
mux := http.NewServeMux()
// 健康检查端点
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if isShuttingDown.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// 模拟业务端点
mux.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Second) // 模拟耗时操作
w.Write([]byte("Work done"))
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
// 2. 启动服务
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// 3. 信号监听
sigChan := make(chan os.Signal, 1) // 必须带缓冲,防止信号丢失
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) // 同时监听两种信号
<-sigChan // 阻塞等待信号
log.Println("Shutdown signal received")
// 4. 开始停机流程
// 4.1 标记健康检查失败,开始摘流
isShuttingDown.Store(true)
log.Println("Health check marked unhealthy, waiting for drain...")
time.Sleep(3 * time.Second) // 留出摘流缓冲时间
// 4.2 执行Shutdown,等待现有请求完成
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel() // 确保资源释放
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
// 5. 此处可关闭数据库、Redis等资源
log.Println("Server exited gracefully")
}
这段代码有几个关键点:信号通道必须有缓冲;Shutdown必须传入一个带超时的Context;摘流后主动等待几秒。
不同场景下的策略取舍
优雅停机没有银弹,策略需要根据你的流量模型调整。
| 业务类型 | 核心挑战 | 推荐策略重心 | 常见陷阱 |
|---|---|---|---|
| 短API服务 | 请求处理快,但QPS可能很高。 | 快速摘流,Shutdown超时略大于P99耗时(如10-15秒)。 | 把http.ErrServerClosed误当作启动错误上报报警。 |
| 长耗时请求(上传/导出) | 单个请求可能持续数十秒或数分钟。 | 设置更长的Shutdown超时,或对长任务实现异步与可恢复。 | 超时设得再长,也可能被K8s的terminationGracePeriodSeconds(默认30秒)强制终止。 |
| WebSocket/SSE | Shutdown不会自动关闭这些被接管的长连接。 | 在Shutdown前,主动向所有连接广播关闭消息并等待客户端断开。 | 误以为调用了Shutdown就万事大吉,导致连接泄漏。 |
| 消费队列的网关 | 除了HTTP,还有后台Goroutine在消费消息。 | 先停止从队列取新任务,再关闭HTTP监听,最后等待已取任务处理完。 | HTTP服务停了,但消费Goroutine还在跑,导致消息重复或丢失。 |
与Kubernetes的协同
在K8s环境中,优雅停机需要应用和平台配置双管齐下。
应用侧:必须同时监听SIGTERM(K8s删除Pod时发送)。你的Shutdown总超时必须小于Pod的terminationGracePeriodSeconds(默认30秒),建议预留至少5秒缓冲,例如设为25秒。否则,超时未结束的进程会被发送SIGKILL强制杀死。
平台侧:可以配置preStop钩子,在发送SIGTERM前执行一个命令(如sleep 5),给负载均衡摘流提供额外时间。同时,确保就绪探针(Readiness Probe)指向你那个会因isShuttingDown标志而失败的端点。
必须手动关闭的资源与顺序
http.Server.Shutdown()只负责HTTP层面的连接排空。其他任何由你创建的资源,都必须手动关闭,且顺序至关重要。错误的关闭顺序可能导致panic或数据丢失。
基本原则是按反向依赖顺序关闭:
- 首先停止所有消息消费者(如Kafka、RabbitMQ),停止拉取新消息。
- 关闭HTTP/WebSocket/SSE服务(即调用Shutdown)。
- 关闭数据库连接池(调用
db.Close()),确保所有进行中的事务已完成。 - 关闭缓存客户端(如Redis)、外部API客户端等。
- 最后,停止所有内部后台定时器或监控上报Goroutine。
例如,如果你先关了数据库,但HTTP请求还在尝试执行查询,就会立刻出错。
避坑指南:为什么我的Shutdown卡住了?
如果你发现调用Shutdown后程序一直不退出,通常是因为有Handler(或它调用的下游函数)没有尊重Context的超时。
- 数据库操作:必须使用
db.QueryContext(ctx, ...)而非db.Query(...)。 - HTTP客户端调用:使用
http.NewRequestWithContext或req.WithContext(ctx)。 - 纯CPU计算或死循环:需要在Handler中定期检查
ctx.Done()通道。 - 中间件启动的异步Goroutine:比如日志异步刷盘,必须也监听同一个Context,否则它们会成为“孤儿”任务,阻止进程退出。
优雅停机本质上是一种合作式关机。它要求你的业务代码具备“被中断”的能力。
总结:从功能实现到工程习惯
实现Go HTTP服务的优雅停机,技术层面是清晰的:监听信号、摘流缓冲、调用Shutdown、按序关资源。但更重要的是把它变成一种工程习惯。每个新的HTTP服务项目,都应该默认包含这套停机骨架。在K8s部署描述中,也应有意识地配置terminationGracePeriodSeconds和preStop钩子。
它的价值在于,当发布窗口到来时,你能确信服务会安静、有序地离开,而不是在中断用户请求后留下一堆需要人工干预的混乱状态。这不仅仅是代码的优雅,更是对线上稳定性的负责。
原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/112/