原文网址:https://tomaz.lovrec.eu/posts/graceful-server-restart/,如有侵权,请告知删除。部分地方略有小改动。
我们的团队使用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端口上侦听两个进程。
在我们的研究中,我们发现了两个可能的解决方案:
我们选择了后一种方法,因为它更简单并且是传统的UNIX fork/exec产生模型,其中进程的所有打开文件都被提供给子进程。 虽然goLang的os/exec只包允许我们只将stdin,stdout和stderr传递给子进程。 值得庆幸的是,os包提供了较低级别的原语,可用于将其他文件传递给子进程。
现在该计划在纸面上相对简单:
在父级和子级都使用相同套接字的点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上准备了一个项目,其中包含类似的代码【传送门】。此示例中仍有一些缺少的功能,检查和注意事项,如果您选择按原样使用此代码,则可能会导致应用程序出现不稳定或崩溃。