一个没有处理 SIGTERM 的服务,每次滚动发布都会硬生生掐断一批正在处理的请求,丢单又报错:一次优雅停机缺失的深度复盘
那个问题是被发布时段的监控曲线揪出来的:我们每次上线、滚动重启服务,监控上都会准时出现一小撮 5xx 错误和请求超时,客服那边也总在发布后收到零星的"下单失败/支付卡住"投诉。一开始我以为是发布瞬间的抖动,没太在意,直到有一次发布正好赶上业务高峰,这"一小撮"放大成了一大批丢单,我才不得不认真排查。我盯着发布流程看了半天,终于想明白了根源,后背发凉:我们的服务完全没有处理"优雅停机"。部署系统在滚动更新时,会先给老进程发一个 SIGTERM 信号、通知它"该退出了",可我们的进程没有捕获这个信号——于是它要么被默认行为立即终止、要么过一会被 SIGKILL 强行杀死。在它被杀的那一刻,它手里正在处理的那些请求(正在查库、正在调下游、正在写订单)被硬生生掐断,连接没关、事务可能没提交、响应再也回不到客户端——于是就有了那一批 5xx 和丢单。我们只让进程"停"了,却没给它一个机会"把手头的事干完再停"。这篇就把这次"优雅停机缺失"的坑,从头到尾复盘一遍。
故障现场:一个不处理 SIGTERM、说停就停的服务
问题不在某一行代码,而在缺了一段代码——进程没有任何对 SIGTERM 的处理:
// ✗ 出问题的服务: 启动后直接 ListenAndServe, 没有任何优雅停机处理
func main() {
http.HandleFunc("/order", handleOrder) // 下单接口, 一次处理可能耗时几百ms
http.ListenAndServe(":8080", nil) // ✗ 没有捕获SIGTERM, 没有优雅关闭
}
// 部署滚动更新时发生了什么:
// 1. 部署系统给老进程发 SIGTERM("请退出");
// 2. 进程【没有捕获】SIGTERM → 走默认行为: 进程被立即终止;
// (或: 进程没在宽限期内退出 → 部署系统过一会发 SIGKILL 强杀);
// 3. 被终止的【那一刻】, 所有【正在处理中】的请求被硬生生中断:
// - 正在查数据库/调下游的, 连接被掐断;
// - 正在写订单事务的, 可能没来得及提交(或提交了没返回);
// - 客户端拿到的是 连接重置/超时/502 → 下单失败、丢单;
// 4. 同时, 负载均衡可能还在往这个【正在死】的实例转发新请求(它还没被摘掉),
// 这些新请求也全部失败。
// 缺失的两件事:
// - 没有"优雅停机": 收到SIGTERM后, 应该【先停止接收新请求, 把已接收的处理完, 再退出】;
// - 没有和负载均衡/注册中心配合: 应该【先从LB摘掉自己(不再有新请求进来), 再开始停机】。
// 关键: 进程不处理SIGTERM = "说杀就杀", 手头正在处理的请求全部被中断 → 发布即抖动、即丢单。
第一次意识到这个缺失时,我有些懊恼:"我只想着怎么把服务'启动起来',却从来没想过怎么让它'体面地停下来'。"这个坑最容易被忽视的地方在于:它只在"停止/重启"的那一刻发作,而服务"跑起来"的时候一切正常;开发时我们盯着的是"功能对不对",几乎从不关心"它被关闭时会怎样"——"停机"这个生命周期阶段,是被遗忘的角落。可在持续部署的今天,服务被频繁地滚动重启(每次发布、每次扩缩容、每次故障迁移),"优雅停机"不是可有可无的细节,而是保证"发布无损"的刚需。下面就来拆解,优雅停机到底要做什么。
第一件事:搞懂优雅停机要解决什么,以及信号机制
我顺着这次事故,把"优雅停机"的来龙去脉彻底理清了。
优雅停机(graceful shutdown)要解决什么?
【核心: 让进程在"被通知退出"后, 不立即死, 而是"停止接新活、干完手头的活、清理干净、再退出"】
1. 进程是怎么"被通知退出"的——信号(signal):
- SIGTERM(15): "请你终止"——【可被捕获】, 是优雅停机的信号(部署/k8s默认发它);
- SIGKILL(9): "立即杀死"——【不可捕获、不可忽略】, 强制终止(宽限期后的最后手段);
- SIGINT(2): Ctrl+C, 通常也当作请求退出处理。
- → 部署系统的套路: 先发SIGTERM给你机会优雅退出, 等一个"宽限期"还没退就发SIGKILL强杀。
2. 不做优雅停机的后果:
- 收到SIGTERM不处理 → 进程立即死(或被SIGKILL强杀);
- 正在处理的请求被中断、连接被掐、事务可能不一致、响应丢失 → 报错、丢数据。
3. 优雅停机要做的几件事(收到SIGTERM后):
- ① 从负载均衡/注册中心【摘掉自己】(或先让健康检查失败), 不再有【新请求】进来;
- ② 【停止接收新请求】, 但【继续处理完已经接收的请求】(等它们做完);
- ③ 给一个【超时】: 等存量请求一段时间(如30秒), 还没完就该放弃(不能无限等);
- ④ 【清理资源】: 关闭数据库连接池、刷新缓冲、提交/回滚事务、关闭文件;
- ⑤ 然后才【正常退出】。
4. 要和编排系统配合(以k8s为例):
- k8s删Pod时: 先把Pod从Service端点摘除(停止新流量) + 发SIGTERM;
- terminationGracePeriodSeconds: 给的宽限期(默认30s), 超了发SIGKILL;
- preStop钩子: 可在发SIGTERM前先sleep几秒, 等LB摘除生效, 避免"摘除还没生效就开始停"。
一句话: 部署靠SIGTERM通知退出、宽限期后SIGKILL强杀; 优雅停机=捕获SIGTERM后先摘流量、
停收新请求、处理完存量(带超时)、清理资源再退出; 并和编排系统的宽限期/preStop配合。
这套机制,是整个坑的根。进程靠信号被通知退出:SIGTERM(可捕获,优雅停机的信号,部署默认发它)、SIGKILL(不可捕获,强制终止,宽限期后的最后手段);部署套路是先发 SIGTERM 给你机会优雅退出、宽限期还没退就 SIGKILL 强杀。不做优雅停机:收到 SIGTERM 不处理则进程立即死,正在处理的请求被中断、事务不一致、响应丢失。优雅停机要做:①从负载均衡摘掉自己(不再有新请求);②停止接新请求、处理完存量;③给个超时(等存量一段时间、不无限等);④清理资源(关连接池、刷缓冲、提交事务);⑤再正常退出。还要和编排系统配合:k8s 删 Pod 时先从 Service 摘端点+发 SIGTERM,terminationGracePeriodSeconds 是宽限期,preStop 可先 sleep 等 LB 摘除生效。一句话:部署靠 SIGTERM 通知退出、宽限期后 SIGKILL 强杀;优雅停机=捕获 SIGTERM 后先摘流量、停收新请求、处理完存量(带超时)、清理资源再退出;并和编排系统的宽限期/preStop 配合。
第二件事:正解——捕获 SIGTERM,优雅关闭 HTTP server,配合编排宽限期
搞懂了原理,正解就清晰了:捕获 SIGTERM、调用框架的优雅关闭(停止接新请求、等存量处理完、带超时)、清理资源;并配置好编排系统的宽限期和 preStop。
// ✓ 正解: 捕获SIGTERM, 优雅关闭 http.Server
func main() {
srv := &http.Server{Addr: ":8080", Handler: router()}
// 后台启动服务
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
// ★ 监听 SIGTERM / SIGINT
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit // 阻塞, 直到收到信号
log.Println("收到停机信号, 开始优雅关闭...")
// ★ 给一个超时(如30秒), 等存量请求处理完
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// ★ Shutdown: 停止接收新请求, 等已接收的请求处理完(或超时), 再返回
if err := srv.Shutdown(ctx); err != nil {
log.Printf("优雅关闭超时, 强制退出: %v", err)
}
// ★ 清理其他资源: 关闭数据库连接池、刷新缓冲、关闭消息队列消费者等
db.Close()
cache.Close()
log.Println("已优雅退出")
}
// → 收到SIGTERM后: srv.Shutdown 停止接新请求、等存量处理完(最多等30秒);
// 存量请求得以正常返回、资源被清理 → 发布时不再掐断请求、不再丢单。
# ✓ 配套: Kubernetes 配置, 让摘流量和停机配合好
spec:
terminationGracePeriodSeconds: 40 # ★ 宽限期要 > 你的优雅关闭超时(30s)+余量
containers:
- name: app
lifecycle:
preStop:
exec:
# ★ 先 sleep 几秒, 等 k8s 把本Pod从Service端点摘除"生效"(传播有延迟),
# 避免"还在往我转发新请求, 我却已经开始停机"的窗口
command: ["sh", "-c", "sleep 5"]
readinessProbe: # 健康检查: 停机时让它失败, 帮助尽快摘流量
httpGet: { path: /healthz, port: 8080 }
# k8s删Pod流程: 摘Service端点 + 执行preStop(sleep 5) + 发SIGTERM →
# 你的进程优雅关闭(最多30s) → 若40s还没退则SIGKILL。
# ★ 关键时序: 先摘流量(没有新请求了), 再停机(处理完存量), 宽限期 > 优雅关闭超时。
修复的核心,是"捕获停机信号,先摘流量、再处理完存量、清理后退出,并和编排宽限期对齐"。正解一:捕获 SIGTERM、优雅关闭——signal.Notify 监听 SIGTERM,收到后用 srv.Shutdown(ctx)(停止接新请求、等存量处理完,带 30 秒超时),再关闭数据库连接池等资源、正常退出。正解二:配置编排系统——k8s 的 terminationGracePeriodSeconds 要大于优雅关闭超时+余量;preStop 先 sleep 几秒等 Service 端点摘除生效(避免"还在转发新请求就开始停机");readinessProbe 帮助尽快摘流量。关键时序是:先摘流量(没有新请求)、再停机(处理完存量)、宽限期 > 优雅关闭超时。归根结底:捕获 SIGTERM 后先摘流量、停收新请求、处理完存量(带超时)、清理资源再退出;并让编排的宽限期/preStop 与之对齐。
第三件事:服务生命周期与发布相关的其他常见坑
排查后我把服务停机/发布相关的其他常见坑也系统梳理了一遍。
服务生命周期 / 发布的其他常见坑
# 1. 不处理SIGTERM(本文): 停机掐断请求。→ 捕获SIGTERM优雅关闭。
# 2. 宽限期太短: terminationGracePeriod < 优雅关闭耗时, 还没处理完就被SIGKILL。→ 宽限期要够。
# 3. 没先摘流量就停机: 边停边还在收新请求。→ preStop sleep / readiness失败先摘流量。
# 4. 优雅关闭没设超时: 存量请求hang住导致进程永远不退。→ Shutdown要带超时, 该放弃就放弃。
# 5. 没处理在途的异步任务/消息: 只关了HTTP, 没等消费中的MQ消息/后台任务做完就退。
# 6. 资源没清理: 没关数据库连接、没刷日志缓冲、没注销服务注册。
# 7. 启动没做就绪检查: 新实例还没真正ready, LB就把流量打过来(发布另一端的对称问题)。
# 8. 有状态/长连接没处理: WebSocket/长轮询连接在停机时要通知客户端重连。
# 共同根源: 只关注服务"运行中"的逻辑, 忽视了"启动"和"停机"这两个生命周期阶段的正确处理;
# 而在持续部署下, 服务被频繁启停, 这两个阶段的健壮性直接决定"发布是否无损"。
# 核心: 把"优雅启动"和"优雅停机"当成服务的必备能力; 捕获SIGTERM、先摘流量、处理完存量(带超时)、
# 清理资源; 和编排系统的宽限期/preStop/探针配合; 让每次发布对用户无感、无损。
排查让我把发布相关的其他坑也梳理清了。一、不处理 SIGTERM(本文)。二、宽限期太短(还没处理完就被强杀)。三、没先摘流量就停机(边停边收新请求)。四、优雅关闭没设超时(存量 hang 住永不退)。五、没处理在途异步任务/消息。六、资源没清理。七、启动没做就绪检查(对称问题)。八、有状态/长连接没处理。它们的共同根源是:只关注服务"运行中"的逻辑,忽视了"启动"和"停机"这两个生命周期阶段;而在持续部署下服务被频繁启停,这两个阶段的健壮性直接决定"发布是否无损"。核心是:把"优雅启动"和"优雅停机"当成服务必备能力;捕获 SIGTERM、先摘流量、处理完存量(带超时)、清理资源;和编排系统的宽限期/preStop/探针配合;让每次发布对用户无感无损。下面这张图,是这次优雅停机缺失坑的成因与解法:
第四件事:常见信号速查表
这次踩坑后,我把和进程停机相关的常见信号整理成一张表。
| 信号 | 含义 | 能否捕获 |
|---|---|---|
| SIGTERM(15) | 请求终止(优雅退出) | 能(优雅停机捕获它) |
| SIGKILL(9) | 立即杀死 | 不能(强制, 最后手段) |
| SIGINT(2) | Ctrl+C 中断 | 能 |
| SIGHUP(1) | 挂起/重载配置 | 能(常用于热重载) |
| SIGQUIT(3) | 退出并dump | 能 |
| SIGSTOP(19) | 暂停进程 | 不能 |
这张表把停机相关的信号钉清了。核心是:进程的退出是通过"信号"来协调的——SIGTERM 是"礼貌的请求退出"(可捕获,你能拦下来做优雅停机)、SIGKILL 是"不容商量的强杀"(捕获不了,内核直接干掉);部署系统的标准做法,就是先发"礼貌的 SIGTERM"给你机会,再用"强硬的 SIGKILL"兜底。它给我的最大启发是:这套"先礼后兵"的信号机制,体现了一种优雅的协作式设计——系统先用一个"可被拒绝/可被处理"的温和方式(SIGTERM)请求你配合,给你自我了断的体面和时间;只有当你不配合或超时,才动用不可抗拒的强制手段(SIGKILL);这种"先协商、给宽限、再强制"的模式,在很多健壮系统里都能看到(如事务的提交/回滚、锁的等待/超时、连接的关闭挥手)。这让我对"如何设计退出/终止"有了体会:好的"终止"设计,不是"啪一下关掉",而是"给一个优雅退出的机会 + 一个兜底的强制保证"——既照顾了"正常情况下的体面收尾",又防住了"异常情况下的赖着不走";无论是写服务停机、还是设计任何"关闭/取消/超时"逻辑,这种"优雅优先、强制兜底"的思路都值得借鉴。理解信号的先礼后兵、用"优雅优先+强制兜底"设计终止逻辑——是这个坑带给我的认知。
第五件事:服务全生命周期的健壮性清单
这次让我意识到,服务的健壮性不只在"运行中",而在它的整个生命周期。我整理成表。
| 阶段 | 要做对的事 | 不做的后果 |
|---|---|---|
| 启动 | 就绪后才接流量(readiness) | 没ready就被打流量, 报错 |
| 运行 | 功能正确+容错 | 常规bug |
| 停机(本文) | 优雅停机, 处理完存量 | 掐断请求、丢单 |
| 异常崩溃 | 能被拉起+不丢数据 | 数据不一致 |
| 扩缩容 | 启停都平滑 | 抖动 |
| 配置变更 | 热重载或平滑重启 | 重启抖动 |
这张表道出了一个常被忽视的全局视角。核心是:一个真正健壮的服务,健壮性要覆盖它的整个生命周期——不只是"运行中功能对",还包括"启动时就绪了才接流量""停机时优雅收尾""崩溃后能恢复且不丢数据""扩缩容/配置变更时平滑";任何一个生命周期阶段没处理好,都会成为可用性的破口。它给我的深刻启发是:我们做服务时,注意力极度偏向"运行中"这个阶段(写功能、修 bug),却系统性地忽视了"启动、停机、崩溃、变更"这些"过渡时刻"——而恰恰是这些"状态切换的瞬间",最脆弱、最容易出问题(本文的停机、发布抖动都是);"运行稳"只是健壮的一部分,"切换稳(启停、升降、恢复都平滑)"才是更高阶、也更容易被遗漏的健壮性。这给了我一种更完整的健壮性视角:设计一个服务,要主动地为它的每一个生命周期阶段和状态切换都考虑健壮性——问自己"它启动时会怎样?停止时会怎样?崩了会怎样?升级时会怎样?",而不只是"它跑起来对不对";"全生命周期的健壮",才是面向生产、面向持续部署的服务该有的样子。关注服务全生命周期(尤其启停等过渡时刻)的健壮性——是这个优雅停机坑,带给我的更高视角的认知。
第六件事:上线一个服务前,我现在的停机检查习惯
现在每当我要上线/部署一个服务,我都会按这张图把停机这块过一遍:
这张图的精髓,是"上线前确认捕获信号、优雅关闭、先摘流量、宽限期够、资源清理"。依次确认捕获 SIGTERM、优雅关闭(停接新请求等存量带超时)、先摘流量再停机、宽限期 > 优雅关闭超时、在途任务和资源都清理,最后用一次滚动重启验证无 5xx。这套习惯,让我从"只管服务能起来"变成了"上线前先确认它能体面地停下来"——核心始终是:长期运行的服务必须能优雅停机,上线前把捕获信号、摘流量、处理存量、清理资源、宽限期对齐都确认好。
我立下的几条规矩
这场"优雅停机缺失、发布丢单"的事故,换来了我做服务时,刻进骨子里的几条铁律:
- 长期运行的服务必须捕获 SIGTERM 做优雅停机。这是发布无损的刚需。
- 收到停机信号先摘流量,再停机。preStop sleep + readiness 失败,别边停边收。
- 优雅关闭:停接新请求、处理完存量、带超时。存量 hang 住也要超时放弃。
- 编排宽限期要大于优雅关闭超时。否则还没处理完就被 SIGKILL。
- 清理所有资源。关连接池、刷缓冲、提交事务、等在途异步任务做完。
- SIGKILL 不可捕获。别指望它,所有体面收尾都靠在 SIGTERM 时做。
- 为服务的全生命周期考虑健壮性。启动、停机、崩溃、变更都要平滑。
写在最后
回头看,这场由"没处理 SIGTERM"引发的、每次发布都丢单的事故,真正教给我的,远不止"服务要做优雅停机"这一个技巧。它让我对"一个东西的'结束',和它的'开始'与'运行'一样,需要被认真地设计",有了一次刻骨的体会。我栽跟头,根源在于我对"服务"的认知里,有一个巨大的盲区——"它该怎么结束"。我把全部心思都放在"怎么让它启动、怎么让它正确地运行"上,潜意识里以为"停止"就是"啪一下关掉"那么简单,不需要任何设计。可这次让我明白:对一个"正在做着事(处理着请求、持有着连接、占着事务)"的服务来说,"结束"绝不是一个瞬间的、无代价的动作——它是一个需要"收尾"的过程:手头的活要干完、占的资源要还回去、该通知的要通知到;"怎么体面地结束",和"怎么正确地开始和运行"一样,是一件需要专门设计、不能想当然的事。这让我领悟到一个更普适的认知:在编程和系统设计里,"清理/收尾/终止"的逻辑,是和"初始化/执行"同等重要、却被严重低估的一半——对象有构造也要有析构、资源有申请也要有释放、连接有建立也要有关闭、事务有开始也要有提交/回滚、进程有启动也要有优雅停机;我们总是热衷于设计"怎么把事情做起来",却常常忽视"怎么把事情干净利落地收场"——而很多最隐蔽、最致命的 bug(资源泄漏、数据不一致、发布抖动),恰恰出在这个被忽视的"收尾"环节。这给了我一种"有始有终"的设计自觉:设计任何"有生命周期"的东西时,都要给它的"结束"以和"开始"同等的重视——每写一个"申请/打开/启动/开始",就立刻想它对应的"释放/关闭/停止/结束"该怎么做、做得干不干净;"善始善终",不只是一种态度,更是健壮系统的一个硬性要求。重视"结束/收尾"的设计、给生命周期的终点以应有的重视——这,是我用一次优雅停机缺失的事故,换来的、关于 DevOps、也关于如何设计一切有生命周期的系统的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写一个长期运行的服务时,在写完启动逻辑后,顺手补上那段捕获 SIGTERM、优雅关闭的代码,那我们那些在发布时丢掉的订单,就还算买了个教训。
—— 别看了 · 2026