热门IT资讯网

golang 服务平滑重启小结

发表于:2024-11-28 作者:热门IT资讯网编辑
编辑最后更新 2024年11月28日,golang 服务平滑重启小结背景golang 程序平滑重启框架supervisor 出现 defunct 原因使用 master/worker 模式背景在业务快速增长中,前期只是验证模式是否可行,初

golang 服务平滑重启小结


  • 背景
  • golang 程序平滑重启框架
  • supervisor 出现 defunct 原因
  • 使用 master/worker 模式

背景

在业务快速增长中,前期只是验证模式是否可行,初期忽略程序发布重启带来的暂短停机影响。当模式实验成熟之后会逐渐放量,此时我们的发布停机带来的影响就会大很多。我们整个服务都是基于云,请求流量从 四层->七层->机器。

要想实现平滑重启大致有三种方案,一种是在流量调度的入口处理,一般的做法是 ApiGateway + CD ,发布的时候自动摘除机器,等待程序处理完现有请求再做发布处理,这样的好处就是程序不需要关心如何做平滑重启。

第二种就是程序自己完成平滑重启,保证在重启的时候 listen socket FD(文件描述符) 依然可以接受请求进来,只不过切换新老进程,但是这个方案需要程序自己去完成,有些技术栈可能实现起来不是很简单,有些语言无法控制到操作系统级别,实现起来会很麻烦。

第三种方案就是完全 docker,所有的东西交给 k8s 统一管理,我们正在小规模接入中。

golang 程序平滑重启框架

java、net 等基于虚拟机的语言不同,golang 天然支持系统级别的调用,平滑重启处理起来很容易。从原理上讲,基于 linux fork 子进程的方式,启动新的代码,再切换 listen socket FD,原理固然不难,但是完全自己实现还是会有很多细节问题的。好在有比较成熟的开源库帮我们实现了。

graceful https://github.com/tylerb/graceful
endless https://github.com/fvbock/endless

上面两个是 github 排名靠前的 web host 框架,都是支持平滑重启的,只不过接受的进程信号有点区别 endless 接受 signal HUPgraceful 接受 signal USR2graceful 比较纯粹的 web hostendless 支持一些 routing 的能力。

我们看下 endless 处理信号。(如果对 srv.fork() 内部感兴趣可以品读品读。)

func (srv *endlessServer) handleSignals() {    var sig os.Signal    signal.Notify(        srv.sigChan,        hookableSignals...,    )    pid := syscall.Getpid()    for {        sig = <-srv.sigChan        srv.signalHooks(PRE_SIGNAL, sig)        switch sig {        case syscall.SIGHUP:            log.Println(pid, "Received SIGHUP. forking.")            err := srv.fork()            if err != nil {                log.Println("Fork err:", err)            }        case syscall.SIGUSR1:            log.Println(pid, "Received SIGUSR1.")        case syscall.SIGUSR2:            log.Println(pid, "Received SIGUSR2.")            srv.hammerTime(0 * time.Second)        case syscall.SIGINT:            log.Println(pid, "Received SIGINT.")            srv.shutdown()        case syscall.SIGTERM:            log.Println(pid, "Received SIGTERM.")            srv.shutdown()        case syscall.SIGTSTP:            log.Println(pid, "Received SIGTSTP.")        default:            log.Printf("Received %v: nothing i care about...\n", sig)        }        srv.signalHooks(POST_SIGNAL, sig)    }}

supervisor 出现 defunct 原因

使用 supervisor 管理的进程,中间需要加一层代理,原因就是 supervisor 可以管理自己启动的进程,意思就是 supervisor 可以拿到自己启动的进程id(PID),可以检测进程是否还存活,carsh后做自动拉起,退出时能接收到进程退出信号。

但是如果我们用了平滑重启框架,原来被 supervisor 启动的进程发布重启 fork子进程之后正常退出,当再次发布重启 fork 子进程后就会变成无主进程就会出现 defunct(僵尸进程) 的问题,原因就是此子进程无法完成退出,没有主进程来接受它退出的信号,退出进程本身的少量数据结构无法销毁。

使用 master/worker 模式

supervisor 本身提供了 pidproxy 程序,我们在配置 supervisor command 时候使用 pidproxy 来做一层代理。由于进程的id会随着不停的发布 fork 子进程而变化,所以需要将程序的每次启动 PID 保存在一个文件中,一般大型分布式软件都需要这样的一个文件,mysqlzookeeper 等,目的就是为了拿到目标进程id。

这其实是一种 master/worker 模式,master 进程交给 supervisor 管理,supervisor 启动 master 进程,也就是 pidproxy 程序,再由 pidproxy 来启动我们目标程序,随便我们目标程序 fork 多少次子进程都不会影响 pidproxy master 进程。

pidproxy 依赖 PID 文件,我们需要保证程序每次启动的时候都要写入当前进程 idPID 文件,这样 pidproxy 才能工作。
supervisor 默认的 pidproxy 文件是不能直接使用的,我们需要适当的修改。

https://github.com/Supervisor/supervisor/blob/master/supervisor/pidproxy.py

#!/usr/bin/env python""" An executable which proxies for a subprocess; upon a signal, it sends thatsignal to the process identified by a pidfile. """import osimport sysimport signalimport timeclass PidProxy:    pid = None    def __init__(self, args):        self.setsignals()        try:            self.pidfile, cmdargs = args[1], args[2:]            self.command = os.path.abspath(cmdargs[0])            self.cmdargs = cmdargs        except (ValueError, IndexError):            self.usage()            sys.exit(1)    def go(self):        self.pid = os.spawnv(os.P_NOWAIT, self.command, self.cmdargs)        while 1:            time.sleep(5)            try:                pid = os.waitpid(-1, os.WNOHANG)[0]            except OSError:                pid = None            if pid:                break    def usage(self):        print("pidproxy.py   [ ...]")    def setsignals(self):        signal.signal(signal.SIGTERM, self.passtochild)        signal.signal(signal.SIGHUP, self.passtochild)        signal.signal(signal.SIGINT, self.passtochild)        signal.signal(signal.SIGUSR1, self.passtochild)        signal.signal(signal.SIGUSR2, self.passtochild)        signal.signal(signal.SIGQUIT, self.passtochild)        signal.signal(signal.SIGCHLD, self.reap)    def reap(self, sig, frame):        # do nothing, we reap our child synchronously        pass    def passtochild(self, sig, frame):        try:            with open(self.pidfile, 'r') as f:                pid = int(f.read().strip())        except:            print("Can't read child pidfile %s!" % self.pidfile)            return        os.kill(pid, sig)        if sig in [signal.SIGTERM, signal.SIGINT, signal.SIGQUIT]:            sys.exit(0)def main():    pp = PidProxy(sys.argv)    pp.go()if __name__ == '__main__':    main()

我们重点看下这个方法:

def go(self):        self.pid = os.spawnv(os.P_NOWAIT, self.command, self.cmdargs)        while 1:            time.sleep(5)            try:                pid = os.waitpid(-1, os.WNOHANG)[0]            except OSError:                pid = None            if pid:                break

go 方法是守护方法,会拿到启动进程的id,然后做 waitpid ,但是当我们 fork 进程的时候主进程会退出,os.waitpid 会收到退出信号,然后就退出了,但是这是个正常的切换逻辑。

可以两个办法解决,第一个就是让 go 方法纯粹是个守护进程,去掉退出逻辑,在信号处理方法中处理:

    def passtochild(self, sig, frame):        pid = self.getPid()        os.kill(pid, sig)        time.sleep(5)        try:            pid = os.waitpid(self.pid, os.WNOHANG)[0]        except OSError:            print("wait pid null pid %s", self.pid)        print("pid shutdown.%s", pid)        self.pid = self.getPid()        if self.pid == 0:            sys.exit(0)        if sig in [signal.SIGTERM, signal.SIGINT, signal.SIGQUIT]:            print("exit:%s", sig)            sys.exit(0)

还有一个方法就是修改原有go方法:

    def go(self):        self.pid = os.spawnv(os.P_NOWAIT, self.command, self.cmdargs)        while 1:            time.sleep(5)            try:                pid = os.waitpid(-1, os.WNOHANG)[0]            except OSError:                pid = None            try:                with open(self.pidfile, 'r') as f:                    pid = int(f.read().strip())            except:                print("Can't read child pidfile %s!" % self.pidfile)            try:                os.kill(pid, 0)            except OSError:                sys.exit(0)

当然还可以用其他方法或者思路,这里只是抛出问题。如果你想知道真正问题在哪里,可以直接在本地 debug pidproxy 脚本文件,还是比较有意思的,知道真正问题在哪里如何修改,就完全由你来发挥了。

作者:王清培 (趣头条 Tech Leader)

0