Backend

Node.js en producción: mejores prácticas que aprendí en proyectos reales

Trabajando en proyectos como RHINO y Menteo AI, he aprendido un montón de cosas sobre cómo llevar aplicaciones Node.js a producción de manera efectiva. Desde el manejo de errores hasta la seguridad, hay muchas cosas a tener en cuenta. En este artículo, quiero compartir con vos las mejores prácticas que he implementado en mis proyectos reales para que puedas mejorar la estabilidad, seguridad y rendimiento de tus aplicaciones.

Manejo de errores

El manejo de errores en Node.js es crucial para mantener la estabilidad de una aplicación. Una mala gestión puede llevar a caídas inesperadas y, en el peor de los casos, a pérdida de datos. En RHINO, aprendimos a manejar errores eficazmente utilizando un middleware de manejo de errores centralizado.

Middleware de manejo de errores

Un middleware de errores centralizado te permite tener un punto único donde podés capturar y gestionar todos los errores que ocurren en tu aplicación. Aquí te dejo un ejemplo básico:

const express = require('express');
const app = express();

// Middleware para manejar errores
app.use((err, req, res, next) => {
    console.error(err.stack);
    res.status(500).send('Algo salió mal!');
});

// Rutas de ejemplo
app.get('/', (req, res) => {
    throw new Error('Error de ejemplo!');
});

app.listen(3000, () => {
    console.log('Servidor corriendo en puerto 3000');
});

Es importante que el middleware de errores esté registrado después de todas las rutas, para que pueda capturar cualquier error generado en las mismas.

Manejo de errores asincrónicos

Node.js utiliza promesas para manejar operaciones asincrónicas, y es fácil que los errores se pierdan si no los manejás correctamente. Utilizá .catch() para capturar errores en promesas:

async function fetchData() {
    try {
        const data = await someAsyncFunction();
        console.log(data);
    } catch (err) {
        console.error('Error al obtener datos:', err);
    }
}

fetchData();

En Menteo AI, hemos implementado esta técnica para asegurar que cualquier error en las operaciones de red sea capturado y registrado correctamente.

Logging con Winston o Pino

El logging es fundamental para monitorear el comportamiento de tu aplicación en producción. Tanto Winston como Pino son librerías populares para este propósito. Personalmente, me gusta usar Pino por su rendimiento superior.

Configuración básica de Pino

Acá te dejo un ejemplo de cómo configurar Pino en tu aplicación Node.js:

const pino = require('pino');
const logger = pino({
    level: 'info',
    prettyPrint: { colorize: true }
});

logger.info('Iniciando la aplicación...');
logger.error('Esto es un error!');

En RHINO, utilizamos Pino junto con un servicio de agregación de logs para centralizar y analizar los logs de todas nuestras instancias de aplicación.

Integración con Express

Integrar Pino con Express es bastante sencillo. Podés usar el middleware express-pino-logger para registrar automáticamente todas las solicitudes:

const express = require('express');
const pino = require('pino');
const expressPino = require('express-pino-logger');

const logger = pino();
const app = express();

app.use(expressPino({ logger }));

app.get('/', (req, res) => {
    res.send('Hola Mundo!');
});

app.listen(3000, () => {
    logger.info('Servidor corriendo en puerto 3000');
});

Esta integración nos ha permitido identificar cuellos de botella en nuestras aplicaciones y optimizar el rendimiento.

Seguridad

La seguridad es un aspecto crítico en cualquier aplicación en producción. En Merchant Hub Akua, hemos implementado varias estrategias para proteger nuestras aplicaciones Node.js.

Helmet

Helmet es una librería que ayuda a proteger tu aplicación Express estableciendo varios encabezados HTTP de seguridad. Acá te dejo cómo podés integrarlo:

const express = require('express');
const helmet = require('helmet');

const app = express();

app.use(helmet());

app.get('/', (req, res) => {
    res.send('Hola Mundo Seguro!');
});

app.listen(3000, () => {
    console.log('Servidor corriendo en puerto 3000');
});

Helmet configura encabezados como Content-Security-Policy y Strict-Transport-Security, que son esenciales para proteger tu aplicación contra ataques comunes.

Rate Limiting

El rate limiting es una técnica para limitar el número de solicitudes que un cliente puede hacer a tu servidor en un período de tiempo determinado. Esto es útil para prevenir ataques de denegación de servicio (DoS). Implementalo usando express-rate-limit:

const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutos
    max: 100 // límite de 100 solicitudes por IP
});

app.use(limiter);

Esta práctica nos ha permitido proteger nuestras APIs de abusos y mantener la calidad del servicio para todos los usuarios.

Clustering

Aprovechar al máximo el hardware disponible es crucial para el rendimiento de una aplicación Node.js en producción. El clustering permite que una aplicación Node.js utilice múltiples núcleos del servidor para manejar más tráfico.

Configuración de Clustering

Podés utilizar el módulo cluster de Node.js para habilitar el clustering. Aquí te dejo un ejemplo básico:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }

    cluster.on('exit', (worker, code, signal) => {
        console.log(`Worker ${worker.process.pid} muerto`);
    });
} else {
    http.createServer((req, res) => {
        res.writeHead(200);
        res.end('Hola Mundo!');
    }).listen(8000);
}

En Menteo AI, el clustering nos ayudó a mejorar la escalabilidad de nuestra aplicación, permitiendo un manejo eficiente del tráfico.

Desventajas del Clustering

Si bien el clustering mejora el rendimiento, también introduce complejidades adicionales, como la necesidad de manejar la comunicación entre procesos. Asegurate de que tu aplicación está diseñada para manejar estas complejidades antes de implementarlo.

Graceful Shutdown

En producción, es importante cerrar tu aplicación de manera elegante para no perder conexiones o datos en proceso. Esto es especialmente necesario durante despliegues o reinicios del servidor.

Implementación de Graceful Shutdown

Aquí tenés un ejemplo básico de cómo implementar un cierre elegante:

let server;

process.on('SIGTERM', () => {
    console.log('Recibido SIGTERM, cerrando servidor...');
    if (server) {
        server.close(() => {
            console.log('Servidor cerrado.');
        });
    }
});

server = app.listen(3000, () => {
    console.log('Servidor corriendo en puerto 3000');
});

En RHINO, implementamos graceful shutdown para asegurar que las conexiones activas sean finalizadas correctamente antes de que el servidor se cierre.

Environment Variables

Las variables de entorno son esenciales para mantener configuraciones sensibles fuera del código fuente. En Merchant Hub Akua, las utilizamos para gestionar configuraciones como claves de API y URLs de bases de datos.

Gestión de Variables de Entorno

Utilizá la librería dotenv para cargar variables de entorno desde un archivo .env:

require('dotenv').config();

const dbUrl = process.env.DB_URL;
console.log('Conectando a la base de datos:', dbUrl);

Esto te permite cambiar configuraciones sin tener que modificar el código, lo cual es especialmente útil en entornos de producción.

Buenas Prácticas

  • Mantené tu archivo .env fuera del control de versiones.
  • Usá variables de entorno para todas las configuraciones que puedan cambiar entre entornos.
  • Validá las variables de entorno al inicio de la aplicación para evitar errores durante la ejecución.

Conclusión

Llevar aplicaciones Node.js a producción puede ser un desafío, pero siguiendo estas mejores prácticas, podés mejorar significativamente la estabilidad, seguridad y rendimiento de tu aplicación. Espero que las experiencias y consejos compartidos te sean útiles. Si tenés alguna pregunta o querés compartir tus propias experiencias, no dudes en contactame.