Weshalb Infrastruktur-Code nicht nur Konfiguration ist
Längst wird das Konzept, Infrastruktur und Konfigurationen textuell in Form von Code zu beschreiben und zu versionieren, großflächig in Projekten eingesetzt. Auch deshalb entstand ein Ökosystem an Tools und Frameworks, die die Möglichkeit bieten, mittels einer DSL oder gar Programmiersprachen wie Java, Python oder TypeScript Infrastruktur zu programmieren. Durch Infrastruktur-Code ergeben sich viele Vorteile wie Wiederverwendbarkeit einzelner Komponenten, verbesserte Konsistenz, Erhöhung des Automatisierungsgrades im Projekt, sowie Transparenz gegenüber dem reinen Konfigurieren per Oberfläche.
Von diesen Vorteilen kann jedoch nur unter bestimmten Voraussetzungen dauerhaft profitiert werden. Eine Schlüsselrolle hierfür spielen vor allem Prinzipien und Fundamente aus der Softwareentwicklung. Infrastruktur-Code ist im Gegensatz zu den meisten Programmiersprachen rein deskriptiv. Man beschreibt den Aufbau des gewünschten Zielsystems textuell. Tools wie Terraform oder AWS Cloudformation lehnen ihre DSL (Hashicorp Configuration Language – HCL) an YAML an, ein Format, das für Konfigurationsdateien weit verbreitet ist. Das lässt Infrastruktur-Code schnell als eine reine Sammlung verschiedener Konfigurationsdateien aussehen, deren Verhalten bei Ausführung stets deterministisch sind.
Tatsächlich ist Infrastruktur-Code aber durch eine Reihe an Funktionserweiterungen des rein deskriptiven Ansatzes variabel. Bei der großen Mehrheit der Tools gibt es die Möglichkeit, Schleifenkonstrukte, Interpolationen, Funktionen, Bedingungen und (komplexe) Datenstrukturen zu definieren und zu nutzen. Das führt dazu, dass der Code sich abhängig vom Input verhält, also für das System relevante Geschäftslogik beinhaltet. Geschäftslogik, die bei Änderungen getestet werden muss, um die Korrektheit des Codes zu beweisen und unerwünschte Fehler zu vermeiden. Die Möglichkeit, durch Infrastruktur-Code Änderungen automatisiert in Produktion zu bringen, birgt genauso viele aus der Softwareentwicklung bekannte Fallstricke, die man durch automatisierte Tests und Qualitätsprüfungen minimieren kann.
Infrastruktur-Code testen
Software entwickelt sich stets weiter. Es kommen neue Features dazu, Fehler werden gefixt und Abhängigkeiten gepatcht. Das führt in der Regel dazu, dass sich auch die Anforderungen an die Umgebung und die Infrastruktur unserer Software fortwährend ändert. Daraus resultiert auch, dass von Zeit zu Zeit Änderungen an der Infrastruktur notwendig sind, um unser System weiter sicher, stabil, performant und kosteneffizient betreiben zu können. Die Stabilität und Qualität von Infrastruktur-Systemen kann nur durch permanente Änderungen an solcher, zum Beispiel in Form von Updates/ Resizing gewährleistet werden. Allerdings sind Änderungen stets fehleranfällig. Betrachten wir Infrastruktur-Komponenten von kritischen Systemen, so müssen Wartungs- und Änderungsarbeiten sorgfältig geplant, idealerweise getestet, kommuniziert und durchgeführt werden.
Im Kontext der Softwareentwicklung gibt es seit langem bewährte Test-Mechanismen und Prozesse, deren Ziel es ist, den kritischen Weg in die Produktion so sicher und fehlerfrei wie möglich zu gestalten. Durch häufige, kurze Feedbackschleifen werden etwaige Fehler umgehend während der Entwicklung gemeldet, bevor diese in die Produktion gelangen, und können so behoben werden. Der Hauptfokus bei Software liegt in der Korrektheit der Funktionalität. Dies spiegelt sich auch in den Tests wider: Um möglichst schnell Fehler zu finden, werden zunächst die Kernfunktionen isoliert in Unit-Tests getestet, um anschließend dann weitere Tests auf Integrationsebene durchzuführen.
Bei Infrastruktur-Code gestalten sich derartige Vorgehensweisen etwas anders und auch der Fokus von vielen Unit-Tests zu Integrationstests.
Wir beschreiben unser Zielsystem mit Code und diese Beschreibung wird dann mit dem Ist-Zustand abgeglichen. Weichen diese voneinander ab, müssen Aktionen ausgeführt werden, welche die Integrität wieder herstellen.
Beim Programmieren von Infrastruktur-Code werden verschiedene Ressourcen, Services und Komponenten miteinander verknüpft. Der Fokus liegt also primär auf der Integrationsebene, da je nach Konfigurations-Parametern und Abhängigkeiten das Gesamtsystem funktioniert oder nicht. Besteht das Beispielsystem aus einer App, welche Daten aus einer Datenbank liest und diese bereitstellt, so hilft es wenig, die Komponenten einzeln zu testen. Unit-Tests helfen zwar, Fehlkonfigurationen der Komponente oder Verstöße gegen die Compliance (zum Beispiel: eine Datenbank darf nicht in einem öffentlichen Netz stehen) zu finden. Das System funktioniert aber nur, wenn auch weitere Aspekte wie Firewalls, Netzwerke etc. korrekt konfiguriert sind. Wirklich aussagekräftige Ergebnisse über das System können also erst nach den Integrationstests getroffen werden.
Da Infrastruktur-Code deklarativer Natur ist, macht es oft keinen Sinn, ihn nur in Isolation zu testen. Dennoch gibt es eine Reihe an sinnvollen Tests, die auf Unit-Ebene durchgeführt werden sollten. Tools wie Terraform, CDK, Pulumi oder Cloudfront bieten Möglichkeiten der Interpolation, Schleifenkonstrukten und Schalter, welche den deklarativen Code dynamisch und dadurch weniger vorhersagbar machen. Das kann beispielsweise dazu führen, dass bestimmte Ressourcen, Einstellungen oder Komponenten abhängig von Variablen provisioniert werden oder nicht. Ein typischer Fall ist, wenn z. B. eine Web-Applikation-Firewall nur in der Produktion deployt und an den Loadbalancer gehängt werden soll. Durch einen umgebungsspezifischen Schalter wird hier also nicht nur zusätzliche Infrastruktur gebaut, sondern hat auch signifikanten Einfluss auf das Verhalten des Datenflusses. Gerade um unerwünschte Seiteneffekte bei Änderungen zu verhindern, ergibt es durchaus Sinn, Validierungen und Tests einzuführen, die solche dynamischen Deklarationen testen. Aber auch die semantische Bedeutung von Infrastruktur-Konfiguration kann von essenzieller Wichtigkeit sein und sollte durch Tests abgesichert sein. Benötigt ein System zwei separate Objektspeicher, so werden diese als Code zu großen Teilen identisch sein.
Listing 1: Flüchtiger Objektspeicher
resource "aws_s3_bucket" "bucket" {
bucket = local.name
acl = "private"
lifecycle {
prevent_destroy = false
}
}
Listing 2: Archiv Objektspeicher
resource "aws_s3_bucket" "bucket" {
bucket = local.name
acl = "private"
lifecycle {
prevent_destroy = true
}
}
Wird der eine Objektspeicher lediglich als flüchtiger Speicher zur Zwischenspeicherung von Daten genutzt und der andere aber als Langzeitarchiv, so sind die Anforderungen an die Sicherheit und Verfügbarkeit immens unterschiedlich. In so einem Fall würde es durchaus Sinn ergeben, den Archivspeicher so zu konfigurieren, dass er nicht unbeabsichtigt gelöscht werden kann. Mit einem Test kann gewährleistet werden, dass im Archiv entsprechende Konfigurations-Parameter gesetzt werden.
Ein weiterer Anwendungsfall, in dem Unit-Tests sinnvoll sind, sind Komponenten, die zur Infrastruktur gehören, aber eigene Erweiterungen der Zielplattform darstellen. Das könnte zum Beispiel eine Funktion sein, die auf bestimmte Events oder Ereignisse reagiert und weitere Prozesse, z. B. einen Alert in das firmenweite Messenger-System triggert. Es handelt sich hier um kleine Softwarekomponenten innerhalb der Infrastruktur-Definition, deren korrekten Funktionsweise essenziell für das Gesamtsystem ist und deren Funktionalität sich einfach mit Tests sicherstellen lässt.
Zur Integration von Systemen gehört es auch, Firewallregeln und ACLs zu definieren, damit Systeme über das Netzwerk miteinander kommunizieren können. Fehler oder zu offene Einstellungen können leicht zum Einfallstor für Angreifer werden und stellen ein hohes Risiko im System dar. Für viele Infrastruktur-Code-Tools gibt es Linter, die den Code analysieren und gegen Richtlinien prüfen können. Auf diese Weise lassen sich Compliance und Sicherheitsvorschriften automatisiert und schnell auf der Codebasis überprüfen. Mit Hilfe von Unit-Tests und Linting erhält man schnell und automatisiert ein erstes Feedback über Auswirkungen einer Änderung.
Im nächsten Schritt gilt es, die Infrastruktur und die Codebasis integrativ zu testen, hier sollte auch der Hauptfokus liegen, denn erst, wenn die Komponenten unserer definierten Infrastruktur auch im Zusammenspiel wie gewünscht funktioniert, kann die Anwendung darauf laufen. Der Webserver muss beispielsweise in der Lage sein, mit der Datenbank zu kommunizieren (vgl. Abb. 2). Dies lässt sich nur testen, indem wir die Infrastruktur irgendwo hin deployen. Hier gibt es verschiedene Möglichkeiten, zum einen bieten sich Test/Stage-Systeme an, die ein Abbild der Produktion darstellen. Wenden wir gegen ein solches System den Infrastruktur-Code an, lässt sich schnell feststellen, welche Änderungen vollzogen werden und in welcher Weise – also ob eine Downtime erforderlich ist oder nicht. Der Vorteil von Tests gegen existierende Umgebungen ist, dass solche Tests recht schnell durchgeführt werden können, da sich in der Regel nicht die gesamte Infrastruktur ändert, sondern lediglich ein Teil davon. Ein Nachteil ist, dass man nicht sicher sagen kann, wie sich Änderungen auf das Bootstrapping von Umgebungen auswirkt, also dem Neuaufsetzen von Grund auf. Ein weiterer Nachteil liegt bei den Kosten durch den permanenten Betrieb der Test/Stage-Systeme, aber dieser relativiert sich, wenn solche Systeme auch von den Entwicklungsteams mitgenutzt werden.
Es kommt bei der Wahl einer geeigneten Architektur stark auf den Kontext des Projektes an.
Es kann durchaus gewünscht sein, Systeme von Grund auf zu testen, etwa um Metriken wie Mean-Time-To-Restore permanent zu erheben oder die Disaster Recovery zu testen. Dabei werden alle Infrastruktur-Komponenten auf leeren Umgebungen neu erstellt und nach dem Test wieder entfernt. Tools wie Terratest für Terraform bieten solche Funktionalität und sorgen am Ende des Tests wieder dafür, dass die Ressourcen gelöscht werden. Durch das gesamte Bootstrapping des Systems ergeben sich deutlich längere Testdurchlaufzeiten im Vergleich zum inkrementellen Testen. Zusätzlich sollte man einen Mechanismus bzw. Prozess bereitstellen, der dafür sorgt, verwaiste Testinfrastruktur periodisch zu entfernen, um Kosten zu sparen. Zum ganzheitlichen Test des Systems können zusätzlich E2E- und Smoke-Tests unterstützen. Ziel ist es, das Zusammenspiel von Infrastruktur und Applikation unter möglichst realen Bedingungen zu testen.
Architektur für Infrastruktur-Code
In Softwareprojekten spielt die Architektur eine zentrale Rolle. Auch bei der Konzeption von Infrastruktur-Code-Komponenten spielt die bewusst oder unbewusst gewählte Architektur eine tragende Rolle in Bezug auf Wartbarkeit, Qualität, Nutzbarkeit und Erweiterbarkeit. Da sich auch die Anforderungen an die Infrastruktur mit der Zeit ändern, ist es wichtig, den Code in einem Zustand zu halten, der es leicht macht, Änderungen zu vollziehen. Eine gewachsene Codebasis, die unübersichtlich und groß ist, kann im schlimmsten Fall dazu führen, dass einige Vorteile, die man durch das Anwenden von Infrastruktur-Code gewinnt – wie das einfache Bootstrapping von Umgebungen oder Wiederverwendung einzelner Komponenten – tatsächlich nicht genutzt werden können. Es kommt bei der Wahl einer geeigneten Architektur stark auf den Kontext des Projektes an: So ist es bei einem größeren Projekt, an dem mehrere Teams arbeiten, sinnvoll, eine verteilte Architektur einer monolithischen vorzuziehen. Dabei gibt es bei der Verteilung verschiedene Aspekte, die relevant für eine gute Aufteilung sind. So liegt der Fokus auf hoher Kohäsion und loser Kopplung, aber auch den Verantwortungsbereichen der involvierten Teams.
In einer monolithischen Infrastruktur-Code-Basis kann das Gesamtsystem verwaltet werden, dazu zählt auch Basis-Infrastruktur wie etwa Netzwerksegmente, ACLs und Firewallregeln.
Der Vorteil hier liegt vor allem darin, dass sämtliche Ressourcen eines Systems an einer Stelle gepflegt und verwaltet werden. Dies erfordert allerdings in der Wartung und Entwicklung eine hohe Kompetenz und Wissen über ein breites Aufgabenfeld des verantwortlichen Teams. Es muss sowohl tiefes Wissen über die Anwendung selber, aber auch über die Gesamtinfrastruktur, in die diese eingebettet ist, vorhanden sein. Das kann schnell zur Gefahr werden und zu einer kognitiven Überlastung des Teams führen. Gerade bei größeren Projekten kann eine verteilte Architektur sinnvoll sein. Viele Tools für Infrastruktur-Code ermöglichen eine Trennung auf Artefaktebene: Das bedeutet insbesondere, dass Querschnittsthemen oder Ressourcen, die nicht zwingend direkt zur Anwendung gehören, wie zum Beispiel das Definieren von Netzwerksegmenten, als wiederverwendbare Artefakte bereitgestellt werden können. Der Vorteil hier ist eine einfache Abstraktion, die einen Großteil der Komplexität in der Verwendung verschleiert, außerdem können so einfach Compliance-Regeln und Strukturen vorgegeben werden.
Betrachtet man die Testbarkeit, so wandert die Verantwortung hierfür in das Team, das die Module bereitstellt. Aus Sicht des Verwenders sind Module Blackboxen. Gutes Design solcher Module ist wichtig, um die Akzeptanz zu fördern. Ein Modul dessen Verwendung annähernd soviel Aufwand erzeugt wie eine eigenständige Implementierung wird tendenziell nicht genutzt. Ein Modul ist ein Produkt, dessen Ziel es ist, dem Anwender Arbeit abzunehmen.
Die Verteilung von Infrastruktur-Code kann auch entlang von Teamverantwortlichkeiten erfolgen. Ein Operationsteam kann so eine komplette Umgebung bereitstellen, in der bereits Netzwerk und andere Basis-Infrastruktur fertig konfiguriert sind. Das Entwicklerteam der Anwendung kann sich dann auf alles Applikationsnahe konzentrieren, wie zum Beispiel ein API-Gateway. Der anwendungsnahe Infrastruktur-Code hat unter Umständen eine direkte Abhängigkeit zur Basis-Infrastruktur wie zum Beispiel Subnetz-Definitionen. Um diese Abhängigkeit zu minimieren, kann zum Beispiel ein Key/Value Store als Schnittstelle eingesetzt werden, der wichtige Inputparameter zur Verfügung stellt. Das bringt auch den Vorteil, dass man freier in der Toolwahl ist. Definiert ein Operationsteam beispielsweise Terraform als das Werkzeug seiner Wahl für Basis-Infrastruktur, so kann der Teil der applikationnahen Infrastruktur losgelöst von Terraform mit einem anderen Framework entwickelt werden. Inputparameter werden dann zur Laufzeit aus dem Key/Value Store gelesen. Dies minimiert auch die Kommunikation zwischen den verschiedenen Teams bei Änderungen.
Obwohl die Aufteilung und Wiederverwendung von Infrastruktur-Code viele Vorteile mit sich bringt, braucht es wie bei Softwareprodukten definierte Versionierungsregeln, kommunizierte und transparente Prozesse zu Patches und Upgrade-Pfaden. Module bzw. Schnittstellen zwischen den einzelnen Infrastruktur-Plänen sind idealerweise als unveränderlich zu behandeln, um unvorhergesehene Seiteneffekte zu minimieren. Es empfiehlt sich, Deprecations transparent zu kommunizieren und einen End-of-Life-Prozess zu definieren, um zu verhindern, mehrschichtige Rückwärtskompatibilitässchichten einbauen zu müssen.
Verteilte Architekturen bringen nicht nur bei Projekten mit mehreren involvierten Teams Vorteile, es lohnt sich auch innerhalb eines Teams, Systemkomponenten zu separieren, um die Code-Basis übersichtlich zu halten und die Laufzeit der Codeausführung klein zu halten. Im Kontext von Deployment oder Disaster Recovery sind kurze Laufzeiten von enormem Vorteil. Das Ausführen einer komplexen, monolithischen Infrastruktur-Basis kann schnell bis zu mehreren Minuten andauern, da die Überprüfung des Ist- und des Sollzustandes immer auch Ressourcen berücksichtigt, die mit einer Änderung nichts zu tun haben. Einige Tools bieten hier die Lösung, nur Teile des Codes auszuführen, um damit zielgerichtet Änderungen vorzunehmen. Dies birgt jedoch die Gefahr, dass Abhängigkeiten nicht richtig erkannt werden und dadurch Änderungen nicht an allen notwendigen Ressourcen vollzogen werden. Eine vollständige Ausführung bietet auch Aufschluss darüber, ob das tatsächliche System noch mit dem übereinstimmt, was im Code definiert ist, deckt also etwaige Konfigurationsdrifts auf.
Fazit
Infrastruktur-Code ist mehr als die textuelle Beschreibung von Ressourcen eines Systems, der Code ist essenzieller Bestandteil der Software und sollte auch als solcher wahrgenommen werden. Ein System wird nicht einmal aufgebaut und bleibt dann unverändert, es müssen permanent Anpassungen gemacht werden, die zum Teil mit dem Produktlebenszyklus und Anforderungen der Software einhergehen. Erst durch automatisierte Tests und eine gut überlegte Architektur lassen sich die Vorteile durch Infrastruktur-Code erst richtig und vor allem nachhaltig nutzen. Besonders deutlich wird das im volatilen Cloud-Umfeld, wo keine Hardware gekauft, sondern über Dienste gemietet wird. Das Hardware Sizing erfolgt in der Regel auch kostenoptimiert, d. h. bei Bedarf ändert man seine Spezifikationen kurzfristig, Überprovisionierungen für mittelfristige Bedarfe spielen eher keine Rolle mehr und bei größeren OS-Updates können ganze Maschinen ausgetauscht werden. Damit häufigere Änderung an der Infrastruktur automatisiert, schnell und vor allem sicher ausgerollt werden können, braucht es möglichst kurze Durchlaufzeiten und eine gute Qualitätssicherung, zum Beispiel in Form automatisierter Tests. Das Ökosystem rund um das Testen von Infrastruktur-Code ist noch recht überschaubar, wächst aber stark. Auch ein Blick in den Thoughtworks Technology Radar lohnt sich dahingehend [1]. Aktuell im Fokus liegen die Tools Conftest oder Regular, die Unterstützung für verschiedene Infrastruktur-Code-Tools bieten. Weitere Ausführungen dieses Themas sowie Patterns und Anti-Patterns finden sich in Kief Morris Buch "Infrastructure as Code - Dynamic Systems in the Cloud Age" [2].