从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: %dn", 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: %sn", err)
            os.Exit(1)
        }
        fd.Close()
    } else {
        ln, err = net.Listen("tcp", server.Addr)
        if err != nil {
            fmt.Printf("listen %s fail, error: %sn", server.Addr, err)
            os.Exit(1)
        }
    }
    go func() {
        err := server.Serve(ln)
        if err != nil && err != http.ErrServerClosed{
            fmt.Printf("serve error: %sn", err)
        }
    }()
    setupSignal()
    fmt.Println("over")
}

func setupSignal() {
    ch := make(chan os.Signal, 1)
    signal.Notify(ch, syscall.SIGUSR2, syscall.SIGINT, syscall.SIGTERM)
    sig := 

代码中很关键的两行:

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

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

  • https://github.com/jpillora/overseer
  • https://github.com/facebookarchive/grace
  • https://github.com/mholt/caddy 内部实现基于fd继承,未对外暴露API

文章来源于互联网:Golang的Graceful Restart

发表评论