Ein erfolgreiches System tendiert dazu, mit der Zeit zu wachsen. Es wird von mehr Leuten verwendet, mehr Personen arbeiten daran, mehr Elemente werden hinzugefügt. Wenn das System wächst, werden auch Änderungen riskanter und komplexer. Das führt häufig zu komplizierteren und zeitaufwendigeren Prozessen zum Managen der Änderungen. Der Overhead für das Durchführen von Änderungen erschwert es, Fehler im System zu beheben und es zu verbessern. Damit wachsen die technischen Schulden, und die Qualität des Systems lässt nach.
Das ist die negative Variante des Zyklus, in dem eine hohe Geschwindigkeit die Qualität verbessert und die bessere Qualität schnellere Änderungen ermöglicht – beschrieben in Kapitel 1.
Das Anwenden der drei zentralen Praktiken von Infrastructure as Code – alles als Code zu definieren, kontinuierlich testen und ausliefern sowie kleine Einheiten bauen (siehe »Drei zentrale Praktiken für Infrastructure as Code« auf Seite 40) – ermöglicht die positive Version des Zyklus.
Dieses Kapitel fokussiert sich auf die dritte Praktik – das Erstellen Ihres Systems aus kleineren Einheiten, sodass Sie eine schnellere Änderungsrate beibehalten, während Sie gleichzeitig die Qualität auch bei einem wachsenden System verbessern können. Die meisten Tools und Sprachen für Infrastruktur-Code besitzen Features, die Module, Bibliotheken und andere Arten von Komponenten unterstützen. Aber Design Thinking und Praktiken für die Infrastruktur sind noch nicht so ausgereift wie das beim Softwaredesign der Fall ist.
Daher baut dieses Kapitel auf den Designprinzipien für Modularität auf, die sich in Jahrzehnten des Softwaredesigns entwickelt haben, und betrachtet sie aus der Perspektive der Code-getriebenen Infrastruktur. Dann wirft es einen Blick auf die verschiedenen Arten von Komponenten in einem Infrastruktur-System, wobei der Schwerpunkt darin liegt, wie wir sie für eine bessere Modularität einsetzen können. Ausgehend davon können wir dann Überlegungen bezüglich des Ziehens von Grenzen zwischen Infrastruktur-Komponenten anstellen.
Das Ziel der Modularität ist, Änderungen am System einfacher und sicherer vornehmen zu können. Sie unterstützt dieses Ziel auf unterschiedlichen Wegen. Einer ist, doppelte Implementierungen zu vermeiden, um die Anzahl an Code-Änderungen zu verringern, die Sie vornehmen müssen, um eine bestimmte Änderung auszuliefern. Ein anderer ist, die Implementierung zu vereinfachen, indem Komponenten angeboten werden, die sich auf unterschiedliche Art und Weise für verschiedene Einsatzzwecke kombinieren lassen.
Ein dritter Weg zum Erleichtern und Absichern von Änderungen ist, das System so zu designen, dass Sie Änderungen an einer kleineren Komponente vornehmen können, ohne andere Teile des Systems anpassen zu müssen. Kleinere Elemente lassen sich einfacher, sicherer und schneller ändern als große Elemente.
Die meisten Design-Regeln zur Modularität können für Spannungen sorgen. Folgt man ihnen sorglos, können sie ein System tatsächlich brüchiger und schlechter änderbar machen. Die vier zentralen Metriken aus »Die Four Key Metrics« auf Seite 39 sind eine nützliche Hilfe dabei, die Effektivität beim Modularisieren Ihres Systems zu beachten.
Beim Designen von Komponenten liegt die Kunst darin, zu entscheiden, welche Elemente Ihres Systems zusammengehalten und welche getrennt werden sollen. Um das richtig zu machen, müssen Sie die Beziehungen und Abhängigkeiten zwischen den Elementen verstehen. Zwei wichtige Design-Charakteristiken einer Komponente sind Kopplung und Kohäsion (https://oreil.ly/Qe3Sh). Das Ziel eines guten Designs ist, eine geringe Kopplung und eine hohe Kohäsion zu erreichen.
Das Koppeln beschreibt, wie oft eine Änderung an einer Komponente eine Änderung an einer anderen Komponente erfordert. Eine völlige Kopplungsfreiheit ist für zwei Elemente eines Systems kein realistisches Ziel, denn das heißt, dass sie vermutlich überhaupt nicht Teil des gleichen Systems sind. Stattdessen streben wir eine geringe oder lose Kopplung an.
Ein Stack und ein Server-Image sind gekoppelt, weil Sie vermutlich den der Server-Instanz zugewiesenen Speicher im Stack erhöhen müssen, wenn Sie Software auf dem Server upgraden. Aber Sie sollten im Stack nicht jedes Mal Code ändern müssen, wenn Sie das Server-Image aktualisieren. Eine geringe Kopplung erleichtert Änderungen an einer Komponente, weil das Risiko gering ist, andere Teile des Systems zu beeinträchtigen.
Kohäsion beschreibt die Beziehung zwischen den Elementen innerhalb einer Komponente. Wie bei der Kopplung bezieht sich das Konzept der Kohäsion auf Änderungs-Patterns. Änderungen an einer in einem Stack mit geringer Kohäsion definierten Ressource sind oft für andere Ressourcen im Stack nicht relevant.
Ein Infrastruktur-Stack, der getrennte Networking-Strukturen für Server definiert, die durch zwei andere Stacks provisioniert werden, besitzt eine geringe Kohäsion. Komponenten mit einer hohen Kohäsion lassen sich leichter ändern, weil sie kleiner, einfacher und sauberer sind und einen geringeren Explosionsradius haben (siehe »Explosionsradius« auf Seite 88) als Komponenten, in denen sich ein Gewirr von wenig miteinander in Beziehungen stehenden Objekten findet.
Vier Regeln für einfaches Design Kent Beck, der Vater von XP und TDD, erwähnt häufig vier Regeln (https://oreil.ly/gelGl) für das einfache Design einer Komponente. Demnach sollte einfacher Code
|
Zu Softwarearchitektur und -design gehören viele Prinzipien und Richtlinien zum Designen von Komponenten mit geringer Kopplung und hoher Kohäsion.
Das DRY-Prinzip (Don’t Repeat Yourself) sagt: »Jedes Wissenselement muss eine einzige, eindeutige und verbindliche Repräsentation in einem System haben.«1 Durch Wiederholungen werden Entwicklerinnen und Entwickler gezwungen, eine Änderung an mehreren Stellen vorzunehmen.
So nutzen beispielsweise alle Stacks bei ShopSpinner einen Benutzer-Account pro visioner, um Konfigurationen auf Server-Instanzen anzuwenden. Ursprünglich fanden sich die Anmeldedetails für den Account in jedem Stack – wie auch der Code, der das Basis-Server-Image gebaut hat. Mussten die Anmeldedetails für den Benutzer-Account geändert werden, mussten sie an all diesen Stellen in der Codebasis gefunden und angepasst werden. Daher verschob das Team die Anmeldedetails an eine zentrale Stelle, auf die sich jeder Stack und der Server-Image-Builder beziehen.
Nützliche Wiederholungen Das DRY-Prinzip rät davon ab, ein Konzept doppelt zu implementieren, was aber nicht das Gleiche ist wie die mehrfache Verwendung der gleichen Coding-Zeilen. Wenn mehrere Komponenten von gemeinsam genutztem Code abhängen, kann das für eine enge Kopplung sorgen, wodurch sich Änderungen nur schwerer umsetzen lassen. |
|
Ich habe Teams kennengelernt, die darauf bestanden, jeglichen Code, der ähnlich aussah, zu zentralisieren – beispielsweise alle virtuellen Server mit einem einzelnen Modul zu erstellen. In der Praxis müssen Server, die für unterschiedliche Zwecke erzeugt werden, zum Beispiel Anwendungsserver, Webserver und Build-Server, auch unterschiedlich definiert werden. Ein Modul, das all diese verschiedenen Arten von Servern erzeugen können muss, kann übermäßig kompliziert werden. |
|
Wenn Sie sich überlegen, ob Code wiederholt werden kann oder zentralisiert werden sollte, sollten Sie darüber nachdenken, ob der Code wirklich das gleiche Konzept repräsentiert. Wenn eine Instanz des Codes geändert wird – soll dann auch immer die andere Instanz geändert werden? |
|
Machen Sie sich auch Gedanken dazu, ob es eine gute Idee ist, die beiden Code-Instanzen im gleichen Änderungszyklus zusammenzubringen. Es kann unrealistisch sein, alle Anwendungsserver im Unternehmen zu einem gleichzeitigen Upgrade zwingen zu wollen. |
Wiederverwendung verstärkt Kopplung. Eine gute Faustregel für die Wiederverwendung ist daher, DRY in einer Komponente umzusetzen, über Komponenten hinweg davon aber abzusehen.
Um ein zusammenbaubares System zu erstellen, sorgen Sie für unabhängige Elemente. Es sollte einfach sein, eine Seite einer Abhängigkeitsbeziehung zu ersetzen, ohne die andere zu stören.1
Das ShopSpinner-Team beginnt mit einem einzelnen Server-Image für Linux-Anwendungen, das sie von unterschiedlichen Stacks aus provisionieren. Später fügen sie ein Server-Image für Windows-Anwendungen hinzu. Sie designen den Code zum Provisionieren von Server-Images von beliebigen Stacks so, dass sie nach Bedarf für eine gegebene Anwendung leicht zwischen diesen beiden Images wechseln können.
Das Single-Responsibility-Prinzip (SRP) besagt, dass jede gegebene Komponente für genau eine Sache verantwortlich sein sollte. Die Idee ist, jede Komponente fokussiert zu halten, sodass ihre Inhalte kohäsiv sind.2
Eine Infrastruktur-Komponente – sei es ein Server, eine Konfigurations-Bibliothek, eine Stack-Komponente oder ein Stack – sollte mit einem einzelnen Zweck im Hinterkopf organisiert sein. Diesen Zweck kann man in Ebenen ordnen. Das Bereitstellen der Infrastruktur für eine Anwendung ist ein einzelner Zweck, der durch einen Infrastruktur-Stack erfüllt werden kann. Sie können diesen Zweck weiter unterteilen – in das sichere Traffic-Routing für eine Anwendung, implementiert durch eine Stack-Bibliothek; einen Anwendungsserver, implementiert durch ein Server-Image; und eine Datenbank-Instanz, implementiert durch ein Stack-Modul. Jede Komponente erfüllt auf jeder Ebene einen einzelnen, leicht verständlichen Zweck.
Viele lassen sich dazu verleiten, Komponenten entlang technischer Konzepte zu bauen. So kann es beispielsweise nach einer guten Idee aussehen, eine Komponente zum Definieren eines Servers zu erstellen und diese dann in jedem Stack zu verwenden, die einen Server benötigt. In der Praxis koppelt jede gemeinsam genutzte Komponente aber jeglichen Code, den sie verwendet.
Ein besseres Vorgehen ist, Komponenten entlang eines Domänenkonzepts zu bauen. Ein Anwendungsserver ist ein Domänenkonzept, das Sie vielleicht für mehrere Anwendungen wiederverwenden wollen. Ein Build-Server ist ein weiteres Domänenkonzept, das Sie eventuell wiederverwenden wollen, um den verschiedenen Teams ihre eigenen Instanzen anzubieten. Daher sind das bessere Komponenten als Server, die vermutlich auf sehr unterschiedliche Art und Weise eingesetzt werden.
Auch bekannt als »Prinzip der Verschwiegenheit« besagt das Gesetz von Demeter (https://de.wikipedia.org/wiki/Gesetz_von_Demeter), dass eine Komponente nicht wissen sollte, wie andere Komponenten implementiert sind. Diese Regel sorgt für klare, einfache Schnittstellen zwischen Komponenten.
Das ShopSpinner-Team hat diese Regel zunächst verletzt – durch einen Stack, der ein Anwendungsserver-Cluster definiert, und einen gemeinsam verwendeten Networking-Stack, der einen Load Balancer und Firewall-Regeln für dieses Cluster definiert. Der gemeinsam genutzte Networking-Stack besitzt zu viel Detailwissen über den Anwendungsserver-Stack.
In einer Abhängigkeitsbeziehung zwischen Komponenten erzeugt oder definiert eine Provider-Komponente eine Ressource, die eine Consumer-Komponente verwendet. Ein gemeinsam genutzter Networking-Stack kann ein Provider sein, der Netzwerk-Adressblöcke wie zum Beispiel Subnets erstellt. Ein Anwendungs-Infrastruktur-Stack kann ein Consumer des Networking-Stacks sein, der Server und Load Balancer in den vom Provider gemanagten Subnets provisioniert. |
|
Zentrales Thema dieses Kapitels ist das Definieren und Implementieren von Schnittstellen zwischen Infrastruktur-Komponenten. |
Wenn Sie Beziehungen von einer Komponente aus verfolgen, die Consumern Ressourcen bereitstellt, sollten Sie niemals eine Schleife finden. Mit anderen Worten – eine Provider-Komponente sollte niemals Ressourcen einer ihrer direkten oder indirekten Consumern nutzen.
Das ShopSpinner-Beispiel eines gemeinsam genutzten Netzwerk-Stacks besitzt eine zirkuläre Abhängigkeit. Der Anwendungsserver-Stack weist die Server in seinem Cluster Netzwerkstrukturen aus dem gemeinsam genutzten Networking-Stack zu. Der Networking-Stack erstellt Load Balancer und Firewall-Regeln für die angegebenen Server-Cluster im Anwendungsserver-Stack.
Das ShopSpinner-Team kann die zirkulären Abhängigkeiten beheben und das Wissen verringern, das der Networking-Stack über andere Komponenten besitzt, indem die Networking-Elemente, die für den Anwendungsserver-Stack spezifisch sind, in diesen verschoben werden. Das verbessert gleichzeitig Kohäsion und Kopplung, da der Networking-Stack nicht mehr länger Elemente enthält, die viel enger mit den Elementen anderer Stacks in Beziehung stehen.
In den Kapiteln 8 und 9 werden Praktiken zum kontinuierlichen Testen von Infrastruktur-Code bei der Arbeit an Änderungen beschrieben. Dieser starke Fokus auf das Testen führt dazu, dass die Testbarkeit zu einer essenziellen Designüberlegung für Infrastruktur-Komponenten wird.
Ihr System zum Ausliefern von Änderungen muss dazu in der Lage sein, Infrastruktur-Code auf jeder Ebene zu erstellen und zu testen – von einem Serverkonfigurationsmodul, das einen Monitoring-Agenten installiert, bis hin zu Stack-Code, der ein Container-Cluster aufbaut. Pipeline-Stages müssen schnell eine isolierte Instanz jeder Komponente erzeugen können. Solches Testen ist mit einer Spaghetti-Codebasis mit verwirrenden Abhängigkeiten oder mit großen Komponenten, deren Provisionierung eine halbe Stunde erfordert, nicht möglich.
Diese Herausforderungen sorgen dafür, dass viele Initiativen zur Einführung effektiver, automatisierter Testregime für Infrastruktur-Code im Sande verlaufen. Es ist schwierig, automatisierte Tests für ein schlecht designtes System zu schreiben und auszuführen.
Und das ist der heimliche Vorteil automatisierter Tests: Sie sorgen für ein besseres Design. Die einzige Möglichkeit, Code kontinuierlich zu testen und auszuliefern, ist das Implementieren und Warten eines sauberen Systemdesigns mit loser Kopplung und starker Kohäsion.
Es ist einfacher, automatisierte Tests für Serverkonfigurationsmodule zu implementieren, die lose gekoppelt sind. Es ist einfacher, Mocks für ein Modul mit saubereren, einfacheren Schnittstellen zu bauen und einzusetzen (siehe »Test-Fixtures für den Umgang mit Abhängigkeiten verwenden« auf Seite 172). Sie können einen kleinen, wohldefinierten Stack in einer Pipeline viel schneller provisionieren.
Zu einem Infrastruktur-System gehören verschiedene Arten von Komponenten (wie in Kapitel 3 beschrieben), die jeweils aus unterschiedlichen Elementen zusammengesetzt sein können. Eine Server-Instanz ist vielleicht aus einem Image erstellt worden, dabei wird eine Serverkonfigurations-Rolle genutzt, die eine Reihe von Serverkonfigurationsmodulen referenziert, die wiederum Code-Bibliotheken importieren. Ein Infrastruktur-Stack kann aus Server-Instanzen aufgebaut sein und nutzt vielleicht Stack-Code-Module oder -Bibliotheken. Und mehrere Stacks können zusammengeführt werden, um eine größere Umgebung oder Landschaft aufzubauen.
Der Infrastruktur-Stack (wie in Kapitel 5 definiert) ist die zentrale deploybare Infrastruktur-Einheit. Der Stack ist ein Beispiel für ein Architektonisches Quantum, das von Ford, Parsons und Kua beschrieben wird als »unabhängig deploybare Komponente mit einer hohen funktionalen Kohäsion, die alle strukturellen Elemente enthält, die für eine korrekte Funktionsweise des Systems erforderlich sind.«1 Mit anderen Worten – ein Stack ist eine Komponente, die Sie für sich alleine in die Produktivumgebung bringen können.
Wie schon erwähnt kann ein Stack aus Komponenten zusammengestellt werden, und ein Stack kann selbst eine Komponente sein. Server sind eine mögliche Komponente eines Stacks – und sie sind wichtig genug, dass wir uns in diesem Kapitel noch speziell mit ihnen befassen werden. Die meisten Stack-Management-Tools unterstützen auch, Stack-Code in Modulen zusammenzufassen oder Bibliotheken einzusetzen, um Elemente des Stacks zu erzeugen.
In Abbildung 15-1 sehen Sie zwei Stacks, die hier StackA und StackB genannt sind und die ein gemeinsames Code-Modul verwenden, das eine Networking-Struktur definiert.
Abbildung 15-1: Gemeinsames Code-Modul, das von zwei Stacks verwendet wird
In Kapitel 16 sind einige Patterns und Antipatterns für den Einsatz von Stack-Code-Modulen und -Bibliotheken beschrieben.
Stack-Module und -Bibliotheken sind für das Wiederverwenden von Code nützlich. Aber sie sind weniger hilfreich, wenn es darum geht, Stacks besser änderbar zu machen. Ich habe Teams erlebt, die versucht haben, einen monolithischen Stack zu verbessern (siehe »Antipattern: Monolithic Stack« auf Seite 86), indem sie den Code in Module unterteilten. Dadurch lässt sich der Code zwar besser verstehen, aber jede Stack-Instanz war immer noch so groß und komplex wie zuvor.
In Abbildung 15-2 sehen Sie, dass Code, der in Module unterteilt wird, in der Stack-Instanz wieder zusammenkommt.
Abgesehen davon, dass ein Modul ein zusätzliches Element jeder Stack-Instanz ist, sorgt es auch für eine Kopplung zwischen Stacks, wenn es ebenfalls von anderen Stacks genutzt wird. Änderungen am Modul, um eine Anforderung in einem Stack zu erfüllen, können sich auch auf andere Stacks auswirken, die dieses Modul einsetzen. Diese Kopplung kann zu Reibungen bei Änderungen führen.
Abbildung 15-2: Stack-Module sorgen für zusätzliche Komplexität in der Stack-Instanz
Will man einen großen Stack besser handhabbar machen, ist der Ansatz erfolgreicher, ihn in mehrere Stacks zu unterteilen, die jeweils unabhängig von den anderen provisioniert, gemanagt und geändert werden können. In »Patterns und Antipatterns für das Strukturieren von Stacks« auf Seite 86 finden Sie ein paar Patterns zu Größe und Inhalten eines Stacks. Kapitel 17 geht bezüglich des Managens der Abhängigkeiten zwischen Stacks ins Detail.
Server sind eine häufig genutzte Stack-Komponente. In Kapitel 11 wurden die verschiedenen Komponenten eines Servers und sein Lebenszyklus vorgestellt. Stack-Code greift auf Server normalerweise über eine Kombination aus Server-Images (siehe Kapitel 13) und Serverkonfigurationsmodulen zu (siehe »Server-Konfigurationscode« auf Seite 209), wobei oft eine Rolle spezifiziert wird (siehe »Serverrollen« auf Seite 212).
In der Codebasis des ShopSpinner-Teams findet sich auch ein Beispiel für den Einsatz eines Server-Image als Komponente eines Stacks. Es gibt einen Stack namens cluster_of_host_nodes, der ein Cluster aus Servern aufbaut, die als Container-Host-Knoten dienen sollen (siehe Abbildung 15-3).
Abbildung 15-3: Server-Image als Provider für einen Stack
Der Code, der das Server-Cluster definiert, legt als Namen des Server-Image host_node_image fest:
server_cluster:
name: "cluster_of_host_nodes"
min_size: 1
max_size: 3
each_server_node:
source_image: host_node_image
memory: 8GB
Das Team nutzt eine Pipeline, um Änderungen am Server-Image zu bauen und zu testen. Eine weitere Pipeline testet Änderungen an cluster_of_host_nodes und integriert es mit der neuesten Version von host_node_image, die ihre Tests bestanden hat (siehe Abbildung 15-4).
Abbildung 15-4: Pipeline zum Integrieren von Server-Image und seinem Consumer-Stack
In »Infrastruktur-Delivery-Pipelines« auf Seite 152 wurde erklärt, wie Pipelines für die Infrastruktur genutzt werden können.
Bei diesem Beispiel gibt es allerdings ein kleines Problem. Die erste Pipeline-Stage für den Stack cluster_of_host_nodes nutzt nicht das host_node_image. Aber das Beispiel mit dem Stack-Code enthält den Namen des Server-Image, daher kann es nicht als Online-Test-Stage ausgeführt werden (siehe »Online-Test-Stages für Stacks« auf Seite 168). Das Testen des Stack-Codes ohne das Image mag nützlich sein, damit das Team Probleme mit dem Stack-Code finden kann, ohne aufwendig die vollständigen Host-Knoten-Server provisionieren zu müssen.
Das ShopSpinner-Team geht das Problem an, indem es das hartcodierte host_node_image aus dem Stack-Code extrahiert und stattdessen einen Stack-Parameter verwendet (siehe Kapitel 7). Dieser Code ist besser testbar:
server_cluster:
name: "cluster_of_host_nodes"
min_size: 1
max_size: 3
each_server_node:
source_image: ${HOST_NODE_SERVER_IMAGE}
memory: 8GB
Die Online-Test-Stage für den Stack cluster_of_host_nodes kann den Parameter HOST_NODE_SERVER_IMAGE mit der ID für ein reduziertes Server-Image setzen. Das Team kann in dieser Stage Tests ausführen, um zu prüfen, ob das Server-Cluster korrekt läuft – einschließlich Skalieren und Wiederherstellen ausgefallener Instanzen. Das reduzierte Server-Image ist ein Beispiel für ein Test-Double (siehe »Test-Fixtures für den Umgang mit Abhängigkeiten verwenden« auf Seite 172).
Durch diese kleine Änderung, die hartcodierte Referenz auf das Server-Image durch einen Parameter zu ersetzen, wird die Kopplung verringert. Zudem wird die Kompositionsregel befolgt (siehe »Regel des Zusammenbaus« auf Seite 294). Das Team kann einfach Instanzen von cluster_of_host_nodes erstellen und dabei ein anderes Server-Image verwenden, was hilfreich wird, wenn man im Team ein anderes Betriebssystem für seine Cluster testen und inkrementell ausrollen will.
Im Feld des verteilten Computings ermöglicht eine Shared-Nothing-Architektur (https://oreil.ly/4MFP8) ein Skalieren, indem sichergestellt ist, dass einem System neue Knoten hinzugefügt werden können, ohne für zusätzliche Streitigkeiten um andere Ressourcen zu sorgen.
Das typische Gegenbeispiel ist eine Systemarchitektur, bei der sich Prozessoren eine Festplatte teilen. Die Zugriffe auf die gemeinsame Festplatte beschränken die Skalierbarkeit des Systems, wenn Sie mehr Prozessoren hinzufügen. Entfernen Sie die gemeinsam genutzte Festplatte aus dem Design, kann das System linearer skalieren, indem es Prozessoren hinzufügt.
Ein Shared-Nothing-Design mit Infrastruktur-Code verschiebt die Ressourcen aus einem gemeinsamen Stack in jeden Stack, der sie braucht, wodurch die Provider-Consumer-Beziehung wegfällt. So könnte das ShopSpinner-Team beispielsweise den application-infrastructure-stack und den shared-network-stack in einem Stack zusammenfassen.
Jede Instanz der Anwendungs-Infrastruktur besitzt ihren eigenen, vollständigen Satz an Networking-Strukturen. Damit kommen diese Strukturen zwar mehrfach vor, aber jede Anwendungs-Instanz bleibt unabhängig von den anderen. Bei einer verteilten Systemarchitektur fallen so Skalierungseinschränkungen weg. So kann beispielsweise das ShopSpinner-Team so viele Instanzen der Anwendungs-Infrastruktur hinzufügen, wie benötigt werden, ohne den Adressraum zu verbrauchen, der von einem einzelnen gemeinsamen Networking-Stack allokiert worden wäre.
Häufiger wird ein Shared-Nothing-Infrastruktur-Code-Design aber genutzt, um das Modifizieren, erneute Bauen und Wiederherstellen der Networking-Ressourcen für einen Anwendungs-Stack zu vereinfachen. Ein Design mit einem gemeinsamen Networking-Stack vergrößert den Explosionsradius und den Management-Overhead bei der Arbeit am Netzwerk.
Shared-Nothing-Infrastruktur unterstützt zudem das Zero-Trust-Sicherheitsmodell (siehe »Zero-Trust-Sicherheitsmodell mit SDN« auf Seite 61), da jeder Stack für sich abgesichert werden kann.
Abbildung 15-5: Mehrere Stacks mit einem Shared-Nothing-Deployment-Modell
Für ein Shared-Nothing-Design muss nicht alles in eine einzelne Stack-Instanz gesteckt werden. Für das ShopSpinner-Team ist eine Alternative zum Kombinieren der Networking- und Anwendungs-Infrastruktur in einem einzelnen Stack das Definieren der Networking- und Anwendungs-Infrastruktur wie zuvor in getrennten Stacks, nur dass nun für jede Instanz des Anwendungs-Stacks eine eigene Instanz des Networking-Stacks erzeugt wird (siehe Abbildung 15-5).
Mit diesem Ansatz teilen sich Instanzen des Anwendungs-Stacks kein Networking mit anderen Stacks, trotzdem können die beiden Teile unabhängig voneinander gemanagt werden. Die Grenzen liegen dabei darin, dass alle Netzwerk-Stack-Instanzen weiterhin durch den gleichen Code definiert werden, daher müssen Sie bei allen Änderungen am Code besonders darauf achten, dass keine der Instanzen beeinträchtigt wird.
Um die Infrastruktur zu unterteilen, sollten Sie – wie bei jedem System – nach Seams Ausschau halten. Ein Seam ist eine Stelle, an der Sie Verhalten in Ihrem System ändern können, ohne es an diesem Ort anzupassen.1 Die Idee ist, natürliche Stellen für Grenzen zwischen Teilen Ihres Systems zu finden, an denen Sie einfache und klare Integrationspunkte schaffen können.
Jede der folgenden Strategien gruppiert Infrastruktur-Elemente basierend auf einem spezifischen Aspekt: Patterns zur Änderung, zu organisatorischen Strukturen, zu Sicherheit und Governance und Resilienz und Skalierbarkeit. Bei diesen Strategien geht es letztendlich – wie bei den meisten Architektur-Prinzipien und -Praktiken – darum, auf Änderbarkeit hin zu optimieren. Die Aufgabe ist, Komponenten so zu entwerfen, dass Sie einfacher, sicherer und schneller Änderungen an Ihrem System vornehmen können.
Der einfachste Ansatz zum Optimieren von Komponentengrenzen in Bezug auf Änderungen ist, deren natürliches Änderungsmuster zu verstehen. Das ist die Idee hinter dem Finden von Seams – ein Seam ist eine natürliche Grenze.
Bei einem bestehenden System lernen Sie vielleicht, welche Elemente sich typischerweise gemeinsam ändern, indem Sie vergangene Änderungen unter die Lupe nehmen. Feingranulare Änderungen, wie zum Beispiel Code-Commits, liefern die nützlichsten Einblicke. Die effektivsten Teams optimieren auf häufige Commits, vollständiges Integrieren und Testen all dieser Check-ins. Wenn Sie verstehen, welche Komponenten dazu tendieren, sich gemeinsam als Teil eines einzelnen Commits oder mehrerer eng zusammengehöriger Commits über Komponenten hinweg zu ändern, können Sie Muster finden, die einen Hinweis darauf geben, wie Sie Ihren Code refaktorieren sollten, um mehr Kohäsion und weniger Kopplung zu erreichen.
Wenn Sie die Arbeiten auf einer höheren Ebene analysieren, wie zum Beispiel Tickets, Stories oder Projekte, können Sie besser verstehen, welche Teile des Systems in einem Satz von Änderungen gerne beteiligt sind. Aber Sie sollten auf kleine, häufige Änderungen hin optimieren. Stellen Sie daher sicher, bis in die Details vorzudringen, um zu verstehen, welche Änderungen unabhängig von anderen vorgenommen werden können, und um inkrementelle Änderungen im Kontext größerer Änderungsvorhaben zu ermöglichen.
Die verschiedenen Teile einer Infrastruktur haben eventuell unterschiedliche Lebenszyklen. So werden beispielsweise Server in einem Cluster (siehe »Computing-Ressourcen« auf Seite 58) dynamisch erzeugt und zerstört – vielleicht sogar mehrmals am Tag. Ein Datenbank-Storage-Volume ändert sich hingegen seltener.
Gruppieren Sie Infrastruktur-Ressourcen in deploybaren Komponenten – insbesondere Infrastruktur-Stacks – anhand ihres Lebenszyklus, kann das deren Management vereinfachen. Stellen Sie sich einen Anwendungsserver-Infrastruktur-Stack bei ShopSpinner vor, der aus Networking-Routen, einem Server-Cluster und einer Datenbank-Instanz besteht.
Die Server in diesem Stack werden mindestens einmal pro Woche aktualisiert, wobei sie mit neuen Server-Images mit den neuesten Betriebssystem-Patches neu gebaut werden (siehe Kapitel 13). Das Datenbank-Storage-Device ändert sich nur selten, auch wenn eventuell neue Instanzen gebaut werden, um Instanzen der Anwendung wiederherzustellen oder zu replizieren. Das Team ändert gelegentlich das Networking in anderen Stacks, wofür das Anpassen des anwendungsspezifischen Routings in diesem Stack erforderlich ist.
Definieren Sie diese Elemente in einem einzelnen Stack, kann das Risiken mit sich bringen. Ein Update am Anwendungsserver-Image kann fehlschlagen. Um das Probleme zu beheben, müssen Sie den gesamten Stack neu bauen – einschließlich des Datenbank-Storage-Device, wofür wiederum ein Backup der Daten erforderlich ist, um sie auf der neuen Instanz wiederherzustellen (siehe »Datenkontinuität in einem sich ändernden System« auf Seite 430). Auch wenn es möglich ist, all das in einem einzelnen Stack zu managen, wäre es einfacher, wenn das Datenbank-Storage in einem eigenen Stack definiert würde (siehe Abbildung 15-6).
Abbildung 15-6: Unterschiedliche Stacks haben unterschiedliche Lebenszyklen.
Änderungen werden an diesen Micro Stacks (siehe »Pattern: Micro Stack« auf Seite 92) vorgenommen, ohne die anderen direkt zu beeinflussen. Mit diesem Ansatz sind stackspezifische Management-Events möglich. So könnte beispielsweise eine Änderung am Datenbank-Storage-Stack ein Datenbackup anstoßen, das vermutlich viel zu teuer wäre, um es bei jeder Änderung an den anderen Elementen des ersten, kombinierten Stacks auszulösen.
Das Optimieren von Stackgrenzen anhand von Lebenszyklen ist besonders für das automatisierte Testen in Pipelines nützlich. Pipeline-Stages laufen häufig mehrfach am Tag, wenn an Infrastruktur-Änderungen gearbeitet wird, daher müssen sie darauf optimiert sein, schnell Feedback zu liefern und einen guten Arbeitsrhythmus zu ermöglichen. Organisieren Sie Infrastruktur-Elemente abhängig von ihren Lebenszyklen in getrennten Stacks, kann das die Zeit verringern, die für das Testen von Änderungen erforderlich ist.
Arbeiten Sie beispielsweise am Infrastruktur-Code für die Anwendungsserver, bauen manche Pipeline-Stages vielleicht jedes Mal den Stack neu (siehe »Pattern: Ephemeral Test Stack« auf Seite 178). Es kann länger dauern, Networking-Strukturen oder große Daten-Storage-Devices neu zu bauen, und eventuell ist das für viele der einzelnen Änderungen auch gar nicht erforderlich. In diesem Fall kann das oben gezeigte Micro Stack Design (Abbildung 15-6) das Testen und den Auslieferungsprozess geradliniger gestalten.
Ein dritter Anwendungsfall für das Trennen von Stacks anhand des Lebenszyklus ist die Kostenkontrolle. Um die Kosten für Public Clouds im Griff zu behalten, ist es üblich, Infrastruktur herunterzufahren oder zu zerstören und später wieder neu zu bauen, wenn sie in ruhigeren Phasen nicht benötigt wird. Aber manche Elemente, wie zum Beispiel Daten-Storage, lassen sich nur schwieriger neu bauen. Sie können diese in ihre eigenen Stacks auslagern und laufen lassen, während andere Stacks zerstört werden, um die Kosten zu reduzieren.
Das Gesetz von Conway (https://de.wikipedia.org/wiki/Gesetz_von_Conway) besagt, dass Systeme dazu tendieren, die Struktur der Organisation widerzuspiegeln, in der sie erzeugt wurden.1 Ein Team findet es meist einfacher, Software und Infrastruktur zu integrieren, für die es vollständig verantwortlich ist, und das Team wird ganz von alleine härtere Grenzen zu Teilen des Systems schaffen, die von anderen Teams verantwortet werden.
Das Gesetz von Conway hat zwei allgemeinere Auswirkungen auf das Designen von Systemen mit Infrastruktur. Die eine ist, Komponenten zu vermeiden, an denen mehrere Teams Änderungen vornehmen müssen. Die andere ist, darüber nachzudenken, ob Sie Teams so strukturieren, dass sie die architektonischen Grenzen widerspiegeln, die Sie haben wollen (das »Inverse Conway-Manöver«, https://oreil.ly/_dI92).
Legacy-Silos schaffen disjunkte Infrastruktur Ältere Organisationsstrukturen, die das Bauen und Betreiben in eigene Teams ausgelagert haben, sorgen häufig für inkonsistente Infrastrukturen entlang des Wegs in die Produktivumgebung, und beim Ausliefern von Änderungen steigen Zeit, Kosten und Risiko. Bei einer Organisation, mit der ich zusammengearbeitet habe, nutzten das Anwendungsentwicklungs-Team und das Operations-Team verschiedene Konfigurationstools zum Bauen von Servern. Sie verschwendeten bei jedem Software-Release viele Wochen damit, die Anwendungen korrekt in die Produktivumgebung deployen und dort ausführen zu können. |
Gerade für Infrastruktur lohnt es sich, zu überlegen, wie Sie das Design mit der Struktur der Teams abstimmen können. In den meisten Organisationen sind diese nach Produkten oder Services und Anwendungen organisiert. Selbst wenn die Infrastruktur von mehreren Teams genutzt wird, können Sie sie vielleicht so designen, dass jedes Team eigene Instanzen erhält.
Durch das Abstimmen der Infrastruktur-Instanzen mit den Teams, die sie verwenden, werden Änderungen weniger disruptiv. Statt ein einzelnes Änderungsfenster mit allen Teams abgleichen zu müssen, die eine gemeinsame Instanz verwenden, können Sie für jedes Team eigene Fenster festlegen.
Fällt in Ihrem System etwas aus, können Sie eine unabhängig deploybare Komponente wie zum Beispiel einen Infrastruktur-Stack neu bauen. Sie können Elemente in einem Stack manuell reparieren oder neu bauen und damit Infrastruktur-Chirurgie betreiben. Dafür greift jemand mit einem umfassenden Verständnis für die Infrastruktur sehr sorgfältig in diese ein. Ein einfacher Fehler kann die Situation viel schlimmer machen.
Manche sind stolz auf ihre Infrastruktur-Chirurgie, aber sie ist kein probates Mittel, um Lücken in Ihren Infrastruktur-Management-Systemen zu stopfen.
Eine Alternative dazu ist das neue Bauen von Komponenten mit wohldefinierten Prozessen und Werkzeugen. Sie sollten jede Stack-Instanz neu bauen können, indem Sie den gleichen automatisierten Prozess auslösen, den Sie auch zum Anwenden von Änderungen und Updates verwenden. Ist das möglich, müssen Sie nicht mitten in der Nacht Ihre brillanteste Chirurgin oder den besten Chirurgen für die Infrastruktur wecken, wenn etwas ausfällt. In vielen Fällen können Sie automatisch ein Wiederherstellen auslösen.
Infrastruktur-Komponenten müssen so designt sein, dass es möglich ist, sie schnell neu zu bauen und wiederherzustellen. Organisieren Sie Ressourcen in Komponenten anhand ihres Lebenszyklus (siehe »Grenzen mit Komponenten-Lebenszyklen abstimmen« auf Seite 304), können Sie auch Rebuild- und Recovery-Anwendungsfälle mit in Betracht ziehen.
Das Beispiel zum Aufteilen von Infrastruktur mit persistenten Daten (Abbildung 15-6) geht so vor. Der Prozess zum erneuten Bauen des Daten-Storage sollte Schritte beinhalten, die Daten automatisch sichern und wieder laden, was für ein Desaster-Recovery-Szenario sehr praktisch werden kann. In »Datenkontinuität in einem sich ändernden System« auf Seite 430 finden Sie mehr dazu.
Teilen Sie die Infrastruktur abhängig vom Rebuild-Prozess in Komponenten auf, hilft Ihnen das dabei, die Wiederherstellung zu vereinfachen und zu optimieren. Ein anderer Ansatz zum Verbessern der Resilienz ist das Betreiben mehrerer Instanzen von Teilen Ihrer Infrastruktur. Strategien zur Redundanzsteigerung können auch beim Skalieren helfen.
Zum Skalieren von Systemen werden gerne zusätzliche Instanzen bestimmter Komponenten erstellt. Sie können Instanzen hinzufügen, wenn in einer bestimmten Zeit höhere Anforderungen bestehen, und Sie können auch überlegen, Instanzen in unterschiedlichen geografischen Regionen zu deployen.
Viele Entwicklerinnen und Entwickler wissen, dass die meisten Cloud-Plattformen automatisch Server-Cluster nach oben und unten skalieren können (siehe »Computing-Ressourcen« auf Seite 58), wenn sich die Last ändert. Ein wichtiger Vorteil von FaaS Serverless (siehe »Infrastruktur für FaaS Serverless« auf Seite 286) ist, dass Code-Instanzen nur dann ausgeführt werden, wenn sie auch benötigt sind.
Aber andere Elemente Ihrer Infrastruktur, wie zum Beispiel Datenbanken, Message Queues und Storage Devices, können zu Flaschenhälsen werden, wenn die Rechenleistung hochgeht. Und auch unabhängig von Ihrer Infrastruktur können sich Teile Ihres Softwaresystems zu Flaschenhälsen entwickeln.
So kann beispielsweise das ShopSpinner-Team mehrere Instanzen des Produkt-Browsing-Service deployen, um mit einer höheren Last umzugehen, weil der meiste User-Traffic das System zu bestimmten Spitzenzeiten erreicht. Das Team behält eine einzelne Instanz seines Frontend-Traffic-Routing-Stacks und eine einzelne Instanz des Datenbank-Stacks bei, mit dem sich die Anwendungsserver-Instanzen verbinden (siehe Abbildung 15-7).
Abbildung 15-7: Die Anzahl der Instanzen der verschiedenen Stacks skalieren
Andere Teile des Systems, wie zum Beispiel die Services zum Bestell-Checkout und zur Kundenprofil-Verwaltung, müssen vermutlich nicht zusammen mit dem Produkt-Browsing-Service skaliert werden. Teilen Sie diese Services auf unterschiedliche Stacks auf, hilft das dem Team dabei, sie schneller skalieren zu können. So sparen Sie sich viele überflüssige Elemente, die entstehen würden, wenn Sie alles Replizieren.
Früher wurden viele Systeme durch die Architektinnen und Architekten nach Funktionen organisiert. Die Networking-Sachen kamen zusammen, die Datenbank-Angelegenheiten lagen in einem Bereich, und alles rund um das Betriebssystem gehörte zu einer weiteren Gruppe. Das ist oft ein Ergebnis der Organisationsstruktur, wie es vom Gesetz von Conway vorhergesagt wird – sind Teams anhand dieser technischen und funktionalen Spezialitäten organisiert, werden sie die Infrastruktur anhand dessen unterteilen, was sie managen.
Ein Fallstrick dieses Ansatzes ist, dass ein Service, der den Anwenderinnen und Anwendern bereitgestellt wird, viele Funktionen in sich vereint. Das wird häufig als vertikaler Service dargestellt, der horizontale Funktionsschichten durchschneidet (siehe Abbildung 15-8).
Abbildung 15-8: Die Infrastruktur für jeden Service ist auf mehrere Stacks verteilt.
Organisieren Sie Systemelemente in funktionalen Infrastruktur-Stacks, hat das zwei Nachteile. Einer ist, dass eine Änderung an der Infrastruktur für einen Service dazu führen kann, dass Änderungen an vielen Stacks vorzunehmen sind. Diese Änderungen müssen sorgfältig orchestriert werden, um sicherzustellen, dass in einem Consumer-Stack keine Abhängigkeit eingeführt wird, bevor sie im Provider-Stack erscheint (siehe »Provider und Consumer« auf Seite 296).
Verteilt man die Zuständigkeit für die Infrastruktur eines einzelnen Service über mehrere Funktions-Teams, sorgt das für einen merklichen Kommunikations-Overhead und zusätzliche Prozesse für Änderungen an einem Service.
Der zweite Nachteil funktionaler Stacks zeigt sich, wenn es gemeinsame Verwendungen über Services hinweg gibt. In Abbildung 15-9 managt ein Server-Team eine einzelne Stack-Instanz mit Servern für mehrere Services.
Ändert das Team den Server für einen der Services, existiert ein Risiko, dass andere Services nicht mehr laufen, weil erst die Stackgrenze den Explosionsradius bei einer Änderung beschränkt (siehe »Antipattern: Monolithic Stack« auf Seite 86).
Abbildung 15-9: Durch das Ändern einer horizontalen Schicht werden mehrere Endanwender-Services beeinflusst.
Sicherheit, Compliance und Governance schützen Daten, Transaktionen und die Service-Verfügbarkeit. Die verschiedenen Elemente eines Systems werden auch verschiedene Regeln haben. So stellt beispielsweise der PCI-Sicherheitsstandard (https://oreil.ly/JDkPl) bestimmte Anforderungen für die Teile eines Systems, die sich mit Kreditkartendaten oder Bezahlprozessen befassen. Persönliche Daten von Kunden und Mitarbeiterinnen müssen häufig ebenfalls einer strengeren Kontrolle unterliegen.
Viele Organisationen teilen ihre Infrastruktur anhand der Regeln und Richtlinien auf, die für die Services und Daten gelten, die darauf beheimatet sind. Dadurch wird klarer, welche Maßnahmen für eine gegebene Infrastruktur-Komponente zu treffen sind. Der Auslieferungsprozess für Änderungen kann dann an die Governance-Anforderungen angepasst werden. So kann er beispielsweise Reviews und Genehmigungen erzwingen und dokumentieren und Änderungsreports erzeugen, die das Auditing vereinfachen.
Netzwerkgrenzen sind keine Grenzen für Infrastruktur-Stacks Oft wird die Infrastruktur in Networking-Sicherheitszonen unterteilt. Systeme, die in einer Frontend-Zone laufen, sind direkt aus dem öffentlichen Internet erreichbar und werden durch Firewalls und andere Mechanismen geschützt. Andere Zonen – beispielsweise für Anwendungshosting und Datenbanken – sind nur aus bestimmten anderen Zonen heraus erreichbar und sie besitzen zusätzliche Sicherheitsschichten. |
|
Diese Grenzen sind zwar wichtig, um sich vor Angriffen über das Netz zu schützen, aber meist passen sie nicht für das Aufteilen von Infrastruktur-Code in deploybare Einheiten. Wenn Sie den Code für Webserver und Load Balancer in einem »Frontend«-Stack zusammenbringen, sorgt das nicht für eine Abwehrschicht gegen böswillige Änderungen am Code für Anwendungsserver oder Datenbanken. Das Bedrohungsmodell für das Ausnutzen von Infrastruktur-Code und -Tools unterschiedet sich vom Bedrohungsmodell für Angriffe über das Netzwerk. |
|
Nutzen Sie auf jeden Fall Ihren Infrastruktur-Code, um Netzwerkgrenzen in Schichten zu erzeugen.1 Aber Ihnen sollte auch klar sein, dass es keine gute Idee ist, das Networking-Sicherheitsmodell auf das Strukturieren von Infrastruktur-Code anzuwenden. |
In diesem Kapitel ging es zunächst darum, wie Sie größere und komplexere, als Code definierte Infrastruktur managen, indem Sie sie in kleinere Einheiten herunterbrechen. Dabei wurde auf vorige Inhalte des Buchs zurückgegriffen, unter anderem auf die Organisationseinheiten von Infrastruktur (Stacks und Server) sowie auf die anderen zentralen Praktiken – das Definieren von Elementen als Code und das kontinuierliche Testen.