【译】优雅重启golang web服务

原文网址:https://tomaz.lovrec.eu/posts/graceful-server-restart/,如有侵权,请告知删除。部分地方略有小改动。

 

优雅重启golang web服务

    我们的团队使用GoLang开发了许多API,为我们的应用程序提供必要的功能。 当然,我们使用 net/http 包作为API的Web服务器部分,因为它是如此强大的包,并且提供了使用GoLang开发Web服务器所需的所有功能。 每个API在启动时都会执行一系列步骤,包括读取和解析配置,设置日志logger,设置数据库连接池,启动Web服务器,准备内部路由器用来处理来自Web服务器的HTTP请求路由到我们的处理程序代码,等等。 几个星期前,我们遇到了一个问题,由于配置更改导致我们不得不手动重启API,由于流量过大而导致问题,我们日志中记录到了几个失败的请求,因为我们被迫重启API ,它在关闭或启动过程中无法处理请求。

    经过一些研究,我们发现了两种可行方案。第一种是开发一系列CLI命令,这些命令允许我们重新加载和/或重新启动API的某些部分;第二种是让API正常重启而不取消任何已接收的请求或丢弃任何新的请求。 由于方案一使用特定命令意味着我们必须重新处理API的几乎所有部分以允许重新加载,以及每次我们向API添加需要重新加载的任何内容时添加新命令,我们立即选择优雅重启。

理论

    理论上,我们想要启动一个新的API实例,它将重新加载配置、重新配置所需的所有内容,并作为相关API的新副本启动。 理论上这听起来很简单,但由于API正在侦听TCP端口,这使事情变得复杂,因为我们不能在同一个TCP端口上侦听两个进程。

    在我们的研究中,我们发现了两个可能的解决方案:

  • 在套接字上设置SO_REUSERPORT标志,允许多个进程绑定到同一端口
  • 复制套接字并将其作为文件传递给子进程并在那里重新创建它

    我们选择了后一种方法,因为它更简单并且是传统的UNIX fork/exec产生模型,其中进程的所有打开文件都被提供给子进程。 虽然goLang的os/exec只包允许我们只将stdin,stdout和stderr传递给子进程。 值得庆幸的是,os包提供了较低级别的原语,可用于将其他文件传递给子进程。

    现在该计划在纸面上相对简单:

  1. 收听SIGHUP信号并在收到时触发正常重启
  2. 启动一个新的UNIX域套接字,用于父进程和子进程之间的通信
  3. fork运行进程将stdin,stdout,stderr和监听器文件传递给子进程
  4. 等待子进程加载配置并准备一切以便开始
  5. 通过UNIX域套接字从父级请求侦听器元数据信息
  6. 尝试在子项中重新创建套接字
  7. 正常关闭父Web服务器

在父级和子级都使用相同套接字的点6和7之间,内核将以循环方式将所有新传入请求路由到两个进程,直到一个停止。 因此,在父节点停止之后,内核继续仅向具有新请求的子节点提供内容。

实践

    为了达到预期的效果,我们在启动服务器时需要一些额外的步骤,因此我们要单独创建监听套接字而不是从net/http包中启动服务器

package main

import (
	"fmt"
	"net"
	"net/http"
)

var cfg *srvCfg

type srvCfg struct {
	// Socket file location
	sockFile string

	// Listen address
	addr string

	// Listener
	ln net.Listener
}

// create a new listener and return it
func createListener() (net.Listener, error) {
	ln, err := net.Listen("tcp", cfg.addr)
	if err != nil {
		return nil, err
	}

	return ln, nil
}

// obtain a network listener
func getListener() (net.Listener, error) {
	// try to import a listener if we are a fork
	// TODO

	fmt.Println("listener not imported")

	// couldn't import a listener, let's create one
	ln, err := createListener()
	if err != nil {
		return nil, err
	}

	return ln, err
}

// start the server and register the handler
func start(handler http.Handler) *http.Server {
	srv := &http.Server{
		Addr: cfg.addr,
	}

	srv.Handler = handler

	go srv.Serve(cfg.ln)

	return srv
}

// obtain a listener, start the server
func serve(config srvCfg, handler http.Handler) {
	cfg = &config

	var err error
	cfg.ln, err = getListener()
	if err != nil {
		panic(err)
	}

	start(handler)

	// TODO: listen for signals
}

func main() {
	serve(srvCfg{
		sockFile: "/tmp/api.sock",
		addr:     ":8000",
	}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`Hello, world!`))
	}))
}

    接下来我们想要添加一个信号监听器,它首先监听只监听SIGTERM和SIGINT信号,然后在给定的超时时间内正常停止服务器。 请注意,示例仅显示已更改的代码部分。

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

type srvCfg struct {
	// ...

	// Amount of time allowed for requests to finish before server shutdown
	shutdownTimeout time.Duration
}

// ...

// listen for signals
func waitForSignals(srv *http.Server) error {
	sig := make(chan os.Signal, 1024)
	signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
	for {
		select {
		case s := <-sig:
			switch s {
			case syscall.SIGTERM, syscall.SIGINT:
				return shutdown(srv)
			}
		}
	}
}

// gracefully shutdown the server
func shutdown(srv *http.Server) error {
	fmt.Println("Server shutting down")

	ctx, cancel := context.WithTimeout(context.Background(),
		cfg.shutdownTimeout)
	defer cancel()

	return srv.Shutdown(ctx)
}

// obtain a listener, start the server
func serve(config srvCfg, handler http.Handler) {
    // ...

	srv := start(handler)

	err = waitForSignals(srv)
	if err != nil {
		panic(err)
	}
}

func main() {
	serve(srvCfg{
		sockFile:        "/tmp/api.sock",
		addr:            ":8000",
        shutdownTimeout: 5 * time.Second,
	}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`Hello, world!`))
	}))
}

    为了开始正常重启,我们选择了SIGHUP信号。 收到此信号后,我们将创建一个新的UNIX域套接字,用于父进程和子进程之间的通信。 子进程一旦启动就会尝试连接到此套接字,并准备启动自己的内部Web服务器。 在成功连接时,它会将get_listener消息传输给父级。 在考虑孩子无法启动之前,父级将等待srvCfg.childTimeout一段时间,并且它将中止自身的关闭。

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

type srvCfg struct {
	// ...

	// Amount of time allowed for a child to properly spin up and request the listener
	childTimeout time.Duration
}

// ...

// accept connections on the socket
func acceptConn(l net.Listener) (c net.Conn, err error) {
	chn := make(chan error)
	go func() {
		defer close(chn)
		c, err = l.Accept()
		if err != nil {
			chn <- err
		}
	}()

	select {
	case err = <-chn:
		if err != nil {
			fmt.Printf("error occurred when accepting socket connection: %v\n",
				err)
		}

	case <-time.After(cfg.childTimeout):
		fmt.Println("timeout occurred waiting for connection from child")
	}

	return
}

// create a new UNIX domain socket and handle communication
func socketListener(chn chan<- string, errChn chan<- error) {
	ln, err := net.Listen("unix", cfg.sockFile)
	if err != nil {
		errChn <- err
		return
	}
	defer ln.Close()

	// signal that we created a socket
	chn <- "socket_opened"

	// accept
	c, err := acceptConn(ln)
	if err != nil {
		errChn <- err
		return
	}

	// read from the socket
	buf := make([]byte, 512)
	nr, err := c.Read(buf)
	if err != nil {
		errChn <- err
		return
	}

	data := buf[0:nr]
	switch string(data) {
	case "get_listener":
		fmt.Println("get_listener received - sending listener information")
		// TODO: send listener
		chn <- "listener_sent"
	}
}

// handle SIGHUP - create socket, fork the child, and wait for child execution
func handleHangup() error {
	c := make(chan string)
	defer close(c)
	errChn := make(chan error)
	defer close(errChn)

	go socketListener(c, errChn)

	for {
		select {
		case cmd := <-c:
			switch cmd {
			case "socket_opened":
				// TODO: fork

			case "listener_sent":
				fmt.Println("listener sent - shutting down")

				return nil
			}

		case err := <-errChn:
			return err
		}
	}

	return nil
}

// listen for signals
func waitForSignals(srv *http.Server) error {
	sig := make(chan os.Signal, 1024)
	signal.Notify(sig, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
	for {
		select {
		case s := <-sig:
			switch s {
			case syscall.SIGHUP:
				err := handleHangup()
				if err == nil {
					// no error occured - child spawned and started
					return shutdown(srv)
				}

			// ...
			}
		}
	}
}

func main() {
	serve(srvCfg{
		sockFile:        "/tmp/api.sock",
		addr:            ":8000",
		shutdownTimeout: 5 * time.Second,
		childTimeout:    5 * time.Second,
	}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`Hello, world!`))
	}))
}

    创建UNIX域套接字时,socketListener函数将通过通道发送socket_opened消息,并发出已创建套接字的handleHangup函数信号。 一旦创建了套接字,我们就可以继续使用os.StartProcess函数分叉进程,传递打开文件的句柄,包括监听器文件句柄。

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"syscall"
	"time"
)

// obtain the *os.File from the listener
func getListenerFile(ln net.Listener) (*os.File, error) {
	switch t := ln.(type) {
	case *net.TCPListener:
		return t.File()

	case *net.UnixListener:
		return t.File()
	}

	return nil, fmt.Errorf("unsupported listener: %T", ln)
}

// fork the process
func fork() (*os.Process, error) {
	// get the file descriptor and pack it up in the metadata
	lnFile, err := getListenerFile(cfg.ln)
	if err != nil {
		return nil, err
	}
	defer lnFile.Close()

	// pass the stdin, stdout, stderr, and the listener files to the child
	files := []*os.File{
		os.Stdin,
		os.Stdout,
		os.Stderr,
		lnFile,
	}

	// get process name and dir
	execName, err := os.Executable()
	if err != nil {
		return nil, err
	}
	execDir := filepath.Dir(execName)

	// spawn a child
	p, err := os.StartProcess(execName, []string{execName}, &os.ProcAttr{
		Dir:   execDir,
		Files: files,
		Sys:   &syscall.SysProcAttr{},
	})
	if err != nil {
		return nil, err
	}

	return p, nil
}

// handle SIGHUP - create socket, fork the child, and wait for child execution
func handleHangup() error {
	// ...

	for {
		select {
		case cmd := <-c:
			switch cmd {
			case "socket_opened":
				p, err := fork()
				if err != nil {
					fmt.Printf("unable to fork: %v\n", err)
					continue
				}
				fmt.Printf("forked (PID: %d), waiting for spinup", p.Pid)

		// ...
		}
	}

	return nil
}

    子进程现在应该已经启动,但它将无法完全启动,因为它将尝试将新套接字绑定到同一地址。 为防止这种情况,子进程尝试连接到打开的UNIX域套接字,并使用消息get_listener向父进程发送信号。 然后,父节点将监听器的所有元数据发送给子节点,并使用listener_sent消息向handleHangup函数发送信号,然后该节点将正常关闭。 子进程作为fork启动时已经收到了监听器文件,但它仍然必须尝试为监听器重新创建/重建底层* os.File。

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"sync"
	"syscall"
	"time"
)

type listener struct {
	// Listener address
	Addr string `json:"addr"`

	// Listener file descriptor
	FD int `json:"fd"`

	// Listener file name
	Filename string `json:"filename"`
}

// import the listener from the UNIX domain socket and attempt to
// recreate/rebuild the underlying *os.File
func importListener() (net.Listener, error) {
	c, err := net.Dial("unix", cfg.sockFile)
	if err != nil {
		return nil, err
	}
	defer c.Close()

	var lnEnv string
	wg := sync.WaitGroup{}
	wg.Add(1)
	go func(r io.Reader) {
		defer wg.Done()

		buf := make([]byte, 1024)
		n, err := r.Read(buf[:])
		if err != nil {
			return
		}

		lnEnv = string(buf[0:n])
	}(c)

	_, err = c.Write([]byte("get_listener"))
	if err != nil {
		return nil, err
	}

	wg.Wait()

	if lnEnv == "" {
		return nil, fmt.Errorf("Listener info not received from socket")
	}

	var l listener
	err = json.Unmarshal([]byte(lnEnv), &l)
	if err != nil {
		return nil, err
	}
	if l.Addr != cfg.addr {
		return nil, fmt.Errorf("unable to find listener for %v", cfg.addr)
	}

	// the file has already been passed to this process, extract the file
	// descriptor and name from the metadata to rebuild/find the *os.File for
	// the listener.
	lnFile := os.NewFile(uintptr(l.FD), l.Filename)
	if lnFile == nil {
		return nil, fmt.Errorf("unable to create listener file: %v", l.Filename)
	}
	defer lnFile.Close()

	// create a listerer with the *os.File
	ln, err := net.FileListener(lnFile)
	if err != nil {
		return nil, err
	}

	return ln, nil
}

// obtain a network listener
func getListener() (net.Listener, error) {
	// try to import a listener if we are a fork
	ln, err := importListener()
	if err == nil {
		fmt.Printf("imported listener file descriptor for addr: %s\n", cfg.addr)
		return ln, nil
	}

	// ...
}

// send listener file metadata over the UNIX domain socket
func sendListener(c net.Conn) error {
	lnFile, err := getListenerFile(cfg.ln)
	if err != nil {
		return err
	}
	defer lnFile.Close()

	l := listener{
		Addr:     cfg.addr,
		FD:       3,
		Filename: lnFile.Name(),
	}

	lnEnv, err := json.Marshal(l)
	if err != nil {
		return err
	}

	_, err = c.Write(lnEnv)
	if err != nil {
		return err
	}

	return nil
}

// create a new UNIX domain socket and handle communication
func socketListener(chn chan<- string, errChn chan<- error) {
	// ...

	switch string(data) {
	case "get_listener":
		fmt.Println("get_listener received - sending listener information")

		err := sendListener(c)
		if err != nil {
			errChn <- err
			return
		}

		chn <- "listener_sent"
	}
}

    现在,当子进程启动并准备启动其Web服务器时,它将尝试重新创建/重建收到的侦听器文件的* os.File,并向父进程发信号通知它已到达此点。 然后父进程尝试正常关闭,允许所有打开到父进程的连接达到srvCfg.shutdownTimeout完成时间,如果所有请求都在此之前完成处理,则服务器将关闭,如果达到超时, 无论状态或请求数量仍然开放,它都将被杀死。

结论

    这是一个更简化的想法表示,应该谨慎使用,虽然这个例子确实有效。 由于代码在这里被分解成许多片段,我在GitHub上准备了一个项目,其中包含类似的代码【传送门】。此示例中仍有一些缺少的功能,检查和注意事项,如果您选择按原样使用此代码,则可能会导致应用程序出现不稳定或崩溃。

你可能感兴趣的:(golang)