Jason Pan

网关热重启

黄杰 / 2020-08-31


热启动背景

两种方式实现不中断流量的服务:

rolling and hot restart deploy methods

滚动部署:启动新服务节点,流量被耗尽,并从旧节点转移到新节点 热重启:机器间没有流量转移,通过某些方式处理进程。

两种方式的优劣比较

滚动部署

热重启

应该努力争取滚动部署, 但长时间内仍需要使用热重启。

其他

业务层应该不关注如何热启动,只需要选择策略。 统一框架处理连接迁移。

已建立的连接不会进行迁移。

热重启的目标

不可变状态的原则

热重启的过程中会有两个进程并行运行一段时间。

不支持仅重新加载本地配置

执行完整的二进制重新加载是配置重新加载的超集

重启过程中统计信息应当保持一致,将两个进程的统计合并成一个统计源。

Envoy热重启的直观认识

我们实际的操作envoy的热更新,并检查进程相关状态:

Envoy的重启过程

对上述演示过程的描述:

  1. start_envoy.sh是准备的启动脚本,其中要使用 $RESTART_EPOCH 变量来指定是第几次热启动
  2. 使用envoy自带的hot-restart.py脚本拉起进程envoy子进程, restart-epoch0
  3. 查看envoy的端口,启动的9002位普通监听端口,10001为管理端口
  4. 使用 telnet 创建一个到9002的连接,并通过 netstat 观察
  5. 通过 kill 给控制进程发一个 SIGHUP 的信号
  6. 看到父进程拉起另外一个子进程, restart-epoch1
  7. 查看envoy的端口,两个监听端口已经转移到了新的进程2894上,已经建立的连接仍然在老的进程上
  8. 10分钟后观察两个envoy进程仍然存在
  9. 15分钟后观察,只剩下一个envoy进程

10分钟关掉所有连接受 --drain-time-s <integer> 参数控制

(optional) The time in seconds that Envoy will drain connections during a hot restart or when individual listeners are being modified or removed via LDS. Defaults to 600 seconds (10 minutes).

15分钟关闭受 --parent-shutdown-time-s <integer> 参数控制

(optional) The time in seconds that Envoy will wait before shutting down the parent process during a hot restart. See the hot restart overview for more information. Defaults to 900 seconds (15 minutes).

Envoy热重启的实现

Matt在《Envoy hot restart》一文中给出了如下的架构图:

Envoy热重启架构

通过直接触发envoy的热启动,也可以直观的了解envoy热启动的过程。

前后拉起两个envoy的子进程,两个进程通过 --restart-epoch <integer> 参数关联起来,新进程的 epoch 是老的 epoch + 1

两个进程间通过UDS(UNIX Domain Socket),使用地址 envoy_domain_socket_parent_{n}envoy_domain_socket_child_{n+1} 组成一个双工的通道,完成新老进程之间进行通信。

两进程之间的交互过程:

Envoy热重启架构

更多细节

UDS

APUE 第17章高级进程间通信,介绍了UNIX Domain Socke(UDS)t的使用方法,17.4节传送文件描述符描述了通过UDS传输fd的过程。 Envoy中也是通过这一方式来实现的监听socket的迁移。

第15章中介绍了两种管道

匿名管道有限制只能在关联的进程间使用(有共同的祖先),而FIFO则没有这个限制。

类似的,UDS也有两种

我们使用 netstat 看到的envoy的套接字就是第二种, @ 符号后的就是路径名。

> netstat -lanp | grep "bazel-bin"
unix  2   [ ]    DGRAM     627937988 2894/bazel-bin/sour  @envoy_domain_socket_parent_1
unix  2   [ ]    DGRAM     627937793 2699/bazel-bin/sour  @envoy_domain_socket_parent_0
unix  2   [ ]    DGRAM     627937987 2894/bazel-bin/sour  @envoy_domain_socket_child_1
unix  2   [ ]    DGRAM     627937792 2699/bazel-bin/sour  @envoy_domain_socket_child_0

两个进程各绑定一个UDS,可以接收别的进程发来的消息。(对吗?)

sendmsgrecvmsg 允许通过UDS发送特殊消息,比如文件描述符。

File Descriptor 与 File Description

File Descriptor 每个进程中指向 File Description(一个基础内核数据结构)。

内核维护一张所有打开文件描述的“打开文件表”。

file descriptor and file description

如果两个进程A和B尝试打开同一个文件,两个进程都有自己的File Descriptor,他们都指向打开文件表中的同一个File Description。

所以发送一个fd实际上意味着发送一个File Description的引用。

即使发送进程通过sendmsg发送后、接收进程还没有调用recvmsg收到fd的过程中,fd被发送进程关闭了,File Description仍然是打开的。 因为,调用一次sendmsg会增加一次File Description的引用计数,只有当引用计数掉到0时,才会关闭打开的文件。

UDS传送fd

sendmsgrecvmsg 使用 msghdr 结构传输消息。

/* Structure describing messages sent by
`sendmsg' and received by ` recvmsg'.  */
struct msghdr
  {

    void *msg_name;             /* Address to send to/receive from.  */
    socklen_t msg_namelen;      /* Length of address data.  */

    struct iovec *msg_iov;      /* Vector of data to send/receive into.  */
    size_t msg_iovlen;          /* Number of elements in the vector.  */

    void *msg_control;          /* Ancillary data (eg BSD filedesc passing). */
    size_t msg_controllen;      /* Ancillary data buffer length.
                                   !! The type should be socklen_t but the
                                   definition of the kernel is incompatible
                                   with this.  */

    int msg_flags;              /* Flags on received message.  */

  };

APUE中 sendfd 函数中构造 msghdr 的部分代码:

static struct cmsghdr *cmptr = NULL; /* malloc'ed first time */
struct iovec iov[1];
struct msghdr msg;
char buf[2]; /* send_fd()/recv_fd() 2-byte protocol */
      
iov[0].iov_base = buf;        
iov[0].iov_len = 2;
msg.msg_iov = iov;
msg.msg_iovlen = 1;
msg.msg_name = NULL;  
msg.msg_namelen = 0;  
if (cmptr == NULL && (cmptr = malloc(CONTROLLEN)) == NULL)
  return (-1);
cmptr->cmsg_level = SOL_SOCKET;
cmptr->cmsg_type = SCM_RIGHTS;
cmptr->cmsg_len = CONTROLLEN;  
msg.msg_control = cmptr;
msg.msg_controllen = CONTROLLEN;
*(int *)CMSG_DATA(cmptr) = fd_to_send; /* the fd to pass */

Envoy Restarter的代码实现

Envoy启动的时候,会创建一个 Envoy::Server 的对象 server_ ,而每个 Envoy::Server 对象都有成员变量 restarter_

  HotRestart& restarter_; 

HotRestart 的具体实现类是 HotRestartImpl ,其维护着两个成员变量

  HotRestartingChild as_child_;
  HotRestartingParent as_parent_;

envoy objects in code

HotRestartingChildHotRestartingParent 都继承自 HotRestartingBase

Envoy的fd传送实现在 Envoy::Server::HotRestartingBase::sendHotRestartMessage 函数中。

SO_REUSEPORT

Envoy热启动的介绍中有提到SO_REUSEPORT选项,这个选项是从linux 3.9之后版本添加的,可以让多个进程监听同一个地址的同一个端口。

使用Nginx文档中的图说明这个一选项的用途。

这选项出现之前,一台机器的一个地址和端口智能有一个监听socket。

当使用SO_REUSEPORT选项之后,可以使用多个socket去监听相同地址、端口的组合。

之前的SO_REUSEADDR是什么含义?

Stackoverflow上有一个回答SO_REUSEADDRSO_REUSEPORT的区别是什么很详细。

简单的解释SO_REUSEADDR有两点作用

  1. 改变了通配绑定时处理源地址冲突的处理方式

在绑定套接字之前在套接字上启用了SO_REUSEADDR,除非与另一个绑定到源地址和端口的完全相同的套接字发生冲突,否则该套接字可以成功绑定。

SO_REUSEADDR       socketA        socketB       Result
---------------------------------------------------------------------
  ON/OFF       192.168.0.1:21   192.168.0.1:21    Error (EADDRINUSE)
  ON/OFF       192.168.0.1:21      10.0.0.1:21    OK
  ON/OFF          10.0.0.1:21   192.168.0.1:21    OK
   OFF             0.0.0.0:21   192.168.1.0:21    Error (EADDRINUSE)
   OFF         192.168.1.0:21       0.0.0.0:21    Error (EADDRINUSE)
   ON              0.0.0.0:21   192.168.1.0:21    OK
   ON          192.168.1.0:21       0.0.0.0:21    OK
  ON/OFF           0.0.0.0:21       0.0.0.0:21    Error (EADDRINUSE)
  1. 如何对待TIME_WAIT状态

如果未设置SO_REUSEADDR,则状态为TIME_WAIT的套接字仍被视为已绑定到源地址和端口,并且任何将新套接字绑定到相同地址和端口的尝试都将失败,直到该套接字真正关闭为止。

Envoy的优雅退出

如前边介绍的restart时,parent优雅退出会用用到两个参数

另外一种情况也会有同样的动作发生:LDS的结果发生变化话时,listener会被修改或移除。

ConnectionManagerImpl::ActiveStream::encodeHeaders函数中会,调用一个决策函数Network::DrainDecision::drainClose,来判断一个连接是否需要移除。

drainClose()函数对于设置drain-strategy'gradual'的逻辑部分如下:

  ASSERT(server_.options().drainStrategy() == Server::DrainStrategy::Gradual);

  // P(return true) = elapsed time / drain timeout
  // If the drain deadline is exceeded, skip the probability calculation.
  const MonotonicTime current_time = server_.dispatcher().timeSource().monotonicTime();
  if (current_time >= drain_deadline_) {
    return true;
  }

  const auto remaining_time =
      std::chrono::duration_cast<std::chrono::seconds>(drain_deadline_ - current_time);
  ASSERT(server_.options().drainTime() >= remaining_time);
  const auto elapsed_time = server_.options().drainTime() - remaining_time;
  return static_cast<uint64_t>(elapsed_time.count()) >
         (server_.random().random() % server_.options().drainTime().count());

如注释所描述,当流逝的时间越多,释放掉连接的概率就越大。

贡献开源很简单

在阅读envoy hot restart相关的代码的时候,发现有错别字,就顺便修改一下做个贡献。

通用步骤

一般在github上,contribute有几个步骤:

项目特殊规范

一般开源项目的根目录下,会有CONTRIBUTING.md的文件介绍如何为项目做贡献。 比如,在envoy的CONTRIBUTING.md中, 有这样一些要求说明:

contribute to envoy

参考文章

  1. Envoy hot restart
  2. File Descriptor Transfer over Unix Domain Sockets
  3. 蓝绿部署、红黑部署、AB测试、灰度发布、金丝雀发布、滚动发布的概念与区别
  4. We’re switching to a DCO for source code contributions