天天看點

golang 服務平滑重新開機小結

golang 服務平滑重新開機小結

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

要想實作平滑重新開機大緻有三種方案,一種是在流量排程的入口處理,一般的做法是 ApiGateway + CD ,釋出的時候自動摘除機器,等待程式處理完現有請求再做釋出處理,這樣的好處就是程式不需要關心如何做平滑重新開機。

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

  • 背景
  • golang 程式平滑重新開機架構
  • supervisor 出現 defunct 原因
  • 使用 master/worker 模式

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

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

與 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 管理的程序,中間需要加一層代理,原因就是 supervisor 可以管理自己啟動的程序,意思就是 supervisor 可以拿到自己啟動的程序id(PID),可以檢測程序是否還存活,carsh後做自動拉起,退出時能接收到程序退出信号。

但是如果我們用了平滑重新開機架構,原來被 supervisor 啟動的程序釋出重新開機 __fork__子程序之後正常退出,當再次釋出重新開機 fork 子程序後就會變成無主程序就會出現 defunct(僵屍程序) 的問題,原因就是此子程序無法完成退出,沒有主程序來接受它退出的信号,退出程序本身的少量資料結構無法銷毀。

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 <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)

繼續閱讀