Crear un microservicio con Node.js y Express: estructura, endpoints y apagado
Crear un microservicio con Node.js y Express implica bastante más que un app.listen(): una estructura de carpetas que no se enrede a medida que crece, endpoints que devuelven lo que el consumidor espera, configuración por entorno, un mecanismo claro para hablar con otros servicios y, en producción, algo que casi ningún tutorial cubre, cómo apagarlo sin tirar peticiones en vuelo. Este artículo recorre las primeras cuatro piezas y termina con un experimento propio y medido sobre la quinta: la diferencia entre process.exit(0) y un apagado correcto no es un matiz, es la diferencia entre perder el 100% o el 0% de lo que estaba en curso en ese instante.
Qué hace que un servicio sea un microservicio
No es el tamaño del código, es el límite de responsabilidad. Un microservicio típico cumple tres condiciones: resuelve un dominio de negocio acotado (usuarios, pedidos, notificaciones), se despliega y escala de forma independiente de los demás, y no comparte base de datos con otro servicio, porque compartir esquema es la forma más común de acabar con un monolito distribuido con la complejidad operativa de varios procesos y ninguna de sus ventajas. Node.js y Express encajan bien aquí porque el coste de arrancar un servicio nuevo es bajo: un package.json, unas pocas dependencias y una ruta.
Estructura de carpetas de un microservicio en Express
Un único archivo server.js funciona para una demo, pero en cuanto el servicio tiene más de dos o tres rutas conviene separar responsabilidades desde el principio, para no tener que reorganizar bajo presión más adelante:
servicio-usuarios/
├── src/
│ ├── config/
│ │ └── env.js # lee y valida variables de entorno
│ ├── routes/
│ │ └── users.routes.js # define las rutas y delega en el controlador
│ ├── controllers/
│ │ └── users.controller.js
│ ├── services/
│ │ └── users.service.js # lógica de negocio, sin conocer Express
│ ├── clients/
│ │ └── orders.client.js # cliente HTTP hacia otro microservicio
│ └── server.js # ensambla app, monta rutas, expone listen/close
├── package.json
├── .env
└── Dockerfile
La regla que sostiene esta estructura es que services/ no debería importar nada de Express: si la lógica de negocio depende de req/res, migrar ese servicio a otro framework o probarlo de forma aislada se vuelve mucho más caro de lo necesario.
Construir el servicio: rutas, controlador y validación
El ejemplo siguiente es un microservicio de usuarios completo y ejecutable con npm install express express-validator, no una única ruta de ejemplo:
// src/services/users.service.js
const users = new Map([
[1, { id: 1, nombre: 'Juan' }],
[2, { id: 2, nombre: 'Ana' }],
]);
let nextId = 3;
function list() {
return [...users.values()];
}
function create({ nombre }) {
const user = { id: nextId++, nombre };
users.set(user.id, user);
return user;
}
function findById(id) {
return users.get(id) ?? null;
}
module.exports = { list, create, findById };
// src/controllers/users.controller.js
const usersService = require('../services/users.service');
function listUsers(req, res) {
res.json(usersService.list());
}
function createUser(req, res) {
const user = usersService.create(req.body);
res.status(201).json(user);
}
function getUser(req, res) {
const user = usersService.findById(Number(req.params.id));
if (!user) return res.status(404).json({ error: 'user not found' });
res.json(user);
}
module.exports = { listUsers, createUser, getUser };
// src/routes/users.routes.js
const express = require('express');
const { body, validationResult } = require('express-validator');
const controller = require('../controllers/users.controller');
const router = express.Router();
router.get('/', controller.listUsers);
router.get('/:id', controller.getUser);
router.post(
'/',
body('nombre').isString().trim().notEmpty(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
next();
},
controller.createUser,
);
module.exports = router;
La validación en la capa de rutas, antes de llegar al controlador, evita el patrón más frecuente de bugs en microservicios pequeños: lógica de negocio que asume que los campos obligatorios ya llegaron bien formados porque nadie los comprobó antes.
Configuración por entorno y health checks
Cada instancia del servicio (local, staging, producción) necesita su propio puerto, su propia URL de base de datos y sus propias credenciales, sin tocar código:
// src/config/env.js
require('dotenv').config();
function required(name) {
const value = process.env[name];
if (!value) throw new Error(`falta la variable de entorno ${name}`);
return value;
}
module.exports = {
port: Number(process.env.PORT ?? 3000),
nodeEnv: process.env.NODE_ENV ?? 'development',
ordersServiceUrl: required('ORDERS_SERVICE_URL'),
};
// src/server.js
const express = require('express');
const usersRoutes = require('./routes/users.routes');
const config = require('./config/env');
const app = express();
app.use(express.json());
app.use('/api/usuarios', usersRoutes);
app.get('/healthz', (req, res) => res.status(200).json({ status: 'ok' }));
const server = app.listen(config.port, () => {
console.log(`servicio de usuarios escuchando en ${config.port}`);
});
module.exports = { app, server };
/healthz no es decoración: es lo que un orquestador (Kubernetes, un balanceador, un script de despliegue) consulta para decidir si el proceso está vivo antes de enrutarle tráfico. Sin él, un despliegue no tiene forma automática de distinguir "el proceso existe" de "el proceso responde".
Cómo se comunica con otros servicios
La forma más simple de que un microservicio use a otro es una petición HTTP directa, apuntando a una URL que llega por configuración, no hardcodeada:
// src/clients/orders.client.js
const config = require('../config/env');
async function getOrdersForUser(userId) {
const res = await fetch(`${config.ordersServiceUrl}/api/pedidos?userId=${userId}`, {
signal: AbortSignal.timeout(2000),
});
if (!res.ok) throw new Error(`orders-service respondió ${res.status}`);
return res.json();
}
module.exports = { getOrdersForUser };
Con AbortSignal.timeout se evita el fallo más común de la comunicación entre servicios: una petición sin límite de tiempo que deja al servicio que llama colgado si el otro extremo no responde nunca. Para volúmenes de eventos más altos o para desacoplar productor y consumidor en el tiempo, la alternativa habitual es una cola o un bus de mensajes (RabbitMQ, Kafka), pero eso es un cambio de modelo, no un simple sustituto de fetch: implica pensar en entrega al menos una vez, orden de mensajes y qué hacer si el consumidor está caído.
Cómo se despliega
Un microservicio en contenedor necesita que las señales del sistema operativo lleguen realmente al proceso de Node, y eso depende de cómo se escriba el Dockerfile:
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/server.js"]
La forma CMD ["node", "src/server.js"] es forma exec: el proceso de Node se convierte en el PID 1 del contenedor y recibe las señales Unix directamente. Con la forma shell (CMD node src/server.js), el ejecutable no es el PID 1 y no recibe esas señales de forma directa, según la referencia oficial de Dockerfile sobre STOPSIGNAL y formas de ENTRYPOINT/CMD. Esa diferencia, que parece de estilo, es la que determina si el apagado correcto que se construye en la siguiente sección llega a ejecutarse en absoluto.
Apagarlo sin perder peticiones en vuelo
Un microservicio en producción no se detiene una vez: se detiene cada vez que hay un despliegue, cada vez que el orquestador reequilibra nodos y cada vez que un autoscaler decide que hay demasiadas réplicas para el tráfico actual. El fallo típico no es que el servidor no arranque bien, sino que muere con conexiones a medio responder, y el síntoma en producción no es un error de código: son picos de 5xx o de ECONNRESET exactamente en la ventana de cada despliegue.
Quién manda qué señal antes del SIGKILL
- Kubernetes marca el Pod como
Terminating, envíaSIGTERMy, si el proceso sigue vivo trasterminationGracePeriodSeconds(30 segundos por defecto), envíaSIGKILL, según el ciclo de vida del Pod en la documentación oficial. - PM2 envía
SIGINTpor defecto, noSIGTERM. Si el proceso no ha terminado antes dekill_timeout, PM2 fuerza unSIGKILL; la propia documentación oficial de señales y clean restart de PM2 fija ese valor por defecto en 1.6 segundos, personalizable conkill_timeouten el ecosystem file. - Windows no soporta
SIGTERMcomo señal real: la documentación de Node es explícita, "SIGTERMno está soportado en Windows, pero se puede escuchar", según la documentación oficial de Node.js sobre eventos de señal. Probar el apagado conCtrl+Cen Windows pruebaSIGINT, no elSIGTERMque llegará en un contenedor Linux.
El experimento: montaje, resultado y qué demuestra en realidad
Para aislar el efecto que importa (qué hace Express con las conexiones abiertas, no qué hace el sistema operativo con las señales), el disparador del apagado en este experimento no es una señal real: es una petición HTTP a un endpoint interno que ejecuta la misma función que registrarías en process.on('SIGTERM', shutdown). El montaje, en tres pasos: un servidor con una ruta GET /work que simula 250 ms de trabajo antes de responder 200; un generador de carga que abre 60 conexiones simultáneas y, 30 ms después, dispara el apagado; y dos modos del mismo servidor, naive (process.exit(0) inmediato) y graceful (server.close() con una red de seguridad de 5 segundos).
// server.js
const express = require('express');
const MODE = process.env.MODE === 'graceful' ? 'graceful' : 'naive';
const WORK_MS = 250;
const app = express();
app.get('/work', (req, res) => {
setTimeout(() => res.status(200).json({ ok: true }), WORK_MS);
});
app.post('/__shutdown', (req, res) => {
res.status(202).end();
if (MODE === 'naive') {
process.exit(0);
} else {
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000);
}
});
const server = app.listen(3900, () => console.log(`[${MODE}] escuchando en 3900`));
// load.js
const http = require('node:http');
const INFLIGHT = 60;
let ok = 0, failed = 0, done = 0;
const failReasons = {};
function report() {
console.log(JSON.stringify({ inflight: INFLIGHT, ok, failed, failReasons }));
process.exit(0);
}
for (let i = 0; i < INFLIGHT; i++) {
const req = http.get('http://127.0.0.1:3900/work', (res) => {
res.on('data', () => {});
res.on('end', () => {
res.statusCode === 200 ? ok++ : failed++;
if (++done === INFLIGHT) report();
});
});
req.on('error', (err) => {
failed++;
failReasons[err.code || err.message] = (failReasons[err.code || err.message] || 0) + 1;
if (++done === INFLIGHT) report();
});
}
setTimeout(() => {
const req = http.request({ host: '127.0.0.1', port: 3900, path: '/__shutdown', method: 'POST' }, (res) => res.on('data', () => {}));
req.on('error', () => {});
req.end();
}, 30);
setTimeout(() => { if (done < INFLIGHT) report(); }, 6000);
Ejecutado dos veces por modo, el resultado fue idéntico en ambas repeticiones:
| Modo | En vuelo | Completadas | Falladas | Motivo |
|---|---|---|---|---|
naive | 60 | 0 | 60 | ECONNRESET (60/60) |
graceful | 60 | 60 | 0 | — |
Conviene ser preciso sobre qué prueba exactamente este resultado y qué no. El montaje está construido para producir un resultado binario: las 60 peticiones duran los mismos 250 ms, el disparo del apagado llega a los 30 ms (con las 60 conexiones ya abiertas) y el modo graceful dispone de una red de seguridad de 5 segundos, muy por encima de lo que necesita. Con ese diseño, 0/60 y 60/60 son el resultado esperado, no un hallazgo estadístico, y dos repeticiones confirman que el montaje es determinista, no que exista una tasa de pérdida general aplicable a cualquier carga o latencia. Lo que el experimento demuestra con solidez es el mecanismo: process.exit() termina el proceso y su event loop de inmediato, sin margen para que ninguna conexión abierta termine su ciclo; server.close() deja de aceptar conexiones nuevas pero conserva abiertas "todas las conexiones conectadas a este servidor que no estén enviando una petición o esperando una respuesta", según la documentación oficial de Node.js sobre el módulo http. Con handlers de duración variable, tráfico real y un balanceador por delante, la proporción exacta de peticiones perdidas será otra, pero el mecanismo que decide si se pierden o no es el mismo que aísla este experimento.
Un matiz de versión importa aquí: desde Node.js 19.0.0, server.close() cierra las conexiones inactivas antes de retornar, según el historial de cambios de esa misma documentación. Antes de esa versión, una conexión keep-alive sin petición activa podía quedar viva indefinidamente tras llamar a close(), y hacía falta server.closeIdleConnections() aparte para forzarla. Si la imagen base todavía corre Node 18 o anterior, eso cambia cuánto tarda el proceso en apagarse limpio.
El shutdown real: SIGTERM, readiness y una red de seguridad
El endpoint /__shutdown de arriba es solo el disparador de laboratorio. En producción, la misma lógica se cuelga de la señal real, y para que sea honesto llamarlo listo para producción tiene que ser código que corre de principio a fin, no un comentario con la limpieza pendiente:
let shuttingDown = false;
// Sustituye estos dos por tus propios clientes (pool de PostgreSQL, Redis, etc.);
// se dejan como null para que este bloque sea ejecutable tal cual sin dependencias.
const db = null;
const redis = null;
app.get('/healthz/ready', (req, res) => {
if (shuttingDown) return res.status(503).json({ ready: false });
res.status(200).json({ ready: true });
});
async function closeResources() {
if (db && typeof db.end === 'function') await db.end();
if (redis && typeof redis.quit === 'function') await redis.quit();
}
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`${signal} recibido, dejando de aceptar tráfico nuevo`);
server.close(async () => {
console.log('drenaje completo, cerrando dependencias');
try {
await closeResources();
} finally {
process.exit(0);
}
});
// Debe ser menor que terminationGracePeriodSeconds (o kill_timeout de PM2,
// lo que aplique) para no morir por SIGKILL en mitad del cleanup.
setTimeout(() => {
console.error('drenaje no terminó a tiempo, forzando salida');
process.exit(1);
}, 25_000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
El endpoint /healthz/ready parece obligatorio en cualquier tutorial de graceful shutdown, pero conviene leer la letra pequeña de Kubernetes antes de darlo por imprescindible: cuando el Pod se borra, el EndpointSlice actualiza su condición ready a false "aunque no exista ninguna readiness probe configurada", según la documentación oficial de Kubernetes sobre probes. La readiness probe acelera la salida del tráfico si el periodSeconds es corto (10 segundos por defecto), pero el mecanismo base ya existe sin ella. Donde sí es imprescindible es fuera de Kubernetes, con un balanceador que no tiene ese mecanismo de EndpointSlice.
Cuando el que manda no es Kubernetes sino PM2
El experimento usó 250 ms de trabajo simulado y ambos modos tuvieron tiempo de sobra dentro de cualquier ventana de gracia razonable. El problema aparece con handlers más lentos: si una ruta hace una llamada a otro microservicio con un timeout de 2 segundos, y ese microservicio corre bajo PM2 con el kill_timeout por defecto de 1.6 segundos, PM2 manda SIGKILL antes de que el propio código de drenaje tenga oportunidad de terminar, aunque ese código sea correcto sobre el papel. El ajuste es medible y directo: subir kill_timeout por encima de la latencia p99 del handler más lento más un margen (pm2 start app.js --kill-timeout 8000), o bajar esa latencia por debajo del kill_timeout que ya se tiene. Corriendo PM2 dentro de un Pod de Kubernetes, el límite real del apagado no es el terminationGracePeriodSeconds del Pod: es el kill_timeout de PM2, mucho más corto, que actúa primero.
Qué cambia con varios servicios y un balanceador por delante
En un único proceso, medir cuántas peticiones se pierden es sencillo porque solo hay un punto de fallo. En una malla de microservicios el problema se compone: si el servicio de usuarios llama al de pedidos mientras este último se está apagando, el cliente de la sección anterior ve exactamente la misma ventana de ECONNRESET que mide el experimento, indistinguible de la de un usuario externo. El patrón razonable es que las llamadas internas entre servicios usen reintento con backoff en errores de conexión (no en 4xx/5xx de aplicación) para verbos idempotentes, y que las escrituras no idempotentes se diseñen con una clave de idempotencia desde el principio, porque el drenaje nunca es instantáneo y algún cliente va a golpear esa ventana en algún momento.
De vuelta al principio: qué hace completo a este microservicio
La estructura de carpetas, las rutas con validación, la configuración por entorno, el cliente HTTP hacia otro servicio y el Dockerfile en forma exec son las piezas que responden a "cómo se crea"; el apagado correcto, con su red de seguridad y su readiness check, es la pieza que responde a "cómo sobrevive en producción sin perder tráfico en cada despliegue". Ninguna de las dos mitades sustituye a la otra: un servicio bien estructurado que muere mal en cada rolling update pierde peticiones igual que uno mal estructurado, y un apagado perfecto sobre un servicio sin estructura ni configuración por entorno no llega a desplegarse dos veces sin fricción. El siguiente paso natural, una vez que esto funciona, es logging estructurado y trazado entre servicios: en cuanto hay más de un microservicio, el problema deja de ser "por qué cayó este proceso" y pasa a ser "cuál de los cinco fue, y en qué orden".