Einführung in Terraform

Die eigene Cloud-Infrastruktur automatisch ausrollen, verwalten und aktualisieren, ohne sich um die eigentlichen Zwischenschritte kümmern zu müssen, das alles erlaubt Terraform. Terraform ist ein "Infrastructure-as-Code"-Tool: Es erlaubt, die eigene Infrastruktur als Code zu "dokumentieren" und diese entsprechend umzusetzen, egal ob es Datenbanken, Load Balancer oder gar Docker-Container sind. Terraform übernimmt Änderungen interaktiv. Dieser Artikel gibt eine Einführung in das Tool: Als Beispiel dient das Deployment eines Hosts in der AWS Cloud, was sich mit leichten Anpassungen auf andere Cloud-Dienste übertragen lässt.
Motivation
Cloud-Infrastruktur als Code formulieren und das unabhängig davon, ob es um Single-Cloud, Multi-Cloud, Hybrid-Cloud oder sogar das eigene Rechenzentrum geht? Genau das ist mit Terraform möglich. Die Vorteile eines solchen Ansatzes, Infrastructure as Code (IaC) zu definieren, sind vielfältig: Ist die Infrastruktur erst einmal in Codeform, lässt sie sich in der Versionskontrolle verwalten und ihr kompletter Lebenszyklus – Aufbau, Wartung, Änderungen bis hin zum Abbau – wird automatisierbar. Dies vereinfacht zusätzlich Auditprozesse und die durchgängige Versionierung vermeidet das Entstehen von sogenannten "Schneeflocken", also Systemen, die durch fortwährende Änderungen nicht mehr in ihrem aktuellen Zustand reproduzierbar sind.
Doch wie funktioniert Terraform im Detail und was unterscheidet es von anderen etablierten Werkzeugen wie beispielsweise Ansible? Dieser Artikel gibt eine Einführung in das Werkzeug Terraform sowie die zugehörige Konfigurationssprache Hashicorp Configuration Language (HCL). Ziel ist dabei ein schneller, direkter Zugang zu den wichtigsten Kernkonzepten anhand von Beispielen. Konkret behandeln die Beispiele die Provisionerung einer virtuellen Maschine in AWS samt Netzwerk, IP und DNS. Sie lassen sich aber auch mit minimalen Änderungen in andere Clouds oder das eigene Rechenzentrum übertragen.
Funktionsweise und Abgrenzung zu anderen Werkzeugen
Terraform verfolgt einen sogenannten "deklarativen Ansatz": Als Input dient Terraform eine Beschreibung des Zielzustandes der eigenen Infrastruktur. Dieser Ansatz bildet damit einen Gegensatz zum "imperativen Ansatz", d. h. der Beschreibung des Weges, um einen Zielzustand zu erhalten. Dem Benutzer wird also die Aufgabe abgenommen, sich um die Pflege des eigentlichen Workflows zu kümmern. Er muss lediglich das Endresultat definieren, die praktische Umsetzung übernimmt die Anwendung selbst.
Als direkt erkennbarer Vorteil ist der Code für einen solchen deklarativen Ansatz sehr viel kompakter als im imperativen Fall, da die komplette Logik für den eigentlichen Workflow entfällt. Der Code wird dadurch sowohl übersichtlicher als auch besser lesbar und somit auch einfacher zu prüfen und zu warten. Darüber hinaus hat der Code den Charakter einer Dokumentation, beschreibt also den Zielzustand der eigenen Infrastruktur komplett, man spricht von "Infrastructure as Code" (IaC).
Hinsichtlich der Struktur des Codes unterscheidet sich Terraform vor allem dadurch von anderen Werkzeugen, wie beispielsweise Ansible, dass der deklarative Ansatz konsequent umgesetzt ist. Wie später noch näher zu sehen sein wird, kennt die Hashicorp Configuration Language, in der Terraform-Module geschrieben sind, keine Folge einzelner Schritte. Stattdessen ergibt sich die Reihenfolge, in der Ressourcen erstellt werden, implizit aus den Abhängigkeiten der Ressourcen untereinander.
Die letztendliche Umsetzung des Codes in die Tat – das Provisionieren und Editieren der Infrastruktur – findet dann mit dem Kommandozeilenprogramm terraform statt. Anhand des Codes baut es zunächst den erwähnten impliziten Abhängigkeitsbaum auf. Dann stellt es die Verbindung zu den nötigen API-Servern her und prüft, welche API-Aufrufe nötig sind, um den gewünschten Zielzustand zu erreichen. Schließlich erstellt es eine Vorschau der geplanten Änderungen und setzt diese, sofern gewünscht, in die Tat um.
Terraform bewahrt dabei ein "lokales Gedächtnis" über die verwaltete Infrastruktur und vergleicht das mit der aktuellen Konfiguration. Dieser sogenannte "State", der insbesondere die UUIDs der erstellten Ressourcen enthält, ist eine weitere Besonderheit von Terraform.
Basiskonzepte
Zur Beschreibung von Infrastruktur benutzt Terraform die eigene Sprache HCL. Die Logik dieser Sprache bestimmt die Struktur von Terraform-Code und sagt auch einiges über die Funktionsweise von Terraform, wie folgendes Beispiel verdeutlicht: Das Szenario sieht vor, dass ein Webserver mit fester IP-Adresse existiert. Diesem soll nun via Route53, dem DNS-Service von AWS, ein entsprechender FQDN innerhalb der Zone beispiel.de zugewiesen werden. Ein solcher Eintrag, hier mit dem Namen demo-server.beispiel.de sieht in der HCL wie folgt aus:
resource "aws_route53_record" "this" {
zone_id = "OUGHDE68KJH6"
name = "demo-server"
type = "A"
ttl = "60"
records = ["12.34.56.78"]
}
Wie an diesem Beispiel zu sehen ist, gruppiert die HCL logische Einheiten des Codes in Blöcken. Ein Block beginnt stets mit der Deklaration des Blocktyps, welcher die syntaktische Rolle des Blocks verdeutlicht. In diesem konkreten Fall handelt es sich um einen resource-Block, also eine Ressource, die aktiv von Terraform verwaltet wird. Auf den Blocktyp folgen dann ein oder mehrere Blocklabel, welche den Block genauer beschreiben. Das erste Label, aws_route53_record, spezifiziert die Art der verwalteten Ressource näher. Bei this handelt es sich dagegen nur um eine reine Benennung. Im darauffolgenden Blockkörper, umschlossen von geschweiften Klammern, erfolgt die eigentliche Konfiguration der Ressource. Er enthält zusätzliche Argumente wie den Namen des Records, den Recordtyp, die "time to live" und den eigentlichen Record, also die IP des Servers. Außerdem erfordert Route53 die Angabe der ID der zugehörigen Route53-Zone.
Wie wird nun aus diesem Code-Schnipsel die eigentliche Infrastruktur?
Ganz im Sinne des IaC steht am Anfang die Beschreibung der Infrastruktur mittels Codeblöcken wie dem obigen. Diese können dann beispielsweise in einer Datei main.tf gesammelt werden – die Strukturierung von Code in mehreren Dateien wird später noch näher betrachtet. Um die einzelnen Ressourcen verarbeiten zu können, muss Terraform sich dann für das entsprechende Projekt via terraform init vorbereiten. Es lädt dabei alle benötigten Plugins – die sogenannten provider – herunter, um die definierten Ressourcen verarbeiten zu können. Nun kann die eigentliche Provisionierung erfolgen.
Via terraform plan erstellt Terraform eine Vorschau aller notwendigen Änderungen am Zustand der Infrastruktur. Dafür verbindet es sich mit den APIs und Interfaces der Provider, überprüft den Ist-Zustand der Infrastruktur und gleicht ihn mit den Anforderungen aus dem Code ab. Nach einer Durchsicht der geplanten Änderungen, startet terraform apply mit der Umsetzung. Nachträgliche Anpassungen der Infrastruktur sind nun durch die Bearbeitung des Codes möglich. Ein neuer plan-apply-Zyklus setzt diese dann in die Tat um. Abschließend ist auch mittels terraform destroy eine Löschung der gesamten von Terraform verwalteten Infrastruktur möglich.
Um den Überblick über die verwaltete Infrastruktur nicht zu verlieren, speichert Terraform den Zustand verwalteter Ressourcen in einer lokalen Datei zwischen. Standardmäßig handelt es sich dabei um terrafom.tfstate in dem jeweiligen Projektordner. Insbesondere erlaubt diese lokale Speicherung auch die Verwaltung einer Vielzahl unterschiedlicher Ressourcen verschiedenster Provider in einem einheitlichen Schema. Darüber hinaus beinhaltet der sogenannte "Terraform State" relevante Metadaten, wie Abhängigkeiten der einzelnen Ressourcen zueinander. Diese werden vor allem dafür benötigt, um die logische Reihenfolge der einzelnen Ressourcen während des Deployments zu ermitteln.
Das state-File selbst wird standardmäßig als unverschlüsselte JSON-Datei erzeugt. Jedwede manuelle Modifikation des state-Files sollte nach Möglichkeit unterlassen werden. Möchte man den Inhalt des state-Files dennoch manuell ändern, ist dies mit dem "terraform state"-Befehl möglich. Da sich das Format des state-Files zwischen einzelnen Versionen stark unterschieden kann, sollte der aktuelle "State" mittels des terraform output -show ausgelesen werden. Dieser Output ist zur weiteren Verwendung von Drittsoftware vorgesehen und damit stabil.
Da der State auch sensible Daten wie Passwörter und Accountdaten im Klartext beinhaltet, ist ein verantwortungsvoller Umgang mit dieser Datei sehr wichtig. Spätestens, wenn mehrere Personen Zugriff auf ein Projekt haben und damit auch den State untereinander teilen müssen, bietet sich dafür die Verwendung eines sogenannten "Terraform Backends" an. Dieses übernimmt die verschlüsselte Verwahrung des Terraform States und bietet im Idealfall auch einen Lock-Mechanismus an, um zu vermeiden, dass gleichzeitig mehrere apply-Durchläufe starten und somit die Integrität des States riskieren.
Ein etwas komplexeres Beispiel
Wie ein Terraform-Projekt nun in der Praxis aussieht, soll folgendes Szenario verdeutlichen. Ziel ist die Provisionierung einer virtuellen Maschine in AWS mit eigener DNS-Auflösung via Route53. Aufbauend auf dieser Grundkonfiguration können weitere Konfigurationen erfolgen, wie die Konfiguration eines Webservers, die Anbindung von App- und Datenbankservern, Loadbalancer und so weiter.
Die zur Umsetzung benötigten Plugins und Ressourcen sind schnell identifiziert. Da das Deployment vollständig in AWS stattfindet, genügt der gleichnamige provider "aws", der von Hashicorp selbst gepflegt und regelmäßig aktualisiert wird. Das neue Terraform-Projekt bekommt nun eine providers.tf mit dem Inhalt:
provider "aws" {
region = "eu-central-1"
}
Dieser Block enthält alle Informationen, die Terraform benötigt, um den entsprechenden Provider zu laden. Als Ort für das Deployment der AWS- Ressourcen soll die Frankfurter Region eu-central-1 dienen. Der Login in den AWS-Account selbst soll in diesem Beispiel über Umgebungsvariablen in der Shell erfolgen. Diese landen also nach guter Praxis nicht im Code, um ihn versionierbar zu halten.
Ein 'terraform apply' und einen Schluck Kaffee später ist die erste Ressource auch schon fertig.
An dieser Stelle sei angemerkt, dass die Benennung der Datei als providers.tf reine Konvention und nicht verpflichtend ist. Generell interessiert sich Terraform nicht für Dateinamen (solange sie auf .tf enden) und ebenso wenig für die Reihenfolge von Blöcken in diesen Dateien. Damit ergibt sich bei der Strukturierung von Code viel Freiheit, die genutzt werden kann, um den Code gut nutz- und wartbar zu halten.
Nun ist es Zeit, den Host aufzusetzen – in AWS-Jargon eine "EC2 instance". Dazu reicht folgender Code:
resource "aws_instance" "this" {
instance_type = "t2.micro"
ami = "ami-0a1ee2fb28fe05df3"
subnet_id = "subnet-01234567890abcdef"
tags = {
Name = "demo_amazon_linux_vm"
}
}
Dieser Code landet in einer eigenen Datei main.tf, um die Ressourcen thematisch von den Providerinformationen in providers.tf zu trennen. Neben dem Typ der Instanz – hier die kleinste verfügbare Einheit t2.micro – wird das Image für das Deployment ami und die Subnet ID subnet_id benötigt. Die Angabe eines Name-Tags, erleichtert später das Auffinden der Maschine im Cloud-Interface. Ein terraform apply und einen Schluck Kaffee später ist die erste Ressource auch schon fertig.
Jetzt ergibt sich allerdings ein Problem: Auch wenn obige Definition vollkommen ausreicht, den Host zu beschreiben, so ist sie doch in der Praxis kaum einsetzbar. Diese Definition setzt nämlich voraus, dass alle Details zur EC2-Instance VOR dem Deployment bekannt sind – unter anderem auch die ID des Subnets, in der sich die Instance befinden soll. Wollen wir aber wirklich von Null starten, so müssen wir auch dieses Subnet mit Terraform erzeugen, und zwar idealerweise im selben Durchlauf wie den Host. Damit ist die ID des Subnets zum Zeitpunkt der Ausführung von terraform apply noch gar nicht bekannt. Genau hier zeigt sich aber die Stärke von Terraform, denn es ist in der Lage, derartige Abhängigkeiten während eines Durchlaufs direkt aufzulösen. Dazu erweitern wir unseren Code um die Definition des benötigten Subnets in der main.tf folgendermaßen:
resource "aws_subnet" "this" {
vpc_id = "vpc-0123456789abcdef"
cidr_block = "10.10.1.0/24"
availability_zone = "eu-central-1a"
tags = {
Name = "demo_vpc_subnet"
}
}
resource "aws_instance" "this" {
instance_type = "t2.micro"
ami = "ami-0a1ee2fb28fe05df3"
subnet_id = aws_subnet.this.id
tags = {
Name = "demo_amazon_linux_vm"
}
}
Anstelle der expliziten subnet_id in der Instance steht nun eine Referenz auf den entsprechenden Parameter der aws_subnet-Ressource. Eine solche Referenz hat dabei stets die Form <Ressourcentyp>.<Name>.<Parameter>. Terraform ist in der Lage, aus solchen Referenzen implizite Abhängigkeiten zu erkennen und so die Reihenfolge des Deployments selbst zu bestimmen. Es wird also in diesem Beispiel das Subnet zuerst erstellen und dann, sobald dessen ID bekannt ist, mit der Erstellung der Instance fortfahren. Hier wird auch die Benennung der Ressourcen mit dem Namen this klar: Ressourcennamen dienen in Terraform lediglich der Identifizierung des Blocks und werden in Referenzen stets zusammen mit dem Ressourcentyp genannt. Wenn es von einem Typ also nur einen Block gibt, ist ein Name überflüssig – die Konvention this hat sich dafür etabliert.
Es lassen sich natürlich nicht nur Subnet-IDs referenzieren. Vielmehr liefert jede Terraform-Ressource ein ganzes Bündel an Rückgabeparametern an Terraform. An dieser Stelle lohnt sich ein kurzer Blick in die Dokumentation der jeweiligen Ressource.
Wie in den meisten Fällen ist auch unser Terraform-Projekt nicht vollständig für die initiale Provisionierung aller betroffenen Ressourcen verantwortlich. Oft baut ein Projekt auf bereits bestehender Infrastruktur auf, welche aber nicht verändert werden soll. In diesem Beispiel betrifft das das Deployment eines A-Records innerhalb einer Zone von Route53. Während wir die IP-Adresse für den Host und den zugehörigen Record in unserem Projekt verwalten wollen, soll die entsprechende Zone bereits existieren und hier nur referenziert werden. Zur Beschreibung solcher "Datenquellen" gibt es in Terraform zum Glück den data-Block, welcher thematisch sowohl in die providers.tf als auch in die main.tf passt:
data "aws_route53_zone" "this" {
name = "beispielzone.de"
}
Datenquellen verhalten sich in der Benutzung sehr ähnlich zu Ressourcen. Auch sie besitzen spezifische Argumente und Attribute, wobei diese aber nur der eindeutigen Identifikation dienen. Terraform stellt anhand dieser Information eine Anfrage an den Provider, um die Ressource auszulesen und deren Parameter für die weitere Verwendung bereitzustellen. Auch hier lohnt es sich, einen Blick in die Dokumentation zu werfen, um sich einen Überblick über die verfügbaren Datenquellen zu verschaffen.
Da sich auch Datenquellen wie Ressourcen referenzieren lassen, ergibt sich so die Möglichkeit, die Elastic-IP und den Route53-Record in der main.tf zu ergänzen:
resource "aws_eip" "this" {
instance = aws_instance.this.id
vpc = true
}
resource "aws_route53_record" "this" {
records = [aws_eip.this.public_ip]
zone_id = data.aws_route53_zone.this.zone_id
name = "demo-amazon-linux-vm"
type = "A"
ttl = "60"
}
Einen Nachteil hat dieser Code allerdings noch: Der Name der Zone ist fest im Code verankert. Es kann aber durchaus sein, dass diese Zone nicht mehr existiert, oder bei der Ausführung des Terraform-Projekts eine andere Zone benutzt werden soll. Um dieses Problem zu umgehen, gibt es variable-Blöcke, die Parameter für den Code beschreiben:
data "aws_route53_zone" "this" {
name = var.aws_route53_zone_name
}
variable "aws_route53_zone_name" {
type = string
description = "Name for the AWS Route53 zone."
}
Es bietet sich an, Variablenblöcke in einer eigenen variables.tf zu sammeln, da sie das "Interface" des Codes darstellen. Beim Ausführen von terraform apply fragt Terraform nun interaktiv nach dem Wert für die freie Variable. Der Vorteil dieser Trennung von benutzerspezifischen Eingaben und Terraform-Code liegt auf der Hand: Terraform-Code wird universeller einsetzbar und lässt sich an spezifische Situationen anpassen. Der Code selbst bleibt generisch, als "Quelle der Wahrheit" für Benutzereingaben dienen die Variablen.
Neben der interaktiven Eingabe des Variablenwerts kann Terraform diesen auch aus der Umgebungsvariable TF_VAR_<Variablenname> auslesen. Alternativ gibt es auch die die Möglichkeit, Variablenwerte in einer terraform.tfvars-Datei als Schlüssel-Wert-Paare zu sammeln. Für jede Variable lässt sich darüber hinaus ein default-Wert definieren, auf den Terraform zurückfällt, sollte keine entsprechende Umgebungsvariable oder tfvars-Datei existieren. Dadurch bleibt natürlich die interaktive Abfrage aus, was aber beispielsweise in einer CI-Pipeline durchaus gewünscht sein kann.
Für Variablen stehen die einfachen Datentypen bool, string und number zur Verfügung, sowie komplexere Typen wie Listen und Maps. Darüber hinaus lassen sich Testkriterien für Variablen definieren, um die Benutzereingabe auf Brauchbarkeit zu überprüfen. Apropos Tests und Kriterien: Der terraform validate prüft, ob alle Parameter in Ressourcenblöcken die richtigen Datentypen haben, während terraform fmt den Code einheitlich formatiert – beide Befehle eignen sich hervorragend für Commit-Hooks und Lint-Stages in der CI-Pipeline.
Ebenso wichtig wie die Eingabe von Informationen ist allerdings auch die Ausgabe von Informationen nach erfolgreichem Deployment. Sollen beispielsweise externe Werkzeuge in einer CI-Pipeline den eben erstellten Server konfigurieren, so müssen sie beispielsweise auf dessen IP zugreifen.
Derartige Ausgaben lassen sich mit Hilfe des output-Blocks definieren:
output "aws_elastic_ip_ip_address" {
value = aws_eip.this.public_ip
description = "Public IP address of the AWS EC2 instance"
}
Outputs sind idealerweise in einer outputs.tf gruppiert, die zusammen mit der variables.tf das Interface des Codes bilden. Im Gegensatz zu Variablen, die nie eine Referenz enthalten können, enthält ein Output normalerweise immer eine Referenz. Nach Ausführung von terraform apply wird ein entsprechender Output zur Weiterverwendung ausgegeben. Mit terraform output lässt sich außerdem auch die Ausgabe aller Outputs erzwingen, ohne erneut einen Durchlauf starten zu müssen. Das ist möglich, da Outputs im State gespeichert werden.
An dieser Stelle ist es wichtig, sensible Daten zu schützen. Mit sensitive = true markierter Output wird in den Logs von terraform plan und terraform apply maskiert. Dies ist insbesondere bei Daten wie Passwörtern, SSH-Schlüsseln oder ähnlichem empfehlenswert. Auch Variablen lassen sich als sensitive markieren, wodurch sie und aus ihnen abgeleitete Werte in der Ausgabe maskiert bleiben.
Zusammenfassung und Ausblick
Terraform hat sich mittlerweile als das IaC-Werkzeug etabliert und ist insbesondere dann das Mittel der Wahl, wenn es um die providerübergreifende Verwaltung von Infrastruktur geht. Durch seinen konsequent deklarativen Ansatz erlaubt es Planung, Aufbau und Verwaltung der eigenen Infrastruktur, ohne dabei den Benutzer mit dem Setup von Cloud-APIs zu belasten. Stattdessen unterstützt die eigens entwickelte Sprache HCL bei der übersichtlichen Dokumentation der eigenen Infrastrukturressourcen.
Nach der Einführung in die Kernkonzepte des Kommandozeilenprogramms, sowie der zugehörigen Konfigurationssprache bleiben für den produktiven Einsatz natürlich noch einige Punkte offen.
Zuerst ist da die Einbindung des Terraform-Workflows in die eigene CI-Umgebung. Hier gibt es viele Wege und wie immer hängt die beste Wahl von den eigenen Anwendungsfällen, sowie dem bereits vorhandenen Tooling ab. Allen gemeinsam ist, dass der Terraform State in jedem Fall über ein geeignetes "Backend" zentralisiert zu verwalten ist. Dadurch liegt er nicht lokal auf den Agents der CI-Pipeline, wo er einfach gelöscht oder kompromittiert werden könnte. DevOps-Lösungen wie GitLab bieten hier komfortable Integrationen an, aber auch Objektspeicher oder Datenbanken sind gut geeignet. Zudem sollte bei Änderungen an produktiven Systemen stets ein manueller Auditschritt eingeplant werden. So kann die Pipeline beispielsweise nach Validierung des Codes einen terraform plan erstellen und dann auf ein manuelles Starten des terraform apply warten. Ein solches Konzept lässt sich auch in der Cloud z. B. über Azure DevOps über Umgebungen gut realisieren. Alternativ lässt sich ein terraform plan auch automatisiert bei Pull-Requests erstellen, sodass schon vor dem Merge klar ist, welche Änderungen an der Infrastruktur entstehen werden.
Ein weiterer wichtiger Punkt in diesem Zusammenhang ist die Integration von Terraform in gegebenenfalls bestehende Arbeitsabläufe zum Konfigurationsmanagement: Soll Terraform auf erstellten Hosts direkt einen Puppet-Agent installieren oder passiert das über eine eigene Pipeline? Falls Ansible verwendet wird: läuft es als einzelner Schritt in der Pipeline oder soll Terraform Ansible direkt aufrufen? Prinzipiell ist es sogar – mittels provisioner-Blöcken – möglich, das Konfigurationsmanagement in den Terraform-Code zu integrieren.
Als letzter offener Punkt stellt sich noch die Frage nach der Paketierung und Wiederverwendbarkeit von Terraform-Code: Hier gibt es das Konzept von Terraform-Modulen, welche bestehenden Code als Einheit mit klar definiertem Interface kapseln. Ein typisches Beispiel ist hier die Paketierung der Kombination "Netzwerk-Server-Firewall" als ein Modul, welches zentral im Unternehmen gewartet wird. Diese Module lassen sich dann ihrerseits wiederum in Terraform-Code als module-Blöcke instanziieren. Ein Update des "upstream"-Moduls schlägt dann automatisch auf alle Instanzen durch, sofern das Update dem gewünschten Versionsschema entspricht.
Abschließend lässt sich sagen, dass Terraform auch provider bietet, die über die klassische Infrastruktur im Sinne von Rechen- und Netzwerkressourcen hinausgehen. So lassen sich mit Terraform auch Docker-Container, Kubernetes-Ressourcen, Zertifikate, Git-Remotes und vieles mehr konfigurieren und die Zahl der provider wächst immer weiter. Die in diesem Artikel beschriebenen Konzepte dienen also als Startpunkt für die Arbeit mit Terraform, nicht nur im Zusammenhang mit Infrastruktur, sondern mit allem, was sich mit dem konsequent deklarativen Konzept verträgt.