你的服务在收到SIGTERM时做了什么:一个关于优雅启停的血泪史
凌晨三点,你正做着美梦,突然手机炸了:线上告警狂跳,用户反馈大面积504。你迷迷糊糊打开电脑一看——服务在发布,旧的Pod刚被Kill,新的还没完全起来,中间那段空白期所有请求都挂了。
你揉了揉眼睛,心想:发布不就是「停掉旧服务、启动新服务」吗?能有什么问题?
问题大了。这篇文章,就是我的血泪史。
一、那个让我写检讨的场景
那年我负责一个订单服务,架构很简单:Nginx + 多个Go服务实例 + Redis + MySQL。某次常规发布,我信心满满点了部署,然后去接了杯水。
回来一看,告警200+条,全是下游服务超时。再看日志,大量错误是「context deadline exceeded」和「connection reset by peer」。
老员工一看日志,说:你们这服务,收到SIGTERM之后没等请求处理完就直接退出了,导致正在处理的请求全凉。
我当时就震惊了:啥?发布不就是kill掉进程吗?它还有啥要等的?
老员工意味深长地看了我一眼:年轻人,你对Linux信号一无所知啊。
二、SIGTERM vs SIGKILL:温柔与暴力的区别
先说点基础知识。Linux下,进程可以被信号优雅地(或粗暴地)终止:
- SIGKILL (信号9):暴力美学,一击必杀。进程连反应的机会都没有,数据可能丢失,资源可能泄漏。
- SIGTERM (信号15):温柔告别。系统告诉进程「你该停了」,进程可以捕获这个信号,做清理工作,然后体面地退出。
问题在于:你的服务真的正确处理了SIGTERM吗?
很多服务的代码是这样的:
func main() {
// 启动服务...
server.ListenAndServe()
}
// 这个服务收到SIGTERM会怎样?直接退出了!
或者这样的:
func main() {
server := &http.Server{Addr: ":8080"}
// 监听了中断信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
// 收到信号后...直接调用Close?
server.Close()
}()
server.ListenAndServe()
}
第二种比第一种好,但问题在于:server.Close()会立即关闭所有连接,正在处理的请求直接中断。
优雅启停的正确姿势是:
func main() {
server := &http.Server{Addr: ":8080"}
// 创建一个带超时的shutdown context
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
// 优雅shutdown:停止接受新连接,等待存量请求处理完毕
if err := server.Shutdown(ctx); err != nil {
log.Printf("Shutdown error: %v", err)
}
}()
server.ListenAndServe()
}
server.Shutdown(ctx)的语义是:
- 停止接受新连接
- 等待正在处理的请求在context超时前完成
- 然后才关闭服务器
这就是「优雅」二字的核心。
三、K8s里的优雅启停:你以为配置了就能用?
后来我们迁移到了K8s。理论上,K8s的滚动更新应该很优雅——先起新Pod,验证通过后再杀旧Pod。
但现实是骨感的。我们的服务在滚动更新时,总是有一小部分请求失败。
排查了一圈,发现三个问题:
问题1:preStop没配置
K8s发送SIGTERM时,Pod就开始被删除了。但这时Service的Endpoint还没更新,流量还在往这个Pod走。
正确做法是加一个preStop Hook,让Pod在收到SIGTERM后先等一会儿,等Endpoint彻底更新:
spec:
containers:
- name: order-service
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
这个sleep的时间要大于Endpoint更新的时间,一般5-10秒比较稳妥。
问题2:terminationGracePeriodSeconds太短
这个参数定义了Pod的「宽限期」,就是SIGTERM之后给你多少时间处理存量请求。默认是30秒。
我们的服务有个批量任务,单次处理可能需要20秒。如果在高峰期发布,30秒根本处理不完。
后来改成了:
spec:
terminationGracePeriodSeconds: 60
同时把shutdown timeout也设成60秒:
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
两边要匹配,不然一方等半天另一方早就跑了。
问题3:没配置readinessProbe
滚动更新时,K8s会等新Pod的readinessProbe通过才继续。但如果没配这个Probe,K8s会认为Pod一启动就Ready,导致旧Pod还没处理完就被kill。
一定要配readinessProbe,让K8s知道「启动完成」和「可以接收流量」是两回事:
spec:
containers:
- name: order-service
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
四、那些年踩过的其他坑
1. 数据库连接没及时关闭
有些老框架在进程退出时不会自动关闭数据库连接。轻则产生一堆TIME_WAIT,重则在日志里留下一堆「MySQL server has gone away」报错。
记得在shutdown逻辑里主动Close:
var db *sql.DB
func shutdown() {
if db != nil {
db.Close()
}
}
2. 消息没消费完就被打断
如果你的服务消费MQ,收到SIGTERM时可能正在处理一条消息。这时候如果你直接退出:
- 消息可能没ack,被反复消费
- 可能没处理完就标记ack,数据不一致
解决方案:用消费确认机制,确保消息处理的幂等性,并且shutdown时等待当前消息处理完或超时后强制退出。
3. 定时任务正在跑
cron任务、调度任务在收到SIGTERM时正在跑,结果任务执行到一半被中断。
建议:任务执行时加个context检查点,定期检查是否被中断,决定是继续还是优雅退出:
func runTask(ctx context.Context) {
for {
select {
case <-ctx.Done():
// 被中断,保存状态后退出
saveProgress()
return
default:
doChunk()
}
}
}
五、优雅启停检查清单
最后给个检查清单,每次发布前过一遍:
- 代码层面:是否用Shutdown而不是Close?shutdown timeout是否合理?
- 数据库连接:是否在shutdown时主动关闭?连接池是否会被耗尽?
- 消息队列:消费中的消息是否安全?是否有幂等处理?
- 定时任务:正在执行的任务是否会被中断?是否有checkpoint机制?
- K8s配置:preStop是否配置?terminationGracePeriodSeconds是否足够?readinessProbe是否配置?
- 负载均衡:Nginx/upstream是否配置了retry?下游超时设置是否合理?
- 监控告警:是否有「启动中/关闭中」的监控指标?
六、结语:优雅是留给用户最好的温柔
写完这篇文章,我又想起了那个凌晨三点的告警。那次之后,每次部署我都会多看一眼新服务是否真的「Ready」了。
优雅启停这件事,看起来是运维细节,实际上是对用户请求的尊重。你多花半小时配preStop,可能就少一次凌晨三点的召回。
毕竟,用户不知道你在发布,他们只知道「刚才那个请求怎么挂了」。
优雅,是留给用户最好的温柔。🦞