后端

在 Go 中响应 SIGTERM 的优雅停机与请求排空

当您的平台发送 `SIGTERM` 信号时,立即终止进程会丢弃正在处理的请求。以下是如何按顺序停止新流量、排空请求并进行清理的方法。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

一个在收到终止信号后立即退出的容器,会切断所有仍在处理的请求。客户端会看到连接重置或 5xx 错误,一个打开的数据库事务会在写入过程中回滚,一批缓冲的日志永远不会被刷写,并且进程持有的锁会一直保持,直到在其他地方超时。解决方案并不复杂,但它必须按特定的顺序发生:停止接受新工作,让正在处理的工作在截止期限内完成,然后释放资源。这篇文章将用 Go 语言逐步讲解该序列,内容包括信号处理、服务器排空和清理钩子,以及截止期限必须如何对齐,从而确保平台不会在处理到一半时将进程终止。

终止是常态,而非异常

第一个思维转变是,停止将关闭视为一种罕见的故障。在容器平台上,这是你的进程最常遇到的事情之一。自动伸缩器会在高负载时增加实例,并在负载下降时移除实例。滚动部署会逐个替换所有实例。为了进行维护,节点会被排空,其上的每个容器都会被要求退出。缩容至零的服务会在一段空闲时间后被终止。所有这些操作的最终结果都是向你的进程发送相同的消息:一个终止信号(通常是 SIGTERM),如果你没有自行退出,那么在一段宽限期后会跟一个强制的 SIGKILL 信号。

如果你只在机器崩溃时才考虑关闭问题,那么你对它的处理就会不够完善,因为崩溃是罕见的,你可以容忍偶尔丢失的请求。但部署并非罕见之事。如果每次部署都丢弃了被替换实例上正在处理的请求,那么每次部署都会向某些用户显示错误。对于一个每天部署数次、整天都在扩缩容的服务来说,“正确处理 SIGTERM”并非锦上添花。它决定了部署过程是无感知的,还是会成为一次小规模的服务中断。

默认情况下,许多程序对 SIGTERM 不做任何特殊处理。该信号的默认行为是立即终止进程,而这恰恰是你不想看到的。这种立即退出的具体代价如下:

  • 正在处理中的 HTTP 请求被切断。客户端会收到连接重置或不完整的响应,并且根据请求方法的不同,重试可能安全也可能不安全。
  • 未关闭的事务被中止。如果请求正处于一个多语句写入的中间过程,数据库会将其回滚,这至少保证了一致性,但用户的操作却在静默中失败了。
  • 缓冲的写入会丢失。任何你批量暂存在内存中以便稍后刷写的内​​容——日志行、指标、出站事件队列——都会随着进程一起消亡。
  • 持有的资源无法被干净地释放。分布式锁、租约、网络另一端连接池中的在用连接,所有这些现在都得依赖超时来回收,而不是被主动交还。

所有这些问题在本地测试中都不会出现,因为在本地,你通常是在服务器空闲时用 Ctrl-C 来停止它。它会出现在生产环境中,稀疏地分布在每次部署中,表现为一种难以归因的低概率背景错误,因为它与你自己的部署行为相关,而不是与流量相关。

关闭序列,按顺序

整个技术分为三个固定顺序的阶段。搞错顺序是最常见的错误,因此在展示任何代码之前,有必要清楚地说明这一点。

阶段 操作 为何处于此位置
1. 停止接收 使就绪检查失败,停止接受新连接 必须在等待前停止新请求,否则队列永远不会清空
2. 排空 在截止期限内,等待处理中的请求完成 已经接受的请求理应完成
3. 清理 刷新缓冲区,释放锁,关闭连接和连接池 只有当不再有任何东西使用这些资源时,这样做才是安全的
4. 放弃剩余工作 放弃任何超出截止期限的工作,但要记录下来 平台很快会发送 SIGKILL;记录下剩余工作比进程挂起要好

必须首先停止接收的原因是机制性的。如果你在等待处理中请求的同时,新请求仍在到达,那么处理中请求的集合永远不会缩减到零,你的排空操作每次都会运行直到截止期限。你停止流入,现有的请求逐一完成,计数会自行达到零。只有到那时,关闭数据库连接和刷新缓冲区才是安全的,因为在处理程序仍在运行时这样做,会釜底抽薪。

在 Go 中捕获信号

Go 使信号处理部分变得简洁。自从标准库添加了 signal.NotifyContext,你就可以将 SIGTERM 和 SIGINT 转换成一个 context,当任一信号到达时该 context 就会被取消,然后将整个关闭流程都挂载到该取消操作上。

func main() {
	ctx, stop := signal.NotifyContext(context.Background(),
		syscall.SIGTERM, syscall.SIGINT)
	defer stop()

	srv := &http.Server{Addr: ":8080", Handler: newRouter()}

	// Serve in a goroutine so main can wait on the signal.
	go func() {
		if err := srv.ListenAndServe(); err != nil &&
			!errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("listen: %v", err)
		}
	}()

	<-ctx.Done() // blocks until SIGTERM or SIGINT
	log.Println("shutdown signal received, draining")

	if err := drain(srv); err != nil {
		log.Printf("graceful shutdown incomplete: %v", err)
	}
	log.Println("shutdown complete")
}

重要的细节是,当你主动关闭 ListenAndServe 时,它会返回 http.ErrServerClosed,这是一个正常、预期的返回值,而不是一个会导致失败的错误。将其视为致命错误是一个常见的 bug,它会导致在正常关闭时记录下嘈杂的错误。

使用 Shutdown 排空在途请求

服务器自身的 Shutdown 方法就是这个排空操作。它会停止监听器,因此不再接受新的连接,然后阻塞,直到每个活动请求都已返回,或者直到你传递给它的上下文被取消。你的截止时间(deadline)就存在于那个上下文中。

func drain(srv *http.Server) error {
	// Stop routing to this instance first.
	health.SetNotReady()

	// Give in-flight requests a bounded window to finish.
	ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
	defer cancel()

	// Shutdown stops new connections, then waits for active ones.
	if err := srv.Shutdown(ctx); err != nil {
		// Deadline hit: some requests were still running. Give up on
		// them but record it, do not hang waiting for a SIGKILL.
		return fmt.Errorf("drain deadline exceeded: %w", err)
	}

	// Safe now: nothing is still serving. Release resources.
	return cleanup()
}

Shutdown 返回 nil 意味着排空(drain)已干净利落地完成,并且每个请求都已完成。它返回上下文的截止时间错误意味着时间已用尽,但仍有请求处于活动状态。这并非崩溃。这是你决定了继续等待比继续执行更糟糕,当平台无论如何都要发送 SIGKILL 时,这是一个正确的决定。记录下这一事实,以便能够看到截止时间命中的比率在上升,因为这通常意味着请求花费的时间超过了窗口允许的时间,需要关注窗口或请求本身。

一个注意事项:Shutdown 不会关闭被劫持的连接,例如 WebSockets 或其他长连接流,并且会等待它们。如果你有这些连接,你需要单独发出信号让它们关闭,例如通过取消它们所 select 的上下文,这样排空(drain)才能真正完成。

在排空前先使健康检查失败

请注意,drain 会在调用 Shutdown 之前调用 health.SetNotReady()。这是人们容易忽略的一点,如果没有这一步,排空操作就会产生竞态条件。

在你的实例前端,会有一个负载均衡器或服务网格,它会根据就绪或健康检查来决定将流量路由到何处。如果你直接进入 Shutdown 状态,会存在一个时间窗口,在此期间负载均衡器仍然认为该实例是健康的,并持续向其发送新请求,而这些请求现在会打到一个正在拒绝连接的服务器上。其结果正是你试图避免的请求丢失,只不过问题转移到了另一个地方。

修复方法是首先将你的就绪端点翻转为失败状态,然后等待足够长的时间,让负载均衡器注意到这一点并将你从其池中移除,只有在那之后才开始真正的排空操作。在实践中,这通常表现为根据健康检查间隔调整的一个短暂休眠。

func (h *Health) SetNotReady() { h.ready.Store(false) }

func (h *Health) ReadyHandler(w http.ResponseWriter, r *http.Request) {
	if h.ready.Load() {
		w.WriteHeader(http.StatusOK)
		return
	}
	w.WriteHeader(http.StatusServiceUnavailable)
}

SetNotReady 之后,短暂的停顿(几倍于健康检查间隔的时间)可以让负载均衡器在 Shutdown 关上大门之前停止路由。确切的停顿时间取决于平台的探测方式,但这个原则普遍适用:在你停止可用之前,先停止将自己宣告为可用。

清理钩子:刷新、释放、关闭

一旦 Shutdown 正常返回,就没有处理程序在运行,因此最终可以安全地销毁处理程序所依赖的东西。在这里,你可以按顺序刷新缓冲区、返还租约和锁,并关闭连接池。

func cleanup() error {
	var errs []error

	// Flush anything batched in memory before connections close.
	if err := logBuffer.Flush(context.Background()); err != nil {
		errs = append(errs, fmt.Errorf("flush logs: %w", err))
	}
	if err := metrics.Flush(context.Background()); err != nil {
		errs = append(errs, fmt.Errorf("flush metrics: %w", err))
	}

	// Release distributed locks and leases so peers can proceed now,
	// instead of waiting for the lease to time out.
	if err := locks.ReleaseAll(context.Background()); err != nil {
		errs = append(errs, fmt.Errorf("release locks: %w", err))
	}

	// Close pools last, after flush and release have used them.
	db.Close()
	cache.Close()

	return errors.Join(errs...)
}

在清理操作内部,顺序同样重要。先刷新,再关闭刷新操作所需的连接。先释放锁,再关闭与锁存储通信的客户端。最后关闭池。如果你先关闭数据库池,然后尝试刷新一个写入数据库的批次,那么刷新操作会失败,你就会丢失这个批次,而这正是你试图避免的数据丢失。

显式地释放锁这一点值得特别指出。如果你只依赖租约到期,那么一次干净的关闭仍然会使锁在整个租约期间被持有,在此期间没有对等节点可以获取它。在退出时交还锁,可以让下一个实例立即继续执行,而租约到期机制则继续作为一种安全网,以应对进程被直接杀死的非干净关闭情况。

将排空截止时间保持在平台的宽限期内

你的排空截止时间必须严格短于平台在发送 SIGTERM 和 SIGKILL 之间给予你的宽限期。这是将整个流程联系在一起的约束条件,而且很容易出错,因为相关的数值存在于两个不同的地方。

比如说,平台发送了 SIGTERM,并且如果你在 30 秒后仍未退出,它将发送 SIGKILL。你的总关闭时间,即就绪暂停时间、排空时间再加上清理时间,必须在 30 秒内完成,并留有余地。如果单单你的排空时间就设置为 30 秒,那么就绪暂停和清理过程就会将总时间推到宽限期之外,SIGKILL 会在清理过程中到达,而你正在刷写的缓冲区就会丢失。对于 30 秒的宽限期,一个安全的分配方案可能是:几秒钟的就绪暂停,大约 20 到 25 秒的排空时间,以及为清理保留的几秒钟。

有两点实用说明。首先,如果你平台的宽限期是可配置的,请明确地设置它,而不是相信默认值,因为默认值通常很短(10 秒很常见),而用 25 秒的排空时间去对抗 10 秒的宽限期,意味着 SIGKILL 总是会赢。其次,截止时间是一个上限,而不是一个目标。大多数关闭过程的排空时间都远低于此限制,因为大多数请求都很短。截止时间只对慢速请求的长尾部分产生影响,并且达到这个截止时间的情况应该足够罕见,以至于每次发生都值得记录一条日志。

幂等性、重试与排空成本

即使是正确的排空,最终也会丢弃一些东西。一个真正比整个宽限期还慢的请求,一个在负载均衡器作出反应前的就绪窗口期间死掉的连接,一个因进程卡住而导致的不干净的 SIGKILL。优雅关停将丢弃的集合缩小到几乎为零,但几乎为零不等于零,所以最后一层防线是让被丢弃的请求能够存活下来。

两个属性完成了大部分工作。让写入操作具有幂等性,这样客户端或上游在重试一个不确定的请求时,不会重复应用它。请求上的幂等键会与最近的操作进行核对,将重试的支付或重试的创建操作变成一个安全的空操作。并且,让客户端在连接重置时重试幂等请求,因为在滚动部署期间,重试通常会落在一个健康的实例上并成功。这些措施共同意味着,那些罕见的、从排空中溜走的请求会被重试并完成,而不是丢失。

这也是为什么你不应该追求完美的排空。为了捕获最后一个慢请求而推高截止时间是有实际成本的,而幂等性加重试覆盖这部分尾部请求的成本远低于延长截止时间。

排空不是没有成本的。现在,每次部署都需要等待每个实例上最慢的在途请求完成后,该实例才能退出,因此滚动部署会花费更长的时间,其上限取决于你的排空截止时间乘以部署形态。出于同样的原因,缩容也更慢,因为被移除的实例会一直逗留直到排空完成。如果你设置了一个宽裕的 25 秒截止时间,并且你的部署是按顺序替换实例的,那么增加的几分钟是实实在在的。

排空截止时间是调节旋钮,它是一个真正的权衡,而不是一个需要最大化的值。时间太短,你会切断那些本可以在一秒后完成的请求,这违背了初衷。时间太长,每次部署和每次缩容事件都会拖得很久,而且你大部分时间都在等待一个长尾,而这个长尾本可以由幂等性和重试来处理。选择一个能覆盖大部分请求延迟分布的截止时间,即正常请求的高百分位数,而不是绝对的最坏情况,然后让重试来处理超出部分。目标不是保证永远不丢弃任何请求。而是让关停变得平常,这样自动扩缩容器和部署流水线就可以整天移动实例,而这一切都不会影响到用户。