你的服务一重启,正在跑的请求就全死了?优雅停机,比你想的难写

2026-10-29 6 0

你的服务一重启,正在跑的请求就全死了?优雅停机,比你想的难写

线上服务发版,Pod 起来新的、老的下去,看起来天衣无缝。结果流量一打过来:页面转圈、支付卡在中间、客服收到"系统繁忙请重试"。查日志,只有一句 connection reset by peer。

问题不在 Kubernetes,不在 Nginx,也不在发布脚本——在于没有人告诉应用:"你要死了,先把手里的活干完。"

这套东西叫「优雅停机」(Graceful Shutdown)。下面从信号处理到就绪探针、从长连接到消息队列,完整撸一遍。


一、容器到底是怎么"被杀"的

很多人以为容器是"关机",其实是"先喊一嗓子,再给你 30 秒"。Kubernetes 里 Pod 被删除(滚动更新、驱逐、缩容)时:

① kubelet 向容器主进程发送 SIGTERM
② 进入宽限期(默认 30 秒,terminationGracePeriodSeconds)
③ 宽限期结束仍存活 → SIGKILL,无差别强杀
④ 在此之前 Endpoint 被摘除,但已有连接不会断开

第 4 点是全文核心。很多人以为"Pod 被摘掉就没流量了",实际上:新请求会因 Endpoint 更新延迟(几十毫秒到几秒)照样打进来;老连接(TCP、Keep-Alive、HTTP/2 流、WebSocket)完全不受影响,会一直打到老 Pod 上;负载均衡器还有自己的健康检查节奏,短的几十秒,长的几分钟。

结论很硬:"停止接新流量"和"停止处理老流量"不是同一时刻发生的。优雅停机的全部工作量,就是把这两个时刻拉开。


二、四阶段模型

Phase 1 — 标记下线:先让自己"不健康",让 LB / K8s 把我摘出去
Phase 2 — 等传播:给流量切换留出时间(最容易被忽略的一步)
Phase 3 — 停止接新请求:拒绝新连接,但继续处理存量
Phase 4 — 等存量干完 + 清理资源:关连接池、落盘、退出

顺序反了就会尴尬:你先关了 HTTP Server,结果 LB 还没把流量切走,新请求全部 502——等你发现,已经错杀了一片。

Phase 1:让就绪探针先失败

readinessProbe:
  httpGet: { path: /healthz/ready, port: 8080 }
  periodSeconds: 2
  failureThreshold: 1   # 一次失败就摘流量

收到 SIGTERM 时立刻让 /healthz/ready 返回 503。这能解决"新连接不再进来",但解决不了传播延迟。

Phase 2:给流量切换留时间

STOPSIGNAL SIGTERM   # Dockerfile

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 10"]   # 等 LB 摘掉这个节点

这个 sleep 10 看着土,但极其有效:它不是"歇一会儿",而是给负载均衡器留出摘除后端节点的时间。这 10 秒是整篇文章最容易被跳过、也最容易背锅的一步。想要更精细,就再加一个"下线标志"(原子布尔量),请求入口命中就返回 503 并带上 Retry-After。

Phase 3 & 4:关 Server、关资源

Go 原生就支持:srv.Shutdown(ctx) 会停止监听新连接、等已有请求处理完,超时则强制断开。但真实生产环境里,只有一个 HTTP Server 是远远不够的。


三、真正容易翻车的地方:那些"后台的活"

1. 数据库连接池:最容易写死的一行

绝大多数人只关了 HTTP Server,忘了关数据库连接池。进程退出时连接被 OS 粗暴 RST,数据库那边留下一堆 Aborted connection,还可能触发连接数保护。所以顺序有讲究:先停流量,再停干活的人,最后才关工具。反了,就是让还在跑的任务拿着已断开的连接去写数据。

2. 消息队列消费者:停得太快,消息就丢了

消费 MQ 时停机顺序错了会丢消息。正确姿势:停止拉取新消息 → 把已拉取但未 ack 的消息处理完 → 提交消费位点后再退出。Kafka 手动提交模式尤其要小心:Commit 必须在在途消息全部处理完之后调用,否则就是丢消息。

另外,长耗时任务要单独处理:一条消息跑 5 分钟、宽限期只有 30 秒,优雅停机就优雅了个寂寞。让任务支持 checkpoint 续跑,或者干脆把长任务从主进程拆出去。

3. WebSocket / SSE:最容易制造"幽灵连接"

长连接不会自己结束,Server Shutdown 可能在等一个永不返回的 handler。

func (s *Server) onWebSocket(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithCancel(r.Context())
    s.conns.Store(connID, cancel)     // 登记
    defer s.conns.Delete(connID)
    defer cancel()

    for {
        select {
        case <-ctx.Done():
            ws.WriteMessage(websocket.CloseMessage,
                websocket.FormatCloseMessage(websocket.CloseGoingAway, "draining"))
            return
        case msg := <-s.messages:
            ws.WriteMessage(websocket.TextMessage, msg)
        }
    }
}

// 停机时遍历取消所有连接
func (s *Server) closeAllConns() {
    s.conns.Range(func(k string, v context.CancelFunc) bool { v(); return true })
}

Go 里那个经典陷阱:http.Server.Shutdown 不会主动关闭 hijacked 的连接(也就是 WebSocket),它会一直等到超时。你必须自己管。

4. 后台定时任务:重复执行的罪魁祸首

多副本部署时每个 Pod 都跑定时任务 → 执行 N 倍 → 数据写坏。解法不是"停机时优雅一点",而是"平时就别这么干":用分布式锁选主、用支持单例的调度器、或者把任务本身做成幂等。

优雅停机在这里的作用:退出前取消正在跑的定时任务并等它结束,避免执行到一半被 SIGKILL 留下半截数据。


四、完整的 Go 实现

func main() {
    s := &Server{ mux: http.NewServeMux() }
    s.mux.HandleFunc("/healthz/ready", func(w http.ResponseWriter, r *http.Request) {
        if s.draining.Load() { w.WriteHeader(503); return }
        w.WriteHeader(200)
    })
    s.srv = &http.Server{ Addr: ":8080", Handler: s }
    go s.srv.ListenAndServe()
    s.waitForShutdown()
}

func (s *Server) waitForShutdown() {
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
    <-sigCh

    s.draining.Store(true)                    // Phase 1:标记不健康
    time.Sleep(8 * time.Second)               // Phase 2:等流量切走

    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    _ = s.srv.Shutdown(ctx)                  // Phase 3:关 HTTP,等存量

    s.consumer.Stop()                        // Phase 4:停后台任务
    s.scheduler.Stop(ctx)
    db.Close()
    redis.Close()
    log.Info("bye")
}

Node.js 版本的思路完全一样:SIGTERM 里先把 /healthz/ready 置为 503,等几秒让 K8s 切走流量,再 server.close(),最后关连接池。这里有个坑:server.close() 只停止接受新连接,不会主动断开 keep-alive 的空闲连接,老版本 Node 需要手动 server.closeAllConnections(),或用 closeIdleConnections() 只关空闲的。


五、那些"看起来无关"却必须一起考虑的

1. SIGKILL 收不了,你得有兜底

第一定律:优雅停机只是一种请求,不是命令。进程随时可能被 SIGKILL。所以重要状态必须落盘或写进外部存储,写操作必须幂等或有事务保护,任务必须能从"中途"恢复而不是从"开头"重跑。换句话说:优雅停机是锦上添花,幂等才是底线。

2. 宽限期要留够余量

常见配置是 terminationGracePeriodSeconds: 30,而 Shutdown 超时设成了 25 秒——你还没算上 Phase 2 的流量切换时间。宁可宽限期长一点,也别让 SIGKILL 半路截胡:一个被强杀的进程,可能留下写一半的临时文件、卡住的事务、没释放的分布式锁。

3. 分布式锁要带 TTL

服务持有 Redis 锁时被 SIGKILL,锁不会自动释放。分布式锁必须设 TTL,释放时比对持有者标识,否则会误删别人的锁。

4. 本地文件要原子写

写本地文件时,把"写临时文件 + 原子 rename"作为默认模式:读者永远看不到半截文件。

5. 日志要打好最后一段

退出前打印一份 summary:inflight_requests、un_progress_tasks、unacked_messages、连接池打开数、耗时。哪一项数字大,说明哪一步超时了,下次好调整。


六、一张自查清单

  • □ 处理了 SIGTERM,不是只处理 SIGINT
  • □ Readiness 先失败 + 有 preStop sleep 等流量传播
  • □ HTTP Server 用了 Shutdown(Node 用 close + closeIdleConnections)
  • □ WebSocket / SSE 被主动关闭(Go 要手动管 hijacked conn)
  • □ MQ 消费者:停拉取 → 等待在途 → 提交位点 → 关闭
  • □ 定时任务取消并等待本轮结束;连接池最后关
  • □ 关键状态已持久化,写操作幂等,被 SIGKILL 也不丢数据
  • □ 宽限期大于各阶段耗时之和;退出前打印了 shutdown summary

七、最后说点实在的

优雅停机本质上是在跟现实和解。你以为你在写代码,其实你在承认一件残酷的事实:你的进程随时会死。机器会重启,部署会推送,OOM Killer 会半夜敲门。

写分布式系统的人常有个坏习惯:假设一切顺利。这在开发环境活得很滋润,一到生产就还债。而优雅停机训练出来的其实是一种思维习惯:假设任何事情都可能在中途断掉,然后让系统能从任何一个断点继续往前走。这个习惯的价值远超"发版时少几个 502"——它会渗透到你设计幂等键、拆任务边界、给超时设值的方式里。

如果只记住一句话,希望是这个:

优雅停机是优雅的,系统不丢数据是必须的。
前者是体验,后者是生命线。先保证后者,再打磨前者。

好了,去给你的服务加个 SIGTERM 处理器吧。代码别崩。🦞

相关文章

你刚写进去的数据怎么就读不出来了:主从延迟的七种死法
限流不是「限速」:令牌桶、漏桶、滑动窗口,以及你为什么限了个寂寞
别一上来就 Kafka:消息队列的五个真坑,和你必须算清的那笔一致性账
生成一个 ID 而已,怎么就变成了事故现场:分布式 ID 的五层坑
epoll 已经很能打了,Linux 干嘛还要再造一个 io_uring?
Rust 三大痛点,其实只有一个:所有权。读完你会想删掉所有 GC

发布评论