從1.8開始搬男,Go標(biāo)準(zhǔn)庫(kù)中的net/http支持了GracefulShutdown
腺兴,使得進(jìn)程可以把現(xiàn)有請(qǐng)求都處理完之后再退出悼尾,從而最大限度地減少不一致性給服務(wù)端帶來的負(fù)擔(dān)傻铣。如果不做GracefulShutdown章贞,有哪些不一致性呢?簡(jiǎn)單舉個(gè)例子:
服務(wù)T是類似微博的點(diǎn)贊功能,當(dāng)用戶點(diǎn)贊某條微博的時(shí)候,一方面要給點(diǎn)贊數(shù)+1靴迫,另一方面要通知post的作者“XXX贊了你的微博”友酱,同時(shí)還要有策略通知點(diǎn)贊人的粉絲“你關(guān)注的XXX點(diǎn)贊了這條微博”……當(dāng)然這些功能不是一個(gè)事務(wù)证九,而且也不是同步的,應(yīng)該異步來做。所以,最終的流程可能是:
db.IncrPostNumber()
mqA.Send(messageA)
mqB.Send(messageB)
//...
如果不做gracefulShutdown赡麦,在中途的任何一個(gè)步驟時(shí),進(jìn)程被殺掉帕识,都可能造成一些問題泛粹。當(dāng)然就這個(gè)例子來說,往小了說也不是什么大事肮疗,這些問題都可以忍受晶姊。但往大了說,大V通過點(diǎn)贊讓粉絲看到某條微博伪货,這也是收費(fèi)的们衙。結(jié)果廣告主給了錢卻看不到效果,甚至發(fā)現(xiàn)根本沒有粉絲看到碱呼,這是要讓你退錢的蒙挑!
所以做GracefulShutdown,不論對(duì)什么業(yè)務(wù)系統(tǒng)來說巍举,都是很有必要的脆荷。但是本文我們不討論GracefulShutdown,而是討論一個(gè)更進(jìn)一步的話題懊悯,Graceful Restart。
GracefulShutdown和Graceful Restart是什么區(qū)別呢梦皮?從名字上大概就能看出炭分,一個(gè)是優(yōu)雅退出,一個(gè)是優(yōu)雅重啟剑肯。優(yōu)雅退出上面也說了捧毛,重點(diǎn)是保證進(jìn)程退出前處理完當(dāng)下所有的請(qǐng)求。而優(yōu)雅重啟要求更高,它的目標(biāo)是在進(jìn)程重啟時(shí)整個(gè)過程要平滑呀忧,不要讓用戶感受到任何異樣师痕,不要有任何downtime,也就是停機(jī)時(shí)間而账,保證進(jìn)程持續(xù)可用胰坟。因此,gracefulShutdown只是實(shí)現(xiàn)gracefulRestart的一個(gè)必要部分泞辐,gracefulRestart還要求更多笔横。
一種GracefulRestart的方法是,通過部署系統(tǒng)配合nginx來完成咐吼。由于大部分業(yè)務(wù)系統(tǒng)都是掛在nginx之后通過nginx進(jìn)行反向代理的吹缔,因此在重啟某臺(tái)機(jī)器的進(jìn)程A時(shí),可以把該機(jī)器IP從nginx的upstream中摘除掉锯茄,等一段時(shí)間比如1分鐘厢塘,該進(jìn)程差不多也處理完了所以請(qǐng)求,實(shí)際上已經(jīng)處于空閑狀態(tài)了肌幽。這時(shí)就可以kill掉該進(jìn)程并重啟俗冻,等重啟成功之后,再把該機(jī)器的IP加回到nginx對(duì)應(yīng)的upstream中去牍颈。
這種方式是語(yǔ)言迄薄、平臺(tái)無關(guān)的一種技術(shù)方案,但是缺點(diǎn)也很明顯:
- 首先就是復(fù)雜煮岁,需要部署系統(tǒng)和網(wǎng)關(guān)(nginx)恰到好處地配合讥蔽。開發(fā)人員點(diǎn)擊部署時(shí),部署系統(tǒng)需要通知nginx摘掉某個(gè)upstream的某個(gè)IP画机;然后等進(jìn)程重啟成功之后冶伞,部署系統(tǒng)需要通知nginx在某個(gè)upstream中加上某個(gè)IP。這一整套系統(tǒng)的開發(fā)測(cè)試還是有一定復(fù)雜性的步氏。
- 其次是等待時(shí)間的未知性响禽。當(dāng)把機(jī)器A摘掉以后過多久進(jìn)程才能處理完請(qǐng)求?10秒荚醒?1分鐘芋类?誰(shuí)也不知道…間隔短了,會(huì)出問題界阁,因?yàn)椴糠终?qǐng)求被卡斷了侯繁;間隔長(zhǎng)了,上線又慢泡躯,而且你還是不能確定是否請(qǐng)求都處理完了(其實(shí)基本上沒問題贮竟,但是理論上無法保證)丽焊。
- 另一個(gè)問題是壓力陡增。對(duì)于大公司動(dòng)輒幾百臺(tái)的集群咕别,摘一兩臺(tái)無關(guān)緊要技健。但是對(duì)于小公司,比如某個(gè)服務(wù)只有兩臺(tái)機(jī)器惰拱,并且每臺(tái)機(jī)器壓力都挺大雌贱。這時(shí)如果直接摘一臺(tái),所有流量到另一臺(tái)機(jī)器上弓颈,使得那臺(tái)機(jī)器承受不住帽芽,那么可能會(huì)導(dǎo)致整個(gè)服務(wù)不可用。
因此這里引出第二種實(shí)現(xiàn)方式——fd繼承
FD繼承
fd(file descriptor)也就是文件描述符翔冀,是Unix*系統(tǒng)上最常見的概念导街,everything is file。我們基于一個(gè)非诚俗樱基礎(chǔ)的知識(shí)點(diǎn):
進(jìn)程T fork 出子進(jìn)程時(shí)搬瑰,子進(jìn)程會(huì)繼承父進(jìn)程T打開的fd。
進(jìn)程T大概的處理流程類似于:
int sock_fd = createSocketBindTo(":80");
int ok = listen(sock_fd, backlog);
do {
int connect_sock = accept(sock_fd, &SockStruct, &Addr);
process(connect_sock);
}
也就是:
- 構(gòu)建監(jiān)聽某個(gè)端口的socket
- 不斷從該socket中讀取連接控硼,并處理
這里你可以發(fā)現(xiàn)泽论,如果想要accept到連接,我們只需要socket就夠了卡乾,bind listen這些都是準(zhǔn)備工作翼悴。如果父進(jìn)程把這些工作都做了,子進(jìn)程似乎可以直接從繼承過來的socket上讀取數(shù)據(jù)幔妨。
這里先不說具體實(shí)現(xiàn)細(xì)節(jié)鹦赎,但是大體思路其實(shí)就是上面說的,非常簡(jiǎn)單误堡。進(jìn)程通過環(huán)境變量或者args來判斷是應(yīng)該先Listen再accpet古话,還是直接用繼承來的socket進(jìn)行accept。
這里有個(gè)問題锁施,子進(jìn)程如果在該socket上accept陪踩,主進(jìn)程也accept,那么對(duì)同一個(gè)socket進(jìn)行accept操作并發(fā)安全嗎悉抵?答案是——安全肩狂,這是glibc為我們保證的,正如malloc這類函數(shù)調(diào)用一樣基跑。
下面是一個(gè)簡(jiǎn)單的代碼示例:
package main
import (
"context"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"syscall"
)
var (
upgrade bool
ln net.Listener
server *http.Server
)
func init() {
flag.BoolVar(&upgrade, "upgrade", false, "user can't use this")
}
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello world from pid:%d, ppid: %d\n", os.Getpid(), os.Getppid())
}
func main() {
flag.Parse()
http.HandleFunc("/", hello)
server = &http.Server{Addr:":8999",}
var err error
if upgrade {
fd := os.NewFile(3, "")
ln,err = net.FileListener(fd)
if err != nil {
fmt.Printf("fileListener fail, error: %s\n", err)
os.Exit(1)
}
fd.Close()
} else {
ln, err = net.Listen("tcp", server.Addr)
if err != nil {
fmt.Printf("listen %s fail, error: %s\n", server.Addr, err)
os.Exit(1)
}
}
go func() {
err := server.Serve(ln)
if err != nil && err != http.ErrServerClosed{
fmt.Printf("serve error: %s\n", err)
}
}()
setupSignal()
fmt.Println("over")
}
func setupSignal() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGUSR2, syscall.SIGINT, syscall.SIGTERM)
sig := <-ch
switch sig {
case syscall.SIGUSR2:
err := forkProcess()
if err != nil {
fmt.Printf("fork process error: %s\n", err)
}
err = server.Shutdown(context.Background())
if err != nil {
fmt.Printf("shutdown after forking process error: %s\n", err)
}
case syscall.SIGINT,syscall.SIGTERM:
signal.Stop(ch)
close(ch)
err := server.Shutdown(context.Background())
if err != nil {
fmt.Printf("shutdown error: %s\n", err)
}
}
}
func forkProcess() error {
flags := []string{"-upgrade"}
cmd := exec.Command(os.Args[0], flags...)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
l,_ := ln.(*net.TCPListener)
lfd,err := l.File()
if err != nil {
return err
}
cmd.ExtraFiles = []*os.File{lfd,}
return cmd.Start()
}
代碼中很關(guān)鍵的兩行:
fd := os.NewFile(3, "")
ln,err = net.FileListener(fd)
fd.Close()
3是什么婚温?3其實(shí)就是從父進(jìn)程繼承過來的socket fd。雖然子進(jìn)程可以默認(rèn)繼承父進(jìn)程絕大多數(shù)的文件描述符(除了文件鎖之類的)媳否,但是golang的標(biāo)準(zhǔn)庫(kù)os/exec只默認(rèn)繼承stdin stdout stderr這三個(gè)栅螟。需要讓子進(jìn)程繼承的fd需要在fork之前手動(dòng)放到ExtraFiles中。由于有了stdin 0 stdout 1 stderr 2篱竭,因此其它fd的序號(hào)從3開始力图。
還有一個(gè)可能比較讓人困惑的問題是,fd.Close()
是干什么的掺逼,Close它會(huì)有什么影響吃媒。這個(gè)問題直接的答案是,沒有任何影響吕喘,只是為了防止資源泄漏赘那。具體可以看看net.FileListerner
的文檔,相關(guān)的知識(shí)點(diǎn)有點(diǎn)多氯质,可以google fcntl和dup2關(guān)鍵字募舟。
當(dāng)子進(jìn)程運(yùn)行起來后,就可以調(diào)用server實(shí)現(xiàn)好的Shutdown方法闻察,來關(guān)停主進(jìn)程了拱礁。
這種方法代來的一個(gè)問題是,當(dāng)主進(jìn)程fork出子進(jìn)程辕漂,然后主進(jìn)程退出后呢灶,子進(jìn)程的父進(jìn)程就變成了1(孤兒進(jìn)程)。如果使用supervisor等工具來監(jiān)聽服務(wù)的話钉嘹,就會(huì)遇到問題(主進(jìn)程退出了立刻又被supervisor拉起來鸯乃,然后端口沖突了)。這時(shí)候就需要使用linux pidfile跋涣。
RE_USEPORT
還有第三種可以做到不停機(jī)重啟的辦法缨睡,那便是使用Linux內(nèi)核的新特性reuseport。以前仆潮,如果多個(gè)進(jìn)程或者線程同時(shí)監(jiān)聽一個(gè)端口宏蛉,只有一個(gè)可以成功,其它都會(huì)返回端口被占用的錯(cuò)誤性置。
新內(nèi)核支持通過setsockopt對(duì)socket進(jìn)行設(shè)置拾并,使得多個(gè)進(jìn)程或者線程可以同時(shí)監(jiān)聽一個(gè)端口,內(nèi)核來進(jìn)行負(fù)載均衡鹏浅。
利用多進(jìn)程模型加上reuseport庫(kù)的支持嗅义,很容易就可以實(shí)現(xiàn)不停機(jī)重啟。
但是隐砸,reuseport也不是萬(wàn)能的靈丹妙藥之碗,它也有自己的問題,在連接建立非常頻繁的場(chǎng)景下季希,由于內(nèi)核使用的算法的局限性褪那,它的性能會(huì)下降很多幽纷。當(dāng)然,這和不停機(jī)重啟沒有任何關(guān)系博敬,只是順便一提友浸,如果僅僅使用reuseport特性實(shí)現(xiàn)gracefulRestart,應(yīng)該不會(huì)遇到這樣的問題偏窝。
nginx高版本也使用了reuseport收恢,關(guān)于它的性能問題,可以參見這篇文章
到底是通過繼承fd還是reuseport來實(shí)現(xiàn)graceful restart祭往,相關(guān)的比較可以參見https://gravitational.com/blog/golang-ssh-bastion-graceful-restarts/伦意,不過結(jié)論基本上認(rèn)為繼承fd更靠譜(當(dāng)然這篇文章得出的結(jié)論也受限于當(dāng)時(shí)golang本身標(biāo)準(zhǔn)庫(kù)實(shí)現(xiàn)的局限性,使得沒辦法對(duì)Conn進(jìn)行setsockopt硼补,因?yàn)镃onn不是一個(gè)socket對(duì)象而是一個(gè)runtime.NetPoller)
More
現(xiàn)在開源社區(qū)有不少相關(guān)的實(shí)現(xiàn)驮肉,比如:
- https://github.com/jpillora/overseer
- https://github.com/facebookarchive/grace
- https://github.com/mholt/caddy 內(nèi)部實(shí)現(xiàn)基于fd繼承,未對(duì)外暴露API