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()
alfinally
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!