Einstieg in Terraform & AWS

Einstieg in Terraform & AWS

Als Entwickler möchte ich die für meine Anwendung erforderliche Infrastruktur genauso verwalten und ausliefern können wie meine Anwendung selbst. Somit ist es nur konsequent, die Deklaration der benötigten Infrastruktur meiner Anwendung genauso als ein Artefakt zu behandeln wie den Quellcode der Anwendung oder deren Build-Artefakte.

Terraform ist ein Infrastructure-as-Code-Tool (IaC) zum Verwalten von Cloud-Infrastruktur. In diesem Workshop erstellen wir eine Cloud-Infrastruktur mit Terraform und dem Cloud-Provider AWS. Wir erstellen dabei drei verschiedene Webapplikationen in Form von drei Docker-Containern, deployen sie in die AWS-Cloud und machen all diese Webapplikationen unter einer öffentlichen IP-Adresse zugänglich.

Anforderungen

Zur Durchführung des Workshops müssen drei Programme installiert sein. Zudem muss ein aktiver AWS-Benutzeraccount vorhanden und dafür eine Authentifizierung auf der lokalen Maschine eingerichtet sein. Die folgenden vier Voraussetzungen müssen zur Durchführung des Workshops erfüllt sein:

1. Terraform

Ein IaC-Tool zum Verwalten der Cloud-Infrastruktur. Die Installation von Terraform war erfolgreich, wenn der folgende Befehl eine entsprechende Ausgabe liefert:

% terraform --version
Terraform v1.1.3

2. Docker

Docker muss lokal installiert und der Docker Daemon gestartet sein. Mit dem folgenden Befehl kann das überprüft werden:

% docker info
Client:
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc., v0.7.1)
  compose: Docker Compose (Docker Inc., v2.2.1)
  scan: Docker Scan (Docker Inc., v0.14.0)
...

3. AWS CLI-Client

Der AWS CLI-Client ist ein Kommandozeilen-Tool zur Verwaltung von AWS Services in der AWS Cloud. Die erfolgreiche Installation des AWS CLI-Clients kann mit dem aws --version getestet werden.

4. Setup der AWS Credentials

Zur Durchführung des Workshops benötigen wir einen aktiven AWS-Account. Für die Authentifizierung mit der AWS Cloud müssen serverseitig Zugangsdaten erstellt und danach auf der lokalen Maschine hinterlegt werden. Die einzelnen Schritte sehen so aus:

4.1. Serverseitigen Zugriffsschlüssel erstellen

Die Zugangsdaten können im Benutzerbereich der Weboberfläche erstellt werden:
(Benutzer > ihr.benutzername > Sicherheitsanmeldeinformationen > Zugriffsschlüssel erstellen)

Nach dem Erstellen des Zugriffsschlüssels sollten die Werte für „Zugriffsschlüssel-ID“ und „Geheimer Zugriffsschlüssel“ notiert werden.

4.2. Clientseitige Zugangsdaten hinterlegen

Diese beiden Werte werden nun nun in der Datei ~/.aws/credentials unterhalb des Benutzerverzeichnisses hinterlegt(Linux & macOS):

aws_access_key_id={ACCESS_KEY_ID}
aws_secret_access_key={SECRET_ACCESS_KEY}

Unter Windows wird die Datei unter C:\Users\username\.aws\credentials abgelegt.

4.3. Zugangsberechtigung zur AWS Cloud testen

Mit aws sts get-caller-identity wird überprüft, ob die lokal abgelegten Zugangsdaten korrekt sind. Im Erfolgsfall wird ein JSON mit benutzerspezifischen Werten ausgegeben.

5. Optional: Terraform-Plugin für IntelliJ / VS Code

Das Plugin erweitert den Code Editor um Syntaxunterstützung für Terraform-Dateien (.tf).

Vorbereitung und Big Picture

Wir wollen eine Node.js-Anwendung in einen Docker-Container packen und diesen in die AWS Cloud deployen. Anschließend soll uns die Node.js-Anwendung auf einer öffentlichen IP zur Verfügung stehen. Um den Docker-Container vorab zu erstellen und dessen Betrieb lokal mit Docker zu testen- und so auch die Funktionsweise von Docker und von unserer Node.js-Anwendung kennenzulernen –, wollen wir den Docker-Container zunächst lokal definieren, instanziieren und betreiben.

Neuer Projektordner

Beginnen wir mit der Erstellung eines neuen, leeren Projektordners, der an einer beliebigen Stelle des Dateisystems erstellt werden kann. Alle Pfadangaben dieses Workshops verstehen sich relativ zu diesem Projektordner.

Node.js-Applikation lokal ablegen

Bei der Node.js-Anwendung handelt es sich um eine minimale Express.js-Serveranwendung. Wird die JavaScript-Datei mit dem node-Befehl gestartet, so etabliert sie einen Websocket auf Port 8181 und reagiert dort auf eingehende Requests. Die JavaScript-Datei ist eigenständig lauffähig, da sie alle erforderlichen Bibliotheken mitliefert (primär Express.js), was die verhältnismäßig hohe Dateigröße erklärt. Die Datei kann hier heruntergeladen werden und und muss im Unterverzeichnis application/js des Projektordners abgelegt werden.

Dockerfile für den Betrieb der Node.js-Applikation

Das Dockerfile für den Betrieb unserer Node.js-Anwendung legen wir in unserem Projektordner unter ./Dockerfile-Node ab. Es basiert auf dem Docker-Image für Node 14.x. Beim Bauen des Docker-Images wird der gesamte Unterordner application/js in den Container kopiert und der Container-Port 8181 exponiert. Danach wird die Anwendung mit dem Node-Befehl ./Dockerfile-Node gestartet, sodass sie sich auf den Containerport 8181 etabliert und dort auf eingehende Requests wartet.

Docker Container mit Node.js-Anwendung instanziieren und lokal starten

Aus dem Dockerfile können wir nun ein lokales Docker Image erstellen:

% docker build -f 'Dockerfile-Node' --tag express-js-app:14.18

Daraus können wir dann einen neuen Container instanziieren und starten. Beim erfolgreichen Durchlauf wird der Container im Hintergrund gestartet und dessen Container-ID auf der Kommandozeile ausgegeben:

% docker run --detach --publish 5555:8181 --tty express-js-app:14.18
93e62c5bbb6e6a5339dd8f4ca88c61e042c87c1d52d779c61e2fa4879fa9d865

Der interne Container-Port 8181 wurde im letzten Befehl durch die Angabe der Option --publish auf den Port 5555 unseres Host-Betriebssystems gemappt. Nachdem der Container gestartet wurde, steht die Node.js-Anwendung nun also auf Port 5555 unseres lokalen Hostbetriebssystems zur Verfügung. Mit einem cURL können wir zwei Routen der Anwendung requesten.

% curl http://localhost:5555/
Hello World. This is the result from the base route. Port 8181
% curl http://localhost:5555/user
[{"name":"John Smith","username":"jsmith"},{"name":"Jane Williams","username":"jwilliams"},{"name":"Robert Brown","username":"rbrown"}]

Alternativ können wir die beiden URLs auch im Browser testen:

Glückwunsch!
Wir haben den Docker-Container und die darin betriebene Node.js-Anwendung nun erfolgreich lokal gebaut und gestartet und haben somit die Express.js-Anwendung auf der lokalen Hostmaschine unter dem Port 5555 verfügbar gemacht.Das erste große Ziel unseres Workshops ist es nun, diesen Docker-Container auf den AWS-Server zu pushen und die Node.js-Anwendung so auf einer öffentlichen IP unter Port 5555 verfügbar zu machen.

Neue Terraform-Konfiguration

Eine Terraform-Konfiguration bezeichnet einen Satz an Terraform-Dateien, mit denen eine Cloud-Infrastruktur beschrieben wird. Die von Terraform verwendete Syntax ist dabei deklarativ und beschreibt den gewünschten Endzustand unserer Cloud-Infrastruktur. Das bedeutet, dass sie keine Schritt für Schritt Anweisungen beinhaltet.

Alle Terraform-Dateien haben die Dateinamenerweiterung .tf. Beim Ausführen eines Terraform-Befehls werden alle Terraform-Dateien im aktuellen Verzeichnis eingelesen. Somit ist die Anzahl und die Benamung aller Terraform-Dateien gänzlich dem Entwickler überlassen.

Wir wollen unsere Terraform-Konfiguration in dem Unterordner /terraform/ unseres Projektordners ablegen. Alle kommenden Terraform-Befehle müssen somit auch aus diesem Unterverzeichnis heraus aufgerufen werden.

Deklaration des Cloud Providers

Wir beginnen mit der Deklaration des Cloud Providers und definieren hier aws als Provider. Eine obligatorische Angabe für diesen Provider ist die Region, die wir auf eu-central-1 festlegen. Somit verwenden wir AWS-Server mit Standort Frankfurt am Main.

provider "aws" {
    region = "eu-central-1"
}

Mit Hilfe des Befehls terraform init werden alle von Terraform benötigten Pakete für diesen Provider geladen. Diesen Befehl führen wir nun einmalig aus.

Terraform hat daraufhin die Lockdatei .terraform.lock.hcl angelegt, mit deren Hilfe Änderungen in der Installation der von Terraform benötigten Pakete nachverfolgt werden. Die Pakete selbst hat Terraform im Unterverzeichnis .terraform abgelegt.

Anschließend können wir unsere neue Cloud Infrastruktur mit dem Befehl terraform apply erstellen.

Damit wird unsere aktuelle Terraform-Konfiguration auf den AWS-Server angewendet. Da wir bisher aber noch gar keine Ressourcen deklariert haben, wurden in unserer AWS-Cloud auch noch keine Ressourcen erstellt. Als Ressource wird jedes Objekt einer Cloud-Infrastruktur bezeichnet. In den folgenden fünf Schritten werden wir verschiedene Ressourcen in diversen AWS-Services deklarieren und anlegen.

Terraform hat nun bereits das lokale Statefile terraform.tfstate erstellt, in der der Status der zuletzt angewandte Konfiguration festgehalten wurde. Das Statefile fungiert als Source of Truth und ermöglicht Terraform den Abgleich von Änderungen an der aktuellen Konfiguration.

Deklaration des ECR Repositories

Mit dem AWS-Service ECR (Elastic Container Registry) können Docker-Container-Images auf den AWS-Server gepusht und verwaltet werden.

Um unseren Node.js-Container deployen zu können, deklarieren wir nun ein neues ECR Repository. Dieses bleibt vorerst leer – wir können unseren Docker-Container zu einem späteren Zeitpunkt dort hineinpushen. Nun muss die Datei ./terraform/ecr_repository_node.tf mit folgendem Inhalt erstellt werden:

resource "aws_ecr_repository" "workshop_ecr_repository_node" {
    name = "workshop_ecr_repository_node"
    force_delete = true
}

Für das neue ECR Repository wird die ID und der Name „workshop_ecr_repository_node“ vergeben. Das force_delete Flag erlaubt Terraform das Löschen des Repositories, selbst wenn sich darin noch Container Images befinden. Die Änderung wenden wir nun mit terraform apply an. Terraform zeigt uns nun die geplanten Änderungen an der Konfiguration als Diff. Die Durchführung von Änderungen muss immer explizit durch die Eingabe von „yes“ bestätigt werden.

Nach dem Durchlauf wird das neu erstellte Container-Repository workshop_ecr_repository_node in der AWS-Weboberfläche unter ECR > Repositories angezeigt:

Klicken wir auf das Repository in der Web-Oberfläche, so kann man über den Button „View Push Commands“ die erforderlichen CLI-Befehle einsehen, mit denen ein Docker Container-Image lokal gebaut und in dieses ECR Repository gepusht werden kann.

Diese Befehle führen wir jetzt aber nicht manuell aus, sondern wir verbauen sie in die zuvor angelegte Terraform-Deklaration für unser ECR Repository. Durch die Verwendung der Variable ${aws_ecr_repository.workshop_ecr_repository_node.repository_url} können wir dynamisch die URL des ECR Repositories einsetzen, deren Wert erst nach dessen Erstellen zur Verfügung steht:

resource "aws_ecr_repository" "workshop_ecr_repository_node" {
    name = "workshop_ecr_repository_node"
    force_delete = true
    // login to local Docker registry
    provisioner "local-exec" {
        command = "aws ecr get-login-password --region eu-central-1 | docker login --username AWS --password-stdin ${aws_ecr_repository.workshop_ecr_repository_node.repository_url}"
        interpreter = ["bash", "-c"]
    }
    // build local Docker Image from Node.js-Dockerfile
    provisioner "local-exec" {
        command = "docker build -t workshop_ecr_repository_node -f ${path.module}/../Dockerfile-Node ${path.module}/../"
        interpreter = ["bash", "-c"]
    }
    // tag Docker Image
    provisioner "local-exec" {
        command = "docker tag workshop_ecr_repository_node:latest ${aws_ecr_repository.workshop_ecr_repository_node.repository_url}:latest"
        interpreter = ["bash", "-c"]
    }
    // push Docker Image to ECR
    provisioner "local-exec" {
        command = "docker push ${aws_ecr_repository.workshop_ecr_repository_node.repository_url}:latest"
        interpreter = ["bash", "-c"]
    }
}

Da dieser lokal ausgeführten Befehl lediglich bei der Provisionierung, also dem initialen Setup des Repositories, ausgeführt wird, müssen wir unsere bisher erstellte Terraform-Konfiguration mit terraform destroy erst explizit zerstören. Beide Befehle müssen natürlich durch Anwendung von „yes“ bestätigt werden.

% terraform destroy
% terraform apply

Die Provisionierung dauert nun deutlich länger. Anschließend können wir unser neu erstelltes Container-Image in unserem ECR Repository in der Weboberfläche sehen. Unsere Node.js-Anwendung ist damit in einem Container Image abgelegt und in der ECR registriert.

Deklaration des ECS Services

Der AWS-Service ECS (Elastic Container Service) ist ein Service zur Orchestrierung von Containern. Mit ihm können Container aus registrierten Container-Images instanziiert und gestartet werden.

Der ECS besteht aus drei Komponenten: Cluster, Services und Tasks:

  • Tasks beschreiben, welche Container wie gestartet werden sollen. Für einen Task werden beispielsweise das Port Mapping und das Container-Image für einen Container angegeben.
  • Ein Service startet und betreibt einen oder mehrere Tasks und startet diese neu oder beendet sie, sofern erforderlich.
  • Ein Cluster ist eine logische Gruppierung von Services und Tasks.

Im Folgenden erstellen wir jede der drei genannten Komponenten für den ECS:

Deklaration eines ECS Clusters

Wir fügen unter ./terraform/ecs_cluster.tf den neuen Service-Cluster mit der ID und dem Namen „workshop_ecs_cluster“ hinzu. Weitere Angaben sind hierfür nicht erforderlich.

resource "aws_ecs_cluster" "workshop_ecs_cluster" {
    name = "workshop_ecs_cluster"
}

Die Erweiterung unserer Terraform-Konfiguration wenden wir nun mit terraform apply umgehend an. Anschließend wird unser neu erstellter ECS Cluster im ECS Service angezeigt:

Deklaration einer ECS Task Definition

Wir definieren nun unseren ersten Task und geben hierfür unser zuvor erstelltes ECR Repository an. Hierfür arbeiten wir mit der bereits zuvor verwendeten Variable zur Auflösung der ECR Repository URL.

Angegeben wird unter anderem auch die CPU– u. RAM-Usage sowie das gewünschte Port Mapping. Der Host-Port 5555 der Servermaschine soll also auf den Container-Port 8181 umgemappt werden – äquivalent zu unserem lokal durchgeführten Lauf des Docker-Containers.

resource "aws_ecs_task_definition" "workshop_ecs_task" {
    family = "workshop_ecs_task"
    container_definitions = <<EOF
    [
        {
            "name": "node",
            "cpu": 128,
            "memory": 128,
            "image": "${aws_ecr_repository.workshop_ecr_repository_node.repository_url}",
            "essential": true,
            "portMappings": [
                {
                    "hostPort": 5555,
                    "protocol": "tcp",
                    "containerPort": 8181
                }
            ]
        }
    ]
EOF
}

Wir wenden diese neue Konfiguration nun wieder mit terraform apply an. Unsere neue Task-Definition wird anschließend unter ECS > Aufgabendefinitionen angezeigt:

Deklaration eines ECS Services

Nun erstellen wir den ECS Service und geben hierfür die ID des zuvor deklarierten ECS Cluster und der zuvor deklarierten ECS Taskdefinition an.

resource "aws_ecs_service" "workshop_ecs_service" {
    name            = "workshop_ecs_service"                          # Naming our first service
    cluster         = aws_ecs_cluster.workshop_ecs_cluster.id         # Referencing our created Cluster
    task_definition = aws_ecs_task_definition.workshop_ecs_task.arn   # Referencing the task our service will spin up
    desired_count   = 1                                               # number of task definitions to run
}

Auch diese Erweiterung an unserer Terraform-Konfiguration wenden wir unmittelbar mit terraform apply an.

Nach Abschluß des Deployments wurde der neue ECS Service erstellt und in unserem Workshop-Cluster „workshop_ecs_cluster“ angelegt. Beim Einsehen des Clusters wird unser neuer Service nun in der Weboberfläche angezeigt:

Deklaration der VPC Netzwerk-Sicherheitsgruppe

Der AWS Service VPC (Virtual Private Cloud) verwaltet Netzwerk-Sicherheitsgruppen für die Steuerung der Zugangs- und Abgangskontrolle zu unserer Servermaschine. Damit kann der Zugang und Abgang zu unserer Servermaschine kontrolliert bzw. eingeschränkt werden.

Wir fügen eine neue Netzwerk-Sicherheitsgruppe unter ./terraform/ecs_security_group.tf hinzu und öffnen ausschließlich den eingehenden Port 5555 auf unserer Host-Maschine und dessen Weitergabe an unseren ECS Service:

resource "aws_security_group" "workshop_ecs_security_group" {
    name = "workshop_ecs_security_group"
    ingress {
        from_port = 5555 # allow traffic in from port 5555
        to_port = 5555
        protocol = "tcp" # allow ingoing tcp protocol
        cidr_blocks = ["0.0.0.0/0"] # allow traffic in from all sources
    }
    egress {
        from_port = 0 # allow traffic out on all ports
        to_port = 0
        protocol = "-1" # allow any outgoing protocol
        cidr_blocks = ["0.0.0.0/0"] # allow traffic out from all sources
    }
}

Nach dem Anwenden der Konfiguration mit terraform apply ist die neue Sicherheitsgruppe erstellt und wird in der Weboberfläche unter VPC > Sicherheit > Sicherheitsgruppen angezeigt:


Deklaration des IAM Instance Profiles und der IAM Role

Im AWS-Service IAM (Identity and Access Management) muss für den Zugriff auf unsere Servermaschine ein Instanzprofil und eine Rolle erstellt werden. Hierfür verwenden wir die Standardimplementierung und legen die beiden Dateien ./terraform/iam_instance_profile.tf sowie ./terraform/iam_role.tf an:

resource "aws_iam_instance_profile" "workshop_iam_instance_profile" {
    name = "workshop_iam_instance_profile"
    path = "/"
    role = aws_iam_role.workshop_iam_role.name
}
resource "aws_iam_role" "workshop_iam_role" {
    name = "workshop_iam_role"
    path = "/"
    assume_role_policy = data.aws_iam_policy_document.workshop_iam_policy_document.json
}
data "aws_iam_policy_document" "workshop_iam_policy_document" {
    statement {
        actions = ["sts:AssumeRole"]
        principals {
            type = "Service"
            identifiers = ["ec2.amazonaws.com"]
        }
    }
}
resource "aws_iam_role_policy_attachment" "workshop_iam_role_policy_attachment" {
    role = aws_iam_role.workshop_iam_role.name
    policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
}

Auch diese Erweiterung an unserer Konfiguration wenden wir anschließend mit terraform apply an. Anschließend wird die neu erstellte IAM Role in der Weboberfläche unter IAM > Zugriffsverwaltung > Rollen angezeigt:



Das erstellte IAM Instance Profile kann nicht über die Web-Oberfläche, sondern lediglich über Terraform bzw. über den AWS CLI-Client verwaltet werden.

Deklaration der EC2-Instanz

Nun haben wir alle Services beisammen, die wir zum Betrieb unseres Containers benötigen. Da wir die bisher erstellten Ressourcen auf einer Servermaschine betreiben wollen, erstellen wir als letzte Ressource eine EC2-Instanz unter Verwendung des AWS-Services EC2 (Elastic Compute Cloud).

Für unsere neue EC2-Instanz wird die zuvor erstellte Sicherheitsgruppe und das Instanzprofil angegeben. Zudem definieren die Felder ami<code> und </code>instance_type, welche Art von Servermaschine eingesetzt werden soll. Eine Übersicht aller für diese Angaben möglichen Werte gibt es unter EC2 > Abbilder > AMI Katalog.

Wir erstellen unsere vorerst letzte Ressource unter ./terraform/ec2_instance.tf und erstellen damit eine neue AWS EC2 Instanz:

resource "aws_instance" "workshop_ec2_instance" {
    ami                  = "ami-509a053f"
    instance_type        = "t2.micro"
    security_groups      = [aws_security_group.workshop_ecs_security_group.name]
    iam_instance_profile = aws_iam_instance_profile.workshop_iam_instance_profile.name
    user_data            = &lt;&lt;EOF
#!/bin/bash
echo ECS_CLUSTER=${aws_ecs_cluster.workshop_ecs_cluster.name} &gt; /etc/ecs/ecs.config
EOF
    tags = {
        Name = "workshop_ec2_instance"
    }
}

Im Feld user_data wird ein Shell-Skript definiert, das beim Starten der Maschine ausgeführt wird. Hier ist es für den erfolgreichen Betrieb des ECS Services erforderlich, den Namen unseres ECS Clusters in die vom ECS verwendete Konfigurationsdatei /etc/ecs/ecs.config auf der Servermaschine zu schreiben.

Unsere neue EC2-Instanz wird anschließend in der Weboberfläche unter EC2 > Instances > Instances angezeigt:

Die für unsere EC2-Instanz zugewiesene public IP kann in der Detailansicht der EC2-Instanz ausgelesen werden:

Achtung!
Nach dem Abschluss des Befehls terraform apply kann es bis zu ca. 1-3 Minuten dauern bis der Container unter dem Port 5555 und über die neue public IP tatsächlich erreichbar ist. Bis dahin werden Requests unter Port 5555 auf die public IP mit „Connection refused“ quittiert.

Mit einem cURL curl -v 52.59.78.142:5555/user können wir nun die Node.js-Express-Anwendung in unserem Container erreichen. Alternativ können wir den selben Request auch im Browser testen:

Das Schweizer Messer für die Cloud-Infrastruktur

Mit Terraform kann die gesamte Cloud-Infrastruktur unserer Anwendungen über das CLI deklariert und verwaltet werden – so müssen die einzelnen Komponenten nicht manuell über die grafische Weboberfläche angelegt oder verwaltet werden. Terraform funktioniert mit allen großen Cloud Providern – darunter befinden sich neben AWS auch Microsoft Azure, Google Cloud, Kubernetes und Oracle Cloud Infrastructure.

Ich freue mich, wenn ich in unserem Hands-on einen schnellen Einstieg in die praktische Arbeit mit Terraform und dem Cloud Provider AWS geben konnte. Im nächsten Teil dieser Blogserie deployen wir zwei weitere Application Container und lernen dabei weitere praktische Features von Terraform kennen.

Für Rückfragen stehe ich gerne unter christopher.stock@mayflower.de zur Verfügung.

Avatar-Foto

Von Christopher Stock

Christopher ist als Senior Developer bei der Mayflower GmbH tätig und entwickelt dort seit über neun Jahren hochwertige Web-, Desktop- und Mobile-Applikationen, unter anderem mit Java, Swift, TypeScript, C# und PHP. Seine Freizeit verbringt er neben dem Programmieren und Designen gerne mit Fitness, Verreisen, seiner Freundin und der Umsetzung kreativer Ideen.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert