我每次发布服务监控就报一批 5xx,我一直以为是发布时正常的网络抖动,最后发现是 Pod 被杀时根本没做优雅停机、正在处理的请求被硬生生掐断的深度复盘
这是一个让我对"进程的死法"刻骨铭心的故事。我的服务跑在 Kubernetes 上,用滚动更新(rolling update)来发布新版本。每次发布,监控上,总会冒出一小批 5xx 错误——一些用户的请求,会在发布的那几秒里,突然失败。一开始,我没太当回事,在我朴素的认知里,这就是"发布时正常的网络抖动"嘛,旧实例下线、新实例上线,中间有点小波动,在所难免;反正错误量不大,过几秒就恢复了,用户重试一下也就好了。
可这批 5xx,每次发布必现,且随着发布越来越频繁、流量越来越大,投诉也多了起来。我终于下决心去深究,扒了扒那些失败请求的日志和 Pod 的生命周期,真相才浮出水面:这根本不是什么"网络抖动",而是我的 Pod 在被杀死时,没有做"优雅停机(graceful shutdown)",导致那些正在处理中的请求,被硬生生地、从中间掐断了!具体来说:滚动更新时,Kubernetes 要下线一个旧 Pod,它会先给这个 Pod 里的进程,发一个 SIGTERM 信号(意思是"请你退出");可我的程序,压根没有处理这个 SIGTERM 信号——它收到信号后,要么立刻就退出了(那些正在处理、还没返回响应的请求,就被直接丢弃,客户端收到的就是连接被断、5xx),要么没反应,然后被 Kubernetes 在宽限期后,用 SIGKILL 强制杀死(同样,在处理中的请求,被暴力终止)。更要命的,是另一层时序问题:在 Pod 开始终止的那一刻,Kubernetes 把它从 Service 的流量列表(endpoints)里摘除,和它真正停止接收新流量之间,是有延迟的(因为 endpoints 的更新、kube-proxy 规则的同步,都需要时间传播);也就是说,在这个 Pod 已经在关闭、甚至进程已经退出之后的一小段时间里,还可能有新的请求,被路由到它身上——这些请求,自然也就失败了。我这才痛彻地明白:我一直精心地照料着进程的"生"(怎么启动、怎么初始化),却完全忽略了进程的"死"(怎么体面地、不伤及无辜地退出)。一个进程,在被要求关闭时,如果只是"啪"地一下立刻死掉,那它手头正在干的活(正在处理的请求),就全成了牺牲品。而"优雅停机",正是教进程怎么好好地、负责任地"死"——先停止接收新活、再把手头的活干完、然后才退出。我那批被当成"网络抖动"的 5xx,其实是一个个被我的进程"猝死"时,无情抛弃的、用户的请求。
故障现场:Pod 被杀,正在处理的请求被掐断
我把这个"无优雅停机"的现场,用过程和代码摊开给你看:
// ✗ 灾难: Pod 终止时, 进程没优雅停机, 请求被掐断
// Kubernetes 终止一个 Pod 的过程:
// 1. Pod 被标记为 Terminating, 同时从 Service 的 endpoints 里摘除。
// ⚠️ 但摘除是"异步传播"的, kube-proxy/Ingress 更新有延迟!
// → 这期间, 仍可能有新请求被路由到这个正在关闭的 Pod。
// 2. K8s 给容器的主进程发 SIGTERM 信号(请你退出)。
// 3. 等待 terminationGracePeriodSeconds(默认 30s)。
// 4. 若进程还没退出 → 发 SIGKILL 强制杀死(暴力终止, 不留情面)。
// 我的程序(没处理 SIGTERM)会怎样?
// - 收到 SIGTERM 后, 框架/运行时可能"立刻退出进程":
// → 此刻"正在处理、还没返回响应"的请求, 被直接丢弃!
// → 客户端收到: 连接重置 / 502 / 504 等 5xx。
// - 或进程对 SIGTERM 无响应 → 30s 后被 SIGKILL 强杀:
// → 同样, 处理中的请求被暴力终止。
// 两个问题叠加:
// A. 进程"猝死": 没把"正在处理的请求"处理完就退出 → 这些请求失败。
// B. 流量"晚摘除": Pod 关闭后短时间内, 仍有新请求被路由过来 → 失败。
// 我以为的"网络抖动", 其实是:
// 每次发布, 旧 Pod 被杀, 它手头那些没处理完的请求 + 误路由来的新请求,
// 被成批地掐断 → 成批的 5xx。
// 根因: 进程没做"优雅停机"——
// 收到 SIGTERM 后, 没有"停止接收新请求 + 处理完存量请求 + 再退出"。
看着这个终止过程,我才算真正理解了这批"网络抖动"的真相。问题的核心,是我的进程,在被要求关闭时,没有做"优雅停机"。要理解这一点,得先看清 Kubernetes 终止一个 Pod 的完整过程:第一步,Pod 被标记为 Terminating,同时被从 Service 的 endpoints 里摘除(但注意,这个摘除是异步传播的,kube-proxy、Ingress 等的规则更新都有延迟);第二步,K8s 给容器主进程发 SIGTERM 信号(请你退出);第三步,等待一个宽限期(terminationGracePeriodSeconds,默认 30 秒);第四步,如果进程还没退出,就发 SIGKILL 强制杀死。而我的程序(没处理 SIGTERM),会出两个问题:问题 A(进程"猝死")——收到 SIGTERM 后,运行时可能立刻就退出了进程,于是那些"正在处理、还没返回响应"的请求,被直接丢弃,客户端收到连接重置或 5xx;(或者进程对信号无响应,30 秒后被 SIGKILL 强杀,同样掐断处理中的请求)。问题 B(流量"晚摘除")——由于 endpoints 摘除的延迟,在这个 Pod 已经开始关闭、甚至进程已退出之后的一小段时间里,仍可能有新请求被路由过来,这些请求也失败了。这两个问题叠加,就完美解释了我那批"网络抖动":每次发布,旧 Pod 被杀,它手头那些没处理完的请求,加上因摘除延迟而被误路由过来的新请求,被成批地掐断,于是,监控上就出现了成批的 5xx。归根结底,我领悟到一个我一直忽视的道理:我一直精心照料着进程的"生"(怎么启动、怎么初始化、怎么预热),却完全忽略了进程的"死"——怎么体面地、不伤及无辜地退出。一个进程,在被要求关闭时,如果只是"啪"地一下立刻死掉,那它手头正在干的活(正在处理的请求),就全成了牺牲品。而优雅停机,正是教会进程怎么"好好地、负责任地死"——先停止接收新活,再把手头的活干完,然后才退出。我那批被当成"网络抖动"的 5xx,其实,是一个个被我的进程"猝死"时,无情抛弃的、用户的真实请求。
第一件事:搞懂进程终止流程和"优雅停机"
定位到根源,我必须把"Pod/进程的终止流程"和"什么是优雅停机",彻底搞清楚:
进程终止流程 & 优雅停机(graceful shutdown)
# 操作系统/K8s 怎么"请进程退出":
# - 先发 SIGTERM(信号 15): "请你优雅地退出"——进程可以捕获它, 做收尾。
# - 给一段宽限期(K8s: terminationGracePeriodSeconds, 默认 30s)。
# - 还不退 → 发 SIGKILL(信号 9): "立刻去死"——无法捕获, 强制杀死。
# → SIGTERM 是"温柔的请求", SIGKILL 是"强制的处决"。
# "优雅停机"是什么? 进程收到 SIGTERM 后, 应该:
# 1. 停止接收"新"请求(不再接活)。
# 2. 把"正在处理的"请求, 处理完、返回响应(干完手头的活)。
# 3. 释放资源(关连接、刷盘、注销注册中心...)。
# 4. 然后, 才退出。
# → 在宽限期内, 体面地把自己"收拾干净"再走, 不抛弃任何在途请求。
# 不优雅停机的后果(本文):
# - 收到 SIGTERM 立刻退 / 不理被 SIGKILL → 在途请求被掐断 → 5xx。
# K8s 里, 零停机滚动发布, 还要解决"流量摘除时序":
# - Pod 进入 Terminating 时, endpoints 摘除是"异步、有延迟"的。
# - 所以要用 preStop hook(如 sleep 几秒), 让进程"先等一会再开始关",
# 给流量摘除留出传播时间, 期间继续处理可能误路由来的请求。
# - 顺序应是: 标记下线 → (preStop 等待, 流量摘除生效) → SIGTERM 优雅停机。
# 核心: 进程不仅要"会启动", 更要"会优雅地关闭"。
# 优雅停机 + 正确的终止时序, 是滚动发布"零停机、不丢请求"的前提。
想清楚之后,我对"进程的关闭",有了全新的、郑重的认识。首先,要分清两个信号:操作系统/K8s 想让一个进程退出时,会先发 SIGTERM(信号 15)——这是一个"温柔的请求",意思是"请你优雅地退出",进程可以捕获它、做收尾工作;然后给一段宽限期;如果到期还不退,才发 SIGKILL(信号 9)——这是"强制的处决",无法被捕获,进程会被直接杀死。而所谓"优雅停机",就是指进程在收到 SIGTERM 后,应该做的一套体面的收尾:第一,停止接收"新"请求(不再接活);第二,把"正在处理的"请求,处理完、返回响应(干完手头的活);第三,释放资源(关闭连接、刷盘、从注册中心注销);第四,然后,才退出。也就是说,在宽限期内,体面地把自己收拾干净再走,不抛弃任何一个在途的请求。而我那批 5xx,正是因为没做这套收尾——收到 SIGTERM 就立刻退、或不理被 SIGKILL,在途请求自然被掐断。此外,我还搞懂了 K8s 里要做到"零停机"的另一个关键——流量摘除的时序:Pod 进入 Terminating 时,endpoints 的摘除是异步、有延迟的;所以要用 preStop 钩子(比如先 sleep 几秒),让进程"先等一会儿再开始关",给流量摘除留出传播的时间,这期间继续处理那些可能被误路由过来的请求;正确的顺序应该是:标记下线 →(preStop 等待,让流量摘除生效)→ SIGTERM 优雅停机。由此,我得出了那个本该早就铭记的结论:一个进程,不仅要"会启动",更要"会优雅地关闭"。优雅停机,加上正确的终止时序,正是滚动发布能做到"零停机、不丢请求"的前提——这,是我用一批次次被当成"网络抖动"的 5xx,补上的、关于进程生命周期的、最重要的一课。
第二件事:正解——处理 SIGTERM,做优雅停机
搞懂了根因——"进程没处理 SIGTERM、在途请求被掐断"——正解就清晰了:让进程捕获 SIGTERM 信号,收到后,执行优雅停机:先停止接收新请求,再等正在处理的请求处理完,然后才退出;并配合 K8s 的 preStop 钩子和合理的宽限期,解决流量摘除的时序问题。
// 正解1: 程序里捕获 SIGTERM, 做优雅停机(以 Go HTTP 服务为例)
func main() {
srv := &http.Server{Addr: ":8080", Handler: handler}
go srv.ListenAndServe()
// 监听 SIGTERM / SIGINT
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit // 阻塞, 直到收到信号
log.Println("收到关闭信号, 开始优雅停机...")
// 优雅停机: 给一个超时, 停止接收新请求 + 等存量请求处理完
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil { // ✓ Shutdown: 不再 accept 新连接,
log.Println("优雅停机超时:", err) // 等已有请求处理完, 再返回
}
log.Println("已优雅退出")
// (Java: Spring Boot 配 server.shutdown=graceful; Node: server.close() ...)
}
# 正解2: K8s 配置, 配合优雅停机解决"流量摘除时序"
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
terminationGracePeriodSeconds: 30 # 宽限期: 给优雅停机留够时间
containers:
- name: app
lifecycle:
preStop:
exec:
# ✓ preStop: 进程收到 SIGTERM 前, 先等几秒,
# 给 endpoints 摘除/流量规则同步留出传播时间,
# 期间继续处理可能误路由来的请求, 之后才开始关。
command: ["sh", "-c", "sleep 5"]
readinessProbe: # ✓ 就绪探针: 没就绪不给流量
httpGet: { path: /healthz, port: 8080 }
# 完整时序(零停机的关键):
# 1. Pod 进入 Terminating, K8s 从 endpoints 摘除它(异步生效)。
# 2. 执行 preStop: sleep 5s —— 等待摘除真正生效(此期间还能处理在途/误路由请求)。
# 3. preStop 结束后, K8s 发 SIGTERM 给进程。
# 4. 进程优雅停机: 停止接收新请求 + 处理完存量 + 退出。
# 5. (若超过 terminationGracePeriodSeconds 仍没退 → SIGKILL 兜底)
# → 全程没有请求被"硬掐断", 实现零停机发布。
# 核心: 程序捕获 SIGTERM 优雅停机 + K8s preStop 等待 + 足够的宽限期。
# 三者配合, 才能做到滚动发布"不丢一个请求"。
这套正解,核心是让进程"会好好地死",并配合 K8s 解决流量时序。正解1(程序捕获 SIGTERM 做优雅停机):在程序里,监听 SIGTERM 信号,收到后,调用框架提供的"优雅关闭"方法(如 Go 的 srv.Shutdown(ctx)、Spring Boot 的 server.shutdown=graceful、Node 的 server.close())——这些方法的行为,正是"不再接收新连接、等待已有请求处理完、然后才返回",并且通常会带一个超时(在宽限期内完成)。正解2(K8s 配置解决流量摘除时序):光程序优雅停机还不够,还要配 K8s——用 preStop 钩子,在进程收到 SIGTERM 之前,先 sleep 几秒,给 endpoints 摘除、流量规则同步,留出传播的时间(这期间进程还活着,能继续处理那些可能被误路由过来的请求);同时,设置足够的 terminationGracePeriodSeconds(给优雅停机留够时间),并配好 readinessProbe(就绪探针,没就绪就不给流量)。把这套组合起来,就是零停机发布的完整时序:Pod 进入 Terminating、被从 endpoints 摘除(异步)→ 执行 preStop 的 sleep、等待摘除真正生效(此间仍能处理在途/误路由请求)→ K8s 发 SIGTERM → 进程优雅停机(停新、处理完存量、退出)→(若超时未退,SIGKILL 兜底)。这样,全程没有任何请求被"硬掐断",就实现了真正的零停机发布。归根结底:程序捕获 SIGTERM 优雅停机 + K8s preStop 等待 + 足够的宽限期,三者配合,才能做到滚动发布时"不丢一个请求"。我那次的错误,正是只做了进程的启动、却没做它的优雅关闭,更没配 K8s 的终止时序——而正解,就是把这"善终"的一整套,补齐。
下面这张图,对比了"无优雅停机"和"优雅停机"两条路径:
这张图的对比很清楚:左边红色那条,进程不会优雅停机,收到 SIGTERM 立刻退或被强杀,正在处理的请求被掐断,加上流量摘除延迟、误路由的请求也失败,于是每次发布都报一批 5xx;右边绿色那条,进程捕获 SIGTERM 优雅停机、配合 preStop 先等流量摘除生效,再停止接新请求、处理完存量才退,全程不掐断任何请求、实现零停机发布。两条路的根本分野,在于你的进程,会不会"好好地死"。
第三件事:零停机发布,还要管好这些细节
填平了优雅停机这个核心,我系统排查了一遍:要真正做到"零停机发布",除了优雅停机,还有哪些细节要管好:
零停机滚动发布的完整要素(优雅停机只是其一):
# 1. 优雅停机(本文): 进程捕获 SIGTERM, 处理完在途请求再退。
# 2. preStop + 足够的宽限期: 解决"流量摘除"的时序延迟。
# terminationGracePeriodSeconds 要 > preStop 等待 + 最长请求处理时间。
# 3. readinessProbe(就绪探针): 新 Pod "真正准备好"了, 才接流量。
# - 没配就绪探针 → 新 Pod 刚起、还没初始化完, 流量就打过来 → 5xx。
# - 就绪探针通过了, 才加入 endpoints。
# 4. 滚动更新策略: maxUnavailable / maxSurge 控制好节奏。
# - maxUnavailable=0 + maxSurge>0: 先起新的、就绪了再下旧的, 容量不掉。
# 5. 客户端/上游也要会"重试 + 连接管理":
# - 长连接(keep-alive)在 Pod 下线时要能正确处理(连接被关后重连)。
# - 上游对"连接重置"做有限重试, 能消化掉极少数边界情况。
# 6. 别让请求处理时间 > 宽限期:
# - 如果有超长请求(如大文件、长任务), 宽限期要够长, 或这类任务异步化。
# 7. 数据库连接、消息消费等也要优雅关闭:
# - 优雅停机不只是 HTTP——还要停止消费 MQ、提交/回滚事务、归还连接。
# 核心: 零停机 = 优雅停机(进程会善终) + 流量时序正确(preStop/就绪探针)
# + 发布节奏可控(滚动策略) + 上游容错(重试)。缺一环都可能丢请求。
这一排查,让我对"零停机发布"有了体系化的认识。优雅停机,只是其中一环;要真正做到零停机,还需要管好一整套细节:preStop + 足够的宽限期(解决流量摘除的时序延迟,且宽限期要大于"preStop 等待 + 最长请求处理时间");readinessProbe 就绪探针(确保新 Pod 真正准备好了才接流量——没配就绪探针,新 Pod 刚起、还没初始化完,流量就打过来,照样 5xx);滚动更新策略(用 maxUnavailable/maxSurge 控制好节奏,比如 maxUnavailable=0 + maxSurge>0,先起新的、就绪了再下旧的,保证容量不掉);客户端/上游的重试与连接管理(长连接在 Pod 下线时要能正确重连,上游对"连接重置"做有限重试,消化掉极少数边界情况);别让请求处理时间超过宽限期(有超长请求时,宽限期要够长、或把这类任务异步化);以及数据库连接、消息消费等也要优雅关闭(优雅停机不只是 HTTP——还要停止消费 MQ、提交或回滚事务、归还连接)。归根结底:零停机 = 优雅停机(进程会善终)+ 流量时序正确(preStop / 就绪探针)+ 发布节奏可控(滚动策略)+ 上游容错(重试);这是一个系统工程,缺了任何一环,都可能在发布时丢请求。我那次,正是缺了"优雅停机"和"流量时序"这两环,才有了那批挥之不去的、被误认为"网络抖动"的 5xx。
第四件事:容器里"进程的善终",还有哪些坑
这次踩坑,把我引向了容器里"进程信号与退出"的一系列相关问题,我一并梳理清楚了:
容器里"进程信号与善终"的相关坑:
# 1. 优雅停机没做(本文): 进程不处理 SIGTERM, 在途请求被掐断。
# 2. PID 1 信号问题: 容器里主进程是 PID 1, 信号处理特殊!
# - 用 shell 包裹启动(sh -c "java ...") → 信号发给了 shell(PID 1),
# 没转发给真正的应用进程 → 应用收不到 SIGTERM → 优雅停机失效!
# - 正解: 用 exec 形式启动(让应用直接当 PID 1), 或用 init(tini)转发信号。
# 3. SIGKILL 兜底前没退完: terminationGracePeriodSeconds 太短,
# 优雅停机还没处理完, 就被 SIGKILL 强杀 → 还是丢请求。
# → 宽限期要 > preStop + 最长请求处理时间。
# 4. 后台任务/线程没等: 优雅停机只关了 HTTP, 但还有 MQ 消费、定时任务、
# 异步线程在跑 → 它们被强行中断, 可能丢数据/状态不一致。
# → 优雅停机要"全面": HTTP、MQ、定时任务、连接池, 都要有序关闭。
# 4'. 僵尸进程: PID 1 不回收子进程 → 僵尸进程堆积(需要 init 进程帮忙 reap)。
# 5. 启动同理要"优雅": readiness 没好就别接流量(对应关闭的"优雅")。
# 启动慢的应用, 要配 startupProbe, 别被存活探针误杀。
# 6. 日志要在退出前刷出去: 进程退出前, 把缓冲的日志 flush, 别丢了关键日志。
# 核心: 容器里, "进程怎么收到信号、怎么退出"是一门必修课。
# 尤其 PID 1 的信号转发——它能让你"明明写了优雅停机却还是不生效"。
# 要让信号"正确地"传到应用, 应用再"全面地"优雅关闭。
这一梳理,让我对容器里"进程的善终"有了全面的认识。除了优雅停机本身,还有一系列相关的坑:PID 1 信号问题(极隐蔽)——容器里主进程是 PID 1,信号处理很特殊:如果你用 shell 包裹启动(如 sh -c "java ..."),那 SIGTERM 会发给 shell(它才是 PID 1),而没有转发给真正的应用进程,导致应用收不到 SIGTERM、优雅停机彻底失效!正解是用 exec 形式启动(让应用直接当 PID 1)、或用 tini 这类 init 进程来转发信号。宽限期太短(优雅停机还没处理完就被 SIGKILL 强杀,宽限期要大于"preStop + 最长请求处理时间")。后台任务没等(优雅停机只关了 HTTP,但 MQ 消费、定时任务、异步线程还在跑,被强行中断会丢数据——优雅停机要"全面",HTTP、MQ、定时任务、连接池都要有序关闭)。僵尸进程(PID 1 不回收子进程会导致僵尸进程堆积,需要 init 进程帮忙 reap)。启动也要"优雅"(就绪探针没好就别接流量,启动慢的应用配 startupProbe 别被存活探针误杀)。日志要在退出前刷出去(进程退出前 flush 缓冲日志,别丢了关键的诊断信息)。归根结底:在容器里,"进程怎么收到信号、怎么退出",是一门必修课;尤其是 PID 1 的信号转发——它能让你陷入"明明写了优雅停机、却还是不生效"的困惑。核心是:让信号"正确地"传到应用,应用再"全面地"优雅关闭。把这些容器进程善终的坑,整理成一张表:
| 坑 | 现象 | 正解 |
|---|---|---|
| 不处理 SIGTERM | 在途请求被掐断 | 捕获信号,优雅停机 |
| shell 包裹启动(PID 1) | 应用收不到 SIGTERM | exec 启动 / tini 转发 |
| 宽限期太短 | 没退完就被强杀 | 宽限期 > preStop+最长请求 |
| 只关 HTTP | MQ/定时任务被中断丢数据 | 全面优雅关闭 |
| 流量摘除延迟 | 误路由请求失败 | preStop 等待 + 就绪探针 |
第五件事:管好进程的"生",更要管好它的"死"
这次踩坑,在认知层面给了我最大的纠偏——它让我明白,程序的"退出",和"启动"一样重要。我把这层反思,沉淀了下来:
认知纠偏: 程序的"死", 和"生"一样, 是要认真设计的
# 我的误解(错误的):
# 我花了大量精力, 让程序"启动好、跑得好", 却从没认真想过
# "它该怎么停下来"——我默认"停止就是直接退出, 有什么好设计的?"
# → 我只管了"生", 完全忽略了"死"。
# 真相: "优雅地停止", 是一个需要认真设计的、完整的环节
# - 程序运行时, 手头有"在途的活"(请求、事务、消息、连接)。
# - 直接"啪"地停掉, 这些活就全成了牺牲品(丢请求/丢数据/状态不一致)。
# - "优雅停机" = 在停止前, 负责任地把手头的活"善后"干净。
# 这是一个普遍被忽视的工程视角: 关注"生命周期"的完整性
# - 不只关注"正常路径"(启动、稳定运行), 也要关注"边界路径":
# 启动中、关闭中、异常中、扩缩容中、故障中...
# - 很多线上问题, 都发生在这些"被忽视的边界路径"上(如发布、缩容)。
# - 资源也一样: 申请了要释放、打开了要关闭、注册了要注销——有始有终。
# 正确的习惯:
# 1. 设计一个程序/组件时, 不仅想"它怎么工作", 也想"它怎么优雅地停止"。
# 2. 凡是"有状态、有在途任务"的, 都要设计优雅关闭(处理完再退)。
# 3. 关注"生命周期的边界"(发布、缩容、故障), 那里最容易出问题。
核心: 程序的"死", 和"生"一样重要, 都要认真设计。
只管启动、不管退出, 就会在每次"停止"时, 留下一地被掐断的请求。
这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是:我花了大量精力,让程序"启动好、跑得好",却从没认真想过"它该怎么停下来"——我默认"停止,就是直接退出,有什么好设计的?"。我,只管了"生",完全忽略了"死"。可真相是:"优雅地停止",是一个需要认真设计的、完整的环节——程序运行时,手头有大量"在途的活"(正在处理的请求、未提交的事务、正在消费的消息、打开的连接);如果直接"啪"地一下停掉,这些活,就全成了牺牲品(丢请求、丢数据、状态不一致);而"优雅停机",正是要在停止之前,负责任地,把手头这些活,善后干净。而这,其实是一个普遍被忽视的工程视角——关注"生命周期"的完整性:不能只关注"正常路径"(启动、稳定运行),更要关注那些"边界路径":启动中、关闭中、异常中、扩缩容中、故障中……很多线上问题,恰恰就发生在这些被忽视的边界路径上(就像我这次的发布、以及缩容)。资源也是同理:申请了要释放、打开了要关闭、注册了要注销——有始,有终。由此,我立下了几条习惯:第一,设计一个程序/组件时,不仅想"它怎么工作",也要想"它怎么优雅地停止";第二,凡是"有状态、有在途任务"的,都要设计优雅关闭(处理完再退);第三,格外关注"生命周期的边界"(发布、缩容、故障),那里最容易出问题。归根结底:程序的"死",和"生"一样重要,都要认真设计。只管启动、不管退出,就会在每一次"停止"时,留下一地被掐断的请求——而那,正是我曾经误以为是"网络抖动"的、真实的代价。把"只管生"和"生死都管"两种心态对比成一张表:
| 维度 | 只管启动(踩坑) | 生死都管(成熟) |
|---|---|---|
| 对停止 | 直接退出就行 | 优雅停机,认真设计 |
| 在途任务 | 停止时被掐断 | 处理完再退 |
| 关注路径 | 只关注正常运行 | 也关注关闭/扩缩容边界 |
| 资源 | 申请了不管释放 | 有始有终,注销关闭 |
| 发布时 | 一批 5xx | 零停机不丢请求 |
一套"零停机发布该配什么"的决策流程
把这次踩坑的全部教训,我浓缩成了一张"要做到零停机发布,该配齐哪些东西"的决策图,贴在了团队的部署规范里:
这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:要零停机发布,程序先捕获 SIGTERM 做优雅停机(停新请求 + 处理完存量 + 关 MQ/连接),并确保信号能传到应用(别用 shell 包裹、用 exec 或 tini);K8s 这边,配 preStop sleep 等流量摘除生效、宽限期要大于 preStop + 最长请求时间、配 readinessProbe 让新 Pod 就绪才接流量、用 滚动策略(maxUnavailable=0 先起新再下旧);最后,上游对连接重置做有限重试兜底。这条"程序善终 + K8s 时序 + 上游容错"的完整链路,现在是我们团队配置每一个零停机服务时的清单。
我立下的几条优雅停机与发布规矩
这次"发布报 5xx"的踩坑,让我把优雅停机和零停机发布的注意事项,认真地立成了几条规矩:
- 程序必须捕获 SIGTERM 做优雅停机。停止接收新请求、处理完在途请求、释放资源,再退出。
- 确保信号能传到应用进程。别用
sh -c包裹(SIGTERM 发给了 shell),用 exec 启动或 tini 转发。 - 配 preStop 等待流量摘除。
preStop: sleep 几秒,给 endpoints 摘除留传播时间,消化误路由请求。 - 宽限期要够。
terminationGracePeriodSeconds> preStop + 最长请求处理时间,别让优雅停机被强杀打断。 - 配 readinessProbe。新 Pod 真正就绪才接流量,别让没初始化完的实例收到请求。
- 优雅停机要全面。不只 HTTP,MQ 消费、定时任务、事务、连接池都要有序关闭。
- 管好进程的"死"。程序的退出和启动一样要认真设计;关注发布/缩容/故障等生命周期边界。
写在最后
这次"我每次发布都报一批 5xx、还一直以为是网络抖动、最后发现是没优雅停机"的经历,是我在 DevOps 路上,一次很典型、也很受用的成长。它教给我的,远不止"要做优雅停机"这一条具体的技术经验,更是一个被我长期忽视的工程视角——程序的"死",和"生"一样,是需要认真设计的。我那批被误认为"网络抖动"的 5xx,根源就在于,我把全部心思都放在了"怎么让程序启动好、跑得好"上,却从没想过"它该怎么体面地停下来";于是,每一次停止,它都"啪"地一下猝死,把手头正在处理的、用户的请求,无情地抛弃。
所以,当你设计、部署任何一个服务时,请别只盯着"正常路径"(怎么启动、怎么稳定运行),而要同样认真地,去对待那些"边界路径"——尤其是它"怎么停止":它收到关闭信号时,手头那些在途的请求、事务、消息,该怎么善后?发布、缩容、故障时,怎么不伤及无辜?就像优雅停机,你只要让程序学会"先停止接活、再干完手头的活、然后才走",并配好 K8s 的终止时序,就绝不会再让每一次发布,都留下一地被掐断的请求、和一批被误读为"网络抖动"的告警。从"只管启动"到"生死都管"、从只关注正常路径到守护好生命周期的每一个边界,是从一个"能把服务跑起来"的开发,走向一个"能让服务平稳发布、优雅退场"的工程师,必经的修炼。愿你的每一次发布,都丝般顺滑、不丢一个请求;也愿你我,在设计每一个程序时,都既教它如何漂亮地"生",也教它如何体面地"死"。共勉。
—— 别看了 · 2026