Hablando de la actualización en caliente de Node.js

Introducción: recuerdo que cuando Node.js recién comenzaba en 2015 y 16, también me preguntaron cómo implementar la actualización en caliente de los servicios de Node.js durante la entrevista de trabajo en mi antiguo empleador.

foto.png

foto.png

Recuerdo que cuando Node.js recién comenzaba en 2015 y 16, también me preguntaron cómo implementar la actualización en caliente de los servicios de Node.js en la entrevista de trabajo en mi antiguo empleador.

De hecho, a Noder, que fue transferido de Php-fpm / Fast-cgi en los primeros días, definitivamente le gusta este esquema de implementación que actualiza el código de lógica empresarial sin reiniciar el servidor. Sus ventajas también son muy obvias:

  • No es necesario reiniciar el servicio, lo que significa que la conexión del usuario no se interrumpirá, especialmente para aplicaciones con una gran cantidad de enlaces prolongados.
  • La caché de carga de actualización de archivos es un proceso muy rápido que puede completar las actualizaciones de la aplicación en milisegundos

También hay muchos efectos secundarios de las actualizaciones activas, como fugas de memoria comunes (fugas de recursos).Este artículo utilizará clear-module y decache, dos módulos auxiliares de actualización activa populares con descargas relativamente altas, para discutir qué actualización activa le dará a nuestra aplicación .que problemas surgen.

El principio del reemplazo térmico.

Antes de comenzar a hablar sobre actualizaciones importantes, primero debemos comprender la descripción general del mecanismo del módulo de Node.js, para que podamos tener una comprensión más profunda de los problemas que trae más adelante.

El mecanismo de carga de módulos implementado por el propio Node.js se muestra en la siguiente figura:

imagen.giffoto.png

En pocas palabras, los pasos para que el módulo principal A introduzca el submódulo B son los siguientes:

  • Determinar si existe caché del submódulo B

  • Si no existe, compile y analice B

  • Agregar caché del módulo B a require.cache(donde la clave es la ruta completa del módulo B)

  • Agregue la referencia del módulo B a la matriz del módulo principal childrenA

  • Si existe, juzgue si B existe en la matriz del módulo principal A children, si no, agregue la referencia del módulo B.

De hecho, en este punto, ya podemos encontrar que para lograr una actualización en caliente sin pérdida de memoria, necesitamos desconectar los siguientes enlaces de referencia del módulo que se actualizará en caliente:

imagen.giffoto.png

De esta forma, cuando volvamos al requiresubmódulo B, el contenido del módulo B se leerá del disco nuevamente y luego se compilará e importará a la memoria, dándose cuenta de la capacidad de actualización en caliente.

实际上,第一节中提到的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

抓取堆快照后进行分析:

imagen.giffoto.png

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

imagen.giffoto.png

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

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

imagen.giffoto.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);

En este punto cuando ejecutamos y node index.jsno cambiamos podemos update_mod.jsver:

utils v1
index v1
utils v1
index v1

Tenga en cuenta que la memoria es update_mod.jstodas las v1versiones.

No es necesario reiniciar el servicio en este momento, estamos update_mod.jsmodificando version:

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

Luego observe que la salida se convierte en:

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

index.jsSe realizó una operación de actualización en caliente en , por lo que volvió a requirellegar update_mod.jsa la última v2versión y utils.jsno habrá cambios en .

Tal situación de múltiples versiones de un módulo no solo aumenta la dificultad de localizar fallas en línea, sino que también causa pérdidas de memoria hasta cierto punto.

Adecuado para escenarios de actualización en caliente

Hablar del problema aparte de la escena es un hooligan. Aunque hay tantos problemas con las actualizaciones activas, de hecho hay escenarios de uso de actualizaciones activas de módulos. Los discutiremos desde dos dimensiones, en línea y fuera de línea.

Para escenarios fuera de línea, los problemas menores de pérdida de memoria y recursos pueden dar paso a la eficiencia del desarrollo, por lo que la actualización en caliente es muy adecuada para la carga y descarga de un solo módulo del marco en modo de desarrollo.

Para escenarios en línea, las actualizaciones en caliente no son inútiles. Por ejemplo, está claro que los padres y los niños dependen de módulos lógicos cohesivos uno a uno y no crean atributos de recursos. La conexión en caliente se puede realizar a través de la organización de código adecuada para lograr una perfecta publicación de actualizaciones Propósito.

Al final, en general, debido al riesgo de envenenar la aplicación y los beneficios de la actualización en caliente debido a la falta de familiaridad, todavía me opongo relativamente al uso de la tecnología de actualización en caliente en el entorno de producción en línea para los usuarios; y si el módulo ESM está cargado más tarde Con un mecanismo de descarga que claramente puede adaptarse a la especificación e implementado por el motor, puede ser el momento adecuado para que las actualizaciones en caliente se usen realmente de manera amplia y segura.

algún resumen

En el proceso de participación en el mantenimiento de AliNode en los últimos años, he lidiado con muchas fugas de memoria causadas por actualizaciones calientes, solo aproveché la oportunidad para escribir este artículo para revisar los casos anteriores.

En la actualidad, los módulos que logran la actualización en caliente se pueden atribuir a la categoría de "magia negra". En comparación con la "tecnología negra", la "magia negra" es una espada de doble filo. Antes de usarla, debe tener cuidado de no lastimarte a ti mismo

Enlace original; click.aliyun.com/m/100034840…

Este artículo es contenido original de Alibaba Cloud y no se puede reproducir sin permiso.

Supongo que te gusta

Origin juejin.im/post/7117938639442542606
Recomendado
Clasificación