« 有创意必须实现出来才有意义 | 返回首页 | 代理服务和过载保护 »

skynet 服务的沙盒保护

昨天我们新的 MMO 游戏第一次上线小规模测试,暴露了一些问题。

服务器在开服 3 小时后,突然内存暴涨,CPU 占用率提升不多。当时 SA 已经收到报警邮件,但刚巧在午餐时间,而游戏功能还正常,耽误了半个小时。处理不够及时,导致在最终没有能收集到足够在线数据前,服务器已不能正常操作。另外,忘记配置 core dump 文件输出是另一个原因。

在最后几分钟,我们收集到一些信息:某个 lua 服务陷入 C 代码中的死循环,在 skynet 控制台发 signal 无法中断它(skynet 的 signal 可以中断 lua vm 的运行 )。从 log 分析,内存暴涨是突发的,几乎在一瞬间吃光了所有内存,而并非累积。

第一次宕机后,迅速重启了服务器。同时在内网又同步运行了机器人压力测试,但是无论是外网环境和内网环境均无法重现故障。

从这次事件中,我发现了 skynet 收集运行时事故关键信息的不足,赶紧补充了几个工具脚本。

比如,skynet 控制台的观测每个服务状态和内存使用情况的控制指令,是通过循环向每个服务发送请求,由各个服务分别汇报然后汇总输出的。一旦某个服务死循环,就会阻塞这个过程,导致没有汇总报告输出。

解决这个问题倒是不复杂,只需要分别收集信息,设置超时时间,然后就可以得到部分报告,并感知到超时服务。现在,则需要从 log 中查询死循环报告。没有事先准备好这个脚本,导致突发事件中没能及时处理。


下午,问题重现。这次发现及时,服务器还可以正常操作。我们迅速的查到了死循环的服务,并用 gdb 直接 attach 到进程调试。发现工作线程卡死在该服务的 lua gc 流程中,在遍历一个极其巨大的 table 。

lua 的 GC 虽然是步进式的,但对于遍历单个 table 这个操作是原子的。当 table 大小达到上亿个 slot 时,这个步骤就变得极其漫长。因为它已经超过了物理内存的范围,使用了交换分区。实际上是在外存中遍历。

可以确定,这个服务的 vm 就是罪魁祸首,该 table 吃掉了 90% 以上的内存。从调用栈我们可以看到这个服务在处理一条仅为 15 字节的外部消息。(在 log 中也显示有这条导致死循环的消息的地址和长度,未来考虑直接在 log 中 dump 出内容)

我们的通讯使用的 sproto 协议 ,所以很快的写一个小脚本用 sproto decode 这 15 字节就还原了现场。

原来是因为某玩家在购买游戏物品时,传入了一个不正常的数量值。而服务器在处理购买的时候是用了一个 O(n) 的 for 循环处理的。在处理过程中,不断的向一个临时 table insert 数据。当这个数量达到几亿时,自然就吃光了所有内存。

如果想追究细节的话,情况是这样的:

lua 的 table 的数组部分是在装满后,翻倍增加的。而数组足够大后,一次翻倍就会立刻触发 gc 。而 gc 又会遍历 vm 中所有对象。一旦使用了交换分区,那么这个超大的 table 就会立刻从外存交换进内存遍历,还需要 copy 一次到新的内存空间,导致这个循环运行非常缓慢。


一个意外的发现是,一旦 gdb attach 进去挂起了有问题的线程后,服务器居然变得流畅了,完全不影响玩家游戏。究其原因是因为,当 gc 挂起后,不再遍历内存,而这时内存尚有冗余可以供其它业务运行。而 skynet 采用的是多工作线程平等调度的方案。每个工作线程在需要的时候才去取活干,而并没有为每个工作线程单独配置消息队列,受影响的仅仅是一个用户(实际上这个用户早已离线),它不会阻塞任何其它服务的运行。

如果这个时候直接停掉这个工作线程,将漏洞热修复,服务器完全可以正常运行下去。不过后果就是该服务的 lua vm 无法回收。所以更友好的方案是进入正常的关服流程,将玩家全部下线后,杀掉整个进程。


经过这次事故,我觉得 skynet 有必要增加一个新特性:允许开发者限制单个 lua vm 使用内存的大小。

虽然 skynet 已经在准备发布 1.0 版 ,原则上不再增加特性。但我觉得这个太重要了,不想留到正式版之后的版本中,所以立刻加上了。选择不用的话,不会有太多副作用。

这里是使用范例:https://github.com/cloudwu/skynet/blob/master/test/testmemlimit.lua

如果需要开启,必须在脚本一开始就调用 skynet.memlimit 设置上限,单位是字节数。一旦该 VM 使用超过这个限制,就会抛出内存错误。一般情况下,这个 vm 还可以正常工作,比如做一些退出的工作。这是因为超限通常是因为 table 翻倍这种一次性申请大块内存的操作引起的。一旦发生,虽然当前执行流程被打断,但由于 skynet 采用的是独立 coroutine 处理不同的消息,后续消息依旧可以正常处理,并有足够的内存使用。

个人建议对于玩家代理服务,可以设置上限到 128 M 左右。当然以过往经验,在正常情况通常应保持在 10M 以下。


另外,现在默认还在每个 vm 使用 32M / 64M / 128 M (依次翻倍) ... 内存时,写一条 log 作为警告,方便开发者排除线上问题。


ps. 事后我们查询了两次引发 bug 的用户,是两个不同的用户,使用的不同的设备。

Comments

受影响的仅仅是一个用户, 好奇这个vm是用来干嘛的,只有一个用户受影响,感觉像是该用户的私有服务,不像是公共服务。
前一段时间我自己维护一个c语言加lua游戏服务器,觉得这个结构不好,把系统的复杂度提升很多。排错难。做游戏的注意力应该放在内容上。最后我用c语言重新实现。这是我个人历经,个人看法。
确实是个好东西,值得点赞~~~
某年前,某公司一信息管理系统,存在一个SQL注入漏洞, 登陆都不用密码的,后来发现编码员省事,都没检测数据,就提交数据库了。 很多编逻辑代码的程序员,都给自己的代码一个想像的逻辑范围,而不是用真实的测试代码规范它。对外接口,需要被狂轰烂炸的,什么数据都有可能。不能为了一时的快速,而忽悠正确性安全性的检测。 不过,安全级别也有不一样的,像这种游戏级别,逻辑代码要求快速,死几次,也是能够容忍的。至于没有进行数据检查,那是程序员的素质问题。
回应一下楼上各位 skynet,一句话概括来说,是一个高性能的易于并发的框架。 一个高性能的Actor模型,有一定的程度上的逻辑沙盒,不能要求更多了。 安全性并不是skynet的特长,也就是说瞎写消息很容易写死,skynet并不避讳暴露设计缺陷。 另外,BigWorld是python脚本,但是一般用的时候必须gc.disable(),否则生产环境中后果也不可预料。(关闭gc以后引用计数还是起作用的)
某玩家传入了一个不正常的变量值,。 日本证券软件,一个用户传入一个不正常的挂单,又不能取消挂单,损失几百亿日元。 日本卫星,受外界因素产生自旋,需要动力来修正,一行代码错误,自旋加速,轨道中解体。 外因内因一起作用,低概率的事情也会发生高概率的风险。 内存泄漏,没有银弹,只能是加强管控,LUA根本就没有必要用GC,还不如PYTHONE的引用指针外加解决环引的GC。 另外LUA的TABLE自增加倍也是死穴,完全没必要,应该改成HAMT.
非常不错
一直在学skynet,虽然没怎么懂
这个特性很实用
这个例子看出,对于大的系统,lua的不合适之处
用过Bigworld之后,才慢慢理解到skynet的强大(这俩都不算是常规框架) 下个项目必用,搞起
默默的点个赞

Post a comment

非这个主题相关的留言请到:留言本