在業務快速增加中,前期只是驗證模式是否可行,初期忽略程序發佈重啓帶來的暫短停機影響。當模式實驗成熟以後會逐漸放量,此時咱們的發佈停機帶來的影響就會大不少。咱們整個服務都是基於雲,請求流量從 四層->七層->機器。java
要想實現平滑重啓大體有三種方案,一種是在流量調度的入口處理,通常的作法是 ApiGateway + CD ,發佈的時候自動摘除機器,等待程序處理完現有請求再作發佈處理,這樣的好處就是程序不須要關心如何作平滑重啓。python
第二種就是程序本身完成平滑重啓,保證在重啓的時候 listen socket FD(文件描述符) 依然能夠接受請求進來,只不過切換新老進程,可是這個方案須要程序本身去完成,有些技術棧可能實現起來不是很簡單,有些語言沒法控制到操做系統級別,實現起來會很麻煩。mysql
第三種方案就是徹底 docker,全部的東西交給 k8s 統一管理,咱們正在小規模接入中。linux
與 java、net 等基於虛擬機的語言不一樣,golang 自然支持系統級別的調用,平滑重啓處理起來很容易。從原理上講,基於 linux fork 子進程的方式,啓動新的代碼,再切換 listen socket FD,原理當然不難,可是徹底本身實現仍是會有不少細節問題的。好在有比較成熟的開源庫幫咱們實現了。git
graceful https://github.com/tylerb/graceful
endless https://github.com/fvbock/endlessgithub
上面兩個是 github 排名靠前的 web host 框架,都是支持平滑重啓的,只不過接受的進程信號有點區別 endless 接受 signal HUP,graceful 接受 signal USR2 。graceful 比較純粹的 web host,endless 支持一些 routing 的能力。golang
咱們看下 endless 處理信號。(若是對 srv.fork() 內部感興趣能夠品讀品讀。)web
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後作自動拉起,退出時能接收到進程退出信號。sql
可是若是咱們用了平滑重啓框架,原來被 supervisor 啓動的進程發佈重啓 __fork__子進程以後正常退出,當再次發佈重啓 fork 子進程後就會變成無主進程就會出現 defunct(殭屍進程) 的問題,緣由就是此子進程沒法完成退出,沒有主進程來接受它退出的信號,退出進程自己的少許數據結構沒法銷燬。docker
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)