pinchito.es

Modo cluster para node.js

Imagen: OmegaCen/Astro-WISE/Kapteyn Institute

¿Quieres habilitar el modo cluster en node.js? En este artículo veremos cómo, completo con código y detalles.

¿Por qué necesito el modo cluster?

El funcionamiento normal de node.js es en modo mono-procesador: un hilo de ejecución, un solo proceso. Lo de tener un hilo es la esencia de node.js: en lugar de correr varios hilos o threads, usamos el procesamiento asíncrono para procesar múltiples peticiones a la vez. Por otra parte, tener un solo proceso puede ser una limitación muy grande.

Hoy en día los procesadores multi-núcleo han dejado de ser la joya de los datacenters para convertirse en algo rutinario: es normal llevar en la mochila un dispositivo con dos núcleos o cores, y a menudo otro en los bolsillos. En Amazon AWS se pueden alquilar servidores con 8 CPUs virtuales por unos €350/mes (precios de hoy). Prácticamente cualquier cosa más grande que una Raspberry Pi tiene más de un núcleo. Sin embargo, nuestro node.js por defecto corre como un único proceso en el servidor, usando un único core o procesador. ¿Cómo podemos dejar de desaprovechar el 50% (o el 87.5%) de estas máquinas?

Activando el modo cluster

Node tiene un API de cluster bastante completo. A día de hoy (v0.10.5), el API está marcado como Stability: 1 - Experimental. Esto puede echar para atrás a cualquiera que necesite usarlo en producción.

Para los valientes, hay que decir que este API está presente desde la versión v0.6.x, y funciona de forma robusta desde la versión v0.8.x al menos. Marcarlo como experimental permite a los desarrolladores hacer cambios entre versiones sin tener que dar explicaciones. Así que cuidado con cambios futuros; como siempre, hay que hacer pruebas exhaustivas antes de desplegar una nueva versión de node.js.

Maestro y trabajadores

Uno de los secretos a voces de node.js es que adopta muchos principios de Unix, llevándolos a su propio terreno. En este caso la creación de procesos es muy similar al modelo de Unix: un sencillo fork() crea una copia del proceso actual. A partir de ese momento el primer proceso se convierte en maestro o master, y la copia en un trabajador o worker. Es similar a cómo funcionan nginx o Apache, y a otros programas multi-proceso Linux.

Al turrón

Vamos a ver el código necesario para usar el modo cluster. El ejemplo que vamos a comentar está sacado directamente de la documentación de node.js, con mínimas adaptaciones: un servidor HTTP que devuelve siempre la cadena hola, mundo.

Primero un par de requires, uno para cluster y otro para el servidor HTTP:

var cluster = require('cluster');
var http = require('http');

Crearemos tantos workers como CPUs tengamos en el sistema:

var numCPUs = require('os').cpus().length;

Es habitual hacer que el proceso master se dedique únicamente a gestionar a los workers, y que sean los workers los que hagan el trabajo sucio. El patrón es siempre el mismo:

if (cluster.isMaster)
{
  // crea workers
}
else
{
  // abre el servidor
}

Primero creamos los workers en el proceso maestro:

if (cluster.isMaster)
{
  // crea workers, uno por CPU
  for (var i = 0; i < numCPUs; i++)
  {
    cluster.fork();
  }

Ahora controlamos la salida de los workers:

  cluster.on('exit', function(worker, code, signal)
  {
    console.log('worker ' + worker.process.pid + ' died');
  });
}

Eso es todo lo que tiene que hacer el master. En los workers (es decir, cluster.isMaster es falso) creamos un servidor HTTP:

else
{
  // crea un servidor HTTP
  http.createServer(function(req, res)
  {
    res.writeHead(200);
    res.end("hola, mundo\n");
  }).listen(8000);
}

¡Y listo! El código real que usamos en MediaSmart Mobile es muy similar a éste: poco más hace falta para poner un servidor en producción. En el resto de este artículo vamos a ver otros detalles que pueden ser útiles.

Conexiones compartidas

El modo de uso más popular de node.js es como servidor: ponerlo a escuchar por un puerto y responder peticiones. ¿Cómo podemos conseguir que varios workers escuchen por el mismo puerto? ¿Tendremos que hacer que el proceso master reciba las peticiones y las despache a los workers usando algún algoritmo ingenioso?

Un momento: el código que acabamos de ver crea un servidor por cada worker directamente, sin pararse a pensar. Vamos a verlo otra vez en contexto:

if (cluster.isWorker)
{
  // crea un servidor HTTP
  http.createServer(function(req, res)
  {
    res.writeHead(200);
    res.end("hola mundo\n");
  }).listen(8000);
}

¿Acaso el cluster se encarga de hacer el reparto? La realidad es mucho mejor: el propio core de node.js va a hacer para nosotros el trabajo duro. Si varios workers comparten una conexión TCP, repartirá las peticiones entrantes entre los procesos que escuchan por el mismo puerto. Nosotros no tenemos nada que hacer: es una de las cosas que más nos gustan del modo cluster.

El algoritmo que usa no es round robin, o sea un reparto equitativo entre procesos. Más bien se tiende a cargar más unos cuantos procesos, dejando el resto más libres. Un servidor de producción con varios procesos de node y carga media tiene esta pinta:

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+ COMMAND
 6633 dilbert   20   0  824m 123m 6284 S   77  1.8   2:11.64 node
 6764 dilbert   20   0  810m 129m 6276 S   76  1.9   0:32.71 node
 6429 dilbert   20   0  885m 111m 6284 S   70  1.6   2:49.19 node
 6696 dilbert   20   0  813m 127m 6284 R   69  1.8   1:24.06 node
 6148 dilbert   20   0  894m 114m 6284 R   59  1.6   4:36.41 node
 6362 dilbert   20   0  892m 109m 6284 R   57  1.6   3:23.42 node
 6233 dilbert   20   0  900m 151m 6296 R   51  2.2   4:22.03 node
 6297 dilbert   20   0  898m 150m 6296 S   40  2.2   3:53.95 node

Supervisión de trabajadores

Una técnica útil es hacer que el master siga la pista de cuándo se mueren los workers, por ejemplo por errores imprevistos, para crear más. El código de la documentación es muy elocuente:

cluster.on('exit', function(worker, code, signal)
{
  console.log('Se ha muerto el worker %s, reiniciando', worker.process.pid);
  cluster.fork();
});

Una técnica interesante es matar directamente los workers después de un tiempo determinado, y recrearlos con un nuevo fork(). De esta forma evitamos que pequeñas filtraciones de memoria o memory leaks afecten a la ejecución de nuestro servidor. No es broma; IBM llamó a esta técnica rejuvenation hace unos años, y la usa en sus servidores de gama alta. En otros ámbitos podría considerarse una mera chapuza para evitar arreglar fallos escandalosos; en cualquier caso no vamos a ver el código concreto aquí, pero con la documentación debería bastar para saber cómo hacerlo.

El lector avispado se habrá dado cuenta de que, pese a nuestro cuidado por replicar los procesos, hemos introducido un punto único de fallo: si el proceso master se muere, todo nuestro tinglado se derrumba porque nadie va a poder recrearlo. Por eso, trabajar en modo cluster no quita que sigamos usando algún tipo de gestión de procesos que levante nuestro proceso maestro si se cae: supervisor y forever son dos módulos de node muy útiles, y en Linux podemos usar la gestión nativa de procesos, systemd o Upstart. Porque estábamos usando ya algún tipo de gestión de procesos, ¿verdad? ¿Verdad?

Bueno, pues si no es así, ahora es buen momento para mirar las posibilidades.

Mensajes internos

¿Qué podemos hacer si queremos agregar información entre todos los workers? Un caso de uso típico es cuando queremos sacar estadísticas globales de cuántas peticiones hemos servido por minuto. En modo cluster no nos vale con guardar los resultados en memoria y pintarlos de vez en cuando: hay que agregar la información entre todos los procesos worker que ahora tenemos andando. En primer lugar, esta función envía al master la información de la variable stats usando process.send():

function sendStats(stats)
{
  var message = { stats: stats };
  process.send(message);
}

En el master queremos recoger los mensajes enviados por los workers y añadir las estadísticas a la variable globalStats. Tenemos que llamar al worker resultado del fork() de esta forma:

var worker = cluster.fork();
worker.on('message', function(message)
{
  globalStats += message.stats;
});

Si queremos recibir mensajes de varios tipos, sólo tenemos que añadir un atributo message.type con el tipo de mensaje, y luego discriminar al recibirlo.

Los workers también pueden recibir mensajes del master, usando exactamente la misma API. Nunca vamos a poder compartir memoria, pero sí objetos aleatorios que serán serializados y recibidos.

Servidor stateless

Para usar el modo cluster apropiadamente tenemos que asegurarnos de que nuestro servidor sea (atención, palabro) stateless: que no mantenga el estado de las peticiones en memoria.

Un servidor stateless es siempre una buena práctica, por ejemplo si tenemos varios frontends sirviendo peticiones. Supongamos que creamos una cookie o un token de acceso para cada usuario que entra, y lo usamos para identificar sus peticiones. Si guardamos la lista de tokens en memoria, y la siguiente petición del mismo usuario llega a otro frontend, estamos perdidos: no tenemos forma de reconocer al usuario.

Lo mismo vale para el modo cluster, que a todos los efectos es como si tuviéramos varios servidores independientes. Todo dato necesario para el servidor debe estar almacenado en base de datos o algún otro tipo de almacenamiento compartido — un memcached es una buena elección para datos volátiles.

¿Un servidor cluster y stateful?

En modo cluster existe una alternativa al servidor stateless: compartir la información a través del master. Cuando cada worker crea un token, tiene que informar al master, que a su vez informará a todos los demás workers para que actualicen sus listas de tokens válidos. Y lo mismo cuando se invalida un token. Es una aplicación interesante de la mensajería que dejamos como ejercicio al lector.

Para uso práctico, podemos ver que la complejidad empieza a aumentar más allá de lo razonable. Además esta solución no es válida para múltiples frontends. La recomendación de este desarrollador es (siempre que sea posible) el servidor stateless.

Conclusión

El modo cluster es una herramienta esencial para poner node.js en producción. No te dejes atemorizar por el API experimental o por la aparente complejidad del problema; node.js hace que tener múltiples procesos sea casi tan fácil como tener uno solo.

Original publicado en GodTIC el 2013-07-27.

Back to the index.