Die Zeiten, in denen wir für jede Anwendung ein eigenes Ökosystem gebaut haben, sind (Gott sei Dank) vorbei. Kaum jemand setzt heute für eine Webanwendung noch einen Server von Grund auf neu auf und installiert eine ganze Batterie an Abhängigkeiten per Hand. Statt dessen setzen wir zunehmend auf Plattformen und „Platform as a Service“-Anbieter.
Doch damit unsere Anwendungen hier ihre volle Leistung ausspielen können, bedarf es einer Reihe an Voraussetzungen, die wir schaffen müssen, wollen wir unser Ziel erreichen: eine möglichst „geräuschlose“ Integration unserer Software in ihre Umgebung.
Die Grundsätze der sogenannten „Twelve-Factor App“ sind eine Zusammenfassung von Prinzipien, die uns hierbei helfen: Prinzipien, die sich bewährt haben, und dafür sorgen, dass wir uns als Entwickler*innen auch tatsächlich auf das Entwickeln konzentrieren können.
Die Geschichte der Twelve-Factor App
Ihren Ursprung haben die Grundsätze einer Twelve-Factor App bei Heroku, einem der ersten Platform-as-a-Service-Anbieter. Präsentiert wurden sie erstmals 2011 von Adam Wiggins, einem der Gründer und ehemaligem CTO von Heroku.
Zwar wirkten (und waren) sie zunächst sehr stark darauf ausgelegt, Anwendungen möglichst reibungslos auf Heroku als Plattform zu deployen, dennoch sind sie allgemein genug, um auch als Ratgeber für das Deployment bei anderen Anbietern wirken zu können.
Wir können sogar noch einen Schritt weitergehen: Auch für Anwendungen, die nicht bei Platform-as-a-Service-Anbietern, sondern auf “klassischen” Systemen deployed werden, lassen sich viele der Ideen und Prinzipien übertragen und dazu nutzen, auch in solchen Einsatzgebieten den Entwicklungs- und Deploymentprozess einfacher und komfortabler zu gestalten.
Werfen wir also einen näheren Blick auf die zwölf Prinzipien.
1. Codebasis
Als Grundlage für eine Twelve-Factor App dient immer eine Codebasis in genau einem Repository unter Versionskontrolle. Aus dieser einen Codebasis werden (idealerweise bei jedem Commit) neue Applikationsversionen gebaut, die anschließend auf dem Zielsystem deployed werden.
Die eine Codebasis dient hierbei als Quelle für unterschiedliche Zielsysteme, wie z.B. das Test- und Produktionssystem. Auch das lokale Ausführen der Anwendung auf einem Entwicklerrechner ist hierbei “nur” ein weiteres Zielsystem, das aus der Codebasis “bedient” wird.
Jedes Deployment, das heißt jede laufende Anwendungsversion, kann dabei einem eindeutigen Zeitpunkt (einem Commit) innerhalb der Versionshistorie zugeordnen werden. Auffälligkeiten oder Defects in der Produktionsumgebung lassen sich daher sofort einem Versionsstand zuordnen, was unter anderem das Auffinden der entsprechenden Funktionalität im Debugging deutlich erleichtert.
2. Abhängigkeiten
So gut wie keine moderne Anwendung kommt heutzutage ohne externe Abhängigkeiten aus.
Das Management dieser Abhängigkeiten ist hierbei eine Kunst für sich. Wurden früher Bibliotheken, Frameworks oder andere Ressourcen häufig manuell bezogen und verwaltet, so haben sich hierfür für nahezu alle Entwicklungsplattformen eigene Anwendungen zur Abhängigkeitsverwaltung etabliert.
Ob Maven für Java, RubyGems für Ruby, Hex für Elixir oder diverse andere Package-Manager – alle verfolgen das gleiche Ziel: Abhängigkeiten zu deklarieren. Das eigentliche Auflösen dieser Abhängigkeiten, also das Beziehen der notwendigen Ressourcen, wird hierbei vom Package-Manager erledigt. Der/die Entwickler*in kann sich darauf konzentrieren zu definieren, was verwendet werden soll, und muss sich nicht noch zusätzlich damit beschäftigen, wie die Abhängigkeit eingebunden wird und von woher sie bezogen werden kann.
Ebenso wie die eigentlichen Quellen ist die Deklaration der Abhängigkeiten Teil der Codebasis, womit zu jedem Zeitpunkt nicht nur der Stand der eigenen Quelldateien ersichtlich ist, sondern auch genau welche Version welcher Abhängigkeiten beim Erstellen einer Anwendung verwendet wurde.
Das explizite Deklarieren von Abhängigkeiten bedeutet auch, dass es keine weiteren oder zusätzlichen undeklarierten Abhängigkeiten geben darf. Sämtliche Annahmen über die Umgebung, in der die Anwendung später läuft, müssen ebenfalls deklarativ ausgedrückt und vom Zielsystem verstanden werden. Nur so ist ein reproduzierbarer System- und Anwendungsaufbau möglich.
3. Konfiguration
Im Abschnitt 1 haben wir definiert, dass eine Anwendung aus genau einer Codebasis heraus gebaut wird, aber in mehrere Zielumgebungen deployed werden kann. Bestimmte Teile der Anwendung sollen sich aber dennoch unterschiedlich verhalten, je nachdem in welcher Zielumgebung sie ausgeführt werden.
Eine einfache Ausprägung einer solchen unterschiedlichen Konfiguration pro Umgebung ist die verwendete Datenbank. Auf dem Testsystem soll schließlich eine andere Datenbank als auf dem Produktionssystem verwendet werden.
Die genaue Konfiguration der Datenbank (und jedes andere Konfigurationselement) findet in einer Twelve-Factor App nicht innerhalb der Anwendung selbst statt, sondern wird immer von außen an die Anwendung übergeben.
Die Anwendung, die aus einem definierten Stand der Codebasis gebaut wird, ist hierbei für die unterschiedlichen Zielumgebungen immer identisch. Eine Java-Anwendung zum Beispiel wird die exakt gleichen JAR-Archive für die Test- und Produktionsumgebung verwenden. Die spezifischen Konfigurationselemente für eben diese Umgebungen werden in der Umgebung für die Anwendung definiert und können daher von der Anwendung selbst als Umgebungsvariablen ausgelesen werden. Innerhalb der Anwendung können dann die Werte, die aus den Umgebungsvariablen ausgelesen wurden, für beliebige Aktionen verwendet werden.
4. Backing Services
Die meisten Anwendungen benötigen externe Dienste, um ihren gesamten Funktionsumfang zu erbringen. Das bekannteste Beispiel hierfür ist sicherlich eine Datenbank. Anwendungen nutzen eine Datenbank als separaten Service, über den sie ihre Daten persistieren. Wir sprechen hierbei auch von sogenannten Backing Services. Ein Backing Service ist ein zusätzlicher Dienst, der nicht direkter Teil einer Anwendung ist, von dieser jedoch erwartet wird.
Eine Twelve-Factor App erwartet, dass solche Backing Services von der Laufzeitumgebung bereitgestellt werden und über Konfigurationseinträge in den Umgebungsvariablen (siehe Abschnitt 3) konfiguriert werden. Die Anwendung kümmert sich also nicht selbst um das Bereitstellen und den Lebenszyklus von Backing Services, sondern nutzt “nur” die Services, die ihr von der Umgebung zur Verfügung gestellt werden.
Ein typisches Beispiel für eine Umgebungsvariable, die von der Laufzeitumgebung bereitgestellt wird, ist die Verbindungs-URL einer Datenbank über die Umgebungsvariable “DATABASE_URL” (z.B. “postgres://user:password@192.168.1.11/database”).
Weitere Beispiele für Backing Services, die über die Laufzeitumgebung bereitgestellt werden, sind Queues und Topics, Log-Aggregatoren, aber auch scheinbar banale Dinge wie ein Dateisystem. Die Laufzeitumgebung übernimmt hier möglicherweise ein automatisches Backup oder automatische Vergrößerung bzw. Verkleinerung von Speichermedien, verlangt dafür aber von der Anwendung, dass Daten nur in bestimmte – über die Konfiguration definierte – Verzeichnisse geschrieben werden.
Auch hier ist das Ziel, dass sich die Anwendung auf ihre Kernfunktionalitäten konzentriert, um Dinge wie das Verwalten von Datenbanken oder des Dateisystems an die Laufzeitumgebung zu delegieren.
5. Build, Release, Run
Eine Twelve-Factor App trennt klar zwischen den verschiedenen Zuständen der Anwendung, den sogenannten “Run Stages”.
Während der Build-Stage werden alle notwendigen Artefakte der Anwendung erstellt und verpackt. Kompilierte Sprachen erstellen in dieser Phase z.B. das eigentliche Binary, und Webanwendungen (egal ob kompiliert oder interpretiert) können hier Assets erstellen und/oder weitere Build-Pipelines ausführen. Ebenfalls findet in der Build-Phase das Auflösen von externen Abhängigkeiten durch Package-Manager und ähnlichen Tools statt (siehe auch Abschnitt 2).
Das finale Verpacken aller erzeugten Artefakte sowie der Konfigurationselemente für eine spezifische Umgebung bezeichnen wir als Release. Ein Release hat immer einen eindeutigen Bezeichner und enthält die ausführbare Anwendungen sowie einen Satz an Konfigurationseinträgen.
Eine Veränderung der eigentlichen Anwendung und ein neues Deployment auf eine Zielplattform bedingt daher immer ein neues Release. Gleichzeitig bedingt in einer Twelve-Factor App das Verändern von einem oder mehrerer Konfigurationseinträge ebenso ein Release. Ein bestimmtes Release (eine Release “Version”) lässt daher immer einen genauen Rückschluss nicht nur auf die spezifische Version der Anwendung, sondern auch auf den genauen Satz an Konfigurationseinträgen zu.
Es ist hierbei Aufgabe der Laufzeitumgebung, historische Releases zu archivieren und Informationen über den genauen Aufbau eines Releases zur Verfügung zu stellen.
6. Prozesse
Eine Twelve-Factor App ist so aufgebaut, dass sie zur Laufzeit aus einem oder mehreren zustandslosen Prozessen basiert.
Zustandslos bedeutet hierbei, dass die Laufzeitumgebung einen Prozess einer Anwendung jederzeit beenden und durch einen neuen Prozess ersetzen kann. Ebenso kann die Laufzeitumgebung entscheiden, nicht nur einen, sondern mehrere Prozesse einer Anwendung parallel laufen zu lassen, wenn sie entscheidet, dass dies z.B. aus Performance- oder Ausfallsicherheitsgründen sinnvoll ist.
Für die Anwendung selbst bedeutet das, dass Daten, die von der Anwendung dauerhaft benötigt werden, immer in Backing Services (siehe Abschnitt 4) gespeichert werden müssen. Die Anwendung kann ein evtl. vorhandenes lokales Filesystem oder einen Speicher selbstverständlich für z.B. lokale Caching-Operationen nutzen, darf aber keine Annahmen über die Verfügbarkeit dieser Daten machen. Die Laufzeitumgebung könnte beispielsweise ein Filesystem, das nicht explizit als Backing Service zur Verfügung gestellt wurde, jederzeit bereinigen und dort gespeicherte Daten löschen.
Die Anwendung kann (und sollte) Prozesse in verschiedene Prozesstypen unterteilen. Eine typische Webanwendung hätte als einen Prozesstyp beispielsweise den “Web”-Prozess. Dieser Prozess ist es, der Anfragen von HTTP-Clients entgegennimmt, abarbeitet und das Ergebnis an den Client zurücksendet.
Parallel könnte diese Anwendung aber noch einen weiteren Prozesstyp definieren, der Hintergrundoperationen durchführt, die nicht direkt mit der HTTP-Client-Verarbeitung zu tun haben. Solche Hintergrundoperationen (üblicherweise “Worker” genannt) könnten zum Beispiel das Aktualisieren von Suchindizes oder das regelmäßige Versenden von Nachrichten zu bestimmten Zeitpunkten sein.
Unterschiedliche Anwendungen können hier unterschiedliche Prozesstypen definieren, je nach genauem Einsatzbereich.
7. Port Binding
Die Kommunikation einer Twelve-Factor App mit ihrer Außenwelt findet immer durch einen oder mehreren Ports statt, die von der Laufzeitumgebung gemanaged und reserviert werden. Eine typische Webanwendung wird üblicherweise (mindestens) die Ports 80 (für unverschlüsselte HTTP-Anfragen) und 443 (für verschlüsselte HTTP-Anfragen) verwenden.
Über dedizierte Umgebungsvariablen als Teil der Konfiguration (siehe auch Abschnitt 3) bekommt die Anwendung von der Laufzeitumgebung mitgeteilt, welche Ports genau für die Kommunikation der Anwendung mit der Außenwelt zur Verfügung stehen. Ein Binding an diese Ports erlaubt es dann der Anwendung, eingehende Anfragen zu bearbeiten und entsprechende Antworten zu versenden.
Ein solches Setup steht im Gegensatz zu beispielsweise klassischen Java-Applicationservern, wo die Kommunikation bereits vom Applicationserver übernommen wurde und die Anwendung selbst mit HTTP-Clients über die Servlet API kommunizierte.
8. Nebenläufigkeit und Skalierung
Nebenläufige Prozesse (Hintergrundprozesse, sogenannte Worker) haben wir bereits in Abschnitt 6 definiert. Die Skalierung einer Anwendung kann in einer Twelve-Factor App sowohl horizontal als auch vertikal passieren. Jeder Prozesstyp wird von der Laufzeitumgebung auf bestimmte Hardware-Komponenten abgebildet. So ist es denkbar, dass jeder Prozess vom Typ “Web” auf einer eigenen (virtuellen oder tatsächlichen) Maschine mit einer bestimmten CPU- und Arbeitsspeicherausstattung ausgeführt wird.
Vertikal skaliert kann hier nun eine bessere CPU- und/oder Speicherausstattung verwendet werden. Horizontal skaliert könnte die Laufzeitumgebung entscheiden, den “Web”-Prozess nicht nur einmal, sondern mehrmals auf separaten Maschinen zu starten und auszuführen.
Da die Twelve-Factor App entsprechend Abschnitt 6 darauf ausgelegt ist, innerhalb eines klar definierten Prozesses ausgeführt zu werden, hat die Laufzeitumgebung hier einen hohen Freiheitsgrad, vorhandene Hardwareressourcen bestmöglich auszunutzen und neue Ressourcen bei Bedarf zu provisionieren.
Unterschiedliche Prozesstypen können hierbei unterschiedlich skaliert werden. Während ein Hintergrundprozess beispielsweise nur einmal auf einer Maschine mit wenig Ressourcen kostengünstig betrieben werden kann, möchten wir für den Hauptprozesstyp, der eine hohe Anzahl an HTTP-Requests gleichzeitig abarbeiten muss, möglicherweise mehrere leistungsfähige Maschinen verwenden, auf die die einzelnen Prozesse platziert werden können.
9. Wegwerfprozesse
Wie wir in Abschnitt 4 und Abschnitt 6 bereits gelernt haben, ist eine Twelve-Factor App darauf ausgelegt, persistente Daten immer (nur) in den Backing Services zu speichern. Die eigentliche Anwendung selbst darf keinen Zustand in einem laufen Prozess halten, bzw. darf keine Annahmen über die Verfügbarkeit eines solchen Zustands machen.
Hieraus folgert, dass ein einzelner Prozess als “Wegwerfprozess” angesehen werden kann. Er wird von der Laufzeitumgebung zu einem bestimmten Zeitpunkt erstellt, erfüllt während seiner Lebenszeit bestimmte Aufgaben, z.B. das Verarbeiten von Anfragen über einen Port (siehe Abschnitt 7) oder das Abarbeiten von Hintergrundaktionen, und kann danach (oder sogar währenddessen) von der Laufzeitumgebung “weggeräumt” werden.
Eine Anwendung sollte daher so aufgebaut sein, dass das Neustarten und Beenden keine Ausnahmen darstellen, sondern stattdessen normale Operationen sind, die immer und immer wieder vorkommen.
Zwei Designparadigmen für eine Anwendung sind daher schneller Start, so dass ein neuer Prozess sofort Daten verarbeiten kann, und ein sauberer Shutdown, so dass die Ressourcen eines beendeten Prozesses schnell wieder für neue Prozesse zur Verfügung stehen.
10. Ähnlichkeit von Entwicklungs- und Produktivsystemen
Je unterschiedlicher ein Test- oder Entwicklungssystem von dem tatsächlichen Produktivsystem ist, auf dem die Anwendung läuft, desto schwieriger, aufwändiger und damit auch teurer wird der Entwicklungsprozess. Wenn bei einem gemeldeten Defect im Produktionssystem nicht sofort herausgefunden werden kann, wieso und unter welchen Bedingungen der Fehler auftritt, weil ein Entwicklerrechner deutlich anders konfiguriert ist (“works on my machine”), entsteht nicht nur Frust bei allen Beteiligten, sondern echte und fassbare Kosten.
Eine Twelve-Factor App ist durch die verschiedenen Prinzipien geradezu darauf optimiert, die Unterschiede zwischen verschiedenen Zielsystemen so klein wie möglich zu halten. Ein Entwicklungssystem (also ein Setup der Anwendung auf einem einzelnen Rechner eines Entwicklers) ist hierbei “nur” eine weitere Umgebung, die sich hauptsächlich durch den Satz an Umgebungsvariablen zur Konfiguration (siehe Abschnitt 3) und einem reduzierten Satz an aktiven Prozesstypen (siehe Abschnitt 8) definiert.
Gleichzeitig wird es durch dieses Setup für neue Entwickler*innen einfach, eine bestehende Codebasis zu verwenden und für eigene Tests zu verwenden. Im Idealfall reicht hier bereits ein eigener Satz an Umgebungsvariablen zur Konfiguration und eine lokale Laufzeitumgebung aus.
11. Logs
Gute Logs helfen Entwickler*innen einer Anwendung dabei, einen Einblick in eine laufende Anwendung zu bekommen und “unter die Haube zu schauen”. So lassen sich nicht nur Fehler proaktiv analysieren und beheben, sondern auch allgemeine Aussagen über ein laufendes System treffen und Vorhersagen, wie bestimmte Features genutzt (oder auch nicht genutzt) werden, bestätigen oder falsifizieren.
Häufig kommen hierbei immer noch Logdateien zum Einsatz, die von jeder Anwendung nach unterschiedlichen Prinzipien geschrieben und verwaltet werden. Logdateien widersprechen jedoch den in Abschnitt 8 und Abschnitt 9 definierten Regeln. Sie beinhalten Zustand (die Logmeldungen) und müssen daher persistiert werden.
Eine Twelve-Factor App sieht daher Logmeldungen nur als eine spezielle Art von Daten an, die von der Anwendung persistiert und für einen Benutzerkreis (beispielsweise Entwickler oder Administratoren) zur Verfügung gestellt werden müssen. Wie für alle anderen Daten auch ist hierbei der korrekte Weg, die Logmeldungen an einen Log-Aggregator zu übergeben, der regulär als Backing Service (siehe Abschnitt 4) von der Laufzeitumgebung bereitgestellt wird.
Gute Log-Aggregatoren erlauben ein komfortables Management von Logmeldungen über einen längeren Zeitraum und sind deutlich mächtiger als einfache Logdateien.
Für die Anwendung sollte das Schreiben von Logmeldungen daher denkbar einfach sein: Jede Zeile in einem Log repräsentiert eine Lognachricht, und jede Lognachricht wird automatisch an den Log-Aggregator übergeben. Der einfachste Weg hierbei ist, sämtliche Logmeldungen direkt auf die beiden Standard-Streams STDOUT und STDERR zu schreiben, und alle eingehenden Nachrichten auf diesen Streams an den Log-Aggregatgor zu übermitteln.
12. Administrationsprozesse
Von Zeit zu Zeit hat fast jede Anwendung Bedarf für bestimmte Administrationsprozesse. Dinge, die nur einmalig (oder in sehr unterschiedlichen Intervallen) erfolgen, kein essentieller Teil der eigentlichen Anwendung, aber dennoch nützlich und sinnvoll sind (“Einmalprozesse”).
Ein solcher Prozess könnte beispielsweise der Export bestimmter Informationen in eine CSV-Datei sein, die dann wiederum von anderen Systemen oder Personen weiterverwendet wird.
In einer Twelve-Factor App sind solche Administrationsprozesse “nur” ein besonderer Prozesstyp (siehe Abschnitt 6) und werden als solcher behandelt. Hierdurch haben auch solche “Einmalprozesse” Zugriff auf die gleichen Backing Services und Umgebungsvariablen wie die restlichen “dauerhaften” Prozesse.
Die Ausführung solcher Einmalprozesse erfolgt von der Laufzeitumgebung analog zur Ausführung der restlichen Prozesse, mit dem Unterschied, dass der Einmalprozess nicht dauerhaft ausgeführt wird. Stattdessen wird ein Prozess mit dazugehörigen Ressourcen erzeugt, der eigentliche Prozessbefehl ausgeführt und anschließend der Prozess mitsamt belegten Ressourcen wieder beendet.
Die „Twelve-Factor App“ – Zusammenfassung
Die einzelnen Prinzipien der Twelve-Factor App bauen teilweise aufeinander auf und unterstützen sich gegenseitig. So wird beispielsweise ein Wegwerfprozesses (Abschnitt 9) erst durch die Definition eines Prozesstyps (Abschnitt 6) und die Verfügbarkeit von Backing Services (Abschnitt 4) möglich und sinnvoll.
Das große Ziel, die Entwicklung einer Anwendung sowie deren Betrieb möglichst einfach und “geräuschlos” zu gestalten, wird hierbei von verschiedenen Seiten mit unterschiedlichen Prinzipien und Regeln gefördert. Gleichzeitig bedarf es aber nicht immer und in allen Situationen einer “kompletten Ausbaustufe”, bei der alle Prinzipien bis ins kleinste umgesetzt und befolgt werden.
Auch außerhalb von Platform-as-a-Service-Umgebungen, für die eine Twelve-Factor App ursprünglich konzipiert wurde, lassen sich viele der Konzepte und Ideen anwenden, manchmal direkt, manchmal “nur” als Inspiration für die eigene Anwendungsarchitektur.
So dienen die Ideen der Twelve-Factor App als mächtiges Werkzeug im Werkzeugkasten eines jeden Entwicklers und können uns dabei helfen, schneller und gelassener das zu tun, was wir eigentlich wollen: Unseren Nutzern sinnvolle Features zur Verfügung zu stellen.
Titelmotiv: Unsplash