我每次发布服务监控就报一批 5xx,我一直以为是发布时正常的网络抖动,最后发现是 Pod 被杀时根本没做优雅停机、正在处理的请求被硬生生掐断的深度复盘

我的服务跑在 K8s 上滚动发布,每次发布监控都冒一批 5xx,我一直当成"发布时正常的网络抖动"没在意。可它每次必现、投诉变多,深究才知根本不是抖动:K8s 下线旧 Pod 时发 SIGTERM,而我的程序压根没处理这个信号——要么立刻退出、把正在处理的请求直接丢弃,要么被 SIGKILL 强杀;再加上 endpoints 摘除有延迟,Pod 关闭后还有新请求被误路由过来。我只精心照料进程的"生",却完全忽略了它的"死"。这篇从 Pod 终止流程与优雅停机讲起,到捕获 SIGTERM 优雅停机 + preStop + 宽限期 + 就绪探针的零停机正解、PID 1 信号转发等容器坑,以及那句最戳心的——程序的死和生一样要认真设计,只管启动不管退出每次停止都留一地被掐断的请求。

我每次发布服务监控就报一批 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 摘除(异步)→ 执行 preStopsleep、等待摘除真正生效(此间仍能处理在途/误路由请求)→ 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"的踩坑,让我把优雅停机和零停机发布的注意事项,认真地立成了几条规矩:

  1. 程序必须捕获 SIGTERM 做优雅停机。停止接收新请求、处理完在途请求、释放资源,再退出。
  2. 确保信号能传到应用进程。别用 sh -c 包裹(SIGTERM 发给了 shell),用 exec 启动或 tini 转发。
  3. 配 preStop 等待流量摘除。preStop: sleep 几秒,给 endpoints 摘除留传播时间,消化误路由请求。
  4. 宽限期要够。terminationGracePeriodSeconds > preStop + 最长请求处理时间,别让优雅停机被强杀打断。
  5. 配 readinessProbe。新 Pod 真正就绪才接流量,别让没初始化完的实例收到请求。
  6. 优雅停机要全面。不只 HTTP,MQ 消费、定时任务、事务、连接池都要有序关闭。
  7. 管好进程的"死"。程序的退出和启动一样要认真设计;关注发布/缩容/故障等生命周期边界。

写在最后

这次"我每次发布都报一批 5xx、还一直以为是网络抖动、最后发现是没优雅停机"的经历,是我在 DevOps 路上,一次很典型、也很受用的成长。它教给我的,远不止"要做优雅停机"这一条具体的技术经验,更是一个被我长期忽视的工程视角——程序的"死",和"生"一样,是需要认真设计的。我那批被误认为"网络抖动"的 5xx,根源就在于,我把全部心思都放在了"怎么让程序启动好、跑得好"上,却从没想过"它该怎么体面地停下来";于是,每一次停止,它都"啪"地一下猝死,把手头正在处理的、用户的请求,无情地抛弃。

所以,当你设计、部署任何一个服务时,请别只盯着"正常路径"(怎么启动、怎么稳定运行),而要同样认真地,去对待那些"边界路径"——尤其是它"怎么停止":它收到关闭信号时,手头那些在途的请求、事务、消息,该怎么善后?发布、缩容、故障时,怎么不伤及无辜?就像优雅停机,你只要让程序学会"先停止接活、再干完手头的活、然后才走",并配好 K8s 的终止时序,就绝不会再让每一次发布,都留下一地被掐断的请求、和一批被误读为"网络抖动"的告警。从"只管启动"到"生死都管"、从只关注正常路径到守护好生命周期的每一个边界,是从一个"能把服务跑起来"的开发,走向一个"能让服务平稳发布、优雅退场"的工程师,必经的修炼。愿你的每一次发布,都丝般顺滑、不丢一个请求;也愿你我,在设计每一个程序时,都既教它如何漂亮地"生",也教它如何体面地"死"。共勉。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我调用外部接口图省事没设超时,平时一直好好的,直到对方一抽风卡住不返回,我这边的线程被一个个拖死、整个服务跟着雪崩的深度复盘

2026-6-1 23:07:52

技术教程

我让大模型返回 JSON,平时一直解析得好好的,直到某次它在 JSON 外面裹了一段解释文字,我的 JSON.parse 当场崩了、整个功能瘫痪的深度复盘

2026-6-1 23:20:51

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索