golang 服务平滑重启小结

标签: graceful endless supervisor defunct


  • 背景
  • 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 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)

本站文章资源均来源自网络,除非特别声明,否则均不代表站方观点,并仅供查阅,不作为任何参考依据!
如有侵权请及时跟我们联系,本站将及时删除!
如遇版权问题,请查看 本站版权声明
THE END
分享
二维码
海报
<<上一篇
下一篇>>