【导读】线上服务在发布时有减少对当前业务影响的需求,会采用优雅重启的方案。本文详细介绍了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