对于一个常驻、高访问量的网络服务来说,升级/重启时,一个难以忽视的问题是避免对正在通信的客户端造成影响。因此大家一直在寻求一种优雅、零宕机的升级/重启方案(seamless reload/upgrade
)。在工程师们的日常实践中,尝试了不同的方案。各方案的核心都是fork-exec
流程,其不同的区别就是在这个过程中,如何优雅的传递活跃的网络连接,如何避免新建连接失败,以及处理这个过程中的错误和如何回退。
方案选型
首先先简单介绍一些方案1:
SO_REUSEPORT 多进程
在HAProxy 1.5.11时,采用该方案。首先可以对监听 socket 启用SO_REUSEPORT
,这样可以使得多个监听 socket 共享同一个地址,这样可以使得我们能同时启动多个进程来监听同一个地址。在升级或重启的过程中,我们启动多个进程。
然后向老的进程发送退出命令,然后老的进程停止accept
然后关闭监听的 socket,然后服务完已有链接后退出进程,最终回归到单进程的状态。
但是这样仍然存在拒绝服务的问题,因为启用SO_REUSEPORT
的 socket 在内核中拥有不同的队列,在老进程停止accept
并关闭监听 socket 的过程中(下图红字部分),内核仍然会给该 socket 分配新建的链接到队列中,当老进程关闭监听 socket 后,内核并不会将其队列中的 pending 链接转移另一个监听相同地址的 socket 的队列里去,这样就造成了,如果业务新建连接的QPS很高时,仍然会拒绝一些新建连接的请求。
为了解决该问题,我们需要采用iptable
、tc
等工具在升级/重启过程中,拒绝或者阻塞SYN包的进入,避免在此过程中产生新建连接,但是会造成服务产生一定的延时2。
继承监听SOCKET
其利用父子进程fork-exec
继承文件描述符的特性,在父子进程之间维护传递监听 socket。在升级/重启的过程中,父进程将监听 socket 继承给子进程,使得整个过程没有监听 socket 被关闭,从而不产生拒绝服务的问题。但是这使得进程模型变得复杂。
因此有的项目,例如einhorn,其将监听 socket 与业务逻辑分离成为独立进程,成为一个SOCKET SERVER
,专门负责监听 socket 的传递,使得父子进程模型变得简单。
UNIX SOCKET进程间传递监听SOCKET
HAProxy 1.83采用该方案,其采用UNIX SOCKET
的access ancillary data中的SCM_RIGHTS
在非同源父子进程间传递监听 socket 。这样也使得在升级/重启过程中产生关闭监听 socket的问题。
开源实现
下面简单分析几个go语言实现的相关功能的 package:
facebookarchive/grace
facebookarchive/grace
整体实现比较简单,其只提供了一个简单的继承监听套接字
方案,并不具备处理子进程失败、已有连接的功能。
rcrowley/goagain
rcrowley/goagain
的实现比facebookarchive/grace
还要简单,采用继承监听套接字
方案,但是只能继承一个监听 socket,参考价值比较低。
jpillora/overseer
jpillora/overseer
采用主从进程设计,有父进程创建监听 socket ,然后fork-exec
派生出子进程,将全部监听 socket 继承给子进程,业务逻辑由子进程来运行。自带定时拉取新版本升级的功能,比较适合用来写App/Agent。由于框架设计的开发性不足,用户定制性差,比如动态增加端口等功能无法在该框架下实现。
cloudflare/tableflip
cloudflare/tableflip采用继承监听套接字
方案4,整体设计开放性足够,目前看起来是最好的一个实现。其提供在升级/重启过程中的父子进程之间同步功能,例如Ready()
、WaitForParent()
等。也能够灵活处理多个监听 socket和已存在的链接等。