概述

在 Web 或者网络应用中,时不时会有热重启(加载)的需求,例如 Nginx 在不断开连接的情况下重新加载新配置,微服务在不断业务的情况下完成热升级,这些都是热重启的一些应用,本文就将聊一下 Go 语言中可以如何实现热重启。

原理

其实热加载抽象一下,就是如何实现在不关闭 Socket 的情况下,运行新的代码,那么可以理解成这么几个步骤:

看上去不是很难,那么下面就对每一个步骤进行介绍在 Go 中可以怎么实现。

fork 进程

在 Go 的标准库里面,不止一种用于 fork 进程的方法,我这里使用的是 exec.Command。因为参数 Cmd 的结构里面又一个 ExtraFiles 的成员,可以用于给新进程继承父进程中打开的文件。代码示例如下:

  1. [[email protected].io]# cat fork.go
  2. file := netListener.File() // this returns a Dup()
  3. path := "/path/to/executable"
  4. args := []string{
  5. "-graceful"}
  6. cmd := exec.Command(path, args...)
  7. cmd.Stdout = os.Stdout
  8. cmd.Stderr = os.Stderr
  9. cmd.ExtraFiles = []*os.File{file}
  10. err := cmd.Start()
  11. if err != nil {
  12. log.Fatalf("gracefulRestart: Failed to launch, error: %v", err)
  13. }

这里的 netListener 是用于监听 HTTP 请求的 Listener,然后 File() 方法返回的是 dup 的文件描述符,但是,因为这个文件描述符没有设置 FD_CLOEXEC 标志位,所以,如果直接这样传给子进程的话,会导致在子进程中它是关闭的。

但是,好在我们有更好的办法,继续看代码,你会发现下面有一个 ExtraFiles 参数,根据文档描述,如果这个参数是非空的,那么实例 i 的文件描述符会变成 3+i,这也就意味着在子进程中,这段代码继承来的文件描述符始终是 3。

最后一个特别的地方就是我这里传了一个 -graceful 参数,因为这是开启的子进程,总要让进程区分一下自己是子进程还是父进程吧,因为父进程需要自己开启监听;而子进程只需要从父进程继承就可以了。

子进程初始化

接着就进入子进程的世界了,这里直接来看下代码:

  1. [[email protected].io]# cat client.go
  2. server := &http.Server{Addr: "0.0.0.0:8888"}
  3. var gracefulChild bool
  4. var l net.Listever
  5. var err error
  6. flag.BoolVar(&gracefulChild, "graceful", false, "listen on fd open 3 (internal use only)")
  7. if gracefulChild {
  8. log.Print("main: Listening to existing file descriptor 3.")
  9. f := os.NewFile(3, "")
  10. l, err = net.FileListener(f)
  11. } else {
  12. log.Print("main: Listening on a new file descriptor.")
  13. l, err = net.Listen("tcp", server.Addr)
  14. }

这里就做了区分,如果是父进程,那么自己开一个监听;如果是子进程,那么请直接使用父进程的监听文件描述符。

通知父进程退出

下一步就要通知父进程退出了,这里的代码还是接着上一段代码:

  1. [[email protected].io]# cat server.go
  2. if gracefulChild {
  3. parent := syscall.Getppid()
  4. log.Printf("main: Killing parent pid: %v", parent)
  5. syscall.Kill(parent, syscall.SIGTERM)
  6. }
  7. server.Serve(l)

这里是给父进程发送了一个 SIGTERM 的信号,告诉父进程你可以退下了,后面的事情就交给我吧。

连接管理

这里有个很难的话题就是连接要怎么管理,例如父进程准备退出的时候,现有的连接要怎么处理?要想处理好连接,那么就需要对连接的状态进行更多的管理,这里先定义一个自己的连接结构,并且重写它的 Accept 方法:

  1. [[email protected].io]# cat listern.go
  2. type gracefulListener struct {
  3. net.Listener
  4. stop chan error
  5. stopped bool
  6. }
  7. func (gl *gracefulListener) Accept() (c net.Conn, err error) {
  8. c, err = gl.Listener.Accept()
  9. if err != nil {
  10. return
  11. }
  12. c = gracefulConn{Conn: c}
  13. httpWg.Add(1)
  14. return
  15. }
  16. func newGracefulListener(l net.Listener) (gl *gracefulListener) {
  17. gl = &gracefulListener{Listener: l, stop: make(chan error)}
  18. go func() {
  19. _ = <-gl.stop
  20. gl.stopped = true
  21. gl.stop <- gl.Listener.Close()
  22. }()
  23. return
  24. }

这里还有一个自己封装的结构体,gracefulConn,也顺便列出来:

  1. [[email protected].io]# cat connection.go
  2. type gracefulConn struct {
  3. net.Conn
  4. }
  5. func (w gracefulConn) Close() error {
  6. httpWg.Done()
  7. return w.Conn.Close()
  8. }

然后对应的安全退出的是实现也就很简单了:

  1. [[email protected].io]# cat listener.go
  2. func (gl *gracefulListener) Close() error {
  3. if gl.stopped {
  4. return syscall.EINVAL
  5. }
  6. gl.stop <- nil
  7. return <-gl.stop
  8. }
  9. func (gl *gracefulListener) File() *os.File {
  10. tl := gl.Listener.(*net.TCPListener)
  11. fl, _ := tl.File()
  12. return fl
  13. }

最后,怎么使用这个 Listener 呢,代码如下:

  1. [[email protected].io]# cat server.go
  2. server := &http.Server{
  3. Addr: "0.0.0.0:8888",
  4. ReadTimeout: 10 * time.Second,
  5. WriteTimeout: 10 * time.Second,
  6. MaxHeaderBytes: 1 << 16}
  7. netListener = newGracefulListener(l)
  8. server.Serve(netListener)

小结

这就是整的一个如何在 Go 语言中实现一个热重启的实现。这里没有给出完整的实现,但是已经有了一个真实的开源实现:endless

本文翻译自:Graceful Restart in Golang

Ref