2

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

 1 year ago
source link: https://studygolang.com/articles/35921
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

tomato01 · 大约4小时之前 · 97 次点击 · 预计阅读时间 8 分钟 · 大约8小时之前 开始浏览    

  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语言信号处理框架,如下图所示:

5-6-1.png

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

  只需要使用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组件,研究下"平滑"的实现原理。


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

280

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK