从一次Proxy宕机引起的服务不可用说起

这段时间在工作中遇到一起因为客户发送非法协议触发的Proxy解协议的BUG,影响了大多数用户13分钟服务不可用。此类Bug大家在写C++程序时可能也经常会遇到,特别对于云服务场景,客户端种类多样,导致此类BUG很难预防,同时造成的危害巨大。实际上此版本的Proxy已经上线超过两周,此前测试和回归Case也已经跑了1个月。
结合此前3月也出现过一起因为某特殊逻辑引起连接未关闭,导致文件描述符猛涨到20w后,超过操作系统限制,导致Accept死循环的BUG,本文从代码、部署和Bug处理层面探讨如何能最大限度的避免该问题。

1. 代码安全

代码覆盖率

最直观和直接的办法是保证单元测试和功能测试覆盖所有代码。但通常这只是解决该办法的第一步,很多时候我们即便覆盖到了所有代码,仍然存在bug。特别对于没有虚拟机保护的C和C++程序。
这时需要将系统与外界出现交互部分的逻辑梳理出来,大家一起做CodeReview。列举出各个依赖所有可能出现异常,并一一核对代码的处理情况。
特别对于复杂的用户输入,如协议处理,采用从后往前,尽全力构造躲过系统判断的输入。

代码Review负责人

现在各个团队代码开发任务都很大,虽然大家提提Review,但是很多时候,大家只是一扫而过,而并没有仔细为他人去考虑各个可能出问题的逻辑。 为每个项目至少设置一个代码Review负责人,并要求其对代码承担一定责任。

其它

除开常用的内存检查工具valgrind,当前存在很多种代码扫描工具,如Clang,上线前对代码进行一次检查,是很有必要的。很多时候一个灾难性bug可能仅仅源于没有初始化某个变量。

2. 部署安全

线上环境部署后的检查

为了避免开发环境和线上环境造成的不统一,系统在部署时,需要提供检查检查脚本。在启动前对程序依赖的所有依赖库以及系统环境变量进行检查。如:
a) 系统参数
b) 三方依赖
c) 监控工具
d) 目录权限
在程序启动后,为了确保系统的正确性,需要运行功能测试,测试通过后,方可确保系统的运转正常。

自动重启

对系统添加监控,当服务出现宕机后立即重启。这个功能操作起来很简单,但是我们需要注意以下几个要点:

  1. 系统启动时是否创建了部分外界资源。
    需要检查当系统出现意外宕机后,创建的外部资源是否被合理释放。
    如是否创建了临时本地文件,这样在系统宕机后,是如何处理。删掉还是读取。读取到可能破坏后的系统行为。
  2. 自动重启的Log日志是否合理。
    重启器在重启之前需要合理的保留bug现场,这样才能方便之后追查问题时。

灰度上线

系统上线前虽然通常已经通过压力测试和稳定性测试,但测试环境通常只是理想情况,一个不错的国人写的工具TCP Copy能很好的模拟线上的真实负载。与传统的压力测试工具(如:abench)相比,tcpcopy的最大优势在于其实时及真实性,除了少量的丢包,完全拷贝线上流量到测试机器,真实的模拟线上流量的变化规律。详情可以看这里

服务分组

对于大多数线上系统,在上线前通常已经通过了大量的单元测试、功能测试和回归测试,系统发生bug的概率通常是很小的,往往是由于一些极端边缘的Case。这些Case在一段时间内通常只会在几台机器发生。为了避免有限几台服务器的错误,影响线上所有用户,我们需要对服务进行分组,使得用户只能访问到特定的分组中。当个别分组出现问题时,只会影响到有限几个用户。
服务的分组虽然加大了部署的复杂性,但相比可能的危害,通常是划算的。

我们的Proxy系统由于前端采用LVS VIP接入,每个VIP只能绑定相同的端口,由于当时VIP数目的限制,最终导致虽然分组功能一直有在计划中,并且已经编码测试完成,却迟迟未能上线。才最终导致大BUG。

3. Bug的修复

服务的热升级

在发现服务出现bug后,我们需要立即修复问题。但往往在很多时候bug只是在极端情况下出现,大部分服务在一段时间仍然可用。但为了修复问题我们需要进行服务的升级。特别是对于云服务涉及到与用户存在长连接时,如Proxy,WebServer,贸然升级造成的丢连接可能造成用户的投诉,为了避免该问题通常需要提前发出公告。热升级系统能很多的避免系统在升级时对用户的影响。通常解决该问题的方法主要是通过添加新的信号处理函数,关闭Accept描述符的读事件。在系统升级时,利用内核reuseport特性,启动新的Proxy在相同的端口。 接着向老的Proxy发送信号,使其关闭新连接监听端口。从而所有新的连接都将请求新的Proxy。
此方法易于实现,对系统的改造较少。但当客户大量使用长连接时,很长时间(几天,一周,甚至一月)老的系统都将一直在处理请求。对于小的功能点升级此方法较好。但如果系统存在BUG,需要及时修复时,将存在一定问题。

我们其实可以通过send_msg,通过linux域套接字在进程间传递文件描述符(连接)。
此方法在APUE里曾经有过描述,在项目中使用需要特别注意,待传递的描述符在程序中不能有缓冲区。这个要求产生了三个限制:
a) 协议
客户端与服务器协议需要有长度字段。这样服务器才能知道何时读完一个完整包,避免读取多余Buffer。
对于采用二进制协议的大部分系统这个是容易满足的,但对于采用文本协议,特别对于Redis协议,这个是难于满足的。因为无法判断此包何时结束。 具体做法只能在收到升级指令后,开始减少一次读Buffer的长度,如逐个字节读取,使得刚好读完一个完整包。
b) 连接如果有极大流量,不适宜迁移。
当连接如果有大量读写请求时,如果此时贸然传递描述符,将可能造成在传递描述符期间TCP Buffer已满,造成丢包问题。 如果客户端提供限速功能,可在此时通知客户端降低流量,从而迅速解决。如若不然也只能等其流量小。
c)如若采用网络框架,需要检查其内部是否包含缓存。
此方法实现成本较高,涉及到对系统解包和读包行为的修改,同时对于协议也有一定要求。 但其收益是,不再受到长连接困扰,在较短时间(小于10分钟)即可完成升级。

隔离危险用户

在出现BUG后,我们很多时候无法马上定位和修复,同时即便迅速修复后,代码往往也不敢保证升级后是否会引起新的BUG。这时我们可另寻他径,结合监控和日志尝试定位引起该BUG的几个可疑用户。对于云服务场景,由于大家共用物理机,我们宁可错杀几个也不能让问题进一步蔓延。

总结

上面的很多讨论可能停留于较为基础理论的层面,在实际工作中,还有很多细节需要把握。防范风险需要的是团队各个成员的意识,从写代码开始到上线每一步都将体现出来。文章仅仅是抛砖引玉,之后将讨论的更具体些,结合具体的BUG来进行讨论。