Configuración de un clúster de Node.js

Todos sabemos que Node.js es excelente para manejar muchos eventos de forma asíncrona, pero lo que mucha gente no sabe es que todo esto se hace en un solo hilo. Nodo...

Todos sabemos que Node.js es excelente para manejar muchos eventos de forma asíncrona, pero lo que mucha gente no sabe es que todo esto se hace en un solo hilo. En realidad, Node.js no tiene subprocesos múltiples, por lo que todas estas solicitudes solo se manejan en el ciclo de eventos de un solo subproceso.

Entonces, ¿por qué no aprovechar al máximo su procesador de cuatro núcleos mediante el uso de un clúster de Node.js? Esto iniciará múltiples instancias de su código para manejar aún más solicitudes. Esto puede parecer un poco difícil, pero en realidad es bastante fácil de hacer con el módulo grupo, que se introdujo en Node.js v0.8.

Obviamente, esto es útil para cualquier aplicación que pueda dividir el trabajo entre diferentes procesos, pero es especialmente importante para las aplicaciones que manejan muchas solicitudes de IO, como un sitio web.

Desafortunadamente, debido a las complejidades del procesamiento paralelo, agrupar una aplicación en un servidor no siempre es sencillo. ¿Qué hace cuando necesita múltiples procesos para escuchar en el mismo puerto? Recuerde que solo un proceso puede acceder a un puerto en un momento dado. La solución ingenua aquí es configurar cada proceso para escuchar en un puerto diferente y luego configurar Nginx para equilibrio de carga solicitudes entre los puertos .

Esta es una solución viable, pero requiere mucho más trabajo de instalación y configuración de cada proceso, y sin mencionar la configuración de Nginx. Con esta solución, solo está agregando más cosas para que las administre usted mismo.

En su lugar, puede bifurcar el proceso maestro en varios procesos secundarios (normalmente, tener un hijo por procesador). En este caso, los hijos pueden compartir un puerto con el padre (gracias a la comunicación entre procesos, o CIP), por lo que no No hay necesidad de preocuparse por administrar múltiples puertos.

Esto es exactamente lo que el módulo cluster hace por usted.

Trabajo con el módulo de clúster

La agrupación en clústeres de una aplicación es extremadamente simple, especialmente para el código del servidor web como proyectos Expresar. Todo lo que realmente necesitas hacer es esto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
var cluster = require('cluster');
var express = require('express');
var numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
    for (var i = 0; i < numCPUs; i++) {
        // Create a worker
        cluster.fork();
    }
} else {
    // Workers share the TCP connection in this server
    var app = express();

    app.get('/', function (req, res) {
        res.send('Hello World!');
    });

    // All workers use this port
    app.listen(8080);
}

La funcionalidad del código se divide en dos partes, el código maestro y el código trabajador. Esto se hace en la sentencia if (if (cluster.isMaster) {...}). El único propósito del maestro aquí es crear todos los trabajadores (la cantidad de trabajadores creados se basa en la cantidad de CPU disponibles), y los trabajadores son responsables de ejecutar instancias separadas del servidor Express.

Cuando un trabajador se bifurca del proceso principal, vuelve a ejecutar el código desde el principio del módulo. Cuando el trabajador llega a la declaración if, devuelve false para cluster.isMaster, por lo que en su lugar creará la aplicación Express, una ruta y luego escuchará en el puerto 8080. En el caso de un procesador de cuatro núcleos, tendríamos cuatro trabajadores generados, todos escuchando en el mismo puerto las solicitudes entrantes.

Pero, ¿cómo se reparten las solicitudes entre los trabajadores? Obviamente, no pueden (y no deberían) escuchar y responder a todas las solicitudes que recibimos. Para manejar esto, en realidad hay un balanceador de carga incorporado dentro del módulo clúster que maneja la distribución de solicitudes entre los diferentes trabajadores. En Linux y OSX (pero no en Windows), la política de operación por turnos (cluster.SCHED_RR) está en vigor de forma predeterminada. La única otra opción de programación disponible es dejarlo en manos del sistema operativo (cluster.SCHED_NONE), que es el predeterminado en Windows.

La política de programación se puede configurar en cluster.schedulingPolicy o configurándola en la variable de entorno NODE_CLUSTER_SCHED_POLICY (con valores de 'rr' o 'none').

También es posible que se pregunte cómo diferentes procesos pueden compartir un solo puerto. La parte difícil de ejecutar tantos procesos que manejan solicitudes de red es que, tradicionalmente, solo uno puede tener un puerto abierto a la vez. El gran beneficio de cluster es que maneja el puerto compartido por usted, por lo que cualquier puerto que tenga abierto, como para un servidor web, será accesible para todos los niños. Esto se hace a través de IPC, lo que significa que el maestro simplemente envía el identificador del puerto a cada trabajador.

Gracias a características como esta, la agrupación en clústeres es muy fácil.

cluster.fork() vs child_process.fork()

Si tiene experiencia previa con el método fork() de niño_proceso, entonces puede estar pensando que cluster.fork() es algo similar ( y lo son, en muchos sentidos), por lo que explicaremos algunas diferencias clave sobre estos dos métodos de bifurcación en esta sección.

Hay algunas diferencias principales entre cluster.fork() y child_process.fork(). El método child_process.fork() es un poco más bajo y requiere que pases la ubicación (ruta del archivo) del módulo como argumento, además de otros argumentos opcionales como el directorio de trabajo actual, el usuario propietario del proceso, variables de entorno, y más.

Otra diferencia es que cluster inicia la ejecución del trabajador desde el principio del mismo módulo desde el que se ejecutó. Entonces, si el punto de entrada de su aplicación es index.js, pero el trabajador se genera en cluster-my-app.js, entonces aún comenzará su ejecución desde el principio en index.js . child_process es diferente en el sentido de que genera la ejecución en cualquier archivo que se le pase, y no necesariamente en el punto de entrada de la aplicación dada.

Es posible que ya haya adivinado que el módulo cluster en realidad usa el módulo child_process debajo para crear los hijos, lo cual se hace con el propio método fork() de child_process, lo que les permite comunicarse a través de IPC, que es cómo se comparten los identificadores de puerto entre los trabajadores.

Para ser claros, bifurcar en Node es muy diferente a horquilla POISIX en que en realidad no clona el proceso actual, pero inicia una nueva instancia V8.

Aunque esta es una de las formas más sencillas de subprocesos múltiples, debe usarse con precaución. El hecho de que pueda generar 1,000 trabajadores no significa que deba hacerlo. Cada trabajador consume recursos del sistema, por lo que solo genera los que realmente se necesitan. Los documentos de Node indican que, dado que cada proceso secundario es una nueva instancia V8, debe esperar un tiempo de inicio de 30 ms para cada uno y al menos 10 MB de memoria por instancia.

Gestión de errores {#gestión de errores}

Entonces, ¿qué haces cuando muere uno (¡o más!) de tus trabajadores? Básicamente, el objetivo de la agrupación en clústeres se pierde si no puede reiniciar los trabajadores después de que fallan. Por suerte para usted, el módulo cluster extiende EventEmitter y proporciona un evento 'exit', que le indica cuándo muere uno de sus hijos trabajadores.

Puede usar esto para registrar el evento y reiniciar el proceso:

1
2
3
4
cluster.on('exit', function(worker, code, signal) {
    console.log('Worker %d died with code/signal %s. Restarting worker...', worker.process.pid, signal || code);
    cluster.fork();
});

Ahora, después de solo 4 líneas de código, ¡es como si tuviera su propio administrador de procesos interno!

Comparaciones de rendimiento {#comparaciones de rendimiento}

Bien, ahora a la parte interesante. Veamos cuánto nos ayuda realmente el agrupamiento.

Para este experimento, configuré una aplicación web similar al código de ejemplo que mostré arriba. Pero la mayor diferencia es que estamos simulando el trabajo que se realiza dentro de la ruta Express usando el módulo dormir y devolviendo un montón de datos aleatorios al usuario.

Aquí está la misma aplicación web, pero con agrupamiento:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var cluster = require('cluster');
var crypto = require('crypto');
var express = require('express');
var sleep = require('sleep');
var numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
    for (var i = 0; i < numCPUs; i++) {
        // Create a worker
        cluster.fork();
    }
} else {
    // Workers share the TCP connection in this server
    var app = express();

    app.get('/', function (req, res) {
        // Simulate route processing delay
        var randSleep = Math.round(10000 + (Math.random() * 10000));
        sleep.usleep(randSleep);

        var numChars = Math.round(5000 + (Math.random() * 5000));
        var randChars = crypto.randomBytes(numChars).toString('hex');
        res.send(randChars);
    });

    // All workers use this port
    app.listen(8080);
}

Y aquí está el código de 'control' a partir del cual haremos nuestras comparaciones. Es esencialmente exactamente lo mismo, solo que sin cluster.fork():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
var crypto = require('crypto');
var express = require('express');
var sleep = require('sleep');

var app = express();

app.get('/', function (req, res) {
    // Simulate route processing delay
    var randSleep = Math.round(10000 + (Math.random() * 10000));
    sleep.usleep(randSleep);

    var numChars = Math.round(5000 + (Math.random() * 5000));
    var randChars = crypto.randomBytes(numChars).toString('hex');
    res.send(randChars);
});

app.listen(8080);

Para simular una gran carga de usuarios, usaremos una herramienta de línea de comandos llamada Cerco, que podemos usar para realizar un montón de solicitudes simultáneas a la URL de nuestra eleccion.

Siege también es bueno porque realiza un seguimiento de las métricas de rendimiento, como la disponibilidad, el rendimiento y la tasa de solicitudes manejadas.

Aquí está el comando Siege que usaremos para las pruebas:

1
$ siege -c100 -t60s http://localhost:8080/

Después de ejecutar este comando para ambas versiones de la aplicación, estos son algunos de los resultados más interesantes:


Tipo Solicitudes totales procesadas Solicitudes/segundo Tiempo de respuesta promedio Rendimiento Sin agrupación 3467 58,69 1,18 s 0,84 MB/s Agrupación (4 procesos) 11146 188,72 0,03 s 2,70 MB/s


Como puede ver, la aplicación agrupada tiene una mejora de alrededor de 3,2 veces con respecto a la aplicación de un solo proceso para casi todas las métricas enumeradas, excepto el tiempo de respuesta promedio, que tiene una mejora mucho más significativa.