2021年的7月13日,正在看小姐姐跳舞的fake_soul突然发现B站视频卡住了,急得他马上提上了裤子。
过了一会,每个群里传来不一样的瓜:“B站大楼爆炸了”、“上海云服务中心起火”、“小破站程序员因不满工作删库跑路”。。。
说实话很惭愧,B站的事故报告在今年7月就发布了,被cue了好几个月装死,到现在才去认真看,呜呜呜~~~
看了看网上很多师傅发出来的分析,“震惊!B站崩溃竟是因为诡计多端的'0'!”
诡计多端的0
我们直接步入正题,事故报告已经发出了关键代码:
local _gcd
_gcd = function(a,b)
if b == 0 then
return a
end
return _gcd(b,a % b)
end
- 这是一段嵌入在B站Nginx服务器的 lua 代码(一种轻量级脚本语言,有着更简单的 C API 所以很容易嵌入应用中,来实现可配置性、可扩展性)
结合事故报告,lua有几个特点:
-
Lua 是动态类型语言,常用习惯里变量不需要定义类型,只需要为变量赋值即可。
-
Lua在对一个数字字符串进行算术操作时,会尝试将这个数字字符串转成一个数字。
-
在Lua语言中,数学运算n % 0的结果是nan (Not A Number) 。
好了,学过一点点编程的家人应该能明白,这个地方的_gcd
函数,是在利用辗转相除来计算a
与b
的最大公约数
举个例子,我们对a
和b
赋值,令a = 12
b=8
,按照上面的逻辑,a 除以 b,余数是4,不为0,进入后面的递归处理, a=8
,b=4
,此时再进行相除,余数为0,说明4是a
和b
的最大公约数。
这样的话就满足了判定条件:
if b == 0 then
return a
end
就结束了运算
乍一看,很合理啊很合理🤔因为也不是什么很复杂运算~
但问题就出在这里,上面我们有提到:
Lua 是动态类型语言,常用习惯里变量不需要定义类型,只需要为变量赋值即可。
所以,如何打破这个运算,让其崩溃呢?
结合上面这句话,有聪明的同学已经猜到了:我们让b
为字符串"0",这样不就好了吗?
这里我们再看一下这个lua函数,解释下为什么:
local _gcd
_gcd = function(a,b)
if b == 0 then
return a
end
return _gcd(b,a % b)
end
- 当b为字符串的"0",由于
_gcd
函数没有对b
的类型进行校验,所以_gcd
就出现了对 "0" 与 0进行判断,但是此时,"0" != 0
(!=
:不等于),所以进入return _gcd(b,a % b)
。
哎,上面我们说了:在Lua语言中,数学运算n % 0的结果是nan (Not A Number) 。
此时,现在返回结果是_gcd("0",nan)
(b="0"
,a=nan
) _gcd("0",nan)
会再次执行,返回值是_gcd(nan,nan)
(b=nan
,a=nan
)- 此时依旧满足
nan != 0
,所以继续进入return _gcd(b,a % b)
,即_gcd(nan,nan)
,和上一步的流程是重复的,死循环不就出现了嘛
这样,整个流程是不是很清晰了,由于一直陷入死循环,Bilibili的Nginx服务器进程就跑满了,占到了100%,就处理不了其他进程了。。。
好了,那么现在的问题:如何让 b 为字符串格式的 "0" 呢
来自小破站的官方回复:
-
在某种发布模式中,应用的实例权重会短暂的调整为0,此时注册中心返回给SLB的权重是字符串类型的"0"。此发布模式只有生产环境会用到,同时使用的频率极低,在SLB前期灰度过程中未触发此问题。
-
SLB 在
balance_by_lua
阶段,会将共享内存中保存的服务IP、Port、Weight 作为参数传给lua-resty-balancer
模块用于选择upstream server
,在节点weight = "0"
时,balancer
模块中的_gcd
函数收到的入参 b 可能为"0"
。
看不懂是吧,没事,我也没看懂,嘿嘿 😛
时间线
事故发生
2021年7月13日22:52 SRE收到大量服务和域名的接入层不可用报警,客服侧开始收到大量用户反馈B站无法使用,同时内部同学也反馈B站无法打开,甚至APP首页也无法打开。基于报警内容,SRE第一时间怀疑机房、网络、四层LB、七层SLB等基础设施出现问题,紧急发起语音会议,拉各团队相关人员开始紧急处理。
紧急修复
5分钟后,运维同学发现承载全部在线业务的主机房七层SLB的 CPU占用率达到了100% ,无法处理用户请求,排除其他设施后,锁定故障为该层。
22:55 远程在家的相关同学登陆VPN后,无法登陆内网鉴权系统(B站内部系统有统一鉴权,需要先获取登录态后才可登陆其他内部系统,和校园内网一样),导致无法打开内部系统,无法及时查看监控、日志来定位问题。
23:07 远程在家的同学紧急联系负责VPN和内网鉴权系统的同学后,了解可通过绿色通道登录到内网系统。
这个地方我盲猜是有后门(doge)
此时已经过去了 25分钟 ,抢修正式开始。(莫名有点燃是怎么回事🤔)
-
首先,运维同学先热重启了一遍SLB,未恢复;然后尝试拒绝用户流量冷重启SLB,CPU依然100%,还是未恢复。
-
接着,运维发现多活机房SLB请求大量超时,但CPU未过载,正准备重启多活机房SLB时,内部群反应主站服务已恢复。
此时:23:23 距离事故发生已经过去31分钟,这个时候大家可能就遇到了视频是能看,但是部分功能依旧不可用,比如收藏、投币啥的。
官方事故报告在文章最后说到 高可用容灾架构 生效了,其实就是说的这个主站业务恢复。这个架构可以理解为是一种应急预案,就是为了防止这种突发情况发生而设计的。
提上裤子的fake_soul同学很气,问:为什么过去了半个多小时了,这个预警才发挥作用???我裤子不是白脱了吗???
这个其实不怨B站,由于点不开B站大家就开始疯狂上拉刷新,而此时B站的CDN流量回源重试 + 用户重试,直接让B站流量突增4倍以上,连接数突增100倍到了千万级别,多活SLB直接就给整过载了。。。别说架构发挥作用了,就算陈叔叔自己来了也得在这傻楞。。。
23:25 - 23:55 未恢复的业务暂无其他立即有效的止损预案,此时尝试恢复主机房的SLB。
-
我们通过Perf发现SLB CPU热点集中在Lua函数上,怀疑跟最近上线的Lua代码有关,开始尝试回滚最近上线的Lua代码。
-
近期SLB配合安全同学上线了自研Lua版本的WAF,怀疑CPU热点跟此有关,尝试去掉WAF后重启SLB,SLB未恢复。
-
SLB两周前优化了Nginx在balance_by_lua阶段的重试逻辑,避免请求重试时请求到上一次的不可用节点,此处有一个最多10次的循环逻辑,怀疑此处有性能热点,尝试回滚后重启SLB,未恢复。
-
SLB一周前上线灰度了对 HTTP2 协议的支持,尝试去掉 H2 协议相关的配置并重启SLB,未恢复。
新建源站
00:00 SLB运维尝试回滚相关配置依旧无法恢复SLB后,决定重建一组全新的SLB集群,让CDN把故障业务公网流量调度过来,通过流量隔离观察业务能否恢复。
00:20 SLB新集群初始化完成,开始配置四层LB和公网IP。
01:00 SLB新集群初始化和测试全部完成,CDN开始切量。SLB运维继续排查CPU 100%的问题,切量由业务SRE同学协助。
01:18 直播业务流量切换到SLB新集群,直播业务恢复正常。
01:40 主站、电商、漫画、支付等核心业务陆续切换到SLB新集群,业务恢复。
01:50 此时在线业务基本全部恢复
后话
谁能想到,B站运维就两个同学,痛!太痛了!但是这个排查速度真的挺快了,B站还是人才济济的。
后来由于本次事故实在是耽误了太久、太多事儿,因此B站给所有用户补了一天大会员。
有人就在此算了一笔账,这7行代码,让陈叔叔一下亏了大约157500000元。