背景
优雅重启一般是指后端服务器进程在重启更新时做到已有的请求正常处理,同时也能不间断的处理所有新的请求。保证服务不中断的核心思想是启动一个新的进程接收新的请求,老的进程不再接收新的请求,然后在处理完老的请求自动退出即可。本文介绍下目前我们之前调研使用过的优雅重启方式以及最佳实践。
一、利用tcp的SO_REUSERPORT选项
设置SO_REUSERPORT这个socket选项为true可以允许多个进程同时监听到同一端口,然后让内核自动做负载均衡,让请求平均地分配给多个进程进行处理。
注意只有第一个启动进程enable了这个选项之后,后续的进程才可以通过enable这个选项bind到同一个端口上。
比如之前我们有些grpc的服务就是采用这种方式进行重启的:
type RpcServer struct {
svr *grpc.Server
svc *service.Service
}
// See net.RawConn.Control
func Control(network, address string, c syscall.RawConn) (err error) {
e := c.Control(func(fd uintptr) {
// SO_REUSEADDR
// if err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil {
if err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil {
panic(err)
}
// SO_REUSEPORT
// if err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
if err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
panic(err)
}
})
if e != nil {
return e
}
return
}
var listenConfig = net.ListenConfig{
Control: Control,
}
func NewRpcServer(svc *service.Service) *RpcServer {
r := &RpcServer{
svr: grpc.NewServer(
grpc.UnaryInterceptor(
grpcmiddleware.ChainUnaryServer(
grpcrecovery.UnaryServerInterceptor(middleware.RecoveryInterceptor()),
)),
),
svc: svc,
}
// 重点:这里使用设置后的监听配置
ln, err := listenConfig.Listen(context.Background(), "tcp4", fmt.Sprintf(":%d", config.Port()))
if err != nil {
panic(err)
}
rpc.RegisterSnakeApiServerServer(r.svr, r)
safego.Go(func() {
if err = r.svr.Serve(ln); err != nil {
logrus.WithFields(logrus.Fields{"err": err}).Error("grpc_server_err")
}
})
return r
}
func (s *RpcServer) Stop() { // 服务的优雅退出
s.svr.GracefulStop()
s.svc.Stop()
logrus.Info("rpc_shutdown")
}
但是这种方式在高并发的请求场景下可能会有请求失败的场景,主要是因为老的进程在退出时,内核其实是感知不到的,所以这个期间发到了老进程的accept队列中的连接请求不会被accept,导致连接失败。
这种方式的主要原因还是新老两个进程使用了两个独立的套接字来接收和处理请求,但是老的套接字关闭没有及时通知内核,直接退出导致有些请求失败。那是否有新启动的进程直接继承老进程套接字的方式呢?答案是有的,下面讲的就是这种方式。
二、利用父子进程的方式实现
通过fork的方式创建一个子进程,然后利用通过系统调用 dup 复制一份父进程的socket文件描述符,进而实现使用同一个网络套接字的功能。
import (
"github.com/cloudflare/tableflip"
)
func main() {
// 用来优雅的重启
var upg, _ = tableflip.New(tableflip.Options{PIDFile: "/var/run/rpcserver.pid"})
defer upg.Stop()
// 监听系统的 SIGUSR2 信号,以此信号触发进程重启
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGUSR2)
for range sig {
// 调用upgrade升级
upg.Upgrade()
}
}()
r := &RpcServer{
svr: grpc.NewServer(
grpc.UnaryInterceptor(
grpcmiddleware.ChainUnaryServer(
grpcrecovery.UnaryServerInterceptor(middleware.RecoveryInterceptor()),
)),
),
svc: svc,
}
// 重点:这里使用upg进行监听,会继承父进程的文件描述符或者创建一个新的
ln, err := upg.Listen("tcp", ":"+config.Port())
if err != nil {
panic(err)
}
defer ln.Close()
pb.RegisterLoginServer(r.svr, r)
safego.Go(func() {
if err = r.svr.Serve(ln); err != nil {
logrus.WithFields(logrus.Fields{"err": err}).Error("grpc_server_err")
}
})
// 当新进程启动成功后,调用 upg.Ready 向父进程发出初始化成功完成的信号
if err := upg.Ready(); err != nil {
panic(err)
}
<-upg.Exit()
r.Stop()
}
三、服务优雅退出的注意点
最后需要和说明的是老的进程退出的逻辑应该是先停止接收新的请求,然后等待处理完当前所有的请求后再退出。在golang中http和grpc服务服务都提供了相应的stop方法,可以比较方便的退出。如果是自己写的tcp的服务的话,需要自己写下这块的逻辑,可以参考grpc的退出逻辑,利用sync.WaitGroup在每个请求到来时加1,在处理完请求后减1,然后在stop时等待所有请求处理完后退出。具体示例如下:
func (r *RpcServer) GracefulStop() {
r.Stop = true // 先标记为进程
// 关闭tcp监听
// 如果老的tcp连接发送新的请求过来,直接关闭连接,让客户端重连到新的进程上
if err := x.listener.Close(); err != nil {
logrus.Error("listener close err:", err)
}
r.RequestGroup.Wait() // 等待所有的请求处理完
// 关闭所有活跃的连接,让客户端重连
for c := range x.activeConn {
c.tcpConn.Close()
}
logrus.Info("rpc graceful stop ok")}
总结
本文总结了下自己在工作中对于golang服务优雅重启的一些调研和实践,欢迎感兴趣的同学随时交流。
参考: