实践: 如何优雅关闭程序 | 青训营

295 阅读4分钟

不管是在何时, 程序都有着被或被迫关闭的可能. 实际环境中程序能够连续正常工作是可遇而不可求的事情, 而测试的时候肯定也会遇到经常性的关闭. 如果在程序关闭时, 尚有未能处理完的事情, 甚至正在对存储层进行写入, 那么重则影响数据一致性与完整性, 轻则干扰程序的调试.

暂且不论对于程序崩溃这样的复杂情况, 一个强健的程序应当至少在被手动关闭的时候可以完整地处理完正在进行的任务. 优雅关闭(graceful shutdown)的概念也由此而来.

要实现优雅关闭, 首先需要知道程序在关闭前有哪些任务还在进行且不能被突然中断. 现暂以Gin + GORM + MinIO + Redis这套框架为基准逐个分析.

  1. 首先自然是GORM. GORM作为跟数据库直接联系的框架, 自然要避免在写任务没有完成时被强制关闭. 这可以由阻止主程序被强制关闭来完成. 而数据库可能远在网络的另一端, 在GORM关闭时是否需要手动close它的连接呢? GORM的作者jinzhu的回复是否, 他甚至提到"Close is one of the most misused methods, most application don't need it, be sure you really need it." GORM的文档中并没有提到需要手动处理close, 它会自动管理这些连接. 有人问道程序崩溃了怎么办, jinzhu的回答非常令人安心: "it is not necessary to close when crash, and you can still Close DB connection in V2".
  2. 其次是MinIO. 同样, 在上传文件时还是不要突然终止为好. 不过MinIO上传文件是可以强制覆盖的, 并且是常用上传函数的默认行为, 即便突然被终止, 也还有修复余地. 它的API文档没有提供有关close连接的任何消息, 事实上, 它的客户端很有可能是静态的而已.
  3. 然后是Redis. Redis-go的文档同样没有给出有关close连接的内容. 它在底层自动维护连接池, 不需要手动管理.
  4. Gin虽然本身没有读写存储的任务, 但是作为最基本的调用其他处理服务的框架, 实现优雅关闭它是最关键的一环. Gin的官方提供了优雅关闭的示例, 通过优雅关闭Gin, 可以让被Gin调用的GORM等实现处理完任务再关闭. 但是, Gin用于自动支持TLS的autoTLS库给出了几乎不同的另一份优雅关闭示例. 所以该如何在同时支持TLS和非TLS时实现实现相似的, 优雅风格的优雅关闭呢?

首先来看非TLS的示例:

func main() {
	router := gin.Default()
	router.GET("/", func(c *gin.Context) {
		time.Sleep(5 * time.Second)
		c.String(http.StatusOK, "Welcome Gin Server")
	})

	srv := &http.Server{
		Addr:    ":8080",
		Handler: router,
	}

	go func() {
		// 服务连接
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("listen: %s\n", err)
		}
	}()

	// 等待中断信号以优雅地关闭服务器(设置 5 秒的超时时间)
	quit := make(chan os.Signal)
	signal.Notify(quit, os.Interrupt)
	<-quit
	log.Println("Shutdown Server ...")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		log.Fatal("Server Shutdown:", err)
	}
	log.Println("Server exiting")
}

可以看到它使用context处理os.Interrupt信号, 并通过channel阻塞主程序直到接收到该信号后调用go1.8后http库自带的shutdown()优雅关闭方法来进行优雅关闭.

再来看看autoTLS的官方示例:

func main() {
  // Create context that listens for the interrupt signal from the OS.
  ctx, stop := signal.NotifyContext(
    context.Background(),
    syscall.SIGINT,
    syscall.SIGTERM,
  )
  defer stop()

  r := gin.Default()

  // Ping handler
  r.GET("/ping", func(c *gin.Context) {
    c.String(http.StatusOK, "pong")
  })

  log.Fatal(autotls.RunWithContext(ctx, r, "example1.com", "example2.com"))
}

只建立了一个处理信号的context, 然后调用RunWithContext()来进行启动, 异常简单. 那么你不好奇RunWithContext()内含什么嘛?

func run(ctx context.Context, r http.Handler, domain ...string) error {
	var g errgroup.Group

	s1 := &http.Server{
		Addr:    ":http",
		Handler: http.HandlerFunc(redirect),
	}
	s2 := &http.Server{
		Handler: r,
	}

	g.Go(func() error {
		return s1.ListenAndServe()
	})
	g.Go(func() error {
		return s2.Serve(autocert.NewListener(domain...))
	})

	g.Go(func() error {
		if v := ctx.Value(ctxKey); v != nil {
			return nil
		}

		<-ctx.Done()

		var gShutdown errgroup.Group
		gShutdown.Go(func() error {
			return s1.Shutdown(context.Background())
		})
		gShutdown.Go(func() error {
			return s2.Shutdown(context.Background())
		})

		return gShutdown.Wait()
	})
	return g.Wait()
}

// Run support 1-line LetsEncrypt HTTPS servers with graceful shutdown
func RunWithContext(ctx context.Context, r http.Handler, domain ...string) error {
	return run(ctx, r, domain...)
}

if v := ctx.Value(ctxKey); v != nil只是为了同时处理掉非withcontext的run. 这个实现同样使用channel和go1.8后的http shutdown(), 只是用了signal.NotifyContext这一包装后的库来产生context并用context的channel阻塞只负责优雅关闭的协程.

那么, 就可以自己实现一个非TLS的RunWithContext()来优雅关闭了:

func RunWithContext(ctx context.Context, r http.Handler, addr string) (err error) {
	var g errgroup.Group

	srv := &http.Server{
		Addr:    addr,
		Handler: r,
	}

	g.Go(func() error {
		return srv.ListenAndServe()
	})

	g.Go(func() error {
		<-ctx.Done() // 阻塞等待终止信号
		return srv.Shutdown(context.Background())
	})

	return g.Wait()
}

这是自定义的用于非TLS优雅关闭的RunWithContext(), 使用包装后的易用的signal.NotifyContext包而非signal.Notify. 该函数的使用方法和autoTLS的官方示例一致. 共两个使用协程, 主程序等待两个协程都退出后才会退出. 一个协程用于服务, 另一个只用于关闭服务. 其他原理与autoTLS的优雅关闭实现一致.

当然这只是个简单的例子, 还可以添加通过Reset恢复os.Interrupt的默认行为以实现多次ctrl+c强制退出; 可以在shutdown()时传入含有timeout的context使其超时后实行强制关闭以更好地平衡优雅关机与保证关闭.