• 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...
    ", 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 that
    signal to the process identified by a pidfile. """
    
    import os
    import sys
    import signal
    import time
    
    class 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 <pidfile name> <command> [<cmdarg1> ...]")
    
        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)

  • 相关阅读:
    jQuery
    我的微信公众号被封了
    安装pillow
    win 10 无线标志都不出现
    msfvenom 摄像头
    Python list的定义和删改
    Python md5解密
    Python 同ip网站查询(制作网站接口)
    python 带参数运行
    Python 查看本机WiFi密码
  • 原文地址:https://www.cnblogs.com/wangiqngpei557/p/11704747.html
Copyright © 2020-2023  润新知