Modularisierte Infrastructure-as-Code in Terraform

Viele Admins wählen Terraform zum Verwalten von Infrastruktur. Seine Flexibilität, Logikbausteine und Modularisierung helfen aber auch Developern.

In Pocket speichern vorlesen Druckansicht
Modularisierte Infrastructure-as-Code in TerraformViele Admins wählen Terraform zum Verwalten von Infrastruktur. Seine Flexibilität, Logikbausteine und Modularisierung helfen aber auch Developern.

(Bild: Erzeugt mit Midjourney durch heise medienwerk)

Lesezeit: 15 Min.
Von
  • Steffen Hupp
  • Christoph Müller
Inhaltsverzeichnis

Cloud-native Anwendungen laufen typischerweise in Kubernetes-Umgebungen, die über YAML-Dateien konfiguriert werden. Da die manuelle Kubernetes-Verwaltung basierend auf den Konfigurationsdateien mühsam ist und in vielen Duplikaten resultiert, entscheiden sich viele Admins für das Infrastructure-as-Code-Tool Terraform von HashiCorp. Mit dem Einbringen von Logikbausteinen, der Modularisierung, der Unterstützung von Variablen und der technologieunabhängigen Erweiterbarkeit ermöglicht Terraform es, verschiedene Infrastrukturkomponenten (wie Kubernetes, Datenbanken, S3 etc.) über eine gemeinsame Schnittstelle zu verwalten.

Steffen Hupp

(Bild: 

Steffen Hupp

)

Steffen Hupp ist seit 2015 als Softwareingenieur am Fraunhofer IESE tätig. Dort ist er verantwortlich für die Entwicklung und technische Konzeption von mobilen Apps, Cross-Plattform-Lösungen und (cloudbasierten) Backend-Systemen sowie Operations.

Christoph Müller

(Bild: 

Christoph Müller

)

Christoph Müller ist seit 2020 technischer Mitarbeiter am Fraunhofer IESE. Sein Fokus liegt auf der Entwicklung und Wartung von Webanwendungen sowie der Bereitstellung und dem Betrieb von Diensten in Cloud-Umgebungen.

Terraform ermöglicht die Deklaration von Komponenten über zwei unterschiedliche Syntaxen: HashiCorp Configuration Language (HCL) oder JSON, wobei die Unterscheidung anhand der Dateiendung (*.tf bzw. *.tf.json) erfolgt. JSON erleichtert das Einbinden von Terraform in automatisierte Prozesse, im Folgenden liegt der Fokus jedoch auf HCL. Zu definierende Ressourcen sind in HCL stets als Block dargestellt. Jeder Block trägt einen Typ und einen Namen. Der Typ leitet sich entweder aus dem Built-In-Wortschatz von Terraform ab oder wird über einen zusätzlich installierten Terraform-Provider bereitgestellt. Offiziell gibt es über hundert dieser Provider, und sie bilden als Plug-ins eine wichtige Schnittstelle zu Infrastruktur-Komponenten wie Kubernetes, AWS oder Azure.

Um exemplarisch ein Kubernetes-Deployment zu definieren, kommt wie in Listing 1 zu sehen der vom Kubernetes-Provider bereitgestellte Datentyp kubernetes_deployment zum Einsatz. Der Name example definiert dabei den Namen des Blocks in Terraform selbst und ist unabhängig vom Namen des Deployments in Kubernetes. Struktur und notwendige Attribute richten sich dabei nach dem jeweiligen Datentyp.

Listing 1: Syntax eines Kubernetes-Deployment in HCL:

resource "kubernetes_deployment" "example" {
  metadata {
    name      = "my-deployment"
    namespace = "example-namespace"
  }
  ...
}

Terraform erlaubt es, über Data-Blöcke Daten verschiedener Quellen einzubinden. Über APIs stellen Terraform-Provider solche Data Sources bereit, sodass Terraform Informationen über dort bereits vorhandene Ressourcen speichern kann, ohne dass diese über Terraform selbst erzeugt werden. Provisionieren Admins Komponenten über die Provider-API eines Cloud-Anbieters, so nutzen sie diese mit der bestehenden Datacenter-Umgebung auch in Terraform. Bei der nächsten Anwendung speichert Terraform diesen Block dann im Terraform-State, aber provisioniert ihn nicht in der Live-Infrastruktur (siehe Listing 2), da dieser nur nach Terraform referenziert ist.

Listing 2: Data Block für den bereitgestellten Datentyp "cloudprovider_datacenter":

data "cloudprovider_datacenter" "my_existing_datacenter" {
  name     = "my_existing_datacenter"
  location = "EU-1"
  id       = "c351ab0a-1422-4b32-b119-aff44d051afa"
}

Dabei ist es möglich, Attribute von Ressourcenblöcken in anderen Ressourcen zu verwenden. Ändert sich das Attribut im referenzierten Ressourcenblock, so aktualisiert es Terraform auch im zugreifenden Ressourcenblock. Das vermeidet händische Übertragungsfehler.

HCL erlaubt darüber hinaus die Deklaration von Variablenblöcken, die sich zur Speicherung von nicht in Ressourcen gebundenen Variablen nutzen lassen. Der Zugriff auf die Werte dieser Variablen erfolgt dann über den Identifier locals. Oft ist es notwendig, nicht nur statisch definierte Werte zu nutzen oder solche, die bei der Provisionierung abgeleitet wurden, sondern auch Werte zu manipulieren. Wie Listing 3 zeigt, bietet HCL dazu Logikelemente wie etwa String-Manipulationen an Attributen.

Listing 3: Locals-Block zur Definition von Variablen:

locals {
  another_ip = "10.0.0.5"
  my_ip      = replace(some_resource.cidr, "/24", "")
}

Terraform ermöglicht das Kapseln von Deklarationen in Module, die Entwicklerinnen und Entwickler ähnlich wie Klassen in der Programmierung einsetzen. Ein Modul selbst referenziert einen Ordner über das source-Attribut. Der Inhalt des Ordners ist grundsätzlich variabel, wobei sich die Terraform-Dateien auf einer Ebene befinden sollten. Exemplarisch könnte ein Modul zur Abstraktion eines Kubernetes-Deployments wie in Listing 4 dargestellt aufgebaut sein.

Listing 4: Beispielhafter Dateibaum eines Terraform-Moduls:

./deployment-module
├── deployment.tf
├── ingress.tf
├── outputs.tf
└── variables.tf

Die Namen der Dateien spielen dabei keine Rolle, lediglich die Dateiendung muss *.tf (oder *.tf.json) lauten. In variables.tf könnte eine Variable definiert werden, die eine Liste eines Objekts (name: string, port: number, url: string) aufnimmt. Variablen haben stets einen Datentyp, der beispielsweise string, aber auch Listen beinhalten kann. Standardwerte legen Entwickler und Entwicklerinnen über Angabe des Attributs default fest (siehe Listing 5).

Listing 5: Deklaration einer Variable mit komplexem Datentyp:

variable "port_map" {
  type = list(object({
    name = string
    port = number
    url  = string
  }))
  default     = []
}

Um das Modul zu verwenden, lässt sich das Attribut port_map setzen, das die gleichnamige Variable mit Inhalt befüllt. Wichtig beim Verwenden des Moduls ist die Angabe des source-Attributs, das auf den Ordner des Moduls referenziert. Diese Angabe steuert, welche Attributangaben (aus Variablen-Blöcken) Terraform benötigt (siehe Listing 6).

Listing 6: Wiederverwendung eines Moduls mit Variablen:

module "example_deployment" {
  source = "../deployment_module"
  name = "example_deployment"
  port_map = [
    {
      name = "http"
      port = 80
      url  = "domain.tld"
    }
  ]
}

Entwicklerinnen und Entwickler können Listen in HCL iterieren, um Ressourcen mehrfach auf Basis des Listeninhalts anzulegen. Listing 6 nutzt das Attribut port_map, um mehrere Objekte für Kubernetes (im Beispiel Ingress-Schnittstellen) anzulegen. Zusätzliche Filter erzeugen nur eine Untermenge der Elemente eines Objekts. Beim Nutzen der for_each-Direktive ist das gesamte Objekt im Schleifen-Kontext, sodass mit dem Code in Listing 7 auf das aktuelle Element über den Identifier each.value zugegriffen werden kann. Im Beispiel arbeitet die Schleife mit den Objekten mit mehreren Eigenschaften in der Liste und übernimmt nur solche, deren URL-Attribut nicht null ist. Das vermeidet Ingress-Objekte ohne valide URL.

Listing 7: Nutzung einer Schleife zur Erzeugung mehrerer Objekte:

resource "kubernetes_ingress_v1" "deployment_ingress" {
  for_each = { for port in var.port_map : port.name => port if port.url != null }
  metadata {
    name      = "${each.value.name}"
  }
  spec {
    rule {
      host = each.value.url
      [...]
    }
  }
}

Wollen Entwickler und Entwicklerinnen Informationen zu den erzeugten Ressourcen aus Terraform extrahieren, bietet das Tool den Ressourcenblocktyp output an. Outputs werden nach erfolgreichem terraform apply ausgegeben oder lassen sich via terraform output in der Standardausgabe anzeigen. Outputs haben dabei immer einen Namen und referenzieren einen Wert (value), der auch Ergebnis eines Funktionsaufrufs sein kann. Strings lassen sich auch mit ${} umfassten Platzhaltern versehen – womit im Beispiel in Listing 8 der Zugriff auf den Wert der name-Variable möglich wird.

Listing 8: Deklaration eines Outputs mit Variablenplatzhalter:

output "deployment_name" {
value = "kubernetes-${var.name}"
}

Terraform eröffnet mit einer Modularisierung von Ressourcen die Möglichkeit, unterschiedliche Ressourcen zu größeren Einheiten zu kombinieren. Admins definieren so komplette Kubernetes-Infrastrukturen mit wenigen Zeilen Code. Über diese Module erfolgen Updates auch in verschiedenen Umgebungen ohne die Gefahr händischer Übertragungsfehler.

In Staging-Umgebungen finden sich häufig ähnliche Strukturen wie in den Produktivumgebungen, die sich nur in ihren Konfigurationswerten unterscheiden. Durch die Kombination und Kapselung von Modulen in Modulen lässt sich eine redundante Konfiguration verhindern. So können Admins ein Modul definieren, das selbst wiederum aus Deployment-, Service- und Ingress-Modulen und -Ressourcen besteht, das sie dann für verschiedene Komponenten wiederverwenden. Dieses Modul weist nach außen nur die nötigen Konfigurationsparameter als Variablen auf.

Die Modularisierung von Ressourcendefinitionen bietet Möglichkeiten zur Abstraktion nichthomogener Strukturen. Module weisen nach außen die bereits vorgestellten Variablen auf, der Inhalt der Module variiert aber. Eine mögliche Situation aus dem Alltag ist, dass Admins in Kubernetes-Clustern einen Ingress-Controller zum Bearbeiten von HTTP-Anfragen installieren möchten. Dieser Ingress-Controller benötigt als Variable ein Set von IP-Adressen für das Load Balancing. Sind der Ingress-Controller und seine notwendige Konfiguration in einem Modul abgekapselt, erlaubt dies, den Controller auszutauschen, ohne viele verschiedene Dateien händisch bearbeiten zu müssen. Es ändert sich lediglich der Modulaufruf, bei dem ein anderes Modul für einen Ingress-Controller referenziert wird.

Hierbei ist zu beachten, dass Abhängigkeiten zwischen Modulen entstehen können. Um Aktualisierungen in referenzierenden Ressourcen vorzunehmen, könnten Entwickler und Entwicklerinnen an dieser Stelle Outputs nutzen.

Terraform benötigt pro Modul einen Terraform-Block. Dieser definiert unter anderem, welche Terraform-Provider zum Einsatz kommen sollen. Ressourcendefinitionen bilden in Terraform immer einen Zustand ab, den Terraform zunächst im lokalen Verzeichnis .terraform ablegt. Es besteht jedoch auch die Möglichkeit, sogenannte Remote States zu verwenden. Die sind vor allem dann sinnvoll, wenn Admins die Provisionierung mit mehreren Endgeräten durchführen, wobei immer nur ein Client den State schreibend bearbeiten sollte. Eine solche Funktion bietet unter anderen GitLab an, die das erste terraform init-Kommando mit einem Backend-Parameter versieht. Dadurch wird der Zustand hochgeladen und dort aktualisiert. Der Zustand hat einen exklusiven Zugriff für einen Nutzer, um inkonsistente Zustände zu erschweren.

Der Befehl terraform init führt die Initialisierung der Umgebung aus. Die im Abschnitt zu HCL angesprochenen Terraform-Provider bieten Möglichkeiten zur Erweiterung der Terraform-Ressourcendefinitionen. Sie abstrahieren API-Schnittstellen nach Terraform, sodass Entwicklerinnen und Entwickler sie als neue Datentypen in Ressourcenblöcken einsetzen können. Die Installation von Ressourcenblöcken erfolgt gewöhnlich über die von HashiCorp betriebene Provider-Infrastruktur, die neben Informationen auch Dokumentation zu den durch den Provider angebotenen Datentypen bereitstellen. Um einen Provider zu installieren, wird ein required_providers-Block im zentralen Terraform-Block ergänzt, der vergleichbar mit maven oder yarn das Provider-Paket in einer bestimmten Version zur Installation vorsieht. Den Download löst das Kommando terraform init aus (siehe Listing 9).

Listing 9: Konfiguration von Terraform-Provider-Abhängigkeiten:

Listing 9: Definition und Konfiguration von Terraform-Provider-Abhängigkeiten terraform {
  required_providers {
    helm = {
      source  = "gavinbunney/kubectl"
      version = "1.14.0"
    }
  }
}

provider "kubectl" {
  config_path = "${path.module}/kubeconfig.yaml"
}

Es gibt auch die Möglichkeit, mit dem für Terraform angebotenen kubectl-Provider Kubernetes-YAML-Konfigurationsdateien zu integrieren, was einen allmählichen Wechsel von Kubernetes in Richtung Terraform vereinfachen kann. Allerdings lassen sich alle Kubernetes-Ressourcen auch direkt in Terraform modellieren und mit verschiedenen Tools automatisch übersetzen.

Das Anwenden von Terraform-Konfigurationsdateien folgt einem einfachen Prozess: Der Befehl terraform init initialisiert die Konfiguration im aktuellen Verzeichnis, lädt alle Provider und Module und initialisiert das Backend, falls nötig. Der Abgleich des echten Zustands, des State im Backend, und der aktuellen Konfiguration lässt sich durch terraform plan auslösen. Dieser Befehl gibt die Differenz zwischen der bestehenden Infrastruktur und den ausstehenden Änderungen aus (siehe Abbildung 1). Dieses Ergebnis lässt sich auch als Ausführungsplan in einer Datei speichern, um es später anzuwenden.

'terraform plan'-Ausgabe für ein Versionsupdate eines Container-Image (Abb. 1).

Der letzte Befehl in diesem Prozess ist terraform apply, mit dem Terraform einen vorher gespeicherten Ausführungsplan anwendet oder wie bei terraform plan die Differenz bestimmt. Diese können Admins nach Bestätigung der Richtigkeit direkt anwenden. Hiermit werden schließlich alle Änderungen an der Infrastruktur ausgeführt und in den State synchronisiert. Erst dieser Schritt kann bei falscher Benutzung oder fehlerhafter Konfiguration echten Schaden anrichten.Auch terraform destroy richtet Schaden an, denn der Befehl löscht alle verwalteten Ressourcen auf der Infrastruktur und gehört deshalb bis auf Spezialfälle nicht in den Standard-Provisionierungsprozess. Beim Automatisieren von Infrastrukturprovisionierung ist es mitunter notwendig, mit Daten aus Terraform zu arbeiten, um etwa weiterführende Aktionen auszulösen. Terraform bietet hierzu die Möglichkeit, Output-Blöcke zu deklarieren. Via terraform output -json kann Terraform diesen Output auch in JSON liefern, um ihn beispielsweise mit Python weiter zu verarbeiten.

Betreiber, die eine bestehende Infrastruktur haben und künftig über Terraform verwalten wollen, müssen diese in den State importieren. Voraussetzung ist, dass der entsprechende Cloud- oder Hosting-Anbieter dies unterstützt. Die Betreiber definieren dann in Terraform eine entsprechende Ressource mit den nötigen Attributen (dadurch wird eine Terraform-Ressource-ID vergeben), die zu der realen Infrastruktur passt. Man importiert sie über den Befehl terraform import <terraform-resource-id> <identifier-in-provider> unter der entsprechenden Terraform-Ressource-ID. Danach sollte mit terraform plan überprüft werden, dass die Ressource richtig in Terraform modelliert ist. Falls nötig sind die Attribute so lange anzupassen, bis die Ressource mit dem State übereinstimmt und im Ausführungsplan nicht mehr als Update / Replace geführt wird.

Terraform bietet die Möglichkeit, partielle Updates durchzuführen. Admins können mit dem Parameter target verschiedene Ressourcen explizit referenzieren, die Terraform im Ausführungsplan berücksichtigen soll, während es alle anderen ignoriert. Diese partiellen Updates sollten Anwender und Anwenderinnen jedoch nur nutzen, wenn es unbedingt nötig ist, da implizite Abhängigkeiten von Ressourcen existieren können, die bei partiellen Updates zu Problemen und unerwartetem Verhalten führen. Admins sollten auch Abhängigkeiten bedenken, da beim Verwenden der target-Filter auch Komponenten mit ausstehenden Änderungen zwangsweise ein Update erhalten, von denen die Ziel-Komponenten abhängen, aber nicht vom Filter inkludiert sind.

Durch Wiederverwenden von Modulen und dem achtlosen Einsatz partieller Updates kann es leicht passieren, dass zwei Ressourcen deployt sind, die auf unterschiedlichen Revisionen des gleichen Moduls basieren, was zu einer inkonsistenten Infrastruktur führt. Allerdings sind partielle Updates auch nützlich, um Fehler in der Infrastruktur (beispielsweise nach fehlgeschlagenen Updates) zu korrigieren. Wenn Admins die gleichen Revisionen der Module in verschiedenen Staging-Umgebungen verwenden, können sie partielle Updates auch nutzen, um nicht alle geplanten Änderungen auf einen Schlag ausführen zu müssen.

Admins sind gut beraten, einen strikten Prozess inklusive Versionskontrolle der Konfigurationen einzuhalten, um Konflikte zu vermeiden. Wenn zwei Personen gleichzeitig etwas an der Infrastruktur ändern, sollte zwar auf Transaktionsebene der Terraform-State für eine Person gelockt sein (falls das State-Backend Locks unterstützt), aber sequenzielle Änderungen ohne Synchronisation der Konfigurationsdateien zwischen diesen Personen können trotz Locks zu inkonsistenten Zuständen führen, da die jeweils anderen Änderungen wieder rückgängig gemacht werden. Deswegen empfiehlt es sich, einen vordefinierten Prozess einzuhalten und beispielsweise mit Feature-Branches zu arbeiten, die erst nach dem Merge angewandt werden.

Der Prozess sollte ebenfalls die Anwendung anderer infrastrukturändernder Tools und Konfigurationsmöglichkeiten abseits von Terraform verhindern, beispielsweise den Einsatz von kubectl. Das bringt den Zustand durcheinander, und selbst wenn Admins die entsprechenden Änderungen in Terraform nachtragen, schreiben manche Tools zusätzliche Metainformationen in die Kubernetes-Ressourcen. Terraform kann diese Informationen wiederum als Änderung interpretieren, sodass Admins Ressourcen erneut ausrollen müssen. Tools dieser Art sollten deshalb nur über einen entsprechenden Terraform-Provider zum Einsatz kommen.

Es gibt verschiedene Fehler, die dazu führen können, dass der Terraform-State und der reale Zustand der Infrastruktur divergieren, beispielsweise durch Fehler in Terraform-Providern, Hardware-Fehler oder Netzwerkunterbrechungen. In solchen Fällen kann es notwendig sein, den State manuell wieder synchron zum realen Zustand zu bringen oder den realen Zustand der Infrastruktur über Wege abseits von Terraform in Ordnung zu bringen. Mit terraform state rm können Admins eine bestimmte problematische Ressource aus dem State entfernen und mit terraform import wieder importieren. Alternativ lässt sich die entsprechende State-Datei manuell bearbeiten und über terraform state push/pull mit dem State-Backend synchronisieren. Je nach Fehlerbild können auch partielle Updates helfen, den Zustand zu bereinigen.

Infrastructure-as-Code-Werkzeuge wie Terraform kombinieren die Definition von Infrastrukturen durch ihren deklarativen Ansatz mit Konzepten aus der Entwicklung, und sie bieten durch Erweiterungen eine homogene Schnittstelle für unterschiedliche Umgebungen und Szenarien. Durch die Verwendung von in Modulen gekapselten Ressourcen vermeiden Entwicklerinnen und Entwickler in Terraform redundante Definitionen und somit Fehler durch händisches Übertragen. Komplexe Strukturen können in Module abgekapselt werden und erlauben durch die Nutzung von Variablen deren parametrisierte Wiederverwendung.

In CI/CD-Pipelines eingebettet lässt sich Terraform auch mit anderen Werkzeugen wie Ansible kombinieren, um Laufzeitkonfigurationen durchzuführen. Beim Konfigurieren von Linux-Systemen beispielsweise lässt sich das System selbst über Terraform deklarieren und nachfolgende Tasks mit Parametern (zum Beispiel via Outputs) an Ansible übergeben. Weitere Tools können die Funktionalität erweitern, beispielsweise TFLint als Linter und Terragrunt für den Abbau von Redundanzen in großen Umgebungen durch die Unterstützung von Pre- / Post-Hooks.

(who)