美文网首页Golang语言社区GolangGo语言社区
go平滑重启调研选型和项目实践

go平滑重启调研选型和项目实践

作者: 打瞌睡滴花花 | 来源:发表于2019-08-19 17:25 被阅读0次

    什么是平滑重启

    当线上代码需要更新时,我们平时一般的做法需要先关闭服务然后再重启服务. 这时线上可能存在大量正在处理的请求, 这时如果我们直接关闭服务会造成请求全部 中断, 影响用户体验; 在重启重新提供服务之前, 新请求进来也会502. 这时就出现两个需要解决的问题:

    • 老服务正在处理的请求必须处理完才能退出(优雅退出)
    • 新进来的请求需要正常处理,服务不能中断(平滑重启)

    本文主要结合linux和Golang中相关实现来介绍如何选型与实践过程.

    优雅退出

    在实现优雅重启之前首先需要解决的一个问题是如何优雅退出:

    一般的思路就是关闭对fd的listen, 确保不会有新的请求进来的情况下处理完已经进入的请求, 并退出程序.

    结合Golang中http包中Server的Shutdown的实现来分析.

    Golang的实现是:

    • 首先设置inShutdown标志

    • 关闭Listener保证不会有新请求进来

    • 等待所有活跃链接变成空闲状态,也就是处理完所有请求

    • 退出服务

    优雅重启

    方法演进

    从linux系统的角度

    • 直接使用exec,把代码段替换成新的程序的代码, 废弃原有的数据段和堆栈段, 并为新程序分配新的数据段与堆栈段, 唯一留下的就是进程号. 这样就会存在的一个问题就是老进程无法优雅退出, 老进程正在处理的请求无法正常处理完成后退出; 并且新进程服务的启动并不是瞬时的, 新进程在listen之后accept之前, 新连接可能因为syn queue队列满了而被拒绝(这种情况很少, 但在并发很高的情况下是有可能出现). 这里结合下图与TCP三次握手的过程来看可能会好理解很多, 个人感觉有种豁然开朗的感觉.
    image.png
    • 通过forkexec创建新进程, exec前在老进程中通过fcntl(fd, F_SETFD, 0);清除FD_CLOEXEC标志,之后exec新进程就会继承老进程 的fd并可以直接使用. 之后新进程和老进程listen相同的fd同时提供服务, 在新进程正常启动服务后发送信号给老进程, 老进程优雅退出. 之后所有请求 都到了新进程也就完成了本次优雅重启. 结合实际线上环境存在的问题: 这时新的子进程由于父进程的退出, 系统会把它的父进程改成1号进程,由于线上环境大多数服务都是通过 supervisor进行管理的,这就会存在一个问题, supervisor会认为服务异常退出, 会重新启动一个新进程.
    • 通过给文件描述符设置SO_REUSEPORT标志让两个进程监听同一个端口, 这里存在的问题是这里使用的是两个不同的FD监听同一个端口, 老进程退出的时候. syn queue队列中还未被accept的连接会被内核kill掉.

    • 通过ancilliary data系统调用使用UNIX域套接字在进程之间传递文件描述符, 这样也可以实现优雅重启. 但是这样的实现会比较复杂, HAProxy中 实现了该模型.

    • 直接fork然后exec调用, 子进程会继承所有父进程打开的文件描述符, 子进程拿到的文件描述符从3递增, 顺序与父进程打开顺序一致. 子进程通过epoll_ctl 注册fd并注册事件处理函数(这里以epoll模型为例), 这样子进程就能和父进程监听同一个端口的请求了(此时父子进程同时提供服务), 当子进程正常启动并提供服务后 发送SIGHUP给父进程, 父进程优雅退出此时子进程提供服务, 完成优雅重启.

    Golang中的实现

    从上面看, 相对来说比较容易的实现是直接forkandexec的方式最简单, 那么接下来讨论下在Golang中的具体实现.

    我们知道Golang中socket的fd默认是设置了FD_CLOEXEC标志的(net/sys_cloexec.go参考源码)

    // Wrapper around the socket system call that marks the returned file
    // descriptor as nonblocking and close-on-exec.
    func sysSocket(family, sotype, proto int) (int, error) {
        // See ../syscall/exec_unix.go for description of ForkLock.
        syscall.ForkLock.RLock()
        s, err := socketFunc(family, sotype, proto)
        if err == nil {
            syscall.CloseOnExec(s)
        }
        syscall.ForkLock.RUnlock()
        if err != nil {
            return -1, os.NewSyscallError("socket", err)
        }
        if err = syscall.SetNonblock(s, true); err != nil {
            poll.CloseFunc(s)
            return -1, os.NewSyscallError("setnonblock", err)
        }
        return s, nil
    }
    

    所以在exec后fd会被系统关闭, 但是我们可以直接通过os.Command来实现, 这里有些人可能有点疑惑了不是FD_CLOEXEC标志的设置, 新起的子进程继承的fd会被 关闭. 事实是os.Command启动的子进程可以继承父进程的fd并且使用, 阅读源码我们可以知道os.Command中通过Stdout,Stdin,Stderr以及ExtraFiles 传递的描述符默认会被Golang清除FD_CLOEXEC标志, 通过Start方法追溯进去我们可以确认我们的想法. (syscall/exec_{GOOS}.go我这里是macos的源码实现参考源码)

    // dup2(i, i) won't clear close-on-exec flag on Linux,
    // probably not elsewhere either.
    _, _, err1 = rawSyscall(funcPC(libc_fcntl_trampoline), uintptr(fd[i]), F_SETFD, 0)
    if err1 != 0 {
        goto childerror
    }
    

    结合supervisor时的问题

    实际项目中, 线上服务一般是被supervisor启动的, 如上所说的我们如果通过父子进程, 子进程启动后退出父进程这种方式的话存在的问题就是子进程会被1号进程接管, 导致supervisor 认为服务挂掉重启服务,为了避免这种问题我们可以使用master, worker的方式.
    这种方式基本思路就是: 项目启动的时候程序作为master启动并监听端口创建socket描述符但是不对外提供服务, 然后通过os.Command创建子进程通过Stdin, Stdout, Stderr,ExtraFilesEnv传递标椎输入输出错误和文件描述符以及环境变量. 通过环境变量子进程可以知道自己是子进程并通过os.NewFile将fd注册到epoll中, 通过fd创建TCPListener对象, 绑定handle处理器之后accept接受请求并处理, 参考伪代码:

    f := os.NewFile(uintptr(3+i), "")
    l, err := net.FileListener(f)
    if err != nil {
        return fmt.Errorf("failed to inherit file descriptor: %d", i)
    }
    
    server:=&http.Server{Handler: handler}
    server.Serve(l)
    

    上述过程只是启动了worker进程并提供服务, 真正的优雅重启, 可以通过接口(由于线上环境发布机器可能没有权限,只能曲线救国)或者发送信号给worker进程,worker 发送信号给master, master进程收到信号后起一个新worker, 新worker启动并正常提供服务后发送一个信号给master,master发送退出信号给老worker,老worker退出.

    日志收集的问题, 如果项目本身日志是直接打到文件,可能会存在fd滚动等问题(目前没有研究透彻). 目前的解决方案是项目log全部输出到stdout由supervisor来收集到日志文件, 创建worker的时候stdout, stderr是可以继承过去的, 这就解决了日志的问题, 如果有更好的方式环境一起探讨.

    参考文章

    谈谈golang网络库的入门认识
    深入理解Linux TCP backlog
    go优雅升级/重启工具调研
    记一次惊心的网站TCP队列问题排查经历
    accept和accept4的区别

    相关文章

      网友评论

        本文标题:go平滑重启调研选型和项目实践

        本文链接:https://www.haomeiwen.com/subject/josesctx.html