golang 服务平滑重启小结
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 HUP,graceful 接受 signal USR2 。graceful 比较纯粹的 web host,endless 支持一些 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 保存在一个文件中,一般大型分布式软件都需要这样的一个文件,mysql、zookeeper 等,目的就是为了拿到目标进程id。
这其实是一种 master/worker 模式,master 进程交给 supervisor 管理,supervisor 启动 master 进程,也就是 pidproxy 程序,再由 pidproxy 来启动我们目标程序,随便我们目标程序 fork 多少次子进程都不会影响 pidproxy master 进程。
pidproxy 依赖 PID 文件,我们需要保证程序每次启动的时候都要写入当前进程 id 进 PID 文件,这样 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)