1. Contexto de partida

Partíamos de un api.js sencillo con un endpoint /scrape en Express, lanzado con PM2 en modo cluster (14 instancias). El contenedor Debian se caía por saturación de RAM.

pm2 start cluster.js --name scraper-api --node-args="--max-old-space-size=1500"

2. Problemas detectados

  • Demasiadas peticiones simultáneas → OOM Killer.
  • Falta de visibilidad sobre peticiones activas.
  • Sin control de recursos: entraba todo.

3. Estrategia de defensa

Área Solución Herramienta
RAM Verificar uso & rechazar > 40 GB systeminformation
Concurrencia global Contador distribuido Redis INCR/DECR
Historial atendidas Últimas 50 URLs Redis LPUSH/LTRIM
Rechazos Últimas 100 URLs rechazadas Redis LPUSH/LTRIM
Latencia Medir tiempos de scraping Redis LPUSH/LTRIM

4. Instalación de dependencias

# SO
sudo apt update && sudo apt install nodejs npm redis-server -y

# Node
npm install express dotenv ioredis systeminformation

Habilitar y arrancar Redis:

sudo systemctl enable redis-server
sudo systemctl start redis-server
redis-cli ping   # → PONG

5. Código final (api.js)

Mostrar/ocultar
// api.js
require('dotenv').config();
const express = require('express');
const si      = require('systeminformation');
const Redis   = require('ioredis');
const scrape  = require('./scraper');

const redis = new Redis({
  host: '127.0.0.1',
  port: 6379,
  maxRetriesPerRequest: null,
  reconnectOnError: () => true
});

const app  = express();
const PORT = process.env.PORT || 3000;

/* Control de RAM */
const memoryCheck = async () => {
  const mem = await si.mem();
  return (mem.active / 1024 / 1024 / 1024) > 40;
};

/* Endpoint principal */
app.get('/scrape', async (req, res) => {
  const { url, proxy } = req.query;
  const view = req.query.view === '1';
  if (!url) return res.status(400).json({ error: 'Falta ?url=' });

  if (await memoryCheck()) {
    await redis.lpush('scraper:rejectedUrls', url);
    await redis.ltrim('scraper:rejectedUrls', 0, 99);
    return res.status(503).json({ error: '? RAM demasiado alta. Rechazada.' });
  }

  await redis.incr('scraper:activeRequests');
  const start      = Date.now();
  const controller = new AbortController();
  const timeout    = setTimeout(() => controller.abort(), 60000);

  try {
    const result = await scrape(url, proxy, { abortSignal: controller.signal });

    await redis.lpush('scraper:lastUrls', url);
    await redis.ltrim('scraper:lastUrls', 0, 49);

    const elapsed = Date.now() - start;
    await redis.lpush('scraper:times', elapsed);
    await redis.ltrim('scraper:times', 0, 99);

    if (view) {
      res.set('Content-Type', 'text/html; charset=utf-8').send(result.body);
    } else {
      res.json({ ok: true, ...result });
    }
  } catch (e) {
    res.status(500).json({ error: controller.signal.aborted ? '⏱️ Timeout' : e.message });
  } finally {
    clearTimeout(timeout);
    redis.decr('scraper:activeRequests').catch(() => {});
  }
});

/* Endpoints de monitorización */
app.get('/status', async (_, res) => {
  const c = await redis.get('scraper:activeRequests');
  res.json({ activeRequests: Math.max(0, parseInt(c) || 0) });
});

app.get('/last-urls',     async (_, r) => r.json({ urls: await redis.lrange('scraper:lastUrls',     0, -1) }));
app.get('/rejected-urls', async (_, r) => r.json({ urls: await redis.lrange('scraper:rejectedUrls', 0, -1) }));
app.get('/avg-time',      async (_, r) => {
  const t = await redis.lrange('scraper:times', 0, -1);
  const avg = t.length ? Math.round(t.reduce((a,b)=>a+ +b,0) / t.length) : 0;
  r.json({ avgMs: avg });
});

app.listen(PORT, () => console.log(`? API en http://0.0.0.0:${PORT}`));

6. Despliegue con PM2

npm install -g pm2
pm2 start api.js -i 14 --name scraper-api --node-args="--max-old-space-size=1500"
pm2 save

7. Endpoints de control

  • /status → peticiones activas
  • /last-urls → últimas 50 URLs procesadas
  • /rejected-urls → últimas 100 rechazadas
  • /avg-time → latencia media (ms)

8. Pruebas rápidas

curl "http://localhost:3000/scrape?url=https://example.com&view=1"
curl "http://localhost:3000/status"
curl "http://localhost:3000/last-urls"
curl "http://localhost:3000/avg-time"

9. Solución a problemas comunes

  • Cannot find module 'systeminformation' npm install systeminformation
  • MaxRetriesPerRequestError → Redis no levantado — instala y arranca redis-server
  • Alias de servicio en Debian → usar redis-server.service
  • Contador negativo → mover decr() al finally y evitar doble decremento

10. Beneficios de esta arquitectura

  • Estabilidad: rechaza exceso antes de colapsar.
  • Observabilidad sin SSH.
  • Escala horizontal con PM2 + Redis.
  • Bajo coste: Redis local y dependencias ligeras.

11. Siguientes pasos opcionales

  • Bloqueo dinámico por IP abusiva.
  • Exportar métricas a Prometheus/Grafana.
  • Auto‑scaling de instancias según carga.
  • Persistir resultados en S3 + CDN.

12. Conclusión

Con este setup tu API de scraping se mantiene viva aun bajo alta carga. Si la demanda crece, solo necesitas escalar CPU/RAM o afinar límites. ¡A raspar sin miedo a los cuelgues!