从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
现在开源社区有不少相关的实现,比如:
有疑问加站长微信联系(非本文作者)