Migrar Soluciones Serverless de la Nube a Entornos On-Premise: errores comunes y lecciones aprendidas
- Altiv Labs 
- 28 abr
- 9 Min. de lectura
Actualizado: 5 may
En Altiv Labs, ayudamos recientemente a un cliente a hacer realidad su ambiciosa visión de Internet de las Cosas (IoT). Construida completamente sobre AWS, su plataforma recopila, procesa y supervisa de forma fluida datos procedentes de cientos de dispositivos conectados distribuidos en múltiples ubicaciones.
La solución combina la ingesta de datos en tiempo real, la transformación y el enrutamiento inteligente de mensajes, y el almacenamiento de datos georreferenciados — todo ello integrado en paneles web que facilitan el análisis y la toma de decisiones. Detrás del escenario, un conjunto de servicios serverless y colas de mensajes mantiene todo funcionando de manera eficiente, mientras que el frontend intuitivo ofrece a los equipos una visión unificada y potentes herramientas para gestionar operaciones críticas.
Diseñada para la eficiencia y la escalabilidad, la plataforma consiguió dar soporte a una amplia variedad de clientes y permitió un crecimiento rápido — todo ello gracias a su arquitectura cloud-native.

Sin embargo, surgió un nuevo reto: un cliente necesitaba que la solución funcionara completamente offline, sin depender de ningún servicio en la nube. Para cumplir con este requisito, nos propusimos migrar la plataforma a un entorno on-premise, guiados por dos objetivos clave:
- preservar la mayor parte posible del código existente; 
- garantizar que la versión offline ofreciera el mismo conjunto completo de funcionalidades y el mismo rendimiento que la solución original en la nube. 
Nuestro primer paso fue repensar cuidadosamente cada componente de la plataforma y planificar cómo adaptarlo al nuevo entorno offline. Desde el principio, decidimos orquestar toda la infraestructura con Docker, una elección que simplificó notablemente tanto el despliegue como el mantenimiento a largo plazo.
Configuración de la infraestructura
Comenzamos organizando la infraestructura en dos servidores principales:
- Servidor de Aplicaciones: encargado del procesamiento de mensajes y de alojar todos los servicios principales. 
- Servidor de Base de Datos: dedicado exclusivamente a PostgreSQL, garantizando que las consultas intensivas no afectaran al rendimiento del resto del sistema. 
Como la solución funcionaría dentro de una red interna, implementamos un DNS interno y generamos nuestros propios certificados de seguridad. Para ello, creamos una Autoridad Certificadora (CA) personalizada e instalamos sus certificados en todos los dispositivos de la red, asegurando así la autenticación y el cifrado de extremo a extremo en todo el entorno.
Componentes del stack

- Broker MQTT: 
Para la comunicación con los dispositivos, necesitábamos un broker open-source, ligero y seguro. Elegimos Mosquitto(de la Eclipse Foundation), que encajaba perfectamente con nuestros requisitos, especialmente por sus sólidos controles de permisos y autenticación para dispositivos.
- PostgreSQL con PostGIS: 
Esta parte fue bastante directa. Pudimos reutilizar casi todos los scripts de creación de base de datos del entorno cloud, realizando únicamente pequeños ajustes para adaptarlos a la nueva infraestructura on-premise.
- RabbitMQ: 
Para gestionar el flujo de mensajes y garantizar la resiliencia del sistema, implementamos RabbitMQ como sistema de colas de mensajes. Además, introdujimos un servicio intermedio, llamado Bridge, para conectar los protocolos MQTT y AMQP.
Más allá del puente de protocolos, el Bridge desempeñó un papel clave en la escalabilidad: distribuía los mensajes entre múltiples colas, permitiendo que varios consumidores los procesaran en paralelo (más adelante profundizaremos en este punto).
Migración de las Lambdas de IoT
Con Mosquitto, PostgreSQL y RabbitMQ ya en marcha, pasamos a migrar las Lambdas de AWS IoT. En nuestro entorno cloud original, estas funciones serverless eran impulsadas por eventos: procesaban los mensajes entrantes de los dispositivos, transformaban los datos y desencadenaban flujos de trabajo basados en eventos IoT, todo en tiempo real.
Para la versión on-premise, reimplementamos estas Lambdas como aplicaciones Node.js, sustituyendo las llamadas a Lambda.invoke por llamadas locales a funciones.
El principal desafío fue el rendimiento: cada mensaje tardaba unos 300 ms en procesarse. Inicialmente, todo funcionaba en un modelo monohilo con una sola cola, lo que limitaba gravemente el throughput. Para mejorar el rendimiento, nuestro primer intento fue optimizar el event loop de Node.js procesando una promesa por dispositivo:
channel.consume(queueName, async (message) => {
  if (message !== null) {
    const messageJson = JSON.parse(message.content.toString());
    const mac = messageJson.topic.toUpperCase();
    if (!msgsDevices[mac]) msgsDevices[mac] = [];
    if (msgsDevices[mac].length < BATCH_SIZE) {
      msgsDevices[mac].push(message);
      if (!devicesProcessing[mac]) {
        devicesProcessing[mac] = true;
        await processDeviceQueue(mac);
        delete devicesProcessing[mac];
      }
    } else {
      channel.nack(message, false, true);
    }
  }
});
Aunque este enfoque trajo algunas mejoras, seguía sin aprovechar al máximo la capacidad de procesamiento del servidor.
Escalado mediante multithreading y múltiples colas
Dado que los recursos del servidor seguían infrautilizados, implementamos una arquitectura de multithreading y multicolas. Utilizando el módulo cluster de Node.js, creamos un proceso trabajador por núcleo de CPU, y cada trabajador gestionaba su propia cola dedicada.
if (cluster.isPrimary) {
  const numWorkers = numCPUs as number;
  const amqpUrl = `amqp://${process.env.AMQP_USERNAME}:${process.env.AMQP_PASSWORD}@${process.env.AMQP_HOST}`;
  for (let i = 0; i <= numWorkers; i++) {
    cluster.fork({
      AMQP_URL: amqpUrl,
      QUEUE_NAME: `mqtt-messages-${i}`,
    });
  }
  cluster.on("exit", (worker, code, signal) => {
    cluster.fork();
  });
} else {
  // worker code...
}Distribución de mensajes por colas
Uno de los principales retos de esta nueva arquitectura era garantizar que, incluso con procesamiento concurrente, los mensajes de cada dispositivo se gestionaran de forma secuencial.
Para lograrlo, utilizamos la dirección MAC de cada dispositivo como base para la distribución:
calculábamos un hash SHA-256 de la dirección MAC y lo usábamos para asignar de manera consistente los mensajes a una de las colas disponibles — cada cola vinculada a un hilo específico del servidor:
function getQueueIndex(macAddress: string): number {
  const hash = crypto.createHash("sha256").update(macAddress).digest("hex");
  const hashInt = parseInt(hash.substring(0, 8), 16);
  return hashInt % NUM_QUEUES;
}
const queueIndex = getQueueIndex(macAddress);
const deviceQueue = `mqtt-messages-${queueIndex}`;
channel.sendToQueue(deviceQueue, Buffer.from(JSON.stringify(msg)), {
  persistent: true,
});
Con este enfoque, finalmente conseguimos aprovechar al máximo los recursos del servidor y alcanzar los niveles de rendimiento necesarios para gestionar el volumen de mensajes esperado.

Migración de las API Lambdas
En el backend, nos enfrentamos al reto de migrar las API Lambdas. Para ello, reestructuramos las funciones en un monolito Node.js utilizando Express, con el objetivo de preservar la mayor parte posible del código existente y, al mismo tiempo, simplificar el mantenimiento futuro.
Estandarización de la integración de Lambdas
En la solución original basada en la nube, cada ruta de la API estaba implementada como una función Lambda independiente, siguiendo las convenciones estándar de AWS. Para evitar reescribir cada Lambda desde cero, desarrollamos una capa de middleware que simula el formato de evento que normalmente se recibe de AWS. Esto permitió que las Lambdas funcionaran con modificaciones mínimas.
export const handlePathParameters = (req: Request, res: Response, next: NextFunction) => {
  req.pathParameters = req.params;
  req.queryStringParameters = req.query;
  req.multiValueQueryStringParameters = req.query;
  next();
}
export const handleQueryStringParameters = (req: Request, res: Response, next: NextFunction) => {
  req.queryStringParameters = req.query;
  req.multiValueQueryStringParameters = req.query;
  next();
}De este modo, simplemente insertábamos el middleware antes de invocar cada Lambda correspondiente, haciendo que todo el procesamiento de parámetros fuera completamente transparente.
Automatización de rutas con Swagger
Como ya utilizábamos un archivo Swagger para documentar todas las rutas, aprovechamos la oportunidad para automatizar la creación de endpoints en Express. Construimos un script que analiza la definición de Swagger, genera los esqueletos de las rutas y organiza las carpetas según la estructura predefinida.

Después de automatizar la generación de rutas, la única tarea manual que quedó fue conectar cada ruta de Express con su Lambda correspondiente y asegurarse de que las respuestas se gestionaran correctamente.
Sustitución de AWS Cognito
La autenticación fue una de las partes más críticas de la migración, ya que originalmente estaba gestionada por AWS Cognito. Para mantener las mismas reglas de autenticación y autorización, desarrollamos nuestro propio servicio de autenticación basado en JWT, replicando la lógica de validación de permisos original. Aquí tienes un ejemplo de la implementación del middleware:
export class AuthService {
  public static readonly JWT_SECRET = process.env.JWT_SECRET;
  public async authenticateMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
    const authHeader = req.headers.authorization;
    if (req.originalUrl.includes('/files/image')) {
      next();
      return;
    }
    if (!authHeader) {
      res.status(403).send({ status: '403', message: 'You do not have permission to access this resource' });
      return;
    }
    const token = authHeader.replace('Bearer ', '');
    try {
      const decoded = jwt.verify(token, AuthService.JWT_SECRET) as { userArn: string, 'custom:accountId': number };
      const accountId = decoded['custom:accountId'];
      // Quick cache check
      const userEndpoints = CACHE[token];
      if (userEndpoints) {
        const hasPermission = userPermissionService.has(userEndpoints, req.originalUrl, req.method);
        if (hasPermission) {
          next();
          return;
        } else {
          res.status(403).send({ status: '403', message: 'You do not have permission to access this resource' });
          return;
        }
      }
      try {
        const { hasPermission, endpoints } = await userPermissionService.verifyUserPermission(
          decoded.userArn, accountId, req.originalUrl, req.method
        );
        CACHE[token] = endpoints;
        setTimeout(() => CACHE[token] = undefined, 1000 * 60); // Expires in 1 minute
        if (hasPermission) {
          next();
          return;
        } else {
          res.status(403).send({ status: '403', message: 'You do not have permission to access this resource' });
          return;
        }
      } catch (error) {
        logger.error(error);
        res.status(500).json({ message: 'Internal server error' });
        return;
      }
    } catch (error: any) {
      logger.info(error);
      res.status(401).json({ message: 'Invalid or expired token' });
      return;
    }
  }
  public async verifyToken(token: string): Promise<boolean> {
    try {
      jwt.verify(token, AuthService.JWT_SECRET) as { id: number };
      return true;
    } catch (error: any) {
      return false;
    }
  }
}Este enfoque nos permitió conservar las mismas reglas de autenticación y autorización que con Cognito, pero en un entorno completamente independiente — utilizando JWTs y gestionando todos los permisos directamente a través de la base de datos.
Migración del frontend
La migración del frontend fue relativamente sencilla. Nuestra pila original ya utilizaba React para la interfaz de usuario, distribuida a través de AWS CloudFront. En el entorno on-premise, mantuvimos el mismo build de React, pero servimos los archivos estáticos utilizando un servidor Nginx.
Configuramos manualmente los certificados SSL para garantizar que toda la aplicación siguiera siendo accesible a través de HTTPS.
Monitorización y registro de logs
Otro aspecto importante de la migración fue la gestión de los logs del sistema y la monitorización. En el entorno de AWS, AWS CloudWatch proporcionaba registro centralizado de logs, métricas y alertas para todos nuestros servicios, facilitando la visualización de los registros, el seguimiento del estado del sistema y la investigación de incidentes.
Con el traslado a on-premise, necesitábamos garantizar el mismo nivel de observabilidad y fiabilidad, pero utilizando herramientas open-source bajo nuestro propio control.
Gestión de logs con logrotate
Para la gestión de logs, configuramos logrotate en todos los contenedores de servicios y sistemas anfitriones. Cada servicio escribe sus logs en archivos locales, mientras que logrotate se encarga de la rotación automática de los registros, su compresión y retención, siguiendo las políticas que definimos. Esto garantiza que los logs sigan siendo fácilmente accesibles sin poner en riesgo el espacio de almacenamiento.
/var/log/postgresql/*.log {
    daily
    dateext
    dateformat -%Y-%m-%d
    rotate __DB_LOGROTATE_ROTATE__
    maxage __DB_LOGROTATE_MAXAGE__
    size __DB_LOGROTATE_SIZE__
    missingok
    notifempty
    compress
    copytruncate
}Métricas y monitorización con Prometheus
Para la monitorización en tiempo real, la recopilación de métricas y el sistema de alertas, adoptamos Prometheus. Cada servidor ejecuta un agente node_exporter, que expone métricas del sistema — como uso de CPU, memoria, disco y red — a través de un endpoint dedicado. Prometheus consulta estos endpoints a intervalos regulares, almacenando los datos en su base de datos de series temporales.
En un entorno on-premise, disponer de un sistema de alertas sólido es fundamental para mantener la fiabilidad y detectar rápidamente cualquier incidencia. Al salir del entorno cloud, la detección temprana de anomalías — como picos inesperados en el consumo de recursos, caídas de servicios o degradación del rendimiento — se volvió aún más crítica.
Con Prometheus, pudimos configurar reglas de alerta personalizadas adaptadas a las necesidades específicas de nuestra plataforma. Estas alertas actúan como un sistema de aviso temprano, notificando inmediatamente al equipo en cuanto algo falla, lo que nos permite reaccionar rápidamente y evitar que pequeños problemas se conviertan en incidencias graves:
groups:
  - name: cpu_alerts
    rules:
      - alert: CPUUsageWarning
        expr: (100 - (avg by(instance)(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)) > 60
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High CPU usage detected"
          description: "The CPU usage is above 50% for more than 5 minutes."
      - alert: CPUUsageCritical
        expr: (100 - (avg by(instance)(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)) > 80
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "High CPU usage detected"
          description: "The CPU usage is above 80% for more than 5 minutes."Conclusión
Migrar la solución de la nube a un entorno on-premise fue un paso importante — y, en general, resultó ser un gran éxito. Conservamos todas las funcionalidades críticas de las que dependían nuestros clientes, mientras ganábamos un control mucho mayor sobre el entorno. Uno de los mayores logros fue eliminar los retrasos de arranque en frío: con todos los servicios funcionando de forma continua, todo responde ahora de manera instantánea.
Por supuesto, no estuvo exento de desafíos. Gestionarlo todo internamente implica asumir responsabilidades que antes quedaban cubiertas en la nube — como el mantenimiento de hardware, las copias de seguridad o las actualizaciones de seguridad. Perdimos parte de la comodidad y tranquilidad que proporciona AWS al encargarse de esas tareas “invisibles” en segundo plano.
Aun así, contar con total visibilidad y flexibilidad nos ha facilitado personalizar, monitorizar y escalar la plataforma para adaptarla a las necesidades específicas de cada cliente — especialmente para aquellos que requieren un funcionamiento completamente offline. Al final, se trata de un intercambio: más responsabilidad, pero también mayor libertad, control y fiabilidad en los escenarios que más importan a este cliente.

Comentarios