一、概述
在日常业务中,服务会经常升级,但是因为某些原因不希望断开和客户端的连接。因此就需要服务的热升级技术。
在研究这个问题之前,可以先看一下nginx是如何做到不间断服务热重启的。
完整的文档可以看: 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