你的服务一重启,正在跑的请求就全死了?优雅停机,比你想的难写
线上服务发版,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 处理器吧。代码别崩。🦞