为了账号安全,请及时绑定邮箱和手机立即绑定

在使用Node.js的应用程序中,你如何发现生产环境中的内存泄漏?

一张表示计算环境内存泄漏的概念图。电脑屏幕上显示了一张图表,曲线持续上升,象征着不断增加。

解决内存泄漏问题,能让开发者开心到笑出声,就像找到了宝藏一样。 😂

经过几个月的努力,我终于解决了那个特定的内存泄漏问题。在这篇文章里,我会简要概述我的推理过程,哪些方法不起作用,以及我最终是如何解决这个问题的。

我将要讨论的服务是一个在Kubernetes中部署的Node.js单体API服务。它是由多个团队在过去6到7年的时间里建立的,每秒处理数千次请求,使其成为我们系统中一个关键的组成部分。没有一个人能够完全理解每个端点的实现细节,这增加了其复杂性。

在我看来,分享这种经验非常重要,因为在网络搜索中通常会找到过于简单化的例子,这些例子在实际操作中并没有太大帮助。

问题已报告 ☣️

2023年5月创建了一个工单,发现当没有新的发布时,响应延迟会显著增加。

像图表中表示页面加载时间95分位数的蓝色部分,以及表示发布标记的橙色条。

由于延迟随着时间缓慢增加,首先怀疑的是内存,我们通过检查生产中一个 pod 的内存使用情况确认了这一点。

如上图所示,内存使用量的增长极其缓慢。要看到明显的内存使用变化,你需要观察一个应用实例几天的时间。

作为第一步的缓解步骤,SRE团队引入了一项程序,在没有计划部署的每天重启该服务。 🤕

1 尝试:尝试本地复制 ❌

我第一反应是试着在当地重现这个问题,获取内存快照并找到问题的根本原因。💡

我尝试了一小时,在一些随机选取的API端点上进行负载测试,并使用Clinic.js doctor来寻找任何可能的内存泄漏迹象。

尽管 Clinic.js 是一个很棒且非常有价值的工具,但它并不完全符合我的使用场景。主要问题在于,我无法在我的笔记本电脑上长时间运行 Clinic.js(超过一小时),因为它在生成最终报告时会卡住。我需要运行测试几小时甚至更长时间。

作为第二个选择,我仅在我的机器上使用vegeta命令行工具(cli)运行了负载测试。

如你所见,延迟时间是稳定的,因此我需要调整策略。 😩

尝试#2:加载测试环境(staging) ❌

在我的本地机器上长时间运行负载测试非常不方便,于是我想到能不能在 staging 上跑测试,这样就方便多了。

这次,我没有随便挑接口做负载测试,而是去我们APM工具那边,找到了那些流量最大且延迟增加影响最严重的接口。

我的团队中有一名成员开发了一个超棒的工具,可以长时间运行负载测试作为Jenkins作业的一部分,并带有非常出色的报告,这正合我意。

一切都准备好了,测试开始了,然后测试已经运行了5天…… 🥁

唉,什么都没有!😭 和生产环境相比,内存使用情况完全不同,这种方法明显行不通。

第三次尝试:从生产环境抓取内存快照 🎯

由于无法在生产环境之外重现这个问题,我只能直接从生产环境中获取堆转储,然后比较。💡

我已经在之前的一篇文章中提到过如何在Kubernetes中运行的pod上附加调试工具的经历这里

在继续之前,先要明白在生产环境中启用调试器可能带来的影响非常重要。⚠️

安全注意事项:启用调试器可能导致重大安全风险。任何人都可能获取应用程序当前的内存信息,从而泄露其中存储的任何敏感信息。

性能影响:仅仅启用调试器功能不应该影响服务的性能,但主动获取堆快照会暂停进程运行。这种中断可能会导致应用程序中的错误。

风险缓解:如果你的服务运行多个实例,整体风险可能会有所减少。此外,你可以暂时防止流量到达某个实例。

以下是可以用来打开 pod 的调试模式的代码:


    # 找到一个适合调试的 pod(pod)
    $ kubectl get pods | grep my-api

    # 查找 Node.js 的进程ID,这里的进程ID是 7
    $ kubectl exec my-api-64dcf6b84d-nx5cl -- ps aux

    # 向 Node.js 进程发送 kill 信号 => 这将启用调试模式
    $ kubectl exec my-api-64dcf6b84d-nx5cl -- kill -SIGUSR1 7

    # 从 Kubernetes 中的 pod 端口映射到本地端口
    $ k port-forward my-api-64dcf6b84d-nx5cl 9229:9229

这应该足够让我在 chrome://inspect 中看到进程并开始截图。但对我而言,这根本不管用,Chrome 检查器无法看到该进程,我也搞不定 🤬

整日用谷歌寻找解决方案,又经历了一次很长的与ChatGPT的心理治疗过程后,我发现了一个有趣的Node.js文档中的一个部分:

这让我想到了一个主意,实际上编写一个脚本,我可以定期运行它来获取快照,然后比较一下。

该脚本通过websockets与Node.js调试器进行通信,发送消息以获取堆转储,将结果保存到.heapsnapshot文件,并且该文件可以在Chrome开发者工具中打开。您可以在这里找到该脚本。

最后我终于有了所有需要的数据,找到了根本原因!

有了这些截图,在开发工具中比较它们就非常简单。

  • 转到chrome://inspect
  • 点击“为 Node 打开专用的 DevTools”
  • 切换到 Memory 选项卡
  • 点击加载个人资料
  • 加载你想要比较的所有堆转储文件
  • 选择一个个人资料,切换到比较模式,然后选择第二个个人资料进行对比

你应当看到类似这样的界面,比如:

那这些信息该怎么用呢?你看得到对象、字符串、函数……不断地在分配和释放之间切换,实例数量一直在变。你实际上怎么找到根本原因的呢?🤔

不断展开所有内容后,复制并粘贴到Excel中尝试可视化数据,我发现如果有内存泄漏,‘Size Delta’这列必须持续增加。🕵🏻‍♂️

我发现“system / JSArrayBufferData等等”一直在涨!每隔两个快照,我注意到有数百个8KB大小的对象被创建了。

查看分配栈后,我发现问题似乎与处理 grpc 连接有关。但我们其实并没有用到 grpc

查看 package-lock.json 中的依赖关系,我很快发现 googlerecaptcha enterprise 库使用 grpc 连接到 google 后端。初看示例代码和我们的实现,我并没有发现任何问题。示例代码中没有提到打开或关闭 grpc 连接。我试图从其他用户那里找到更多示例,唯一可以观察到的区别是,每个人都在每个特定端点的请求中使用同一个 recaptcha 客户端,而我们在每次请求时都创建了一个新的客户端。深入研究库的源码后,我发现每次创建新客户端时都会建立一个新的 grpc 连接,然而,我们的代码从不关闭这些连接。目标:

我和最初开发这个功能的团队合作,将代码重构为仅使用一个客户端实例,将这些改动合并并部署到了生产环境。🚀

监控生产变化 📈

经过几天的观察,我发现生产环境中的内存使用量几乎已经趋于稳定。

这进一步导致稳定的延迟:

耶!💪

你知道吗?恰好在问题被报告整整一年后,我解决了工单上的问题。真巧! 🥹

总结 👨‍🎓

拥有正确的工具来观察应用在生产环境中的性能表现,对于任何服务的成功都至关重要。✅

Node.js 提供了一个很棒的调试工具,可以直接从生产环境中获取到堆快照。一旦你获取到了这些快照,Chrome DevTools 提供了一个出色的、用户友好的工具来比较它们。然而,理解这些数据来找到内存泄漏的根本原因可能会令人困惑。特别有用的是,“Size Delta”列对于识别哪些因素最可能导致内存增加非常有用。

我希望这篇帖子里的信息能帮助你找到应用程序中的内存问题所在。如果你用过不同的工具,或者对任何替代方案有自己的想法,请在评论区分享你的想法。🙏

下次再聊。👋

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消