当线上代码须要更新时,咱们平时通常的作法须要先关闭服务而后再重启服务. 这时线上可能存在大量正在处理的请求, 这时若是咱们直接关闭服务会形成请求所有 中断, 影响用户体验; 在重启从新提供服务以前, 新请求进来也会502. 这时就出现两个须要解决的问题:git
本文主要结合linux和Golang中相关实现来介绍如何选型与实践过程.github
在实现优雅重启以前首先须要解决的一个问题是如何优雅退出:
咱们知道在go 1.8.x后,golang在http里加入了shutdown方法,用来控制优雅退出。
社区里很多http graceful动态重启,平滑重启的库,大可能是基于http.shutdown作的。golang
先来看下http shutdown的主方法实现逻辑。用atomic来作退出标记的状态,而后关闭各类的资源,而后一直阻塞的等待无空闲链接,每500ms轮询一次。macos
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
}
复制代码
// 关闭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
}
复制代码
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
}
}
...
复制代码
exec
,把代码段替换成新的程序的代码, 废弃原有的数据段和堆栈段并为新程序分配新的数据段与堆栈段,惟一留下的就是进程号。这样就会存在的一个问题就是老进程没法优雅退出,老进程正在处理的请求没法正常处理完成后退出。
而且新进程服务的启动并非瞬时的,新进程在listen
以后accept
以前,新链接可能由于syn queue
队列满了而被拒绝(这种状况不多, 但在并发很高的状况下是有可能出现)。这里结合下图与TCP三次握手的过程来看可能会好理解不少,我的感受有种豁然开朗的感受.bash
fork
后exec
建立新进程, exec
前在老进程中经过fcntl(fd, F_SETFD, 0);
清除FD_CLOEXEC
标志,以后exec
新进程就会继承老进程 的fd并能够直接使用。listen
相同的fd同时提供服务, 在新进程正常启动服务后发送信号给老进程, 老进程优雅退出。supervisor
进行管理的,这就会存在一个问题, supervisor
会认为服务异常退出, 会从新启动一个新进程.经过给文件描述符设置SO_REUSEPORT
标志让两个进程监听同一个端口, 这里存在的问题是这里使用的是两个不一样的FD监听同一个端口,老进程退出的时候。 syn queue
队列中还未被accept的链接会被内核kill掉。网络
经过ancilliary data
系统调用使用UNIX域套接字在进程之间传递文件描述符, 这样也能够实现优雅重启。可是这样的实现会比较复杂, HAProxy
中 实现了该模型。并发
直接fork
而后exec
调用,子进程会继承全部父进程打开的文件描述符, 子进程拿到的文件描述符从3递增, 顺序与父进程打开顺序一致。子进程经过epoll_ctl
注册fd并注册事件处理函数(这里以epoll模型为例), 这样子进程就能和父进程监听同一个端口的请求了(此时父子进程同时提供服务), 当子进程正常启动并提供服务后 发送SIGHUP
给父进程, 父进程优雅退出此时子进程提供服务, 完成优雅重启。app
从上面看, 相对来讲比较容易的实现是直接fork
andexec
的方式最简单, 那么接下来讨论下在Golang中的具体实现。socket
咱们知道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启动的, 如上所说的咱们若是经过父子进程, 子进程启动后退出父进程这种方式的话存在的问题就是子进程会被1号进程接管, 致使supervisor 认为服务挂掉重启服务,为了不这种问题咱们可使用master, worker的方式。 这种方式基本思路就是: 项目启动的时候程序做为master启动并监听端口建立socket描述符可是不对外提供服务, 而后经过os.Command
建立子进程经过Stdin
, Stdout
, Stderr
,ExtraFiles
和Env
传递标椎输入输出错误和文件描述符以及环境变量. 经过环境变量子进程能够知道本身是子进程并经过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的区别