Go 实现热重启的详细介绍
最近在优化公司框架trpc时发现了一个热重启相关的问题,优化之余也总结沉淀下,对go如何实现热重启这方面的内容做一个简单的梳理。
1.什么是热重启?
热重启(HotRestart),是一项保证服务可用性的手段。它允许服务重启期间,不中断已经建立的连接,老服务进程不再接受新连接请求,新连接请求将在新服务进程中受理。对于原服务进程中已经建立的连接,也可以将其设为读关闭,等待平滑处理完连接上的请求及连接空闲后再行退出。通过这种方式,可以保证已建立的连接不中断,连接上的事务(请求、处理、响应)可以正常完成,新的服务进程也可以正常接受连接、处理连接上的请求。当然,热重启期间进程平滑退出涉及到的不止是连接上的事务,也有消息服务、自定义事务需要关注。
这是我理解的热重启的一个大致描述。热重启现在还有没有存在的必要?我的理解是看场景。
以后台开发为例,假如运维平台有能力在服务升级、重启时自动踢掉流量,服务就绪后又自动加回流量,假如能够合理预估服务QPS、请求处理时长,那么只要配置一个合理的停止前等待时间,是可以达到类似热重启的效果的。这样的话,在后台服务里面支持热重启就显得没什么必要。但是,如果我们开发一个微服务框架,不能对将来的部署平台、环境做这种假设,也有可能使用方只是部署在一两台物理机上,也没有其他的负载均衡设施,但不希望因为重启受干扰,热重启就很有必要。当然还有一些更复杂、要求更苛刻的场景,也需要热重启的能力。
热重启是比较重要的一项保证服务质量的手段,还是值得了解下的,这也是本文介绍的初衷。
2.如何实现热重启?
如何实现热重启,这里其实不能一概而论,要结合实际的场景来看(比如服务编程模型、对可用性要求的高低等)。大致的实现思路,可以先抛一下。
一般要实现热重启,大致要包括如下步骤:
- 首先,要让老进程,这里称之为父进程了,先要fork出一个子进程来代替它工作;
- 然后,子进程就绪之后,通知父进程,正常接受新连接请求、处理连接上收到的请求;
- 再然后,父进程处理完已建立连接上的请求后、连接空闲后,平滑退出。
听上去是挺简单的...
2.1.认识fork
大家都知道fork()系统调用,父进程调用fork会创建一个进程副本,代码中还可以通过fork返回值是否为0来区分是子进程还是父进程。
intmain(char**argv,intargc){ pid_tpid=fork(); if(pid==0){ printf("iamchildprocess"); }else{ printf("iamparentprocess,ihaveachildprocessnamed%d",pid); } }
可能有些开发人员不知道fork的实现原理,或者不知道fork返回值为什么在父子进程中不同,或者不知道如何做到父子进程中返回值不同……了解这些是要有点知识积累的。
2.2.返回值
简单概括下,ABI定义了进行函数调用时的一些规范,如何传递参数,如何返回值等等,以x86为例,如果返回值是rax寄存器能够容的一般都是通过rax寄存器返回的。
如果rax寄存器位宽无法容纳下的返回值呢?也简单,编译器会安插些指令来完成这些神秘的操作,具体是什么指令,就跟语言编译器实现相关了。
- c语言,可能会将返回值的地址,传递到rdi或其他寄存器,被调函数内部呢,通过多条指令将返回值写入rdi代指的内存区;
- c语言,也可能在被调函数内部,用多个寄存器rax,rdx...一起暂存返回结果,函数返回时再将多个寄存器的值赋值到变量中;
- 也可能会像golang这样,通过栈内存来返回;
2.3.fork返回值
fork系统调用的返回值,有点特殊,在父进程和子进程中,这个函数返回的值是不同的,如何做到的呢?
联想下父进程调用fork的时候,操作系统内核需要干些什么呢?分配进程控制块、分配pid、分配内存空间……肯定有很多东西啦,这里注意下进程的硬件上下文信息,这些是非常重要的,在进程被调度算法选中进行调度时,是需要还原硬件上下文信息的。
Linuxfork的时候,会对子进程的硬件上下文进行一定的修改,我就是让你fork之后拿到的pid是0,怎么办呢?前面2.2节提过了,对于那些小整数,rax寄存器存下绰绰有余,fork返回时就是将操作系统分配的pid放到rax寄存器的。
那,对于子进程而言,我只要在fork的时候将它的硬件上下文rax寄存器清0,然后等其他设置全ok后,再将其状态从不可中断等待状态修改为可运行状态,等其被调度器调度时,会先还原其硬件上下文信息,包括PC、rax等等,这样fork返回后,rax中值为0,最终赋值给pid的值就是0。
因此,也就可以通过这种判断“pid是否等于0”的方式来区分当前进程是父进程还是子进程了。
2.4.局限性
很多人清楚fork可以创建一个进程的副本并继续往下执行,可以根据fork返回值来执行不同的分支逻辑。如果进程是多线程的,在一个线程中调用fork会复制整个进程吗?
fork只能创建调用该函数的线程的副本,进程中其他运行的线程,fork不予处理。这就意味着,对于多线程程序而言,寄希望于通过fork来创建一个完整进程副本是不可行的。
前面我们也提到了,fork是实现热重启的重要一环,fork这里的这个局限性,就制约着不同服务编程模型下的热重启实现方式。所以我们说具体问题具体分析,不同编程模型下实际上可以采用不同的实现方式。
3.单进程单线程模型
单进程单线程模型,可能很多人一听觉得它已经被淘汰了,生产环境中不能用,真的么?强如redis,不就是单线程。强调下并非单线程模型没用,ok,收回来,现在关注下单进程单线程模型如何实现热重启。
单进程单线程,实现热重启会比较简单些:
- fork一下就可以创建出子进程,
- 子进程可以继承父进程中的资源,如已经打开的文件描述符,包括父进程的listenfd、connfd,
- 父进程,可以选择关闭listenfd,后续接受连接的任务就交给子进程来完成了,
- 父进程,甚至也可以关闭connfd,让子进程处理连接上的请求、回包等,也可以自身处理完已建立的连接上的请求;
- 父进程,在合适的时间点选择退出,子进程开始变成顶梁柱。
核心思想就是这些,但是具体到实现,就有多种方法:
- 可以选择fork的方式让子进程拿到原来的listenfd、connfd,
- 也可以选择unixdomainsocket的方式父进程将listenfd、connfd发送给子进程。
有同学可能会想,我不传递这些fd行吗?
- 比如我开启了reuseport,父进程直接处理完已建立连接connfd上的请求之后关闭,子进程里reuseport.Listen直接创建新的listenfd。
也可以!但是有些问题必须要提前考虑到:
- reuseport虽然允许多个进程在同一个端口上多次listen,似乎满足了要求,但是要知道只要euid相同,都可以在这个端口上listen!是不安全的!
- reuseport实现和平台有关系,在Linux平台上在同一个address+port上listen多次,多个listenfd底层可以共享同一个连接队列,内核可以实现负载均衡,但是在darwin平台上却不会!
当然这里提到的这些问题,在多线程模型下肯定也存在。
4.单进程多线程模型
前面提到的问题,在多线程模型中也会出现:
- fork只能复制callingthread,notwholeprocess!
- reuseport多次在相同地址+端口listen得到的多个fd,不同平台有不同的表现,可能无法做到接受连接时的loadbanlance!
- 非reuseport情况下,多次listen会失败!
- 不传递fd,直接通过reuseport来重新listen得到listenfd,不安全,不同服务进程实例可能会在同一个端口上监听,gg!
- 父进程平滑退出的逻辑,关闭listenfd,等待connfd上请求处理结束,关闭connfd,一切妥当后,父进程退出,子进程挑大梁!
5.其他线程模型
其他线程都基本上避不开上述3、4的实现或者组合,对应问题相仿,不再赘述。
6.go实现热重启:触发时机
需要选择一个时机来触发热重启,什么时候触发呢?操作系统提供了信号机制,允许进程做出一些自定义的信号处理。
杀死一个进程,一般会通过kill-9发送SIGKILL信号给进程,这个信号不允许捕获,SIGABORT也不允许捕获,这样可以允许进程所有者或者高权限用户控制进程生死,达到更好的管理效果。
kill也可以用来发送其他信号给进程,如发送SIGUSR1、SIGUSR2、SIGINT等等,进程中可以接收这些信号,并针对性的做出处理。这里可以选择SIGUSR1或者SIGUSR2来通知进程热重启。
gofunc(){ ch:=make(chanos.Signal,1) signal.Notify(ch,os.SIGUSR2) <-ch //接下来就可以做热重启相关的逻辑了 ... }()
7.如何判断热重启
那一个go程序重新启动之后,所有运行时状态信息都是新的,那如何区分自己是否是子进程呢,或者说我是否要执行热重启逻辑呢?父进程可以通过设置子进程初始化时的环境变量,比如加个HOT_RESTART=1。
这就要求代码中在合适的地方要先检测环境变量HOT_RESTART是否为1,如果成立,那就执行热重启逻辑,否则就执行全新的启动逻辑。
8.ForkExec
假如当前进程收到SIGUSR2信号之后,希望执行热重启逻辑,那么好,需要先执行syscall.ForkExec(...)来创建一个子进程,注意go不同于cc++,它本身就是依赖多线程来调度协程的,天然就是多线程程序,只不过是他没有使用NPTL线程库来创建,而是通过clone系统调用来创建。
前面提过了,如果单纯fork的话,只能复制调用fork函数的线程,对于进程中的其他线程无能为力,所以对于go这种天然的多线程程序,必须从头来一遍,再exec一下。所以go标准库提供的函数是syscall.ForkExec而不是syscall.Fork。
9.go实现热重启:传递listenfd
go里面传递fd的方式,有这么几种,父进程fork子进程的时候传递fd,或者后面通过unixdomainsocket传递。需要注意的是,我们传递的实际上是filedescription,而非filedescriptor。
附上一张类unix系统下filedescriptor、filedescription、inode三者之间的关系图:
fd分配都是从小到大分配的,父进程中的fd为10,传递到子进程中之后有可能就不是10。那么传递到子进程的fd是否是可以预测的呢?可以预测,但是不建议。所以我提供了两种实现方式。
9.1ForkExec+ProcAttr{Files:[]uintptr{}}
要传递一个listenfd很简单,假如是类型net.Listener,那就通过tcpln:=ln.(*net.TCPListener);file,_:=tcpln.File();fd:=file.FD()来拿到listener底层filedescription对应的fd。
需要注意的是,这里的fd并非底层的filedescription对应的初始fd,而是被dup2复制出来的一个fd(调用tcpln.File()的时候就已经分配了),这样底层filedescription引用计数就会+1。如果后面想通过ln.Close()关闭监听套接字的话,sorry,关不掉。这里需要显示的执行file.Close()将新创建的fd关掉,使对应的filedescription引用计数-1,保证Close的时候引用计数为0,才可以正常关闭。
试想下,我们想实现热重启,是一定要等连接上接收的请求处理完才可以退出进程的,但是这期间父进程不能再接收新的连接请求,如果这里不能正常关闭listener,那我们这个目标就无法实现。所以这里对dup出来的fd的处理要慎重些,不要遗忘。
OK,接下来说下syscall.ProcAttr{Files:[]uintptr{}},这里就是要传递的父进程中的fd,比如要传递stdin、stdout、stderr给子进程,就需要将这几个对应的fd塞进去os.Stdin.FD(),os.Stdout.FD(),os.Stderr.FD(),如果要想传递刚才的listenfd,就需要将上面的file.FD()返回的fd塞进去。
子进程中接收到这些fd之后,在类unix系统下一般会按照从0、1、2、3这样递增的顺序来分配fd,那么传递过去的fd是可以预测的,假如除了stdin,stdout,stderr再传两个listenfd,那么可以预测这两个的fd应该是3,4。在类unix系统下一般都是这么处理的,子进程中就可以根据传递fd的数量(比如通过环境变量传递给子进程FD_NUM=2),来从3开始计算,哦,这两个fd应该是3,4。
父子进程可以通过一个约定的顺序,来组织传递的listenfd的顺序,以方便子进程中按相同的约定进行处理,当然也可以通过fd重建listener之后来判断对应的监听network+address,以区分该listener对应的是哪一个逻辑service。都是可以的!
需要注意的是,file.FD()返回的fd是非阻塞的,会影响到底层的filedescription,在重建listener先将其设为nonblock,syscall.SetNonBlock(fd),然后file,_:=os.NewFile(fd);tcplistener:=net.FileListener(file),或者是udpconn:=net.PacketConn(file),然后可以获取tcplistener、udpconn的监听地址,来关联其对应的逻辑service。
前面提到file.FD()会将底层的filedescription设置为阻塞模式,这里再补充下,net.FileListener(f),net.PacketConn(f)内部会调用newFileFd()->dupSocket(),这几个函数内部会将fd对应的filedescription重新设置为非阻塞。父子进程中共享了listener对应的filedescription,所以不需要显示设置为非阻塞。
有些微服务框架是支持对服务进行逻辑service分组的,googlepb规范中也支持多service定义,这个在腾讯的goneat、trpc框架中也是有支持的。
当然了,这里我不会写一个完整的包含上述所有描述的demo给大家,这有点占篇幅,这里只贴一个精简版的实例,其他的读者感兴趣可以自己编码测试。须知纸上得来终觉浅,还是要多实践。
packagemain import( "fmt" "io/ioutil" "log" "net" "os" "strconv" "sync" "syscall" "time" ) constenvRestart="RESTART" constenvListenFD="LISTENFD" funcmain(){ v:=os.Getenv(envRestart) ifv!="1"{ ln,err:=net.Listen("tcp","localhost:8888") iferr!=nil{ panic(err) } wg:=sync.WaitGroup{} wg.Add(1) gofunc(){ deferwg.Done() for{ ln.Accept() } }() tcpln:=ln.(*net.TCPListener) f,err:=tcpln.File() iferr!=nil{ panic(err) } os.Setenv(envRestart,"1") os.Setenv(envListenFD,fmt.Sprintf("%d",f.Fd())) _,err=syscall.ForkExec(os.Args[0],os.Args,&syscall.ProcAttr{ Env:os.Environ(), Files:[]uintptr{os.Stdin.Fd(),os.Stdout.Fd(),os.Stderr.Fd(),f.Fd()}, Sys:nil, }) iferr!=nil{ panic(err) } log.Print("parentpid:",os.Getpid(),",passfd:",f.Fd()) f.Close() wg.Wait() }else{ v:=os.Getenv(envListenFD) fd,err:=strconv.ParseInt(v,10,64) iferr!=nil{ panic(err) } log.Print("childpid:",os.Getpid(),",recvfd:",fd) //case1:理解上面提及的filedescriptor、filedescription的关系 //这里子进程继承了父进程中传递过来的一些fd,但是fd数值与父进程中可能是不同的 //取消注释来测试... //ff:=os.NewFile(uintptr(fd),"") //ifff!=nil{ //_,err:=ff.Stat() //iferr!=nil{ //log.Println(err) //} //} //case2:假定父进程中共享了fd0\1\2\listenfd给子进程,那再子进程中可以预测到listenfd=3 ff:=os.NewFile(uintptr(3),"") fmt.Println("fd:",ff.Fd()) ifff!=nil{ _,err:=ff.Stat() iferr!=nil{ panic(err) } //这里pause,运行命令lsof-P-p$pid,检查下有没有listenfd传过来,除了0,1,2,应该有看到3 //ctrl+dtocontinue ioutil.ReadAll(os.Stdin) fmt.Println("....") _,err=net.FileListener(ff) iferr!=nil{ panic(err) } //这里pause,运行命令lsof-P-p$pid,会发现有两个listenfd, //因为前面调用了ff.FD()dup2了一个,如果这里不显示关闭,listener将无法关闭 ff.Close() time.Sleep(time.Minute) } time.Sleep(time.Minute) } }
这里用简单的代码大致解释了如何用ProcAttr来传递listenfd。这里有个问题,假如后续父进程中传递的fd修改了呢,比如不传stdin,stdout,stderr的fd了,怎么办?服务端是不是要开始预测应该从0开始编号了?我们可以通过环境变量通知子进程,比如传递的fd从哪个编号开始是listenfd,一共有几个listenfd,这样也是可以实现的。
这种实现方式可以跨平台。
感兴趣的话,可以看下facebook提供的这个实现grace。
9.2unixdomainsocket+cmsg
另一种,思路就是通过unixdomainsocket+cmsg来传递,父进程启动的时候依然是通过ForkExec来创建子进程,但是并不通过ProcAttr来传递listenfd。
父进程在创建子进程之前,创建一个unixdomainsocket并监听,等子进程启动之后,建立到这个unixdomainsocket的连接,父进程此时开始将listenfd通过cmsg发送给子进程,获取fd的方式与9.1相同,该注意的fd关闭问题也是一样的处理。
子进程连接上unixdomainsocket,开始接收cmsg,内核帮子进程收消息的时候,发现里面有一个父进程的fd,内核找到对应的filedescription,并为子进程分配一个fd,将两者建立起映射关系。然后回到子进程中的时候,子进程拿到的就是对应该filedescription的fd了。通过os.NewFile(fd)就可以拿到file,然后再通过net.FileListener或者net.PacketConn就可以拿到tcplistener或者udpconn。
剩下的获取监听地址,关联逻辑service的动作,就与9.1小结描述的一致了。
这里我也提供一个可运行的精简版的demo,供大家了解、测试用。
packagemain import( "fmt" "io/ioutil" "log" "net" "os" "strconv" "sync" "syscall" "time" passfd"github.com/ftrvxmtrx/fd" ) constenvRestart="RESTART" constenvListenFD="LISTENFD" constunixsockname="/tmp/xxxxxxxxxxxxxxxxx.sock" funcmain(){ v:=os.Getenv(envRestart) ifv!="1"{ ln,err:=net.Listen("tcp","localhost:8888") iferr!=nil{ panic(err) } wg:=sync.WaitGroup{} wg.Add(1) gofunc(){ deferwg.Done() for{ ln.Accept() } }() tcpln:=ln.(*net.TCPListener) f,err:=tcpln.File() iferr!=nil{ panic(err) } os.Setenv(envRestart,"1") os.Setenv(envListenFD,fmt.Sprintf("%d",f.Fd())) _,err=syscall.ForkExec(os.Args[0],os.Args,&syscall.ProcAttr{ Env:os.Environ(), Files:[]uintptr{os.Stdin.Fd(),os.Stdout.Fd(),os.Stderr.Fd(),/*f.Fd()*/},//commentthiswhentestunixsock Sys:nil, }) iferr!=nil{ panic(err) } log.Print("parentpid:",os.Getpid(),",passfd:",f.Fd()) os.Remove(unixsockname) unix,err:=net.Listen("unix",unixsockname) iferr!=nil{ panic(err) } unixconn,err:=unix.Accept() iferr!=nil{ panic(err) } err=passfd.Put(unixconn.(*net.UnixConn),f) iferr!=nil{ panic(err) } f.Close() wg.Wait() }else{ v:=os.Getenv(envListenFD) fd,err:=strconv.ParseInt(v,10,64) iferr!=nil{ panic(err) } log.Print("childpid:",os.Getpid(),",recvfd:",fd) //case1:有同学认为以通过环境变量传fd,通过环境变量肯定是不行的,fd根本不对应子进程中的fd //ff:=os.NewFile(uintptr(fd),"") //ifff!=nil{ //_,err:=ff.Stat() //iferr!=nil{ //log.Println(err) //} //} //case2:如果只有一个listenfd的情况下,那如果fork子进程时保证只传0\1\2\listenfd,那子进程中listenfd一定是3 //ff:=os.NewFile(uintptr(3),"") //ifff!=nil{ //_,err:=ff.Stat() //iferr!=nil{ //panic(err) //} ////pause,ctrl+dtocontinue //ioutil.ReadAll(os.Stdin) //fmt.Println("....") //_,err=net.FileListener(ff)//会dup一个fd出来,有多个listener //iferr!=nil{ //panic(err) //} ////lsof-P-p$pid,会发现有两个listenfd //time.Sleep(time.Minute) //} //这里我们暂停下,方便运行系统命令来查看进程当前的一些状态 //run:lsof-P-p$pid,检查下listenfd情况 ioutil.ReadAll(os.Stdin) fmt.Println(".....") unixconn,err:=net.Dial("unix",unixsockname) iferr!=nil{ panic(err) } files,err:=passfd.Get(unixconn.(*net.UnixConn),1,nil) iferr!=nil{ panic(err) } //这里再运行命令:lsof-P-p$pid再检查下listenfd情况 f:=files[0] f.Stat() time.Sleep(time.Minute) } }
这种实现方式,仅限类unix系统。
如果有服务混布的情况存在,需要考虑下使用的unixdomainsocket的文件名,避免因为重名所引起的问题,可以考虑通过”进程名.pid“来作为unixdomainsocket的名字,并通过环境变量将其传递给子进程。
10.go实现热重启:子进程如何通过listenfd重建listener
前面已经提过了,当拿到fd之后还不知道它对应的是tcp的listener,还是udpconn,那怎么办?都试下呗。
file,err:=os.NewFile(fd) //checkerror tcpln,err:=net.FileListener(file) //checkerror udpconn,err:=net.PacketConn(file) //checkerror
11.go实现热重启:父进程平滑退出
父进程如何平滑退出呢,这个要看父进程中都有哪些逻辑要平滑停止了。
11.1.处理已建立连接上请求
可以从这两个方面入手:
- shutdownread,不再接受新的请求,对端继续写数据的时候会感知到失败;
- 继续处理连接上已经正常接收的请求,处理完成后,回包,close连接;
也可以考虑,不进行读端关闭,而是等连接空闲一段时间后再close,是否尽快关闭更符合要求就要结合场景、要求来看。
如果对可用性要求比较苛刻,可能也会需要考虑将connfd、connfd上已经读取写入的buffer数据也一并传递给子进程处理。
11.2.消息服务
- 确认下自己服务的消息消费、确认机制是否合理
- 不再收新消息
- 处理完已收到的消息后,再退出
11.3.自定义AtExit清理任务
有些任务会有些自定义任务,希望进程在退出之前,能够执行到,这种可以提供一个类似AtExit的注册函数,让进程退出之前能够执行业务自定义的清理逻辑。
不管是平滑重启,还是其他正常退出,对该支持都是有一定需求的。
12.其他
有些场景下也希望传递connfd,包括connfd上对应的读写的数据。
比如连接复用的场景,客户端可能会通过同一个连接发送多个请求,假如在中间某个时刻服务端执行热重启操作,服务端如果直接连接读关闭会导致后续客户端的数据发送失败,客户端关闭连接则可能导致之前已经接收的请求也无法正常响应。这种情况下,可以考虑服务端继续处理连接上请求,等连接空闲再关闭。会不会一直不空闲呢?有可能。
其实服务端不能预测客户端是否会采用连接复用模式,选择一个更可靠的处理方式会更好些,如果场景要求比较苛刻,并不希望通过上层重试来解决的话。这种可以考虑将connfd以及connfd上读写的buffer数据一并传递给子进程,交由子进程来处理,这个时候需要关注的点更多,处理起来更复杂,感兴趣的可以参考下mosn的实现。
13.总结
热重启作为一种保证服务平滑重启、升级的实现方式,在今天看来依然非常有价值。本文描述了实现热重启的一些大致思路,并且通过demo循序渐进地描述了在go服务中如何予以实现。虽然没有提供一个完整的热重启实例给大家,但是相信大家读完之后应该已经可以亲手实现了。
由于作者本人水平有限,难免会有描述疏漏之处,欢迎大家指正。
参考文章
Unix高级编程:进程间通信,StevenRichards
mosn启动流程:https://mosn.io/blog/code/mosn-startup/
到此这篇关于Go实现热重启的详细介绍的文章就介绍到这了,更多相关go热重启内容请搜索毛票票以前的文章或继续浏览下面的相关文章希望大家以后多多支持毛票票!
声明:本文内容来源于网络,版权归原作者所有,内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:czq8825#qq.com(发邮件时,请将#更换为@)进行举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。