Migration von der Cloud auf On-Premise: Stolpersteine und Erkenntnisse bei serverlosen Lösungen
- Altiv Labs
- 28. Apr.
- 8 Min. Lesezeit
Aktualisiert: vor 18 Stunden
Bei Altiv Labs haben wir kürzlich einem Kunden geholfen, seine ehrgeizige Vision für das Internet der Dinge (IoT) zum Leben zu erwecken. Ihre Plattform, vollständig auf AWS aufgebaut, sammelt, verarbeitet und überwacht nahtlos Daten von Hunderten vernetzter Geräte an verschiedenen Standorten.
Die Lösung kombiniert die Echtzeitaufnahme von Daten, intelligente Nachrichtenverarbeitung und -weiterleitung sowie georeferenzierte Datenspeicherung – alles verbunden durch webbasierte Dashboards, die eine einfache Analyse und fundierte Entscheidungen ermöglichen. Im Hintergrund sorgt eine Suite aus serverlosen Diensten und Message Queues dafür, dass alles reibungslos läuft, während das intuitive Frontend den Teams eine einheitliche Übersicht und leistungsstarke Werkzeuge für das Management kritischer Prozesse bietet.
Entwickelt für Effizienz und Skalierbarkeit, unterstützte die Plattform erfolgreich eine breite Palette von Kunden und ermöglichte ein schnelles Wachstum – angetrieben durch ihre cloud-native Architektur.

Doch dann stellte sich eine neue Herausforderung: Ein Kunde benötigte die Lösung vollständig offline, ohne jegliche Abhängigkeit von Cloud-Diensten. Um diese Anforderung zu erfüllen, machten wir uns daran, die Plattform in eine On-Premise-Umgebung zu migrieren – mit zwei klaren Zielen:
So viel wie möglich von der bestehenden Codebasis beibehalten.
Sicherstellen, dass die Offline-Version denselben Funktionsumfang und dieselbe Leistung bietet wie die ursprüngliche Cloud-Lösung.
Unser erster Schritt bestand darin, jede einzelne Komponente der Plattform sorgfältig neu zu überdenken und zu planen, wie sie an die neue Offline-Umgebung angepasst werden könnte. Früh entschieden wir uns dafür, die gesamte Infrastruktur mit Docker zu orchestrieren – eine Wahl, die sowohl die Bereitstellung als auch die langfristige Wartung erheblich vereinfachte.
Infrastrukturaufbau
Wir begannen damit, die Infrastruktur auf zwei Hauptserver zu verteilen:
Anwendungsserver: Übernahm die Nachrichtenverarbeitung und hostete alle zentralen Dienste.
Datenbankserver: Ausschließlich für PostgreSQL vorgesehen, um sicherzustellen, dass intensive Abfragen die Leistung des übrigen Systems nicht beeinträchtigen.
Da die Lösung innerhalb eines internen Netzwerks betrieben werden sollte, implementierten wir ein internes DNS und erstellten eigene Sicherheitszertifikate. Dafür richteten wir eine eigene Certificate Authority (CA) ein und installierten diese auf allen Netzwerkgeräten, um eine durchgehende Authentifizierung und Verschlüsselung innerhalb der gesamten Umgebung sicherzustellen.
Stack-Komponenten

MQTT Broker:
Für die Gerätekommunikation benötigten wir einen Open-Source-Broker, der leichtgewichtig und sicher ist. Unsere Wahl fiel auf Mosquitto (Eclipse Foundation), das perfekt unseren Anforderungen entsprach – insbesondere dank seiner robusten Steuerung von Geräteberechtigungen und Authentifizierung.
PostgreSQL with PostGIS:
Dieser Teil war unkompliziert. Wir konnten nahezu alle Datenbank-Erstellungsskripte aus der Cloud-Umgebung wiederverwenden und mussten nur kleinere Anpassungen für die On-Premise-Umgebung vornehmen.
RabbitMQ:
Um den Nachrichtenfluss zu steuern und die Ausfallsicherheit zu gewährleisten, setzten wir RabbitMQ als Message-Queue-System ein. Zusätzlich führten wir einen Zwischendienst ein, den wir Bridge nannten, um die Protokolle MQTT und AMQP miteinander zu verbinden.
Über die reine Protokollübersetzung hinaus spielte die Bridge eine zentrale Rolle für die Skalierung: Sie verteilte Nachrichten auf mehrere Queues, sodass mehrere Consumer sie parallel verarbeiten konnten (darauf gehen wir später noch näher ein).
Migration der IoT-Lambdas von der Cloud auf On Premise
Nachdem Mosquitto, PostgreSQL und RabbitMQ eingerichtet waren, machten wir uns an die Migration der AWS IoT-Lambdas. In unserer ursprünglichen Cloud-Architektur waren diese serverlosen Funktionen ereignisgesteuert: Sie verarbeiteten eingehende Gerätemeldungen, transformierten die Daten und lösten auf Basis von IoT-Ereignissen Workflows aus – alles in Echtzeit.
Für die On-Premise-Version implementierten wir diese Lambdas neu als Node.js-Anwendungen und ersetzten die Lambda.invoke-Aufrufe durch lokale Funktionsaufrufe.
Die größte Herausforderung lag in der Performance: Jede Nachricht benötigte etwa 300 Millisekunden zur Verarbeitung. Anfangs lief alles in einem Single-Threaded-Modell mit nur einer einzigen Queue – was den Durchsatz erheblich begrenzte. Um die Leistung zu steigern, versuchten wir zunächst, die Event Loop von Node.js zu optimieren, indem wir pro Gerät jeweils ein Promise verarbeiteten:
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);
}
}
});
Auch wenn dieser Ansatz einige Verbesserungen brachte, nutzte er dennoch nicht das volle Verarbeitungspotenzial des Servers aus.
Skalierung mit Multi-Threading und Multi-Queues
Da die Ressourcen des Servers weiterhin nicht vollständig ausgelastet waren, implementierten wir eine Architektur mit mehreren Threads und Queues. Mithilfe des Cluster-Moduls von Node.js erstellten wir einen Worker-Prozess pro CPU-Kern, wobei jeder Worker seine eigene dedizierte Queue verarbeitete.
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...
}
Nachrichtenverteilung über Queues
Eine zentrale Herausforderung in diesem neuen Setup bestand darin sicherzustellen, dass trotz paralleler Verarbeitung die Nachrichten eines Geräts weiterhin in der richtigen Reihenfolge bearbeitet wurden.
Um dies zu erreichen, nutzten wir die MAC-Adresse jedes Geräts als Grundlage für die Verteilung:
Wir berechneten einen SHA-256-Hash der MAC-Adresse und verwendeten diesen, um die Nachrichten konsistent einer der verfügbaren Queues zuzuordnen – wobei jede Queue einem bestimmten Server-Thread zugewiesen war:
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,
});
Mit diesem Ansatz konnten wir schließlich die Ressourcen des Servers vollständig ausnutzen und die erforderliche Performance erreichen, um das erwartete Nachrichtenvolumen zu bewältigen.

Migration der API-Lambdas
Auf der Backend-Seite standen wir vor der Herausforderung, die API-Lambdas zu migrieren. Dazu refaktorierten wir die Funktionen in ein Node.js-Monolith basierend auf Express, mit dem Ziel, so viel wie möglich von der bestehenden Codebasis zu erhalten und gleichzeitig die zukünftige Wartung zu vereinfachen.
Standardisierung der Lambda-Integration
In der ursprünglichen Cloud-basierten Lösung war jede API-Route als eigene AWS-Lambda-Funktion implementiert, entsprechend den gängigen AWS-Konventionen. Um zu vermeiden, jede Lambda-Funktion komplett neu schreiben zu müssen, entwickelten wir eine Middleware-Schicht, die das von AWS üblicherweise verwendete Event-Format simuliert. Dadurch konnten die Lambdas mit nur minimalen Anpassungen weiterverwendet werden.
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();
}
Auf diese Weise fügten wir die Middleware einfach vor dem Aufruf der jeweiligen Lambda-Funktion ein, sodass die gesamte Parameterverarbeitung vollständig transparent ablief.
Automatisierung der Routen mit Swagger
Da wir bereits eine Swagger-Datei zur Dokumentation aller Routen verwendeten, nutzten wir die Gelegenheit, die Erstellung der Endpunkte in Express zu automatisieren. Wir entwickelten ein Skript, das die Swagger-Definition analysiert, die Routen-Skelette generiert und die Ordner basierend auf der vordefinierten Struktur organisiert.

Nachdem die Routengenerierung automatisiert war, bestand die einzige verbleibende manuelle Aufgabe darin, jede Express-Route mit der entsprechenden Lambda-Funktion zu verbinden und sicherzustellen, dass die Antworten korrekt verarbeitet wurden.
AWS Cognito ersetzen: Migration von der Cloud auf On-Premise
Die Authentifizierung war einer der kritischsten Teile der Migration und wurde ursprünglich von AWS Cognito verwaltet. Um dieselben Authentifizierungs- und Autorisierungsregeln beizubehalten, entwickelten wir unseren eigenen, auf JWT basierenden Authentifizierungsdienst und replizierten dabei die ursprüngliche Logik der Berechtigungsprüfung. Hier ein Beispiel für die Implementierung der 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;
}
}
}
Dieser Ansatz ermöglichte es uns, dieselben Authentifizierungs- und Autorisierungsregeln wie bei AWS Cognito beizubehalten, jedoch in einer vollständig unabhängigen Umgebung – basierend auf JWTs und mit direkter Verwaltung aller Berechtigungen über die Datenbank.
Migration des Frontends
Die Migration des Frontends verlief relativ unkompliziert. Unser ursprünglicher Stack setzte bereits auf React für die Benutzeroberfläche, die über AWS CloudFront ausgeliefert wurde. In der On-Premise-Umgebung behielten wir denselben React-Build bei, servierten die statischen Dateien jedoch über einen Nginx-Server.
Die SSL-Zertifikate konfigurierten wir manuell, um sicherzustellen, dass die gesamte Anwendung weiterhin über HTTPS erreichbar blieb.
Monitoring und Logging
Ein weiterer wichtiger Aspekt der Migration war das Management von Systemlogs und Monitoring. In der AWS-Umgebung übernahm AWS CloudWatch das zentrale Logging, die Metrikenerfassung und die Alarmierung für alle unsere Dienste, was es einfach machte, Logs zu visualisieren, den Systemzustand zu überwachen und Vorfälle zu untersuchen.
Mit dem Umzug in die On-Premise-Umgebung brauchten wir eine Lösung, die denselben Grad an Transparenz und Zuverlässigkeit gewährleistet – jedoch mit Open-Source-Tools unter unserer eigenen Kontrolle.
Log-Management mit logrotate
Für das Log-Management konfigurierten wir logrotate in allen Service-Containern und auf den Host-Systemen. Jeder Dienst schreibt seine Logs in lokale Dateien, während logrotate anhand definierter Richtlinien die automatische Rotation, Komprimierung und Aufbewahrung der Logs übernimmt. Dadurch bleiben die Logs leicht einsehbar, ohne das Risiko eines Speicherplatzmangels.
/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
}
Metriken und Monitoring mit Prometheus
Für das Echtzeit-Monitoring, die Metrikenerfassung und die Alarmierung setzten wir auf Prometheus. Auf jedem Server läuft ein node_exporter-Agent, der Systemmetriken – wie CPU-, Speicher-, Festplatten- und Netzwerkauslastung – über einen dedizierten Endpunkt bereitstellt. Prometheus ruft diese Endpunkte in regelmäßigen Abständen ab und speichert die Daten in seiner Zeitreihendatenbank.
In einer On-Premise-Umgebung ist ein starkes Alarmsystem entscheidend, um die Zuverlässigkeit aufrechtzuerhalten und Probleme frühzeitig zu erkennen. Nach dem Wechsel weg von der Cloud wurde die frühzeitige Erkennung von Anomalien – wie unerwarteten Ressourcenspitzen, Dienstausfällen oder Leistungseinbußen – noch wichtiger.
Mit Prometheus konnten wir maßgeschneiderte Alarmierungsregeln erstellen, die genau auf die spezifischen Anforderungen unserer Plattform abgestimmt sind. Diese Alarme dienen als Frühwarnsystem und benachrichtigen das Team sofort, sobald etwas schiefläuft, sodass wir schnell reagieren und kleine Probleme daran hindern können, sich zu größeren Störungen auszuwachsen:
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."
Fazit
Die Migration der Lösung von der Cloud in eine On-Premise-Umgebung war ein großer Schritt – und insgesamt ein voller Erfolg. Wir konnten alle wichtigen Funktionen, auf die unsere Kunden angewiesen sind, erhalten und gleichzeitig deutlich mehr Kontrolle über die Umgebung gewinnen. Einer der größten Vorteile war die Beseitigung von Cold-Start-Verzögerungen: Da alle Dienste nun ständig laufen, reagieren sie sofort.
Natürlich verlief nicht alles ohne Herausforderungen. Alles selbst zu verwalten bedeutet, Aufgaben zu übernehmen, die zuvor von der Cloud abgedeckt wurden – darunter Hardwarewartung, Backups und Sicherheitsupdates. Wir haben ein Stück der Bequemlichkeit und Sicherheit verloren, die AWS durch die Verwaltung dieser „unsichtbaren“ Aufgaben im Hintergrund geboten hatte.
Dennoch hat uns die vollständige Transparenz und Flexibilität ermöglicht, die Plattform einfacher anzupassen, zu überwachen und zu skalieren, um den individuellen Anforderungen jedes Kunden gerecht zu werden – insbesondere für diejenigen, die einen vollständig Offline-Betrieb benötigen. Am Ende ist es ein klassischer Kompromiss: mehr Verantwortung, aber auch mehr Freiheit, Kontrolle und Zuverlässigkeit in den Szenarien, die für diesen Kunden am wichtigsten sind.
Comentários