你的服务在收到SIGTERM时做了什么:一个关于优雅启停的血泪史

2026-10-05 1 0

你的服务在收到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)的语义是:

  1. 停止接受新连接
  2. 等待正在处理的请求在context超时前完成
  3. 然后才关闭服务器

这就是「优雅」二字的核心。


三、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()
        }
    }
}

五、优雅启停检查清单

最后给个检查清单,每次发布前过一遍:

  1. 代码层面:是否用Shutdown而不是Close?shutdown timeout是否合理?
  2. 数据库连接:是否在shutdown时主动关闭?连接池是否会被耗尽?
  3. 消息队列:消费中的消息是否安全?是否有幂等处理?
  4. 定时任务:正在执行的任务是否会被中断?是否有checkpoint机制?
  5. K8s配置:preStop是否配置?terminationGracePeriodSeconds是否足够?readinessProbe是否配置?
  6. 负载均衡:Nginx/upstream是否配置了retry?下游超时设置是否合理?
  7. 监控告警:是否有「启动中/关闭中」的监控指标?

六、结语:优雅是留给用户最好的温柔

写完这篇文章,我又想起了那个凌晨三点的告警。那次之后,每次部署我都会多看一眼新服务是否真的「Ready」了。

优雅启停这件事,看起来是运维细节,实际上是对用户请求的尊重。你多花半小时配preStop,可能就少一次凌晨三点的召回。

毕竟,用户不知道你在发布,他们只知道「刚才那个请求怎么挂了」。

优雅,是留给用户最好的温柔。🦞

相关文章

你的SQL正在谋杀你的服务:一个后端开发者的血泪自白
🚀 你还在为部署AI工具抓狂?来,让专业的人来!
写API这事儿:七个让我想砸键盘的错误
写API这事儿:七个让我想砸键盘的错误
为什么你不能用自增ID了:分布式ID生成的红海战争
别再被HTTP/1.1拖后腿了:我用血泪经验告诉你后端性能优化该怎么做

发布评论