【5-6 Golang】实战—平滑升级

tomato01 · · 1850 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

&emsp;&emsp;Go服务作为常驻进程,想升级怎么办?你是不是想说这还不简单,先杀掉老的服务,再启动新的服务不就完了。可是你有没有想过,在你杀掉老服务的时候,正在处理的请求怎么办?以及老服务退出新服务启动的过程中,客户端请求到达了怎么办?这一简单粗暴的操作,必然会引起瞬时的请求异常。那怎么办,想办法平滑升级呗。 ## 信号 &emsp;&emsp;为什么要先介绍信号呢?因为当我们需要让进程退出的时候,通常就是给进程发送一个退出信号,比如ctrl+C组合其实就是给进程发送了SIGINT信号。发送了信号然后呢?进程当然可以捕获信号了,系统允许进程收到信号后(退出前)做一些处理工作,那这样我们是不是能还能继续处理当前请求,然后关闭连接、释放资源等,完成后再退出,从而实现所谓得平滑退出。 &emsp;&emsp;我们简单介绍下信号,信号分为标准信号(不可靠信号)和实时信号(可靠信号),标准信号是从1-31,实时信号是从32-64。我们熟知的信号比如,SIGINT,SIGQUIT,SIGKILL等等都是标准信号。一般我们给某个进程发送信号,可以使用kill命令,比如kill -9 pid,就是发送SIGKILL信号;kill -INT pid,就可以发送SIGINT信号给进程。 &emsp;&emsp;信号处理器是指当捕获指定信号时(传递给进程)时将会调用的一个函数,信号处理器程序可能随时打断进程的主程序流程。Go语言注册的信号处理器是runtime.sighandler函数。 &emsp;&emsp;当然Go语言中使用信号还是比较简单的,不需要我们再注册信号处理器之类的,如下面程序所示: ``` package main import ( "fmt" "os" "os/signal" "sync" "syscall" ) func main() { c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) wg := sync.WaitGroup{} wg.Add(1) go func() { <- c fmt.Println("quit signal receive, quit") wg.Done() }() wg.Wait() } /* ^C quit signal receive, quit */ ``` &emsp;&emsp;"^C"说明我们按下ctrl+C组合键,这样会给进程发送SIGINT信号,可以看到,先输出语句程序再退出。你可以试一试,如果没有监听SIGINT信号,程序会直接退出,并输出"Process finished with exit code 2"。 &emsp;&emsp;signal.Notify函数注册我们想监听的信号,第一个参数是管道chan类型,当进程捕获到该信号时,会向管道写入数据,此时管道可读,所以我们可以通过读管道感知信号的到来。 &emsp;&emsp;最后,我们简单介绍下Go语言信号处理框架,如下图所示: ![5-6-1.png](https://static.golangjob.cn/221024/4e96c16e984cd97421ccb376062d519e.png) &emsp;&emsp;signal.Notify函数注册管道与监听信号的映射关系,这些数据维护在一个全部的map,key为管道变量,value称之为mask,位标记需要监听的哪些信号;如果之前没有监听过该信号,这里还需要为该信号注册(signal_enable)信号处理器sighandler。进程捕获到信号后,会执行信号处理器sighandler,其再通过异步方式分发信号,一旦我们程序中使用了signal.Notify函数,就会启动子协程循环异步接收信号,并做分发,也就是写数据到对应的管道。 ## 平滑退出 &emsp;&emsp;我们已经了解到如何监听并处理信号了,那如何实现Go进程的平滑退出呢?假设Go进程作为HTTP服务,正在处理请求,接收到退出信号后,是不是应该继续处理这些请求,另外是不是应该避免新的请求进来(关闭监听的socket),等一切处理完毕后,Go进程再退出。 &emsp;&emsp;Go语言本身就提供了平滑结束HTTP服务的方法,所以我们只需要监听退出信号(如SIGINT、SIGTERM等),接收到信号之后调用对应方法就行了: ``` func main() { exit := make(chan interface{}, 0) sig := make(chan os.Signal, 2) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) go waitShutdown(sig, exit, server) //启动HTTP服务 err := server.ListenAndServe() if err != nil { fmt.Println(err) } //只有HTTP服务结束后主协程才能退出 <-exit } func waitShutdown(sig chan os.Signal, exit chan interface{}, server *http.Server) { <-sig ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() //停止HTTP服务,注意context有超时时间 err := server.Shutdown(ctx) //通知主协程,HTTP服务已停止 close(exit) } ``` &emsp;&emsp;注意在停止HTTP服务时,context是有超时时间的,毕竟我们不可能无限制的一直等待。waitShutdown函数返回,说明HTTP服务已经平滑停止了,或者超时了。server.Shutdown方法,完成了我们说的结束前的清理工作。注意主协程还阻塞式读管道exit,为什么呢?因为一旦调用server.Shutdown方法,server.ListenAndServe方法就会报错返回,这时候主协程就结束了,Go程序也就退出了,那正在处理的请求怎么办?所以只有等到waitShutdown函数结束返回时,才说明HTTP服务已经平滑停止,主协程才能结束。 &emsp;&emsp;下面简单看看Shutdown方法的实现: ``` func (srv *Server) Shutdown(ctx context.Context) error { //关闭监听的fd,防止新请求到来 lnerr := srv.closeListenersLocked() //关闭server.doneChan管道,这样服务主循环才能结束 srv.closeDoneChanLocked() //也可以注册一些onShutdown方法,服务结束时回调 for _, f := range srv.onShutdown { go f() } //定时周期性新欢 for { //关闭所有连接 if srv.closeIdleConns() && srv.numListeners() == 0 { return lnerr } select { //超时了 case <-ctx.Done(): return ctx.Err() //重置定时器 case <-timer.C: timer.Reset(nextPollInterval()) } } } func (srv *Server) Serve(l net.Listener) error { for { rw, err := l.Accept() if err != nil { select { //server.doneChan管道已关闭,退出循环 case <-srv.getDoneChan(): return ErrServerClosed default: } } ...... } } ``` &emsp;&emsp;看到了吧,Shutdown方法一运行就关闭了server.doneChan管道,Serve方法死循环就会退出,导致主协程的退出,所以我们一定要等到Shutdown方法结束返回,这才说明HTTP服务平滑退出了。 ## 平滑升级 &emsp;&emsp;经过一系列操作,我们的服务实现平滑退出了,那平滑升级怎么办?也就是代码发布过程中,如果做到平滑不影响服务呢?想想应该怎么办?至少应该先启动新的进程吧,等其正常提供服务时,再停止老的进程。 &emsp;&emsp;其实这里还有一个问题需要解决:旧的进程对于80,8080这种监听端口已经bind并且listen了,如果新的进程进行同样的bind操作,会产生类似这种错误:Address already in use。如何监听这些端口的呢?我们先了解下exec这个系统调用(创建新进程就是通过这个系统调用实现的),其会用新的程序替换现有进程的代码段,数据段,BSS,堆,栈;但fd比较特殊,对于进程创建的fd,exec之后仍然有效(除非设置了FD_CLOEXEC标记),所以新进程还是能使用之前监听的fd的。问题是,这些fd是什么呢?新进程怎么知道监听的fd呢?环境变量是不是可以? &emsp;&emsp;这里推荐一个开源组件 https://github.com/facebookarchive/grace , 其封装了平滑升级相关的处理逻辑,使用起来也比较简单,参考官方demo: ``` package main import ( "flag" "fmt" "net/http" "os" "time" "github.com/facebookgo/grace/gracehttp" ) var ( address = flag.String("addr", ":48567", "Zero address to bind to.") now = time.Now() ) func main() { flag.Parse() gracehttp.Serve( &http.Server{Addr: *address, Handler: newHandler("Zero ")}, ) } func newHandler(name string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/sleep/", func(w http.ResponseWriter, r *http.Request) { duration, err := time.ParseDuration(r.FormValue("duration")) if err != nil { http.Error(w, err.Error(), 400) return } time.Sleep(duration) fmt.Fprintf( w, "%s started at %s slept for %d nanoseconds from pid %d.\n", name, now, duration.Nanoseconds(), os.Getpid(), ) }) return mux } //kill -USR2 pid ``` &emsp;&emsp;只需要使用gracehttp.Serve包装一下我们的HTTP服务,就能实现服务的平滑升级。gracehttp监听的是USR2信号,接收到信号后,创建新的进程,新的进程启动后再平滑停止老的进程,gracehttp包装了HTTP服务启动过程: ``` didInherit = os.Getenv("LISTEN_FDS") != "" ppid = os.Getppid() func (a *app) run() error { //监听:直接创建socket,或者从环境变量读取到了fd,构造socket监听 if err := a.listen(); err != nil { return err } //启动服务 a.serve() // 如果监听fd是继承的,并且父进程不是init进程,杀死父进程(发信号) if didInherit && ppid != 1 { if err := syscall.Kill(ppid, syscall.SIGTERM); err != nil { return fmt.Errorf("failed to close parent: %s", err) } } //监听信号 go a.signalHandler() //等待HTTP服务完全退出 waitdone := make(chan struct{}) go func() { defer close(waitdone) a.wait() }() select { //起新进程报错了 case err := <-a.errors: if err == nil { panic("unexpected nil error") } return err //服务退出了 case <-waitdone: if logger != nil { logger.Printf("Exiting pid %d.", os.Getpid()) } return nil } //到这里老进程就要退出了 } ``` &emsp;&emsp;gracehttp包装的HTTP服务启动过程,第一步就是创建监听fd,只是在平滑升级时候,创建监听是从老的进程继承过来的;第二步就是启动HTTP服务了,HTTP服务启动之后就能发信号结束老的进程了;进程启动后记得一定要监听指定信号,包括SIGINT、SIGTERM让进程平滑退出,以及SIGUSR2启动新的进程;最后,老的进程一定要等到HTTP服务完全结束才能退出,不然可是会影响服务的。 ``` func (a *app) signalHandler(wg *sync.WaitGroup) { ch := make(chan os.Signal, 10) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR2) for { sig := <-ch switch sig { case syscall.SIGINT, syscall.SIGTERM: //平滑退出HTTP服务 return case syscall.SIGUSR2: //启动新的进程 if _, err := a.net.StartProcess(); err != nil { a.errors <- err } } } } //fd写到环境变量 "LISTEN_FDS" func (n *Net) ListenTCP(nett string, laddr *net.TCPAddr) (*net.TCPListener, error) { //继承父进程的fd if err := n.inherit(); err != nil { return nil, err } } ``` &emsp;&emsp;看到了吧,平滑升级还是挺简单的,只需要监听指定信号,先创建新的进程,再让老的进程平滑退出就行了,只是需要注意监听fd的继承逻辑。 ## 总结 &emsp;&emsp;本篇文章核心是介绍平滑退出以及平滑升级的核心逻辑,在开发Go项目还是比较重要的,首先需要了解信号的基本概念,另外可以结合Go net/http标准库,以及gracehttp组件,研究下"平滑"的实现原理。

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1850 次点击  ∙  1 赞  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传