Go 的 graceful shutdown

4,844 阅读3分钟

为什么需要 graceful shutdonw

当你的程序被关闭或需要重启时,可能有些代码正在执行,如你的 Web 服务正在处理用户请求,如果直接关闭,正在处理的用户求就会失败,所以理想的处理方法是,当程序需要关闭和重启时,

  1. 告诉上一层(如负载均衡服务),服务马上要关闭了,不要再把新的请求路由给我了
  2. 把现在还没有结束的请求处理完,然后关闭和重启。

对于2, 通常会设定一个超时时间,防止某个请求处理的时间过长而导致程序无法正常关闭和关闭时间过长

基本流程

监听信号

通常的做法是监听信号量,代码如下:

package main

import (
	"fmt"
	"os"
	"os/signal"
	"syscall"
)

func main() {
	sigs := make(chan os.Signal, 1)
	done := make(chan bool)

	signal.Notify(sigs, syscall.SIGINT)

	go func() {
		sig := <-sigs
		fmt.Println("shutting down, caused by ", sig)
		close(done)
	}()

	<-done
	fmt.Println("Graceful shutdown.")
}

这里只监听了 interrupt 信号,当按 Ctrl-C 时,发送的就是这个信号, 也可以用通过 kill 命令来发送,如程序的PID 是 2345, 则 kill -s INT 2345

常用的信号量

1       HUP (hang up)
2       INT (interrupt)
3       QUIT (quit)
6       ABRT (abort)
9       KILL (non-catchable, non-ignorable kill)
14      ALRM (alarm clock)
15      TERM (software termination signal)

设置 timeout

在 go 里,超时处理通常通过 context 来实现

package main

import (
	"context"
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"
)

const stopTimeout = time.Second * 10

func stop(done context.CancelFunc) {
	time.Sleep(stopTimeout / 2)
	done()
}

func main() {
	sigs := make(chan os.Signal, 1)
	done := make(chan bool)

	signal.Notify(sigs, syscall.SIGINT)

	go func() {
		sig := <-sigs
		fmt.Println("Shutting down, caused by ", sig)

		ctx, cancel := context.WithTimeout(context.Background(), stopTimeout)
		defer cancel()
		go stop(cancel)

		select {
		case <-ctx.Done():
			close(done)
		}
	}()

	<-done
	fmt.Println("Service shutdown.")
}

http 的 graceful shutdown

go 的 http 包提供了 Shutdown 方法用来实现在 Graceful Shutdown。

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"time"
)
const stopTimeout = time.Second * 10

func hello(w http.ResponseWriter, r *http.Request)  {
	time.Sleep(stopTimeout / 2)
	w.WriteHeader(http.StatusOK)
}

func main() {
	var srv = &http.Server{Addr: ":8080"}
	sigs := make(chan os.Signal, 1)
	done := make(chan bool)
	signal.Notify(sigs, os.Interrupt)
	http.HandleFunc("/hello", hello)

	go func() {
		<-sigs
		ctx, cancel := context.WithTimeout(context.Background(), stopTimeout)
		defer cancel()
		if err := srv.Shutdown(ctx); err != nil {
			log.Println("Server shutdown with error: ", err)
		}
		close(done)
	}()

	if err := srv.ListenAndServe(); err != http.ErrServerClosed {
		log.Fatal("Http server start failed", err)
	}
	<-done
}

这里我们可以通过修改 hello 方法里的休眠时间来模拟比较耗时的请求。

测试方法:

启动服务后,在 Terminal 里通过 curl 来发请求,这个请求不会马上返回,因为我们在服务端设置了休眠。这个时候按 Ctrl-C 来关闭 Server.

  1. hello 里的休眠时间小于 stopTimeout 时,客户端会正常收到返回。
  2. hello 里的休眠时间大于 stopTimeout 时,客户端会无法收到正常的返回结果,会出现curl: (52) Empty reply from server 类似的错。

http.Server#Shutdown的逻辑是:先关闭所有的 listeners, 然后等待所有的连接(connection)都处理完,再关闭服务。如果传入的 context 超时,则会直接返回。

文档如下:

Shutdown gracefully shuts down the server without interrupting any active connections. Shutdown works by first closing all open listeners, then closing all idle connections, and then waiting indefinitely for connections to return to idle and then shut down. If the provided context expires before the shutdown is complete, Shutdown returns the context's error, otherwise it returns any error returned from closing the Server's underlying Listener(s).

docker

当我们要把程序部署到 docker 里时,要注意 docker stop 是把主进程发送的 SIGTERM 信号,所以我们的程序也要同时监听这个信号。

signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

另一点需要注意的时, docker stop 的默认等待时间是 10s,所以我们的程序的 stopTimeout 应该要小于这个 10s,或者通过 -t 指定 docker stop 的超时间。