头图

Node.js内存泄漏追踪记

原文链接:Tracking down high memory usage in Node.js
作者:gkampitakis
译者:倔强青铜三

前言

大家好,我是倔强青铜三。是一名热情的软件工程师,我热衷于分享和传播IT技术,致力于通过我的知识和技能推动技术交流与创新,欢迎关注我,微信公众号:倔强青铜三。欢迎点赞、收藏、关注,一键三连!!!

追踪Node.js中的高内存使用问题

在这篇文章中,我将分享我如何追踪并修复Node.js中的高内存使用问题。

背景

最近,我收到了一个工单,标题是“修复库x中的内存泄漏问题”。描述中包含了一个Datadog仪表板,显示有十几个服务因高内存使用而崩溃,并且它们都有一个共同点——都使用了x库。

我刚接触代码库不久(不到两周),这使得任务具有挑战性,也值得分享。

我开始工作时只有两个信息:

  • 有一个库被所有服务使用,它导致了高内存使用,并且涉及到redis(redis包含在库的名称中)。
  • 受影响服务的列表。

下面是与工单链接的仪表板:

仪表板

服务运行在Kubernetes上,很明显服务随时间积累内存,直到达到内存限制,崩溃(回收内存)然后重新开始。

方法

在这一节中,我将分享我如何处理手头的任务,识别高内存使用的罪魁祸首,然后修复它。

理解代码

由于我对代码库不太熟悉,我首先想要理解代码,了解有问题的库做了什么以及它应该如何使用,希望这个过程能更容易地识别问题。不幸的是,没有适当的文档,但从阅读代码和搜索服务如何使用库,我能够理解它的大致情况。它是一个包装redis流的库,为事件生产和消费提供了方便的接口。花了一天半的时间阅读代码,由于代码结构和复杂性(很多类继承和rxjs,我不熟悉),我无法掌握所有细节和数据流。

所以我决定暂停阅读,尝试在观察代码运行时发现问题并收集遥测数据。

隔离复现问题

由于没有可用的配置文件数据(例如持续配置文件),这将帮助我进一步调查,我决定在本地复现问题并尝试捕获内存配置文件。

我发现了几种在Node.js中捕获内存配置文件的方法:

  • 使用堆快照
  • 使用堆分析器
  • 性能分析JavaScript
  • Clinic.js

没有线索知道从哪里开始,我决定运行我认为是库中最“数据密集”的部分,即redis流的生产者和消费者。我构建了两个简单的服务,它们将从redis流中生产和消费数据,然后我继续捕获内存配置文件并随时间比较结果。不幸的是,在对服务产生负载并比较配置文件几个小时后,我无法发现两个服务中的任何内存消耗差异,一切看起来都很正常。库暴露了许多不同的接口和与redis流交互的方式。很明显,复现问题比我预期的要复杂,尤其是考虑到我对实际服务的领域特定知识的有限。

那么问题是,我怎样才能找到正确的时刻和条件来捕获内存泄漏呢?

从暂存服务捕获配置文件

如前所述,捕获内存配置文件最简单和最方便的方式是拥有实际受影响服务上的持续配置文件,我没有这个选项。我开始调查如何至少利用我们的暂存服务(它们也面临着相同的高内存消耗)来捕获我需要的数据,而不需要额外的努力。

我开始寻找一种方法,可以将Chrome DevTools连接到正在运行的pod之一,并随时间捕获堆快照。我知道内存泄漏发生在暂存环境中,所以如果我能捕获到这些数据,我希望至少能够发现一些热点。令我惊讶的是,确实有这样做的方法。

执行此操作的过程:

  • 通过向您的pod上的node进程发送SIGUSR1信号来启用Node.js调试器。
kubectl exec -it <nodejs-pod-name> -- kill -SIGUSR1 <node-process-id>

如果成功,您应该看到服务的日志:

Debugger listening on ws://127.0.0.1:9229/....
For help, see: https://nodejs.org/en/docs/inspector
  • 通过运行以下命令在本地暴露调试器正在监听的端口
kubectl port-forward <nodejs-pod-name> 9229
  • 将Chrome Devtools连接到前面步骤中启用的调试器。访问chrome://inspect/,您应该在目标列表中看到您的Node.js进程:

Chrome DevTools

如果没有,请确保您的目标发现设置正确:

目标发现设置

现在您可以开始随时间捕获快照(周期取决于内存泄漏发生所需的时间)并进行比较。Chrome DevTools提供了非常方便的方法来做到这一点。

当创建快照时,主线程的所有其他工作都会停止。根据堆内容,它甚至可能需要超过一分钟。快照是在内存中构建的,因此可能会使堆大小翻倍,导致填满整个内存然后崩溃应用程序。

如果您要在生产环境中捕获堆快照,请确保您从中捕获的进程崩溃不会影响应用程序的可用性。

来自Node.js文档

所以回到我的案例,选择两个快照进行比较并按delta排序,我得到了以下结果。

内存快照比较

我们可以看到最大的正delta发生在string构造函数上,这意味着服务在两个快照之间创建了很多字符串,但它们仍在使用中。现在的问题是这些字符串在哪里创建以及谁在引用它们。幸运的是,捕获的快照包含了这些信息,称为Retainers

Retainers

在挖掘快照和不断增长的字符串列表时,我注意到了一个模式,这些字符串类似于一个ID。点击它们,我可以看到引用它们的链对象——也就是Retainers。这是一个名为sentEvents的数组,来自我可以从库代码中识别的类名。我们找到了罪魁祸首,一个不断增长的ID列表,到这一点,我假设它们从未被释放。我随时间捕获了一堆快照,这是唯一一个不断作为热点出现且具有大正delta的地方。

验证修复

有了这些信息,我不需要完全理解代码,而是需要关注数组的目的,它何时被填充以及何时被清除。代码中有一个位置是向数组push项目,另一个位置是pop项目,这缩小了修复的范围。

可以安全地假设数组没有在应该清空的时候被清空。跳过代码细节,基本上发生的事情是:

  • 库暴露了用于消费、生产事件或同时生产和消费事件的接口。
  • 当它同时消费和生产事件时,它需要跟踪进程本身产生的事件,以便跳过它们,不重新消费它们。sentEvents在生产时被填充,在尝试消费时被清除,以便跳过消息。

你可以看到这是怎么回事吗?当服务仅使用库来生产事件时,sentEvents仍然会被填充所有事件,但没有代码路径(消费者)来清除它。

我对代码进行了补丁,仅在生产者-消费者模式下跟踪事件,并部署到暂存环境。即使在暂存负载下,也很明显补丁有助于减少高内存使用,并且没有引入任何回归。

结果

当补丁部署到生产环境时,内存使用量大幅减少,服务的可靠性得到了提高(不再有OOM)。

一个不错的副作用是,处理相同流量所需的pod数量减少了50%。

结论

这是我追踪Node.js内存问题的一个很好的学习机会,也让我进一步熟悉了可用的工具。

我认为最好不要详细讨论每个工具的细节,因为那将值得一篇单独的文章,但我希望这是任何对这个话题感兴趣或面临类似问题的人的良好起点。

最后感谢阅读!欢迎关注我,微信公众号:倔强青铜三。欢迎点赞收藏关注,一键三连!!!

倔强青铜三
28 声望0 粉丝