一张表示计算环境内存泄漏的概念图。电脑屏幕上显示了一张图表,曲线持续上升,象征着不断增加。
解决内存泄漏问题,能让开发者开心到笑出声,就像找到了宝藏一样。 😂
经过几个月的努力,我终于解决了那个特定的内存泄漏问题。在这篇文章里,我会简要概述我的推理过程,哪些方法不起作用,以及我最终是如何解决这个问题的。
我将要讨论的服务是一个在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”列对于识别哪些因素最可能导致内存增加非常有用。
我希望这篇帖子里的信息能帮助你找到应用程序中的内存问题所在。如果你用过不同的工具,或者对任何替代方案有自己的想法,请在评论区分享你的想法。🙏
下次再聊。👋
共同学习,写下你的评论
评论加载中...
作者其他优质文章