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 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   [ ...]")

    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)