内容简介:从1.8开始,Go标准库中的net/http支持了服务T是类似微博的点赞功能,当用户点赞某条微博的时候,一方面要给点赞数+1,另一方面要通知post的作者“XXX赞了你的微博”,同时还要有策略通知点赞人的粉丝“你关注的XXX点赞了这条微博”……当然这些功能不是一个事务,而且也不是同步的,应该异步来做。所以,最终的流程可能是:如果不做gracefulShutdown,在中途的任何一个步骤时,进程被杀掉,都可能造成一些问题。当然就这个例子来说,往小了说也不是什么大事,这些问题都可以忍受。但往大了说,大V通过点赞
从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
现在开源社区有不少相关的实现,比如:
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。