背景
- 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() 内部感兴趣可以品读品读。)
代码语言:javascript复制
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),可以检测进程是否还存活,crash后做自动拉起,退出时能接收到进程退出信号。
但是如果我们用了平滑重启框架,原来被 supervisor 启动的进程发布重启 fork子进程之后正常退出,当再次发布重启 fork 的子进程就会变成没有主进程,那么,此子进程就无法完成正常退出。这样, defunct(僵尸进程) 问题就出现了。这个子进程无法完成退出的原因是没有接受子进程退出信号的主进程。同时,退出进程本身在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 文件是不能直接使用的,我们需要适当的修改。
代码语言:javascript复制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
classPidProxy:
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)
while1:
time.sleep(5)
try:
pid = os.waitpid(-1, os.WNOHANG)[0]
exceptOSError:
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()
我们重点看下这个方法:
代码语言:javascript复制
def go(self):
self.pid = os.spawnv(os.P_NOWAIT,self.command,self.cmdargs)
while1:
time.sleep(5)
try:
pid = os.waitpid(-1, os.WNOHANG)[0]
exceptOSError:
pid =None
if pid:
break
go 方法是守护方法,会拿到启动进程的id,然后做 waitpid ,但是当我们 fork 进程的时候主进程会退出,os.waitpid 会收到退出信号,然后就退出了,但是这是个正常的切换逻辑。
可以使用两个办法解决,第一个就是让 go 方法纯粹是个守护进程,去掉退出逻辑,在信号处理方法中处理:
代码语言:javascript复制
def passtochild(self, sig, frame):
pid =self.getPid()
os.kill(pid, sig)
time.sleep(5)
try:
pid = os.waitpid(self.pid, os.WNOHANG)[0]
exceptOSError:
print("wait pid null pid %s",self.pid)
print("pid shutdown.%s", pid)
self.pid =self.getPid()
ifself.pid ==0:
sys.exit(0)
if sig in[signal.SIGTERM, signal.SIGINT, signal.SIGQUIT]:
print("exit:%s", sig)
sys.exit(0)
还有一个方法就是修改原有go方法:
代码语言:javascript复制
def go(self):
self.pid = os.spawnv(os.P_NOWAIT,self.command,self.cmdargs)
while1:
time.sleep(5)
try:
pid = os.waitpid(-1, os.WNOHANG)[0]
exceptOSError:
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)
exceptOSError:
sys.exit(0)
当然还可以用其他方法或者思路,这里只是抛出问题。如果你想知道真正问题在哪里,可以直接在本地 debug pidproxy 脚本文件,还是比较有意思的,知道真正问题在哪里如何修改,就完全由你来发挥了。