分析 "构建" 中的内存溢出

date
Nov 12, 2020
slug
terser-oom
status
Published
tags
Node.js
summary
开启三方模块转换的工具,引用 terser 会导致内存溢出(Out of Memory)
type
Post
结论:开启三方模块转换的工具,引用 terser 会导致内存溢出(Out of Memory)
修复后成效:CI 耗时从 15min+ 减少到 5min-(缓存耗时 3min-)

背景

自从 umi 3 发布后,经常有反馈构建爆栈、CI 爆栈;
notion image
我就纳闷了,Node.js 运行时内存默认为 512MB,不会轻易爆栈,当时的临时解决方案是通过 max_old_space_size 增大了 Node.js 运行时内存,解决问题 umi#5599

排查思路

这是一个隐藏很深的内存泄露 bug,我甚至一度认为是 webpack 构建、webpack-chain 序列化等导致。
排查比较好用的是 二分法(即每次注释掉一半代码,直至定位到具体代码,形成最小复现案例)

OOM 用例

出现 OOM 的是这个用例 bundler-webpack/index.test.ts#L11-L79,通过在 fixtures 项目中跑 umi build 后,测 dist 产物与预期是否一致。
notion image
因为所有项目都要跑 umi build ,起初我以为跑太多 webpack 实例,导致内存吃紧,直到我单独测试 x-mainiest-production 这一个用例时,居然 OOM 了
// ~/github/umi
$ yarn test -t "x-manifest-production" \ packages/bundler-webpack/src/index.test.ts
 
notion image
又测试了一个非 production 项目(含 production 目录名即用 webpack mode=production 构建),居然不内存泄露✅ 。。。这里大胆猜测下:内存泄露与 webpack 使用 production 模式有关,然后转向 webpack 配置进行排查。

webpack 排查

二分法注释代码,然后跑用例,发现一个神奇的问题:在获取 wepback 配置阶段,已经内存泄露
notion image
查到这,发现不能把 OOM 问题扔锅给 webpack 了,配置获取** 阶段已经爆栈了。

webpack-chain 排查

于是找到 umi 中对 webpack 配置封装的代码 bundler-webpack/getConfig.ts,当注释掉 webpack-chain 的 toConfig 方法时,用例通过✅ ,开始怀疑是不是配置序列化导致?
notion image
扫了下 issues,没有相关 OOM 问题,又瞄了一眼源码,只做了配置转换和插件序列化,没有其它额外操作。这时候思路中断,难道锅要从 webpack 到 webpack-chain 吗?

terser-webpack-plugin 排查

紧接着在 toConfig() 方法的前面用二分法注释,当注释到 terser-webpack-plugin 时,突然用例通过✅ ,奇怪地是替换成其它 webpack plugin(例如 file-loader 等) 没问题,只要是 terser-webpack-plugin,就会 OOM。
 
然后在 [email protected] 方法里打一些日志,结果发现连类初始化都没走到。
notion image
猜测是某个模块引入时就出现问题了,二分法注释,发现是 [email protected] 里的 terser 模块在引入时,已经 OOM ❌

terser 排查

一样的思路,先搜了下 issues,发现有相关 OOM 问题,解决方案大多是增大 Node.js 运行时内存 terser#164,这样解决不了本质问题。开始排查 terser 里是哪行导致,熟练了二分法,这里首先把最后一行 sourceMappingURL 注释,居然用例通过了 
notion image
有些兴奋,这说明 OOM 链路是这样的:
- getConfig() # 获取 webpack 配置
    - toConfig() # webpack-chain 序列化配置
    - require('terser-webpack-plugin') # 开始引入 terser 插件
        - require('terser')
 
开始准备最小复现 Demo,这里我直接弄了个 jest + terser 的 demo, jest 运行以为复现就能复现,神奇的是居然没有复现。。。
 

const { minify } = require('terser');

test('terser oom', () => {
  expect(typeof minify).toEqual('function');
});
这个用例是直接通过的 ✅ ,心态崩了,难道是因为 umi-test,这锅有点大啊。。。

umi-test 排查

在上面的 demo 中,如果我将 jest 换成 umi-test,就复现 OOM ❌。
{
  "name": "jest-terser-oom",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "@types/jest": "^26.0.15",
    "jest": "^26.6.3",
    "terser": "^5.3.8"
+   "@umijs/test": "^3.2.27"
  },
  "scripts": {
-   "test": "jest"
+   "test": "umi-test"
  }
}
@umijs/test 在 jest 基础上进行封装,开箱即用
排查思路一致,逐个注释,当我去掉 transformIgnorePatterns 配置时,用例通过了✅ 。
 
- transformIgnorePatterns: []
 
翻阅了 transformIgnorePatterns 文档,默认是 ["/node_modules/", "\\.pnp\\.[^\\\/]+$"] ,也就是说,umi-test 默认将所有 node_modules 模块经过 babel-jest 转换。
 
那么问题就变成了,terser 模块因为有了 sourceMap,只要经过了 transform 转换**,就会 OOM。terser 作者也同时评论说是 sourceMap 导致的问题 terser#issuecomment
 
notion image

回归 terser

本来问题已经有解决方案了,出于好奇,想知道是哪块代码导致的 OOM,直到我注释掉这个 domprops 对象,便不再内存泄露了。
notion image
这个数组对象有 7768 项,相当大的数组,再结合 sourceMap,内存消耗避免不了。
notion image

修复方案

两个方案:
  1. 改 terser,不加 sourceMap terser/terser#872
  1. jest 不编译 node_modules 下模块,也不会引入到 sourceMap umijs/umi#5665
 
这里我使用了第二个方案,带来的成效很明显,umi 的 ci 直接从 15min+ 减少到 6min-,缓存跑只需要 3 min。
 
notion image
同时内部的 Bigfish 框架(基于 umi,bigfish = umi + 内部插件集),CI 降到 10min 内
notion image

参考

这里提供一些排查、解决 OOM 的方法
  • 使用 Chrome devtools 无侵入式排查 Node 进程
    • 启动项目
    • 查看对应 pid(进程 id,这里要查到最后真正执行的进程)
    • 执行 node -e "process._debugProcess(进程id)"
    • 在 Chrome Develop Tool 里打 Memory 快照
    • Memory 快照对比,看未释放的内存对象
 

© ycjcl868 2021