【导读】线上服务在发布时有减少对当前业务影响的需求,会采用优雅重启的方案。本文详细介绍了facebook开源库grace的使用。

逐步分析

猜测

查阅相关资料后,大概猜测出做法

服务重启时,旧进程并不直接停止,而是用旧进程 fork 一个新进程,同时旧进程的所有句柄都 dup 到新进程。这时新的请求都由新的进程处理,旧进程在处理完自己的任务后,自行退出。

这只是大概流程,里面还有许多细节需要考虑

分析 grace

github
https://github.com/facebookarchive/grace

流程简述
利用启动时的参数(包括命令行参数、环境变量等),重新启动新进程。同时将当前 socket 句柄给新进程。
旧进程不再 Accept,待当前任务结束后,进程退出
源码分析
如何启动新进程

// facebookgo/grace/gracenet/net.go:206(省略非核心代码)

func (n *Net) StartProcess() (int, error) {
    listeners, err := n.activeListeners()

  // 复制 socket 句柄
    files := make([]*os.File, len(listeners))
    for i, l := range listeners {
        files[i], err = l.(filer).File()
        defer files[i].Close()
    }

  // 复制标准 IO 句柄
    allFiles := append([]*os.File{os.Stdin, os.Stdout, os.Stderr}, files...)

  // 启动新进程,并传递句柄
    process, err := os.StartProcess(argv0, os.Args, &os.ProcAttr{
        Dir:   originalWD,
        Env:   env,
        Files: allFiles,
    })
    return process.Pid, nil
}

这段代码是启动新进程的过程。

  • 变量files保存listeners句柄(即 socket 句柄)
  • 变量allFiles保存files+stdout、stdin、stderr句柄
  • os.StartProcess启动新进程,并传递父进程句柄

注:这里传递的句柄只包括 socket 句柄与标准 IO 句柄。

旧进程如何退出
旧进程退出需要确保当前的请求全部处理完成。同时不再接收新的请求。

如何不接收新的请求
回答这个问题需要提到socket 流程。

通常建立 socket 需要经历以下四步:

  • socket
  • bind
  • listen
  • accept
    通常,accept 处于一个循环中,这样就能持续处理请求。所以若不想接收新请求,只需退出循环,不再 accept 即可。

如何确保当前请求全部处理完成
回答这个问题,我们需要给每一个连接赋予一系列状态。恰好,net/http包帮我们做好了这件事。

// GOROOT/net/http/server.go:2743

type ConnState int

const (
  // 新连接刚建立时
    StateNew ConnState = iota

  // 连接处于活跃状态,即正在处理的请求
    StateActive

  // 连接处于空闲状态,一般用于 keep-alive
    StateIdle

  // 劫持状态,可以理解为关闭状态
    StateHijacked

  // 关闭状态
    StateClosed
)

通过状态,我们就能精确判断所有请求是否处理完成。只要所有活跃(StateActive)的连接都成为空闲(StateIdle)或者关闭(StateClosed)状态。就可以保证请求全部处理完成。

具体代码

// facebookgo/httpdown/httpdown.go:347

func ListenAndServe(s *http.Server, hd *HTTP) error {
  // 监听端口,提供服务
    hs, err := hd.ListenAndServe(s)

    signals := make(chan os.Signal, 10)
    signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)

  // 监听信号量 2 和 15(即 kill -2 -15)
    select {
    case <-signals:
        signal.Stop(signals)
    // hs.Stop() 开始停止服务
        if err := hs.Stop(); err != nil {
            return err
        }
    }
}

这段代码是启动服务的入口代码

  • ListenAndServe 监听端口,提供 http 服务
  • signal.Notify 注册要监听的信号量,这里监听syscall.SIGTERM和syscall.SIGINT,即一般终止进程的信号量
  • hs.Stop() 停止服务,结束当前进程

可以看出,服务退出的逻辑都在hs.Stop()

// facebookgo/httpdown/httpdown.go:293

func (s *server) Stop() error {
    s.stopOnce.Do(func() {
    // 禁止 keep-alive
        s.server.SetKeepAlivesEnabled(false)

    // 关闭 listener, 不再接收请求
        closeErr := s.listener.Close()
        <-s.serveDone

    // 通过 stop(一个 chan),传递关闭信号
        stopDone := make(chan struct{})
        s.stop <- stopDone

        // 若在 s.stopTimeout 以内没有结束,则强行 kill 所有连接。默认 s.stopTimeout 为 1min
        select {
        case <-stopDone:
        case <-s.clock.After(s.stopTimeout):
            // stop timed out, wait for kill
            killDone := make(chan struct{})
            s.kill <- killDone
        }
    })}

Stop 方法

  • 禁止 keep-alive
  • 关闭 listener,即不再 accept 新请求
  • 想 s.stop\(一个 chan) 传递关闭的信号
  • 若 s.stopTimeout 时间内,没有退出,则强行 kill 所有连接。
  • 那么,等待所有请求处理完毕的逻辑,应该处于消费 s.stop 的地方。

这里我们注意到,最核心的结构体有这样几个属性

// facebookgo/httpdown/httpdown.go:126

type server struct {
  ...

    new    chan net.Conn
    active chan net.Conn
    idle   chan net.Conn
    closed chan net.Conn
    stop   chan chan struct{}
    kill   chan chan struct{}

  ...
}

stop 和 kill 说过了,是用来传递停止和强行终止信号的。

其余new、active、idle、closed是用来记录处于不同状态的连接的。

我们记录了不同状态的连接,那么在关闭时,就能等连接处于“空闲“或”关闭“时再关闭它。

// facebookgo/httpdown/httpdown.go:233

case c := <-s.idle:
    conns[c] = http.StateIdle

    // 那些处于“活跃”的连接,会等到它转为“空闲”时,将其关闭
    if stopDone != nil {
        c.Close()
    }
case c := <-s.closed:
    // 所有连接关闭后,退出
    if stopDone != nil && len(conns) == 0 {
        close(stopDone)
        return
    }
case stopDone = <-s.stop:
  // 所有连接关闭后,退出
    if len(conns) == 0 {
        close(stopDone)
        return
    }

// 关闭所有“空闲”连接
    for c, cs := range conns {
        if cs == http.StateIdle {
            c.Close()
        }
    }

这里可以看出,当接收到关闭信号时(stopDone = <-s.stop)

  • 会遍历所有“空闲”连接,将其关闭。
  • 而那些处于“活跃”的连接,会等到它转为“空闲”时,将其关闭
  • 在所有连接关闭后,退出

总结

进程重启主要就是如何退出、如何启动。grace 代码量不多,以上叙述了核心的逻辑,有兴趣的同学可以 fork github 源码研读。

转自:HammerMax

segmentfault.com/a/1190000022484443