Node.jsホットアップデートについて話す

はじめに:Node.jsが2015年と16年に始まったばかりのとき、以前の雇用主での就職の面接中にNode.jsサービスのホットアップデートを実装する方法も尋ねられたことを覚えています。

picture.png

picture.png

Node.jsが2015年と16年に始まったばかりのとき、以前の雇用主の就職の面接でNode.jsサービスのホットアップデートを実装する方法も尋ねられたことを覚えています。

実際、初期にPhp-fpm / Fast-cgiから転送されたNoderは、サーバーを再起動せずにビジネスロジックコードを更新するこの展開スキームを間違いなく気に入っています。その利点も非常に明白です。

  • サービスを再起動する必要がないということは、特に多数の長いリンクホールドがあるアプリケーションの場合、ユーザー接続が中断されないことを意味します
  • ファイル更新の読み込みキャッシュは、ミリ秒単位でアプリケーションの更新を完了することができる非常に高速なプロセスです

一般的なメモリリーク(リソースリーク)など、ホットアップデートには多くの副作用もあります。この記事では、ダウンロード数が比較的多い2つの人気のあるホットアップデート補助モジュールであるclear-moduleとdecacheを使用して、ホットアップデートがアプリケーションに与える影響について説明します。 。どのような問題が発生しますか。

熱交換の原理

ホットアップデートについて説明する前に、まずNode.jsのモジュールメカニズムの概要を理解する必要があります。これにより、後で発生する問題をより深く理解できるようになります。

Node.js自体によって実装されたモジュール読み込みメカニズムを次の図に示します。

image.gifpicture.png

簡単に言うと、親モジュールAがサブモジュールBを導入する手順は次のとおりです。

  • サブモジュールBキャッシュが存在するかどうかを判別します

  • 存在しない場合は、Bをコンパイルして解析します

  • Bモジュールキャッシュをに追加しますrequire.cache(ここで、キーはモジュールBのフルパスです)

  • Bモジュール参照を親モジュールAのchildren配列に追加します

  • 存在する場合は、親モジュールAの配列にBが存在するかどうかを判断し、存在しchildrenない場合は、Bモジュールの参照を追加します。

実際、この時点で、メモリリークなしでホットアップデートを実現するには、ホットアップデートするモジュールの次の参照リンクを切断する必要があることがすでにわかります。

image.gifpicture.png

このように、requireサブモジュールBに再度移動すると、モジュールBの内容がディスクから再度読み取られ、コンパイルされてメモリにインポートされるため、ホットアップデートの機能が実現されます。

实际上,第一节中提到的clear-moduledecache两个包都是按照这个思路实现的模块热更,当然它们考虑的会更加完善一些,比如将子模块 B 本身的依赖也一并清除,以及对于循环引用场景的处理。

那么,借助于这两个模块,Node.js 应用的热更新是不是就完美无缺了呢?我们接着看。

问题一:内存泄露

内存泄露是一个非常有意思的问题,凡是进入 Node.js 全栈开发深水区的同学基本或多或少都会遇到内存泄露的问题,那么从我个人的故障排查定位经验来说,开发者其实不需要畏惧内存泄露,因为相比其它摸不着头脑的问题,内存泄露是一个只要你熟悉代码并且肯花时间百分百可解的故障类型。

这里我们来看看看似清除了所有旧模块引用的热更方案,又会以怎样的形式产生内存泄露现象。

decache

考虑构造以下热更例子,先使用decache进行测试:

'use strict';

const cleanCache = require('decache');

let mod = require('./update_mod.js');
mod();
mod();

setInterval(() => {
  cleanCache('./update_mod.js');
  mod = require('./update_mod.js');
  mod();
}, 100);

这个例子中相当于在不断清理./update_mod.js这个模块的缓存进行热更,它的内容如下:

'use strict';

const array = new Array(10e5).fill('*');
let count = 0;

module.exports = () => {
  console.log('update_mod', ++count, array.length);
};

为了能快速观察到内存泄露现象,这里构造了一个大数组来替代常规的模块闭包引用。

为了方便观察我们可以在index.js中可以添加一个方法来定时打印当前的内存状况:

function printMemory() {
  const { rss, heapUsed } = process.memoryUsage();
  console.log(`rss: ${(rss / 1024 / 1024).toFixed(2)}MB, heapUsed: ${(heapUsed / 1024 / 1024).toFixed(2)}MB`);
}

printMemory();
setInterval(printMemory, 1000);

最后执行node index.js文件,可以看到内存迅速溢出:

update_mod 1 1000000
update_mod 2 1000000
rss: 34.59MB, heapUsed: 11.51MB
update_mod 1 1000000
rss: 110.20MB, heapUsed: 80.09MB
update_mod 1 1000000
...

rss: 921.63MB, heapUsed: 888.99MB
update_mod 1 1000000
rss: 998.09MB, heapUsed: 965.12MB
update_mod 1 1000000
update_mod 1 1000000

<--- Last few GCs --->

[50524:0x158008000]    13860 ms: Scavenge 1018.3 (1024.6) -> 1018.3 (1028.6) MB, 2.3 / 0.0 ms  (average mu = 0.783, current mu = 0.576) allocation failure 
[50524:0x158008000]    14416 ms: Mark-sweep (reduce) 1026.0 (1036.3) -> 1025.9 (1029.3) MB, 457.8 / 0.0 ms  (+ 86.6 ms in 77 steps since start of marking, biggest step 8.7 ms, walltime since start of marking 555 ms) (average mu = 0.670, current mu = 0.360

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

抓取堆快照后进行分析:

image.gifpicture.png

很明显Module@39215children数组中大量塞入了重复的热更模块update_mod.js的编译结果导致了内存泄露,而进一步查看Module@39215信息:

image.gifpicture.png

可以看到其正是入口的index.js

阅读decache实现源代码后发现,产生泄露的原因则是我们在热更实现原理一节中提到的要去掉全部的三条引用,而遗憾的是decache仍然只断开了最基础的require.cache这一条引用链路:

image.gifpicture.png

至此,decache由于最基本的热更内存问题都尚未解决,白瞎了其 94w 的月下载量,可以直接排出我们的热更方案参考。

参考:

clear-module

接下来我们看看月下载量为 19w 的clear-module表现如何。

由于前一小节中的测试代码代表了最基础的模块热更场景,且clear-moduleAPI使用和decache基本一致,所以我们仅替换cleanCache引用即可进行本轮测试:

// index.js
const cleanCache = require('clear-module');

同样执行node index.js文件,可以看到内存变化如下:

update_mod 1 1000000
update_mod 2 1000000
rss: 35.00MB, heapUsed: 11.58MB
update_mod 1 1000000
rss: 110.69MB, heapUsed: 80.10MB
update_mod 1 1000000
rss: 187.36MB, heapUsed: 156.52MB
update_mod 1 1000000
rss: 256.28MB, heapUsed: 225.26MB
update_mod 1 1000000
rss: 332.78MB, heapUsed: 301.71MB
update_mod 1 1000000
rss: 401.61MB, heapUsed: 370.38MB
update_mod 1 1000000
rss: 42.67MB, heapUsed: 11.17MB
update_mod 1 1000000
rss: 65.63MB, heapUsed: 34.15MB
update_mod 1 1000000

这里可以发现,clear-module内存趋势呈现波浪形,说明它完美处理了原理一节中提到的旧模块的全部引用,使得热更前的旧模块可以被正常 GC 掉。

经过源代码查阅,发现clear-module确实将父模块对子模块的引用也一并清除:

image.gif图片.png

因此这个例子中热更不会导致进程内存泄露 OOM。

详细代码可以参见:github.com/sindresorhu…

那么是不是认为clear-module就可以高枕无忧没有内存烦恼了呢?

其实不然,我们接着对上面的index.js进行一些小小的改造:

'use strict';

const cleanCache = require('clear-module');

let mod = require('./update_mod.js');
mod();
mod();

require('./utils.js');

setInterval(() => {
  cleanCache('./update_mod.js');
  mod = require('./update_mod.js');
  mod();
}, 100);

对比之前新增了一个utils.js,它的逻辑相当简单:

'use strict';

require('./update_mod.js')

setInterval(() => require('./update_mod.js'), 100); 

对应的场景其实就是index.js中清理掉update_mod.js后,同样使用到的这个模块的utils.js也重新进行require引入保持使用最新的热更模块逻辑。

继续执行node index.js文件,可以看到这次又出现内存迅速溢出的现象:

update_mod 1 1000000
update_mod 2 1000000
rss: 34.59MB, heapUsed: 11.51MB
update_mod 1 1000000
rss: 110.20MB, heapUsed: 80.09MB
update_mod 1 1000000
...

rss: 921.63MB, heapUsed: 888.99MB
update_mod 1 1000000
rss: 998.09MB, heapUsed: 965.12MB
update_mod 1 1000000
update_mod 1 1000000

<--- Last few GCs --->

[53359:0x140008000]    13785 ms: Scavenge 1018.5 (1025.1) -> 1018.5 (1029.1) MB, 2.2 / 0.0 ms  (average mu = 0.785, current mu = 0.635) allocation failure 
[53359:0x140008000]    14344 ms: Mark-sweep (reduce) 1026.1 (1036.8) -> 1025.9 (1029.3) MB, 462.2 / 0.0 ms  (+ 87.7 ms in 89 steps since start of marking, biggest step 7.5 ms, walltime since start of marking 559 ms) (average mu = 0.667, current mu = 0.296

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

继续抓取堆快照进行分析:

image.gif图片.png

这次是在Module@37543children数组下有大量重复的热更模块upload_mod.js导致了内存泄露,我们来看下Module@37543的详细信息:

image.gif图片.png

是不是感觉很奇怪,clear-module明明清理掉了父模块对热更子模块的引用(反应到这个例子中是index.js这个父模块),但是utils.js里面却还保留了这么多旧引用呢?

其实这里是因为,Node.js 的模块实现机制里,子模块和父模块其实本质上是多对多的关系,而又因为模块缓存的机制,子模块仅会在第一次被加载的时候执行构造函数初始化:

image.gif图片.png

这样就意味着,clear-module里所谓的去掉父模块对热更模块的旧引用仅仅是第一次引入热更模块对应的这个父模块,在这个例子中就是index.js,所以index.js对应的children数组是干净的。

utils.js作为父模块引入热更模块时,读取的是热更模块最新版本的缓存,更新children引用:

image.gif图片.png

它会去判断这个缓存对象在children数组中不存在的话则加入进去,显然热更前后两次编译update_mod.js得到的内存对象不是同一个,因此在utils.js中产生了泄露。

至此在稍微复杂的点逻辑下,clear-module也败下阵来,考虑到实际开发中的逻辑负载度会比这个高很多,显然在生产中使用热更新,除非作者对模块机制掌控十分透彻,否则还是在给自己给后人挖坑。

留一个有趣的思考:clear-module在这种场景下的泄露也并非无解,有兴趣的同学可以参照原理思考下如何来规避在此场景下的热更内存泄露。

参考:

lodash

可能有同学会觉得上面这个例子还不够典型,我们来看一个开发者完全无法控制的非幂等子依赖模块因为热更而导致重复加载产生的内存泄露案例。

这里也不去为了构造内存泄露特意去找很偏门的包,我们就以周下载量高达 3900w 的非常常用的工具模块 lodash 为例,继续修改我们的 uploda_mod.js:

'use strict';

const lodash = require('lodash');
let count = 0;
module.exports = () => {
  console.log('update_mod', ++count);
};

接着在 index.js 中去掉上面的 utils.js,保持只对 update_mod.js 进行重复热更:

'use strict';

const cleanCache = require('clear-module');

let mod = require('./update_mod.js');
mod();
mod();

setInterval(() => {
  cleanCache('./update_mod.js');
  mod = require('./update_mod.js');
  mod();
}, 10);

function printMemory() {
  const { rss, heapUsed } = process.memoryUsage();
  console.log(`rss: ${(rss / 1024 / 1024).toFixed(2)}MB, heapUsed: ${(heapUsed / 1024 / 1024).toFixed(2)}MB`);
}

printMemory();
setInterval(printMemory, 1000);

然后执行 node index.js 文件,可以看到这次又双叕泄露了,随着 update_mod.js 热更,堆内存迅速上升最后 OOM。

在这个案例中,非幂等执行的子模块产生泄露的原因稍微复杂一些,涉及到 lodash 模块重复编译执行会造成闭包循环引用。

其实会发现,引入模块对开发者是不可控的,换句话说开发者是无法确认自己是否引入了可以幂等执行的公共模块,那么对于像 lodash 这种无法幂等执行的库,热更就会造成其产生内存泄露。

问题二:资源泄露

讲完了热更可能引发的内存问题场景,我们来看看热更会导致的另一类相对更加无解一些资源泄露问题。

我们依旧以简单的例子来进行说明,首先还是构造index.js

'use strict';

const cleanCache = require('clear-module');

let mod = require('./update_mod.js');

setInterval(() => {
  cleanCache('./update_mod.js');
  mod = require('./update_mod.js');
  console.log('-------- 热更新结束 --------')
}, 1000);

这次我们直接使用clear-module进行热更新操作,引入待热更模块update_mod.js如下:

'use strict';

const start = new Date().toLocaleString();

setInterval(() => console.log(start), 1000);

update_mod.js中我们创建了一个定时任务,以 1s 的间隔输出模块第一次被引入时的时间。

最后执行node index.js可以看到如下结果:

2022/1/21 上午9:37:29
-------- 热更新结束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
-------- 热更新结束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
2022/1/21 上午9:37:31
-------- 热更新结束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
2022/1/21 上午9:37:31
2022/1/21 上午9:37:32
-------- 热更新结束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
2022/1/21 上午9:37:31
2022/1/21 上午9:37:32
2022/1/21 上午9:37:33
-------- 热更新结束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
2022/1/21 上午9:37:31
2022/1/21 上午9:37:32
2022/1/21 上午9:37:33
2022/1/21 上午9:37:34

显然,clear-module虽然正确清除了热更模块旧引用,但是旧模块内部的定时任务并没有被一起回收进而产生了资源泄露。

实际上,这里的定时任务只是资源中的一种而已,包括socketfd在内的各种系统资源操作,均无法在仅仅清除掉旧模块引用的场景下自动回收。

问题三:ESM 喵喵喵?

不管是decache还是clear-module,都是在 Node.js 实现的 CommonJS 模块机制的基础上进行的热更逻辑整合。

但是整个前端发展到今天,原生 ECMA 规范定义的模块机制为 ESModule(简称 ESM),因为是规范定义的,所以其实现是在引擎层面,对应到 Node.js 这一层则是由 V8 实现的,因此目前的热更无法作用于 ESM 模块。

不过在我看来,基于 CommonJS 的热更因为实现在更加上层,会暗藏各种坑所以非常不推荐在生产中使用,但是基于 ESM 的热更如果规范能定义完整的模块加载和卸载机制,反而是真正的热更新方案的未来。

Node.js 在这一块也有对应的实验特性可以加以利用,详情参见:ESM Hooks。(nodejs.org/dist/latest… Stability: 1 的状态,需要持续观望下。

问题四:模块版本混乱

Node.js 的热更新实际上并不是很多同学想象中的那种全局旧模块替换,因为缓存机制可能会导致内存中同时存在多个被热更模块的不同版本,从而造成一些难以定位的奇怪 Bug。

我们继续构造一个小例子来进行说明,首先编写待热更模块update_mod.js

'use strict';

const version = 'v1';

module.exports = () => {
  return version;
};

然后添加一个utils.js来正常使用此模块:

'use strict';

const mod = require('./update_mod.js');

setInterval(() => console.log('utils', mod()), 1000);

接着编写启动入口index.js进行热更新操作:

'use strict';

const cleanCache = require('clear-module');

let mod = require('./update_mod.js');

require('./utils.js');

setInterval(() => {
  cleanCache('./update_mod.js');
  mod = require('./update_mod.js');
  console.log('index', mod())
}, 1000);

この時点で、実行してnode index.js変更しないと、次のupdate_mod.jsことがわかります。

utils v1
index v1
utils v1
index v1

メモリはupdate_mod.jsすべてのv1バージョンであることに注意してください。

今すぐサービスを再起動する必要はありません。update_mod.js変更していversionます:

// update_mod.js
const version = 'v2';

次に、出力が次のようになることを確認します。

index v1
utils v1
index v2
utils v1
index v2
utils v1

index.jsでホットアップデート操作が実行されたため、最新バージョンにrequire到着し、に変更はありません。update_mod.jsv2utils.js

モジュールの複数のバージョンのこのような状況は、オンライン障害の特定を困難にするだけでなく、ある程度のメモリリークを引き起こします。

ホットアップデートシナリオに適しています

シーン以外の問題について話すのはフーリガンです。ホットアップデートには非常に多くの問題がありますが、実際には非常にモジュールのホットアップデートの使用シナリオがあります。オンラインとオフラインの2つの側面からそれらについて説明します。

オフラインのシナリオでは、マイナーなメモリとリソースリークの問題が開発効率に影響を与える可能性があるため、ホットアップデートは開発モードでのフレームワークの単一モジュールのロードとアンロードに非常に適しています。

オンラインシナリオの場合、ホットアップデートは役に立たないわけではありません。たとえば、親と子が1対1のまとまりのあるロジックモジュールに依存し、リソース属性を作成しないことは明らかです。適切なコード編成を通じてホットプラグを実行して、シームレスを実現できます。アップデートのリリース。目的。

結局、一般的に、アプリケーションを汚染するリスクと不慣れによるホットアップデートの利点のために、ユーザーのオンライン本番環境でホットアップデートテクノロジーを使用することにはまだ比較的反対です。ESMモジュールがロードされている場合後で仕様に明確に準拠し、エンジンによって実装できるオフロードメカニズムを使用すると、ホットアップデートを実際に広く安全に使用するのに適切なタイミングになる可能性があります。

いくつかの要約

過去数年間のAliNodeのメンテナンスに参加する過程で、ホットアップデートによって引き起こされる多くのメモリリークに対処してきました。この記事を書く機会を得て、以前のケースを確認しました。

現在、ホットアップデートを実現しているモジュールは、実際には「黒魔術」の範疇にあると考えられます。「黒魔術」と比較すると、「黒魔術」は両刃の剣です。使用する前に注意が必要です。自分を傷つけるために。

元のリンク; click.aliyun.com/m/100034840…

この記事はAlibabaCloudのオリジナルコンテンツであり、許可なく複製することはできません。

おすすめ

転載: juejin.im/post/7117938639442542606
おすすめ