诡计多端的’0′ | B站事故报告

B站事故报告:2021.07.13 我们是这样崩的

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函数,是在利用辗转相除来计算ab的最大公约数

举个例子,我们对ab赋值,令a = 12 b=8,按照上面的逻辑,a 除以 b,余数是4,不为0,进入后面的递归处理, a=8b=4,此时再进行相除,余数为0,说明4是ab的最大公约数。

这样的话就满足了判定条件:

    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
  1. 当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
  2. _gcd("0",nan)会再次执行,返回值是 _gcd(nan,nan)b=nana=nan
  3. 此时依旧满足nan != 0,所以继续进入return _gcd(b,a % b),即 _gcd(nan,nan),和上一步的流程是重复的,死循环不就出现了嘛

这样,整个流程是不是很清晰了,由于一直陷入死循环,Bilibili的Nginx服务器进程就跑满了,占到了100%,就处理不了其他进程了。。。

image

好了,那么现在的问题:如何让 b 为字符串格式的 "0" 呢

来自小破站的官方回复:

  • 在某种发布模式中,应用的实例权重会短暂的调整为0,此时注册中心返回给SLB的权重是字符串类型的"0"。此发布模式只有生产环境会用到,同时使用的频率极低,在SLB前期灰度过程中未触发此问题。

  • SLB 在balance_by_lua阶段,会将共享内存中保存的服务IP、Port、Weight 作为参数传给lua-resty-balancer模块用于选择upstream server,在节点 weight = "0" 时,balancer 模块中的 _gcd 函数收到的入参 b 可能为 "0"

看不懂是吧,没事,我也没看懂,嘿嘿 😛

image

时间线

事故发生

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分钟 ,抢修正式开始。(莫名有点燃是怎么回事🤔)

  1. 首先,运维同学先热重启了一遍SLB,未恢复;然后尝试拒绝用户流量冷重启SLB,CPU依然100%,还是未恢复。

  2. 接着,运维发现多活机房SLB请求大量超时,但CPU未过载,正准备重启多活机房SLB时,内部群反应主站服务已恢复。

此时:23:23 距离事故发生已经过去31分钟,这个时候大家可能就遇到了视频是能看,但是部分功能依旧不可用,比如收藏、投币啥的。

image

官方事故报告在文章最后说到 高可用容灾架构 生效了,其实就是说的这个主站业务恢复。这个架构可以理解为是一种应急预案,就是为了防止这种突发情况发生而设计的。

提上裤子的fake_soul同学很气,问:为什么过去了半个多小时了,这个预警才发挥作用???我裤子不是白脱了吗???

这个其实不怨B站,由于点不开B站大家就开始疯狂上拉刷新,而此时B站的CDN流量回源重试 + 用户重试,直接让B站流量突增4倍以上,连接数突增100倍到了千万级别,多活SLB直接就给整过载了。。。别说架构发挥作用了,就算陈叔叔自己来了也得在这傻楞。。。

image

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元。

image