我们如何解决网站上的内存泄漏问题
fiit网站用于创建订阅,并帮助用户登录 Sky 和 Amazon 等电视平台上的应用程序。因此,它是公司一项重要的资产,但最终其内容并不经常更新。
网站内存泄漏问题已经存在一段时间了。我们如何确定呢?我认为下面的内存使用情况图表已经很清楚地说明了这一点。这是正常一周的内存使用情况(绿色部分显示了最小值、最大值和平均值)。
可以看到,每隔一两天,内存使用量就会飙升至 100%,然后最终崩溃,之后又会重新开始这个过程。
该技术栈已过时,部分 Node 模块已落后三年,NodeJS 版本为 10(发布于 2018 年 10 月)。撰写本文时,LTS 版本为 14.17。
所以我们知道存在漏洞,可能是我们的代码有问题,也可能是版本过旧。该从哪里入手呢?
方案A——本地化概况
我们决定先分析代码。我们会将应用程序运行在生产模式下,并在不同时间点使用 Chrome 开发者工具获取内存快照。比较这些快照之间的差异,应该能够找出问题所在。
然而,在此之前,还有几个其他问题需要解决……
问题 1 - 如何在本地对服务器进行恶意测试?
我们使用 Apache Benchmark 来完成这项任务。它非常棒,你可以设置请求总数和并行请求数。例如
ab -c 50 -n 5000 -k http://localhost:8080/
问题二:应该删除哪些页面?
现在我们有了可以用来模拟高用户量的工具,我们应该瞄准哪里呢?
该网站使用 GA,所以我抓取了访问量最高的 2 个页面(其中一个是首页),并计划使用这两个页面。
问题 3 - 如何捕获通过 Docker 容器运行的服务器的内存?
这包含 3 个步骤
- 在 Docker 镜像中暴露 NodeJS 调试器端口(端口号为 9229)。例如
9229:7001 - 在 Docker 镜像中启动应用程序并启用调试器。例如
--inspect=0.0.0.0,启用垃圾回收--expose-gc(原因是我们可以在收集快照之前触发 GC,从而隔离未被正确回收的内存部分)。 - 打开 Chrome 开发者工具(
chrome://inspect/#devices),并添加一个目标到localhost:7001(我们的远程调试端口)。 - 在列表中找到要分析的应用程序,点击它,即可打开 Chrome 开发者工具。在这里,我们可以使用“内存”选项卡来获取堆快照。
现在我们的服务器应用程序运行在 Docker 容器中,并且可以连接到服务器的内存。
本地化分析
我们决定先发送 1000 个请求,每次发送 30 个。
我们将每 2 分钟对内存堆进行一次快照,持续约 8 分钟,global.gc()每次快照都会强制执行一次 GC(垃圾回收),然后在最后比较不同的快照,查找占用内存量较大的项,即 1% 或更大的项。
我们立即发现问题出在使用 Lodash 的方式上,更具体地说是memoize()函数本身。
我们每次都传递不同的键,因此每次都会创建一个新的函数实例。所以内部 Map 会不断增长。这属于内存泄漏。
我们在下面的截图中找到了两个罪魁祸首,请注意第一个内部数组是“node_modules”,并且已从代码中移除。
还有第三例,情况比其他几例更糟,所以也被删除了。
移除 memoize 函数解决了这些特定问题。
然而,生产方面的整体问题依然存在,这些盒子仍然以同样的方式漏内存。
此时我们觉得,深入了解生产环境中的泄露情况可能会有所帮助。
备选方案——提高生产透明度
我们决定采用两种方法来收集生产包装盒信息。
1. 普罗米修斯
把 Prometheus 也加进来——它在捕获各种底层指标方面非常出色,或许能帮助我们找到漏洞的源头。
我们的想法是为应用添加一个 /metrics 接口,用来暴露一些统计数据,然后我们可以收集这些数据并在 Grafana 上显示。
这是 /metrics 输出的一个示例。
使用 Koa 库,更改看起来大致如下,使用流行的库prom-client。
这项更改生效后,我们便可以分析结果。
其中一个最有趣的指标是“NodeJS 堆空间使用量”。它可以帮助您检查不同内存空间的性能。
const metricsRouter = new Router();
metricsRouter.get('/metrics', async (ctx) => {
ctx.set('Content-Type', register.contentType);
ctx.body = await register.metrics();
ctx.status = 200;
});
router.use(metricsRouter.routes());
我们发现,泄漏点位于“旧空间”内。
为了展示内存空间的变化,这里展示的是一段时间内的“新空间”。
这是同一时期“旧空间”的情况。存在明显的泄漏。
这表明有些对象在垃圾回收后仍然存在,主要原因有两个:一是内存中仍有指向其他对象的指针,二是原始数据仍在被持续写入。这两个原因通常都是由糟糕的代码造成的。
2. 堆转储
利用heapdump包,我们可以捕获并下载生产环境中的内存快照,将其导入本地 Chrome 内存分析器,并尝试定位内存泄漏的源头。
这样做的目的是让真实的服务器内存包含真正的泄漏点,而本地复现尝试大多未能得到一致的结果。
这样做存在 3 个问题,大部分问题源于我们在生产环境中使用 AWS ECS Fargate 将请求分发到多个容器中。
问题 1
我们无法通过终端直接连接到正在运行的服务器(例如使用类似“docker exec”的命令),因为它们没有以这种方式公开。任何“ECS”命令都会在新容器中运行,因此无法提供我们可用的内存快照。
问题二
鉴于“问题 1”,我们知道必须通过暴露 URL 来下载快照。然而,我们无法稳定地访问同一台服务器的 URL。负载均衡器会不断地将我们切换到不同的服务器(我们没有启用会话保持),因此我们只能对不同服务器进行内存转储,而这些转储在比较后会被证明毫无用处。
问题 3
构建内存快照本身就会消耗大量的 CPU 和内存资源。这是一项非常耗费资源的任务。因此,我们必须确保任何提供此功能的 URL 都经过某种形式的身份验证。
解决方案
我们在网站上添加了一个网址,并通过时效性单向哈希添加了身份验证,以确保未经我们许可,任何人均无法访问该页面。
下载内存堆快照文件的过程首先是在本地生成一个与整点开始时间相关的有效哈希值(我省略了实际值)。
node -e "const moment = require('moment'); console.log(crypto.createHmac('sha256', 'secret-key').update(JSON.stringify({ date: moment.utc().startOf('hour').toISOString(), value: '[obfuscated]' })).digest('hex'));"
然后点击网站网址/heapdump?hash=<hash>下载文件。
服务器上运行相同的哈希码,只要哈希值与当前内存快照匹配,即可解决问题 1 和问题 3。
快照文件名是 ECS 任务 ID(有关如何获取该 ID 的更多详细信息,请参阅AWS 文档此处),因此多次运行该快照应该可以解决问题 2,并从同一台服务器上生成配置文件。
分析生产快照
我们采用了与本地性能分析相同的流程,即先执行一次任务,等待几分钟后再执行一次,然后比较两次任务之间分配的内存。
以下是 Google 开发者工具中显示的两个任务的性能分析结果。
好消息是,快照清晰地显示了内存泄漏的迹象。内存占用始终在 50MB 左右,10-15 分钟后增加到 85MB,再过 10-15 分钟后达到 122MB。生成快照会消耗一些内存,但应该不会太多。
我们在本地环境中没有观察到这种情况。
通过查看间隔较大的快照之间“已分配的对象”(见下文),我们发现一个内部数组增加了 4-5%(注意:数组顶部的元素是快照本身,生成快照需要占用内存)。
这适用于保留内存,而保留内存很重要,因为它能让我们了解如果该对象被垃圾回收,将会释放多少内存。
由于每个条目都来自不同的库,我们认为这可能是 NodeJS 的问题,也许 v10 中的映射/数组没有 v14 中那么优化?
升级 NodeJS
升级网站使用的 NodeJS 版本相当简单,因为我们没有使用 v10 版本中任何已弃用的功能。我们只需要更新 Docker 镜像、CircleCI 配置和本地 NVM 配置,所有这些都更新到当时的最新版本 v14.16。
结果立竿见影……记忆力没有持续急剧上升,而是保持稳定,如下所示。
升级后,之前造成问题的堆内存空间也明显改善了。
就是这样🙌🏻!我们对生产环境进行了分析,找到了根本原因,实施了修复,从那天起,网站的内存使用情况就一直很稳定。
吸取的教训
我们从这段旅程中学到了很多,以下我们详细介绍其中几点:
- 处理泄漏问题时,局部分析是一个不错的起点,但不要把所有希望都寄托于此,以免找不到泄漏原因。
- 使用 Prometheus 定位内存泄漏的内存空间
- 使用 Google 开发者工具比较内存堆转储文件,以定位内存泄漏的源头。
- 如果您正在考虑收集生产内存快照,请务必了解您的基础架构能够做到什么,不能做到什么。
- 务必保持依赖项更新,否则最终会付出代价。
希望您喜欢这篇文章。
如果您有兴趣加入Fiit的工程部门,请点击此处访问我们的招聘页面,查看有哪些职位空缺。
谢谢
文章来源:https://dev.to/fiit/how-we-resolved-a-memory-leak-on-our-website-1kf0











