如何在go中优雅的热升级服务

一、概述

在日常业务中,服务会经常升级,但是因为某些原因不希望断开和客户端的连接。因此就需要服务的热升级技术。

在研究这个问题之前,可以先看一下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

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据