牛逼,K8s 的优雅退出才叫真优雅!

3,399 阅读6分钟

我在《Go 标准库 net/http 如何实现优雅退出?》 一文中讲解了 net/http 实现的 HTTP Server 如何优雅退出。

《深挖 Go 标准库 net/http 源码,来看看优雅退出到底是如何实现的?》一文讲解了优雅退出源码是如何实现的。

《Go Web 框架 Gin 如何实现优雅退出?》一文讲解了在 Go 语言最流行的 Web 框架 Gin 中如何进行优雅退出。

现在,我们已经掌握了 Go 中 HTTP Server 程序如何实现优雅退出,是时候看一看 K8s 中提供的一种更为牛逼🐮的优雅退出退出方案了😄。

这要从 K8s API Server 启动入口说起:

github.com/kubernetes/…

func main() {
	command := app.NewAPIServerCommand()
	code := cli.Run(command)
	os.Exit(code)
}

K8s API Server 启动入口代码非常简单,我们可以进入 app.NewAPIServerCommand() 查看更多细节:

github.com/kubernetes/…

// NewAPIServerCommand creates a *cobra.Command object with default parameters
func NewAPIServerCommand() *cobra.Command {
	...
	cmd := &cobra.Command{
		...
		RunE: func(cmd *cobra.Command, args []string) error {
			...
			return Run(cmd.Context(), completedOptions)
		},
		...
	}
	cmd.SetContext(genericapiserver.SetupSignalContext())

	...
	return cmd
}

NewAPIServerCommand 函数中,我们要关注的核心代码只有两行:

一行是 cmd.SetContext(genericapiserver.SetupSignalContext()),这是在为 cmd 对象设置 ctx 属性。

另一行是 RunE 属性中最后一行代码 Run(cmd.Context(), completedOptions),这里是启动程序,并使用了 cmd 对象的 ctx 属性。

很明显,K8s 使用了 Go 语言中流行的 Cobra 命令行框架作为程序的启动框架,Cobra 提供了如下两个方法可以设置和获取 Context

func (c *Command) Context() context.Context {
	return c.ctx
}

func (c *Command) SetContext(ctx context.Context) {
	c.ctx = ctx
}

NOTE: 如果你对 Cobra 不太熟悉,可以参考我的另一篇文章《Go 语言现代命令行框架 Cobra 详解》

这里的 ctx 就是串联起 K8s 实现优雅退出的核心对象。

首先通过 genericapiserver.SetupSignalContext() 获取到一个 context.Context 对象,根据函数名称可以猜测到它可能跟信号有关。

对于 Run(cmd.Context(), completedOptions) 方法的调用,由于嵌套层级比较深,逻辑比较复杂,我就不把整个代码调用链都贴出来讲了。总之,这个启动过程最终可以定位到 preparedGenericAPIServer.RunWithContext 这个方法的执行。在 RunWithContext 方法内部的第一行代码 stopCh := ctx.Done() 是重点,它拿到了一个控制程序退出时机的 channel(这跟我们前文讲解的优雅退出示例中 quit := make(chan os.Signal, 1) 变量作用相同),而这个 ctx 实际上就是 genericapiserver.SetupSignalContext() 的返回值,如果你感兴趣可以详细研究下这个 stopCh 的使用过程。

我们直接去分析 genericapiserver.SetupSignalContext() 的实现:

github.com/kubernetes/…

package server

import (
	"context"
	"os"
	"os/signal"
)

var onlyOneSignalHandler = make(chan struct{})
var shutdownHandler chan os.Signal

// SetupSignalHandler registered for SIGTERM and SIGINT. A stop channel is returned
// which is closed on one of these signals. If a second signal is caught, the program
// is terminated with exit code 1.
// Only one of SetupSignalContext and SetupSignalHandler should be called, and only can
// be called once.
func SetupSignalHandler() <-chan struct{} {
	return SetupSignalContext().Done()
}

// SetupSignalContext is same as SetupSignalHandler, but a context.Context is returned.
// Only one of SetupSignalContext and SetupSignalHandler should be called, and only can
// be called once.
func SetupSignalContext() context.Context {
	close(onlyOneSignalHandler) // panics when called twice

	shutdownHandler = make(chan os.Signal, 2)

	ctx, cancel := context.WithCancel(context.Background())
	signal.Notify(shutdownHandler, shutdownSignals...)
	go func() {
		<-shutdownHandler
		cancel()
		<-shutdownHandler
		os.Exit(1) // second signal. Exit directly.
	}()

	return ctx
}

// RequestShutdown emulates a received event that is considered as shutdown signal (SIGTERM/SIGINT)
// This returns whether a handler was notified
func RequestShutdown() bool {
	if shutdownHandler != nil {
		select {
		case shutdownHandler <- shutdownSignals[0]:
			return true
		default:
		}
	}

	return false
}

这里代码不多,但却相当精妙,可以一窥 K8s 设计之优雅。

我们从 SetupSignalContext 函数开始分析。

SetupSignalContext 函数第一行代码,通过调用 close(onlyOneSignalHandler) 来确保在整个程序中只调用一次 SetupSignalContext 函数,调用多次则直接 panic。这能强制调用方写出正确的代码,避免出现意料之外的情况。

shutdownHandler 是一个包含了两个缓冲区的 channel,而不像我们定义的 quit := make(chan os.Signal, 1) 那样只有一个缓冲区大小。

我们前文讲过,通过 signal.Notify(c chan<- os.Signal, sig ...os.Signal) 函数注册所关注的信号后,signal 包在给 c 发送信号时不会阻塞。因为我们要接收两次退出信号,所以 shutdownHandler 缓冲区大小为 2

这也是 SetupSignalContext 函数的精髓所在,它实现了收到一次 SIGINT/SIGTERM 信号,程序优雅退出,收到两次 SIGINT/SIGTERM 信号,程序强制退出的功能。

代码片段如下:

ctx, cancel := context.WithCancel(context.Background())
signal.Notify(shutdownHandler, shutdownSignals...)
go func() {
	<-shutdownHandler
	cancel()
	<-shutdownHandler
	os.Exit(1) // second signal. Exit directly.
}()

这里使用一个带有取消功能的 Context,当第一次收到信号时,就调用 cancel() 取消这个 ctx。而这个 ctx 会作为函数返回值返给调用方,调用方拿到它,就可以在需要的地方调用 <-ctx.Done() 来等待退出信号了。这就是 preparedGenericAPIServer.RunWithContext 方法中调用 stopCh := ctx.Done() 拿到 channel,然后等待 <-stopCh 退出信号的逻辑了。

这里用到的 shutdownSignals 变量,定义在 signal_posix.go 文件中:

github.com/kubernetes/…

package server

import (
	"os"
	"syscall"
)

var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM}

shutdownSignals 是一个保存了两个信号的切片对象。

os.Interrupt 实际上是一个变量,它的值等于 syscall.SIGINT

// The only signal values guaranteed to be present in the os package on all
// systems are os.Interrupt (send the process an interrupt) and os.Kill (force
// the process to exit). On Windows, sending os.Interrupt to a process with
// os.Process.Signal is not implemented; it will return an error instead of
// sending a signal.
var (
	Interrupt Signal = syscall.SIGINT
	Kill      Signal = syscall.SIGKILL
)

这里为实现优雅退出,监控了两个信号 SIGINTSIGTERM,并没有监控 SIGQUIT 信号。不过这已经足够用了,根据我的经验,绝大多数情况下我们都会使用 Ctrl + C 终止程序,而非使用 Ctrl + \

SetupSignalHandler 函数内部调用了 SetupSignalContext 函数,它唯一的作用就是直接返回给调用方 ctx.Done() 所返回的 channel,以此来方便调用方。

RequestShutdown 函数可以主动触发退出事件信号(SIGTERM/SIGINT),返回值表示是否触发成功。

现在将 K8s 优雅退出方案集成进我们的 net/http 优雅退出示例程序中:

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"time"

	genericapiserver "k8s.io/apiserver/pkg/server"
)

func main() {
	srv := &http.Server{
		Addr: ":8000",
	}

	http.HandleFunc("/sleep", func(w http.ResponseWriter, r *http.Request) {
		duration, err := time.ParseDuration(r.FormValue("duration"))
		if err != nil {
			http.Error(w, err.Error(), 400)
			return
		}

		time.Sleep(duration)
		_, _ = w.Write([]byte("Welcome HTTP Server"))
	})

	go func() {
		if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("HTTP server error: %v", err)
		}
		log.Println("Stopped serving new connections")
	}()

	// NOTE: 只需要替换这 3 行代码,Gin 版本同理
	// quit := make(chan os.Signal, 1)
	// signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	// <-quit

	// 可以直接丢弃,context.Context.Done() 返回的就是普通空结构体
	<-genericapiserver.SetupSignalHandler()

	log.Println("Shutdown Server...")

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// We received an SIGINT/SIGTERM signal, shut down.
	if err := srv.Shutdown(ctx); err != nil {
		// Error from closing listeners, or context timeout:
		log.Printf("HTTP server Shutdown: %v", err)
	}
	log.Println("HTTP server graceful shutdown completed")
}

我们只需要将如下 3 行代码:

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

替换成 K8s 提供的 SetupSignalHandler 函数调用即可:

<-genericapiserver.SetupSignalHandler()

其他代码都不用修改。

执行示例程序,按一次 Ctrl + C 测试优雅退出:

$ go build -o main main.go && ./main
^C2024/08/22 09:24:46 Shutdown Server...
2024/08/22 09:24:46 Stopped serving new connections
2024/08/22 09:24:49 HTTP server graceful shutdown completed
$ echo $?
0
$ curl "http://localhost:8000/sleep?duration=5s"
Welcome HTTP Server

执行示例程序,按两次 Ctrl + C 测试强制退出:

$ go build -o main main.go && ./main
^C2024/08/22 09:25:28 Shutdown Server...
2024/08/22 09:25:28 Stopped serving new connections
^C
$ echo $?                                       
1
$ curl "http://localhost:8000/sleep?duration=5s"
curl: (52) Empty reply from server

完美,K8s 为我们提供了优雅退出的新思路。这样在开发环境,为了方便调试,我们可以无需等待优雅退出,只要连续发送两次 SIGTERM/SIGINT 即可强制退出程序。在生产环境发送一次 SIGTERM/SIGINT 信号等待优雅退出。

使用 Gin 框架开发的 Web 程序也可以这样修改,你可以自行尝试。

本文示例源码我都放在了 GitHub 中,欢迎点击查看。

联系我