Golang Graceful


原文链接: Golang Graceful

Go 1.8 http graceful 体验 - Coldstar - SegmentFault 思否
Golang开发支持平滑升级(优雅重启)的HTTP服务 - tabalt的博客
Linux Signal及Golang中的信号处理 - CSDN博客
graceful这个库,只提供了优雅关闭, 不提供优雅重启.

https://github.com/facebookgo/grace

1. 优雅关闭

什么叫做优雅关闭呢? 意思就是服务器要关闭了, 会拒绝新的连接,但是老的连接不会被强制关闭,而是 会等待一定时间, 等待客户端主动关闭, 除非客户端一直没有关闭, 到了预设的超时时间才进行服务器端关闭.

2. 优雅重启

3. 平滑升级

用新的可执行文件替换老的可执行文件(如只需优雅重启,可以跳过这一步)
通过pid给正在运行的老进程发送 特定的信号(kill -SIGUSR2 $pid)
正在运行的老进程,接收到指定的信号后,以子进程的方式启动新的可执行文件并开始处理新请求
老进程不再接受请求,但是要等正在处理的请求处理完成,所有在处理的请求处理完之后,便自动退出
新进程在父进程退出后,会被init进程领养,并继续提供服务

去年在做golangserver的时候,内部比较头疼的就是在线服务发布的时候,大量用户的请求在发布时候会被重连,在那时候也想了n多的方法,最后还是落在一个github上的项目,facebook的一个golang项目grace,那时候简单研究测试了一下可以就直接在内部使用了起来,这段时间突然想起来,又想仔细研究一下这个项目了。
从原理上来说是这样一个过程:

1)发布新的bin文件去覆盖老的bin文件
2)发送一个信号量,告诉正在运行的进程,进行重启
3)正在运行的进程收到信号后,会以子进程的方式启动新的bin文件
4)新进程接受新请求,并处理
5)老进程不再接受请求,但是要等正在处理的请求处理完成,所有在处理的请求处理完之后,便自动退出
6)新进程在老进程退出之后,由init进程收养,但是会继续服务。

所以一步一步来看,关键是从第2步开始之后怎么做,所以我们先来看看第2步的实现,这个应该说很简单,发送信号量到一个进程,使用kill命令即可,在facebook这个项目中发送的信号量有3个:SIGINT,SIGTERM,SIGUSR2,前面两个信号收到后程序会直接退出,后面一个信号SIGUSR2才会执行所谓的优雅重启。

第3步,正在运行的进程收到SIGUSR2信号后,会以子进程的方式启动新的bin文件。先直接上代码看:https://github.com/facebookgo/grace/blob/master/gracehttp/http.go

func (a *app) signalHandler(wg *sync.WaitGroup) {
    ch := make(chan os.Signal, 10)
    signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR2)
    for {
        sig := <-ch
        switch sig {
        case syscall.SIGINT, syscall.SIGTERM:  
            // this ensures a subsequent INT/TERM will trigger standard go behaviour of
            // terminating. 执行标准的go终止行为,程序就结束了
            signal.Stop(ch)
            a.term(wg)
            return
        case syscall.SIGUSR2: // 这里开始执行优雅重启
            err := a.preStartProcess()  
            // 这个函数在源代码中没有具体实现功能,只是预留了一个钩子函数,用户可以注册自己的函数,可以在重启之前做些自定义的事情。一般情况下也没有什么可以做的,除非有些特殊的服务环境或是状态保存之类的,至少目前,我们的server还没有遇到
            if err != nil {
                a.errors <- err
            }
            // we only return here if there's an error, otherwise the new process
            // will send us a TERM when it's ready to trigger the actual shutdown.
            if _, err := a.net.StartProcess(); err != nil { // 这里开始正式所谓的优雅重启            
                a.errors <- err
            }
        }
    }
}

func (n *Net) StartProcess() (int, error) {
    listeners, err := n.activeListeners() // 获取目前在监听的端口,这块也是重点,下面重点介绍
    if err != nil {
        return 0, err
    }
 
    // Extract the fds from the listeners.  从监听端口中把文件描述符取出来
    files := make([]*os.File, len(listeners))
    for i, l := range listeners {
        files[i], err = l.(filer).File()
        if err != nil {
            return 0, err
        }
        defer files[i].Close()
    }
 
    // Use the original binary location. This works with symlinks such that if
    // the file it points to has been changed we will use the updated symlink.
    // 获取可执行bin文件的路劲,也可以是链接路劲,会使用最新的链接路径作为启动文件路劲的
    argv0, err := exec.LookPath(os.Args[0])
    if err != nil {
        return 0, err
    }
 
    // Pass on the environment and replace the old count key with the new one.
    // 获取 LISTEN_FDS 换进变量值 
    var env []string
    for _, v := range os.Environ() {
        if !strings.HasPrefix(v, envCountKeyPrefix) {
            env = append(env, v)
        }
    }
    env = append(env, fmt.Sprintf("%s%d", envCountKeyPrefix, len(listeners)))
 
    allFiles := append([]*os.File{os.Stdin, os.Stdout, os.Stderr}, files...)
    // 这里调用一个golang底层的进程启动函数,来指定,上面获取的参数来启动进程
    process, err := os.StartProcess(argv0, os.Args, &os.ProcAttr{
        Dir:   originalWD,
        Env:   env,
        Files: allFiles,
    })
    if err != nil {
        return 0, err
    }
    // 返回新进程id。
    return process.Pid, nil 
}

以上是启动新进程,并且接管监听端口的过程, 一般情况下端口是不可以重复监听的,所以这里就要需要使用比较特别的办法,从上面的代码来看就是读取监听端口的文件描述符,并且把监听端口的文件描述符传递给子进程,子进程里从这个文件描述符实现对端口的监听

另外还有一个比较特别的地方就是老的接口怎么关闭的问题,关闭必须要把已经收到的请求处理完成之后再关闭。为此facebook的同学另外开了一个项目httpdown,继承了原始的httpserver,但是多了对各种链接状态的维护和处理,这部分后面在分析。

####################

前段时间用Golang在做一个HTTP的接口,因编译型语言的特性,修改了代码需要重新编译可执行文件,关闭正在运行的老程序,并启动新程序。对于访问量较大的面向用户的产品,关闭、重启的过程中势必会出现无法访问的情况,从而影响用户体验。

使用Golang的系统包开发HTTP服务,是无法支持平滑升级(优雅重启)的,本文将探讨如何解决该问题。

一、平滑升级(优雅重启)的一般思路

一般情况下,要实现平滑升级,需要以下几个步骤:

  1. 用新的可执行文件替换老的可执行文件(如只需优雅重启,可以跳过这一步)
  2. 通过pid给正在运行的老进程发送 特定的信号(kill -SIGUSR2 $pid)
  3. 正在运行的老进程,接收到指定的信号后,以子进程的方式启动新的可执行文件并开始处理新请求
  4. 老进程不再接受新的请求,等待未完成的服务处理完毕,然后正常结束
  5. 新进程在父进程退出后,会被init进程领养,并继续提供服务


二、Golang Socket 网络编程

Socket是程序员层面上对传输层协议TCP/IP的封装和应用。Golang中Socket相关的函数与结构体定义在net包中,我们从一个简单的例子来学习一下Golang Socket 网络编程,关键说明直接写在注释中。

1、服务端程序 server.go

package main

import (
	"fmt"
	"log"
	"net"
	"time"
)

func main() {
	// 监听8086端口
	listener, err := net.Listen("tcp", ":8086")
	if err != nil {
		log.Fatal(err)
	}
	defer listener.Close()

	for {
		// 循环接收客户端的连接,没有连接时会阻塞,出错则跳出循环
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println(err)
			break
		}

		fmt.Println("[server] accept new connection.")

		// 启动一个goroutine 处理连接
		go handler(conn)
	}
}

func handler(conn net.Conn) {
	defer conn.Close()

	for {
		// 循环从连接中 读取请求内容,没有请求时会阻塞,出错则跳出循环
		request := make([]byte, 128)
		readLength, err := conn.Read(request)

		if err != nil {
			fmt.Println(err)
			break
		}

		if readLength == 0 {
			fmt.Println(err)
			break
		}

		// 控制台输出读取到的请求内容,并在请求内容前加上hello和时间后向客户端输出
		fmt.Println("[server] request from ", string(request))
		conn.Write([]byte("hello " + string(request) + ", time: " + time.Now().Format("2006-01-02 15:04:05")))
	}
}

2、客户端程序 client.go

package main

import (
	"fmt"
	"log"
	"net"
	"os"
	"time"
)

func main() {

	// 从命令行中读取第二个参数作为名字,如果不存在第二个参数则报错退出
	if len(os.Args) != 2 {
		fmt.Fprintf(os.Stderr, "Usage: %s name ", os.Args[0])
		os.Exit(1)
	}
	name := os.Args[1]

	// 连接到服务端的8086端口
	conn, err := net.Dial("tcp", "127.0.0.1:8086")
	checkError(err)

	for {
		// 循环往连接中 写入名字
		_, err = conn.Write([]byte(name))
		checkError(err)

		// 循环从连接中 读取响应内容,没有响应时会阻塞
		response := make([]byte, 256)
		readLength, err := conn.Read(response)
		checkError(err)

		// 将读取响应内容输出到控制台,并sleep一秒
		if readLength > 0 {
			fmt.Println("[client] server response:", string(response))
			time.Sleep(1 * time.Second)
		}
	}
}

func checkError(err error) {
	if err != nil {
		log.Fatal("fatal error: " + err.Error())
	}
}

3、运行示例程序

# 运行服务端程序
go run server.go

# 在另一个命令行窗口运行客户端程序
go run client.go "tabalt"

三、Golang HTTP 编程

HTTP是基于传输层协议TCP/IP的应用层协议。Golang中HTTP相关的实现在net/http包中,直接用到了net包中Socket相关的函数和结构体。

我们再从一个简单的例子来学习一下Golang HTTP 编程,关键说明直接写在注释中。

1、http服务程序 http.go

package main

import (
	"log"
	"net/http"
	"os"
)

// 定义http请求的处理方法
func handlerHello(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("http hello on golang\n"))
}

func main() {

	// 注册http请求的处理方法
	http.HandleFunc("/hello", handlerHello)

	// 在8086端口启动http服务,会一直阻塞执行
	err := http.ListenAndServe("localhost:8086", nil)
	if err != nil {
		log.Println(err)
	}

	// http服务因故停止后 才会输出如下内容
	log.Println("Server on 8086 stopped")
	os.Exit(0)
}

2、运行示例程序

# 运行HTTP服务程序
go run http.go

# 在另一个命令行窗口curl请求测试页面
curl http://localhost:8086/hello/

# 输出如下内容:
http hello on golang

四、Golang net/http包中 Socket操作的实现

从上面的简单示例中,我们看到在Golang中要启动一个http服务,只需要简单的三步:

  1. 定义http请求的处理方法

  2. 注册http请求的处理方法

  3. 在某个端口启动HTTP服务

而最关键的启动http服务,是调用http.ListenAndServe()函数实现的。下面我们找到该函数的实现:

func ListenAndServe(addr string, handler Handler) error {
	server := &Server{Addr: addr, Handler: handler}
	return server.ListenAndServe()
}

这里创建了一个Server的对象,并调用它的ListenAndServe()方法,我们再找到结构体Server的ListenAndServe()方法的实现:

func (srv *Server) ListenAndServe() error {
	addr := srv.Addr
	if addr == "" {
		addr = ":http"
	}
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}

从代码上看到,这里监听了tcp端口,并将监听者包装成了一个结构体 tcpKeepAliveListener,再调用srv.Serve()方法;我们继续跟踪Serve()方法的实现:

func (srv *Server) Serve(l net.Listener) error {
	defer l.Close()
	var tempDelay time.Duration // how long to sleep on accept failure
	for {
		rw, e := l.Accept()
		if e != nil {
			if ne, ok := e.(net.Error); ok && ne.Temporary() {
				if tempDelay == 0 {
					tempDelay = 5 * time.Millisecond
				} else {
					tempDelay *= 2
				}
				if max := 1 * time.Second; tempDelay > max {
					tempDelay = max
				}
				srv.logf("http: Accept error: %v; retrying in %v", e, tempDelay)
				time.Sleep(tempDelay)
				continue
			}
			return e
		}
		tempDelay = 0
		c, err := srv.newConn(rw)
		if err != nil {
			continue
		}
		c.setState(c.rwc, StateNew) // before Serve can return
		go c.serve()
	}
}

可以看到,和我们前面Socket编程的示例代码一样,循环从监听的端口上Accept连接,如果返回了一个net.Error并且这个错误是临时性的,则会sleep一个时间再继续。 如果返回了其他错误则会终止循环。成功Accept到一个连接后,调用了方法srv.newConn()对连接做了一层包装,最后启了一个goroutine处理http请求。

五、Golang 平滑升级(优雅重启)HTTP服务的实现

我创建了一个新的包gracehttp来实现支持平滑升级(优雅重启)的HTTP服务,为了少写代码和降低使用成本,新的包尽可能多地利用net/http包的实现,并和net/http包保持一致的对外方法。现在开始我们来看gracehttp包支持平滑升级 (优雅重启)Golang HTTP服务涉及到的细节如何实现。

1、Golang处理信号

Golang的os/signal包封装了对信号的处理。简单用法请看示例:

package main

import (
	"fmt"
	"os"
	"os/signal"
	"syscall"
)

func main() {

	signalChan := make(chan os.Signal)

	// 监听指定信号
	signal.Notify(
		signalChan,
		syscall.SIGHUP,
		syscall.SIGUSR2,
	)

	// 输出当前进程的pid
	fmt.Println("pid is: ", os.Getpid())

	// 处理信号
	for {
		sig := <-signalChan
		fmt.Println("get signal: ", sig)
	}
}

2、子进程启动新程序,监听相同的端口

在第四部分的ListenAndServe()方法的实现代码中可以看到,net/http包中使用net.Listen函数来监听了某个端口,但如果某个运行中的程序已经监听某个端口,其他程序是无法再去监听这个端口的。解决的办法是使用子进程的方式启动,并将监听端口的文件描述符传递给子进程,子进程里从这个文件描述符实现对端口的监听。

具体实现需要借助一个环境变量来区分进程是正常启动,还是以子进程方式启动的,相关代码摘抄如下:

// 启动子进程执行新程序
func (this *Server) startNewProcess() error {

	listenerFd, err := this.listener.(*Listener).GetFd()
	if err != nil {
		return fmt.Errorf("failed to get socket file descriptor: %v", err)
	}

	path := os.Args[0]

	// 设置标识优雅重启的环境变量
	environList := []string{}
	for _, value := range os.Environ() {
		if value != GRACEFUL_ENVIRON_STRING {
			environList = append(environList, value)
		}
	}
	environList = append(environList, GRACEFUL_ENVIRON_STRING)

	execSpec := &syscall.ProcAttr{
		Env:   environList,
		Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), listenerFd},
	}

	fork, err := syscall.ForkExec(path, os.Args, execSpec)
	if err != nil {
		return fmt.Errorf("failed to forkexec: %v", err)
	}

	this.logf("start new process success, pid %d.", fork)

	return nil
}

func (this *Server) getNetTCPListener(addr string) (*net.TCPListener, error) {

	var ln net.Listener
	var err error

	if this.isGraceful {
		file := os.NewFile(3, "")
		ln, err = net.FileListener(file)
		if err != nil {
			err = fmt.Errorf("net.FileListener error: %v", err)
			return nil, err
		}
	} else {
		ln, err = net.Listen("tcp", addr)
		if err != nil {
			err = fmt.Errorf("net.Listen error: %v", err)
			return nil, err
		}
	}
	return ln.(*net.TCPListener), nil
}

3、父进程等待已有连接中未完成的请求处理完毕

这一块是最复杂的;首先我们需要一个计数器,在成功Accept一个连接时,计数器加1,在连接关闭时计数减1,计数器为0时则父进程可以正常退出了。Golang的sync的包里的WaitGroup可以很好地实现这个功能。

然后要控制连接的建立和关闭,我们需要深入到net/http包中Server结构体的Serve()方法。重温第四部分Serve()方法的实现,会发现如果要重新写一个Serve()方法几乎是不可能的,因为这个方法里调用了好多个不可导出的内部方法,重写Serve()方法几乎要重写整个net/http包。

幸运的是,我们还发现在 ListenAndServe()方法里传递了一个listener给Serve()方法,并最终调用了这个listener的Accept()方法,这个方法返回了一个Conn的示例,最终在连接断开的时候会调用Conn的Close()方法,这些结构体和方法都是可导出的!

我们可以定义自己的Listener结构体和Conn结构体,组合net/http包中对应的结构体,并重写Accept()和Close()方法,实现对连接的计数,相关代码摘抄如下:

type Listener struct {
	*net.TCPListener

	waitGroup *sync.WaitGroup
}

func (this *Listener) Accept() (net.Conn, error) {

	tc, err := this.AcceptTCP()
	if err != nil {
		return nil, err
	}
	tc.SetKeepAlive(true)
	tc.SetKeepAlivePeriod(3 * time.Minute)

	this.waitGroup.Add(1)

	conn := &Connection{
		Conn:     tc,
		listener: this,
	}
	return conn, nil
}

func (this *Listener) Wait() {
	this.waitGroup.Wait()
}

type Connection struct {
	net.Conn
	listener *Listener

	closed bool
}

func (this *Connection) Close() error {

	if !this.closed {
		this.closed = true
		this.listener.waitGroup.Done()
	}

	return this.Conn.Close()
}

4、gracehttp包的用法

gracehttp包已经应用到每天几亿PV的项目中,也开源到了github上:github.com/tabalt/gracehttp,使用起来非常简单。

如以下示例代码,引入包后只需修改一个关键字,将http.ListenAndServe 改为 gracehttp.ListenAndServe即可。

package main

import (
    "fmt"
    "net/http"

    "github.com/tabalt/gracehttp"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "hello world")
    })

    err := gracehttp.ListenAndServe(":8080", nil)
    if err != nil {
        fmt.Println(err)
    }
}

测试平滑升级(优雅重启)的效果,可以参考下面这个页面的说明:

https://github.com/tabalt/gracehttp#demo

使用过程中有任何问题和建议,欢迎提交issue反馈,也可以Fork到自己名下修改之后提交pull request。

`