paint-brush
掌握 Go 中的优雅关闭:Kubernetes 综合指南经过@gopher

掌握 Go 中的优雅关闭:Kubernetes 综合指南

经过 Alex6m2024/08/14
Read on Terminal Reader

太長; 讀書

本指南将深入探讨正常关闭的世界,特别关注它们在 Kubernetes 上运行的 Go 应用程序中的实现。
featured image - 掌握 Go 中的优雅关闭:Kubernetes 综合指南
Alex HackerNoon profile picture

您是否曾沮丧地拔掉电脑的电源线?虽然这似乎是一种快速解决方案,但它可能会导致数据丢失和系统不稳定。在软件世界中,存在一个类似的概念:硬关机。这种突然终止可能会像物理终止一样导致问题。幸运的是,有一种更好的方法:正常关机。


通过集成优雅关机,我们为服务提供提前通知。这使它能够完成正在进行的请求,可能将状态信息保存到磁盘,并最终避免关机期间数据损坏。


本指南将深入探讨正常关闭的世界,特别关注它们在 Kubernetes 上运行的Go 应用程序中的实现。

Unix 系统中的信号

在基于 Unix 的系统中实现正常关机的关键工具之一是信号的概念,简单来说,信号是一种将特定事物从另一个进程传达给一个进程的简单方法。通过了解信号的工作原理,我们可以利用它们在我们的应用程序中实施受控终止程序,确保关机过程平稳且数据安全。


有很多信号,你可以在这里找到它们,但我们只关注关闭信号:

  • SIGTERM - 发送给进程以请求其终止。最常用,我们稍后会重点介绍它。
  • SIGKILL—— “立即退出”,不可干扰。
  • SIGINT——中断信号(如Ctrl+C)
  • SIGQUIT——退出信号(例如Ctrl+D)


这些信号可以由用户(Ctrl+C / Ctrl+D)、另一个程序/进程或系统本身(内核/操作系统)发送,例如,操作系统发送SIGSEGV又称分段错误。


我们的豚鼠服务

为了在实际环境中探索优雅关闭的世界,让我们创建一个可以进行实验的简单服务。这个“实验鼠”服务将有一个端点,通过调用 Redis 的INCR命令来模拟一些实际工作(我们将添加一点延迟)。我们还将提供一个基本的 Kubernetes 配置来测试平台如何处理终止信号。


最终目标:确保我们的服务能够正常处理关闭,而不会丢失任何请求/数据。通过将并行发送的请求数与 Redis 中的最终计数器值进行比较,我们将能够验证我们的正常关闭实现是否成功。

我们不会详细介绍如何设置 Kubernetes 集群和 Redis,但您可以在我们的 Github 存储库中找到完整的设置


验证过程如下:

  1. 将 Redis 和 Go 应用程序部署到 Kubernetes。
  2. 使用vegeta发送 1000 个请求(40 秒内每秒 25 次)。
  3. 在 vegeta 运行时,通过更新图像标签初始化 Kubernetes滚动更新
  4. 连接到 Redis 来验证“计数器”,它应该是 1000。


让我们从基础的 Go HTTP 服务器开始。

硬关机/main.go

 package main import ( "net/http" "os" "time" "github.com/go-redis/redis" ) func main() { redisdb := redis.NewClient(&redis.Options{ Addr: os.Getenv("REDIS_ADDR"), }) server := http.Server{ Addr: ":8080", } http.HandleFunc("/incr", func(w http.ResponseWriter, r *http.Request) { go processRequest(redisdb) w.WriteHeader(http.StatusOK) }) server.ListenAndServe() } func processRequest(redisdb *redis.Client) { // simulate some business logic here time.Sleep(time.Second * 5) redisdb.Incr("counter") }

当我们使用此代码运行验证程序时,我们会发现一些请求失败,并且计数器小于 1000 (每次运行的数量可能会有所不同)。


这显然意味着我们在滚动更新期间丢失了一些数据。😢

在 Go 中处理信号

Go 提供了一个信号包,允许您处理 Unix 信号。需要注意的是,默认情况下,SIGINT 和 SIGTERM 信号会导致 Go 程序退出。为了让我们的 Go 应用程序不会突然退出,我们需要处理传入的信号。

有两种方法可以实现此目的。


使用渠道:

 c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGTERM)


使用上下文(目前首选的方法):

 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop()


当列出的信号之一到达时,当返回的stop()函数被调用时,或者当父上下文的 Done 通道关闭时(以先发生者为准), NotifyContext将返回标记为完成(其 Done 通道已关闭)的父上下文的副本。


我们当前的 HTTP 服务器实现存在一些问题:

  1. 我们有一个缓慢的 processRequest goroutine,并且由于我们不处理终止信号,程序会自动退出,这意味着所有正在运行的 goroutine 也会终止。
  2. 该程序不会关闭任何连接。


我们重写一下。


优雅关机/main.go

 package main // imports var wg sync.WaitGroup func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop() // redisdb, server http.HandleFunc("/incr", func(w http.ResponseWriter, r *http.Request) { wg.Add(1) go processRequest(redisdb) w.WriteHeader(http.StatusOK) }) // make it a goroutine go server.ListenAndServe() // listen for the interrupt signal <-ctx.Done() // stop the server if err := server.Shutdown(context.Background()); err != nil { log.Fatalf("could not shutdown: %v\n", err) } // wait for all goroutines to finish wg.Wait() // close redis connection redisdb.Close() os.Exit(0) } func processRequest(redisdb *redis.Client) { defer wg.Done() // simulate some business logic here time.Sleep(time.Second * 5) redisdb.Incr("counter") }


以下是更新摘要:

  • 添加了signal.NotifyContext来监听 SIGTERM 终止信号。
  • 引入了sync.WaitGroup来跟踪正在进行的请求(processRequest goroutines)。
  • 将服务器包装在 goroutine 中,并使用带有上下文的server.Shutdown来正常停止接受新连接。
  • 使用wg.Wait()来确保所有正在进行的请求(processRequest goroutines)在继续之前完成。
  • 资源清理:添加redisdb.Close()以便在退出之前正确关闭 Redis 连接。
  • 干净退出:使用os.Exit(0)指示成功终止。

现在,如果我们重复验证过程,我们将看到所有 1000 个请求都得到正确处理。🎉


Web 框架/HTTP 库

Echo、Gin、Fiber 等框架将为每个传入请求生成一个 goroutine,为其提供上下文,然后根据您决定的路由调用您的函数/处理程序。在我们的例子中,它将是为“/incr”路径提供给 HandleFunc 的匿名函数。


当你拦截SIGTERM信号并要求你的框架正常关闭时,会发生两件重要的事情(简单来说):

  • 您的框架停止接受传入请求
  • 它等待任何现有的传入请求完成(隐式等待 goroutines 结束)。


注意:一旦 Kubernetes 将您的 pod 标记为终止,它也会停止将来自负载均衡器的传入流量引导到您的 pod。

可选:关机超时

终止进程可能很复杂,尤其是涉及许多步骤(例如关闭连接)时。为确保进程顺利运行,您可以设置超时。此超时可充当安全网,如果进程花费的时间比预期的要长,则可以正常退出。


 shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() go func() { if err := server.Shutdown(shutdownCtx); err != nil { log.Fatalf("could not shutdown: %v\n", err) } }() select { case <-shutdownCtx.Done(): if shutdownCtx.Err() == context.DeadlineExceeded { log.Fatalln("timeout exceeded, forcing shutdown") } os.Exit(0) }

Kubernetes 终止生命周期

由于我们使用 Kubernetes 来部署服务,因此让我们深入了解它如何终止 Pod。一旦 Kubernetes 决定终止 Pod,将发生以下事件:

  1. Pod 被设置为“终止”状态并从所有服务的端点列表中删除。
  2. 如果定义了preStop Hook,则会执行。
  3. SIGTERM信号已发送到 pod。但是,现在我们的应用程序知道该做什么了!
  4. Kubernetes 等待宽限期( terminationGracePeriodSeconds ),默认为 30 秒。
  5. 向pod发送SIGKILL信号,移除pod。

如您所见,如果您有一个长时间运行的终止过程,则可能需要增加terminationGracePeriodSeconds设置,以便您的应用程序有足够的时间正常关闭。

结论

优雅关闭可保护数据完整性、保持无缝的用户体验并优化资源管理。凭借其丰富的标准库和对并发性的重视,Go 使开发人员能够轻松集成优雅关闭实践 - 这是在 Kubernetes 等容器化或编排环境中部署应用程序的必需品。

您可以在我们的 Github 存储库中找到 Go 代码和 Kubernetes 清单。

资源