Twelve-Factor App: Review

Lesezeit: 13 Minuten

Nachdem ich im letzten Beitrag die Twelve-Factor App vorgestellt habe, möchte ich in diesem Beitrag meine Meinung zu den einzelnen Aspekten dieser Methode darlegen.

Die 12 Faktoren sind eine gute Orientierungshilfe für Software, die in einer Cloud oder PaaS läuft oder als SaaS betrieben wird. Dabei werden nicht alle Empfehlungen für alle Umgebungen geeignet sein und man sollte sich als Team einig werden, welche Punkte man berücksichtigten möchte und welche mit guter Begründung außer Acht gelassen werden.

1. Codebase

Den Empfehlungen zur Codebase stimme ich uneingeschränkt zu. Ein Versionsmanagementsystem sollte nicht nur heute, sondern schon seit Jahrzehnten Standard sein. Teilen sich mehrere Apps eine gemeinsame Codebase, gibt es in der Regel auch gemeinsamen Code. Die Codebase wächst in verschiedene Richtungen und früher oder später haben Änderungen in der gemeinsamen Codebase unbeabsichtigte Quereffekte.

2. Abhängigkeiten

Die Aussagen zu den Abhängigkeiten unterstütze ich ebenfalls voll und ganz. Es ist wünschenswert, wenn ein Entwickler einfach nur die Codebase auschecken muss und sofort anfangen kann zu arbeiten. Implizite Abhängigkeiten kosten eine Menge Zeit und sind der Erfahrung nach oftmals nicht oder nur sehr unzureichend dokumentiert. Das Problem betrifft nicht nur Entwickler, die neu in ein Projekt einsteigen, sondern äußert sich auch jedes Mal, wenn ein Entwickler neue Hardware erhält. Zumal damit nicht klar ist, welche Betriebssysteme bzw. Entwicklungsumgebungen die App unterstützt. Sind alle Abhängigkeiten/Deklarationen Bestandteil der Codebase, lässt sich die Frage deutlich schneller beantworten.

3. Konfiguration

Auch die Punkte hinsichtlich Konfiguration treffen voll und ganz meinen Geschmack. Einschränkend muss ich aber sagen, dass mit einer externalisierten Konfiguration, etwa über ein Tool wie Hashicorp Consul, auch ein gewisser Overhead mit einhergeht. Die Infrastruktur muss diese Möglichkeit überhaupt erst bieten, wobei die Verantwortung dafür außerhalb des App-Projektteams liegen kann, und speziell mit Consul / Consul-Template habe ich auch negative Erfahrung gemacht, wobei ich dieses Tool damit keinesfalls abwerten, sondern nur erwähnt haben möchte, dass die Einführung eines solchen Tools andere Herausforderungen und vielleicht sogar Probleme mit sich bringen kann. Aus meiner Sicht ist nicht für jede Situation eine komplette Externalisierung der Konfiguration erforderlich oder erstrebenswert.

Bei Verwendung von Gitlab CI kann man über die sogenannten CI Secrets Konfiguration verstecken, so dass diese außerhalb der Codebase liegt und bei entsprechend eingestellten Berechtigungen nicht mal von den Entwicklern eingesehen werden können. Im Kontext der Pipeline kann man dann auf diese Variablen zurückgreifen. Da CI Secrets bei einer großen Menge an Konfigurationsparametern über mehrere Umgebungen hinweg aber schnell unübersichtlich werden, ist es aus meiner Sicht in bestimmten Fällen ein guter Kompromiss, nur die besonders sensitiven Informationen wie Benutzer und Passwort über CI Secrets zu verwalten und die restliche Parameter in Konfigurationsdateien innerhalb der Codebase vorzuhalten. Selbstverständlich bringt dies den Nachteil mit sich, dass eine Änderung der Konfiguration ein neues Release erfordert und die Codebase kann dann auch in den meisten Fällen nicht öffentlich zugänglich sein, da auch eine Offenlegung der anderen Konfigurationswerte meistens nicht erwünscht ist.

4. Unterstützende Dienste

Die Empfehlungen zu unterstützenden Diensten teile ich uneingeschränkt. EIne einfache Austauschbarkeit von Diensten ist ein massiver Vorteil. Insofern würde ich auch gut abwägen, wann z.B. Vendor-spezifische SQL-Statements wirklich erforderlich sind und wann man gut darauf verzichten kann. Aus der Historie heraus habe ich mit einem möglichst allgemeingültigen Interface für bestimmte Dienste sehr positive Erfahrung gemacht, als wir mit minimalem Aufwand in einem Microservice-Ökosystem RabbitMQ durch AWS SNS/SQS ersetzen konnten und eine Oracle-Datenbank zunächst durch eine MariaDB und später Amazon Aurora ablösen konnten, ohne dass Code innerhalb der App umgeschrieben werden musste. Die langfristige zukünftige Entwicklung ist in den wenigsten Fällen absehbar und durch Entscheidungen auf Management-Ebene, auf die das Team keinen oder nur wenig Einfluss hat, kann es schnell passieren, dass ein System zu einem Cloud-Provider hin oder in eine selbst gehostete Umgebung migriert werden soll und diverse unterstützende Dienste dabei ausgetauscht werden müssen.

5. Build, release, run

Mit dem Verfahren build, release, run stimme ich grundsätzlich überein, bin mir aber nicht ganz im Klaren darüber, wie das hinsichtlich der Versionierung zu verstehen ist. Mir ist die Möglichkeit einer fachlichen Versionierung wichtig, vor allem in Geschäftsanwendungen. Ich möchte wissen, welche Komponenten in welcher Version auf welcher Umgebung laufen und zu dieser Version auch möglichst die Release Notes einsehen können. Das lässt sich wunderbar mit einem Tool wie Semantic Release realisieren. Ich befürworte es, zwischen der Version eines Build-Artefaktes und der Version eines mit der Konfiguration verpackten Artefaktes, z.B. eines Docker-Images, zu differenzieren. Das Artefakt, das die umgebungsspezifische Konfiguration enthält, darf gerne eine umgebungsspezifische Versionsbezeichnung haben, auch um unmissverständlich klarzustellen, welches Release in welcher Umgebung deployed werden darf. Das Build-Artefakt sollte aber über eine separate Versionierung verfügen, so dass ebenfalls unmissverständlich ersichtlich ist, welche Features, Fixes und allgemein Änderungen in dieser Version enthalten sind.

6. Prozesse

Die Empfehlungen zu Prozessen kann ich mit Einschränkungen unterschreiben: Ich bin der Meinung, dass es in bestimmten Fällen vorwiegend aus Performancegründen erstrebenswert ist, einen internen Cache in der App zu halten. Dieser Cache existiert dann pro Instanz und muss unter Umständen über alle Instanzen hinweg neu aufgebaut werden, wenn sich wesentliche Daten darin geändert haben, deren Änderung unmittelbar spürbar sein muss. Dabei sollte aber jede App allein für die Aktualität seines eigenen Caches verantwortlich sein, indem sie z.B. Änderungen über regelmäßiges Polling mitbekommt. In einem solchen Fall, in dem ein externes System (Datenbank, Key-Value-Store, Datagrid etc.) einfach zu inperformant ist, sich die Daten ggf. auch nur selten ändern und vom Umfang her sehr klein sind, würde ich eine Ausnahme von der Regel der Zustandslosigkeit machen.

7. Bindung an Ports

Portbindung ist eine gute Sache: Eine Spring-Boot-Anwendung, die den Tomcat-Webserver selbst mitbringt und sich überall starten lässt, hat einen gewaltigen Vorteil hinsichtlich Portabilität und Flexibilität gegenüber einer Anwendung, die als War-/Ear-File ausgeliefert wird und in einen bestehenden Web-Application-Server deployed werden muss. Ports erleichtern den Austausch zwischen verschiedenen Apps. Tools wie Docker ermöglichen es einen beliebigen externen Port auf den internern Port des Containers zu mappen, so dass Deployments flexibel für die jeweilige Umgebung vorgenommen werden können, ohne dass es zu Portkonflikten kommen muss.

8. Nebenläufigkeiten

Da Prozesse und Portbindung schon in separaten Faktoren geregelt sind und prozessinterne Parallelisierung explizit als Möglichkeit offen gelassen wird, verstehe ich diesen Punkt eher als Einladung, von diesem Architekturmuster Gebrauch zu machen. Für mich ist diese Option stark kontextabhängig und ich würde mir zunächst zwei Fragen stellen:

  • Sollte ich diese Aufgabe eher als nebenläufigen Prozess oder als eigene App abbilden?
  • Rechtfertigt diese Aufgabe einen eigenen nebenläufigen Prozess oder läuft sie besser in einem anderen Prozess mit?

Wenn es zum Beispiel einen bidirektionalen Datenaustausch zwischen zwei Systemen gibt, in dem beispielsweise Daten über eine API abgefragt und an eine andere API gemeldet werden, ist es eine valide Überlegung ob es sich dabei um die gleiche App mit zwei nebenläufigen Prozessen oder zwei verschiedene Apps handelt. Wenn die Schnittmenge dieser Apps nahe null liegt, sind separate Apps möglicherweise die bessere Wahl.

Wenn eine App regelmäßig alte Daten in einer Tabelle reorganisiert, dieser Aufräumprozess zyklisch, z.B. jede Stunde, anläuft und in der Regel in Bruchteilen einer Sekunde schon wieder zuende ist, würde ich dafür keinen eigenen nebenläufigen Prozess vorsehen, sondern diese Aufgabe in einer Spring-Boot-App etwa über eine Scheduled-Annotation abbilden.

9. Einweggebrauch

Ein schneller Start und Stopp und vor allem ein geregelter Shutdown auch im Fehlerfall sind auf jeden Fall wünschenswerte Ziele. Gerade ein geordnetes Herunterfahren ist im Ops-Bereich wichtig, um manuelle Eingreife, möglicherweise auch Rufbereitschaftseinsätze, und die bis zur Korrektur entstandenen Probleme, die sich unter Umständen auch bei den Nutzern der App negativ bemerkbar machen, so weit es geht zu reduzieren.

Je nach Sprache, Framework und Laufzeitumgebung ist es aber unterschiedlich anspruchsvoll eine gute Performance vor allem beim Start der App zu erreichen. Während ein Go-Microservice sicherlich so geschrieben werden kann, dass er bereits nach Sekundenbruchteilen hochgefahren ist, benötigen schwergewichtige JavaEE-Anwendungen oftmals eine Minute oder sogar deutlich mehr. Meiner Meinung nach ist eine kurze Startzeit ein wichtiges Ziel, das aber nicht immer voll erreicht werden kann.

Interessant finde ich bei diesem Faktor übrigens die Erwähnung des POSIX-spezifischen SIGTERM-Signals.

10. Dev-Prod-Vergleichbarkeit

Ich finde es wichtig, dass Code in möglichst kurzen Intervallen eingecheckt und dieser Code auch automatisch auf seine Qualität hin überprüft und optimalerweise ebenfalls in einer Development-Umgebung deployed wird. Wenn dieser Code auch verhältnismäßig zeitnah nach Produktion gelangt, können Fehler schnell erkannt und oftmals besser den verursachenden Commits zugeordnet werden, als wenn die Feedback-Loop eine Größenordnung von Wochen bis Monaten hat.

ich unterschreibe, dass die Werkzeuglücke möglichst klein gehalten werden sollte. In jedem Fall muss ein Fehler in Kombination mit den in Produktion verwendeten Werkzeugen und unterstützenden Diensten bereits in einer vorherigen Umgebung und so früh wie möglich auffallen.

Continuous Deployment ist sicherlich nicht überall ein Ziel und damit auch kein Deployment-Intervall im Rahmen von Stunden. Je nach Umgebung kann es aus meiner Sicht auch in einer Microservice-Welt oder bei einer SaaS-App legitim sein, nur alle paar Tage oder vielleicht wöchentlich zu deployen. Denn um Continuous Deployment zu erreichen, muss die Qualität zunächst einmal auf einem sehr hohen Niveau sein, damit Continuous Deployment nicht zu Continuous Disaster wird, wie ein Kollege von mir mal zu sagen pflegte.

Mit der Aussage, dass Code-Autoren und Code-Deployer dieselben Personen sein sollten, gibt die Twelve-Factor App auch ganz klar DevOps als Ansatz zum Entwickeln und Betreiben der App vor. Auch dieser Ansatz wird nicht in jedem Umfeld das Ziel sein und bringt neben sicherlich zahlreichen Vorteilen auch seine eigenen Nachteile mit sich. Für eine öffentlich zugängliche App bedeutet dieser Ansatz nämlich auch, dass die Entwickler sich auf Rufbereitschaften im Feierabend, am Wochenende und an Feiertagen einlassen müssen.

11. Logs

Ich teile die Meinung, dass Logs gerade in einem verteilten System am besten über stdout abgefangen und zentral zugänglich gemacht werden sollten, wie beispielsweise über den ELK-Stack. Grundsätzlich ist es von hoher Bedeutung Logs leicht zugänglich und durchsuchbar zu machen, damit Fehlern schnell auf den Grund gegangen werden kann. Ein einheitliches Verfahren ist wichtig, da sonst die Übersicht verloren geht und sich leichter Fehler einschleichen, wenn jeder Service das Logging anders handhabt. Das Schreiben nach stdout ist eine Möglichkeit, unabhängig von der Programmiersprache auf die gleiche Art und Weise zu loggen, ohne auf Bindings von Frameworks für die jeweilige Sprache angewiesen zu sein. Der Vorteil gegenüber einem Binding ist auch, dass das Schreiben nach stdout die Komplexität reduziert und der Entwickler sich besser auf die Kernfunktionalität der App konzentrieren kann, wenn er sich nicht mit einer Logging-API, deren Aktualität und möglichen Breaking Changes beschäftigen muss. Ich möchte aber nicht ausschließen, dass es Situationen geben kann, in denen eine für das Szenario bessere Lösung als das Abgreifen über stdout vorliegt. Da wird man dann einfach abwägen müssen.

12. Admin-Prozesse

Die Beispiele zur Datenbankmigration sagen mir nicht zu. Ich würde an dieser Stelle lieber mit einem Tool ansetzen, was beim Start der App einmalig läuft, den Stand des Datenbankschemas mit dem bekannten Stand der App-Version abgleicht und Schema-Änderungen in diesem Zuge in das Schema einspielt. Gute Ansätze dafür unter Java sind Flyway und Liquibase.

Für sonstige administrative Prozesse wäre mir vor allem wichtig, dass sie nach Möglichkeit idempotent sind, damit sie auch gefahrlos mehrfach ausgeführt werden können. Manuelle Eingriffe birgen immer die Gefahr eines menschlichen Fehlers und sollten daher so robust wie möglich ausgelegt sein.

Mit den restlichen Empfehlungen stimme ich überein, vor allem sollte Administrationscode Teil der Codebase sein und in der gleichen Umgebung ablaufen, wie die anderen Prozesse der App.