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

原文链接
github

什么是平滑重启

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

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

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

优雅退出

在实现优雅重启之前首先需要解决的一个问题是如何优雅退出:
我们知道在go 1.8.x后,golang在http里加入了shutdown方法,用来控制优雅退出。
社区里不少http graceful动态重启,平滑重启的库,大多是基于http.shutdown做的。

http shutdown 源码分析

先来看下http shutdown的主方法实现逻辑。用atomic来做退出标记的状态,然后关闭各种的资源,然后一直阻塞的等待无空闲连接,每500ms轮询一次。

var shutdownPollInterval = 500 * time.Millisecond

func (srv *Server) Shutdown(ctx context.Context) error {
    // 标记退出的状态
    atomic.StoreInt32(&srv.inShutdown, 1)
    srv.mu.Lock()
    // 关闭listen fd,新连接无法建立。
    lnerr := srv.closeListenersLocked()
    
    // 把server.go的done chan给close掉,通知等待的worekr退出
    srv.closeDoneChanLocked()

    // 执行回调方法,我们可以注册shutdown的回调方法
    for _, f := range srv.onShutdown {
        go f()
    }

    // 每500ms来检查下,是否没有空闲的连接了,或者监听上游传递的ctx上下文。
    ticker := time.NewTicker(shutdownPollInterval)
    defer ticker.Stop()
    for {
        if srv.closeIdleConns() {
            return lnerr
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-ticker.C:
        }
    }
}
…

是否没有空闲的连接
func (s *Server) closeIdleConns() bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    quiescent := true
    for c := range s.activeConn {
        st, unixSec := c.getState()
        if st == StateNew && unixSec < time.Now().Unix()-5 {
            st = StateIdle
        }
        if st != StateIdle || unixSec == 0 {
            quiescent = false
            continue
        }
        c.rwc.Close()
        delete(s.activeConn, c)
    }
    return quiescent
}

关闭server.doneChan和监听的文件描述符

// 关闭doen chan
func (s *Server) closeDoneChanLocked() {
    ch := s.getDoneChanLocked()
    select {
    case <-ch:
        // Already closed. Don't close again.
    default:
        // Safe to close here. We're the only closer, guarded
        // by s.mu.
        close(ch)
    }
}

// 关闭监听的fd
func (s *Server) closeListenersLocked() error {
    var err error
    for ln := range s.listeners {
        if cerr := (*ln).Close(); cerr != nil && err == nil {
            err = cerr
        }
        delete(s.listeners, ln)
    }
    return err
}

// 关闭连接
func (c *conn) Close() error {
    if !c.ok() {
        return syscall.EINVAL
    }
    err := c.fd.Close()
    if err != nil {
        err = &OpError{Op: "close", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
    }
    return err
}

这么一系列的操作后,server.go的serv主监听方法也就退出了。

func (srv *Server) Serve(l net.Listener) error {
    ...
    for {
        rw, e := l.Accept()
        if e != nil {
            select {
             // 退出
            case <-srv.getDoneChan():
                return ErrServerClosed
            default:
            }
            ...
            return e
        }
        tempDelay = 0
        c := srv.newConn(rw)
        c.setState(c.rwc, StateNew) // before Serve can return
        go c.serve(ctx)
    }
}

那么如何保证用户在请求完成后,再关闭连接的?

func (s *Server) doKeepAlives() bool {
    return atomic.LoadInt32(&s.disableKeepAlives) == 0 && !s.shuttingDown()
}


// Serve a new connection.
func (c *conn) serve(ctx context.Context) {
    defer func() {
                ... xiaorui.cc ...
        if !c.hijacked() {
                        // 关闭连接,并且标记退出
            c.close()
            c.setState(c.rwc, StateClosed)
        }
    }()
        ...
    ctx, cancelCtx := context.WithCancel(ctx)
    c.cancelCtx = cancelCtx
    defer cancelCtx()

    c.r = &connReader{conn: c}
    c.bufr = newBufioReader(c.r)
    c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)

    for {
                // 接收请求
        w, err := c.readRequest(ctx)
        if c.r.remain != c.server.initialReadLimitSize() {
            c.setState(c.rwc, StateActive)
        }
                ...
                ...
                // 匹配路由及回调处理方法
        serverHandler{c.server}.ServeHTTP(w, w.req)
        w.cancelCtx()
        if c.hijacked() {
            return
        }
                ...
                // 判断是否在shutdown mode, 选择退出
        if !w.conn.server.doKeepAlives() {
            return
        }
    }
    ...

优雅重启

方法演进

从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是可以继承过去的,这就解决了日志的问题, 如果有更好的方式环境一起探讨。

原文链接
github

参考文章

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,463评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,868评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,213评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,666评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,759评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,725评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,716评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,484评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,928评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,233评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,393评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,073评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,718评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,308评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,538评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,338评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,260评论 2 352

推荐阅读更多精彩内容