Cómo agregar soporte de clúster a Node.js

Cómo utilizar el módulo de clúster de Node.js para aprovechar un procesador multinúcleo en su entorno de producción.

Por naturaleza, JavaScript es un lenguaje de subproceso único. Esto significa que cuando le dice a JavaScript que complete un conjunto de instrucciones (por ejemplo, cree un elemento DOM, maneje un clic de botón o en Node.js para leer un archivo del sistema de archivos), maneja cada una de esas instrucciones una a la vez. tiempo, de forma lineal.

Lo hace independientemente de la computadora en la que se esté ejecutando. Si su computadora tiene un procesador de 8 núcleos y 64 GB de RAM, cualquier código JavaScript que ejecute en esa computadora se ejecutará en un solo hilo o núcleo.

Las mismas reglas se aplican en una aplicación Node.js. Debido a que Node.js se basa en el motor de JavaScript V8, las mismas reglas que se aplican a JavaScript se aplican a Node.js.

Cuando está creando una aplicación web, esto puede causar dolores de cabeza. A medida que su aplicación crece en popularidad (o complejidad) y necesita manejar más solicitudes y trabajo adicional, si solo confía en un único subproceso para manejar ese trabajo, se encontrará con cuellos de botella:solicitudes descartadas, servidores que no responden, o interrupciones del trabajo que ya se estaba ejecutando en el servidor.

Afortunadamente, Node.js tiene una solución para esto:el cluster módulo.

El cluster El módulo nos ayuda a aprovechar todo el poder de procesamiento de una computadora (servidor) al distribuir la carga de trabajo de nuestra aplicación Node.js. Por ejemplo, si tenemos un procesador de 8 núcleos, en lugar de aislar nuestro trabajo en un solo núcleo, podemos distribuirlo en los ocho núcleos.

Usando cluster , nuestro primer núcleo se convierte en el "maestro" y todos los núcleos adicionales se convierten en "trabajadores". Cuando llega una solicitud a nuestra aplicación, el proceso maestro realiza una verificación de estilo round-robin preguntando "¿qué trabajador puede manejar esta solicitud en este momento?" El primer trabajador que cumple con los requisitos recibe la solicitud. Enjuague y repita.

Configurando un servidor de ejemplo

Para comenzar y brindarnos un poco de contexto, configuraremos una aplicación Node.js simple usando Express como un servidor HTTP. Queremos crear una nueva carpeta en nuestra computadora y luego ejecutar:

npm init --force && npm i express

Esto inicializará nuestro proyecto usando NPM, el administrador de paquetes de Node.js, y luego instalará el express Paquete NPM.

Una vez que esto esté completo, querremos crear un index.js archivo en nuestra nueva carpeta de proyecto:

/index.js

import express from "express";

const app = express();

app.use("/", (req, res) => {
  res.send(
    `"Sometimes a slow gradual approach does more good than a large gesture." - Craig Newmark`
  );
});

app.listen(3000, () => {
  console.log("Application running on port 3000.");
});

Aquí, import express from 'express' para tirar express en nuestro código. A continuación, creamos una instancia de express llamando a esa importación como una función y asignándola a la variable app .

A continuación, definimos una ruta simple en la raíz / de nuestra aplicación con app.use() y devuelva un texto para asegurarse de que todo funciona (esto es solo para mostrar y no tendrá ningún efecto real en la implementación de nuestro clúster).

Finalmente, llamamos a app.listen() pasando 3000 como el puerto (podremos acceder a la aplicación en ejecución en http://localhost:3000 en nuestro navegador después de iniciar la aplicación). Aunque el mensaje en sí no es muy importante, como segundo argumento para app.listen() pasamos una función de devolución de llamada para cerrar la sesión de un mensaje cuando se inicia nuestra aplicación. Esto será útil cuando necesitemos verificar si nuestro soporte de clúster funciona correctamente.

Para asegurarse de que todo esto funcione, en su terminal, cd en la carpeta del proyecto y luego ejecute node index.js . Si ve lo siguiente, ya está todo listo:

$ node index.js
Application running on port 3000.

Agregar soporte de clúster a Node.js

Ahora que tenemos nuestra aplicación de ejemplo lista, podemos comenzar a implementar cluster . La buena noticia es que el cluster El paquete está incluido en el núcleo de Node.js, por lo que no necesitamos instalar nada más.

Para mantener las cosas limpias, crearemos un archivo separado para nuestro código relacionado con el clúster y usaremos un patrón de devolución de llamada para vincularlo con el resto de nuestro código.

/clúster.js

import cluster from "cluster";
import os from "os";

export default (callback = null) => {
  const cpus = os.cpus().length;

  if (cluster.isMaster) {
    for (let i = 0; i < cpus; i++) {
      const worker = cluster.fork();

      worker.on("message", (message) => {
        console.log(`[${worker.process.pid} to MASTER]`, message);
      });
    }

    cluster.on("exit", (worker) => {
      console.warn(`[${worker.process.pid}]`, {
        message: "Process terminated. Restarting.",
      });

      cluster.fork();
    });
  } else {
    if (callback) callback();
  }
};

Comenzando desde arriba, importamos dos dependencias (ambas incluidas con Node.js y no). debe instalarse por separado):cluster y os . El primero nos da acceso al código que necesitaremos para administrar nuestro clúster de trabajo y el segundo nos ayuda a detectar la cantidad de núcleos de CPU disponibles en la computadora donde se ejecuta nuestro código.

Justo debajo de nuestras importaciones, a continuación, export la función que llamaremos desde nuestro index.js principal archivo más tarde. Esta función es responsable de configurar nuestro soporte de clúster. Como argumento, tome nota de nuestra expectativa de un callback función que se está pasando. Esto será útil más adelante.

Dentro de nuestra función, usamos el mencionado os paquete para comunicarse con la computadora donde se ejecuta nuestro código. Aquí, llamamos a os.cpus().length esperando os.cpus() para devolver una matriz y luego medir la longitud de esa matriz (que representa la cantidad de núcleos de CPU en la computadora).

Con ese número, podemos configurar nuestro Cluster. Todas las computadoras modernas tienen un mínimo de 2 a 4 núcleos, pero tenga en cuenta que la cantidad de trabajadores creados en su computadora diferirá de lo que se muestra a continuación. Lee:no entres en pánico si tu número es diferente.

/clúster.js

[...]

  if (cluster.isMaster) {
    for (let i = 0; i < cpus; i++) {
      const worker = cluster.fork();

      worker.on("message", (message) => {
        console.log(`[${worker.process.pid} to MASTER]`, message);
      });
    }

    cluster.on("exit", (worker) => {
      console.warn(`[${worker.process.pid}]`, {
        message: "Process terminated. Restarting.",
      });

      cluster.fork();
    });
  }

[...]

Lo primero que debemos hacer es verificar si el proceso en ejecución es la instancia maestra de nuestra aplicación o no. uno de los trabajadores que crearemos a continuación. Si es la instancia maestra, hacemos un ciclo for por la longitud del cpus matriz que determinamos en el paso anterior. Aquí, decimos "mientras el valor de i (nuestra iteración de bucle actual) es menor que la cantidad de CPU que tenemos disponibles, ejecute el siguiente código".

El siguiente código es cómo creamos nuestros trabajadores. Para cada iteración de nuestro for bucle, creamos una instancia de trabajador con cluster.fork() . Esto bifurca el proceso maestro en ejecución y devuelve una nueva instancia secundaria o de trabajo.

A continuación, para ayudarnos a transmitir mensajes entre los trabajadores que creamos y nuestra instancia maestra, agregamos un detector de eventos para el message event al trabajador que creamos, dándole una función de devolución de llamada.

Esa función de devolución de llamada dice "si uno de los trabajadores envía un mensaje, retransmitirlo al maestro". Entonces, aquí, cuando un trabajador envía un mensaje, esta función de devolución de llamada maneja ese mensaje en el proceso maestro (en este caso, desconectamos el mensaje junto con el pid del trabajador que lo envió).

Esto puede ser confuso. Recuerde, un trabajador es una instancia en ejecución de nuestra aplicación. Entonces, por ejemplo, si ocurre algún evento dentro de un trabajador (ejecutamos una tarea en segundo plano y falla), necesitamos una forma de saberlo.

En la siguiente sección, veremos cómo enviar mensajes desde dentro de un trabajador que aparecerá en esta función de devolución de llamada.

Sin embargo, un detalle más antes de continuar. Hemos agregado un controlador de eventos adicional aquí, pero esta vez, estamos diciendo "si el clúster (es decir, cualquiera de los procesos de trabajo en ejecución) recibe un evento de salida, manéjelo con esta devolución de llamada". La parte de "manejo" aquí es similar a lo que hicimos antes, pero con un ligero cambio:primero, desconectamos un mensaje junto con el pid del trabajador. para hacernos saber que el trabajador murió. Luego, para garantizar que nuestro clúster se recupere (lo que significa que mantenemos la cantidad máxima de procesos en ejecución disponibles para nosotros en función de nuestra CPU), reiniciamos el proceso con cluster.fork() .

Para ser claros:solo llamaremos cluster.fork() así si un proceso muere.

/clúster.js

import cluster from "cluster";
import os from "os";

export default (callback = null) => {
  const cpus = os.cpus().length;

  if (cluster.isMaster) {
    for (let i = 0; i < cpus; i++) {
      const worker = cluster.fork();

      // Listen for messages FROM the worker process.
      worker.on("message", (message) => {
        console.log(`[${worker.process.pid} to MASTER]`, message);
      });
    }

    cluster.on("exit", (worker) => {
      console.warn(`[${worker.process.pid}]`, {
        message: "Process terminated. Restarting.",
      });

      cluster.fork();
    });
  } else {
    if (callback) callback();
  }
};

Un detalle más. Terminando con nuestro código de clúster, en la parte inferior de nuestra función exportada agregamos un else declaración para decir "si este código es no se está ejecutando en el proceso maestro, llame a la devolución de llamada pasada si hay una".

Necesitamos hacer esto porque solo queremos que nuestra generación de trabajadores tenga lugar dentro del proceso maestro, no en ninguno de los procesos de trabajo (de lo contrario, tendríamos un ciclo infinito de creación de procesos que nuestra computadora no estaría encantada).

Poner el clúster de Node.js en uso en nuestra aplicación

Bien, ahora la parte fácil. Con nuestro código de clúster configurado en el otro archivo, regresemos a nuestro index.js archivar y configurar todo:

/index.js

import express from "express";
import favicon from "serve-favicon";
import cluster from "./cluster.js";

cluster(() => {
  const app = express();

  app.use(favicon("public/favicon.ico"));

  app.use("/", (req, res) => {
    if (process.send) {
      process.send({ pid: process.pid, message: "Hello!" });
    }

    res.send(
      `"Sometimes a slow gradual approach does more good than a large gesture." - Craig Newmark`
    );
  });

  app.listen(3000, () => {
    console.log(`[${process.pid}] Application running on port 3000.`);
  });
});

Hemos agregado un poco aquí, así que vayamos paso a paso.

Primero, hemos importado nuestro cluster.js archivar arriba como cluster . A continuación, llamamos a esa función, pasándole una función de devolución de llamada (este será el valor del callback argumento en la función exportada por cluster.js ).

Dentro de esa función, hemos colocado todo el código que escribimos en index.js antes, con algunas modificaciones.

Inmediatamente después de crear nuestro app instancia con express() , arriba notarás que estamos llamando a app.use() , pasándole otra llamada a favicon("public/favicon.ico") . favicon() es una función del serve-favicon dependencia agregada a las importaciones en la parte superior del archivo.

Esto es para reducir la confusión. De forma predeterminada, cuando visitamos nuestra aplicación en un navegador, el navegador realizará dos solicitudes:una para la página y otra para el favicon.ico de la aplicación. expediente. Saltando adelante, cuando llamamos a process.send() dentro de la devolución de llamada de nuestra ruta, queremos asegurarnos de que no recibamos la solicitud del favicon.ico archivo en además a nuestra ruta.

Donde esto se vuelve confuso es cuando enviamos mensajes de nuestro trabajador. Debido a que nuestra ruta recibe dos solicitudes, terminaremos recibiendo dos mensajes (que pueden parecer que las cosas están rotas).

Para manejar esto, importamos favicon de serve-favicon y luego agregue una llamada a app.use(favicon("public/favicon.ico")); . Después de agregar esto, también debe agregar un public carpeta a la raíz del proyecto y coloque un favicon.ico vacío archivo dentro de esa carpeta .

Ahora, cuando las solicitudes ingresen a la aplicación, solo recibiremos un mensaje como favicon.ico la solicitud se manejará a través del favicon() software intermedio.

Continuando, notará que hemos agregado algo arriba de nuestro res.send() llame a nuestra raíz / ruta:

if (process.send) {
  process.send({ pid: process.pid, message: "Hello!" });
}

Esto es importante. Cuando trabajamos con una configuración de clúster en Node.js, debemos tener en cuenta la comunicación entre procesos o IPC. Este es un término que se usa para describir la comunicación, o mejor dicho, la capacidad de comunicarse, entre la instancia maestra de nuestra aplicación y los trabajadores.

Aquí, process.send() es una forma de enviar un mensaje desde una instancia de trabajo de vuelta a la instancia maestra. ¿Por qué es eso importante? Bueno, porque los procesos de trabajo son bifurcaciones del proceso principal, queremos tratarlos como si fueran hijos del proceso maestro. Si algo sucede dentro de un trabajador en relación con la salud o el estado del clúster, es útil tener una forma de notificar al proceso maestro.

Donde esto puede resultar confuso es que no hay una indicación clara de que este código está relacionado con un trabajador.

Lo que debe recordar es que un trabajador es solo el nombre que se usa para describir una instancia adicional de nuestra aplicación, o aquí, en términos más simples, nuestro servidor Express.

Cuando decimos process aquí, nos referimos al proceso actual de Node.js que ejecuta este código. Eso podría ser nuestra instancia maestra o podría ser una instancia de trabajo.

Lo que separa a los dos es el if (process.send) {} declaración. Hacemos esto porque nuestra instancia maestra no tener un .send() método disponible, solo nuestras instancias de trabajo. Cuando llamamos a este método, el valor que pasamos a process.send() (aquí estamos pasando un objeto con un pid y message , pero puede pasar lo que quiera) aparece en el worker.on("message") controlador de eventos que configuramos en cluster.js :

/clúster.js

worker.on("message", (message) => {
  console.log(`[${worker.process.pid} to MASTER]`, message);
});

Ahora esto debería tener un poco más de sentido (específicamente el to MASTER parte). No es necesario que mantenga esto en su propio código, pero ayuda a explicar cómo se comunican los procesos.

Ejecutando nuestro servidor en clúster

Último paso. Para probar las cosas, ejecutemos nuestro servidor. Si todo está configurado correctamente, desde la carpeta del proyecto en su terminal, ejecute node index.js (nuevamente, tenga en cuenta la versión de Node.js que está ejecutando):

$ node index.js
[25423] Application running on port 3000.
[25422] Application running on port 3000.
[25425] Application running on port 3000.
[25426] Application running on port 3000.
[25424] Application running on port 3000.
[25427] Application running on port 3000.

Si todo funciona, debería ver algo similar. Los números de la izquierda representan los ID de proceso de cada instancia generada, en relación con la cantidad de núcleos en su CPU. Aquí, mi computadora tiene un procesador de seis núcleos, así que obtengo seis procesos. Si tuviera un procesador de ocho núcleos, esperaría ver ocho procesos.

Finalmente, ahora que nuestro servidor se está ejecutando, si abrimos http://localhost:3000 en nuestro navegador y luego verifique nuevamente en nuestra terminal, deberíamos ver algo como:

[25423] Application running on port 3000.
[25422] Application running on port 3000.
[25425] Application running on port 3000.
[25426] Application running on port 3000.
[25424] Application running on port 3000.
[25427] Application running on port 3000.
[25423 to MASTER] { pid: 25423, message: 'Hello!' }

La última declaración de registro es el mensaje recibido en nuestro worker.on("message") controlador de eventos, enviado por nuestra llamada a process.send() en la devolución de llamada para nuestra raíz / controlador de ruta (que se ejecuta cuando visitamos nuestra aplicación en http://localhost:3000 ).

¡Eso es!

Terminando

Anteriormente, aprendimos cómo configurar un servidor Express simple y convertirlo de un proceso Node.js de ejecución única a una configuración multiproceso en clúster. Con esto, ahora podemos escalar nuestras aplicaciones utilizando menos hardware aprovechando toda la potencia de procesamiento de nuestro servidor.