HAProxy Technologies的研发部门发布了一个补丁集,可以在不丢弃数据包的情况下实现HAProxy的无缝重新加载。该补丁集最早合并到HAProxy 1.8开发分支中,而后在每个版本持续支持。
这项工作是HAProxy Technologies更大的路线图计划的一部分,以支持更多的微服务用例,因为我们看到这些用例变得越来越频繁。这个特定的修复应该很好地解决大型环境,其中多个服务都共享相同的HAProxy服务器,或者微服务编排系统比传统的负载平衡设置更频繁地添加/删除服务,或者许多其他用例需要频繁的HAProxy重载。
让我带你了解问题的历史以及我们如何达成最终解决方案。
本章节来自谷歌翻译,如有雷同,纯属巧合。原链接
1.什么是无缝重装
用户经常称之为“无缝”或“无中断”的重新加载是在不影响用户体验的情况下执行的配置更新或服务升级。
配置更新可能需要某些服务的停止/启动序列,导致临时中断并使管理员在执行它们或尝试尽可能长时间推迟之前担心很多。使用今天的微服务和非常频繁的重新加载,在配置更新或服务升级期间丢失任何单个连接是不可接受的。
服务升级更为重要,因为虽然某些产品是从落地考虑设计的,无需重新加载即可支持配置更新,但如果没有停止/启动序列,则无法进行升级。因此,管理员会留下在生产中运行的那些软件组件的虚假版本,因为它永远不是正确的时间。
HAProxy不区分配置更新和服务升级。在每种情况下,新的可执行文件都与新配置一起使用,因此服务升级与配置更新一样透明。但是,随着时间的推移,情况已经发生变化,在haproxy 1.2.11实现了软重载机制。基于
应该注意的是,在步骤2和3之间存在一个小的“black hole”,其中端口不受任何过程的约束。在2006年,我们在现场看到的负载大约是每秒几百个连接的数量级,并且在重新加载期间(短于一毫秒)正确着陆的任何连接的风险足够低,可以忽略。
同年,OpenBSD系统支持SO_REUSEPORT,这是一个套接字选项,允许将新套接字绑定到与前一个套接字相同的端口。好处是,在OpenBSD上它允许我们跳过第2步和第3步,并且没有“black hole”。
这是在同年的HAProxy 1.2.14版本中实现的,并且曾经是HAProxy中第一个真正无缝的重新加载。
然而在旧版本linux内核也支持过SO_REUSEPORT,但是从2.4开发期间就丢弃了,然而这将被重新打补丁使用。但是主线的linux内核并不接受。因此,这个补丁被保存,直到6.0版本(2.6.32)
与此同时,一个新的,更好的SO_REUSEPORT实现被带到Linux内核3.9,允许负载智能地分布在多个套接字上。HAProxy可以立即受益于这一新的改进。
但它带来了一个问题,部分源于其可扩展性。套接字队列是独立的,人们逐渐开始报告在HAProxy重新加载期间在负载下观察到偶尔的RST。
haproxy团队在实验室中以非常高的负载重现了这一点。重新加载期间发出的RST速率约为每次重新加载2-3个连接。起初它看起来很低,但微服务和云环境彻底改变了观点。有些服务每隔几秒就会重新加载一次。在Linux 4.9下以每秒55,000个连接运行并且每秒重新加载10次的haproxy服务,在180次重新加载之后,可能会在100万个连接中达到155次连接失败:
2.尝试解决
- 阻止SYN
大约在2010年,当用户开始报告此问题时,重新加载操作期间阻止SYN数据包(这应该非常快)。它最终阻止了在重新加载期间建立新连接,导致一些客户端重新传输它们丢弃的SYN数据包并使它们无法经历重置。但是,尽管在某些低延迟环境中,在重新加载期间偶尔会导致额外的200ms-3s延迟是不可接受的
- 套接字服务器
在2011年初,在发布HAProxy版本1.5-dev4之后,Simon Hormans尝试通过使用套接字服务器实现master-worker模型以完全不同的方式解决该问题
想法是,不是让HAProxy重新加载,而是一个进程保持活动状态,它会处理所有套接字并在重新加载时将它们传递给每个新进程。遗憾的是,这项工作在当时公布了HAProxy的许多内部限制,如果不对HAProxy的信号处理,内存管理和流程管理进行大规模的重新设计,就没有办法让它可靠地工作。因此,这个补丁集从未合并过,但它确实很有趣,这些重点已经转移到改进HAProxy内部,以便在以后使这种架构成为可能
- 延迟SYN
在2015年,Yelp的Joe Lynch仍然不满意“阻塞SYN”方法造成的额外延迟,他发现了一种很好的方法来显着改善它。他没有丢弃SYN数据包,而是使用Linux排队规则(qdisc)来延迟它们。删除qdisc后,排队的数据包会自动释放,并且只有在HAProxy重新加载新配置文件时才会延迟额外的延迟。该脚本最终变得有点复杂,但是一旦编写完就没有必要再触摸它了,大多数用户认为这种方法是最佳的
- 更改套接字的分数
在Joe Lynch和他的同事Josh Snyder的帮助下,我们考虑了其他各种选项,例如在关机期间删除SO_REUSEPORT选项并使这些套接字得分更低,将套接字积压设置为零,在关闭之前关闭插座并具有drain mode和其他类似机制。恰巧在同一时间,Tolga Ceylan提出了类似的设计来解决影响Nginx的同样问题。
在此期间,建议尝试eBPF
- 使用eBPF将SYN转移到某些队列
试图实施eBPF导致失败。简而言之,原则是为传入的数据包分配一个不同的队列,这样它们就不会落入退出进程的队列中。这种方法的一个问题是无法知道剩余进程的队列号,如果重新加载比旧进程的平均生命周期更频繁,那么这些数字也可能在旧进程的生命周期内发生变化(相当微服务中的常见问题)。所以我们基本上能做的就是接受或拒绝流量
- 套接字服务器
在2016年,GitHub(也使用HAProxy)在master-worker模型中提出了与套接字服务器非常相似的东西,但它的侵入性要小得多,它使用HAProxy将已绑定套接字重用为侦听套接字的能力。他们的服务器被称为“multibinder”,在这里描述:https://githubengineering.com/glb-part-2-haproxy-zero-downtime-zero-delay-reloads-with-multibinder/
- 其他
讨论了各种其他想法,例如将侦听套接字的绑定更改为错误的接口,或者如果已经绑定它以取消绑定以降低其在compute_score()函数中的分数(它确定将传入连接发送到哪个队列) 。所有这些想法都带来了不可接受的缺点(例如暂时将敏感套接字暴露给不安全的接口)并且没有实现预期的缺陷完善
3.制定长期解决方案
在尝试基于eBPF实现某些功能失败之后,在短期内无法在内核中获得一个很好的解决方案。此外,每天受这个问题影响的所有用户(以及运行企业Linux发行版的用户)都很少有机会将必要的内核更新反向移植到他们的生产系统。所以我认为唯一剩下的可接受的短期解决方案是回到“套接字服务器”方法(旧进程和新进程之间的文件描述符传输),以便永远不会关闭套接字。但是,我们不是依赖于复杂的主工作者模型,而是尝试使用HAProxy CLI套接字,该套接字通常是UNIX套接字,并且最重要的是可以使用SCM_RIGHTS传输文件描述符。SCM_RIGHTS是UNIX套接字的一个鲜为人知的功能,它允许一个进程将一个或多个文件描述符传输到另一个进程。另一个进程以它们所处的相同状态(通常使用不同的FD编号)接收它们,就好像它们是使用dup()复制一样
haproxy团队Olivier Houchard讨论了这个问题,他对此非常感兴趣,他立即开始研究HAProxy中需要改变的内容以支持它。与此同时,开始进行彻底的分析
4. 分析问题
在2017年之前,在7年多的时间里HAProxy Technologies没有人想出一个很好的解决方案来解决这个问题。当旧版本的haproxy运行在双核系统的笔记本上,每秒重新加载haproxy10次,在压力测试中,每40000个链接大概有一个错误。每秒reload 10次,每秒运行80,000个连接!将负载生成器连接到与haproxy相同的CPU,超过一个小时和1.5亿个连接没有任何单个错误。
在网络上运行了相同的测试,发现当HAProxy绑定到与网络中断相同的CPU时没有一个错误,并且只有在HAProxy进程位于不同的CPU上时才会发生错误。
这解释了为什么这些年来情况发生了变化。多年来,由于HAProxy的资源使用非常有限,我们大多数人都在廉价的单CPU机器上运行,而且默认情况下不会出现问题。然后,当haproxy开始部署在SMP机器上时,问题再一次避开了我们 - 许多这些SMP系统配备了单队列NIC,默认情况下内核的调度程序倾向于自动将接收进程移动到与收到中断。这是我们通常手动禁用以获得更高性能的东西,但由于绝大多数用户没有更改默认设置,因此它们仍未受影响。
实际原因是由于一个简单的错误,使用参数“-F”和“-c”启动了我的负载生成器。第一个与HAProxy的“tcp-smart-connect”选项完全相同 - 它将HTTP请求与连接的ACK融合以保存一个数据包。第二个用RST关闭以保存另一个数据包。在此测试期间,单个进程每秒150k连接没有出现单个错误。我认为它与“-c”有关,但没有。摆脱问题的是“-F”
使用和不使用“-F”的测试之间的区别在于,通过删除初始ACK,当连接被接受时,请求立即可用。开始认为这是HAProxy中的调度问题,可能没有处理一些已接受的连接。我们认为我们可以通过在绑定行上添加“defer-accept”来复制相同的行为。但“延迟接受”仍然遇到了问题:在整个过程中运行strace告诉我们没有任何单一的区别,事实上,在最后一个accept()返回“-1 EAGAIN”之后,很明显有一些连接仍处于未决状态。
这与上面观察到的CPU绑定相结合,看起来内核中的accept()代码中某处缺少SMP内存屏障。内存屏障是一种同步机制,用于确保修改后的数据对其他CPU可见:这些内容包括刷新挂起缓存写入。没有它,其他CPU只能看到它们在缓存中的内容,并且不一定知道在内存区域中执行的最近更改。在我们的当前情况下可能出现的是,当新连接被接受时,指针正在被提升,但是其他CPU只偶尔看到了这个指针的快照,如下图所示:
因此,当数据到达时,很可能没有应用无数据ACK的内存屏障,导致其他CPU无法看到整个传入连接列表。它导致其中一些留在队列中,当侦听套接字关闭时,它被重置。似乎有可能没有办法比这更精确,同时保持高性能水平(我不确定)。但至少我确认阻止空的ACK摆脱了这个问题
5.临时解决办法
- 在轻负载下运行的那些可以简单地确保它们的HAProxy进程始终绑定到接收网络中断的同一CPU。如果所有中断都路由到同一个CPU,则仅对单队列NIC或多队列NIC可行。在每秒处理数万个连接或需要多队列保持启用的数千兆位负载时,请勿执行此操作。将NIC和haproxy绑定到CPU 0的示例:
$ grep eth0- /proc/interrupts
28: 1259842789 181 PCI-MSI-edge eth0-rx-0
29: 1276436141 772 PCI-MSI-edge eth0-tx-0
$ for i in $(grep eth0- /proc/interrupts |cut -f1 -d:); do
echo 1 > /proc/irq/$i/smp_affinity
done
- 那些在较高负载下运行的,或那些不能应用上述内容的人,或那些使用nbproc并且无法将haproxy的套接字绑定到带来传入流量的相同CPU的那些,但是在重载操作期间CAN可以阻止传入的空ACK。阻止空ACK是可行的,因为它们不会引起任何额外的延迟(与SYN相反)。ACK用于确认连接(并且紧接着是其他数据),它们用于确认接收到的段,以便窗口可以前进(但只要发送窗口未满,就会继续传输段) 。它们最后用于关闭LAST_ACK连接,也可以安全地延迟。
上面展示的iptables规则不能在生产中原样使用,因为它依赖于TCP时间戳并且会在没有它们的情况下阻止其他数据包。下面介绍的以下内容适用于生产。它阻止所有纯ACK数据包以及携带少于3个字节有效负载而没有PUSH标志的数据包。这里不可能匹配单个字节,因为U32仅引用32位有效负载,但同时,没有PUSH标志的3字节数据包没有意义:
$ sudo iptables -I INPUT -i lo -p tcp --dport 80 --tcp-flags SYN,PSH,ACK,FIN,RST ACK -m u32 ! --u32 '0>>22&0x3C@12>>26&0x3C@0&0x0=0:0' -j DROP
$ sudo iptables -I INPUT -i lo -p tcp --dport 443 --tcp-flags SYN,PSH,ACK,FIN,RST ACK -m u32 ! --u32 '0>>22&0x3C@12>>26&0x3C@0&0x0=0:0' -j DROP
6.适当的长期解决方案
就提出适当的长期解决方案而言,第一个想法是看看是否可以调整内核以避免问题。但这可能会导致不良的副作用,而且看起来很棘手,因为问题可能会在以后重新出现。
一个更安全,更恰当的长期解决方案被证明是我们在步骤#4中已经讨论过的 - 将监听文件描述符传递给新进程,以便永远不会关闭连接。
运的是,Olivier已经得到了相当好的代码。实现仅限于最多传递253个侦听器,但这是SCM_RIGHTS API的限制以及我们此时不想轮询的事实,并且甚至足以支持多进程重新加载。
这个补丁集合并到HAProxy 1.8开发分支中,计划于2017年11月稳定发布。
与此同时,我们正在验证剩余的极端情况,并且已经在考虑将此补丁集集成到HAProxy Enterprise 1.7r1中,甚至可能是1.6r2。Olivier用他的代码尽可能不引人注意,以便轻松地向后移植。
7.证明解决方案
我们可以运行一个脚本来测试结果,从重新启动到重新加载,再到在HAProxy实例之间传输套接字的重新加载:
- 重新启动HAProxy
while true
do
sleep 3
./haproxy -f /etc/hapee-1.7/hapee-lb.cfg -p /run/hapee-1.7-lb.pid -st $(cat /run/hapee-1.7-lb.pid)
done
- restart results
$ ab -r -c 10 -n2000 https://127.0.0.1/
Concurrency Level: 10
Time taken for tests: 3.244 seconds
Complete requests: 2000
Failed requests: 11
(Connect: 0, Receive: 0, Length: 9, Exceptions: 2)
- reload haproxy
while true
do
sleep 3
./haproxy -f /etc/hapee-1.7/hapee-lb.cfg -p /run/hapee-1.7-lb.pid -sf $(cat /run/hapee-1.7-lb.pid)
done
- Reload results
$ ab -r -c 10 -n2000 https://127.0.0.1/
Concurrency Level: 10
Time taken for tests: 3.510 seconds
Complete requests: 2000
Failed requests: 1
(Connect: 0, Receive: 0, Length: 1, Exceptions: 0)
通过在HAProxy实例之间传输套接字来重新加载HAProxy
注意:HAProxy配置中的套接字定义需要在其上具有“expose-fd侦听器”,以便-x接受套接字传输
while true
do
sleep 3
./haproxy -f /etc/hapee-1.7/hapee-lb.cfg -p /run/hapee-1.7-lb.pid -x /var/run/hapee-lb.sock -sf $(cat /run/hapee-1.7-lb.pid)
done
- 重新加载+套接字传输结果
$ ab -r -c 10 -n2000 https://127.0.0.1/
Concurrency Level: 10
Time taken for tests: 2.084 seconds
Complete requests: 2000
Failed requests: 0
延伸阅读
linuxea:haproxy 1.9中的多线程linuxea:haproxy1.9 了解四个基础部分linuxea:haproxy1.9日志简介linuxea: 使用HAproxy 1.9 Runtime API进行动态配置