Golang中的热重启

这几天在写组里的一个http框架,于是研究了下,在golang中如何实现服务的热重启,从而实现整个服务的重启可以实现对请求客户端的透明。

什么是热重启

所谓热重启, 就是当关闭一个正在运行的进程时,该进程并不会立即停止,而是会等待所有当前逻辑继续执行完毕,才会中断。这就要求我们的服务需要支持一条重启命令,通过该命令我们可以重启服务,并同时保证重启过程中正在执行的逻辑不会中断,且重启后可以继续正常服务。

热重启的原理

之前在写C++服务的时候实现过热重启,其实原理还是非常简单的,只是会需要涉及到一些linux下系统调用以及进程之间socket句柄传递等细节,为了怕写错,又翻了几篇文章,总的来看,处理过程可以分为以下几个步骤:

  1. 监听重启信号;
  2. 收到重启信号时fork子进程,同时需要将服务监听的socket文件描述符传递给子进程;
  3. 子进程接收并监听父进程传递的socket;
  4. 等待子进程启动成功之后,停止父进程对新连接的接收;
  5. 父进程退出,重启完成

关于上述几点,需要说明下:对于1,仅仅是我们后文将以SIGHUP信号来表示重启,同时需要了解到的是,在第3步,这个时候父进程和子进程都可以接收请求,而在第4步,此时父进程会等待旧连接逻辑处理完成。

Golang中的实现

进程的启动监听

// 启动监听
	http.HandleFunc("/hello", HelloHandler)
	server = &http.Server{Addr: ":8081"}

	var err error
	if *child {
		fmt.Println("In Child, Listening...")

		f := os.NewFile(3, "")
		listener, err = net.FileListener(f)
	} else {
		fmt.Println("In Father, Listening...")

		listener, err = net.Listen("tcp", server.Addr)
	}
	if err != nil {
		fmt.Printf("Listening failed: %v\n", err)
		return
	}

上述的代码段中,实现了一个简单的服务监听。其中child是子进程的标志,我们可以看到在子进程分支中,通过os.NewFile(3,"")打开了文件描述符为3的文件并转为网络监听句柄(至于为什么是3呢,而不是0、1或者其他数字?我们在下面介绍)。

系统信号的监听handler

func singalHandler() {
	ch := make(chan os.Signal, 1)
	signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)

	for {
		sig := <-ch
		fmt.Printf("signal: %v\n", sig)

		ctx, _ := context.WithTimeout(context.Background(), 20*time.Second)
		switch sig {
		case syscall.SIGINT, syscall.SIGTERM:
			log.Printf("stop")
			signal.Stop(ch)
			server.Shutdown(ctx)
			fmt.Printf("graceful shutdown\n")
			return
		case syscall.SIGHUP:
			// reload
			log.Printf("restart")
			err := restart()
			if err != nil {
				fmt.Printf("graceful restart failed: %v\n", err)
			}
			//更新当前pidfile
			updatePidFile()
			
			//带超时的优雅停止
			server.Shutdown(ctx)
			fmt.Printf("graceful reload\n")
			return
		}
	}
}

上述代码段中,我们监听了系统的SIGINT、SIGTERM和SIGHUP信号,其中,对于SIGINT和SIGTERM信号,我们认定为终止信号,需要graceful stop。对于SIGHUP信号,我们认定为重启信号,此时需要执行graceful restart(热重启操作)。

需要注意的是,为了实现graceful stop,在以往我们需要自己实现一个这样的shutdown功能:

1.关闭listenr,停止接收新请求;

2.通过sync.WaitGroup.wait()阻塞服务退出,从而实现等待其他逻辑的全部退出;

然而,得益于golang的更新(>1.8),如上述代码段所示,现在通过调用Golang中的Server.Shutdown()方法就可以直接实现。

重启逻辑

func restart() error {
	tl, ok := listener.(*net.TCPListener)
	if !ok {
		return fmt.Errorf("listener is not tcp listener")
	}

	f, err := tl.File()
	if err != nil {
		return err
	}

	args := []string{"-child"}
	cmd := exec.Command(os.Args[0], args...)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	// put socket FD at the first entry
	cmd.ExtraFiles = []*os.File{f}
	return cmd.Start()
}

上述的代码段中,通过系统调用exec.Command()创建了一个子进程,同时传递了child参数到了子进程中,从而可以执行在进程监听时走子进程创建socket的流程。这儿就回到了上文中我们抛出的os.NewFile(3,"")中的3是如何来的问题了,cmd的ExtraFiles参数会将额外的文件描述符传递给继承的新进程(不包括标准输入、标准输出和标准错误),在这儿父进程给了个listener的fd给子进程了,而子进程里0、1、2是预留给标准输入、输出和错误的,所以父进程给的第一个fd在子进程里顺序排就是从3开始了(需要注意的是,ExtraFiles是不支持Windows操作系统的)。

附录:

基本上上述就是一个完整的热重启逻辑了,下面附上完成的代码段:

package main

import (
	"flag"
	"net/http"
	"net"
	"log"
	"os"
	"os/signal"
	"syscall"
	"golang.org/x/net/context"
	"time"
	"os/exec"
	"fmt"
	"io/ioutil"
	"strconv"
)

var (
	server   *http.Server
	listener net.Listener
	child    = flag.Bool("child", false, "")
)

func init() {
	updatePidFile()
}

func updatePidFile() {
	sPid := fmt.Sprint(os.Getpid())
	tmpDir := os.TempDir()
	if err := procExsit(tmpDir); err != nil {
		fmt.Printf("pid file exists, update\n")
	} else {
		fmt.Printf("pid file NOT exists, create\n")
	}
	pidFile, _ := os.Create(tmpDir + "/gracefulRestart.pid")
	defer pidFile.Close()
	pidFile.WriteString(sPid)
}

// 判断进程是否启动
func procExsit(tmpDir string) (err error) {
	pidFile, err := os.Open(tmpDir + "/gracefulRestart.pid")
	defer pidFile.Close()
	if err != nil {
		return
	}

	filePid, err := ioutil.ReadAll(pidFile)
	if err != nil {
		return
	}
	pidStr := fmt.Sprintf("%s", filePid)
	pid, _ := strconv.Atoi(pidStr)
	if _, err := os.FindProcess(pid); err != nil {
		fmt.Printf("Failed to find process: %v\n", err)
		return
	}

	return
}

func main() {
	flag.Parse()

	// 启动监听
	http.HandleFunc("/hello", HelloHandler)
	server = &http.Server{Addr: ":8081"}

	var err error
	if *child {
		fmt.Println("In Child, Listening...")

		f := os.NewFile(3, "")
		listener, err = net.FileListener(f)
	} else {
		fmt.Println("In Father, Listening...")

		listener, err = net.Listen("tcp", server.Addr)
	}
	if err != nil {
		fmt.Printf("Listening failed: %v\n", err)
		return
	}

	// 单独go程启动server
	go func() {
		err = server.Serve(listener)
		if err != nil {
			fmt.Printf("server.Serve failed: %v\n", err)
		}
	}()

	//监听系统信号
	singalHandler()
	fmt.Printf("singalHandler end\n")

}

func HelloHandler(w http.ResponseWriter, r *http.Request) {
	//time.Sleep(20 * time.Second)
	for i := 0; i < 20; i++ {
		log.Printf("working %v\n", i)
		time.Sleep(1 * time.Second)
	}
	w.Write([]byte("world233333!!!!"))
}

func singalHandler() {
	ch := make(chan os.Signal, 1)
	signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)

	for {
		sig := <-ch
		fmt.Printf("signal: %v\n", sig)

		ctx, _ := context.WithTimeout(context.Background(), 20*time.Second)
		switch sig {
		case syscall.SIGINT, syscall.SIGTERM:
			log.Printf("stop")
			signal.Stop(ch)
			server.Shutdown(ctx)
			fmt.Printf("graceful shutdown\n")
			return
		case syscall.SIGHUP:
			// reload
			log.Printf("restart")
			err := restart()
			if err != nil {
				fmt.Printf("graceful restart failed: %v\n", err)
			}
			//更新当前pidfile
			updatePidFile()
			server.Shutdown(ctx)
			fmt.Printf("graceful reload\n")
			return
		}
	}
}

func restart() error {
	tl, ok := listener.(*net.TCPListener)
	if !ok {
		return fmt.Errorf("listener is not tcp listener")
	}

	f, err := tl.File()
	if err != nil {
		return err
	}

	args := []string{"-child"}
	cmd := exec.Command(os.Args[0], args...)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.ExtraFiles = []*os.File{f}
	return cmd.Start()
}

注:本次在golang中的热重启处理,有参考这篇文章:https://grisha.org/blog/2014/06/03/graceful-restart-in-golang/

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区