记一次 Node.js 应用内存暴涨分析

418 查看

记一次 Node.js 应用内存暴涨分析

起因

之前 TMS 在运行时 CPU 中占用率和内存占用一直很高,导致应用运行状态不是很良好,需要频繁重启。经过排查,找出了部分原因:

  1. 使用的 html-minifier 模块有问题,如果输入的内容是一个有错误的 HTML 结构,会使解析进入死循环,导致 CPU 占用率 100%。
  2. 在使用 vm 模块时,使用姿势错误,导致内存占用无法释放,使内存占用暴涨。

第一个问题我们今天不予讨论,主要来说一下第二个问题。

VM(Virtual Machine) 模块

我们就先了解下 VM 这个模块。

从它的名字和暴露的 API 可以看出,它能创建一个拥有指定上下文的运行环境,可以在里面直接运行 JavaScript 代码,类似 eval。这样运行代码时,不会污染当前作用域,一旦出问题,也不会对当前环境造成很大影响。

虽然这个模块我们平时用的比较少,但它算是 Node.js 的核心模块,在 require 的实现中,你会发现它的身影。我们在使用 Node.js 时,会使用 require 引入很多外部模块,对于 Node.js 来说,我们引入的代码如果直接和运行环境交互,是十分危险的。所以在 Node.js 模块加载的过程中,会先将 .js 文件的内容进行包裹,变成类似 function(...) {}(...) 的形式,然后使用 vm.runInThisContext 去运行,同时将 module、require 等方法传入返回的函数中。具体的模块加载机制,可以在 lib/module.js 中看到实现,不是本文重点,就不细说了。

当然,我们也可以用它来执行我们的代码:

 

问题出现

在 TMS 中,需要压缩用户上传的代码,出于安全和稳定的考虑,需要和当前运行环境进行隔离,这里就可以使用 VM 模块。为了便于理解,简化了一个类似的 Demo,如下:

运行 Demo。为了模拟实际环境中的并发,这里我们使用 ab 来发起请求。

Apache HTTP server benchmarking tool,简称 ab,是一个常用的开源网站压力测试工具,官网

在运行期间,我们使用 top 来观察内存的占用情况。

实验一

可以发现一些问题,

  • 内存占用暴涨,大约 800M
  • 占用的内存在运行结束(没有请求)后,释放很慢
  • QPS 很低

Demo 应用比较简单,引发的问题不大。但如果在实际的应用场景中,一旦发生内存占用过高,无法分配内存空间的情况,会对应用稳定性照成很大影响,甚至导致应用崩溃。

接下来,我们再看一个例子,将上面的代码稍作修改,如下:

用上面同样的方法观察,结果如下图:

实验二

这次,我们看到内存仅占用了 19M,而且增长很平缓,QPS 提高了不少。

仅仅是声明 sandbox 位置的不同,差别却如此之大,为什么呢?

探究原因

我们都知道,一般一个在函数中声明的变量,在函数运行完,就会被释放掉,所占用的空间也会被回收。但在之前的例子,很有可能 sandbox 变量没有被回收,导致的内存暴涨。它和其它变量有什么区别,导致它不能被正确释放呢?

翻了下 vm 的代码,发现在使用 vm.runInNewContext 时,会将你传入的 sandbox 进行 contextify,问题可能就出在这里。

contextify 大体流程如下(src/node_contextify.cc#L281 MakeContext):

  1. 检查传入的对象(sandbox)是否有 _contextifyHidden 这个隐藏的属性。
  2. 如果没有,则 new 一个 ContextifyContext 实例,并且挂载到 sandbox 的 _contextifyHidden 属性上。
  3. 如果存在,则返回,不做处理,防止在一个对象上多次进行 contextify。

如果我们用一个在函数外部声明的 sandbox,如同第二种写法,那么无论我们调用多少次 runInNewContext,都只会进行一次 contextify 操作,效果类似于 vm.runInContext。但是,如果像第一种写法那样,每次都使用一个新的对象,那么每次都要进行 contextify,而 contextify 过程中比较关键的一步是创建一个 ContextifyContext 实例,这个类有些特殊的地方,我们看下它的具体定义(在 src/node_contextify.cc#L49 ):