一、概述
在日常业务中,服务会经常升级,但是因为某些原因不希望断开和客户端的连接。因此就需要服务的热升级技术。
在研究这个问题之前,可以先看一下nginx是如何做到不间断服务热重启的。
- 将新的nginx可执行文件替换掉旧的可执行文件。
- 向
master
进程发送USR2
信号,master
进程在接收到信号后会将pid文件命名为.oldbin
后缀。之后启动新的可执行文件,并启动新的worker
进程。这个时候会有两个master进程
。 -
向第一个
master
进程发送WINCH
信号。第一个master进程会通知旧的worker
进程优雅(处理完当前请求)地退出。 - 如果此时新的可执行文件有问题。可以做以下措施:
- 向旧的
master
进程发送HUP
信号,旧的master
进程会启动新的worker
进程,并且不会重新读取配置文件。然后会向新的master
进程发送QUIT
信号来要求其退出。 - 发送
TERM
信号到新的master进程,新的master进程和其派生的worker
进程都会立刻退出。旧的master
进程会自动启动新的worker
进程。 - 如果升级成功,
QUIT
信号会发送给旧的master
进程,并退出。
完整的文档可以看: http://nginx.org/en/docs/control.html#upgrade
在go中处理这个问题也是这个思路。
二、具体实现
2.1 定义服务
type Server struct {
l net.Listener // 监听端口
conns map[int]net.Conn // 当前服务的所有连接
rw sync.RWMutex // 读写锁,用来保证conns在并发情况下的正常工作
idLock sync.Mutex // 锁,用来保证idCursor在并发情况下的递增没有问题
idCursor int // 用来标记当前连接的id
isChild bool // 是否是子进程
status int // 当前服务的状态
relaxTime int // 在退出时允许协程处理请求的时间
}
2.2 处理信号
func (s *Server) handleSignal() {
sc := make(chan os.Signal)
signal.Notify(sc, syscall.SIGHUP, syscall.SIGTERM)
for {
sig := <-sc
switch sig {
case syscall.SIGHUP:
log.Println("signal sighup")
// reload
go func() {
s.fork()
}()
case syscall.SIGTERM:
log.Println("signal sigterm")
// stop
s.shutdown()
}
}
}
这里只处理了两个信号,HUP
表示要热升级服务,此时会fork一个新的服务。TERM
表示要终止服务。
2.3 如何fork新的服务
func (s *Server) fork() (err error) {
log.Println("start forking")
serverLock.Lock()
defer serverLock.Unlock()
if isForked {
return errors.New("Another process already forked. Ignoring this one")
}
isForked = true
files := make([]*os.File, 1+len(s.conns))
files[0], err = s.l.(*net.TCPListener).File() // 将监听带入到子进程中
if err != nil {
log.Println(err)
return
}
i := 1
for _, conn := range s.conns {
files[i], err = conn.(*net.TCPConn).File()
if err != nil {
log.Println(err)
return
}
i++
}
env := append(os.Environ(), CHILD_PROCESS+"=1")
env = append(env, fmt.Sprintf("%s=%s", SERVER_CONN, strconv.Itoa(len(s.conns))))
path := os.Args[0] // 当前可执行程序的路径
var args []string
if len(os.Args) > 1 {
args = os.Args[1:]
}
cmd := exec.Command(path, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.ExtraFiles = files
cmd.Env = env
err = cmd.Start()
if err != nil {
log.Println(err)
return
}
return
}
这里会将监听的文件描述符以及所有连接的文件描述符都带到新的服务中。这里只需要在新的服务中重新使用这些文件描述符即可保证不断开连接。
2.4 服务的启动流程
func (s *Server) Start(addr string) {
var err error
log.Printf("pid: %v \n", os.Getpid())
s.setState(StateInit)
if s.isChild {
log.Println("进入子进程")
// 通知父进程停止
ppid := os.Getppid()
err := syscall.Kill(ppid, syscall.SIGTERM)
if err != nil {
log.Fatal(err)
}
// 子进程, 重新监听之前的连接
connN, err := strconv.Atoi(os.Getenv(SERVER_CONN))
if err != nil {
log.Fatal(err)
}
for i := 0; i < connN; i++ {
f := os.NewFile(uintptr(4+i), "")
c, err := net.FileConn(f)
if err != nil {
log.Print(err)
} else {
id := s.add(c)
go s.handleConn(c, id)
}
}
}
s.l, err = s.getListener(addr)
if err != nil {
log.Fatal(err)
}
defer s.l.Close()
log.Println("listen on ", addr)
go s.handleSignal()
s.setState(StateRunning)
for {
log.Println("start accept")
conn, err := s.l.Accept()
if err != nil {
log.Fatal(err)
return
}
log.Println("accept new conn")
id := s.add(conn)
go s.handleConn(conn, id)
}
}
func (s *Server) getListener(addr string) (l net.Listener, err error) {
if s.isChild {
f := os.NewFile(3, "")
l, err = net.FileListener(f)
return
}
l, err = net.Listen("tcp", addr)
return
}
启动时,会判断是否是fork出的新的进程。如果是,则继承从父进程传递过来的文件描述符,并重新监听或作为连接处理。
完整的代码参考github: https://github.com/joyme123/graceful_restart_server_in_golang_demo