在業(yè)務(wù)快速增長中,前期只是驗(yàn)證模式是否可行,初期忽略程序發(fā)布重啟帶來的暫短停機(jī)影響。當(dāng)模式實(shí)驗(yàn)成熟之后會(huì)逐漸放量,此時(shí)我們的發(fā)布停機(jī)帶來的影響就會(huì)大很多。我們整個(gè)服務(wù)都是基于云,請求流量從 四層->七層->機(jī)器。
目前創(chuàng)新互聯(lián)公司已為1000多家的企業(yè)提供了網(wǎng)站建設(shè)、域名、網(wǎng)站空間、成都網(wǎng)站托管、企業(yè)網(wǎng)站設(shè)計(jì)、彝良網(wǎng)站維護(hù)等服務(wù),公司將堅(jiān)持客戶導(dǎo)向、應(yīng)用為本的策略,正道將秉承"和諧、參與、激情"的文化,與客戶和合作伙伴齊心協(xié)力一起成長,共同發(fā)展。
要想實(shí)現(xiàn)平滑重啟大致有三種方案,一種是在流量調(diào)度的入口處理,一般的做法是 ApiGateway + CD,發(fā)布的時(shí)候自動(dòng)摘除機(jī)器,等待程序處理完現(xiàn)有請求再做發(fā)布處理,這樣的好處就是程序不需要關(guān)心如何做平滑重啟。
第二種就是程序自己完成平滑重啟,保證在重啟的時(shí)候 listen socket FD(文件描述符) 依然可以接受請求進(jìn)來,只不過切換新老進(jìn)程,但是這個(gè)方案需要程序自己去完成,有些技術(shù)??赡軐?shí)現(xiàn)起來不是很簡單,有些語言無法控制到操作系統(tǒng)級(jí)別,實(shí)現(xiàn)起來會(huì)很麻煩。
第三種方案就是完全 docker,所有的東西交給 k8s統(tǒng)一管理,我們正在小規(guī)模接入中。
與 java、net等基于虛擬機(jī)的語言不同,golang天然支持系統(tǒng)級(jí)別的調(diào)用,平滑重啟處理起來很容易。從原理上講,基于 linux fork子進(jìn)程的方式,啟動(dòng)新的代碼,再切換 listen socket FD,原理固然不難,但是完全自己實(shí)現(xiàn)還是會(huì)有很多細(xì)節(jié)問題的。好在有比較成熟的開源庫幫我們實(shí)現(xiàn)了。
graceful https://github.com/tylerb/graceful
endless https://github.com/fvbock/endless
上面兩個(gè)是 github排名靠前的 web host框架,都是支持平滑重啟的,只不過接受的進(jìn)程信號(hào)有點(diǎn)區(qū)別 endless接受 signal HUP,graceful接受 signal USR2。graceful比較純粹的 web host,endless支持一些 routing的能力。
我們看下 endless處理信號(hào)。(如果對 srv.fork()內(nèi)部感興趣可以品讀品讀。)
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管理的進(jìn)程,中間需要加一層代理,原因就是 supervisor可以管理自己啟動(dòng)的進(jìn)程,意思就是 supervisor可以拿到自己啟動(dòng)的進(jìn)程id(PID),可以檢測進(jìn)程是否還存活,carsh后做自動(dòng)拉起,退出時(shí)能接收到進(jìn)程退出信號(hào)。
但是如果我們用了平滑重啟框架,原來被 supervisor啟動(dòng)的進(jìn)程發(fā)布重啟 fork子進(jìn)程之后正常退出,當(dāng)再次發(fā)布重啟 fork子進(jìn)程后就會(huì)變成無主進(jìn)程就會(huì)出現(xiàn) defunct(僵尸進(jìn)程)的問題,原因就是此子進(jìn)程無法完成退出,沒有主進(jìn)程來接受它退出的信號(hào),退出進(jìn)程本身的少量數(shù)據(jù)結(jié)構(gòu)無法銷毀。
supervisor本身提供了 pidproxy程序,我們在配置 supervisor command時(shí)候使用 pidproxy來做一層代理。由于進(jìn)程的id會(huì)隨著不停的發(fā)布 fork子進(jìn)程而變化,所以需要將程序的每次啟動(dòng) PID保存在一個(gè)文件中,一般大型分布式軟件都需要這樣的一個(gè)文件,MySQL、zookeeper等,目的就是為了拿到目標(biāo)進(jìn)程id。
這其實(shí)是一種 master/worker模式,master進(jìn)程交給 supervisor管理,supervisor啟動(dòng) master進(jìn)程,也就是 pidproxy程序,再由 pidproxy來啟動(dòng)我們目標(biāo)程序,隨便我們目標(biāo)程序 fork多少次子進(jìn)程都不會(huì)影響 pidproxy master進(jìn)程。
pidproxy依賴 PID文件,我們需要保證程序每次啟動(dòng)的時(shí)候都要寫入當(dāng)前進(jìn)程 id進(jìn) PID文件,這樣 pidproxy才能工作。
supervisor默認(rèn)的 pidproxy文件是不能直接使用的,我們需要適當(dāng)?shù)男薷摹?/p>
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()
我們重點(diǎn)看下這個(gè)方法:
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 方法是守護(hù)方法,會(huì)拿到啟動(dòng)進(jìn)程的id,然后做 waitpid,但是當(dāng)我們 fork進(jìn)程的時(shí)候主進(jìn)程會(huì)退出,os.waitpid會(huì)收到退出信號(hào),然后就退出了,但是這是個(gè)正常的切換邏輯。
可以兩個(gè)辦法解決,第一個(gè)就是讓 go方法純粹是個(gè)守護(hù)進(jìn)程,去掉退出邏輯,在信號(hào)處理方法中處理:
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)
還有一個(gè)方法就是修改原有g(shù)o方法:
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)
當(dāng)然還可以用其他方法或者思路,這里只是拋出問題。如果你想知道真正問題在哪里,可以直接在本地 debug pidproxy腳本文件,還是比較有意思的,知道真正問題在哪里如何修改,就完全由你來發(fā)揮了。
作者:王清培 (趣頭條 Tech Leader)