乐趣区

关于go:56-Golang实战平滑升级

  Go 服务作为常驻过程,想降级怎么办?你是不是想说这还不简略,先杀掉老的服务,再启动新的服务不就完了。可是你有没有想过,在你杀掉老服务的时候,正在解决的申请怎么办?以及老服务退出新服务启动的过程中,客户端申请达到了怎么办?这一简略粗犷的操作,必然会引起刹时的申请异样。那怎么办,想方法平滑降级呗。

信号

  为什么要先介绍信号呢?因为当咱们须要让过程退出的时候,通常就是给过程发送一个退出信号,比方 ctrl+ C 组合其实就是给过程发送了 SIGINT 信号。发送了信号而后呢?过程当然能够捕捉信号了,零碎容许过程收到信号后(退出前)做一些解决工作,那这样咱们是不是能还能持续解决以后申请,而后敞开连贯、开释资源等,实现后再退出,从而实现所谓得平滑退出。

  咱们简略介绍下信号,信号分为规范信号(不牢靠信号)和实时信号(牢靠信号),规范信号是从 1 -31,实时信号是从 32-64。咱们熟知的信号比方,SIGINT,SIGQUIT,SIGKILL 等等都是规范信号。个别咱们给某个过程发送信号,能够应用 kill 命令,比方 kill -9 pid,就是发送 SIGKILL 信号;kill -INT pid,就能够发送 SIGINT 信号给过程。

  信号处理器是指当捕捉指定信号时(传递给过程)时将会调用的一个函数,信号处理器程序可能随时打断过程的主程序流程。Go 语言注册的信号处理器是 runtime.sighandler 函数。

  当然 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
*/

  ”^C” 阐明咱们按下 ctrl+ C 组合键,这样会给过程发送 SIGINT 信号,能够看到,先输入语句程序再退出。你能够试一试,如果没有监听 SIGINT 信号,程序会间接退出,并输入 ”Process finished with exit code 2″。

  signal.Notify 函数注册咱们想监听的信号,第一个参数是管道 chan 类型,当过程捕捉到该信号时,会向管道写入数据,此时管道可读,所以咱们能够通过读管道感知信号的到来。

  最初,咱们简略介绍下 Go 语言信号处理框架,如下图所示:

  signal.Notify 函数注册管道与监听信号的映射关系,这些数据保护在一个全副的 map,key 为管道变量,value 称之为 mask,位标记须要监听的哪些信号;如果之前没有监听过该信号,这里还须要为该信号注册(signal_enable)信号处理器 sighandler。过程捕捉到信号后,会执行信号处理器 sighandler,其再通过异步形式散发信号,一旦咱们程序中应用了 signal.Notify 函数,就会启动子协程循环异步接管信号,并做散发,也就是写数据到对应的管道。

平滑退出

  咱们曾经理解到如何监听并解决信号了,那如何实现 Go 过程的平滑退出呢?假如 Go 过程作为 HTTP 服务,正在解决申请,接管到退出信号后,是不是应该持续解决这些申请,另外是不是应该防止新的申请进来(敞开监听的 socket),等所有处理完毕后,Go 过程再退出。

  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)
}

  留神在进行 HTTP 服务时,context 是有超时工夫的,毕竟咱们不可能无限度的始终期待。waitShutdown 函数返回,阐明 HTTP 服务曾经平滑进行了,或者超时了。server.Shutdown 办法,实现了咱们说的完结前的清理工作。留神主协程还阻塞式读管道 exit,为什么呢?因为一旦调用 server.Shutdown 办法,server.ListenAndServe 办法就会报错返回,这时候主协程就完结了,Go 程序也就退出了,那正在解决的申请怎么办?所以只有等到 waitShutdown 函数完结返回时,才阐明 HTTP 服务曾经平滑进行,主协程能力完结。

  上面简略看看 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:
            }
        }

        ......
    }

}

  看到了吧,Shutdown 办法一运行就敞开了 server.doneChan 管道,Serve 办法死循环就会退出,导致主协程的退出,所以咱们肯定要等到 Shutdown 办法完结返回,这才阐明 HTTP 服务平滑退出了。

平滑降级

  通过一系列操作,咱们的服务实现平滑退出了,那平滑降级怎么办?也就是代码公布过程中,如果做到平滑不影响服务呢?想想应该怎么办?至多应该先启动新的过程吧,等其失常提供服务时,再进行老的过程。

  其实这里还有一个问题须要解决:旧的过程对于 80,8080 这种监听端口曾经 bind 并且 listen 了,如果新的过程进行同样的 bind 操作,会产生相似这种谬误:Address already in use。如何监听这些端口的呢?咱们先理解下 exec 这个零碎调用(创立新过程就是通过这个零碎调用实现的),其会用新的程序替换现有过程的代码段,数据段,BSS,堆,栈;但 fd 比拟非凡,对于过程创立的 fd,exec 之后依然无效 (除非设置了 FD_CLOEXEC 标记),所以新过程还是能应用之前监听的 fd 的。问题是,这些 fd 是什么呢?新过程怎么晓得监听的 fd 呢?环境变量是不是能够?

  这里举荐一个开源组件 https://github.com/facebookar…,其封装了平滑降级相干的解决逻辑,应用起来也比较简单,参考官网 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

  只须要应用 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
    }

    // 到这里老过程就要退出了
}

  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}
}

  看到了吧,平滑降级还是挺简略的,只须要监听指定信号,先创立新的过程,再让老的过程平滑退出就行了,只是须要留神监听 fd 的继承逻辑。

总结

  本篇文章外围是介绍平滑退出以及平滑降级的外围逻辑,在开发 Go 我的项目还是比拟重要的,首先须要理解信号的基本概念,另外能够联合 Go net/http 规范库,以及 gracehttp 组件,钻研下 ” 平滑 ” 的实现原理。

退出移动版