前言

需先了解系统信号和相应函数后,再阅读本文将会更丝滑顺畅

  • 信号是Linux或Unix类系统中,一种进程间通讯的方式
  • 当信号发送到某个进程时,操作系统会中断该进程的正常流程,并进入相应的信号处理函数执行操作,完成后再回到中断的地方继续执行
  • 如果目标进程先前注册了某个信号的处理程序(signal handler),则此处理程序会被调用,否则缺省的处理程序被调用

如果有一个go实现的http服务,就会遇到因代码变更或配置修改需重新启动服务的情景,以前使用其他语言实现服务时,从未注意过
无缝重启的问题,因为web服务器(如nginx等)实现无缝重启所以你无需关心,而现在使用Go写服务后,才发现无缝重启也需要自己
动手解决

实际上,无缝重启需要解决的问题有两个

  1. 无缝重启时,UNIX层面问题,如进程能不关闭正在监听的socket实现重启的机制
  2. 确保正在处理的请求可被正常处理完毕或超时退出

无需关闭socket的重启

  • 生成一个子进程并继承旧进程正在监听的socket
  • 子进程初始化后,开始接收socket的连接
  • 一旦子进程发送信号给父进程时,父进程立刻停止接收新连接并终止运行

生成一个子进程

在go库里,有很多种方式可生成子进程,但在解决本文的问题上,使用exec.Cammand是最适合的方式,因为返回的Cmd结构体有个ExtraFiles属性,
该属性指向由父进程继承来的且已打开的文件(除了stdin/err/out,标准输入/输出/错误外)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
file := netListener.File() //netListener[^1]是一个指针,指向正在监听http请求的net.Listener,File()返回一个fd的dup(2)[^2],dup(2)返回的是fd的副本fd2,fd2不含`FD_CLOEXEC`标志,该标志会导致子进程的fd2关闭
path := "/path/to/executable"//你要启动的服务的路径
args := []string{
"-graceful"}

cmd := exec.Command(path, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.ExtraFiles = []*os.File{file}

err := cmd.Start()
if err != nil {
log.Fatalf("gracefulRestart: Failed to launch, error: %v", err)
}

你也许会在命令行里把需继承的fd号码通过参数传入子进程,但是有ExtraFiles字段,就无需这样做了,ExtraFiles文档说「如果该字段为非nil,则下标i代表的fd是3+i」,也即从fd下标从3开始(因为不包含stdin/out/err)
所以子进程继承的fd永远都是3开始

最后 args切片包含 -graceful 选项,意味着你的程序需要有处理该选项的逻辑,用于分辨子进程需重用socket而不是创建新socket

子进程初始化

下面是程序启动的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server := &http.Server{Addr: "0.0.0.0:8888"}

var gracefulChild bool
var l net.Listever
var err error

flag.BoolVar(&gracefulChild, "graceful", false, "listen on fd open 3 (internal use only)")

if gracefulChild {
log.Print("main: Listening to existing file descriptor 3.")
f := os.NewFile(3, "")//监听fd=3
l, err = net.FileListener(f)
} else {
log.Print("main: Listening on a new file descriptor.")
l, err = net.Listen("tcp", server.Addr)
}

发送信号通知父进程终止运行

在子进程准备接收请求前,需要通知父进程停止接收请求并退出:

1
2
3
4
5
6
7
if gracefulChild {
parent := syscall.Getppid()
log.Printf("main: Killing parent pid: %v", parent)
syscall.Kill(parent, syscall.SIGTERM)
}

server.Serve(l)

还在父进程里处理的请求

使用sync.WaitGroup来追踪正在处理的请求,每接收新请求就需自增1,在完成一个请求时就自减1
Go的http标准包里没有hook方式以供在Accept()Close()时进行一些自定义处理,但这也正是接口发挥作用的地方(Accept和Close属于接口Listener)
(可参考JefR.Allen的这篇文章

1
2
3
4
5
type gracefulListener struct {
net.Listener
stop chan error
stopped bool
}

然后重写Accept方法

1
2
3
4
5
6
7
8
9
10
11
func (gl *gracefulListener) Accept() (c net.Conn, err error) {
c, err = gl.Listener.Accept()
if err != nil {
return
}

c = gracefulConn{Conn: c}

httpWg.Add(1)
return
}

有了自增,则还需要在连接处理完毕被关闭时的自减方法

1
2
3
4
5
6
7
8
type gracefulConn struct {
net.Conn
}

func (w gracefulConn) Close() error {
httpWg.Done()
return w.Conn.Close()
}

再写一个new方法

1
2
3
4
5
6
7
8
9
func newGracefulListener(l net.Listener) (gl *gracefulListener) {
gl = &gracefulListener{Listener: l, stop: make(chan error)}
go func() {//这里需要一个goroutine是因为上面Accept()方法会在`c, err = gl.Listener.Accept()`地方阻塞住,没法进行关闭操作
_ = <-gl.stop//阻塞
gl.stopped = true
gl.stop <- gl.Listener.Close()//通过Close解除`gl.Listener.Accept()`的阻塞
}()
return
}

重写Close

1
2
3
4
5
6
7
func (gl *gracefulListener) Close() error {
if gl.stopped {
return syscall.EINVAL
}
gl.stop <- nil//发送停止信号到channel,上面的goroutine就会继续运行
return <-gl.stop//等待goroutine处理
}

还有一个获取fd的方法

1
2
3
4
5
func (gl *gracefulListener) File() *os.File {
tl := gl.Listener.(*net.TCPListener)
fl, _ := tl.File()
return fl
}

要用上面的无缝重启版的Listener,只需要修改server.Serve

1
2
netListener = newGracefulListener(l)
server.Serve(netListener)

为了避免挂起连接,使用以下方式创建server:

1
2
3
4
5
6
server := &http.Server{
Addr: "0.0.0.0:8888",
ReadTimeout: 10 * time.Second,//超时设置
WriteTimeout: 10 * time.Second,//超时设置
MaxHeaderBytes: 1 << 16
}

其他

Q&A

可以在

1
2
3
4
func (gl *gracefulListener) Close() error {
// some code
gl.Listener.Close()
}

Q:关闭listener吗,这样就不用在newGracefulListener里的goroutine了
A:这样做不管用,没法关闭listener,但是原因还不清楚呢,有朋友知道可以分享下吗?

参考文章:

Graceful Restart in Golang
Linux Signal及Golang中的信号处理