Golang中的热重启
这几天在写组里的一个http框架,于是研究了下,在golang中如何实现服务的热重启,从而实现整个服务的重启可以实现对请求客户端的透明。
什么是热重启
所谓热重启, 就是当关闭一个正在运行的进程时,该进程并不会立即停止,而是会等待所有当前逻辑继续执行完毕,才会中断。这就要求我们的服务需要支持一条重启命令,通过该命令我们可以重启服务,并同时保证重启过程中正在执行的逻辑不会中断,且重启后可以继续正常服务。
热重启的原理
之前在写C++服务的时候实现过热重启,其实原理还是非常简单的,只是会需要涉及到一些linux下系统调用以及进程之间socket句柄传递等细节,为了怕写错,又翻了几篇文章,总的来看,处理过程可以分为以下几个步骤:
- 监听重启信号;
- 收到重启信号时fork子进程,同时需要将服务监听的socket文件描述符传递给子进程;
- 子进程接收并监听父进程传递的socket;
- 等待子进程启动成功之后,停止父进程对新连接的接收;
- 父进程退出,重启完成
关于上述几点,需要说明下:对于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/