Golang http.Server安全退出:容易被误用的Shutdown()方法

      ☕ 4 分钟
🏷️
  • #Golang
  • 我开了个新坑,错而知新这个栏目记录的是一些我犯了很久但又不自知,到头来焕然大悟的错误,用它们作为“认知的过程是螺旋的”这句话的论据简直再好不过了。

    用Go写个HTTP服务很容易,但是让运行中的服务安全退出就不是那么直接了。

    如果你对安全退出(graceful shutdown,也叫优雅退出)这个提法比较陌生,它是指HTTP服务在接到用户的退出指令后停止接受新请求,在处理和回复当前正在处理的这批请求后主动退出。区别于SIGKILLkill -9或者“强行停止”),安全退出可以最小化程序在滚动更新时的服务抖动。

    用户的退出指令一般是SIGTERM(k8s的实现)或SIGINT(常常对应bash的Ctrl + C)。

    监听信号

    使用标准库signal即可完成信号监听,小场面:

    1
    2
    3
    4
    5
    
    var waiter = make(chan os.Signal, 1) // 按文档指示,至少设置1的缓冲
    signal.Notify(waiter, syscall.SIGTERM, syscall.SIGINT)
    
    // 阻塞直到有指定信号传入
    <-waiter
    

    值得注意的是,在没有使用signal.Notify()时,Go默认有一套信号处理规则,比如 SIGHUP, SIGINTSIGTERM会让程序直接退出。

    停止HTTP服务

    调用运行中的Server实例的Shutdown()方法可以让服务安全退出:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    
    type GracefulServer struct {
    	Server           *http.Server
    	shutdownFinished chan struct{}
    }
    
    func (s *GracefulServer) ListenAndServe() (err error) {
    	if s.shutdownFinished == nil {
    		s.shutdownFinished = make(chan struct{})
    	}
    
    	err = s.Server.ListenAndServe()
    	if err == http.ErrServerClosed {
    		// expected error after calling Server.Shutdown().
    		err = nil
    	} else if err != nil {
    		err = fmt.Errorf("unexpected error from ListenAndServe: %w", err)
    		return
    	}
    
    	log.Println("waiting for shutdown finishing...")
    	<-s.shutdownFinished
    	log.Println("shutdown finished")
    
    	return
    }
    
    func (s *GracefulServer) WaitForExitingSignal(timeout time.Duration) {
    	var waiter = make(chan os.Signal, 1) // buffered channel
    	signal.Notify(waiter, syscall.SIGTERM, syscall.SIGINT)
    
    	// blocks here until there's a signal
    	<-waiter
    
    	ctx, cancel := context.WithTimeout(context.Background(), timeout)
    	defer cancel()
    	err := s.Server.Shutdown(ctx)
    	if err != nil {
    		log.Println("shutting down: " + err.Error())
    	} else {
    		log.Println("shutdown processed successfully")
    		close(s.shutdownFinished)
    	}
    }
    

    GracefulServer这么个用法:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    
    func main() {
    	flag.Parse()
    
    	var err error
    	defer func() {
    		if err != nil {
    			log.Println("exited with error: " + err.Error())
    		}
    	}()
    
    	// 各种各样的初始化和依赖注入...
    	//
    	// defer tearDown()
    	// defer beforeClose() // 注册各种各样的主程序退出时的清理工作
    	
    	server := &GracefulServer{
    		Server: &http.Server{
    			Addr:    fmt.Sprintf(":%d", port),
    			Handler: myAwesomeHandler,
    		},
    	}
    
    	go server.WaitForExitingSignal(10 * time.Second)
    
    	log.Printf("listening on port %d...", port)
    	err = server.ListenAndServe()
    	if err != nil {
    		err = fmt.Errorf("unexpected error from ListenAndServe: %w", err)
    	}
    	log.Println("main goroutine exited.")
    }
    

    有读者这时可能会问了,为什么要封装一层Server.ListenAndServe()呢,加上shutdownFinished这个channel的意义在哪里?别急,Server.Shutdown()文档里有一句

    When Shutdown is called, Serve, ListenAndServe, and ListenAndServeTLS immediately return ErrServerClosed. Make sure the program doesn’t exit and waits instead for Shutdown to return.

    大意说Shutdown()在调用后,Server.ListenAndServe()立马返回,如果我们把ListenAndServe()放在主函数main()里,主函数很快就会退出。在Go中,不论此时其他goroutine是什么状态,主函数退出会让整个程序退出,那我们就没法安全地确保Server.Shutdown()执行完毕了。于是,shutdownFinished被放在这里提供保护。

    有经验的读者这时也许又会问了,那么为什么不把Server.ListenAndServe()的调用写到goroutine里,信号监听和Server.Shutdown()写到主函数main()里?就像这个例子里写的这样,还不用再封装一层Server,岂不美哉?

    这里就见仁见智了,ListenAndServe()在goroutine中的话,错误处理大概率是log.Fatal(err)这样的操作,如果服务并不是主动退出的(比如启动时立马遇到端口占用的错误),主函数main()中的defer是不会执行的。我这里用了一些额外的复杂度让安全退出的逻辑更圆满了一些。

    如果你感兴趣,我在Github上放了一份完整的实现,你可以编译后自己请求再同时退出体验一下。

    微妙的API

    事实上,使用shutdownFinished确保Server.Shutdown()执行完毕这个操作是我最近才意识到的。大概有两年的时间,我一直都忽略了Server.ListenAndServe()会在Server.Shutdown()开始执行后立马返回这件事,一直使用着错误的实现。而且由于一些机缘巧合,如果在退出时没有正在处理的HTTP请求,这个错误的实现极大概率可以正确地安全退出。_(:зゝ∠)_

    就我个人来看,Server.Shutdown()是一个容易误用的API.

    Server.Shutdown()在2013年就开始讨论,最后在2016年的Go 1.8引入。而上面我们提到的文档里说明Server.ListenAndServe()会在Server.Shutdown()开始执行后立马返回的意外特性在半年之后专门加入到了文档中,这么说,也许我不是这个微妙而且容易不小心误用的API的唯一受害者。┑( ̄Д  ̄)┍

    设计和增加对外的API真是一件困难的事情。最近Go Team在讨论怎么让标准库支持泛型,我衷心祝福他们好运。

    分享

    nanmu42
    作者
    nanmu42
    用心构建美好事物。

    目录