聊一聊Go服务优雅下线与重启的实现

3,720 阅读5分钟

前言

最近服务高可用的重要性越来越大,高可用通常指的是通过故障转移到冗余模块,如主备切换等相应操作,用来保证系统对外提供可用性,而细化到程序下线/重启等操作,在Go里面有哪些处理方式呢?今天我们来聊聊Go程序的优雅关闭与重启,如何让程序在关闭或者重启之前对旧的连接进行处理,尽量做到无感知切换。

概念引入

进程间通讯方式

我们知道进程通信有几种常用的方式:

  • 管道
  • 信号量
  • 网络socket
  • 共享内存

今天我们先来聊一聊信号量,比如P/V信号量,常常用于进程在访问临界区时候,用于唤醒或等待临界区的其他进程,信号量本质上是操作系统发送的一个中断机制,除了P/V信号量,还有常见的场景比如我们在中断按下Ctrl+C用于通知进程退出,会发送一个interrupt信号,也叫SIGINT。

在Go里面,windows平台下的信号量语义如下:

const (
    // More invented values for signals
    SIGHUP  = Signal(0x1)
    SIGINT  = Signal(0x2)
    SIGQUIT = Signal(0x3)
    SIGILL  = Signal(0x4)
    SIGTRAP = Signal(0x5)
    SIGABRT = Signal(0x6)
    SIGBUS  = Signal(0x7)
    SIGFPE  = Signal(0x8)
    SIGKILL = Signal(0x9)
    SIGSEGV = Signal(0xb)
    SIGPIPE = Signal(0xd)
    SIGALRM = Signal(0xe)
    SIGTERM = Signal(0xf)
)

var signals = [...]string{
    1:  "hangup",
    2:  "interrupt",
    3:  "quit",
    4:  "illegal instruction",
    5:  "trace/breakpoint trap",
    6:  "aborted",
    7:  "bus error",
    8:  "floating point exception",
    9:  "killed",
    10: "user defined signal 1",
    11: "segmentation fault",
    12: "user defined signal 2",
    13: "broken pipe",
    14: "alarm clock",
    15: "terminated",
}

使用15个数字以十六进制表示,那么我们接着看,在go里面,是怎么监听系统信号量的呢?

func Notify(c chan<- os.Signal, sig ...os.Signal) {
    if c == nil {
        panic("os/signal: Notify using nil channel")
    }
    // 省略部分代码...
    add := func(n int) {
        if n < 0 {
                return
        }
        if !h.want(n) {
            h.set(n)
            if handlers.ref[n] == 0 {
                enableSignal(n)

                // 单例启动监听,保证程序启动之前注册相应的处理逻辑
                watchSignalLoopOnce.Do(func() {
                    if watchSignalLoop != nil {
                        // 新建协程轮询监听
                        go watchSignalLoop()
                    }
                })
            }
            handlers.ref[n]++
        }
    }
    // 省略部分代码...
}

其中的watchSignalLoop在unix版本中,是一个轮询函数,

func loop() {
    for {
        process(syscall.Signal(signal_recv()))
    }
}

func init() {
    watchSignalLoop = loop
}

至此我们知道了信号量注册和监听的大致过程了,通过注册一个与目标信号量的上下文,异步创建一个协程进行系统信号监听。


接下来我们拿interrupt来举例,监听系统的中断请求,在Go中可以用如下方式注册:

// 注册返回绑定了os.Interrupt的ctx
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)

//...

// 其中stop()函数用于解绑上下文与信号量
defer stop()

通过监听os.Interrupt返回的上下文之后,如果系统调用中断,该ctx会执行终止,也就是ctx.Done(),我们可以利用这个作为我们后续处理的信号量。

优雅关闭

拿到中断信号量之后,我们来看下如何优雅退出,来看下这个函数

// Shutdown gracefully shuts down the server without interrupting any
// active connections. Shutdown works by first closing all open
// listeners, then closing all idle connections, and then waiting
// indefinitely for connections to return to idle and then shut down.
// If the provided context expires before the shutdown is complete,
// Shutdown returns the context's error, otherwise it returns any
// error returned from closing the Server's underlying Listener(s).
func (srv *Server) Shutdown(ctx context.Context) error {
    // ...
}

从注释可以看到,Shutdown()执行会先关闭打开连接,然后关闭空闲连接,接着等待已使用连接变成空闲连接,才会执行关闭。此外,如果传入的ctx上下文在执行关闭前发生过期,则Shutdown()会返回相应错误。

所以我们可以利用Shutdown(),让程序在中断处,执行最后收尾工作,另外用上下文的生命周期来把控收尾的缓冲期。

代码示例:

var (
    server http.Server
)

// 优雅停止demo
func main() {
    // 注册返回绑定了os.Interrupt的ctx
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
    defer stop()

    server = http.Server{
        Addr: ":8080",
    }

    // 注册路由
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(time.Second * 3)
        fmt.Fprint(w, "Hello World")
    })

    // 启动监听
    go server.ListenAndServe()

    // 触发interrupt信号
    <-ctx.Done()

    // 解绑上下文与信号量
    stop()
    log.Print("接收到SIGINT信号, 执行优雅停止, 等待收尾...")

    // 最后10秒回收连接
    timeoutCtx, cancelFunc := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancelFunc()

    if err := server.Shutdown(timeoutCtx); err != nil {
        fmt.Println(err)
    }
    
    log.Print("程序关闭完成.")
}
  1. 注册一个简单的路由请求,等待3秒之后返回“Hello World”
  2. 绑定系统信号量Signal.SIGNINT到上下文
  3. 通过上下文感知中断
  4. 新建10秒生存期的上下文
  5. 传入带生命周期的上下文至Shutdown()函数,用于控制收尾

输出示例
启动程序并且按下Ctrl+C,在没有请求的情况下,程序快速终止。

$ go run main.go
2021/11/06 23:58:03 接收到SIGINT信号, 执行优雅停止, 等待收尾...
2021/11/06 23:58:03 程序关闭完成.

接着我们在程序启动之后执行请求让其耗时处理

$ curl 127.0.0.1:8080
Hello World

并在服务端按下Ctrl+C

$ go run main.go
2021/11/06 23:58:33 接收到SIGINT信号, 执行优雅停止, 等待收尾...
2021/11/06 23:58:35 程序关闭完成.

可以看到日志输出,程序不再是立即退出,而是等待请求终止才会关闭。
而假如说我们调整请求执行逻辑耗时更长,当处理时长超过shutdown函数绑定的上下文周期,则程序会返回一个上下文超时的错误。

2021/11/07 00:02:46 接收到SIGINT信号, 执行优雅停止, 等待收尾...
2021/11/07 00:02:51 优雅停止错误: context deadline exceeded

抛砖引玉

以上就是优雅退出的大致实现,关于可拓展的想法:
上述主要是一个优雅下线之前的处理,生产场景下,服务下线或者不可用还有其他的具体检测措施,比如心跳包超时丢失,k8s中服务下线可以通过轮询周期监听一个本地文件/句柄来判断等,其实信号量只是我们感知程序中断的一种方式,基于服务下线,我们知道了最终可以使用Shutdown()来执行收尾。
此外,当执行收尾之后,如果遇到关联上下文已经超时的情况context deadline exceeded,业务处理层一般可以归档未处理完成的请求,放入重试队列或者以写日志的形式记录下来,归档并放在后续修复。


优雅重启

聊完优雅退出之后,后续我们再来看下程序如何优雅重启。 前阵子看到一篇信号量交互的实现,个人觉得挺有意思,所以拿出来梳理一下,文章链接会放在参考资料。

其实优雅重启核心在于我们需要有一个接盘侠,当下线的服务如果有未处理完的连接,我们需要提供一个新的服务/进程尽可能地处理,并继续持续监听新的请求,对外提供可用性,让请求端无感知。

简单来说,实现优雅重启需要解决两个问题:

  1. 如何在操作系统层面,保留原先创建的socket让新重启的进程继续监听
  2. 保证所有后续请求能够执行响应或者超时

这听起来似乎十分理想,下面我们一步一步拆解,看下是如何实现的。

核心拆解

  1. 在当前监听socket的进程下,fork一个子进程进行“接盘”
  2. 新(子)进程接替,复用原先的socket
  3. 新(子)进程通知原(父)进程停止接收请求并关闭

状态转移

我们前期不过多深入进程启动后续处理的细节,先来梳理下程序需要监听的状态,或者说程序在重启时刻需要对哪些事件做出什么响应。 其实当前服务无非两个状态,

  • 一个是首次启动
  • 另一个是版本变动启动新进程替换旧进程

状态一其实和普通的服务没有本质区别,就是启动完进行listen就好了。

来聊一聊状态二,状态二其实是由状态一延伸出来的,所以程序需要同时兼任两种状态的监听,而监听的触发事件就是上文我们在优雅停止中提到的信号量

我画了一张大致流程图,方便后续加深理解:

graceful-state.png

前置概念

我们再来熟悉下网络Socket编程中一些概念,以便知悉如何进行连接复用。

我们知道在网络环境中,可以使用TCP四元组建立一个端到端的连接,即<src addr>, <src port>, <dest addr>, <dest port>锁定唯一连接标识。

都知道一个TCP连接断开需要经过四次挥手,其中在被断开方有个TIME_WAIT状态,用于等待被断开方关闭连接,或者是发送端缓冲区数据真正发送,这个等待时间一般是不会改变的(默认2min),也就是说在这个TIME_WAIT状态结束之前中,当前tcp元组是无法被复用的,除非设置了SO_REUSEADDR

这里有两个关键参数,SO_REUSEADDRSO_REUSEPORT, 首先字面上意思都是复用,具体概念如下:

参数含义
SO_REUSEADDR允许连接ip地址在未完全断开的情况进行复用
SO_REUSEPORT在开启SO_REUSEADDR的前提下,允许连接端口地址进行复用

那么如果是开启复用并连接成功,在操作系统层面,假如多个文件句柄都绑定了系统的ip+port,系统会怎么处理呢,答案是负载均衡,即系统会根据请求进行分配,类似随机轮询的方式,对相同ip+port的连接进行交互。

这里可能有人会说,这样子不同客户端进程访问是否有权限越界问题呢,确实会有,所以基于安全考虑有一个约定:

To prevent "port hijacking", there is one special limitation: All sockets that want to share the same address and port combination must belong to processes that share the same effective user ID

所有要开启复用同一地址端口的连接必须属于同一个userID,而我们的上下文中是同一个进程或者说同一用户创建处理的,所以可以复用原来的连接,从而避免恶意劫持。

程序示例

我们来看下程序如何实现

  1. 传入复用连接的配置项
func control(network, address string, c syscall.RawConn) error {
    var err error
    c.Control(func(fd uintptr) {
        err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
        if err != nil {
            return
        }

        err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
        if err != nil {
            return
        }
    })
    return err
}
  1. 检测当前监听的tcp元组是否正在监听
func listener() (net.Listener, error) {
    lc := net.ListenConfig{
        Control: control,
    }
    if l, err := lc.Listen(context.TODO(), "tcp", ":8080"); err != nil {
        // 端口未使用,返回err
        return nil, err
    } else {
        return l, nil
    }
}
  1. 监听系统信号量
func upgradeLoop(l *net.Listener, s *http.Server) {
    sig := make(chan os.Signal)
    signal.Notify(sig, syscall.SIGQUIT, syscall.SIGUSR2)
    for t := range sig {
        switch t {
        case syscall.SIGUSR2:
            // 接收升级信号量
            log.Println("Received SIGUSR2 upgrading binary")
            // Fork子进程优雅升级
            if err := spawnChild(); err != nil {
                log.Println(
                    "Cannot perform binary upgrade, when starting process: ",
                    err.Error(),
                )
                continue
            }
        case syscall.SIGQUIT:
            // 接收杀掉当前进程的信号量
            s.Shutdown(context.Background())
            os.Exit(0)
        }
    }
}

// fork创建子进程,并在新进程之前更新覆盖全局父进程id
func spawnChild() error {
    // 获取当前启动传入可执行文件参数, 如./main
    argv0, err := exec.LookPath(os.Args[0])
    if err != nil {
        return err
    }

    wd, err := os.Getwd()
    if err != nil {
        return err
    }

    files := make([]*os.File, 0)
    files = append(files, os.Stdin, os.Stdout, os.Stderr)

    // 存下当前进程, 这个id会在新进程启动之后kill掉
    ppid := os.Getpid()
    os.Setenv("APP_PPID", strconv.Itoa(ppid))

    // 启动新进程
    os.StartProcess(argv0, os.Args, &os.ProcAttr{
        Dir:   wd,
        Env:   os.Environ(),
        Files: files,
        Sys:   &syscall.SysProcAttr{},
    })

    return nil
}
  1. 主协程的逻辑
func main() {
    log.Println("Started HTTP API, PID: ", os.Getpid())
    var l net.Listener
    // 首次启动
    if fd, err := listener(); err != nil {
    	log.Println("Parent does not exists, starting a normal way")
    	l, err = net.Listen("tcp", ":8080")
    
    	if err != nil {
    		panic(err)
    	}
    } else {
    	// 新fork出来的,当前端口已被监听
    	l = fd
    	// 发送quit给父进程
    	killParent()
    	time.Sleep(time.Second)
    }
    
    // 启动server监听
    s := &http.Server{}
    	http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
            log.Printf("New request! From: %d, path: %s, method: %s: ", os.Getpid(),
                    r.URL, r.Method)
    	})
    go s.Serve(l)
    
    // 监听信号量
    upgradeLoop(&l, s)
}

代码引用:

zero-downtime-application项目源码,其实核心在于新进程到旧进程的优雅迁移这个过程,只要理解了代码看起来就会清晰一点了。

这也就是为什么main函数逻辑块需要兼容两个情形,一是正常server流程,一是接收旧进程的收尾。

拓展应用

关于上面的优雅重启触发机制是用户发送信号量pkill -SIGUSR2给进程,作为一个手动升级的无缝切换。

其实基于这个功能可以进行拓展,比如监控服务加入连接探测,请求响应时间告警等,当达到某个触发机制,可以触发优雅重启,从而实现动态拉起的效果,当然后续还是需要复盘定位服务的问题在哪里,毕竟有时候重启并不能解决所有问题。

参考链接