一架梯子,一头程序猿,仰望星空!

golang程序支持平滑升级


一、什么叫平滑升级、优雅重启、热启动?

平滑升级、优雅重启、热启动都是类似的意思指的是怎么样在不影响用户以及业务正常运行的前提下升级程序,关键就是怎么平滑重启程序。

先看下面的场景,理解下平滑重启程序的价值:
我们都知道Go语言开发的程序运行的时候都是常驻内存的,如果不重启程序我们新编译的程序是不会起作用,因此一般修改配置文件、新版本上线都需要重启Go程序。
linux环境下,一般情况我们都是通过Kill命令杀死进程,然后重启程序。

采取直接杀死进程然后重启的风险如下:

  1. 如果程序正在运行的业务还没执行完就被干掉,会导致某些业务数据异常。
  2. 如果访问量比较大直接杀死进程,会导致系统不可用,用户端请求报错。

二、平滑重启大致原理

平滑重启需要解决两个问题:

  1. 新的程序启动后接管老的程序处理新的请求任务。
  2. 老的程序不在接受新的请求并等待已经接受的请求结束后退出程序。

大致实现步骤如下:

  1. 程序监听重启信号,一般使用的是Linux的USR1或者USR2系统预留的两个自定义信号代表重启信号,例如使用USR2信号代表重启信号。
  2. 当程序收到重启信号,则以子进程的方式启动新的程序。
  3. 新程序启动后给老程序发个退出信号,然后处理新的请求。
  4. 老程序收到退出信号后停止接受新的请求,等待已经接受的请求处理任务结束后退出程序。

三、Grace开源库介绍

这么常用的功能,一般都不需要重新造轮子,这里介绍的是Facebook开源的Grace库,github地址:https://github.com/facebookgo/grace
grace库主要提供基于socket开发的服务端的平滑退出,平滑重启工具。
grace库主要包含两个包:

  1. gracenet - grace库的基础包,主要支持基于net.Listener监听链接的服务端实现平滑重启。
  2. gracehttp - 对gracenet进行封装,方便http server实现平滑重启。

因为grace包使用了信号处理,还有linux系统调用(函数),有些信号还有linux系统调用,只有Linux环境才支持,所以大家不要在windows环境调试平滑重启。 为了方便在Windows调试,大家可以对服务端启动部分封装一下,编写windows和Linux两个版本的代码,windows版本关闭平滑重启机器,linux版本才支持平滑重启。

三、基于gracehttp包实现http Server平滑重启

这里我一起看下golang自带的http包实现的Http server如何实现平滑重启。

  1. 安装依赖包
go get github.com/facebookgo/grace/gracehttp
  1. 代码实现
    只要下面一行代码,切换启动server的方式即可。

gracehttp.Serve(servers *http.Server)

下面是例子:

package main

import (
	"fmt"
	//导入gracehttp包
	"github.com/facebookgo/grace/gracehttp"
	"net/http"
	"os"
	"time"
)

func main()  {
	//注册url处理器
	http.HandleFunc("/tizi365/index", func(writer http.ResponseWriter, request *http.Request) {
		//故意休眠10秒然后返回结果,平滑重启,请求是不会被终止,可以正常等待请求结束
		time.Sleep(time.Second * 10)
		writer.Write([]byte("欢迎访问tizi365.com, 梯子教程网站。"))
	})

	//创建http server
	s := &http.Server{
		Addr:":8080",
		Handler:http.DefaultServeMux,
	}

	fmt.Println("启动进程,Id:", os.Getpid())

	//默认的方式启动Http Server,不支持平滑重启
	//err := s.ListenAndServe()

	//使用gracehttp启动 http server, 就一行代码
	err := gracehttp.Serve(s)

	if err != nil {
		fmt.Println("启动失败", err)
	}
	
	fmt.Println("进程,Id:", os.Getpid(),"完成任务退出程序")
}
  1. 测试
//1.编译后运行程序, 不推荐用goland IDE直接运行,或者用go run命令运行,因为grace在重启程序的时候不能处理这种模式运行的程序,会导致只能重启程序1次,第2次重启就找不到程序。

//2. 控制台会输出当前的进程Id
例子:
启动进程,Id: 52225

//3.使用浏览器访问Url
//因为前面设置休眠10秒,所以页面会卡主10秒,模拟一个任务正在执行10秒,10秒后才会返回结果。
http://localhost:8080/tizi365/index

//4. 使用kill命令给进程id发送重启信号
kill -USR2 52225

控制台输出:

启动进程,Id: 52225
启动进程,Id: 52342
进程Id: 52225 完成任务退出程序


//这个时候第三步访问的url没有报错,10秒后正常返回结果。这就实现了平滑重启,新的请求会被新的程序处理,老的程序会处理完已经接受的请求然后退出。
//如果使用kill -9 52225 强制杀死进程你会发现,第三步访问的URL报错了, -9 参数的意思就是发送kill信号,收到这个信号进程会被直接杀死

四、基于gracehttp包实现echo web框架平滑重启

前面介绍的是golang自带的Http包实现的http server如何实现平滑重启,这里介绍下 echo 框架开发的http server如何实现平滑重启。
实际上用法也是一样的,只需要下面一行代码,启动echo。

gracehttp.Serve(servers *http.Server)

//初始化echo服务
e := echo.New()

...忽略其他设置...

//默认启动方式,监听8080端口
//e.Start(":8080")

//使用gracehttp启动 http server, 就一行代码
//设置监听端口
e.Server.Addr = ":8080"
gracehttp.Serve(e.Server)

五、基于gracenet包实现go程序平滑重启

前面介绍的都是Http server平滑重启,如果不是http server,是其他的基于socket的tcp服务端依然可以使用grace库里面的gracenet包实现平滑重启,不过用起来稍微复杂一些。
下面是一个基于TCP协议实现的简单的http server,用于说明如何实现平滑重启

  1. 安装依赖包
go get github.com/facebookgo/grace/gracenet
  1. 代码实现
package main

import (
	"fmt"
	//加载gracenet包
	"github.com/facebookgo/grace/gracenet"
	"net"
	"os"
	"os/signal"
	"sync"
	"sync/atomic"
	"syscall"
	"time"
)
var (
	gnet gracenet.Net
	shutdown int32 //server 退出标记, 1 代表程序需要退出不在处理新的连接请求,默认是 0
	wg sync.WaitGroup
)

func main()  {
	//初始化gracenet
	gnet = gracenet.Net{}
	//监听8080端口
	ln, err := gnet.Listen("tcp", ":8080")
	if err != nil {
		fmt.Println("监听地址失败", err)
		return
	}

	fmt.Println("启动进程,Id:", os.Getpid())

	//启动一个协程处理进程信号
	go signalHandler()

	go func() {
		//休眠1秒,让下面的程序开始接管新的客户端连接,然后在通知父进程退出。
		time.Sleep(time.Second)
		ppid := os.Getppid()
		if isNewChildProcess() && ppid != 1 {
			//当前进程是gracenet包启动的子进程,则代表当前进程是新的程序,并且ppid父进程id不等于超级进程(linux的init进程)
			//既然新的进程已经启动,可以通知老的进程(父进程)退出
			//这里通过SIGTERM信号通知父进程退出
			if err := syscall.Kill(ppid, syscall.SIGTERM); err != nil {
				fmt.Println("信号发送失败", err)
			}
			fmt.Println("通知父进程退出,父进程Id", ppid)
		}
	}()

	//初始化协程同步组件,主要用于等待协程任务结束
	wg = sync.WaitGroup{}

	atomic.StoreInt32(&shutdown, 0)

	//循环获取客户端连接, 如果shutdown标记为1则不在获取新的连接,退出主循环
	for atomic.LoadInt32(&shutdown) != 1 {
		//获取客户端连接
		conn, err := ln.Accept()
		if err != nil {
			continue
		}

		//每个连接启动一个协程处理
		wg.Add(1) //需要等待的任务计数加1
		go handleConn(conn)
	}
}

//处理客户端连接
func handleConn(conn net.Conn)  {

	defer func() {
		//延时关闭连接,这里只是响应一次请求,就关闭连接
		conn.Close()
		//标记任务完成,任务计数减一
		wg.Done()
	}()

	fmt.Println("处理客户端连接,进程Id", os.Getpid())
	//故意等待10秒才返回结果
	time.Sleep(time.Second * 10)
	msg := "欢迎访问tizi365.com, 梯子教程网站。"
	//构造Http 响应协议,响应任意HTTP请求
	conn.Write([]byte(
		"HTTP/1.1 200 OK\r\n" +
		"Content-Type:text/html;charset=utf-8\r\n" +
			fmt.Sprintf("Content-Length:%d\r\n", len(msg)) +
			"\r\n" +
			msg))
}

//检测当前进程是否是由老进程因为平滑重启,启动的新的子进程
//因为grace包启动子进程的时候会通过环境变量LISTEN_FDS传递父进程需要传递多少个net.Listener给子进程,
//因此子进程可以通过LISTEN_FDS环境变量是否为空判断自己是否是被grace工具启动的子进程
func isNewChildProcess() bool {
	return os.Getenv("LISTEN_FDS") != ""
}

//信号处理器,负责监听进程信号处理进程退出,还是重启
func signalHandler() {
	//初始化信号通道
	ch := make(chan os.Signal, 10)
	//注册需要监听的信号,这里监听SIGINT 中断信号,SIGTERM 退出信号,SIGUSR2 自定义信号
	signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR2)

	for {
		//获取信号
		sig := <-ch
		switch sig {
		case syscall.SIGINT, syscall.SIGTERM:
			//如果收到中断和退出信号,则表示需要结束程序
			//关闭信号通道
			signal.Stop(ch)
			//标记当前进程需要平滑退出
			atomic.StoreInt32(&shutdown, 1)
			fmt.Println("父进程开始退出,进程id", os.Getpid())
			//等待当前进程任务完成
			wg.Wait()
			os.Exit(0)
			return
		case syscall.SIGUSR2:
			//如果收到SIGUSR2自定义信号,代表需要重启进程,这里使用gracenet包的工具启动新的进程实例
			if _, err := gnet.StartProcess(); err != nil {
				fmt.Println("启动进程失败", err.Error())
			}
			fmt.Println("重启进程")
		}
	}
}
  1. 测试
    测试方法跟上面gracehttp包实现的平滑重启一样,都是通过kill -USR2 进程id 命令,给进程发送重启信号,因为这里实现的也是简单的http server直接使用http://localhost:8080/访问即可