Go中的优雅关闭:实用模式

原文:victoriametrics.com/blog/go-gra...

任何应用程序中的正常关闭通常都满足三个最低条件:

  1. 通过停止来自 HTTP、发布/订阅系统等来源的新请求或消息来关闭入口点。但是,保持与第三方服务(如数据库或缓存)的传出连接处于活动状态。
  2. 等待所有正在进行的请求完成。如果某个请求耗时过长,则返回正常错误。
  3. 释放关键资源,例如数据库连接、文件锁或网络监听器。执行最终清理。

本文重点介绍 HTTP 服务器和容器化应用程序,但核心思想适用于所有类型的应用程序。

1、捕获信号

在处理优雅关闭之前,我们首先需要捕获终止信号。这些信号告诉我们的应用程序该退出并开始关闭过程了。

那么,什么是信号?

在类 Unix 系统中,信号是软件中断。它们通知进程发生了某些事情,需要采取行动。当信号发出时,操作系统会中断进程的正常流程,以传递通知。

以下是一些可能的行为:

  • **信号处理程序:**进程可以为特定信号注册一个处理程序(函数)。该函数在接收到该信号时运行。
  • **默认操作:**如果未注册任何处理程序,则进程将遵循该信号的默认行为。这可能意味着终止、停止、继续或忽略该进程。
  • **不可阻止的信号:**某些信号(例如 SIGKILL(信号编号 9))无法被捕获或忽略。它们可能会终止进程。

当你的 Go 应用程序启动时,甚至在你的主函数运行之前,Go 运行时就会自动为许多信号(SIGTERM、SIGQUIT、SIGILL、SIGTRAP 等)注册信号处理程序。然而,对于正常关机来说,通常只有三个终止信号是重要的:

  • **SIGTERM(终止):**一种标准且礼貌的终止进程的方式。它不会强制进程停止。当 Kubernetes 希望你的应用程序退出,而不是强制终止它时,它会发送此信号。
  • **SIGINT(中断):**当用户想要从终端停止某个进程时发送,通常是按 Ctrl+C。
  • **SIGHUP(挂断):**最初用于终端断开连接时。现在,它通常用于向应用程序发出信号,要求其重新加载配置。

人们最关心的是 SIGTERM 和 SIGINT。SIGHUP 如今已很少用于关机,而更多地用于重新加载配置。您可以在"用于重新加载配置的 SIGHUP 信号"中找到更多相关信息。

默认情况下,当您的应用程序收到 SIGTERM、SIGINT 或 SIGHUP 时,Go 运行时将终止该应用程序。

剖析:Go 如何终止你的应用程序 当您的 Go 应用程序收到 SIGTERM 时,运行时首先使用内置处理程序捕获它。 它检查自定义处理程序是否已注册。如果没有,运行时会暂时禁用其自身的处理程序,并再次向应用程序发送相同的信号 (SIGTERM)。这一次,操作系统将使用默认行为来处理,即终止进程。

您可以通过使用 os/signal 包注册自己的信号处理程序来覆盖此功能。

go 复制代码
func main() {
  signalChan := make(chan os.Signal, 1)
  signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)

  // Setup work here

  <-signalChan

  fmt.Println("Received termination signal, shutting down...")
}

signal.Notify通知 Go 运行时将指定的信号传递到通道,而不是使用默认行为。这允许你手动处理它们,并防止应用程序自动终止。

容量为 1 的缓冲通道是实现可靠信号处理的理想选择。Go 内部使用带有 default 情况的 select 语句向该通道发送信号:

csharp 复制代码
select {
case c <- sig:
default:
}

这与接收通道通常使用的 select 不同。用于发送时:

  • 如果缓冲区有空间,则发送信号并继续执行代码。
  • 如果缓冲区已满,则信号被丢弃,并执行默认情况。如果你使用的是无缓冲通道,并且没有 Goroutine 主动接收信号,则信号将会丢失。

尽管它只能容纳一个信号,但这个缓冲通道有助于避免在您的应用程序仍在初始化且尚未监听时错过第一个信号。

你可以对同一个信号多次调用 Notify 。Go 会将该信号发送到所有已注册的通道。

当您多次按下 Ctrl+C 时,它不会自动终止应用程序。第一次按下 Ctrl+C 键会向前台进程发送一个 SIGINT 信号。再次按下该键通常会发送另一个 SIGINT 信号,而不是 SIGKILL 信号。大多数终端(例如 bash 或其他 Linux shell)不会自动升级信号。如果要强制停止,必须使用 kill -9 手动发送 SIGKILL 信号。

这对于本地开发来说并不理想,你可能希望第二次按下 Ctrl+C 来强制终止应用。你可以在收到第一个信号后立即使用 signal.Stop 来阻止应用监听后续信号:

go 复制代码
func main() {
  signalChan := make(chan os.Signal, 1)
  signal.Notify(signalChan, syscall.SIGINT)

  <-signalChan

  signal.Stop(signalChan)
  select {}
}

从 Go 1.16 开始,您可以使用 signal.NotifyContext 简化信号处理,它将信号处理与上下文取消联系起来:

css 复制代码
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

// Setup tasks here

<-ctx.Done()
stop()

您仍应在 ctx.Done() 之后调用 stop() 以允许第二个 Ctrl+C 强制终止应用程序。

2、超时意识

了解应用程序在收到终止信号后需要关闭多长时间非常重要。例如,在 Kubernetes 中,默认宽限期为 30 秒,除非使用 terminationGracePeriodSeconds 字段另行指定。在此时间段之后,Kubernetes 会发送 SIGKILL 信号强制停止应用程序。此信号无法被捕获或处理。

您的关闭逻辑必须在这段时间内完成,包括处理任何剩余的请求和释放资源。

假设默认值为 30 秒。最好预留大约 20% 的时间作为安全裕度,以避免在清理完成之前被终止。这意味着要在 25 秒内完成所有操作,以避免数据丢失或不一致。

3、停止接受新请求

使用 net/http 时,可以通过调用 http.Server.Shutdown 方法来处理优雅关闭。此方法会停止服务器接受新连接,并等待所有活动请求完成后再关闭空闲连接。

它的行为如下:

  • 如果现有连接上已有一个请求正在进行,服务器将允许该请求完成。之后,该连接将被标记为空闲并关闭。
  • 如果客户端在关闭期间尝试建立新连接,则会失败,因为服务器的监听器已关闭。这通常会导致"连接被拒绝"错误。

在容器化环境(以及许多其他具有外部负载均衡器的编排环境)中,请勿立即停止接受新请求。即使 Pod 被标记为终止,它仍可能在短时间内接收流量。

Kubernetes 内部组件(例如 kube-proxy)能够快速感知到 Pod 状态变为"Terminating"(终止)。然后,它们会优先将内部流量路由到"Ready,Serving"(就绪/服务)端点,而不是"Terminating,Serving"(终止/服务)端点。

然而,外部负载均衡器独立于 Kubernetes 运行。它通常使用自己的健康检查机制来确定哪些后端节点应该接收流量。此健康检查指示节点上是否有健康(就绪)且未终止的 Pod。但是,此检查需要一些时间来传播。

有两种方法可以解决这个问题:

  1. 使用 preStop 钩子休眠一段时间,以便外部负载均衡器有时间识别 pod 正在终止。
bash 复制代码
lifecycle:
preStop:
	exec:
	command: ["/bin/sh", "-c", "sleep 10"]

真正重要的是,preStop 钩子所花费的时间包含在terminationGracePeriodSeconds 内。

  1. 使就绪探测失败并在代码级别进入休眠状态。此方法不仅适用于 Kubernetes 环境,也适用于其他需要知道 Pod 是否已就绪的负载均衡器环境。

什么是就绪探测? 就绪探测通过配置的方法(如 HTTP 请求、TCP 连接或命令执行)定期检查容器的健康状况,确定容器何时准备好接受流量。如果探测失败,Kubernetes 将从服务的端点中删除该 pod,阻止其接收流量,直到它再次准备就绪。

为了避免在此短暂的窗口期内出现连接错误,正确的策略是先使就绪探测失败。这会告诉编排器,你的 Pod 不应再接收流量:

go 复制代码
var isShuttingDown atomic.Bool

func readinessHandler(w http.ResponseWriter, r *http.Request) {
    if isShuttingDown.Load() {
        w.WriteHeader(http.StatusServiceUnavailable)
        w.Write([]byte("shutting down"))
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
}

此模式也在测试镜像中用作代码示例。在其实现中,当应用程序准备关闭时,使用一个已关闭的通道来向就绪探测发出信号,使其返回 HTTP 503。

更新就绪探测以指示 pod 不再就绪后,请等待几秒钟以允许系统有时间传播更改。

确切的等待时间取决于您的就绪探测配置;在本文中,我们将使用 5 秒,并采用以下简单配置:

yaml 复制代码
readinessProbe:
  httpGet:
    path: /healthz
    port: 8080
  periodSeconds: 5

本指南仅介绍优雅关闭的基本概念。如何规划优雅关闭策略取决于您的应用程序的特性。

_4、_处理待处理的请求

现在我们正在正常关闭服务器,我们需要根据您的关闭预算选择一个超时时间:

css 复制代码
ctx, cancelFn := context.WithTimeout(context.Background(), timeout)
err := server.Shutdown(ctx)

server.Shutdown 函数仅在两种情况下返回:

  1. 所有活动连接均已关闭并且所有处理程序均已完成处理。
  2. 传递给 Shutdown(ctx) 的上下文在处理程序完成之前过期。在这种情况下,服务器放弃等待。

无论哪种情况,Shutdown 都只会在服务器完全停止处理请求后返回。这就是为什么你的处理程序必须快速且具有上下文感知能力。否则,在情况 2 中,它们可能会在中途被切断,这可能会导致部分写入、数据丢失、状态不一致、开放事务或数据损坏等问题。

一个常见的问题是,处理程序无法自动知道服务器何时关闭。

那么,我们如何通知处理程序服务器即将关闭呢?答案是使用 context。主要有两种方法可以做到这一点:

1. 使用上下文中间件注入取消逻辑

该中间件将每个请求与监听关闭信号的上下文包装在一起:

go 复制代码
func WithGracefulShutdown(next http.Handler, cancelCh <-chan struct{}) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := WithCancellation(r.Context(), cancelCh)
        defer cancel()

        r = r.WithContext(ctx)
        next.ServeHTTP(w, r)
    })
}

2. 使用BaseContext为所有连接提供全局上下文

这里,我们创建了一个带有自定义 BaseContext 的服务器,该服务器可以在关闭时取消。此上下文在所有传入请求之间共享:

scss 复制代码
ongoingCtx, cancelFn := context.WithCancel(context.Background())
server := &http.Server{
    Addr: ":8080",
    Handler: yourHandler,
    BaseContext: func(l net.Listener) context.Context {
        return ongoingCtx
    },
}

// After attempting graceful shutdown:
cancelFn()
time.Sleep(5 * time.Second) // optional delay to allow context propagation

在 HTTP 服务器中,可以自定义两种类型的上下文:BaseContext 和 ConnContext。对于优雅关闭,BaseContext 更合适。它允许您创建一个适用于整个服务器的具有取消功能的全局上下文,并且您可以取消它以向所有活动请求发出服务器正在关闭的信号。

如果你的函数不遵循上下文取消机制,所有这些关于优雅关闭的操作都将毫无用处。尽量避免使用 context.Background()、time.Sleep() 或任何其他忽略上下文的函数。

例如,time.Sleep(duration) 可以用这样的上下文感知版本替换:

go 复制代码
func Sleep(ctx context.Context, duration time.Duration) error {
    select {
    case <-time.After(duration):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

资源泄漏 在旧版本的 Go 中,time.After 可能会泄漏内存,直到计时器触发。此问题已在 Go 1.23 及更高版本中修复。如果您不确定使用的是哪个版本,可以考虑使用 time.NewTimer 以及 Stop 语句,并使用可选的 <-t.C 语句检查 Stop 是否返回 false。 参见:时间:停止要求 Timer/Ticker.Stop 进行提示 GC

虽然本文重点介绍 HTTP 服务器,但同样的概念也适用于第三方服务。例如,database/sql 包中有一个 DB.Close 方法。它会关闭数据库连接并阻止启动新的查询。它还会等待所有正在进行的查询完成后再完全关闭。

所有系统的正常关闭的核心原则都是相同的:停止接受新的请求或消息,并让现有操作在定义的宽限期内完成

有人可能会对 server.Close() 方法感到疑惑,该方法会立即关闭正在进行的连接,而无需等待请求完成。那么,在 server.Shutdown() 返回错误后,还可以使用该方法吗?

简而言之,答案是肯定的,但这取决于你的关闭策略。 Close 方法会强制关闭所有活动的监听器和连接:

  • 正在主动使用网络的处理程序在尝试读取或写入时将会收到错误。
  • 客户端将立即收到连接错误,例如 ECONNRESET('套接字挂断')
  • 但是,不与网络交互的长时间运行的处理程序可能会继续在后台运行。

这就是为什么使用上下文来传播关闭信号仍然是更可靠和更优雅的方法。

5、释放关键资源

一个常见的错误是,一旦收到终止信号就立即释放关键资源。此时,你的处理程序和正在进行的请求可能仍在使用这些资源。您应该延迟资源清理,直到关闭超时或所有请求都完成。

很多情况下,只需让进程退出即可。操作系统会自动回收资源。例如:

  • 当进程终止时,Go 分配的内存会自动释放。

  • 文件描述符被操作系统关闭。

  • 操作系统级资源(如进程句柄)被回收。

但是,在关闭期间仍需要进行显式清理的重要情况:

  • 数据库连接应正确关闭。如果仍有事务处于打开状态,则需要提交或回滚。如果没有正确关闭,数据库将不得不依赖连接超时。
  • 消息队列和代理通常需要彻底关闭。这可能涉及刷新消息、提交偏移量或向代理发出客户端退出的信号。否则,可能会出现重新平衡问题或消息丢失。
  • 外部服务可能无法立即检测到连接断开。手动关闭连接可以让这些系统比等待 TCP 超时更快地完成清理。

一个好的规则是按照初始化组件的相反顺序关闭它们。这尊重了组件之间的依赖关系。

Go 的 defer 语句使这变得更容易,因为最后一个延迟函数会首先执行:

css 复制代码
db := connectDB()
defer db.Close()

cache := connectCache()
defer cache.Close()

某些组件需要特殊处理。例如,如果您在内存中缓存数据,则可能需要在退出之前将数据写入磁盘。在这种情况下,请专门为该组件设计一个关闭例程,以便妥善处理清理工作。

总结

这是一个完整的优雅关闭机制示例。为了方便理解,它采用简洁明了的结构。您可以根据需要使用 errgroup、WaitGroup 或任何其他模式对其进行自定义以适合您的应用程序:

scss 复制代码
const (
	_shutdownPeriod      = 15 * time.Second
	_shutdownHardPeriod  = 3 * time.Second
	_readinessDrainDelay = 5 * time.Second
)

var isShuttingDown atomic.Bool

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

	// Readiness endpoint
	http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		if isShuttingDown.Load() {
			http.Error(w, "Shutting down", http.StatusServiceUnavailable)
			return
		}
		fmt.Fprintln(w, "OK")
	})

	// Sample business logic
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-time.After(2 * time.Second):
			fmt.Fprintln(w, "Hello, world!")
		case <-r.Context().Done():
			http.Error(w, "Request cancelled.", http.StatusRequestTimeout)
		}
	})

	// Ensure in-flight requests aren't cancelled immediately on SIGTERM
	ongoingCtx, stopOngoingGracefully := context.WithCancel(context.Background())
	server := &http.Server{
		Addr: ":8080",
		BaseContext: func(_ net.Listener) context.Context {
			return ongoingCtx
		},
	}

	go func() {
		log.Println("Server starting on :8080.")
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			panic(err)
		}
	}()

	// Wait for signal
	<-rootCtx.Done()
	stop()
	isShuttingDown.Store(true)
	log.Println("Received shutdown signal, shutting down.")

	// Give time for readiness check to propagate
	time.Sleep(_readinessDrainDelay)
	log.Println("Readiness check propagated, now waiting for ongoing requests to finish.")

	shutdownCtx, cancel := context.WithTimeout(context.Background(), _shutdownPeriod)
	defer cancel()
	err := server.Shutdown(shutdownCtx)
	stopOngoingGracefully()
	if err != nil {
		log.Println("Failed to wait for ongoing requests to finish, waiting for forced cancellation.")
		time.Sleep(_shutdownHardPeriod)
	}

	log.Println("Server shut down gracefully.")
}
相关推荐
用户6757049885024 小时前
Wire,一个神奇的Go依赖注入神器!
后端·go
一个热爱生活的普通人4 小时前
拒绝文档陷阱!用调试器啃下 Google ToolBox 数据库工具箱源码
后端·go
程序员爱钓鱼5 小时前
Go语言实战案例:编写一个简易聊天室服务端
后端·go·trae
程序员爱钓鱼5 小时前
Go语言实战案例:实现一个并发端口扫描器
后端·go·trae
DemonAvenger5 小时前
构建实时应用:WebSocket+Go实战
网络协议·架构·go
Joker-011113 小时前
深入 Go 底层原理(十二):map 的实现与哈希冲突
算法·go·哈希算法·map
岁忧15 小时前
(LeetCode 面试经典 150 题) 138. 随机链表的复制 (哈希表)
java·c++·leetcode·链表·面试·go
程序员爱钓鱼1 天前
Go语言实战案例:TCP服务器与客户端通信
google·go·trae
程序员爱钓鱼1 天前
Go语言实战案例:多协程并发下载网页内容
google·go·trae