Konsistente, reproduzierbare Entwicklungsserver mit Vagrant: Infrastruktur als Code für Einsteiger

Konsistente, reproduzierbare Entwicklungsserver mit Vagrant: Infrastruktur als Code für Einsteiger

Die händische Installation und Einrichtung einer Umgebung ist sowohl aufwändig. Man muss händisch alle Abhängigkeiten installieren sowie einrichten. Im besten Falle geschieht dies auf einer Virtuellen Maschine (VM). Andernfalls hat man – gerade beim Arbeiten mit verschiedenen Projekten – zusätzlich noch potenzielle Abhängigkeitsprobleme. Als Konsequenz sind die Umgebungen nicht bei allen Entwicklern bzw. auf allen Testservern identisch. Als Folge kann es zu Problemen kommen, im schlimmsten Falle sogar produktiv. Auch die anschließende Fehlersuche ist möglicherweise zeitintensiv.

Im nächsten Abschnitt wollen wir uns daher zunächst mit dem Konzept dahinter beschäftigen, bevor es an die Umsetzung mit Vagrant geht.

Die Lösung: Infrastruktur als Code

Durch die Ausführung von Programmcode entsteht eine Anwendung. Mit Infrastructure as Code wird dieses Konzept auch auf Server und VMs übertragen: Verschiedene Anweisungen legen fest, wie die Zielumgebung aussehen soll. Dies fängt beim Betriebssystem an, geht über die installierten Pakete und Bibliotheken bis hin zu spezifischen Konfigurationsänderungen. Somit entfällt das händische installieren und konfigurieren.

Konkret bietet dies folgende Vorteile:

  1. Zeitersparnis: Das händische Installieren und Einrichten entfällt. Auch wenn dies anfangs schneller erscheint als zu automatisieren, profitiert man mit steigender Wiederholungszahl. Beispielsweise beim neu Installieren oder bei Upgrades.
  2. Flexibilität und Schnelligkeit: Wird ein weiteres Testsystem benötigt, lässt sich dies sehr schnell bereitstellen. Sind etwa Updates nötig, kann man diese kurzfristig auf einer eigenen Testinstanz ausprobieren. Zeitgleich verhunzt man seine produktive Umgebung nicht, an der oft parallel gearbeitet wird.
  3. Konsistenz und Fehlerreduktion: Bei korrekter Umsetzung sind die Systeme konsistent konfiguriert. Dies kann bestimmte Fehler vermeiden, bevor sie (produktiv) auffallen.
  4. Nachvollziehbarkeit: Da jegliche Konfiguration als Anweisung vorliegt, lassen sich diese Versionieren. Damit ist transparent, wer wann was geändert hat. Bei Problemen ist nachvollziehbarer, wodurch diese entstanden sein können. Im Gegensatz zum händischen Arbeiten auf den Servern, am besten noch von Kollegen.

Warum Vagrant?

Über Skripte jeglicher Form – sei es mittels Ansible oder schlichtes Bash – könnte bereits ein großer Teil dieser Anforderungen abgebildet werden. Allerdings wird hierbei immer eine vorliegende VM vorausgesetzt. Das Installieren und Konfigurieren der VM müsste nach wie vor händisch erfolgen. Gerade bei komplexeren Umgebungen mit mehreren Servern ist dies durchaus ein ernsthafter Nachteil. Vagrant setzt hier an und verbindet sich mit einem Hypervisor, etwa VirtualBox. Entsprechend der Anweisungen wird eine VM erstellt sowie entsprechend konfiguriert.

Eine Alternative hierzu ist Terraform. Es eignet sich für den gesamten Lebenszyklus des Servers und bezieht auch weitere Infrastrukturkomponenten mit ein, etwa Switche. Für den produktiven Einsatz ist Terraform somit besser geeignet. Doch auf der anderen Seite für kleinere Testumgebungen etwas überdimensioniert. Hierfür ist Vagrant eine schlankere und zudem vielseitigere Alternative. Da auch Windows unterstützt wird, kann man erste Tests schon auf der lokalen Windows-Arbeitsstation durchführen. Natürlich immer vorausgesetzt, man besitzt einen physischen Computer mit ausreichenden Ressourcen für den jeweiligen Anwendungszweck.

Voraussetzungen für Vagrant

Um Vagrant nutzen zu können, benötigen wir folgende drei Dinge:

  1. Einen PC/Laptop mit ausreichenden Ressourcen für den jeweiligen Einsatzzweck. Vor allem RAM ist wichtig. 8 GB sollten es mindestens sein, besser sind 16/32 GB oder mehr.
    Außerdem müssen die Virtualisierungsfunktionen des Prozessors im BIOS/UEFI aktiviert sein. Ansonsten erscheint eine Fehlermeldung beim Erstellen von VMs. Da sich die Hersteller und Modelle diesbezüglich unterscheiden, ist eine konkrete Anleitung hier nicht möglich.
  2. Einen Hypervisor, es werden verschiedene von Vagrant unterstützt. Wer seitens des Unternehmens gezwungen wird, beispielsweise VMWare zu verwenden, kann dies mit dem entsprechenden Provider tun. Allerdings muss die gewünschte Box den Hypervisor auch unterstützen (weitere Infos siehe Die Box als Basis). Für alle anderen ist die quelloffene Lösung VirtualBox zu empfehlen. Sie ist der Standard und wird auch im folgenden Artikel verwendet.
  3. Vagrant selbst

Im Hinterkopf sollte behalten werden, dass wir mit Vagrant ein Stück weit abstrakt sein möchten. Wir sind also eben nicht an den Hypervisor gebunden, wie dies bei entsprechenden Produkten von z.B. VMWare der Fall wäre. Somit kann ein Vagrantfile von VMWare auch mit wenigen Anpassungen in einer VirtualBox-Umgebung lauffähig gemacht werden und umgekehrt.

Erstellen eines ersten Vagrant-Projektes

Die „Box“ als Basis

Zum Einstieg werden wir uns erst einmal mit Vagrant vertraut machen. Die Basis bildet immer eine sogenannte Box. Hierbei handelt es sich um das VM-Abbild eines normalen Betriebssystemes, wie beispielsweise Ubuntu 20 LTS. Vereinfacht gesagt wurde das offizielle Installationsmedium um ein paar Anpassungen ergänzt, welche für die Integration mit Vagrant und dem Hypervisor notwendig sind. Ein Beispiel ist die Installation der Gasterweiterung für VirtualBox. Sie erhöhen die Performance und bieten zusätzliche Funktionen, wie etwa die Freigabe von Ordnern für die VM. Auch das weiterreichen von USB-Geräten und andere komplexere Dinge sind möglich.

Der Vagrant-Katalog bietet eine große Auswahl an gängiger Basis-Boxen. Teils werden diese sogar offiziell vom OS-Hersteller bereitgestellt, wie etwa bei Ubuntu. Zu beachten ist jedoch, dass nicht jede Box jeden Hypervisor unterstützt! Die mittlere Spalte der Suchergebnisse zeigt diese an. Wer auf einen bestimmten Hypervisor angewiesen ist, kann zudem unter dem Suchfeld filtern:

Die offizielle Ubuntu 20 LTS Box lässt sich somit unter VMWare nicht nutzen, mit VirtualBox jedoch schon. Da es sich bei VirtualBox um eine offene und freie Software handelt, wird sie von vielen bevorzugt. Teilweise werden andere Hypervisors von der Community gepflegt. Hier sollte man jedoch Vorsicht walten lassen: Jeder kann sich einen Account erstellen und eine Box bereitstellen. Ich würde daher die offiziellen Boxen von Hashicorp (dem Unternehmen hinter Vagrant) oder dem OS-Hersteller nach Möglichkeit vorziehen.

Entscheiden wir uns wie oben zu sehen für die offizielle Ubuntu 20.04 LTS Box, heißt diese ubuntu/focal64.

Erstellen des eigentlichen Vagrant-Projektes

Nun geht es in die Praxis: Ein sogenanntes Vagrantfile enthält alle Anweisungen, die Vagrant zur Erzeugung einer VM benötigt. In der Regel wird pro Projekt ein Ordner angelegt. Darin befindet sich dann die Datei Vagrantfile, die per Konvention immer gleich heißt. Möchte man sich eine solche Beispieldatei mit ein paar Kommentaren und Hinweisen (Englisch) erstellen lassen, kann man im angelegten Projektordner folgenden Befehl ausführen:

vagrant init ubuntu/focal64

ubuntu/focal64 ist die Box, welche als Basis dienen soll. Man kann alternativ jeden Boxnamen aus dem oben verlinkten Katalog angeben. Beispielsweise centos/7 für CentOS 7.

Beispiel eines per „vagrant init“ erzeugten Vagrantfiles

Ich habe mir stattdessen ein Vorlagen-Projekt erstellt, das ich als Grundlage nutze. In der generiertenfehlen einige Dinge, die ich im täglichen Einsatz öfter benötige. Hierzu fällt beispielsweise die Dimensionierung der VM mit Prozessorkernen und Arbeitsspeicher. Außerdem sind die ausführlichen Kommentare dort eher störend. Für den Anfang habe ich mein Vorlagen-Projekt auf das wesentliche reduziert:

# -*- mode: ruby -*-
# vi: set ft=ruby :

$name = 'vagrant-apache-webserver'
$memory = 1024
$cpu_cores = 2

Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/focal64"
  
  config.vm.define $name
  config.vm.hostname = $name
  
  config.vm.provider :virtualbox do |vm|
    vm.name = $name
	vm.memory = $memory
	vm.cpus = $cpu_cores
  end
  
  config.vm.provision "shell", inline: 'echo "Im $(whoami) on $(hostname) with Ubuntu $(lsb_release -rs)"'
end

Die ersten beiden Zeilen sind Kommentare. Sie dienen für das entsprechende Highlighting in Texteditoren, da ein Vagrantfile üblicherweise keine Dateiendung besitzt. Dennoch ist ein Vagrantfile immer gültiger Ruby-Code.

$name, $memory und $cpu_cores sind Variablen, die weiter unten entsprechend gesetzt werden: config.vm.define bezieht sich auf den Name der VM, wie er im Hypervisor erscheint. Aus Gründen der Einfachheit und Konsistenz nutze ich diesen auch gleich als Hostname, sodass er keine Leer- oder Sonderzeichen enthalten darf. RAM und CPU-Kerne müssen nicht angegeben werden. Jede Box besitzt Standardwerte, die für schwächere Systeme oft recht niedrig gehalten werden. Bei Ubuntu beispielsweise 1 CPU-Kern mit 1GB RAM. Oft wird man dies aber an die Anforderungen anpassen wollen, gerade 1 CPU-Kern ist doch recht wenig.

config.vm.box legt die Basis-Box fest. In diesem Falle die 20 LTS Version von Ubuntu.

config.vm.provision ist ein simples Beispiel für die Provisionierung, also Konfiguration der VM. Hier wird ein simpler echo-Befehl mit Benutzername, Hostname und Ubuntu-Version ausgegeben. In der Praxis wird man dies natürlich mit Konfigurationsbefehlen ersetzen. Beispielsweise könnte ein Webserver installiert und dessen Konfiguration entsprechend angepasst werden.

Ein Test: Die erste Vagrant-VM

Damit haben wir bereits alles, was für eine erste VM notwendig ist. Sie erfüllt zwar noch keinen Zweck, aber lässt automatisiert einen Befehl ausgeben – eine Art „Hello World“ für Vagrant. Um die Maschine zu erstellen, führen wir einfach vagrant up im Verzeichnis mit dem Vagrantfile aus. Der erste Start kann je nach Systemgeschwindigkeit und Internetverbindung etwas dauern: Vagrant lädt zunächst die gewünschte Basis-Box herunter. Im Falle von Ubuntu 20 LTS sind das knapp 500MB. Im Anschluss erstellt VirtualBox die VM und führt schlussendlich unseren Konsolenbefehl aus, wie das Protokoll zeigt:

==> vagrant-apache-webserver: Running provisioner: shell...
    vagrant-apache-webserver: Running: inline script
    vagrant-apache-webserver: Im root on vagrant-apache-webserver with Ubuntu 20.04

Vagrant speichert heruntergeladene Boxen in ~/.vagrant.d/boxes und nutzt beim nächsten Erzeugen einer VM die dort gespeicherten. Ist die Box lokal vorhanden, wird die obige VM auf einem betagteren i7 975 in ca. 52 Sekunden komplett erstellt.

Ändern der Konfiguration/Neu erstellen

Führen wir vagrant up erneut aus, wird der Befehl schnell wieder beendet: Vagrant prüft zunächst die Basis-Box auf Updates. Da keine vorhanden sind, checkt Vagrant ob die VM existiert. Dies ist der Fall, sodass Vagrant keine weiteren Schritte unternimmt. Soll die VM neu erstellt werden, muss man sie zunächst mit vagrant destroy zerstören. Es erscheint eine Sicherheitsabfrage, die bei unkritischen Tests eher störend ist. Sie lässt sich mit dem -f Schalter deaktivieren: vagrant destroy -f löscht die VM ohne Nachfrage.

Daraus ableitend können wir eine VM mit folgendem Einzeiler neu erstellen und provisionieren:

vagrant destroy -f; vagrant up

Praxisbeispiel: Apache-Webserver

Bisher haben wir uns nur die Funktionsweise von Vagrant angeschaut. In diesem Abschnitt wird es daher Zeit für ein Praxisbeispiel: Auf einer Ubuntu-VM soll ein Apache2-Webserver installiert und entsprechend eingerichtet werden, sodass eine PHP-Anwendung darauf laufen kann. Im Gegensatz zum obigen Vagrantfile sind folgende Erweiterungen nötig:

  1. Apache2 muss installiert werden
  2. Wir benötigen Zugriff auf Port 80/443. Gerade hinsichtlich des Einsatzes mehrere VMs sollte die VM eine eigene IP haben, statt einer bloßen Portweiterleitung.
  3. Gegebenenfalls zusätzliche Konfigurationsanpassungen wie z.B. die Installation von benötigten Apache/PHP-Modulen

Installation und Konfiguration des Apache2-Webservers

Als Beispiel installieren wir Apache mit der neusten in den Repos verfügbaren PHP 7 Version. Zur Demonstration von Konfigurationsanpassungen wird ServerTokens auf Prod gesetzt, sodass die Apache-Version nicht im Header auftaucht. Dafür wird folgender Block ans Ende angefügt, der die Inline-Befehle in einer Shell ausführt:

  config.vm.provision "shell", inline: <<-'SHELL'
    apt-get update && apt-get upgrade -y
    apt-get install -y apache2 php libapache2-mod-php

    sed -i 's/^\(ServerTokens\) [A-Za-z]\+$/\1 Prod/g' /etc/apache2/conf-available/security.conf
    php_mod=$(a2query -m | grep php | awk '{print $1}')
    echo "Enabling php module $php_mod"
    a2enmod $php_mod

    systemctl restart apache2

    echo '<?php phpinfo();' > /var/www/html/i.php
  SHELL

Im Detail aktualisiseren wir die Paketquellen und Pakete. Anschließend wird Apache zusammen mit dem PHP-Modul installiert. Der sed-Befehl setzt ServerTokens Prod in die Konfigurationsdatei ein. Nach dem Aktivieren des PHP-Modules ist ein Neustart des Webservers erforderlich.

Schlussendlich legt das Skript noch eine Datei namens i.php im Webroot des Apache an. Öffnen wir diese Datei im Browser, erscheint nach dem Neu Erstellen der VM die PHP-Infoseite:

Erreichbarkeit der VM im Netzwerk

Mit der Thematik VMs und Netzwerke könnte man problemlos einen eigenen Artikel füllen. An dieser Stelle daher nur die zwei gängigsten Varianten:

Reine Portweiterleitung auf den Host

Hier muss man sich am wenigsten mit dem Netzwerk befassen – es wird schlicht ein Port der VM (z.B. 80) auf einen freien Port des Hosts weitergeleitet. Im einfachsten Falle mappt man 80 in der VM auf 80 des Hosts. Der Webserver ist dann auf Port 80 des Hosts erreichbar. Im Regelfall nutzt man aber einen höheren Port: Zum einen da für Ports unter 1024 root-Rechte benötigt werden. Zum anderen, weil Standardports gerne von anderer Software bereits belegt werden – beispielsweise ein existierender lokal installierter Webserver.

Wir leiten daher Port 80 der VM (=Apache) auf Port 8081 des Hosts. Hierfür ist nur eine Zeile notwendig:

config.vm.network "forwarded_port", guest: 80, host: 8081

Nach dem neu erstellen (destroy/up) können wir localhost:80801 auf dem Host ansteuern. Sowohl auf der Konsole als auch im Browser erhalten wir dort die Standard-Willkommensseite des Apache Webservers:

Dies kann man nach belieben erweitern. Auch eine zweite Direktive ist möglich, die etwa Port 443 für verschlüsselten Datenverkehr auf die VM leitet.

Vorteile dieser Methode: Sie ist einfach und der Dienst ist auch innerhalb des physischen Netzwerkes des Hosts erreichbar. Das kann durchaus von Vorteil sein, wenn man einem Kollegen etwas zeigen möchte. Oder zum testen mit einem anderen Gerät, etwa dem Smartphone im Heimnetz-WLAN. Gerade bei sensibleren Daten möchte man gerade das jedoch eher nicht, sodass ich hier ein privates Netzwerk anbietet.

Feste IP in einem privaten Netzwerk

Der Hybervisor erzeugt hierbei auf dem lokalen PC eine Netzwerkschnittstelle in einem privaten Netzsegment. Darin erhält die VM eine statische IP-Adresse und kann direkt angesprochen werden – aber eben nur vom Host aus. Geräte aus dem Heim- und Firmennetzwerk, in dem sich der Host befindet, erhalten keinen Zugriff.

config.vm.network "private_network", ip: "192.168.60.2"

In diesem Fall fällt auch das NAT weg. Unser Apache-Webserver ist somit direkt über Port 80/443 erreichbar, wie ein Test mittels curl zeigt:

$ curl 192.168.60.2 –head
HTTP/1.1 200 OK
Server: Apache/2.4.41 (Ubuntu)

Gesamtes Vagrantfile

Abschließend steht hier das komplette Vagrantfile zur Verfügung. Es enthält alle Konfigurationsdirektiven, die im Verlauf des Artikels erklärt wurden.

# -*- mode: ruby -*-
# vi: set ft=ruby :

$name = 'vagrant-apache-webserver'
$memory = 1024
$cpu_cores = 2

Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/focal64"

  config.vm.define $name
  config.vm.hostname = $name

  config.vm.provider :virtualbox do |vm|
    vm.name = $name
  vm.memory = $memory
  vm.cpus = $cpu_cores
  end

  config.vm.provision "shell", inline: <<-'SHELL'
    apt-get update && apt-get upgrade -y
    apt-get install -y apache2 php libapache2-mod-php

    sed -i 's/^\(ServerTokens\) [A-Za-z]\+$/\1 Prod/g' /etc/apache2/conf-available/security.conf
    php_mod=$(a2query -m | grep php | awk '{print $1}')
    echo "Enabling php module $php_mod"
    a2enmod $php_mod

    systemctl restart apache2

    echo '<?php phpinfo();' > /var/www/html/i.php
  SHELL

  config.vm.network "forwarded_port", guest: 80, host: 8081
  config.vm.synced_folder 'html', '/var/www/html/'
#  config.vm.network "private_network", ip: "192.168.60.2"
end

Weiterführende Tipps

Wo lege ich Daten ab?

Viele Anwendungen erzeugen verschiedene Daten: Von Logs über temporäre Daten bis hin zu Nutzerdaten. Grundsätzlich gilt: Alles was in der VM selbst gespeichert wird, darf nur temporär sein. Beispielsweise ein Cache-Verzeichnis. Alle persistenten Daten sollten außerhalb der VM liegen. Somit kann die Virtuelle Maschine jederzeit neu erstellt werden, ohne dass diese verloren gehen.

Passend zu unserem Apache-Webserver Szenario, erstellen wir auf dem Host einen Ordner, den wir gerne als Wurzelverzeichnis vom Webserver bereitstellen lassen möchten:

mkdir html
echo '<?php echo "Hello World";' > html/hello.php

Im Vagrantfile müssen wir diesen Ordner dann nur noch entsprechend mounten:

config.vm.synced_folder 'html', '/var/www/html/'

Die Synchronisation funktioniert nun in beide Richtungen. Im Ordner auf dem Host werden die Daten dauerhaft gespeichert. Man kann die VM nun beliebig oft neu erstellen, ohne Datenverlust befürchten zu müssen. Je nach installierter Software muss man dies natürlich anpassen.

Kann –provision als Alternative zum (zeitaufwändigeren) Neu-Erstellen der VM genutzt werden?

Wie oben bereits erklärt, erstellt vagrant up eine bereits vorhandene VM nicht neu. Als Alternative wird das komplette neu Erstellen (destroy + up) empfohlen, da dies für Anfänger am leichsten verständlich ist. Hat man erste Erfahrungen gesammelt, wird dies schnell jedoch etwas zäh: Auch wenn selbst komplexere VMs nach wenigen Minuten erstellt werden, ist dies doch eine große Wartezeit für jeden einzelnen Test.

Möchte man die Provisionierung bei vagrant up erzwingen, kann der Schalter –provision verwendet werden. Dies erstellt die VM nicht neu! Stattdessen wird nur die Konfiguration ab dem Zeitpunkt des Erstellens der VM wiederholt. Im bisherigen Beispiel ist dies lediglich der Bash-Aufruf mit Benutzername/Hostname/OS-Version. Ein Vagrantfile in der Praxis würde hier Software installieren und konfigurieren.

Sinn macht das nur, wenn die Provisionierung darauf ausgelegt ist. Das heißt: Jedes Skript darf keinen Schaden anrichten, wenn es mehrmals läuft. Beispielsweise darf nicht einfach nur etwas an eine Konfigurationsdatei angehangen werden – sonst stünde es bei zweimaliger Ausführung auch zweimal in der Datei. Stattdessen ist zu prüfen, ob die Direktive bereits vorhanden ist.

Am besten ist hier natürlich die Verwendung von Ansible: Das gesamte System ist darauf ausgelegt, den Server in den gewünschten Zustand zu versetzen. Es prüft also den Ist-Zustand, vergleicht diesen mit dem Soll-Zustand und führt nur die notwendigen Schritte aus, wenn beide voneinander abweichen. Gerade dadurch eignet sich Ansible in meinen Augen optimal für die Provisionierung. Die Ansible-Skripte selbst können ebenfalls für die produktiven Systeme genutzt werden.

Leave a Reply