背景

  • golang 程序平滑重啓框架
  • supervisor 出現 defunct 原因
  • 使用 master/worker 模式

背景

在業務快速增長中,前期只是驗證模式是否可行,初期忽略程序發佈重啓帶來的暫短停機影響。當模式實驗成熟之後會逐漸放量,此時我們的發佈停機帶來的影響就會大很多。我們整個服務都是基於雲,請求流量從 四層->七層->機器。

要想實現平滑重啓大致有三種方案,一種是在流量調度的入口處理,一般的做法是 ApiGateway + CD ,發佈的時候自動摘除機器,等待程序處理完現有請求再做發佈處理,這樣的好處就是程序不需要關心如何做平滑重啓。

第二種就是程序自己完成平滑重啓,保證在重啓的時候 listen socket FD(文件描述符) 依然可以接受請求進來,只不過切換新老進程,但是這個方案需要程序自己去完成,有些技術棧可能實現起來不是很簡單,有些語言無法控制到操作系統級別,實現起來會很麻煩。

第三種方案就是完全 docker,所有的東西交給 k8s 統一管理,我們正在小規模接入中。

golang 程序平滑重啓框架

java、net 等基於虛擬機的語言不同,golang 天然支持系統級別的調用,平滑重啓處理起來很容易。從原理上講,基於 linux fork 子進程的方式,啓動新的代碼,再切換 listen socket FD,原理固然不難,但是完全自己實現還是會有很多細節問題的。好在有比較成熟的開源庫幫我們實現了。

上面兩個是 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 文件是不能直接使用的,我們需要適當的修改。


#!/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 腳本文件,還是比較有意思的,知道真正問題在哪裏如何修改,就完全由你來發揮了。

作者:王清培