美文网首页
Golang的Graceful Restart

Golang的Graceful Restart

作者: suoga | 来源:发表于2019-04-24 18:51 被阅读0次

    从1.8开始,Go标准库中的net/http支持了GracefulShutdown,使得进程可以把现有请求都处理完之后再退出,从而最大限度地减少不一致性给服务端带来的负担。如果不做GracefulShutdown,有哪些不一致性呢?简单举个例子:

    服务T是类似微博的点赞功能,当用户点赞某条微博的时候,一方面要给点赞数+1,另一方面要通知post的作者“XXX赞了你的微博”,同时还要有策略通知点赞人的粉丝“你关注的XXX点赞了这条微博”……当然这些功能不是一个事务,而且也不是同步的,应该异步来做。所以,最终的流程可能是:

    db.IncrPostNumber()
    mqA.Send(messageA)
    mqB.Send(messageB)
    //...
    

    如果不做gracefulShutdown,在中途的任何一个步骤时,进程被杀掉,都可能造成一些问题。当然就这个例子来说,往小了说也不是什么大事,这些问题都可以忍受。但往大了说,大V通过点赞让粉丝看到某条微博,这也是收费的。结果广告主给了钱却看不到效果,甚至发现根本没有粉丝看到,这是要让你退钱的!

    所以做GracefulShutdown,不论对什么业务系统来说,都是很有必要的。但是本文我们不讨论GracefulShutdown,而是讨论一个更进一步的话题,Graceful Restart。

    GracefulShutdown和Graceful Restart是什么区别呢?从名字上大概就能看出,一个是优雅退出,一个是优雅重启。优雅退出上面也说了,重点是保证进程退出前处理完当下所有的请求。而优雅重启要求更高,它的目标是在进程重启时整个过程要平滑,不要让用户感受到任何异样,不要有任何downtime,也就是停机时间,保证进程持续可用。因此,gracefulShutdown只是实现gracefulRestart的一个必要部分,gracefulRestart还要求更多。

    一种GracefulRestart的方法是,通过部署系统配合nginx来完成。由于大部分业务系统都是挂在nginx之后通过nginx进行反向代理的,因此在重启某台机器的进程A时,可以把该机器IP从nginx的upstream中摘除掉,等一段时间比如1分钟,该进程差不多也处理完了所以请求,实际上已经处于空闲状态了。这时就可以kill掉该进程并重启,等重启成功之后,再把该机器的IP加回到nginx对应的upstream中去。
    这种方式是语言、平台无关的一种技术方案,但是缺点也很明显:

    • 首先就是复杂,需要部署系统和网关(nginx)恰到好处地配合。开发人员点击部署时,部署系统需要通知nginx摘掉某个upstream的某个IP;然后等进程重启成功之后,部署系统需要通知nginx在某个upstream中加上某个IP。这一整套系统的开发测试还是有一定复杂性的。
    • 其次是等待时间的未知性。当把机器A摘掉以后过多久进程才能处理完请求?10秒?1分钟?谁也不知道…间隔短了,会出问题,因为部分请求被卡断了;间隔长了,上线又慢,而且你还是不能确定是否请求都处理完了(其实基本上没问题,但是理论上无法保证)。
    • 另一个问题是压力陡增。对于大公司动辄几百台的集群,摘一两台无关紧要。但是对于小公司,比如某个服务只有两台机器,并且每台机器压力都挺大。这时如果直接摘一台,所有流量到另一台机器上,使得那台机器承受不住,那么可能会导致整个服务不可用。

    因此这里引出第二种实现方式——fd继承

    FD继承

    fd(file descriptor)也就是文件描述符,是Unix*系统上最常见的概念,everything is file。我们基于一个非常基础的知识点:

    进程T fork 出子进程时,子进程会继承父进程T打开的fd。

    进程T大概的处理流程类似于:

    int sock_fd = createSocketBindTo(":80");
    int ok = listen(sock_fd, backlog);
    do {
      int connect_sock = accept(sock_fd, &SockStruct, &Addr);
      process(connect_sock);
    }
    

    也就是:

    • 构建监听某个端口的socket
    • 不断从该socket中读取连接,并处理

    这里你可以发现,如果想要accept到连接,我们只需要socket就够了,bind listen这些都是准备工作。如果父进程把这些工作都做了,子进程似乎可以直接从继承过来的socket上读取数据。
    这里先不说具体实现细节,但是大体思路其实就是上面说的,非常简单。进程通过环境变量或者args来判断是应该先Listen再accpet,还是直接用继承来的socket进行accept。
    这里有个问题,子进程如果在该socket上accept,主进程也accept,那么对同一个socket进行accept操作并发安全吗?答案是——安全,这是glibc为我们保证的,正如malloc这类函数调用一样。

    下面是一个简单的代码示例:

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

    代码中很关键的两行:

    fd := os.NewFile(3, "")
    ln,err = net.FileListener(fd)
    
    fd.Close()
    

    3是什么?3其实就是从父进程继承过来的socket fd。虽然子进程可以默认继承父进程绝大多数的文件描述符(除了文件锁之类的),但是golang的标准库os/exec只默认继承stdin stdout stderr这三个。需要让子进程继承的fd需要在fork之前手动放到ExtraFiles中。由于有了stdin 0 stdout 1 stderr 2,因此其它fd的序号从3开始。

    还有一个可能比较让人困惑的问题是,fd.Close()是干什么的,Close它会有什么影响。这个问题直接的答案是,没有任何影响,只是为了防止资源泄漏。具体可以看看net.FileListerner的文档,相关的知识点有点多,可以google fcntl和dup2关键字。

    当子进程运行起来后,就可以调用server实现好的Shutdown方法,来关停主进程了。

    这种方法代来的一个问题是,当主进程fork出子进程,然后主进程退出后,子进程的父进程就变成了1(孤儿进程)。如果使用supervisor等工具来监听服务的话,就会遇到问题(主进程退出了立刻又被supervisor拉起来,然后端口冲突了)。这时候就需要使用linux pidfile。

    RE_USEPORT

    还有第三种可以做到不停机重启的办法,那便是使用Linux内核的新特性reuseport。以前,如果多个进程或者线程同时监听一个端口,只有一个可以成功,其它都会返回端口被占用的错误。
    新内核支持通过setsockopt对socket进行设置,使得多个进程或者线程可以同时监听一个端口,内核来进行负载均衡。

    利用多进程模型加上reuseport库的支持,很容易就可以实现不停机重启。
    但是,reuseport也不是万能的灵丹妙药,它也有自己的问题,在连接建立非常频繁的场景下,由于内核使用的算法的局限性,它的性能会下降很多。当然,这和不停机重启没有任何关系,只是顺便一提,如果仅仅使用reuseport特性实现gracefulRestart,应该不会遇到这样的问题。
    nginx高版本也使用了reuseport,关于它的性能问题,可以参见这篇文章
    到底是通过继承fd还是reuseport来实现graceful restart,相关的比较可以参见https://gravitational.com/blog/golang-ssh-bastion-graceful-restarts/,不过结论基本上认为继承fd更靠谱(当然这篇文章得出的结论也受限于当时golang本身标准库实现的局限性,使得没办法对Conn进行setsockopt,因为Conn不是一个socket对象而是一个runtime.NetPoller)

    More

    现在开源社区有不少相关的实现,比如:

    相关文章

      网友评论

          本文标题:Golang的Graceful Restart

          本文链接:https://www.haomeiwen.com/subject/xtijiqtx.html