Der Lebenszyklus der Softwareauslieferung ist in unserer Branche ein bekanntes Konzept. Infrastruktur-Auslieferungen folgen im Kontext der Eisenzeit oft einer anderen Art von Prozess, der gerne weniger streng ist. Oft wird eine Änderung – zum Beispiel an der Hardware – an Produktiv-Infrastruktur vorgenommen, ohne sie zuvor zu testen.
Aber der Einsatz von Code zum Definieren von Infrastruktur schafft die Gelegenheit, Änderungen mit einem umfassenderen Prozess zu managen. Es mag verrückt sein, eine Änderung an einem manuell gebauten System in einem Entwicklungssystem zu reproduzieren, wie zum Beispiel das Ändern des RAMs in einem Server. Aber wenn die Änderung in Code implementiert ist, können Sie sie problemlos mithilfe einer Pipeline entlang eines Pfads bis zur Produktivumgebung ausrollen (siehe »Infrastruktur-Delivery-Pipelines« auf Seite 152). Damit fangen Sie nicht nur ein Problem mit der Änderung selbst ab, die vielleicht trivial aussehen mag (das Hinzufügen von mehr RAM dürfte recht selten etwas kaputt machen), sondern auch Probleme mit dem Prozess des Anwendens der Änderung. Zudem ist so garantiert, dass alle Umgebungen entlang des Pfads in die Produktivumgebung konsistent konfiguriert sind.
Die Pipeline-Metapher beschreibt, wie sich eine Änderung an Infrastruktur-Code ausgehend von der Person, die die Änderung macht, bis in die Produktivumgebung bewegt. Die für diesen Auslieferungsprozess erforderlichen Aktivitäten beeinflussen auch die Organisation Ihrer Codebasis.
Eine Pipeline zum Ausliefern von Code-Versionen enthält verschiedene Arten von Aktivitäten, unter anderem das Bauen, Transportieren, Anwenden und Validieren. Jede Stage in der Pipeline kann mehrere Aktivitäten beinhalten, wie in Abbildung 19-1 zu sehen ist.
Abbildung 19-1: Auslieferungsphasen eines Infrastruktur-Code-Projekts
Bauen
Bereitet eine Version des Codes für den Einsatz vor und macht ihn für andere Phasen verfügbar. Das Bauen geschieht normalerweise in einer Pipeline nur einmal – immer dann, wenn sich der Quellcode ändert.
Transportieren
Bewegt eine Version des Codes zur nächsten Auslieferungs-Stage (wie in »Progressives Testen« auf Seite 148 beschrieben). Hat beispielsweise eine Version des Stack-Projekts ihre Stack-Test-Stage bestanden, kann sie weitertransportiert werden, um zu zeigen, dass sie für die Systemintegrations-Test-Stage bereit ist.
Anwenden
Führt die relevanten Tools aus, um den Code auf die entsprechenden Infrastruktur-Instanzen anzuwenden. Die Instanz könnte eine Auslieferungsumgebung sein, die für Test-Aktivitäten genutzt wird, oder aber eine Produktiv-Instanz.
In »Infrastruktur-Delivery-Pipelines« auf Seite 152 finden Sie mehr zum Pipeline-Design. Hier konzentrieren wir uns auf das Bauen und Weiterbefördern von Code.
Beim Bauen eines Infrastruktur-Projekts wird der Code für den Einsatz vorbereitet. Dazu kann gehören:
Es gibt ein paar Möglichkeiten, den Infrastruktur-Code vorzubereiten und zum Einsatz verfügbar zu machen. Manche Tools unterstützen direkt bestimmte Wege dazu, wie zum Beispiel ein Standard-Artefakt-Paketformat oder ein Repository. Andere überlassen es den Teams, die das Tool verwenden, ihren eigenen Weg zum Ausliefern von Code zu implementieren.
Bei manchen Tools gehört zum Vorbereiten des Codes das Zusammenstellen der Dateien in einer Paketdatei mit einem bestimmten Format – einem Artefakt. Dieser Prozess ist typisch für allgemein nutzbare Programmiersprachen wie Ruby (gems), JavaScript (NPM) oder Python (Python-Pakete für den PIP-Installer). Andere Paketformate für das Installieren von Dateien und Anwendungen für bestimmte Betriebssysteme sind beispielsweise .rpm, .deb, .msi oder NuGet (Windows).
Nicht viele Infrastruktur-Tools haben ein Paketformat für ihre Code-Projekte. Trotzdem bauen manche Teams ihre eigenen Artefakte dafür und verpacken den Stack-Code oder den Server-Code in ZIP-Dateien oder »Tarballs« (tar-Archive, die mit gzip komprimiert werden). Einige Teams verwenden OS-Paketformate und erstellen beispielsweise RPM-Dateien, die Cookbook-Dateien von Chef auf einem Server auspacken. Oder sie erstellen Docker-Images, die Stack-Projektcode und die dafür benötigten Stack-Tool-Executables enthalten.
Eine weitere Möglichkeit ist, den Infrastruktur-Code nicht in Artefakten zu verpacken – insbesondere für Tools, die kein eigenes Paketformat besitzen. Ob man das tun sollte, hängt davon ab, wie man seinen Code verbreitet, bereitstellt und einsetzt, was wiederum davon abhängt, was für eine Art von Repository verwendet wird.
Teams verwenden ein Quellcode-Repository, um Änderungen an ihrem Infrastruktur-Quellcode abzulegen und zu verwalten. Häufig nutzen sie ein eigenes Repository zum Speichern des Codes, der zum Ausliefern auf Umgebungen und Instanzen bereit ist. Manche Teams nutzen – wie wir sehen werden – das gleiche Repository für beide Zwecke.
Konzeptionell hält die Build-Stage diese beiden Repository-Typen getrennt, nimmt Code aus dem Quellcode-Repository, fasst ihn zusammen und veröffentlicht ihn dann im Auslieferungs-Repository (siehe Abbildung 19-2).
Abbildung 19-2: Die Build-Stage veröffentlicht Code im Auslieferungs-Repository.
Das Auslieferungs-Repository speichert normalerweise mehrere Code-Versionen eines Projekts. Transport-Phasen (siehe gleich) markieren Versionen des Projektcodes, um zu zeigen, bis zu welcher Stage sie vorgedrungen sind – ob die Version beispielsweise für Integrationstests oder die Produktivumgebung bereit ist.
Eine Anwendungs-Aktivität holt eine Version des Projektcodes aus dem Auslieferungs-Repository und wendet sie auf eine bestimmte Instanz an, wie zum Beispiel auf die SIT-Umgebung oder die PROD-Umgebung.
Es gibt ein paar Möglichkeiten, ein Auslieferungs-Repository zu implementieren. Ein gegebenes System kann unterschiedliche Repository-Implementierungen für verschiedene Arten von Projekten nutzen. So könnte beispielsweise ein Tool-spezifisches Repository wie Chef Server für Projekte zum Einsatz kommen, die dieses Tool nutzen. Das gleiche System könnte einen normalen Datei-Storage-Service wie ein S3-Bucket für ein Projekt einsetzen, das ein Tool wie Packer verwendet, welches kein Paketformat und auch kein spezialisiertes Repository besitzt.
Es gibt eine Reihe von Arten von Repository-Implementierungen für Auslieferungscode.
Die meisten im vorigen Abschnitt erwähnten Paketformate besitzen ein Paket-Repository-Produkt, einen Service oder einen Standard, den mehrere Produkte und Services implementieren können. Es gibt eine Reihe von Repository-Produkten und gehosteten Services für .rpm-, .deb-, .gem- und .npm-Dateien.
Manche Repository-Produkte wie Artifactory (https://oreil.ly/k3xmX) oder Nexus (https://oreil.ly/mSh_i) unterstützen mehrere Paketformate. Teams in einer Organisation, die eines dieser Produkte nutzt, verwenden sie manchmal, um ihre Artefakte (wie zum Beispiel ZIP-Dateien oder Tarballs) abzulegen. Viele Cloud-Plattformen bieten spezialisierte Artefakt-Repositories an, wie zum Beispiel ein Server-Image-Storage.
Das ORAS-Projekt (OCI Registry As Storage, https://oreil.ly/CGFsM) bietet eine Möglichkeit, Artefakt-Repositories, die ursprünglich für Docker-Images gedacht waren, als Repository für beliebige Arten von Artefakten zu verwenden.
Unterstützt ein Artefakt-Repository Tags oder Label, können Sie diese für den Transport verwenden. Um beispielsweise ein Stack-Projekt-Artefakt in die Systemintegrations-Test-Stage zu befördern, könnten Sie es mit SIT_STAGE=true oder Stage=SIT taggen.
Alternativ könnten Sie mehrere Repositories auf einem Repository-Server erstellen, wobei jeweils ein Repository für jede Auslieferungs-Stage gedacht ist. Um ein Artefakt weiterzubefördern, kopieren oder verschieben Sie das Artefakt in das entsprechende Repository.
Viele Infrastruktur-Tools besitzen ein spezialisiertes Repository, das keine verpackten Artefakte beinhaltet. Stattdessen führen Sie ein Programm aus, das den Code Ihres Projekts auf den Server lädt und ihm eine Version zuweist. Das funktioniert fast genauso wie bei einem spezialisierten Artefakt-Repository – nur ohne Paketdatei.
Beispiele dafür sind Chef Server (https://oreil.ly/HWYph, selbst gehostet), Chef Community Cookbooks (https://oreil.ly/lTyBp, öffentlich) oder Terraform Registry (https://oreil.ly/J85R1, öffentliche Module).
Viele Teams – insbesondere solche, die ihre eigenen Formate zum Ablegen von Infrastrukturcode-Projekten für das Ausliefern einsetzen – speichern sie über einen universell nutzbaren Datei-Storage-Service oder ein entsprechendes Produkt ab. Dabei kann es sich um einen Dateiserver, Webserver oder Object Storage Service handeln.
Diese Repositories besitzen keine spezifische Funktionalität für den Umgang mit Artefakten, wie zum Beispiel Versionsnummern. Daher weisen Sie die Versionsnummern selbst zu – vielleicht durch Aufnahme in den Dateinamen (beispielsweise my-stack-v1.3.45.tgz). Um ein Artefakt weiterzubefördern, können Sie es kopieren oder in einen Ordner für die relevante Auslieferungs-Stage verlinken.
Wenn sich der Quellcode schon in einem Quellcode-Repository befindet und weil viele Infrastruktur-Code-Tools kein Paketformat und keine Werkzeuge für das Behandeln ihres Codes als Release besitzen, fügen viele Teams Code einfach aus ihrem Quellcode-Repository in die Umgebungen ein.
Das Anwenden von Code aus dem Main-Branch (Trunk) auf alle Umgebungen würde es schwierig machen, unterschiedliche Versionen des Codes zu verwalten. Daher nutzen die meisten Teams, die diesen Weg verfolgen, Branches – oft mit einem eigenen Branch für jede Umgebung. Sie transportieren eine Version des Codes in eine Umgebung, indem Sie ihn mit dem relevanten Branch mergen. GitOps kombiniert diese Praktik mit der kontinuierlichen Synchronisation (siehe »Code kontinuierlich anwenden« auf Seite 395 und »GitOps« auf Seite 396).
Der Einsatz von Branches zum Weiterleiten von Code kann die Grenze zwischen dem Bearbeiten von Code und dessen Auslieferung verwischen lassen. Ein zentrales Prinzip von CD ist, Code niemals nach der Build-Stage zu verändern.1 Auch wenn sich Ihr Team darauf einigen mag, niemals Code in Branches zu verändern, ist es häufig schwierig, diese Disziplin dann auch beizubehalten.
Wie in »Projekte und Repositories organisieren« auf Seite 345 erwähnt sind Projekte in einer Codebasis normalerweise untereinander abhängig. Die nächste Frage ist daher, wann und wie Sie die verschiedenen Versionen von Projekten kombinieren, die voneinander abhängen.
Denken Sie beispielsweise an die vielen Projekte in der Codebasis des ShopSpinner-Teams. Sie haben zwei Stack-Projekte. Das eine (application-infrastructure-stack) definiert die Infrastruktur für eine Anwendung – einschließlich eines Pools virtueller Maschinen und Regeln für Load Balancer für den Anwendungs-Traffic. Das andere Stack-Projekt (shared_network_stack) definiert ein Networking, das von mehreren Instanzen von application-infrastructure-stack genutzt wird – unter anderem Adressblöcke (VPC und Subnetze) und Firewall-Regeln, die den Traffic zu den Anwendungsservern zulassen.
Abbildung 19-3: Beispiel für Abhängigkeiten zwischen Infrastruktur-Projekten
Das Team besitzt zudem zwei Serverkonfigurationsprojekte: tomcat-server konfiguriert und installiert die Software für den Anwendungsserver, während monitor-server das Gleiche für einen Monitoring-Agenten macht.
Das fünfte Infrastruktur-Projekt application-server-image baut ein Server-Image mithilfe der Konfigurationsmodule tomcat-server und monitor-server (siehe Abbildung 19-3).
Das Projekt application-infrastructure-stack erzeugt seine Infrastruktur innerhalb der Networking-Strukturen, die durch shared_network_stack angelegt wurden. Zudem wird das Server-Image verwendet, das durch das Projekt application-server-image erstellt wurde, um Server in seinem Anwendungsserver-Cluster zu erzeugen. Und application-server-image baut Server-Images mithilfe der Serverkonfigurations-Definitionen in tomcat-server und monitor-server.
Nimmt jemand eine Änderung an einem dieser Infrastruktur-Code-Projekte vor, schafft er eine neue Version des Projektcodes. Diese Projektversion muss mit einer Version von jedem der anderen Projekte integriert werden. Die Projektversionen können beim Bauen (Build Time), beim Ausliefern (Delivery Time) oder beim Anwenden (Apply Time) integriert werden.
Verschiedene Projekte eines gegebenen Systems können zu unterschiedlichen Zeitpunkten integriert werden, wie das ShopSpinner-Beispiel in den Beschreibungen der folgenden Patterns zeigt.
Linken und Integrieren Das Kombinieren der Elemente eines Computerprogramms wird als Linken (https://de.wikipedia.org/wiki/Linker_(Computerprogramm)) bezeichnet. Es gibt Parallelen zwischen dem Linken und dem in diesem Kapitel beschriebenen Integrieren von Infrastruktur-Projekten. Bibliotheken werden statisch gelinkt, wenn eine ausführbare Datei gebaut wird – ähnlich zur Build-Time Project Integration. |
|
Bibliotheken, die auf einer Maschine installiert werden, werden dynamisch verlinkt, wenn ein Aufruf von einer ausführbaren Datei eintrifft – das entspricht in etwa der Apply-Time Project Integration. In beiden Fällen können sowohl der Provider wie auch der Consumer in der Laufzeitumgebung auf eine andere Version wechseln. |
|
Die Analogie ist nicht perfekt, da der Austausch eines Programms das Verhalten des Executables ändert, während ein Wechsel der Infrastruktur (allgemein gesagt) den Zustand von Ressourcen beeinflusst. |
Das Build-Time-Project-Integration-Pattern führt Build-Aktivitäten für mehrere Projekte aus. Dazu gehören das Integrieren der Abhängigkeiten zwischen den Projekten und das Setzen der Code-Versionen.
Zum Build-Prozess gehört oft, jedes der beteiligten Projekte separat zu bauen und zu testen, bevor alle gemeinsam gebaut und getestet werden (siehe Abbildung 19-4). Dieses Pattern unterscheidet sich von seinen Alternativen darin, dass es entweder ein einzelnes Artefakt für alle Projekte erzeugt oder einen Satz an Artefakten baut, die zusammen versioniert, transportiert und angewendet werden.
Abbildung 19-4: Beispiel für das Integrieren von Projekten beim Bauen
In diesem Beispiel erzeugt eine einzelne Build-Stage ein Server-Image mithilfe mehrerer Serverkonfigurationsprojekte.
Zur Build-Stage können mehrere Schritte gehören, wie zum Beispiel das Bauen und Testen der einzelnen Serverkonfigurationsmodule. Aber das Ergebnis – das Server-Image – besteht aus Code aus all den Projekten, von denen es abhängig ist.
Bauen Sie die Projekte gemeinsam, zeigen sich Abhängigkeitsprobleme sehr früh. So erhalten Sie schnelles Feedback zu Konflikten und sorgen für ein hohes Konsistenzniveau in der Codebasis für den Auslieferungsprozess bis in die Produktivumgebung. Projektcode, der beim Bauen integriert wird, ist im gesamten Auslieferungs-Lebenszyklus konsistent. Die gleiche Code-Version wird in jeder Stage des Prozesses bis in die Produktivumgebung angewendet.
Es ist vor allem eine Frage des Geschmacks, ob man dieses Pattern oder eine seiner Alternativen wählt. Sie müssen entscheiden, welche Vor- und Nachteile Sie akzeptieren und wie gut Ihr Team mit den Herausforderungen von projektübergreifenden Builds umgeht.
Das Bauen und Integrieren mehrerer Projekte zur Laufzeit ist komplex, insbesondere, wenn eine große Zahl davon im Spiel ist. Abhängig von Ihrer Implementierung des Builds kann das zu längeren Feedback-Zeiten führen.
Der Einsatz der Build-Time Project Integration im großen Maßstab erfordert ausgefeilte Werkzeuge, um die Builds zu orchestrieren. Große Organisationen, die dieses Pattern für große Codebasen einsetzen, wie zum Beispiel Google oder Facebook, haben Teams, die sich nur um das Betreuen der hauseigenen Tools kümmern.
Es gibt Build-Tools, die zum Bauen sehr vieler Softwareprojekte geeignet sind (siehe den nächsten Abschnitt). Aber dieser Ansatz wird in der Branche nicht so oft verfolgt wie das getrennte Bauen von Projekten, daher ist die Zahl der Tools kleiner, und es gibt auch nicht so viele Referenzen.
Wenn Projekte gemeinsam gebaut werden, sind die Grenzen zwischen ihnen weniger sichtbar als bei anderen Patterns. Das kann zu einer engeren Kopplung zwischen den Projekten führen. Geschieht das, kann es schwer sein, eine kleine Änderung vorzunehmen, ohne viele andere Teile der Codebasis zu beeinflussen, was zu mehr Zeitaufwand und einem höheren Risiko bei Änderungen führt.
Speichern Sie alle Projekte für den Build in einem einzelnen Repository – oft als ein Monorepo bezeichnet (https://oreil.ly/6yVmG) –, vereinfacht das das gemeinsame Bauen, indem alle zusammen versioniert werden können (siehe »Monorepo – ein Repository, ein Build« auf Seite 347).
Die meisten Tools für Software-Builds – wie Gradle, Make, Maven, MSBuild oder Rake – werden genutzt, um Builds über eine übersichtliche Zahl von Projekten zu orchestrieren. Das Ausführen von Builds und Tests für eine sehr große Zahl von Projekten kann sehr viel Zeit kosten.
Parallelisierung kann diesen Prozess beschleunigen, indem mehrere Projekte in verschiedenen Threads, Prozessen oder sogar in einem Compute Grid gebaut und getestet werden. Aber es ist mehr Rechenleistung erforderlich.
Umfangreiche Builds lassen sich besser optimieren, indem ein gerichteter Graph eingesetzt wird, um das Bauen und Testen auf die Teile der Codebasis zu begrenzen, die sich geändert haben. Gut gemacht sollte das die Zeit verringern, die nach einem Commit für das Bauen und Testen erforderlich ist, sodass es nur ein bisschen länger dauert, als einen Build für einzelne Projekte auszuführen.
Es gibt eine Reihe von Tools, die auf den Umgang mit sehr großen Builds aus vielen Projekten spezialisiert sind. Die meisten davon wurden durch die hauseigenen Tools von Google und Facebook inspiriert. Dazu gehören unter anderem Bazel (https://bazel.build), Buck (https://buck.build), Pants (https://www.pantsbuild.org) und Please (https://please.build).
Die Alternativen zum Integrieren von Projektversionen während des Bauens sind, dies beim Ausliefern (siehe »Pattern: Delivery-Time Project Integration« auf Seite 373) oder beim Anwenden (siehe »Pattern: Apply-Time Project Integration« auf Seite 375) zu erledigen.
Die Strategie, mehrere Projekte in einem einzelnen Repository zu verwalten (»Monorepo – ein Repository, ein Build« auf Seite 347), ist zwar selbst kein Pattern, unterstützt dieses Pattern aber. Das Beispiel, das ich für dieses Pattern genutzt habe (siehe »Beispiel für das Integrieren von Projekten beim Bauen« auf Seite 370), wendet den Serverkonfigurationscode beim Erstellen eines Server-Image an (siehe »Server-Images backen« auf Seite 225). Das Immutable-Server-Pattern (siehe »Pattern: Immutable Server« auf Seite 234) ist ein weiteres Beispiel für eine Build-Time Integration statt einer Delivery-Time Integration.
Auch wenn das in diesem Buch nicht beschrieben ist, lösen viele Projekt-Builds Abhängigkeiten zu Bibliotheken von Drittherstellern beim Bauen auf, laden sie herunter und verpacken sie zusammen mit dem Deliverable. Der Unterschied liegt darin, dass diese Abhängigkeiten nicht zusammen mit dem Projekt gebaut und getestet werden, das sie verwendet. Kommen solche Abhängigkeiten von anderen Projekten aus der gleichen Organisation, ist das ein Beispiel für eine Delivery-Time Project Integration (siehe »Pattern: Delivery-Time Project Integration« auf Seite 373).
Ist es ein Monorepo oder eine Build-Time Project Integration? Die meisten Beschreibungen der Monorepo-Strategie (https://oreil.ly/6yVmG) für das Organisieren einer Codebasis beinhalten das gemeinsame Bauen aller Projekte im Repository – was ich als Build-Time Project Integration bezeichnet habe. Ich wollte dieses Pattern nicht Monorepo nennen, weil der Name auf den Einsatz eines einzelnen Code-Repositories deutet, was für mich nur eine Implementierungs-Option ist. |
|
Ich habe Teams kennengelernt, die mehrere Projekte in einem einzelnen Repository verwalten, ohne sie auch zusammen zu bauen. Auch wenn diese Teams ihre Codebasis oft als Monorepo bezeichnen, nutzen sie nicht das hier beschriebene Pattern. |
|
Andererseits ist es technisch möglich, Projekte aus verschiedenen Repositories auszuchecken und sie dann gemeinsam zu bauen. Das passt zum hier beschriebenen Pattern, da es Projektversionen beim Bauen integriert. Aber so wird es schwieriger, Quellcode-Versionen aufeinander abzustimmen und herauszufinden, welche zusammengehören – zum Beispiel beim Debuggen von Problemen im Produktivumfeld. |
Hat man mehrere Projekte mit Abhängigkeiten dazwischen, baut und testet die Delivery-Time Project Integration jedes Projekt einzeln, bevor es sie zusammenführt. Der Ansatz integriert die Code-Versionen später als bei der Build-Time Integration.
Wurden die Projekte zusammengeführt und getestet, wandert ihr Code gemeinsam entlang den Rest des Auslieferungswegs.
Ein Beispiel: Das Projekt application-infrastructure-stack von ShopSpinner definiert ein Cluster mit virtuellen Maschinen, wobei das Server-Image zum Einsatz kommt, das im Projekt application-server-image definiert wurde (siehe Abbildung 19-5).
Nimmt jemand eine Änderung am Infrastruktur-Stack-Code vor, baut und testet die Auslieferungs-Pipeline das Stack-Projekt für sich alleine, wie das in Kapitel 9 beschrieben wurde.
Besteht die neue Version des Stack-Projekts diese Tests, läuft es weiter in die Integrationstest-Phase, wo der Stack zusammen mit dem letzten Server-Image getestet wird, das seine eigenen Tests bestanden hat. Diese Stage ist der Integrationspunkt für die beiden Projekte. Die Versionen der Projekte wandern dann gemeinsam weiter in die folgenden Stages der Pipeline.
Abbildung 19-5: Beispiel für das Integrieren von Projekten beim Ausliefern
Bauen und testen Sie Projekte einzeln, bevor Sie sie integrieren, ist das eine Möglichkeit, klare Grenzen und eine lose Kopplung zwischen diesen sicherzustellen.
So implementiert beispielsweise ein Mitglied des ShopSpinner-Teams eine Firewall-Regel in application-infrastructure-stack, die einen TCP-Port öffnet, der in einer Konfigurationsdatei in application-server-image definiert ist. Die Person schreibt Code, der die Portnummer direkt aus dieser Konfigurationsdatei liest. Aber als sie den Code weitertransportieren will, schlägt die Test-Stage für den Stack fehl, weil die Konfigurationsdatei aus dem anderen Projekt für den Build-Agenten nicht zur Verfügung steht.
Dieser Fehlschlag ist eine gute Sache. Er macht die Kopplung zwischen den beiden Projekten sichtbar. Das Teammitglied kann den Code so ändern, dass ein Parameterwert für die zu öffnende Portnummer zum Einsatz kommt und der Wert später gesetzt wird (mithilfe eines der in Kapitel 7 beschriebenen Patterns). Der Code wird dadurch wartbarer sein, als wenn man direkte Referenzen auf Dateien in anderen Projekten nutzt.
Die Delivery-Time Integration ist nützlich, wenn Sie klare Grenzen zwischen den Projekten in einer Codebasis brauchen, aber trotzdem Projektversionen gemeinsam testen und ausliefern wollen. Das Pattern lässt sich allerdings nur schlecht für eine große Zahl von Projekten umsetzen.
Bei der Delivery-Time Integration steckt die Komplexität des Auflösens und Koordinierens der verschiedenen Versionen der unterschiedlichen Projekte im Auslieferungsprozess. Dafür ist eine ausgefeilte Auslieferungs-Implementierung erforderlich, wie zum Beispiel eine Pipeline (siehe »Software und Services für die Delivery-Pipeline« auf Seite 156).
Auslieferungs-Pipelines integrieren mehrere Projekte mithilfe des »Fan-In«-Designs (https://oreil.ly/ShOFm). Die Stage, die die verschiedenen Projekte zusammenbringt, wird als Fan-In-Stage oder Projektintegrations-Stage bezeichnet.1
Es hängt von der Art der zu kombinierenden Projekte ab, wie die Stage sie integriert. Im Beispiel des Stack-Projekts, das ein Server-Image verwendet, würde der Stack-Code angewendet werden und eine Referenz auf die relevante Version des Image erhalten. Infrastruktur-Abhängigkeiten werden aus dem Code-Delivery-Repository gelesen (siehe »Infrastruktur-Code mit einem Repository ausliefern« auf Seite 365).
Der gleiche Satz an kombinierten Projektversionen muss in den späteren Stages des Auslieferungsprozesses angewendet werden. Dafür gibt es zwei verbreitete Vorgehensweisen.
Die eine ist, den gesamten Projektcode für den späteren Einsatz in einem einzelnen Artefakt zu bündeln. Werden beispielsweise zwei verschiedene Stack-Projekte integriert und getestet, könnte die Integrations-Stage den Code beider Projekte in einer einzelnen ZIP-Datei zusammenfassen und diese an die folgenden Pipeline-Stages weitergeben. Ein GitOps-Flow würde die Projekte in den Integration-Stage-Branch und von dort aus in die weiteren Branches mergen.
Ein anderer Ansatz ist das Erstellen einer Descriptor-Datei mit den Versionsnummern für jedes Projekt. Zum Beispiel:
descriptor-version: 1.9.1
stack-project:
name: application-infrastructure-stack
version: 1.2.34
server-image-project:
name: application-server-image
version: 1.1.1
Der Auslieferungsprozess behandelt die Descriptor-Datei als Artefakt. Jede Stage, die den Infrastruktur-Code anwendet, holt sich das jeweilige Projekt-Artefakt aus dem Auslieferungs-Repository.
Ein dritter Ansatz wäre das Taggen der relevanten Ressourcen mit einer aggregierten Versionsnummer.
Das Build-Time-Project-Integration-Pattern (siehe »Pattern: Build-Time Project Integration« auf Seite 369) integriert die Projekte gleich zu Beginn und nicht erst, nachdem manche Auslieferungsaktivitäten in den einzelnen Projekten ausgeführt wurden. Das Apply-Time-Project-Integration-Pattern integriert die Projekte in jeder Auslieferungs-Stage, die sie gemeinsam nutzt, setzt aber nicht die Versionen fest.
Auch bekannt als: Decoupled Delivery oder Decoupled Pipelines.
Bei der Apply-Time Project Integration werden mehrere Projekte getrennt voneinander durch die Auslieferungs-Stages befördert. Ändert jemand den Code eines Projekts, wendet die Pipeline den aktualisierten Code auf jede Umgebung im Auslieferungspfad für dieses Projekt an. Diese Version des Projektcodes integriert eventuell mit verschiedenen Versionen von Upstream- oder Downstream-Projekten in jeder dieser Umgebungen.
Im ShopSpinner-Beispiel hängt das Projekt application-infrastructure-stack von Networking-Strukturen ab, die vom Projekt shared-network-stack erstellt wurden. Jedes Projekt besitzt seine eigenen Auslieferungs-Stages, wie in Abbildung 19-6 zu sehen ist.
Abbildung 19-6: Beispiel für das Integrieren von Projekten beim Anwenden
Die Integration zwischen den Projekten findet beim Anwenden des Codes appli cation-infrastructure-stack auf eine Umgebung statt. Diese Operation erzeugt oder verändert ein Server-Cluster, das Netzwerkstrukturen (beispielsweise Subnetze) aus dem gemeinsam genutzten Networking-Code verwendet.
Diese Integration geschieht unabhängig davon, welche Version des gemeinsamen Networking-Stacks in einer gegebenen Umgebung zum Einsatz kommt. Daher geschieht das Integrieren von Versionen jedes Mal neu, wenn der Code angewendet wird.
Das Integrieren von Projekten beim Anwenden minimiert die Kopplung zwischen ihnen. Verschiedene Teams können Änderungen an ihren Systemen in die Produktivumgebung transportieren, ohne sich mit den anderen koordinieren zu müssen und ohne durch Probleme an einer Änderung in einem Projekt eines anderen Teams blockiert zu werden.
Dieser Umfang an Entkopplung passt zu Organisationen mit einer autonomen Teamstruktur. Es hilft auch bei umfangreicheren Systemen, bei denen das Koordinieren von Releases und ihre Auslieferung unter Beteiligung Hunderter oder Tausender Entwicklerinnen und Entwickler impraktikabel ist.
Dieses Pattern verschiebt das Risiko, Abhängigkeiten zwischen Projekten nicht funktionieren zu lassen, auf das Anwenden der Änderungen. Es stellt keine Konsistenz in der Pipeline sicher. Schiebt jemand eine Änderung an einem Projekt schneller durch die Pipeline als andere Änderungen, werden diese in der Produktivumgebung mit einer anderen Version integriert als in den Testumgebungen.
Schnittstellen zwischen Projekten müssen sorgfältig gemanagt werden, um die Kompatibilität zwischen unterschiedlichen Versionen auf beiden Seiten einer Abhängigkeit zu maximieren. Daher erfordert dieses Pattern mehr Komplexität beim Designen, Warten und Testen von Abhängigkeiten und Schnittstellen.
In mancher Hinsicht ist das Designen und Implementieren entkoppelter Builds und Pipelines mithilfe der Apply-Time Integration einfacher als die alternativen Patterns. Jede Pipeline baut, testet und liefert ein einzelnes Projekt.
In Kapitel 17 werden Strategien zum Integrieren mehrere Infrastruktur-Stacks vorgestellt. Wendet beispielsweise eine Stage den application-infrastructure-stack an, muss sie sich auf Networking-Strukturen beziehen, die durch den shared-network-stack erzeugt wurden. In jenem Kapitel werden Techniken zum gemeinsamen Nutzen von Kennungen über Infrastruktur-Stacks hinaus erläutert.
Man kann nicht mit völliger Sicherheit sagen, welche Version des Codes eines anderen Projekts in einer Umgebung zum Einsatz kommt. Daher müssen Teams Abhängigkeiten zwischen Projekten klar erkennen und sie als Verträge behandeln. Der shared-network-stack stellt Kennungen für die Networking-Strukturen bereit, die andere Projekte einsetzen können. Er muss diese auf eine standardisierte Art und Weise anbieten und sollte dabei eines der in Kapitel 17 beschriebenen Patterns nutzen.
Wie in »Test-Fixtures für den Umgang mit Abhängigkeiten verwenden« auf Seite 172 beschrieben können Sie Test-Fixtures verwenden, um jeden Stack isoliert zu testen. Bei ShopSpinner würde das Team das Projekt application-infrastructure-stack testen wollen, ohne eine Instanz von shared-network-stack nutzen zu müssen. Der Netzwerk-Stack definiert redundante und komplexe Infrastruktur, die für den Testfall nicht notwendig ist. Daher kann das Team für den Test eine abgespeckte Networking-Version aufsetzen. Damit verringert es auch das Risiko, dass sich der Anwendungs-Stack auf Details der Implementierung des Netzwerk-Stacks verlässt.
Ein Team, das für ein Projekt zuständig ist, von dem andere Projekte abhängen, kann Vertragstests implementieren, die zeigen, dass ihr Code die Erwartungen erfüllt. Der shared-network-stack kann prüfen, ob die Networking-Strukturen – hier die Subnetze – erstellt werden und ob ihre Kennungen über die Mechanismen bereitgestellt werden, die andere Projekte zum Konsumieren nutzen.
Stellen Sie sicher, dass Vertragstests klar gekennzeichnet sind. Nimmt jemand eine Änderung am Code vor, die dafür sorgt, dass der Test fehlschlägt, sollte ihm klar sein, dass er eventuell andere Projekte beeinträchtigt und nicht nur den Test anzupassen hat, damit dieser die Änderung widerspiegelt.
Viele Organisationen finden das Consumer-driven Contract (CDC) Testing (https://oreil.ly/cgPTj) nützlich. Bei diesem Modell schreiben Teams, die an einem Consumer-Projekt arbeiten, das von Ressourcen eines Provider-Projekts abhängt, Tests, die in der Pipeline des Provider-Projekts laufen. Das hilft dem Provider-Team, die Erwartungen der Consumer-Teams zu verstehen.
Das Build-Time-Project-Integration-Pattern (siehe »Pattern: Build-Time Project Integration« auf Seite 369) liegt am entgegengesetzten Ende des Spektrums. Dieses Pattern integriert Projekte einmal zu Beginn des Auslieferungszyklus, statt sie jedes Mal zusammenzufügen. Die Delivery-Time Project Integration (siehe »Pattern: Delivery-Time Project Integration« auf Seite 373) integriert Projekte auch nur einmal, aber zu einem späteren Zeitpunkt im Auslieferungszyklus.
In »Eine Server-Instanz ausbacken« auf Seite 224 wird die Apply-Time Integration für das Provisionieren von Servern eingesetzt. Abhängigkeiten wie Serverkonfigurationsmodule werden jedes Mal neu angewendet, wenn eine neue Server-Instanz erstellt wird, wobei meist auf die letzte Version des Server-Moduls zurückgegriffen wird, die es bis zur entsprechenden Stage geschafft hat.
Die meisten Teams, die mit Infrastruktur-Code zu tun haben, erstellen eigene Skripte, um ihre Infrastruktur-Tools zu orchestrieren und auszuführen. Manche nutzen Tools zum Bauen von Software, wie zum Beispiel Make, Rake oder Gradle. Andere schreiben Skripte in Bash, Python oder PowerShell. In vielen Fällen wird dieser zusätzliche Code irgendwann mindestens genauso kompliziert wie der Code, der die Infrastruktur definiert, wodurch die Teams einen Großteil ihrer Zeit mit dem Debuggen und Warten dieses Zusatzcodes verbringen.
Die Teams können diese Skripte beim Bauen, beim Ausliefern oder beim Anwenden ausführen. Häufig kümmert sich der Code um mehr als eine dieser Projektphasen. Die Skripte erledigen eine Vielzahl von Aufgaben, unter anderem:
Konfiguration
Konfigurationsparameter-Werte einsammeln und dabei möglicherweise eine Wertehierarchie auflösen. Mehr dazu finden Sie weiter unten.
Abhängigkeiten
Bibliotheken, Provider und anderen Code auflösen und zusammenführen.
Code zum Ausliefern vorbereiten – sei es als Verpacken in ein Artefakt oder durch Erstellen oder Mergen eines Branches.
Transportieren
Code von einer Stage in die nächste befördern –durch Tagging, Verschieben eines Artefakts oder Erstellen und Mergen eines Branches.
Orchestrieren
Verschiedene Stacks und andere Infrastruktur-Elemente anhand der Abhängigkeiten in der richtigen Reihenfolge anwenden.
Ausführen
Die relevanten Infrastruktur-Tools ausführen, Befehlszeilen-Argumente und Konfigurationsdateien für die Instanz zusammenführen, für die der Code angewendet wird.
Testen
Tests vorbereiten und ausführen – zum Beispiel Test-Fixtures und Testdaten provisionieren und Ergebnisse zusammenführen und bereitstellen.
Das Zusammenführen und Auflösen von Konfigurationswerten kann eine der komplexeren Aufgaben für ein Wrapper-Skript sein. Stellen Sie sich ein System wie das ShopSpinner-Beispiel vor, zu dem eine Reihe von Auslieferungsumgebungen, mehrere produktive Kunden-Instanzen und viele Infrastruktur-Komponenten gehören.
Für einen einfachen Satz von Konfigurationswerten auf nur einer Ebene, mit nur einer Datei pro Kombination aus Komponente, Umgebung und Kunde, sind eine ganze Menge Dateien erforderlich. Und viele der Werte würden mehrfach vorkommen.
Nehmen wir einen Wert store_name für jeden Kunden, der für jede Instanz jeder Komponente gesetzt werden muss. Das Team entscheidet schnell, diesen Wert an einer Stelle für eine gemeinsame Verwendung zu setzen und das Wrapper-Skript um Code zu ergänzen, der Werte aus der gemeinsam genutzten Konfiguration und der Konfiguration pro Komponente liest.
Sobald das Team erkennt, dass gemeinsam genutzte Werte in allen Instanzen einer gegebenen Umgebung benötigt werden, erstellt es einen dritten Konfigurationssatz. Hat ein Konfigurationselement in den verschiedenen Konfigurationsdateien einen unterschiedlichen Wert, muss das Skript diesen anhand einer vorgegebenen Hierarchie auflösen.
Diese Art von Parameterhierarchie lässt sich nicht schön programmieren. Sie ist für die Teammitglieder schwieriger zu verstehen, wenn neue Parameter eingeführt werden, die richtigen Werte zu konfigurieren sind oder wenn die Werte in einer bestimmten Instanz ermittelt und debuggt werden sollen.
Eine Konfigurations-Registry bringt die Komplexität in eine andere Richtung. Statt die Parameterwerte aus einem Satz Dateien zu ermitteln, durchsuchen Sie die diversen Unterbäume der Registry. Ihr Wrapper-Skript kann die Werte aus den unterschiedlichen Teilen der Registry auflösen – so wie bei den Konfigurationsdateien. Oder Sie verwenden ein Skript, um im Vorhinein Registry-Werte zu setzen, sodass dieses für das Auflösen einer Hierarchie mit Standardwerten zuständig ist und den korrekten Wert für jede Instanz setzt. Jedes Vorgehen führt aber zu Kopfschmerzen, wenn man herausfinden will, woher ein bestimmter Parameterwert stammt.
Ich habe schon erlebt, dass Teams mehr Zeit mit Fehlern in ihren Wrapper-Skripten als mit dem Verbessern ihres Infrastruktur-Codes verbracht haben. Solch eine Situation entsteht, wenn man Aspekte vermischt und verbindet, die getrennt behandelt werden könnten und sollten. Dazu gehören:
Projektlebenszyklus aufteilen
Ein einzelnes Skript sollte nicht alle Aufgaben in den Bau-, Transport- und Anwendungsphasen eines Projekts abdecken. Schreiben und verwenden Sie verschiedene Skripte für jede dieser Aktivitäten. Stellen Sie sicher, dass Sie ein klares Verständnis davon haben, wo Informationen von einer zur nächsten Phase übergeben werden müssen. Implementieren Sie klare Grenzen zwischen diesen Phasen – so wie das auch bei APIs oder Verträgen der Fall ist.
Aufgaben trennen
Teilen Sie die verschiedenen Aufgaben beim Verwalten Ihre Infrastruktur auf, wie zum Beispiel das Zusammentragen der Konfigurationswerte, das Verpacken von Code oder das Ausführen von Infrastruktur-Tools. Definieren Sie auch hier die Integrationspunkte zwischen diesen Aufgaben und halten Sie sie lose gekoppelt.
Projekte entkoppeln
Ein Skript, das Aktionen für mehrere Projekte orchestriert, sollte nicht das gleiche sein, das Aufgaben in diesen Projekten ausführt. Und es sollte möglich sein, Aufgaben für jedes Projekt einzeln ausführen zu können.
Wrapper-Code ignorant halten
Ihre Skripte sollten nichts über die Projekte wissen, die sie unterstützen. Vermeiden Sie es, Aktionen fest zu verdrahten, die davon abhängen, was der Infrastruktur-Code in Ihren Wrapper-Skripten tut. Ideale Wrapper-Skripte sind generisch und lassen sich für alle Infrastruktur-Projekte eines bestimmten Typs einsetzen (zum Beispiel für alle Projekte, die ein bestimmtes Stack-Tool verwenden).
Behandeln Sie Wrapper-Skripte wie »richtigen« Code, um all diese Aspekte besser berücksichtigen zu können. Testen und validieren Sie Skripte mit Tools wie shellcheck (https://oreil.ly/TTriI). Wenden Sie die Regeln guten Softwaredesigns auf Ihre Skripte an, wie zum Beispiel die Regel der Komposition, das Single-Responsibility-Prinzip oder das Designen entlang von Domänenkonzepten. In Kapitel 15 finden Sie mehr Informationen dazu, aber schauen Sie dafür auch in andere Quellen.
Es ist für das Erreichen einer guten Performance gegenüber den vier zentralen Metriken (siehe »Die Four Key Metrics« auf Seite 39) unerlässlich, einen soliden, zuverlässigen Prozess für das Ausliefern von Infrastruktur zu schaffen. Ihr Auslieferungssystem ist die Grundlage für ein schnelles und zuverlässiges Ausliefern von Änderungen an Ihrem System.