La forma en que se comunican tus microservicios decide cómo se comportan cuando algo falla: una llamada HTTP encadenada propaga la caída de un servicio a todos los demás, mientras que un evento en una cola espera a que el consumidor vuelva. En esta guía verás los patrones principales (petición/respuesta síncrona, mensajería asíncrona, sagas) y montarás en Ubuntu 24.04 un ejemplo funcional con Docker Compose: un API gateway con Nginx, un servicio de pedidos que publica eventos en RabbitMQ y un servicio de inventario que los consume, con cola de mensajes fallidos (dead letter queue).
Requisitos previos
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 2 GB de RAM.
- Un usuario no root con privilegios
sudo. - Docker Engine y el plugin de Docker Compose instalados desde el repositorio oficial de Docker.
- Conocimientos básicos de HTTP y JavaScript. No hace falta instalar Node.js en el host: el código se ejecuta en contenedores
node:22-alpine.
Qué patrón usar en cada caso
Antes de escribir código conviene tener claro qué resuelve cada patrón:
| Patrón | Cómo funciona | Úsalo cuando | Riesgo principal |
|---|---|---|---|
| Petición/respuesta síncrona (REST, gRPC) | El cliente llama y espera la respuesta | Necesitas el resultado para continuar (consultar un precio, validar un token) | Fallos en cascada y latencia acumulada |
| Cola de trabajo (point-to-point) | Un productor deja mensajes, un grupo de consumidores los reparte | Tareas en segundo plano: enviar correos, generar PDF | Mensajes duplicados si el consumidor no es idempotente |
| Publicación/suscripción de eventos | Un servicio anuncia "ha pasado X" y cualquiera puede suscribirse | Varios servicios reaccionan al mismo hecho (pedido creado) | Consistencia eventual, difícil de depurar sin trazas |
| Saga | Secuencia de transacciones locales con acciones de compensación | Una operación de negocio toca varias bases de datos | Estados intermedios visibles y compensaciones mal diseñadas |
| API gateway | Punto de entrada único que enruta a cada servicio | Clientes externos que no deben conocer la topología interna | Punto único de fallo si no se replica |
La regla práctica: usa llamadas síncronas solo cuando el llamante no puede seguir sin la respuesta, y eventos para todo lo que pueda ocurrir "después". El ejemplo de esta guía combina ambos: el cliente hace un POST síncrono al gateway, el servicio de pedidos responde 202 Accepted en cuanto el evento está guardado en RabbitMQ, y el inventario se actualiza de forma asíncrona.
Paso 1: Preparar el proyecto y RabbitMQ
Crea el directorio del proyecto con una carpeta para el código de los servicios y otra para la configuración del gateway:
mkdir -p ~/microservicios/{app,gateway}
cd ~/microservicios
Genera una contraseña aleatoria para RabbitMQ en un archivo .env, que Docker Compose lee automáticamente. Se usa formato hexadecimal para que no contenga caracteres que haya que escapar en la URL de conexión:
echo "RABBITMQ_PASS=$(openssl rand -hex 16)" > .env
chmod 600 .env
Crea el archivo de Compose:
nano compose.yaml
services:
rabbitmq:
image: rabbitmq:4-management
hostname: rabbitmq
environment:
RABBITMQ_DEFAULT_USER: app
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS}
ports:
- "127.0.0.1:15672:15672"
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
pedidos:
image: node:22-alpine
working_dir: /app
command: node servicio-pedidos.js
volumes:
- ./app:/app:ro
environment:
AMQP_URL: amqp://app:${RABBITMQ_PASS}@rabbitmq:5672
depends_on:
rabbitmq:
condition: service_healthy
restart: unless-stopped
inventario:
image: node:22-alpine
working_dir: /app
command: node servicio-inventario.js
volumes:
- ./app:/app:ro
environment:
AMQP_URL: amqp://app:${RABBITMQ_PASS}@rabbitmq:5672
depends_on:
rabbitmq:
condition: service_healthy
restart: unless-stopped
gateway:
image: nginx:stable-alpine
ports:
- "127.0.0.1:8080:80"
volumes:
- ./gateway/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- pedidos
restart: unless-stopped
volumes:
rabbitmq_data:
Puntos importantes de este archivo:
hostname: rabbitmqfija el nombre del nodo de RabbitMQ; sin él, cada vez que se recrea el contenedor el nodo cambia de nombre y no encuentra los datos del volumen.- La consola de administración (15672) y el gateway (8080) solo se publican en
127.0.0.1. Docker añade sus propias reglas de iptables y un puerto publicado en todas las interfaces quedaría expuesto aunque UFW lo bloquee. - El puerto AMQP (5672) no se publica: los servicios llegan a RabbitMQ por la red interna de Compose.
Levanta solo RabbitMQ para comprobar que arranca:
docker compose up -d rabbitmq
docker compose ps
Tras unos segundos el estado debe ser healthy:
NAME IMAGE SERVICE STATUS PORTS
microservicios-rabbitmq-1 rabbitmq:4-management rabbitmq Up 20 seconds (healthy) ...
Paso 2: Declarar la topología de mensajería
La topología (exchanges, colas y enlaces) se declara desde el código de forma idempotente: si ya existe con los mismos parámetros, RabbitMQ no hace nada. El diseño es el siguiente:
- Exchange
pedidosde tipotopic: el servicio de pedidos publica con claves comopedido.creadoopedido.cancelado. - Cola
inventario.pedidos, durable, enlazada con el patrónpedido.*. Si mañana otro servicio necesita los mismos eventos, crea su propia cola y la enlaza al mismo exchange. - Exchange
pedidos.dlxy colapedidos.dlq: los mensajes que el consumidor rechaza acaban aquí en lugar de perderse o de reintentarse en bucle.
Instala la librería amqplib con un contenedor temporal de Node.js, ejecutándolo con tu usuario para que node_modules no quede con propietario root:
echo '{ "name": "microservicios", "private": true }' > app/package.json
docker run --rm -u "$(id -u):$(id -g)" -e npm_config_cache=/tmp/.npm \
-v "$PWD/app":/app -w /app node:22-alpine npm install amqplib
Crea el módulo compartido que conecta y declara la topología:
nano app/topologia.js
const amqp = require('amqplib');
async function conectar() {
const conexion = await amqp.connect(process.env.AMQP_URL);
// Canal con confirmaciones: el broker confirma cada mensaje publicado
const canal = await conexion.createConfirmChannel();
await canal.assertExchange('pedidos', 'topic', { durable: true });
await canal.assertExchange('pedidos.dlx', 'fanout', { durable: true });
await canal.assertQueue('pedidos.dlq', { durable: true });
await canal.bindQueue('pedidos.dlq', 'pedidos.dlx', '');
await canal.assertQueue('inventario.pedidos', {
durable: true,
deadLetterExchange: 'pedidos.dlx',
});
await canal.bindQueue('inventario.pedidos', 'pedidos', 'pedido.*');
conexion.on('close', () => {
console.error('Conexión con RabbitMQ cerrada, saliendo para que Docker reinicie el servicio');
process.exit(1);
});
return canal;
}
module.exports = { conectar };
Salir del proceso cuando se cierra la conexión es deliberado: con restart: unless-stopped, Docker vuelve a arrancar el servicio y este se reconecta limpio, que es más fiable que reimplementar la reconexión a mano.
Paso 3: Publicar eventos desde el servicio de pedidos
El servicio de pedidos expone una API HTTP mínima. Al recibir un pedido publica el evento pedido.creado como mensaje persistente, espera la confirmación del broker y solo entonces responde 202 Accepted. Así el cliente sabe que el pedido no se perderá aunque el inventario esté caído en ese momento.
nano app/servicio-pedidos.js
const http = require('node:http');
const { randomUUID } = require('node:crypto');
const { conectar } = require('./topologia');
function responder(res, estado, cuerpo) {
res.writeHead(estado, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(cuerpo));
}
async function main() {
const canal = await conectar();
const servidor = http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/health') {
return responder(res, 200, { status: 'ok' });
}
if (req.method !== 'POST' || req.url !== '/') {
return responder(res, 404, { detail: 'Ruta no encontrada' });
}
let cuerpo = '';
for await (const trozo of req) cuerpo += trozo;
let pedido;
try {
pedido = JSON.parse(cuerpo);
} catch {
return responder(res, 400, { detail: 'El cuerpo no es JSON válido' });
}
const evento = { ...pedido, pedidoId: randomUUID(), creadoEn: new Date().toISOString() };
try {
canal.publish('pedidos', 'pedido.creado', Buffer.from(JSON.stringify(evento)), {
persistent: true,
contentType: 'application/json',
messageId: randomUUID(),
});
await canal.waitForConfirms();
} catch (err) {
console.error(`No se pudo publicar el evento: ${err.message}`);
return responder(res, 503, { detail: 'Servicio de mensajería no disponible' });
}
console.log(`Pedido ${evento.pedidoId} publicado`);
return responder(res, 202, { pedidoId: evento.pedidoId, estado: 'pendiente' });
});
servidor.listen(3000, () => console.log('Servicio de pedidos escuchando en :3000'));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
El campo messageId es único por mensaje y es lo que el consumidor usará para detectar duplicados.
Paso 4: Consumir eventos en el servicio de inventario
RabbitMQ garantiza entrega "al menos una vez": si el consumidor se cae después de procesar un mensaje pero antes de confirmarlo, el mensaje se vuelve a entregar. Por eso el consumidor debe ser idempotente y confirmar (ack) solo cuando ha terminado su trabajo.
nano app/servicio-inventario.js
const { conectar } = require('./topologia');
// En producción guarda los IDs procesados en la base de datos del servicio,
// en la misma transacción que la actualización de stock.
const procesados = new Set();
async function main() {
const canal = await conectar();
// Como máximo 10 mensajes sin confirmar por consumidor
await canal.prefetch(10);
await canal.consume('inventario.pedidos', (msg) => {
if (msg === null) return;
const id = msg.properties.messageId;
try {
if (procesados.has(id)) {
console.log(`Mensaje ${id} duplicado, se ignora`);
return canal.ack(msg);
}
const pedido = JSON.parse(msg.content.toString());
if (!Array.isArray(pedido.items) || pedido.items.length === 0) {
throw new Error('el pedido no tiene items');
}
console.log(`Reservando stock para el pedido ${pedido.pedidoId}: ${pedido.items.length} líneas`);
procesados.add(id);
canal.ack(msg);
} catch (err) {
console.error(`Mensaje ${id} rechazado: ${err.message}`);
// requeue=false: el mensaje va al dead letter exchange en lugar de reintentarse en bucle
canal.nack(msg, false, false);
}
});
console.log('Servicio de inventario escuchando pedido.*');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Un mensaje con datos inválidos nunca se va a procesar bien por mucho que se reintente, así que se envía directamente a pedidos.dlq para revisarlo a mano. Para errores transitorios (la base de datos no responde) lo habitual es reintentar con retardo antes de mandarlo a la DLQ, por ejemplo con una cola de espera con TTL.
Paso 5: Configurar Nginx como API gateway
El gateway es el único punto de entrada para los clientes: enruta por prefijo de URL, limita la tasa de peticiones, añade un identificador de petición para poder seguir una llamada en los logs de todos los servicios y fija tiempos de espera cortos para que un servicio lento no bloquee conexiones indefinidamente.
nano gateway/default.conf
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
upstream pedidos_backend {
server pedidos:3000;
keepalive 16;
}
server {
listen 80;
location /api/pedidos/ {
limit_req zone=api burst=40 nodelay;
limit_req_status 429;
proxy_pass http://pedidos_backend/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Request-ID $request_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
}
location = /health {
default_type application/json;
return 200 '{"status":"ok"}';
}
}
Como proxy_pass termina en /, Nginx sustituye el prefijo: una petición a /api/pedidos/ llega al servicio como /. Cuando añadas el servicio de inventario al gateway bastará con otro bloque upstream y otra location /api/inventario/.
Arranca todos los servicios:
docker compose up -d
docker compose ps
Los cuatro servicios deben aparecer en estado Up, con RabbitMQ en healthy. Revisa que los dos servicios de Node.js han conectado:
docker compose logs pedidos inventario
pedidos-1 | Servicio de pedidos escuchando en :3000
inventario-1 | Servicio de inventario escuchando pedido.*
Paso 6: Probar el flujo completo
Envía un pedido válido a través del gateway:
curl -s -X POST http://127.0.0.1:8080/api/pedidos/ \
-H "Content-Type: application/json" \
-d '{"clienteId": "c-42", "items": [{"sku": "A-100", "cantidad": 2}]}'
La respuesta llega en cuanto RabbitMQ confirma el evento, sin esperar al inventario:
{"pedidoId":"3f1c9a52-7d7e-4c1b-9e57-0f2a6c1d8b11","estado":"pendiente"}
En los logs del inventario verás que ha procesado el evento:
docker compose logs --tail 5 inventario
inventario-1 | Reservando stock para el pedido 3f1c9a52-7d7e-4c1b-9e57-0f2a6c1d8b11: 1 líneas
Ahora envía un pedido sin líneas, que el inventario rechazará:
curl -s -X POST http://127.0.0.1:8080/api/pedidos/ \
-H "Content-Type: application/json" \
-d '{"clienteId": "c-42", "items": []}'
Comprueba el estado de las colas. El mensaje rechazado debe estar en pedidos.dlq y la cola del inventario vacía:
docker compose exec rabbitmq rabbitmqctl list_queues name messages messages_unacknowledged
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
name messages messages_unacknowledged
inventario.pedidos 0 0
pedidos.dlq 1 0
Por último, comprueba el desacoplamiento, que es la ventaja principal de la mensajería asíncrona. Para el inventario, envía un pedido y vuelve a arrancarlo:
docker compose stop inventario
curl -s -X POST http://127.0.0.1:8080/api/pedidos/ \
-H "Content-Type: application/json" \
-d '{"clienteId": "c-7", "items": [{"sku": "B-200", "cantidad": 1}]}'
docker compose start inventario
docker compose logs --tail 3 inventario
El pedido se aceptó con el inventario parado y se procesó en cuanto el servicio volvió. Con una llamada HTTP síncrona entre pedidos e inventario, ese pedido habría fallado.
Si quieres inspeccionar mensajes desde el navegador, abre un túnel SSH a la consola de administración con ssh -L 15672:127.0.0.1:15672 your_user@your_server_ip y entra en http://localhost:15672 con el usuario app y la contraseña del archivo .env.
Paso 7: Llamadas síncronas resilientes
Cuando un servicio sí necesita la respuesta de otro (por ejemplo, consultar el precio actual antes de aceptar un pedido), aplica tres reglas:
- Tiempo de espera siempre. Una llamada sin timeout puede dejar un proceso esperando indefinidamente.
- Reintentos solo en operaciones idempotentes y con espera exponencial más una parte aleatoria (jitter), para que muchos clientes no reintenten a la vez.
- Circuit breaker cuando el servicio remoto falla de forma sostenida: dejar de llamarlo durante un tiempo protege a ambos lados. En Node.js la librería
opossumimplementa este patrón; en una malla de servicios (Istio, Linkerd) se configura en la infraestructura.
Este es un ejemplo de consulta con timeout y reintentos usando fetch, disponible de serie en Node.js 18 y posteriores:
async function obtenerConReintentos(url, { intentos = 3, timeoutMs = 2000 } = {}) {
for (let intento = 1; intento <= intentos; intento++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (res.ok) return await res.json();
// Los 4xx son errores del cliente: reintentar no los arregla
if (res.status < 500 && res.status !== 429) {
throw Object.assign(new Error(`HTTP ${res.status}`), { definitivo: true });
}
} catch (err) {
if (err.definitivo || intento === intentos) throw err;
}
const espera = Math.min(200 * 2 ** intento, 3000) + Math.random() * 100;
await new Promise((r) => setTimeout(r, espera));
}
throw new Error(`Sin respuesta de ${url} tras ${intentos} intentos`);
}
Evita cadenas largas de llamadas síncronas (A llama a B, que llama a C, que llama a D): la disponibilidad total es el producto de la de cada servicio y la latencia se suma.
Paso 8: Transacciones distribuidas con sagas
En microservicios cada servicio tiene su propia base de datos, así que no existe una transacción que abarque "cobrar el pedido y reservar el stock". Una saga divide la operación en transacciones locales y define, para cada paso, una acción que lo deshace (compensación). Para el flujo de un pedido:
| Paso | Servicio | Evento si va bien | Compensación si un paso posterior falla |
|---|---|---|---|
1. Crear pedido en estado pendiente | Pedidos | pedido.creado | Marcar el pedido como cancelado |
| 2. Reservar stock | Inventario | stock.reservado | Liberar la reserva (stock.liberado) |
| 3. Cobrar | Pagos | pago.completado | Reembolsar |
| 4. Confirmar pedido | Pedidos | pedido.confirmado | (último paso, no necesita compensación) |
Hay dos formas de coordinarla:
- Coreografía: cada servicio escucha eventos y publica los suyos. Si pagos publica
pago.rechazado, inventario libera la reserva y pedidos cancela el pedido. Encaja con la topología de esta guía y no tiene un coordinador central, pero con muchos pasos cuesta seguir el flujo. - Orquestación: un orquestador (normalmente dentro del servicio de pedidos) envía comandos a cada servicio, guarda el estado de la saga en su base de datos y lanza las compensaciones en orden inverso. Es más fácil de razonar cuando hay más de tres o cuatro pasos.
En ambos casos hay dos detalles que suelen fallar en la práctica:
- Publicar el evento y guardar el cambio en la base de datos no es atómico. Si el servicio guarda el pedido y se cae antes de publicar, el evento se pierde. El patrón transactional outbox lo resuelve: el evento se inserta en una tabla
outboxen la misma transacción que el cambio, y un proceso aparte lo publica y lo marca como enviado. - Las compensaciones también se reintentan, así que deben ser idempotentes igual que los consumidores.
Solución de problemas
Los servicios de Node.js se reinician en bucle con ACCESS_REFUSED. RabbitMQ solo crea el usuario de RABBITMQ_DEFAULT_USER la primera vez que arranca con un volumen vacío. Si cambiaste la contraseña en .env después, el volumen conserva la antigua. En un entorno de pruebas puedes borrar el volumen con docker compose down -v y volver a levantar el proyecto; en uno real, cambia la contraseña con docker compose exec rabbitmq rabbitmqctl change_password app nueva_contraseña.
PRECONDITION_FAILED - inequivalent arg al arrancar. Has cambiado los parámetros de una cola que ya existe (por ejemplo, añadiendo deadLetterExchange). RabbitMQ no modifica colas existentes al declararlas; elimínala desde la consola de administración o con rabbitmqctl delete_queue inventario.pedidos y reinicia el servicio.
Mensajes en messages_unacknowledged que no bajan. El consumidor recibió los mensajes pero no llama a ack ni a nack, normalmente por una excepción no capturada o una promesa que nunca se resuelve. Revisa docker compose logs inventario; al reiniciar el consumidor, RabbitMQ vuelve a entregar esos mensajes.
El gateway devuelve 502 o 504. Un 502 indica que Nginx no puede conectar con el servicio (comprueba docker compose ps y los logs de pedidos); un 504 que el servicio tardó más que proxy_read_timeout. Los errores de Nginx aparecen en docker compose logs gateway.
Conclusión
Has montado una arquitectura pequeña pero completa: un API gateway que enruta y limita peticiones, un servicio que acepta trabajo de forma síncrona y lo delega mediante eventos persistentes, y un consumidor idempotente con cola de mensajes fallidos. Como siguientes pasos, puedes añadir el patrón outbox con la base de datos real de cada servicio, publicar el gateway en un dominio con HTTPS y añadir trazas distribuidas con OpenTelemetry para seguir cada X-Request-ID a través de todos los servicios.
