Tech Talk: "Multi-Tenant SaaS App" mit Thomas Ebner von Ramsauer & Stürmer

Tech Talk: "Multi-Tenant SaaS App" mit Thomas Ebner von Ramsauer & Stürmer

Ja hallo! Mein Name ist Thomas Ebner, ich bin Software Entwickler bei Ramsauer und Stürmer und wir entwickeln sozusagen ein branchenübergreifendes ERP-System. Meine Hauptaufgabengebiete – oder meine Tätigkeitsbereiche – sind folgende in der Firma: Web Development, dann die Umsetzung und Optimierung, Konzeptionierung von DevOps Prozessen. Und ich beschäftige mich noch zusätzlich mit Public Cloud Technologien, vor allem in der Azure Cloud.

Das heutige Thema sind Multi Tenant Software as a Service Applications, da das aber ein ziemlich weitgreifendes Thema ist, werde ich mich heute vor allem mit Database Tenancy Patterns beschäftigen.

Zuerst ein kurzer roter Faden durch den heutigen Vortrag: als erstes einmal der Use Case, welche Voraussetzungen haben wir und welche Ziele oder Ergebnisse wollen wir eigentlich erreichen? Dann, ein kleines bisschen Theorie, da werde ich drei grundsätzliche Patterns vorstellen – es gibt natürlich mehr Patterns oder sozusagen eine Mischung dieser Patterns, aber die drei Patterns, die ich vorstelle, sollen eigentlich ganz gut die Eigenschaften und die Vor- und Nachteile dieser Probleme erklären. Dann werden wir eines dieser Patterns nehmen und versuchen sozusagen in der Azure Cloud ein kleines realworld Example nachzubauen – das heißt, wie bekomme ich dieses Pattern wirklich mit den richtigen Infrastrukturteilchen in die Cloud und kann ein einfaches Multi Tenant System aufsetzen? Wie kann ich dann Changes – also neue Features, die was Datenbankmigrationen erfordern – in diese Cloud Architektur deployen? Und wie kann ich zur Laufzeit – ohne Downtimes – neue Tenants aufsetzen? Zum Schluss ein kleines Fazit, so drei oder vier wichtige Punkte, die was meines Erachtens immer wichtig sind, wenn man sich mit Multi-Tenant Patterns auf Datenbankebene beschäftigen will.

Zuerst einmal eine kleine Abgrenzung: von was gehen wir eigentlich aus? Wir sagen wir haben eine Web Applikation, die ist über eine Web API ansteuerbar und hat eine SQL Datenbank als Datastore. Wichtig ist auch – wir beschäftigen uns jetzt heute nicht mit Authentifizierung und Authorisierung – das heißt wir gehen davon aus, der Client stellt seinen Web Request an unsere Applikation und besitzt schon einen Token – zum Beispiel eine JSON Web Token – und ist schon authentifiziert. Wichtig ist auch, unsere App – wie so eine Multi Tenant App sein soll – soll von mehreren Customern, also von mehr Tenants verwendet werden können. Das bedeutet natürlich auch, dass die Daten, auf die ein Tenant in einem User operiert immer per Tenant und per User skalieren. Ganz wichtig auch – wir wollen unsere Applikation als Software as a Service delivern. Das heißt wir builden die Applikation, wir deployen die Applikation und wir deployen sie auf unsere eigens erstellte Infrastruktur in der Public Cloud – in diesem Anwendungsbeispiel auf die Azure Cloud.

Zuerst einmal die drei gängigsten Patterns.

Das einfache Pattern – eine Single-Tenant Applikation. Das heißt wir haben einen Applikation Server und eine Datenbank. Und dieser Applikation Server wird genau von einem Tenant bedient.

Das zweite Pattern ist eine Multi-Tenant App mit shared Databases. Das heißt, alle Tenants teilen sich den gemeinsamen Datastore.

Das letzte Pattern – das wir auch verwenden werden für unser realworld Example – ist dann eine Multi-Tenant App mit einer Datenbank per Tenant. Das heißt jeder Tenant besitzt seine eigene Datenbank.

Zuerst das einfachste Beispiel – wir besitzen eine Single Tenant App und wollen daraus eine Multi Tenant App basteln. Im einfachsten Fall, wenn wir nicht den Anwendungscode ändern wollen, würden wir einfach für jeden Tenant die gesamte Infrastruktur duplizieren. Das heißt jeder Client, der sich mit einer Tenant Applikation verbindet, hat eine eigene Tenant-API URL und feuert seine Requests gegen seine eigene Tenant ab. Und hier sehen wir schon das erste Problem: wir haben eine sehr hohe Komplexität in der Wartung und in der Provisionierung neuer Tenants. Das heißt wir müssen praktisch für jeden Tenant die komplette Infrastruktur neu aufsetzen. In diesem Diagramm ist es noch einfach, aber was ist zum Beispiel wenn wir eine App als Microservice deployed haben mit mehreren Datenbanken? Dann wird die ganze Angelegenheit ziemlich komplex und auch ziemlich teuer. Die Isolation der einzelnen Tenants ist ziemlich hoch – sie ist auf App Level – und natürlich die Komplexität der Entwicklung des Anwendungscodes ist sehr niedrig, weil wir fast keine Änderungen machen müssen im Anwendungscode, sondern nur im Deployment der Infrastruktur.

Das zweite Beispiel ist eine Multi-Tenant App mit einer shared Database. Das heißt wir haben – jetzt nicht wie im vorigen Beispiel – für jeden Tenant eine eigens deployte Appikation, sondern die wird von allen Tenants gleich verwendet. Jedoch teilen sich diese Tenants eine Datenbank. Wichtig hier in diesem Beispiel ist zu beachten, dass in jedem Client Request – zum Beispiel über einen JSON Web Token – die Tenant-ID des einzelnen Tenants verfügbar sein muss. Das heißt, jeder Datensatz wird mit dieser Tenant-ID zu einem bestimmten Tenant abgespeichert und über diese Tenant-ID über die gleiche Datenbank ausgelesen. Der Vorteil dieser Applikationsarchitektur ist eine kleine Komplexität in der Wartung und fast keine Komplexität in der Provisionierung neuer Tenants, weil die Architektur schon vorhanden ist. Das größte Problem, was diese Architektur hier darstellt ist die sehr niedrige Isolation der Daten. Besitze ich einen Tenant, der sehr viele Daten generiert, kann sich das performancetechnisch auf andere Tenants auswirken. Und natürlich können wir hier auch nicht beliebig viele Tenants aufsetzen, weil der Datenbankserver oder die Datenbank selbst nur bis zu einem gewissen Bereich hochskalierbar ist – das heißt – nach sagen wir – 20 Tenants können wir mit dieser Architektur keine neuen Tenants mehr aufsetzen. Und natürlich die Komplexität der Entwicklung ist sehr hoch. Ich muss mit allen Queries und mit allen Datenbankabfragen über diese Tenant-ID meine Daten auslesen.

Jetzt zum letzten Pattern – das sogenannte Multi-Tenant App with database-per-tenant. Das ist auch meiner Meinung nach das gängigste Pattern. Hier haben wir – genau wie im vorigen Beispiel – ebenfalls nur eine Applikation installiert. Also einen Applikationsserver. Aber jedoch hat jeder Tenant seine eigene Datenbank. Um dieses Problem zu lösen – dass jeder Client Request von einem bestimmten Tenant zu seiner Tenant-Datenbank zugewiesen wird – brauchen wir einen sogenannten Tenant Catalog. Das ist hier in diesem Beispiel die vierte Datenbank, die mit „Catalog“ bezeichnet ist. In dieser Datenbank ist nichts anderes abgespeichert, wie die Tenant-ID vom Client Request und zum Beispiel der dazugehörige Connection-String zur Datenbank. Somit kann über jeden Client-Request für einen Tenant die richtige Datenbank zugewiesen werden. Der Vorteil ist: wir haben eine mittlere Komplexität der Wartung und der Provisionierung der Tenants. Wir haben eine hohe Datenisolierung – das heißt wir können aus Compliance Gründen sofort wieder Daten von einem Tenant löschen, wir können sie in einem anderen Datenbankserver überführen. Und wir haben auch nicht wirklich eine sehr hohe Komplexität in der Entwicklung des Anwendungscodes. Das heißt bei modernen Application-Frameworks – wie ASP.NET Core oder Spring Boot – können wir Middlewares einführen, diese lesen die Tenant-ID aus dem JSON Web Token aus, holen sich den Database-Connection-String und connecten sich zur richtigen Datenbank – es ist also sehr überschaubar.

Darum wollen wir uns auch dieses Beispiel nehmen und in ein realworld Example in Azure überführen. Und hier werden wir merken, dass das Ganze noch ein kleines bisschen komplizierter wird. Und zwar, es hat sich eigentlich nicht sehr viel geändert, bis auf hier sozusagen das managen des Tenant Catalogs. Das heißt unser Tenant Catalog ist in diesem Beispiel nicht mehr eine einfache Datenbank, sondern eine wirkliche Single Tenant App. Das heiß sie hat einen eigenen Web-Service und dieser Web-Service ist mit dem Tenant Catalog verbunden. Zusätzlich verwenden wir auch einen Azure Key Vault, ein Azure Key Vault ist nichts anderes wie ein Secret Store, wo wir für einen bestimmten Key ein Secret – einen Connection String oder ein Passwort – hinterlegen können. Das ist generell immer eine gute Idee in einer Cloud Architektur seine Secrets zentral abzulegen, damit ich zentral auf diese Secrets Zugriff habe. Sei es für eine Deployment Pipeline oder für für Reporting Systeme. Natürlich, es ist viel sicherer! Das heißt, unsere App sozusagen verbindet sich nicht jetzt direkt mit der Datenbank auf den Tenant Catalog, sondern wiederum über eine Web-API mit diesem Tenant-Service. Und schickt wieder mit diesem Request die Tenant-ID vom Client mit. Dieser Tenant-Service ermittelt mit einem Key in der Datenbank den Key für den Key Vault, holt sich den Connection-String und liefert diesen Datenbank Connection-String zu der eigentlichen Applikation zurück. Weil wir hier natürlich einige Round Trips erzeugen und unser Bottleneck der Key Vault ist – das heißt die Key Vaults sind nicht wirklich sehr performant – ist es immer ratsam, einen Distributed Cache einzuführen. In unserem Beispiel wäre das ein Azure Redis Cache, das heißt die App geht nicht zuerst direkt auf ein Tenant Service, sie sieht im Cache nach, ist für meine Tenant-ID der Connection String hinterlegt – wenn ja, dann hole ich ihn mir vom Cache, wenn nicht, dann gehe ich auf den Tenant-Service, hole mir den Connection Key und lege ihn auf dann im Redis Cache ab.

Unten vielleicht auch noch zu erklären – unsere drei Tenant Datenbanken laufen in einem Elastic-Database Pool. Unter einem Elastic-Database Pool kann man sich wie nichts anderes als einen SQL-Server vorstellen, der seine Ressourcen je nach Auslastung auf die richtige Datenbank verteilt und somit noch sehr viel preiswerter ist als ein normaler Datenbankserver, wo ich für jede Datenbank einzeln bezahlen muss.

Das Problem, das uns diese Architektur stellt – weil wir für jeden Tenant eine eigene Datenbank besitzen – ist, wie kann ich Changes – das heißt neue Features, die eine Datenbankmigration – in diese Architektur deployen und gleichzeitig alle Datenbank Schemas meiner drei Tenant-Datenbanken hochziehen? Das funktioniert bei uns über ein Azure DevOps Tool. Wir verwenden hier Azure DevOps Server. Das heißt wir haben irgendwo remote ein Git Repository und in dieses Git Repository wird ein Pull-Request erstellt für die Änderungen. Wichtig hier zu wissen ist, in diesen Pull Requests müssen auch die Änderungen der Datenmigration vorhanden sein. Das heißt es ist immer empfehlenswert – wenn man mit Multi-Tenant-Architekturen arbeitet und mit Database Tenancy Patterns – immer ein DB Migration Tool zu verwenden! Bei uns ist das zum Beispiel Entity Framework Core. Somit kann man sich sozusagen aus den Entitäten aus der C# Klassen sein SQL DB Migration Script generieren. Dieser Pull-Request startet mir eine Build Pipeline los. Wichtig ist hier, die Build Pipeline muss mir in diesem Fall zwei Artefacts liefern – zum einen das App Artefact, was ich in die Applikation deployen will, plus mein DB Migration Script. Das heißt, konnte ich mir die Build Pipeline, das Application Artefact und das Migration Artefact erfolgreich generieren, sind alle Tests positiv, dann startet mir diese Build Pipeline meine Deployment Pipeline an. Diese Deployment Pipeline deployt mir ganz normal mein App Artefact. Und jetzt ist es wichtig – wie kann ich jetzt dieses DB Migration Script gleichzeitig auf alle meine Datenbanken ausführen? Hier verwenden wir in Azure nichts anderes wie einen Elastic Job. Ein Elastic Job ist nichts anderes – dem gebe ich ein DB Migration Script rein und der datet mir alle Datenbanken in einem SQL Server oder Elastic Pull hoch. Das heißt, ich habe jetzt ein SQL Migration Script, übergebe es dem Elastic Job und der migriert mir alle Datenbanken in den Elastic Pool. Ich kann das natürlich noch viel feingranularer machen – das heißt ich könnte jetzt nicht sagen, aktualisiere mir alle Datenbanken im Pool, ich könnte auch einzelne Datenbanken angeben. Aber in diesem Beispiel vereinfacht das die Angelegenheit um einiges! Hier noch ein wichtiger Hinweis: es ist hier neben den drei Tenant Datenbanken noch eine sogenannte Schema DB. Die Schema DB ist nichts anderes wie eine leere Datenbank auf die sich niemals ein Tenant verbinden darf, sondern sie ist nur immer eine Momentaufnahme des aktuell deployten DB Migration Scripts.

Und diese Datenbank brauchen wir jetzt für das nächste Problem. Wie können wir neue Tenants zur Runtime provisionieren? Wichtig ist, bitte aufpassen, hier ist nicht ein User Client gemeint, hier ist ein Admin Client gemeint. Das heißt, wir verwenden jetzt für das Provisionieren neuer Tenants unseren Tenant Service. Das heißt, ein Admin Client bedient eine Web-API des Tenant Service, wo er auch neue Tenants erstellen kann. Ein Consultant, ein Projektleiter, gibt die Informationen des neuen Tenants in die Web Oberfläche ein. Der Tenant Service erzeugt mir dann im Hintergrund einen Connection String, legt mir diesen Connection String in den Key Vault und den Key für diesen Connection String in der Catalog DB ab und triggert mir in meinem Azure DevOps System eine eigene Deployment Pipeline. Und diese Deployment Pipeline startet mir nichts anderes wie ein Azure CLI Script. Dieses Azure CLI Script kopiert mir einfach diese Schema DB – wo sich niemals Nutzerdaten befinden dürfen – auf eine neue Datenbank. Und ist dieses Deployment fertig, kann der neue Tenant schon arbeiten. Ich habe keine Downtimes und keine Wartungsfenster.

Zum Schluss ein kleines Fazit, was sind meiner Meinung nach die vier wichtigsten Punkte, wenn man sich mit Database per Tenancy Patterns beschäftigt?

Eigentlich ein Punkt über den wir jetzt wenig gesprochen haben ist „Automate your Infrastructure.“ Wir haben uns jetzt wirklich im Multi-Tenant Bereich nur über das Thema Database Tenancy Patterns unterhalten und merken, dass unsere Architektur sehr viele Infrastruktur Teilchen benötigt, um Änderungen deployen zu können und um neue Tenants provisionieren zu können. In einer wirklich produktiven Umgebung haben wir zum Beispiel eine Development Umgebung, Test Umgebung, Staging Umgebung, Production Umgebung. Würden wir hier Änderungen in einer Umgebung vornehmen, müssten wir alles manuell nachziehen – das ist sehr fehleranfällig. Das heißt, Infrastructure as Code ist hier ein sehr wichtiges Thema.

Grundlegendes Thema – immer wenn man sich mit Multi Tenancy auf Datenbankebene beschäftigt – immer ein Database Migration Tool beschäftigen. Man ist Datenbank unabhängiger und man kann sich automatisiert seine Datenbank Migrationen erzeugen. Wichtig ist auch immer für Deployments und Tenant Provisionierungen ist es sehr ratsam nicht das direkt aus dem Applikationscode zu machen, sondern immer über ein DevOps System über Pipelines. Das heißt ich muss nicht – wenn ich meine Deployments oder meine Provisionierung der Tenants ändere – einen Anwendungscode ändern und dann diesen deployen, sondern ich kann einfach mein Script ändern und in der Pipeline neu abspeichern. Plus meine Pipeline bietet mir sehr viele nützliche Funktionalitäten, wie Historien, Notifications bei Fehlern, die ich mir sonst mühsam in meinem Applikationscode nachprogrammieren müsste.

Ja, das war mein Vortrag und bin gerne für Fragen, Anregungen und Kritik offen!