平滑重启你的后台TCP服务

2022-02-11 18:54:57 浏览数 (2)

1. 何为平滑重启以及为何平滑重启重要?

后台业务一般都是通过TCP协议提供服务。服务难免需要版本升级,需要经历旧进程的退出和新进程的启动。为保证用户链接不异常中断,需要旧进程继续运行,直至处理完用户请求后再退出。这样才不会打断用户请求,这就是所谓的Graceful Shutdown:优雅退出。如果不做优雅退出,用户交互过程中任何一个步骤可能被升级打断,往小了有些不重要的业务,中断一下可以忍受,但如支付的基础服务,升级服务如果不支持优雅退出,造成大量用户掉线,进而造成恶劣的影响。所以对服务实现,不论对什么业务来说都是很有必要的。这也是为什么Go从1.8版本开始,标准库net/http对HTTPServer就添加了一个新的方法GracefulShutdown,使得进程可以把现有请求都处理完了再退出。

但升级的流程不仅仅包括旧进程的退出,还包括新进程的启动。如何保证升级过程中新用户完全无感知,这就涉及另一个更进阶的话题,也就是所谓的Gracefule Restart: 优雅重启,也叫平滑重启,其目标是在服务升级进程重启过程中要平滑,不要让用户感受到任何异样,不要有任何停机时间,要保证服务持续可用。因此,优雅退出只是实现平滑重启的一个必要部分,平滑重启还要求更多。可见平滑重启是后台服务的一个十分重要的基础能力。

2. 如何实现平滑重启?

平滑重启能力这么重要,要如何实现呢?初看平滑重启只需要:

  1. 旧进程继续运行,停止accpet新链接,只处理已有的历史连接,处理完成后退出;
  2. 新进程accept新连接,接管后续所有新的请求;

1很容易实现:停止accept,关闭监听套接字就好?2看似也很简单:新开一个套接字,监听同一地址,accept新连接?初步看起来,这样做应该能实现平滑重启。让我们具体来分析下,这种方案能否实现我们的平滑重启的需求。

让我们先暂时搁置平滑重启的实现,详细看下linux下TCP连接建立过程中的交互,以及其中的维护的两个队列:

  • 半连接队列:也叫syn队列,服务端收到客户端发起的syn请求后,内核会把该连接存储到半连接队列,并向客户端回复syn ack;
  • 全连接队列:也叫accept队列;客户端收到服务端的syn ack后,会向服务端回复ack,完成3次握手后,tcp连接就建立了。这时linux会将该连接从半连接队列放入全连接队列,待服务端调用accept逐个取出来服务。
半连接与全连接队列, 图片来自小林coding博客半连接与全连接队列, 图片来自小林coding博客

通过上述分析可知,linux下每一个服务端的套接字都维护一个全连接队列和半连接队列。TCP的握手流程是由linux内核协议栈异步完成的。新的用户连接是源源不断过来的,服务端需要把半连接队列(半连接队列的连接完成后续握手流程后会进入全连接队列)和全连接的队列中的连接全部取出来,才不会漏掉用户连接,这样才能做到用户无感知。从这个角度分析来看,服务重启或升级时,新进程新建新的套接字(新套接字有自己的半连接和全连接队列),旧进程停止accept新连接的方案,会导致旧进程全连接队列和半连接队列里的连接被丢掉,要真正做到无损,用户无感知,只剩一种方案,那就是新进程继承旧进程套接字,而不新建套接字。

如果新的用户连接建立的速度远远超过服务端处理速度,还是会造成半连接或全连接队列满后,被内核丢掉连接。这种严重超过服务端处理能力的异常情况,一般是恶意攻击导致的。不在这里的讨论之列。平滑重启需要保证在服务预期的处理能力之内,能做到用户无感知。如何配置这两个队列的大小,以及如何查看队列溢出等异常,可以参考这里 进一步了解。

2.1 fork实现父子进程套接字继承共享

上面讨论到了服务重启或升级时,只有新进程继承旧进程监听的套接字才能真正做到平滑重启。新进程如何继承旧进程的套接字资源呢?答案是:通过Unix类系统独有的fork系统调用可以实现父子进程的资源共享,当然也包括套接字的资源共享,然后使用exec系统调用加载新的二进制更新服务端到新版本。

服务首次启动时,直接监听监听套接字,对外提供服务。通过如下流程完成一次平滑重启:

  1. 通过信号或其他手段,通知当前服务进程fork子进程,子进程启动后,就继承了父进程的套接字资源;
  2. 调用exec加载新的二进制更新服务端到新版本前,通过设置环境变量或其他手段告知哪些fd是继承的套接字资源,服务起来后直接用这些fd,而不是重新监听。
  3. 新进程起来后,通过信号或其他手段通知旧进程停止accept新连接,处理完历史连接后主动退出;

一个Go实现的平滑重启简单代码如下:

代码语言:Go复制
package main

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

var (
    upgrade bool
    ln      net.Listener
    server  *http.Server
)

func init() {
    flag.BoolVar(&upgrade, "upgrade", false, "user can't use this")
}

func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "hello world from pid:%d, ppid: %dn",
        os.Getpid(), os.Getppid())
}

func main() {
    flag.Parse()
    http.HandleFunc("/", hello)
    server = &http.Server{Addr: ":8999"}
    var err error
    if upgrade { // 如果是平滑重启,会在fork时添加-upgrade的命令行参数
        // 继承的fd时从3开始的,(0,1,2分别备stdin,stdout,stderr占据了)
        fd := os.NewFile(3, "")
        // 平滑重启时,直接通过fd=3来继承套接字, 通过fd构造net.Listener时
        //,会将原理fd dup一份,因而下面要手动close以释放资源
        ln, err = net.FileListener(fd)
        if err != nil {
            fmt.Printf("fileListener fail, error: %sn", err)
            os.Exit(1)
        }
        fd.Close() // 释放fd 3
    } else { // else分支对应服务首次启动,需要主动listen
        ln, err = net.Listen("tcp", server.Addr)
        if err != nil {
            fmt.Printf("listen %s fail, error: %sn", server.Addr, err)
            os.Exit(1)
        }
    }
    go func() {
        err := server.Serve(ln)
        if err != nil && err != http.ErrServerClosed {
            fmt.Printf("serve error: %sn", err)
        }
    }()
    setupSignal()
    fmt.Println("over")
}

func setupSignal() {
    ch := make(chan os.Signal, 1)
    signal.Notify(ch, syscall.SIGUSR2, syscall.SIGINT, syscall.SIGTERM)
    sig := <-ch
    switch sig {
    case syscall.SIGUSR2: // 通过给服务发送USR2信号触发平滑重启
        fmt.Println("SIGUSR2 received")
        err := forkProcess()
        if err != nil {
            fmt.Printf("fork process error: %sn", err)
        }

        // 调用go标准库里的优雅重启方法,方法中会停止accept新连接,
        // 处理完历史连接后就退出
        err = server.Shutdown(context.Background())
        if err != nil {
            fmt.Printf("shutdown after forking process error: %sn", err)
        }

    case syscall.SIGINT, syscall.SIGTERM: // 这两个信号只触发优雅退出
        signal.Stop(ch)
        close(ch)
        err := server.Shutdown(context.Background())
        if err != nil {
            fmt.Printf("shutdown error: %sn", err)
        }
    }
}

func forkProcess() error {
    flags := []string{"-upgrade"} // 添加命令行参数,告知子进程继承fd而不要重新监听
    fmt.Printf("forkProcess - arg: %v", os.Args[0])
    // 将fork exec两个系统后调用封装到了一起,os.Args[0]就是服务的binary
    // 所在路径,如果升级服务,平滑重启前需要覆盖服务的binary!!!
    cmd := exec.Command(os.Args[0], flags...)
    cmd.Stderr = os.Stderr
    cmd.Stdout = os.Stdout
    l, _ := ln.(*net.TCPListener)
    lfd, err := l.File()
    if err != nil {
        return err
    }
    // ExtraFiles填入继承的fd,GO标准库会保证继承的
    // fd时从3开始(0,1,2分别备stdin,stdout,stderr占据了)
    cmd.ExtraFiles = []*os.File{lfd}
    return cmd.Start()
}

除了父子进程间继承fd以外,还可以通过Unix Domain Socket在不同进程间共享套接字fd,也能达到共享fd的目的,实现原理简单来讲就是kernel帮忙dup了一下给到目的进程。详情参考: https://copyconstruct.medium.com/file-descriptor-transfer-over-unix-domain-sockets-dcbbf5b3b6ec

2.2 kernel新特性reuseport只提升建连效率,【不可以】实现平滑重启

讲完了平滑重启的实现,很多读者有一个误区,认为新版linux内核(>=3.9版本)添加的新特性reuseport特性也能实现平滑重启。事实上新旧进程使用reuseport监听同一地址是做不到无损的平滑重启的

reuseport特性的加入,是可以让多个进程/线程监听同一个地址(ip:port),每监听一次就会新建一个新的套接字,每个套接字都有自己的半连接和全连接队列。内核将不同用户的握手请求随机分配到不同的套接字的半连接队列,完成了完整的握手流程后再进入半连接所在套接字对应的全连接队列中供accept。实现reuseport时为了充分利用多核,提升连接建立的效率

如果启用reuseport,让新进程可以直接监听同一个地址,这会在新进程里创建一个新的套接字。通过上面的分析可知,旧进程的套接字有自己的半连接和全连接队列,新进程的套接字也有自己的半连接和全连接队列。服务升级时,旧进程停止accept,只处理已经accept的历史连接再退出服务,那么在旧进程全连接队列中未被accept的连接旧丢失了,也就实现不了无损平滑重启了。如果旧进程不停止accept,那么内核会源源不断把部分请求分配给旧套接字,这样旧进程也就永远无法退出,也就不能实现服务的更新了。

reuseport不能实现平滑重启,但是能提升建连效率。reuseport和“fork共享套接字”是互补的关系。nginx在1.9.1版本后也添加了reuseport支持,实现上是直接在master里监听worker数量对应的reuseport套接字,再让每个worker进程继承从中继承一个套接字。这样就完美而高效的结合2者的优点。实现细节请参考nginx源码分析—reuseport的使用。

3. 做一个工程友好的平滑重启库

基本的单地址的平滑重启可能不能满足我们的需求,因为随着业务的演进和更新:

  1. 同一个服务往往会增监听地址来提供新的能力,或是多监听几个端口提升处理能力;
  2. 也可能会有旧的能力因为各种原因需要下掉旧的监听地址;
  3. 服务在发布更新时也可能面临新服务起不来的问题,这时需要终止平滑重启流程,让老进程继续服务;
  4. 对于长连接类的应用,可能用户不会主动退出,需要旧服务进程显示的设置一个旧链接存活时间主动关闭链接退出旧服务;
  5. 平滑重启异常支持输出日志,或执行指定的回调上报异常;
  6. 支持配置指定的信号触发平滑重启; ...

因此,实现一个工程友好的平滑重启库,将上述种种工程上的考量纳入库的设计时很有必要的,实现中也是需要纳入考量的,有必要可以封装一个公共的库来给团队使用。

4. 总结

TCP后台服务难免需要升级更新,需要具备平滑重启能力,才能让服务升级对用户无感知。本文简析了平滑重启的原理及相关实现要点,澄清了reuseport实现平滑重启的误区,并结合工程上的考量实现一个通用的平滑重启库,以期为读者了解、实现健壮的平滑重启做一点点微薄的贡献。

参考文献:

  1. https://goteleport.com/blog/golang-ssh-bastion-graceful-restarts/
  2. https://swsmile.info/post/golang-graceful-restart-process/
  3. http://nginx.org/en/docs/control.html
  4. Why does one NGINX worker take all the load?
  5. nginx源码分析—reuseport的使用

0 人点赞