Apps für die Cloud: Was bei der Entwicklung von Cloud Native Apps beachtet werden sollte

Im vorangehenden Blogpost haben wir aufgezeigt, inwiefern uns DevOps, CI/CD und Automatisierung geholfen haben, um „cloud native“ Applikationen zu entwickeln. Wie im ersten Teil der Serie bereits erwähnt, haben wir die Methodik der „Twelve Factor App“ als Basis genommen und Konzepte daraus direkt angewandt und umgesetzt. In diesem Blogpost wollen wir nun aufzeigen, wie wir konkrete Herausforderungen angegangen sind und entsprechend agil Lösungen erarbeitet haben. Für uns war es stets wichtig, bereits während der Entwicklungsphase von Projekten auch die Betriebsplattform, Entwicklungs- und Deploymentprozesse und Engineering miteinzubeziehen.

Disclaimer: Die Lösungsansätze, die hier präsentiert werden, sind weder komplett noch für alle Projekte gleich anwendbar. In unserem Fall haben sie uns geholfen einerseits die Zuverlässigkeit und Stabilität der Applikation, andererseits die Produktivität und die Geschwindigkeit des Entwicklerteams zu steigern.

Das Erste, das wir feststellen mussten, ist, dass die Plattform – APPUiO Container Platform – dynamisch ist. Das heisst, dass sich die Cloud Plattform entsprechend bewegt. Es kommen neue Nodes hinzu, Nodes werden während Wartungsfenster neu gestartet oder während hoher Auslastung der Plattform hoch skaliert. Dies führte dazu, dass Applikations Container, die auf den entsprechenden Nodes liefen, auch neu gestartet oder verschoben werden. Dies hatte wiederum zur Folge hatte, dass wir mit den folgenden Herausforderungen zu kämpfen hatten:

Startzeit der Applikationen

Wird ein Applikationscontainer unserer Java EE 7 Applikation, welche in einem Wildfly 10 Container läuft, während eines Wartungsfensters verschoben, hat dies zur Folge, dass die Applikation neu gestartet wird. Starten des Applikationsservers, Connection Pools füllen, EJB Pools initialisieren, Hibernate Metadaten laden, DB Changes überprüfen und falls nötig ausführen, lokaler Fulltextsearch Index aufbauen, etc. sind entsprechend Tasks, die während dieser Phase laufen. Zu Beginn dauerte dies bei unserer Java EE 7 Applikation mehr als 90 Sekunden. Während dieser Zeit war die Applikation nicht verfügbar und Usern wird entsprechend ein Fehler angezeigt.

Wir haben die Startzeit des Applikationsservers optimiert und auf rund 20 Sekunden verkürzt. Dies verringert einerseits die Zeit des Unterbruchs und erhöht andererseits unsere Flexibilität massiv.

Skalierbarkeit

Um die wartungsbedingten Unterbrüche ebenfalls zu minimieren, die Last auf mehrere Container zu verteilen und dadurch entsprechend sparsamer mit Ressourcen umzugehen, haben wir uns entschieden, unsere Applikation skalierbar zu bauen. Wir haben bereits von Beginn an darauf geachtet, dass wir die Applikation stateless bauen und Session Pinning, lokale Filesysteme oder andere Abhängigkeiten vermeiden, resp. entsprechend den State in den Client oder die Datenbank verschieben. Dies erlaubt es uns, die Applikation hoch zu skalieren. Konkret bedeutet dies, dass jeweils mindestens zwei Instanzen der Applikation gleichzeitig laufen. Bei einem Unterbruch werden die Requests von der noch laufenden Instanz abgearbeitet.

Robustheit, Design for Failure und Selfhealing

Als nächsten Schritt mussten wir unserer Applikation beibringen, dass sie im Fehlerfall oder in ungewöhnlichen Situationen tolerant reagiert und sich nicht bloss mit einer Exception im Nirwana verabschiedet. War beispielsweise die Verbindung zur Datenbank unterbrochen oder die Datenbank nicht verfügbar, konnten auch nach dem Unterbruch keine Verbindungen mehr aufgebaut werden. Es half nur ein Restart der Applikation. Eine triviale Konfiguration des Connection Pools im Wildfly bewirkt, dass Connections entsprechend neu aufgebaut werden, falls sie unterbrochen wurden. Ein weiterer Aspekt sind lang laufende Prozesse, wie das Erstellen von Reports, Importieren oder Exportieren von Daten, welche teilweise mehrere Stunden liefen. Es gibt verschiedene Möglichkeiten, wie man diese robuster gegen Ausfälle machen kann. Man kann komplett auf sie verzichten (wenn möglich), sie als separate Prozesse neben der Applikation laufen lassen (damit die Abhängigkeiten möglichst gering sind) oder sie in Subprozesse unterteilen und so entsprechend ermöglichen, dass bei einem Unterbruch die Arbeit wiederaufgenommen werden kann.

Im Cloudumfeld ist es, bedingt durch das dynamische Umfeld, noch wichtiger, dass man Fehler einkalkuliert und im Sinne des Konzepts „Design for Failure“ Applikationen implementiert und robuster macht. Bei Microservices Architekturen oder allgemein verteilten Systemen ist die Anwendung dieses Konzepts unabdingbar.

Monitoring und Application Healthchecks

Nachdem die Applikation mittels Pipeline kontinuierlich deployed wurde, haben wir relativ schnell festgestellt, dass es essentiell ist, zu wissen, ob eine Applikation läuft oder nicht. Wir brauchten einerseits im Rahmen unserer CI/CD Pipeline eine Möglichkeit schnell und zuverlässig herauszufinden, ob unsere Applikation korrekt deployed wurde. Andererseits wollten wir unsere Testumgebungen überwachen, damit wir den Endkunden während der Testphase über entsprechende Ausfälle besser und frühzeitig informieren können.

Wir haben damit begonnen, sogenannte Healthchecks direkt in der Applikation zu implementieren. Diese gaben über ein REST Endpoint als JSON Response den Status der Applikation zurück.

Angelehnt an die darunterliegende Cloud Plattform, macht es für uns Sinn die folgenden Checks zu integrieren:

1. Liveness Check /health

  • Gibt zurück, ob die Applikation grundsätzlich läuft.
  • Falls nicht, wird sie von der Cloud Plattform neu gestartet. Dies ermöglicht uns einen weiteren Schritt im Bereich Robustheit und Selfhealing, wobei uns die Cloud Plattform unterstützt.

2. Readiness Check /health/ready

  • Gibt zurück, ob das Deployment funktioniert hat.
  • Ob die Applikation für Requests bereit ist, ist ein Indikator für die Cloud Plattform, um Client Requests auf die Applikation zu schicken.
  • Ob die Applikation voll funktionsfähig ist.

Diese beiden Checks sind nun sehr einfach in ein bestehendes Monitoring / Alarming integrier- und auswertbar. Als weiterer Vorteil können so 1:1 die Komponenten OpenShift / Kubernetes von der zugrundeliegenden Cloud Plattform (in dem Falle APPUiO) verwendet werden. So ist über diese Health Checks auch der Plattform bekannt, ob ein Deployment erfolgreich war oder nicht. Dies zeigt die folgende Grafik einer failenden Readiness Probe auf APPUiO.

Auf dieser Basis macht es Sinn, je nach Anforderungen, weiter aufzubauen. So können beispielsweise Cascading Checks implementiert werden. Grundsätzlich überprüft ein Service / eine Applikation nebst der eigenen Funktionsfähigkeit auch, ob die benötigten Backendservices oder externe Services – von denen der Service selber abhängig ist – noch laufen. So ist auf einen Blick im Monitoring oder während einer CI/CD Pipeline sichtbar, welche Komponenten laufen und auf applikatorischer Ebene ist fachlich definierbar, was für Auswirkungen ein fehlgeschlagener Check hat.

Response: HTTP/1.1 200 OK

{

   „status“: „UP“,

  „db“: {

   „status“: „UP“

  },

  „backend“: {

   „status“: „UP“

  }

}

Und im Fehlerfall, falls beispielsweise die Datenbank nicht erreichbar ist:

Response: HTTP/1.1 299 Warning

{

  „status“: „UP“,

  „db“: {

   „status“: „DOWN

  },

  „backend“: {

  „status“: „UP“

  }

}

Schreiben und Verarbeiten von Logs

Im Container Umfeld gilt es als „Best-Practice“, dass Logs aus dem Container über Standard Out und Error geschrieben werden und die Plattform entsprechend für das Zusammentragen, Speichern und Rotieren der Logs zuständig ist. In unserem Fall übernimmt diese Funktion der in OpenShift integrierte EFK (Elastic Search, Fluentd, Kibana) Stack. Die Logs aller Container werden gesammelt und entsprechend in den Elastic Search Service geladen. Damit die volle Funktionsfähigkeit des EFK Stack ausgeschöpft werden kann, ist es sinnvoll, Logs als JSON in einer strukturierten und durch den Log Stack interpretierbaren Form zu schreiben.

Datenbank Schemamigrationen und Updates

Im Sinne von CI/CD und des hohen Automatisierungsgrads, den wir erreichen wollen, behandeln wir Datenbank Schemamigrationen als Teil der Applikation, d.h. sie werden als Source Code implementiert, gebuildet, getestet und deployed. Die Datenbank selber kennt ihren aktuellen Stand: Während der Deploymentphase werden die jeweiligen Changes dann angewendet und ausgeführt. Es gibt eine Vielzahl an zur Verfügung stehenden Tools und Frameworks, die diese Funktionalität bieten. Darunter fallen u.a. Liquibase, Flyway, doctrine oder Rails selber. Unser Senior Software Engineer Philipp Grogg schreibt in seinem Blogpost über die Database Schema Migration.

Fazit

Im Sinne von Continuous Improvement ist es massgeblich, dass man Schritt für Schritt vorwärts geht und Betriebs-, Deployment- und allgemein Infrastrukturaspekte kontinuierlich beleuchtet und daran arbeitet. Es gibt einige Themen, die einem das Leben massiv vereinfachen, wenn sie von Anfang an beachtet und so implementiert werden. So kostet die Umsetzung einer Stateless Architektur von Anfang an kaum mehr, bringt aber in den Bereichen Reduktion von Komplexität und Skalierbarkeit massiven Mehrwert. Im Normalfall werden keine Google Scale Apps gebaut. Hochverfügbarkeit und Skalierbarkeit auf fachlicher und applikatorischer Ebene, UseCase bezogen zu implementieren, ist einfacher realisierbar und verringert die Komplexität von Applikationesarchitekturen massiv.

Schreib einen Kommentar