gracehttp: 优雅重启 Go 程序(热启动 - Zero Downtime)

8
看完此篇你会知道,如何优雅的使用 HTTP Server

问题背景

http 应用程序重启时,如果我们直接 kill -9 使程序退出,然后在启动,会有以下几个问题:

  1. 旧的请求未处理完,如果服务端进程直接退出,会造成客户端链接中断(收到 RST);
  2. 新请求打过来,服务还没重启完毕,造成 connection refused
  3. 即使是要退出程序,直接 kill -9 仍然会让正在处理的请求中断;
  4. 面对海量请求,如何对链接数进行限制,并进行过载保护;
  5. 避免 open too many files 错误;

这些问题会造成不好的客户体验,严重的甚至影响客户业务。所以,我们需要以一种优雅的方式重启/关闭我们的应用,来达到热启动的效果,即:Zero Downtime

(Tips:名词解释)
热启动:新老程序(进程)无缝替换,同时可以保持对client的服务。让client端感觉不到你的服务挂掉了;
Zero Downtime: 0 宕机时间,即不间断的服务;

解决问题

Github: gracehttp

平滑启动

一般情况下,我们是退出旧版本,再启动新版本,总会有时间间隔,时间间隔内的请求怎么办?而且旧版本正在处理请求怎么办?
那么,针对这些问题,在升级应用过程中,我们需要达到如下目的:

  • 旧版本为退出之前,需要先启动新版本;
  • 旧版本继续处理完已经接受的请求,并且不再接受新请求;
  • 新版本接受并处理新请求的方式;

这样,我们就能实现 Zero Downtime 的升级效果。

实现原理

首先,我们需要用到以下基本知识:
1.linux 信号处理机制:在程序中,通过拦截 signal,并针对 signal 做出不同处理;
2.子进程继承父进程的资源:一切皆文件,子进程会继承父进程的资源句柄,网络端口也是文件;
3.通过给子进程重启标识(比如:重启时带着 -continue 参数),来实现子进程的初始化处理;

重启时,我们可以在程序中捕获 HUP 信号(通过 kill -HUP pid 可以触发),然后开启新进程,退出旧进程。信号处理代码示例如下:

package gracehttp

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

var sig chan os.Signal
var notifySignals []os.Signal

func init() {
    sig = make(chan os.Signal)
    notifySignals = append(notifySignals, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGTSTP, syscall.SIGQUIT)
    signal.Notify(sig, notifySignals...) // 注册需要拦截的信号
}

// 捕获系统信号,并处理
func handleSignals() {
    capturedSig := <-sig
    srvLog.Info(fmt.Sprintf("Received SIG. [PID:%d, SIG:%v]", syscall.Getpid(), capturedSig))
    switch capturedSig {
    case syscall.SIGHUP: // 重启信号
        startNewProcess() // 开启新进程
        shutdown() // 退出旧进程
    case syscall.SIGINT:
        fallthrough
    case syscall.SIGTERM:
        fallthrough
    case syscall.SIGTSTP:
        fallthrough
    case syscall.SIGQUIT:
        shutdown()
    }
}

startNewProcess shutdown 具体实现可以参考 Github

过载保护

通过限制 HTTP Serveraccept 数量实现链接数的限制,来达到如果并发量达到了最大值,客户端超时时间内可以等待,但不会消耗服务端文件句柄数(我们知道 Linux 系统对用户可以打开的最大文件数有限制,网络请求也是文件操作)

实现原理

  • 利用 channel 的缓冲机制实现,每个请求都会获取缓冲区的一个单元大小,知道缓冲区满了,后边的请求就会阻塞;
  • 如果客户端请求被阻塞,达到了客户端设置的超时时间,这时候链接会断开,那我们利用 goselect 机制,退出阻塞,并返回,不再进行 accept

处理代码如下:

package gracehttp

// about limit @see: "golang.org/x/net/netutil"

import (
    "net"
    "sync"
    "time"
)

type Listener struct {
    *net.TCPListener
    sem       chan struct{}
    closeOnce sync.Once     // ensures the done chan is only closed once
    done      chan struct{} // no values sent; closed when Close is called
}

func newListener(tl *net.TCPListener, n int) net.Listener {
    return &Listener{
        TCPListener: tl,
        sem:         make(chan struct{}, n),
        done:        make(chan struct{}),
    }
}

func (l *Listener) Fd() (uintptr, error) {
    file, err := l.TCPListener.File()
    if err != nil {
        return 0, err
    }
    return file.Fd(), nil
}

// override
func (l *Listener) Accept() (net.Conn, error) {
    acquired := l.acquire()
    tc, err := l.AcceptTCP()
    if err != nil {
        if acquired {
            l.release()
        }
        return nil, err
    }
    tc.SetKeepAlive(true)
    tc.SetKeepAlivePeriod(time.Minute)

    return &ListenerConn{Conn: tc, release: l.release}, nil
}

// override
func (l *Listener) Close() error {
    err := l.TCPListener.Close()
    l.closeOnce.Do(func() { close(l.done) })
    return err
}

// acquire acquires the limiting semaphore. Returns true if successfully
// accquired, false if the listener is closed and the semaphore is not
// acquired.
func (l *Listener) acquire() bool {
    select {
    case <-l.done:
        return false
    case l.sem <- struct{}{}:
        return true
    }
}

func (l *Listener) release() { <-l.sem }

type ListenerConn struct {
    net.Conn
    releaseOnce sync.Once
    release     func()
}

func (l *ListenerConn) Close() error {
    err := l.Conn.Close()
    l.releaseOnce.Do(l.release)
    return err
}

参考:grace-http:listener.go

gracehttp

现在我们把这个功能做得更优美有点,并提供一个开箱即用的代码库。
地址:Github-gracehttp

支持功能

  1. 平滑重启(Zero-Downtime);
  2. 平滑关闭;
  3. Server 添加(支持 HTTPHTTPS);
  4. 自定义日志组件;
  5. 支持单个端口 server 链接数限流,默认值为:C100K。超过该限制之后,链接阻塞进入等待,但是不消耗系统文件句柄,避免发生雪崩,压坏服务。

使用指南

添加服务

    import "fevin/gracehttp"
    
    ....

    // http
    srv1 := &http.Server{
        Addr:    ":80",
        Handler: sc,
    }
    gracehttp.AddServer(srv1, false, "", "")

    // https

    srv2 := &http.Server{
        Addr:    ":443",
        Handler: sc,
    }
    gracehttp.AddServer(srv2, true, "../config/https.crt", "../config/https.key")

    gracehttp.Run() // 此方法会阻塞,直到进程收到退出信号,或者 panic

如上所示,只需创建好 Server 对象,调用 gracehttp.AddServer 添加即可。

退出或者重启服务

  • 重启:kill -HUP pid
  • 退出:kill -QUIT pid

添加自定义日志组件

    gracehttp.SetErrorLogCallback(logger.LogConfigLoadError)

此处提供了三个 Set* 方法,分别对应不同的日志等级:

  • SetInfoLogCallback
  • SetNoticeLogCallback
  • SetErrorLogCallback

最后

实际中,很多情况会用到这种方式,不妨点个 star 吧!
欢迎一起来完善这个小项目,共同贡献代码。

你可能感兴趣的

载入中...