本文是《Go 企业级工程能力实战》系列的第 12 篇,基于我的开源项目 user-service 的真实代码,从零开始拆解 Go HTTP 服务优雅关闭的全过程。
一、开篇引入:一个午夜惨案
小张是公司的一名后端工程师,今天是他值班。晚上 11 点,运维发来消息:"服务要发版了,你确认一下。"
小张看了一眼监控面板,一切正常。他回复:"发吧。"
K8s 开始了滚动更新。旧 Pod 被发送 SIGTERM 信号,迅速终止,新 Pod 启动。
三分钟后,客服群里突然炸了锅。多个用户投诉:"我在加好友,点了确认之后页面卡死了,刷新后发现好友根本没加上!"
小张查了日志,发现就在那一分钟内,有 23 个"同意好友请求"的 API 调用返回了 connection reset by peer。
原因很简单:K8s 在发送 SIGTERM 后立刻杀掉了进程,正在处理中的请求还没来得及完成就被中断了。
更糟糕的是,AddFriend 的实现是一个事务------在 dao/FriendsDao.go:148-178 中,它需要写两行数据(双向好友关系)。如果事务写到一半进程被杀死,就会出现"A 的好友列表里有 B,但 B 的好友列表里没有 A"的数据不一致。
小张通宵修复了数据,第二天顶着黑眼圈写了优雅关闭的代码。
这个故事不只是小张一个人的惨案。如果你现在还不会写优雅关闭,那下一个通宵修复数据的可能就是你了。
二、概念铺垫:kill -9 和 SIGTERM 是一回事吗?
在讲优雅关闭之前,先搞清楚操作系统是怎么让一个程序停下来的。
2.1 两种"杀进程"方式
Linux 系统中,停止一个进程有几种信号:
| 信号 | 编号 | 含义 | 进程能否捕获? |
|---|---|---|---|
| SIGTERM | 15 | 礼貌地请进程自行退出 | 能 |
| SIGINT | 2 | Ctrl+C 发出的中断信号 | 能 |
| SIGKILL | 9 | 立即终止,不给任何反应时间 | 不能 |
SIGTERM 是"请你走",SIGKILL 是"给我滚"。
当你执行 kill <pid>(不带参数时默认 SIGTERM),操作系统会通知进程:"嘿,该下班了。"进程可以:
- 完成手头的工作(处理中的请求)
- 保存数据(flush 日志、关闭数据库连接)
- 通知依赖方(从注册中心摘除)
当进程做完这些事后再退出------这就是"优雅关闭"。
而 kill -9 <pid> 是直接抽掉脚下的地板------进程连说一句话的机会都没有。
2.2 K8s 世界的优雅关闭流程
在 Kubernetes 中,Pod 停止会经历以下步骤:
1. Pod 被标记为 Terminating → 从 Service 的 Endpoint 列表中移除(不再分配新流量)
2. K8s 向 Pod 内所有容器的主进程发送 SIGTERM 信号
3. 等待 terminationGracePeriodSeconds 秒(默认 30 秒)
4. 如果进程还没退出 → 发送 SIGKILL 强制终止
关键点是第 3 步:K8s 给你了一段"善后时间",你需要在代码里利用好这段时间。
很多同学以为 K8s 会等自己,于是在代码里什么都不做。结果是:进程收到 SIGTERM 后直接退出(Go 的默认行为),K8s 等到超时才发 SIGKILL,但为时已晚------请求已经丢了。
2.3 一个生活类比
优雅关闭就像一个餐厅打烊的过程:
- kill -9:老板直接拉电闸,客人被关在黑暗里,正在上的菜摔在地上
- 直接退出:服务员大喊一声"关门了"然后下班,正在点菜的客人愣在原地
- 优雅关闭:服务员站在门口不再迎客(stop accepting new),等现有客人都用完餐(drain in-flight),厨房关火清理(close DB),然后才锁门下班(exit process)
一个好的服务程序,应该像一个有职业操守的服务员。
三、循序渐进:从"直接退出"到"优雅关闭"的三次迭代
3.1 第一代:裸奔 HTTP 服务
最简单的 Gin 服务启动方式:
go
func main() {
r := gin.Default()
r.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
r.Run(":8080") // 阻塞直到进程被 kill
}
太简陋了,Ctrl+C 或者 K8s 发 SIGTERM 后,进程直接退出。所有正在处理的请求全部中断。
3.2 第二代:手动捕获信号但不等待
进阶一点,你会自己启动 http.Server,并捕获操作系统的终止信号:
go
func main() {
srv := &http.Server{Addr: ":8080", Handler: router}
go func() {
srv.ListenAndServe() // 在 goroutine 中启动
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit // 等待信号
srv.Close() // 立即关闭,不等请求完成!
}
Close() 会立即关闭所有活跃连接,本质上和 kill 没区别。我们需要的是 Shutdown()。
3.3 第三代:优雅关闭的完整实现
看一下 user-service 项目 cmd/main.go:170-195 中的完整实现:
go
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
zapLog.Infof("received signal %v, shutting down...", sig)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if httpsServer != nil {
if err := httpsServer.Shutdown(ctx); err != nil {
zapLog.Errorf("HTTPS server shutdown error: %v", err)
}
}
if httpServer != nil {
if err := httpServer.Shutdown(ctx); err != nil {
zapLog.Errorf("HTTP server shutdown error: %v", err)
}
}
sqlDB, err = db.DB()
if err == nil {
sqlDB.Close()
}
zapLog.Sync()
这就是一个企业级优雅关闭的完整模板。让我们逐行拆解。
四、代码实战:逐行拆解优雅关闭
4.1 signal.Notify:监听系统信号
go
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
signal.Notify注册了SIGINT(Ctrl+C)和SIGTERM(K8s 终止信号)- channel 容量设为 1,因为信号到来后我们立刻消费,不会积压
- 进程运行到这里会阻塞,直到接收到这两个信号之一
- 注意这里没有捕获
SIGKILL------因为它根本不能被捕获,这是操作系统层面的设计
你可能会问:为什么不顺便捕获 SIGHUP?在某些场景下,SIGHUP 用于热加载配置。这是一个可选扩展,但核心的优雅关闭只需要 SIGINT 和 SIGTERM。
4.2 http.Server.Shutdown(ctx):关键的一行代码
go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpsServer.Shutdown(ctx); err != nil {
zapLog.Errorf("HTTPS server shutdown error: %v", err)
}
Shutdown(ctx) 是 Go 1.8 引入的方法,它的行为分两步:
- 停止接受新连接 :关闭
net.Listener,不再 accept 新的 TCP 连接 - 等待现有连接完成:对于已经建立的连接,等待它们处理完当前请求后自然关闭
传入的 ctx 是超时控制------"我给你 10 秒处理完手头的事,10 秒后不管你做完没有,我都要强制关闭"。
为什么是 10 秒?
看一下 k8s/deployment.yaml:29:
yaml
spec:
terminationGracePeriodSeconds: 30
K8s 给了 30 秒的"善后时间"。但我们代码里的 ctx.WithTimeout 只设了 10 秒。
这是一个保守设计:代码超时应该小于 K8s 超时。如果代码里设 40 秒而 K8s 只给 30 秒,那么 30 秒后 K8s 直接 SIGKILL,你的优雅关闭代码根本没跑完。
实际时间线应该是这样的:
0s K8s 发送 SIGTERM
0s 代码捕获信号,开始 Shutdown(不再接受新连接)
0s~10s 处理完已接受的请求(最多等 10s)
10s Shutdown 完成(超时则强制关闭)
10s 关闭数据库连接
10s 同步日志缓冲区
11s 进程正常退出
30s K8s 的 terminationGracePeriodSeconds 到期(但我们早就退出了)
代码实际用了约 11 秒(10s 等待 + 1s 清理),远在 K8s 的 30 秒容限之内。这就是一个有安全余量的设计。
4.3 关闭数据库连接
go
sqlDB, err = db.DB()
if err == nil {
sqlDB.Close()
}
这是优雅关闭中容易被忽略的一步。sqlDB.Close() 会:
- 等待所有正在使用的连接返回连接池
- 关闭所有空闲连接
- 通知 MySQL 服务端这些连接已经关闭(发送 FIN 包)
不关闭会怎样?MySQL 服务端会累积大量 TIME_WAIT 状态的连接,直到这些连接自然超时。在频繁滚动更新的 K8s 场景下,可能导致 MySQL 的 max_connections 被打满。
4.4 同步日志缓冲区
go
zapLog.Sync()
Zap 日志库默认是异步写入的,有内部缓冲区。如果进程退出前不调用 Sync(),最后几条日志可能还在缓冲区里没来得及刷盘。
这是一个极细微的 bug:你以为日志里记录了"shutdown complete",但实际上这条日志在刷盘之前进程就退出了。下次排查问题时你会对着日志文件百思不得其解------"为什么每次优雅关闭的最后几秒日志都不完整?"
Sync() 在这里是优雅关闭的最后一环,确保所有日志完整落盘。
4.5 你可能会忽略的事:确保 Shutdown 不在 goroutine 内
看 cmd/main.go:144-152,服务是在 goroutine 中启动的:
go
go func() {
zapLog.Infof("HTTP server starting on :8080")
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP server error: %v", err)
}
}()
注意 err != http.ErrServerClosed 这个判断。Shutdown() 会触发 ListenAndServe 返回 http.ErrServerClosed 错误,这是正常的,不应当作致命错误处理。
而 Shutdown() 本身是在主 goroutine 中执行的------它阻塞等待所有请求完成(或超时),所以不能放在服务 goroutine 中。这是正确姿势:
go
go srv.ListenAndServe() // 服务 goroutine
// ... 主 goroutine 阻塞等待信号 ...
srv.Shutdown(ctx) // 主 goroutine 中执行 Shutdown
4.6 完整的启动+关闭全景图
回顾 cmd/main.go:74-195 的 runServer() 函数,完整的生命周期是:
1. 初始化日志
2. 初始化 TracerProvider(OpenTelemetry)
3. 初始化数据库连接池
4. 注册 Prometheus 指标
5. 初始化各种 DAO
6. 初始化限流器
7. 创建 Gin 路由
8. 启动 HTTP(S) 服务(在 goroutine 中)
9. ---- 服务运行中 ----
10. 收到 SIGTERM / SIGINT
11. Shutdown HTTP 服务(10 秒超时)
12. Shutdown TracerProvider(5 秒超时,在 defer 中)
13. 关闭数据库连接
14. Sync 日志
15. 进程退出
你注意到步骤 12 了吗?看 cmd/main.go:81-85:
go
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tp.Shutdown(ctx)
}()
TracerProvider 的关闭是通过 defer 实现的,这意味着它会在 runServer 函数返回时自动执行。defer 的执行顺序是 LIFO(后进先出),所以实际执行顺序是:
runServer返回- 执行 defer:
tp.Shutdown(flush 所有未发送的 trace span) - 回到
main函数,进程退出
这就是一个完整的、不丢一个 trace span、不漏一条日志、不遗留一个数据库连接的优雅关闭。
五、高阶话题:优雅关闭与分布式系统的交互
5.1 为什么需要先从注册中心摘除?
在微服务架构中,优雅关闭不只是进程内部的事。你还需要:
- 从服务发现(如 Consul/Etcd)中摘除实例:让负载均衡器不再把流量导向即将关闭的实例
- 等待一段时间(如 5 秒):让负载均衡器刷新它的路由表
- 然后才开始 Shutdown:此时已经没有新流量进来了
user-service 项目没有使用服务发现(它是通过 K8s Service 暴露的),K8s Service 在 Pod 进入 Terminating 状态时会自动从 Endpoint 中移除------这就是第 4.2 节提到的"不再分配新流量"。
但如果你用的是 Consul 等外部注册中心,你需要在捕获信号后、Shutdown 之前,主动调用 deregister 接口。
5.2 优雅关闭和长连接
Gin 用的是 HTTP/1.1,默认启用 keep-alive 连接复用。Shutdown() 会等待当前连接上的请求完成,但不会主动关闭空闲的 keep-alive 连接------这可能导致 Shutdown 超时。
你可以在 Shutdown 之前,主动设置 Connection: close 响应头,让客户端知道不要复用连接。或者干脆在 Shutdown context 超时后接受强制关闭------这就是 10 秒超时的另一个价值。
5.3 initContainer 中的 migrate
再看 k8s/deployment.yaml:34-47:
yaml
initContainers:
- name: migrate
image: user-service:latest
imagePullPolicy: IfNotPresent
command: ["./UserServer", "migrate"]
在 Pod 启动时,先运行 migrate initContainer,执行数据库迁移(cmd/main.go:41-46 的 runMigrate()),成功后才启动主容器。
migrate 也不需要优雅关闭------它执行完就退出,不接收 HTTP 请求。所以它的代码非常简短,就是 db.AutoMigrate + sqlDB.Close + zapLog.Sync。
六、总结
优雅关闭的核心只有三行代码:
go
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
srv.Shutdown(context.WithTimeout(context.Background(), 10*time.Second))
但围绕这三行代码,需要思考的问题远不止于此:
| 考量点 | 实践 |
|---|---|
| 代码超时 vs K8s 超时 | 代码 10s < K8s 30s,留出安全余量 |
| 数据库连接 | sqlDB.Close() 避免连接泄漏 |
| 日志缓冲区 | zapLog.Sync() 确保日志完整落盘 |
| 链路追踪 | tp.Shutdown() flush 未发送的 trace span |
| 错误处理 | 区分http.ErrServerClosed(预期)和其他错误(异常) |
| 服务发现 | K8s Service 自动摘除 / 外部注册中心需主动 deregister |
| 数据库迁移 | initContainer 独立执行,不参与优雅关闭 |
记住这个公式:捕获信号 → 停止接新 → 等现有完成(设超时)→ 关 DB → sync 日志 → 退出。
下次发版前,先检查你的代码里有没有这三行。如果没有,今晚你可能就是那个通宵修数据的人。
完整代码
本文所有示例代码来自开源项目 user-service,一个基于 Go + Gin + GORM 构建的企业级用户管理与社交关系 REST API 微服务。
项目地址:https://github.com/binbin3828/user
本系列 14 篇完整目录:
① 从面条代码到三层架构 ② API 安全洋葱模型 ③ 配置管理与密钥保护 ④ 单元测试 ⑤ 可观测性
⑥ 部署进化 ⑦ 好友请求状态机 ⑧ Redis 实战 ⑨ 中间件链 ⑩ Geohash
⑪ API 响应设计 ⑫ 优雅关闭 ⑬ GORM 避坑 ⑭ Makefile