淺談 Node-js 熱更新

記得在 15 16 年那會 Node.js 剛起步的時候,我在去前東家的入職面試也被問到了要如何實現 Node.js 服務的熱更新。

其實早期從 Php-fpm / Fast-cgi 轉過來的 Noder,肯定非常喜歡這種更新業務邏輯代碼無需重啓服務器即可生效的部署方案,它的優勢也非常明顯:

熱更新的副作用也非常多,比如常見的內存泄露(資源泄露),本文將以 clear-module 和 decache 這兩個下載量比較高的熱門熱更輔助模塊來探討下熱更究竟會給我們的應用帶來哪些問題。

熱更實現原理

在開始談熱更新的問題之前,我們首先要了解下 Node.js 的模塊機制的概貌,這樣對於後面它帶來的問題將能有更加深刻的理解和認識。

Node.js 自己實現的模塊加載機制如下圖所示:


簡單地說父模塊 A 引入子模塊 B 的步驟如下:

其實到了這裏,我們已經可以發現要實現沒有內存泄露的熱更新,需要斷開待熱更模塊的以下引用鏈路:


這樣當我們再次去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

抓取堆快照後進行分析:

很明顯Module@39215children數組中大量塞入了重複的熱更模塊update_mod.js的編譯結果導致了內存泄露,而進一步查看Module@39215信息:

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

閱讀decache實現源代碼後發現,產生泄露的原因則是我們在熱更實現原理一節中提到的要去掉全部的三條引用,而遺憾的是decache仍然只斷開了最基礎的require.cache這一條引用鏈路:

至此,decache由於最基本的熱更內存問題都尚未解決,白瞎了其 94w 的月下載量,可以直接排出我們的熱更方案參考。

參考:

  • decache 問題源碼實際位置:https://github.com/dwyl/decache/blob/main/decache.js#L35

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確實將父模塊對子模塊的引用也一併清除:

因此這個例子中熱更不會導致進程內存泄露 OOM。

詳細代碼可以參見:https://github.com/sindresorhus/clear-module/blob/main/index.js#L25-L31

那麼是不是認爲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

繼續抓取堆快照進行分析:


這次是在Module@37543children數組下有大量重複的熱更模塊upload_mod.js導致了內存泄露,我們來看下Module@37543的詳細信息:


是不是感覺很奇怪,clear-module明明清理掉了父模塊對熱更子模塊的引用(反應到這個例子中是index.js這個父模塊),但是utils.js裏面卻還保留了這麼多舊引用呢?

其實這裏是因爲,Node.js 的模塊實現機制裏,子模塊和父模塊其實本質上是多對多的關係,而又因爲模塊緩存的機制,子模塊僅會在第一次被加載的時候執行構造函數初始化:


這樣就意味着,clear-module裏所謂的去掉父模塊對熱更模塊的舊引用僅僅是第一次引入熱更模塊對應的這個父模塊,在這個例子中就是index.js,所以index.js對應的children數組是乾淨的。

utils.js作爲父模塊引入熱更模塊時,讀取的是熱更模塊最新版本的緩存,更新children引用:


它會去判斷這個緩存對象在children數組中不存在的話則加入進去,顯然熱更前後兩次編譯update_mod.js得到的內存對象不是同一個,因此在utils.js中產生了泄露。

至此在稍微複雜的點邏輯下,clear-module也敗下陣來,考慮到實際開發中的邏輯負載度會比這個高很多,顯然在生產中使用熱更新,除非作者對模塊機制掌控十分透徹,否則還是在給自己給後人挖坑。

留一個有趣的思考clear-module在這種場景下的泄露也並非無解,有興趣的同學可以參照原理思考下如何來規避在此場景下的熱更內存泄露。

參考:

  • 設置父模塊: https://github.com/nodejs/node/blob/v16.13.2/lib/internal/modules/cjs/loader.js#L176

  • 更新引用: https://github.com/nodejs/node/blob/v16.13.2/lib/internal/modules/cjs/loader.js#L167

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。(https://nodejs.org/dist/latest/docs/api/esm.html#esm_hooks) 不過目前其僅處於 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.js變成了最新的v2版本,而utils.js中並不會有任何變化。

類似這種一個模塊多個版本的狀況,不僅會增加線上故障的問題定位難度,某種程度上,它也造成了內存泄露。

適合熱更新的場景

拋開場景談問題都是耍流氓,雖然寫了這麼多熱更新存在的問題,但是確實也有非常模塊熱更新的使用場景,我們從線上和線下兩個維度來探討下。

對於線下場景,輕微的內存和資源的泄露問題可以讓位於開發效率,所以熱更新非常適合於框架在 dev 模式下的單模塊加載與卸載。

而對於線上場景,熱更新也並非一無用處,比如明確父子依賴一對一且不創建資源屬性的內聚邏輯模塊,可以通過合適的代碼組織來進行熱插拔,達到無縫發佈更新的目的。

最後總的來說,因爲不熟悉而給應用下毒的風險與熱更的收益,就目前我個人還是比較反對將熱更新技術用戶線上的生產環境中;而如果後面對 ESM 模塊的加載與卸載機制能明確下沉至規範由引擎實現,可能纔是熱更新真正可以廣泛和安全使用的恰當時機。

一些總結

前幾年參與維護 AliNode 的過程中,處理了多起熱更新引起的內存泄露問題,恰好藉着編寫本文的機會對以前的種種案例進行了回顧。

目前實現熱更新的模塊其實都可以歸結到 “黑魔法” 一類中,與 “黑科技” 相比,“黑魔法” 是一把雙刃劍,使用之前還需要謹慎切勿傷到自己。


Alibaba F2E 阿里巴巴前端官方公衆號

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/xCoTeyJDifi4Ymk0VsI2Ig