背景

在业务快速增长中,前期只是验证模式是否可行,初期忽略程序发布重启带来的暂短停机影响。当模式实验成熟之后会逐渐放量,此时我们的发布停机带来的影响就会大很多。我们整个服务都是基于云,请求流量从 四层->七层->机器。

要想实现平滑重启大致有三种方案,一种是在流量调度的入口处理,一般的做法是 ApiGateway + CD ,发布的时候自动摘除机器,等待程序处理完现有请求再做发布处理,这样的好处就是程序不需要关心如何做平滑重启。

第二种就是程序自己完成平滑重启,保证在重启的时候 listen socket FD(文件描述符) 依然可以接受请求进来,只不过切换新老进程,但是这个方案需要程序自己去完成,有些技术栈可能实现起来不是很简单,有些语言无法控制到操作系统级别,实现起来会很麻烦。

第三种方案就是完全 docker,所有的东西交给 k8s 统一管理.

优雅重启:fork子进程

与 java、net 等基于虚拟机的语言不同,golang 天然支持系统级别的调用,平滑重启处理起来很容易。从原理上讲,基于 linux fork 子进程的方式,启动新的代码,再切换 listen socket FD

原理固然不难,但是完全自己实现还是会有很多细节问题的。好在有比较成熟的开源库帮我们实现了。

graceful https://github.com/tylerb/graceful endless https://github.com/fvbock/endless

上面两个是 github 排名靠前的 web host 框架,都是支持平滑重启的,只不过接受的进程信号有点区别 endless 接受 signal HUP,graceful 接受 signal USR2 。graceful 比较纯粹的 web host,endless 支持一些 routing 的能力。

解决方案是fork一个进程运行新编译的应用,该子进程接收从父进程传来的相关文件描述符,直接复用socket,同时父进程关闭socket。父进程留在后台处理未处理完的用户请求,这样一来问题1解决了。且复用soket也直接解决了问题2,实现0切换时间差。复用socket可以说是endless方案的核心。

使用

endless可以很方便的接入已经写好的程序,对于原生api,直接替换ListenAndServe为endless的方法,如下。并在编译完新的程序后,执行kill -1 旧进程id,旧进程便会fork一个进程运行新编译的程序。注:此处需要保证新编译的程序的路径和程序名和旧程序的一致。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func handler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("WORLD!"))
}

func main() {
	mux1 := mux.NewRouter()
	mux1.HandleFunc("/hello", handler).
		Methods("GET")

	err := endless.ListenAndServe("localhost:4242", mux1)
	if err != nil {
		log.Println(err)
	}
	log.Println("Server on 4242 stopped")

	os.Exit(0)
}

对于使用gin框架的程序,可以以下面的方式接入:

1
2
3
4
5
6
7
8
9
    r := gin.New()
	r.GET("/", func(c *gin.Context) {
		c.String(200, config.Config.Server.AppId)
	})
	s := endless.NewServer(":8080", r)
	err := s.ListenAndServe()
	if err != nil {
		log.Printf("server err: %v", err)
	}

原理

其使用非常简单,实现代码也很少,但是很强大,下面我们看看她的实现:

1
kill -1

endless的使用方法是先编译新程序,并执行"kill -1 旧进程id",我们看看旧程序接收到-1信号之后作了什么:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func (srv *endlessServer) handleSignals() {
	...
	for {
		sig = <-srv.sigChan
		srv.signalHooks(PRE_SIGNAL, sig)
		switch sig {
		case syscall.SIGHUP:	//接收到-1信号之后,fork一个进程,并运行新编译的程序
			log.Println(pid, "Received SIGHUP. forking.")
			err := srv.fork()
			if err != nil {
				log.Println("Fork err:", err)
			}
		...
		default:
			log.Printf("Received %v: nothing i care about...\n", sig)
		}
		srv.signalHooks(POST_SIGNAL, sig)
	}
}

func (srv *endlessServer) fork() (err error) {
	...
	path := os.Args[0]	//获取当前程序的路径,在子进程执行。所以要保证新编译的程序路径和旧程序的一致。
	var args []string
	if len(os.Args) > 1 {
		args = os.Args[1:]
	}

	cmd := exec.Command(path, args...)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.ExtraFiles = files	//socket在此处传给子进程,windows系统不支持获取socket文件,所以endless无法在windows上用。windows获取socket文件时报错:file tcp [::]:9999: not supported by windows。
	cmd.Env = env	//env有一个ENDLESS_SOCKET_ORDER变量存储了socket传递的顺序(如果有多个socket)
	...

	err = cmd.Start()	//运行新程序
	if err != nil {
		log.Fatalf("Restart: Failed to launch, error: %v", err)
	}

	return
}

接下来我们看看程序启动之后做了什么。

新进程启动之后会执行ListenAndServe这个方法,这个方法主要做了系统信号监听,并且判断自己所在进程是否是子进程,如果是,则发送中断信号给父进程,让其退出。最后调用Serve方法给socket提供新的服务。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (srv *endlessServer) ListenAndServe() (err error) {
    ...
	go srv.handleSignals()
	l, err := srv.getListener(addr)
	if err != nil {
		log.Println(err)
		return
	}
	srv.EndlessListener = newEndlessListener(l, srv)
	if srv.isChild {
		syscall.Kill(syscall.Getppid(), syscall.SIGTERM)		//给父进程发出中断信号
	}
	...
	return srv.Serve()	//为socket提供新的服务
}

前面提到复用socket是endless的核心,必须在Serve前准备好,否则会导致端口已使用的异常。复用socket的实现在上面的getListener方法中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (srv *endlessServer) getListener(laddr string) (l net.Listener, err error) {
	if srv.isChild {//如果此方法运行在子进程中,则复用socket
		var ptrOffset uint = 0
		runningServerReg.RLock()
		defer runningServerReg.RUnlock()
		if len(socketPtrOffsetMap) > 0 {
			ptrOffset = socketPtrOffsetMap[laddr]//获取和addr相对应的socket的位置
		}

		f := os.NewFile(uintptr(3+ptrOffset), "")//创建socket文件描述符
		l, err = net.FileListener(f)//创建socket文件监听器
		if err != nil {
			err = fmt.Errorf("net.FileListener error: %v", err)
			return
		}
	} else {//如果此方法不是运行在子进程中,则新建一个socket
		l, err = net.Listen("tcp", laddr)
		if err != nil {
			err = fmt.Errorf("net.Listen error: %v", err)
			return
		}
	}
	return
}

但是父进程关闭socket和子进程绑定socket并不可能同时进行,如果这段时间有请求进来,这个请求会到哪里去呢?关于这个问题,我做了个实验,实验代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
func main() {
	isChild := os.Getenv("child") != ""

	http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
		writer.Write([]byte(fmt.Sprintf("hello world from child?  %v", isChild)))
	})

	var ln net.Listener
	var err error

	if isChild {
		f := os.NewFile(uintptr(3+0), "")//由于只传一个文件,所以此处直接为3
		ln, err = net.FileListener(f)
	} else {
		ln, err = net.Listen("tcp", ":9999")
	}
	if err != nil {
		fmt.Println("listener create", err)
		os.Exit(1)
	}

	go func() {
		c := make(chan os.Signal)
		signal.Notify(c, os.Interrupt)
		<-c

		path := os.Args[0]
		var args []string
		if len(os.Args) > 1 {
			args = os.Args[1:]
		}

		f, err := ln.(*net.TCPListener).File()
		if err != nil {
			fmt.Println("get socket file", err)
			os.Exit(1)
		}

		cmd := exec.Command(path, args...)
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		cmd.ExtraFiles = []*os.File{f}
		cmd.Env = []string{"child=1"}

		err = cmd.Start()
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}
	}()

	http.Serve(ln, nil)
}

在centos7上试验结果如下:

第一种情况:如果某个终端跟服务器建立了长连接(应该是设置了keepalive属性),那么该终端的所有请求都会发到建立长连接的进程去,如下信息,所有computerName的请求都会被转发到父进程去(父进程id为13603):

1
2
3
4
5
6
7
8
[root@localhost care_watch_deploy]# lsof -i:9999
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
care_watc 13603 root 3u IPv6 17537280 0t0 TCP *:distinct (LISTEN)
care_watc 13603 root 5u IPv6 17528589 0t0 TCP 10.100.21.105:distinct->computerName:58776 (ESTABLISHED)
care_watc 13603 root 6u IPv6 17528593 0t0 TCP 10.100.21.105:distinct->computerName:58780 (ESTABLISHED)
care_watc 13603 root 7u IPv6 17537280 0t0 TCP *:distinct (LISTEN)
care_watc 13617 root 3u IPv6 17537280 0t0 TCP*:distinct (LISTEN)
care_watc 13617 root 4u IPv6 17537280 0t0 TCP *:distinct (LISTEN)

第二种情况:如果有新的请求进来,会随机分配到父进程或者子进程,不知道为什么,我多次试验的结果是,20%的请求会被转发到子进程,80%的请求会被转发到父进程。测试的python代码如下,不管运行几次count_child的值永远都是100左右:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import requests

count_child = 0

for i in range(500):
    resp = requests.get("http://10.100.21.105:9999/")
    result = resp.content.decode("utf8")
    if result == "hello world from child?  true":
        count_child += 1

print(count_child)

第三种情况,父进程或者子进程任意一个退出之后,所有请求都会转发到另一个进程进行处理。

从以上三种情况看,endless的做法不会落下任何请求,因为请求不是被父进程处理了就是被子进程处理了,所以endless是个可放心使用的热更新方案。

最终endless的整个执行过程如其日志:

1
2
3
4
5
6
2015/03/22 20:04:10 2710 Received SIGHUP. forking.	//接收到kill -1信号,fork进程运行新程序
2015/03/22 20:04:10 2710 Received SIGTERM.	//父进程接收到子进程发出的中断信号,关闭socket监听器
2015/03/22 20:04:10 2710 Waiting for connections to finish...	//父进程等待请求处理完成
2015/03/22 20:04:10 PID: 2726 localhost:4242	//新进程启动服务
2015/03/22 20:04:10 accept tcp 127.0.0.1:4242: use of closed network connection	//新的用户请求进入到新程序
2015/03/22 20:04:10 Server on 4242 stopped	//父进程处理完所有请求并退出

问题

  1. 简单的http server很容易升级,若监听了多个端口该如何进行热升级?
  2. 若go server使用tls服务(其他也类似),如何进行升级?
  3. go http server在容器场景下是否需要平滑热升级?平滑停机是否足够?如果平滑停机足够的话,那么如何结合docker+k8s进行热升级?
  4. 热更新是采取创建子进程后,将原进程退出的方式,这点不符合守护进程的要求

优雅重启通常并不推荐在生产环境使用,建议生产环境只使用优雅关闭.

优雅关闭:http.Shutdown

http shutdown 源码分析

先来看下http shutdown的主方法实现逻辑。用atomic来做退出标记的状态,然后关闭各种的资源,然后一直阻塞的等待无空闲连接,每500ms轮询一次。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var shutdownPollInterval = 500 * time.Millisecond

func (srv *Server) Shutdown(ctx context.Context) error {
    // 标记退出的状态
    atomic.StoreInt32(&srv.inShutdown, 1)
    srv.mu.Lock()
    // 关闭listen fd,新连接无法建立。
    lnerr := srv.closeListenersLocked()

    // 把server.go的done chan给close掉,通知等待的worker退出
    srv.closeDoneChanLocked()

    // 执行回调方法,我们可以注册shutdown的回调方法
    for _, f := range srv.onShutdown {
        go f()
    }

    // 每500ms来检查下,是否没有空闲的连接了,或者监听上游传递的ctx上下文。
    ticker := time.NewTicker(shutdownPollInterval)
    defer ticker.Stop()
    for {
        if srv.closeIdleConns() {
            return lnerr
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-ticker.C:
        }
    }
}

是否没有空闲的连接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func (s *Server) closeIdleConns() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	quiescent := true
	for c := range s.activeConn {
		st, unixSec := c.getState()
		if st == StateNew && unixSec < time.Now().Unix()-5 {
			st = StateIdle
		}
		if st != StateIdle || unixSec == 0 {
			quiescent = false
			continue
		}
		c.rwc.Close()
		delete(s.activeConn, c)
	}
	return quiescent
}

关闭server.doneChan和监听的文件描述符

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 关闭doen chan
func (s *Server) closeDoneChanLocked() {
    ch := s.getDoneChanLocked()
    select {
    case <-ch:
        // Already closed. Don't close again.
    default:
        // Safe to close here. We're the only closer, guarded
        // by s.mu.
        close(ch)
    }
}

// 关闭监听的fd
func (s *Server) closeListenersLocked() error {
    var err error
    for ln := range s.listeners {
        if cerr := (*ln).Close(); cerr != nil && err == nil {
            err = cerr
        }
        delete(s.listeners, ln)
    }
    return err
}

// 关闭连接
func (c *conn) Close() error {
    if !c.ok() {
        return syscall.EINVAL
    }
    err := c.fd.Close()
    if err != nil {
        err = &OpError{Op: "close", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
    }
    return err
}

这么一系列的操作后,server.go的serve主监听方法也就退出了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func (srv *Server) Serve(l net.Listener) error {
    ...
    for {
        rw, e := l.Accept()
        if e != nil {
            select {
             // 退出
            case <-srv.getDoneChan():
                return ErrServerClosed
            default:
            }
            ...
            return e
        }
        tempDelay = 0
        c := srv.newConn(rw)
        c.setState(c.rwc, StateNew) // before Serve can return
        go c.serve(ctx)
    }
}

那么如何保证用户在请求完成后,再关闭连接的?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func (s *Server) doKeepAlives() bool {
	return atomic.LoadInt32(&s.disableKeepAlives) == 0 && !s.shuttingDown()
}

// Serve a new connection.
func (c *conn) serve(ctx context.Context) {
	defer func() {
		...
		if !c.hijacked() {
                        // 关闭连接,并且标记退出
			c.close()
			c.setState(c.rwc, StateClosed)
		}
	}()
        ...
	ctx, cancelCtx := context.WithCancel(ctx)
	c.cancelCtx = cancelCtx
	defer cancelCtx()

	c.r = &connReader{conn: c}
	c.bufr = newBufioReader(c.r)
	c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)

	for {
                // 接收请求
		w, err := c.readRequest(ctx)
		if c.r.remain != c.server.initialReadLimitSize() {
			c.setState(c.rwc, StateActive)
		}
                ...
                ...
                // 匹配路由及回调处理方法
		serverHandler{c.server}.ServeHTTP(w, w.req)
		w.cancelCtx()
		if c.hijacked() {
			return
		}
                ...
                // 判断是否在shutdown mode, 选择退出
		if !w.conn.server.doKeepAlives() {
			return
		}
    }

优雅关闭:grpc.GracefulStop

goaway帧

grpc的通信协议是http2,http2对于连接关闭使用goaway帧信号。goaway帧(类型= 0x7)用于启动连接关闭或发出严重错误状态信号。 goaway允许端点正常停止接受新的流,同时仍然完成对先前建立的流的处理。这可以实现管理操作,例如服务器维护,升级等。

server

golang grpc server提供了两个退出方法,一个是stop,一个是gracefulStop。先说下gracefulStop。首先close listen fd,这样就无法建立新的请求,然后遍历所有的当前连接发送goaway帧信号。goaway帧信号在http2用来关闭连接的。serveWG.Wait()会等待所有 handleRawConn协程的退出,在grpc server里每个新连接都会创建一个 handleRawConn协程,并且增加waitgroup的计数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (s *Server) GracefulStop() {
    s.mu.Lock()
    ...

    // 关闭 listen fd,不再接收新的连接
    for lis := range s.lis {
        lis.Close()
    }

    s.lis = nil
    if !s.drain {
        for st := range s.conns {
            // 给所有的客户端发布goaway信号
            st.Drain()
        }
        s.drain = true
    }


    // 等待所有handleRawConn协程退出,每个请求都是一个协程,通过waitgroup控制.
    s.serveWG.Wait()

    // 当还有空闲连接时,需要等待。在退出serveStreams逻辑时,会进行Broadcast唤醒。只要有一个客户端退出就会触发removeConn继而进行唤醒。
    for len(s.conns) != 0 {
        s.cv.Wait()
    }

看下drain方法的具体实现,构建goaway请求塞到controlbuf里,由grpc唯一的loopyWriter来写入报文。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 构建goaway请求塞入buf里,然后由统一的loopyWriter来发送报文。
func (t *http2Server) drain(code http2.ErrCode, debugData []byte) {
    t.mu.Lock()
    defer t.mu.Unlock()
    if t.drainChan != nil {
        return
    }
    t.drainChan = make(chan struct{})
    t.controlBuf.put(&goAway{code: code, debugData: debugData, headsUp: true})
}

stop方法相比gracefulStop来说,减少了goaway帧的发送,等待连接的退出。

client

grpc客户端会new一个协程来执行reader方法,一直监听新数据的到来,当帧类型为goaway时调用handleGoAway,该方法会调用closeStream关闭当前连接的所有活动stream。对于开发者来说,只需监听grpc接口中的ctx就得到状态变更。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 接收各类报文
func (t *http2Client) reader() {
    ...
    for {
        t.controlBuf.throttle()
        frame, err := t.framer.fr.ReadFrame()
        switch frame := frame.(type) {
        // 接收goaway信号,回调handleGoAway方法
			case *http2.GoAwayFrame:
            	t.handleGoAway(frame)
            	...
        }
    }
}

// 当前连接里的所有的活动stream进行closeStream
func (t *http2Client) handleGoAway(f*http2.GoAwayFrame) {
    ...
    for streamID, stream := range t.activeStreams {
        if streamID > id && streamID <= upperLimit {
            atomic.StoreUint32(&stream.unprocessed, 1)
            t.closeStream(stream, errStreamDrain, false, http2.ErrCodeNo, statusGoAway, nil, false)
        }
    }
    active := len(t.activeStreams)
    t.mu.Unlock()
    if active == 0 {
        t.Close()
    }
...

通常该连接不可用后,如客户端再次进行unary或streming请求时,grpc会按照规则来实例化新的连接,比如通过dns或者grpc balancer来地址变更。

对比net/http和grpc的graceful shutdown

golang的net/http在graceful实现上不会主动的关闭连接,除非是配置了强制超时退出。因为当你去主动关闭长连接时,有些低质量的客户端可能会出现异常。所以,像nginx、netty、net/http这类服务端不会主动关闭客户端的连接。

但grpc就不同了… 两端的代码本就是自动生成的,质量颇高。利用http2的goaway特性来通知关闭,规避了强制关闭引起的异常。

参考

golang 服务平滑重启小结 如何优雅地重启go程序–endless篇 源码分析golang http shutdown优雅退出的原理 源码分析grpc graceful shutdown优雅退出