您好,登錄后才能下訂單哦!
在業務快速增長中,前期只是驗證模式是否可行,初期忽略程序發布重啟帶來的暫短停機影響。當模式實驗成熟之后會逐漸放量,此時我們的發布停機帶來的影響就會大很多。我們整個服務都是基于云,請求流量從 四層->七層->機器。
要想實現平滑重啟大致有三種方案,一種是在流量調度的入口處理,一般的做法是 ApiGateway + CD ,發布的時候自動摘除機器,等待程序處理完現有請求再做發布處理,這樣的好處就是程序不需要關心如何做平滑重啟。
第二種就是程序自己完成平滑重啟,保證在重啟的時候 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)
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。