网关热重启
潘忠显 / 2020-08-31
热启动背景
两种方式实现不中断流量的服务:
- 滚动部署
- 热重启
滚动部署:启动新服务节点,流量被耗尽,并从旧节点转移到新节点 热重启:机器间没有流量转移,通过某些方式处理进程。
两种方式的优劣比较
滚动部署
- 按照节点更新
- 支持按百分比版本更新
- 金丝雀测试
- 蓝绿部署
- 需要冗余的机器
热重启
- 节点状态变化
- 不需要服务编排的环境
- 不需要多准备机器
- 运维简单、开发复杂
- 缺少灵活性
应该努力争取滚动部署, 但长时间内仍需要使用热重启。
其他
业务层应该不关注如何热启动,只需要选择策略。 统一框架处理连接迁移。
已建立的连接不会进行迁移。
热重启的目标
不可变状态的原则
热重启的过程中会有两个进程并行运行一段时间。
不支持仅重新加载本地配置
执行完整的二进制重新加载是配置重新加载的超集
重启过程中统计信息应当保持一致,将两个进程的统计合并成一个统计源。
Envoy热重启的直观认识
我们实际的操作envoy的热更新,并检查进程相关状态:
对上述演示过程的描述:
- start_envoy.sh是准备的启动脚本,其中要使用
$RESTART_EPOCH
变量来指定是第几次热启动 - 使用envoy自带的hot-restart.py脚本拉起进程envoy子进程,
restart-epoch
为0
- 查看envoy的端口,启动的9002位普通监听端口,10001为管理端口
- 使用
telnet
创建一个到9002的连接,并通过netstat
观察 - 通过
kill
给控制进程发一个SIGHUP
的信号 - 看到父进程拉起另外一个子进程,
restart-epoch
为1
- 查看envoy的端口,两个监听端口已经转移到了新的进程2894上,已经建立的连接仍然在老的进程上
- 10分钟后观察两个envoy进程仍然存在
- 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的子进程,两个进程通过 --restart-epoch <integer>
参数关联起来,新进程的 epoch
是老的 epoch + 1
。
两个进程间通过UDS(UNIX Domain Socket),使用地址 envoy_domain_socket_parent_{n}
与 envoy_domain_socket_child_{n+1}
组成一个双工的通道,完成新老进程之间进行通信。
两进程之间的交互过程:
更多细节
UDS
APUE 第17章高级进程间通信,介绍了UNIX Domain Socke(UDS)t的使用方法,17.4节传送文件描述符描述了通过UDS传输fd的过程。 Envoy中也是通过这一方式来实现的监听socket的迁移。
第15章中介绍了两种管道
- 匿名管道(pipe)
- 命名管道(FIFO)
匿名管道有限制只能在关联的进程间使用(有共同的祖先),而FIFO则没有这个限制。
类似的,UDS也有两种
- 使用
socketpair
创建的fd-pipe - 使用
bind
绑定到sockaddr_un地址上的命名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,可以接收别的进程发来的消息。(对吗?)
sendmsg
和 recvmsg
允许通过UDS发送特殊消息,比如文件描述符。
File Descriptor 与 File Description
File Descriptor 每个进程中指向 File Description(一个基础内核数据结构)。
内核维护一张所有打开文件描述的“打开文件表”。
如果两个进程A和B尝试打开同一个文件,两个进程都有自己的File Descriptor,他们都指向打开文件表中的同一个File Description。
所以发送一个fd实际上意味着发送一个File Description的引用。
即使发送进程通过sendmsg发送后、接收进程还没有调用recvmsg收到fd的过程中,fd被发送进程关闭了,File Description仍然是打开的。 因为,调用一次sendmsg会增加一次File Description的引用计数,只有当引用计数掉到0时,才会关闭打开的文件。
UDS传送fd
sendmsg
、 recvmsg
使用 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_;
HotRestartingChild
和 HotRestartingParent
都继承自 HotRestartingBase
。
Envoy的fd传送实现在 Envoy::Server::HotRestartingBase::sendHotRestartMessage
函数中。
SO_REUSEPORT
Envoy热启动的介绍中有提到SO_REUSEPORT
选项,这个选项是从linux 3.9之后版本添加的,可以让多个进程监听同一个地址的同一个端口。
使用Nginx文档中的图说明这个一选项的用途。
这选项出现之前,一台机器的一个地址和端口智能有一个监听socket。
当使用SO_REUSEPORT
选项之后,可以使用多个socket去监听相同地址、端口的组合。
之前的SO_REUSEADDR
是什么含义?
Stackoverflow上有一个回答SO_REUSEADDR
和SO_REUSEPORT
的区别是什么很详细。
简单的解释SO_REUSEADDR
有两点作用
- 改变了通配绑定时处理源地址冲突的处理方式
在绑定套接字之前在套接字上启用了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)
- 如何对待
TIME_WAIT
状态
如果未设置SO_REUSEADDR
,则状态为TIME_WAIT的套接字仍被视为已绑定到源地址和端口,并且任何将新套接字绑定到相同地址和端口的尝试都将失败,直到该套接字真正关闭为止。
Envoy的优雅退出
如前边介绍的restart时,parent优雅退出会用用到两个参数
--drain-time-s
--drain-strategy
另外一种情况也会有同样的动作发生: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有几个步骤:
- fork
- clone
- modify
- commit
- push
- pull reqeust
- check
- merge
- [delete fork]
项目特殊规范
一般开源项目的根目录下,会有CONTRIBUTING.md的文件介绍如何为项目做贡献。 比如,在envoy的CONTRIBUTING.md中, 有这样一些要求说明:
- 沟通
- 代码、API、文档规范
- 如何提交PR(pre-commit, pre-push等)
- DCO(有些项目需要签CLA)