Über unsMediaKontaktImpressum
Kai Weingärtner 28. Januar 2015

Vagrant, Puppet, Docker für Entwickler und Architekten

Eine neue Anwendung erstmals in die Produktion zu übergeben und sie der Welt zu präsentieren, gehört zu den spannendsten Erlebnissen im Entwickleralltag. Nur gesellt sich zu dieser Spannung häufig auch eine Portion Nervenkitzel: Wird die Anwendung in der Produktionsumgebung genauso funktionieren wie im Test? Wurden alle Konfigurationsparameter berücksichtigt und stehen alle notwendigen Dienste zur Verfügung? Spätestens aber wenn beim fünften Release der Anwendung die gleichen Fragen aufkommen wie beim ersten, wird klar: Das sollte auch anders gehen!

Der DevOps-Ansatz möchte diesem Nervenkitzel begegnen und drängt daher darauf, frühzeitig und in Zusammenarbeit von Betrieb und Entwicklung eine Ablaufumgebung zu definieren, die den Anforderungen des Betriebs genügt und sich mit minimalem Aufwand in die Produktion überführen lässt. Eine wichtige Rolle spielen bei diesem Ansatz Werkzeuge, mit denen sich die Bereitstellung von Ablaufumgebungen automatisieren lässt. Diese ermöglichen, einmal definierte Konfigurationen beliebig zu reproduzieren und weiterzuentwickeln. Zu diesen Werkzeugen gehören Vagrant, Puppet und Docker.

Zur DevOps-Idee gehört, das System so schnell wie möglich in eine produktionsnahe Infrastruktur einzubetten und dort regelmäßig und möglichst automatisiert zu prüfen. Was lange Zeit Aufgabe des Betriebs war und weit ans Ende des Entwicklungsprozesses gestellt wurde, rückt nun in den Fokus der Entwicklung und erfordert von Entwicklern und Architekten neue Kompetenzen.

Das Zielsystem lokal bereitstellen

Gemäß DevOps-Ansatz spricht vieles dafür, bereits auf dem Entwickler-Arbeitsplatz eine einheitliche und realistische Ablaufumgebung bereitzustellen. Hier kommen virtuelle Maschinen ins Spiel. Durch eine Virtual Machine (VM) lässt sich ein Image des Zielsystems mit vorkonfigurierter Ablaufumgebung auf dem Entwickler-Arbeitsplatz einfach starten und isoliert betreiben. In diesem Image läuft dann all das, was das Zielsystem ausmacht: Das korrekte Betriebssystem, vorkonfigurierte Basis-Softwarepakete und der Server zum Betrieb der Anwendung. Da die Entwickler so frühzeitig die echte Ablaufumgebung kennenlernen, reduzieren sich die Migrationsaufwände zur Überführung der Anwendung in den Betrieb.

Aber wie kommt man zu einem Image? Ein verbreiteter Ansatz ist die Erstellung eines Golden Image, einer vollständig vorkonfigurierten Ablaufumgebung, welche als Kopiervorlage weitergereicht wird. Jedoch haben Golden Images den Nachteil, dass sich Änderungen am Image schlecht handhaben lassen. Jede Änderung erzeugt ein neues, komplettes Image das verteilt werden muss. Eine weitere Schwierigkeit: Da das Image im Binärformat vorliegt, gibt es keine einfache Möglichkeit, die Änderungen nachvollziehen zu können. Daher ist das Golden Image auch für die Versionierung mit der Anwendung zu unhandlich. Das Tool Vagrant bietet da eine Alternative.

Vagrant: Umgebungs-Setup als Einzeiler

Mit Vagrant lässt sich die Weiterentwicklung und Weitergabe der Anwendungsumgebung deutlich angenehmer handhaben. Der typische Ablauf sieht wie folgt aus: Der Entwickler checkt ein Vagrant Projekt aus, das im einfachsten Fall nur aus der Datei „Vagrantfile“ besteht, und führt den Befehl vagrant up aus. Ein sehr einfaches Vagrantfile sieht so aus:

VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "puppetlabs/centos-6.5-64-puppet”
end

Beim Aufruf von vagrant up lädt Vagrant das angegebene Basis-Image herunter und startet es. In diesem Fall handelt es sich um ein CentOS mit installiertem Puppet, das im zentralen Repository Atlas [1] liegt. Mit vagrant ssh kann man sich anschließend in der VM anmelden. So lässt sich beispielsweise das Betriebssystem der Produktionsumgebung mit wenig Aufwand lokal aufsetzen.

Abgesehen von der Tatsache, dass statt eines Images ein Vagrantfile verwendet wird, hat sich am Ergebnis nicht viel geändert. Interessanter wird es hingegen, wenn dieses Basis-Image kombiniert wird mit der Provisionierung, also der automatisierten Bereitstellung der Ablaufumgebung über ein Konfigurationsmanagementwerkzeug. Die Idee hinter der Kombination von Basis-Image und Provisionierung ist die folgende: Das Basis-Image wird in allen Umgebungen als Ausgangspunkt vorausgesetzt und jegliche anwendungsspezifischen Installationen und Konfigurationen werden durch eine abstrakte Systemdefinition beschrieben. Diese kann dann in den folgenden Umgebungen wiederverwendet werden (s. Abb.1).

Im nächsten Vagrantfile wird das Basis-Image nach dem Herunterladen mit Puppet provisioniert und der Zugriff auf dem Host-System beispielhaft unter 192.168.33.10 ermöglicht. Weiterhin ist dafür nur der Aufruf von vagrant up erforderlich.

VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "puppetlabs/centos-6.5-64-puppet”
  config.vm.provision "puppet" do |puppet|
    puppet.manifest_file  = "site.pp"
    puppet.module_path    = "puppet/modules"
  end
config.vm.network "private_network", ip: "192.168.33.10"
end

Durch die Kombination aus einheitlichem, stabilem Basis-Image und Provisionierung für die anwendungsspezifische Konfiguration, lässt sich mit wenig Aufwand eine Systemumgebung bereitstellen. Im nächsten Schritt wird gezeigt, wie die Provisionierung mit Puppet iterativ umgesetzt werden kann.

Konfigurationsmanagement mit Puppet

Puppet ersetzt die typischerweise manuellen Schritte zum Aufsetzen einer Umgebung durch einen vollautomatisierten Ablauf. Das beschleunigt nicht nur das Setup, sondern eliminiert auch manuelle Eingriffe als Fehlerquelle. Der einmal automatisierte Ablauf kann jederzeit identisch reproduziert werden.
Dieses Ergebnis lässt sich bis zu einem gewissen Grad auch mit selbst geschriebenen Skripten erreichen. Jedoch bieten Konfigurationsmanagementwerkzeuge noch einige weitere Vorteile:

  • Der gewünschte Systemzustand kann deklarativ beschrieben und von Details der Umsetzung abstrahiert werden. Zum Beispiel kann beschrieben werden, dass ein Softwarepaket installiert sein muss, ohne die betriebssystemspezifische Vorgehensweise beschreiben zu müssen.
  • Konfigurationsläufe sind idempotent, eine mehrfache Ausführung führt also immer zum gleichen Ergebnis.
  • Durch Konzepte zur Modularisierung von Konfigurationsaspekten können Systeme aus wenigen höherwertigen Einheiten zusammengesetzt werden. So können alle Webserver einer Farm mit einem einmal definierten Webserver-Profil identisch provisioniert werden. Oft existieren bereits Module, auf denen aufgesetzt werden kann.
  • Es existieren Lösungen zur Trennung von server- oder umgebungsspezifischen Konfigurationswerten und zur Berücksichtigung von Systemspezifika wie IP-Adresse oder Hostname. Damit lassen sich einmal erstellte Definitionen leicht auf anderen Umgebungen wiederverwenden.

In Puppet wird ein System durch Ressourcen und deren gewünschten Zuständen beschrieben. Ressourcen könnten Softwarepakete, Services oder Dateien sein. Über den Ressource Abstraction Layer (RAL) von Puppet können diese über Betriebssystemgrenzen hinweg einheitlich definiert werden. Systemdefinitionen werden in Manifest-Dateien beschrieben.

Das oben genannte Manifest site.pp könnte etwa so aussehen:

package { "nginx":
  ensure  => present,
}
service { "nginx":
  ensure  => running,
  require => Package["nginx"],
}

Der hier beschriebene Soll-Zustand definiert eine Package-Ressource nginx als vorhanden und eine Service-Ressource nginx als ausgeführt. Durch das Require-Attribut definiert der Service die Package-Ressource als Vorbedingung. Beim Ausführen des Skripts würde Puppet den aktuellen Systemzustand prüfen, bei Bedarf das Package nginx über den passenden Paket-Manager installieren und anschließend den Service starten, falls er noch nicht läuft. Durch die betriebssystemunabhängige Deklaration kann man dieses Manifest auch in einer heterogenen Systemlandschaft wiederverwenden.

Der wirkliche Mehrwert von Puppet kommt zum Tragen, wenn man sich dessen Modulkonzept zu Nutze macht: Im nächsten Beispiel wurde die Systemdefinition in ein class-Element gekapselt. Diese Klasse lässt sich nun nach Belieben wiederverwenden und parametrisieren, eine Möglichkeit hierfür findet sich am Ende des abgebildeten Code-Segments. Eine solche Klasse könnte nun als ein Modul verfügbar gemacht werden, in dem sich noch weitere Dateien wie Konfigurationsdateien ablegen lassen. Diese Module können dann unter Versionskontrolle gestellt und im Team verteilt werden.

class nginx (
    $run = true
){
package { "nginx":
  ensure  => present,
}
service { "nginx":
  ensure  => $run,
  require => Package["nginx"],
}
}
class { ‚nginx‘:
  run => false
}

Das obige Modul ist natürlich trivial. Im nächsten Beispiel wollen wir deshalb einen Jenkins Server mit mehreren Plugins aufsetzen. Dafür müssen wir zuvor lediglich via puppet module install rtyler/puppet ein Puppet Modul aus der PuppetForge, dem zentralen Modul-Repository von Puppet, herunterladen und folgendes Puppet Manifest über puppet apply manifest.pp aufrufen:

node /ciserver.*/ {
  include jenkins;
  Jenkins::plugin {
    “git” : ;
    “chucknorris”:;
  }
}

Über die Direktive node wird das Modul auf dem Host mit dem Hostnamen-Pattern ciserver.* installiert. Neben dem automatisierten Provisionieren einer einzelnen Umgebung eignet sich Puppet nämlich auch zur Verwaltung einer Systeminfrastruktur bestehend aus vielen Umgebungen. In diesem Fall wird Puppet im Master-Agent-Modus betrieben, wobei die Agents als Daemon-Prozesse auf den Nodes laufen und sich regelmäßig vom Puppet Master die System-Definition abholen, die auf den Agent zugeschnitten ist (genannt „Catalog“).

Hier zwei Empfehlungen zur Verwendung von Puppet:

  • Umgebungsspezifische Parameter wie Endpoint-URLs oder Log-in-Daten sollten von der System-Definition getrennt werden. Da die System-Definition bereits in einer produktionsnahen Umgebung aufgebaut wurde, kann diese durch einfaches Austauschen der Umgebungskonfiguration dann in der echten Produktionsumgebung weiterverwendet werden. Damit wurde der Systemaufbau bereits in mindestens einer vorgelagerten Umgebung getestet.
  • Die erstellten Puppet Module sollten versioniert und vom Puppet Master über die Versionskontrolle geladen werden. Alle Systemänderungen werden dann durch ein Check-in in die Versionskontrolle angestoßen. Durch diesen Ansatz, der in Abb.2 dargestellt ist, gewinnt man eine zentral verwaltete, versionierte System-Definition, die jederzeit den aktuellen Zustand der Systeme widerspiegelt.

Eine Konfiguration, die aus mehreren Knoten besteht, lässt sich auch mit Vagrant herstellen. Mit einem Multi-Machine-Setup [2] könnte so eine produktionsähnliche Systemlandschaft entwickelt werden, die einen Webserver, einen Applicationserver und einen Datenbankserver enthält und bei der ebenfalls Puppet die Provisionierung übernimmt.

Leichtgewichtige Container mit Docker

Auch wenn Puppet den Vorgang der Provisionierung gegenüber einem manuellen Vorgehen schon deutlich beschleunigt, wiederholt sich dieser doch für jede weitere Umgebung, die wir aufsetzen müssen. Die Provisionierung eines Basis-Images mit einer komplexen Umgebungs-Definition wie einem Application Server kann etliche Minuten dauern. Bei langlebigen Systemen ist das meist kein Problem. Was aber, wenn wir in unserem Build-Prozess einen Integrationstest einbauen wollen, der auf einer frisch installierten Testumgebung durchgeführt wird? Oder wenn wir einen Lasttest-Schritt einbauen wollen, in dem wir die Anwendung kurzzeitig von 20 Clients aufrufen lassen? Zum einen würde die Provisionierung dieser Instanzen viel Zeit kosten und zum anderen würde für jede Instanz eine Virtual Machine mit komplettem Betriebssystem gestartet werden, was erhebliche System-Ressourcen beanspruchte.

Hier kann Docker eine Lösung sein. Docker macht sich Funktionen moderner Linux-Kernels zunutze, die es erlauben, mehrere Umgebungen isoliert voneinander unter demselben Betriebssystem auszuführen. Weiterhin setzt Docker statt auf Basis-Image plus Provisionierung auf Images die eine vollständige Anwendungsumgebung beinhalten. Beides zusammen ermöglicht das Starten einer Instanz in meist nur wenigen Sekunden. Ein Docker Image wird mit Hilfe eines Dockerfile erstellt und kann dann beliebig oft instanziert werden. Das instanzierte Image wird Container genannt. Das Dockerfile für das Nginx-Beispiel [3] sieht so aus:

FROM dockerfile/ubuntu
# Install Nginx.
RUN \
  add-apt-repository -y ppa:nginx/stable && \
  apt-get update && \
  apt-get install -y nginx && \
  rm -rf /var/lib/apt/lists/* && \
  echo "\ndaemon off;" >> /etc/nginx/nginx.conf && \
  chown -R www-data:www-data /var/lib/nginx

# Define mountable directories.
VOLUME ["/etc/nginx/sites-enabled", "/etc/nginx/certs", "/etc/nginx/conf.d", "/var/log/nginx"]

# Define working directory.
WORKDIR /etc/nginx

# Define default command.
CMD ["nginx"]

# Expose ports.
EXPOSE 80
EXPOSE 443

Wie bei Vagrant wird auch bei Docker ein Basis-Image angegeben (hier nach FROM), auf dem aufgesetzt wird. Hier existiert im zentralen Repository DockerHub eine große Auswahl an Images, die als Basis verwendet werden können, zum Beispiel alle verbreiteten Linux-Distributionen. Durch den Aufruf von docker build im Projektordner wird anhand des Dockerfiles ein Image erstellt, das anschließend mit docker run als Container gestartet werden kann. Es fällt auf, dass Docker nicht wie Puppet vom Betriebssystem abstrahiert. Da ein Docker-Container aber auf allen Linux-Distributionen lauffähig ist, auf denen der Docker-Daemon läuft, fällt dies kaum ins Gewicht.

Docker führt die Befehle im Dockerfile sequentiell aus und erzeugt für jeden Befehl ein eigenes Image, das aus der Differenz zum vorherigen Image besteht. Docker realisiert dies durch die Verwendung eines Union Mounts, bei dem jeder Image-Layer als eigenes Dateisystem über einen anderen Layer gestapelt wird. Wird ein Befehl im Dockerfile geändert, wird auf dem darunter liegenden Image, also auf dem Image des vorherigen Befehls, aufgesetzt, was die Weiterentwicklung eines Images beschleunigt. Docker-Images werden in Repositories abgelegt, die den Austausch im Team erleichtern.

Um vom Host-System auf die in einem Docker-Container laufende Anwendung zugreifen zu können, muss im Dockerfile mit EXPOSE der Anwendungs-Port freigegeben werden. Dieser kann dann beim Start des Containers über docker run –p <host_port>:<container_port> an einen Host Port gebunden werden. Falls Container nur untereinander kommunizieren müssen, wie ein Applicationserver-Container, der auf einen Datenbank-Container zugreifen will, können diese über Container Linking miteinander verbunden werden. Sie belegen dann keine Ports auf dem Host. Durch diese Mechanismen wird erreicht, dass mehrere Container auf einem Host laufen können, auch wenn sie auf dem gleichen Image basieren.

Damit eröffnen sich unter anderem folgende Einsatzszenarien für Container:

  • Bereitstellung von kurzlebigen „Wegwerf-Umgebungen“:
    Da sich Umgebungen innerhalb von Sekunden starten und stoppen lassen, eignen sich Docker-Container für Anwendungsfälle, in denen die Systeme nur kurzzeitig benötigt werden. Beispielsweise könnte unser Jenkins Build-Server in einem Docker-Container die Anwendungsumgebung für Integrationstests starten und nach der Testdurchführung wieder stoppen. Für diesen Zweck sind Container deutlich effizienter und ressourcenschonender als VM-Instanzen.
  • Elastische Skalierung durch das Starten und Stoppen weiterer Container:
    Aus dem gleichen Grund können zusätzliche Container bei Lastspitzen wenn nötig nachgestartet werden.
  • Betrieb einer großen Anzahl gleicher Umgebungen, zum Beispiel für Lasttests
  • Container als Service:
    Wo bisher verschiedene Services auf einer Umgebung ausgeführt wurden, weil jede Umgebung einen Ressourcen-Overhead bedeutete, können dank ihrer effizienten Ressourcenteilung nun Docker Container auf die Bereitstellung eines Services beschränkt werden.
  • Paralleler Betrieb unterschiedlicher Anwendungsversionen:
    Da die Systemumgebung im Docker-Container gekapselt ist, können mehrere Anwendungsversionen parallel betrieben werden, solange keine Schnittstellen betroffen sind.

Übrigens lassen sich Docker-Container auch problemlos in einer VM betreiben. In Vagrant kann dafür unter anderem eine Boot2Docker [4] oder CoreOS Box [5] verwendet werden.

Puppet oder Docker – oder beides?

An dieser Stelle fragt man sich vielleicht, ob man jetzt Docker oder Puppet oder vielleicht sogar beide Tools gemeinsam einsetzen sollte. Immerhin stellen beide Werkzeuge vorkonfigurierte Umgebungen bereit. Doch es gibt auch Unterschiede zwischen ihnen.

Die Entscheidung, Puppet einzusetzen, kann auch für Anwendungsumgebungen passen, die bereits in Betrieb sind. Das Tool kann beispielsweise dazu genutzt werden, den Systemzustand nachträglich deklarativ zu beschreiben und sicherzustellen. Da zum Verwalten einer Umgebung durch Puppet lediglich der Agent installiert werden muss, ist die Auswirkung auf die Infrastruktur gering. Anschließend können sukzessive Services und Dateien aufgenommen werden, die durch Puppet verwaltet werden sollen.

Der Einsatz von Puppet kann also auch allein aus dem Betrieb heraus getroffen werden. Eine Anwendungsumgebung als Docker-Container auszuliefern, ist hingegen eine architekturelle Entscheidung, da sie Auswirkungen auf Aspekte wie Deployment und Software-Verteilung hat. Mit Puppet lässt sich auch eine große Serverlandschaft zentral verwalten, während Docker-Container ohne ein solches Werkzeug immer noch einzeln per Hand gestartet werden müssen. Die Verwaltung mehrerer Docker-Container könnte wiederum Puppet leisten.

Wer zudem nicht auf die deklarative, distributionsübergreifende Beschreibungssprache von Puppet verzichten möchte, kann natürlich auch im Dockerfile Puppet installieren und verwenden. Eine Kombination von Puppet und Docker kann also durchaus sinnvoll sein.

Fazit

Puppet, Vagrant und Docker bieten viel Potenzial, um den Entwicklungsablauf zu beschleunigen. Sei es durch das schnelle Aufsetzen von Umgebungen zu Testzwecken, als Basis zur Entwicklung einer einheitlichen, reproduzierbaren Umgebungskonfiguration vom Entwickler-Arbeitsplatz bis zur Produktion oder durch das automatische Ausbringen einer Anwendung samt Umgebung im Sinne von Continuous Delivery.

Infrastruktur-Code in Form von Puppet-Modulen oder Dockerfiles, muss dabei im Hinblick auf Qualität und Wartbarkeit der gleiche Stellenwert eingeräumt werden wie dem Anwendungscode, da die Anwendungsumgebung das Zielsystem ebenso ausmacht wie die Anwendung selbst. Mittels einer durchgängigen Verwendung der Infrastrukturautomatisierung in der Software-Lieferkette steigt die Reife des Gesamtsystems stetig und muss nicht in jeder Lieferstufe erneut hergestellt werden, wie es im klassischen Vorgehen der Fall wäre (s. Abb.3).

Für jedes der Werkzeuge existiert mittlerweile eine große Zahl von Basisumgebungen bzw. Modulen, die einen komfortablen Schnellstart in die Infrastruktur-Automatisierung ermöglichen. Für Vagrant finden sich diese über Atlas [6], für Docker über Docker Hub [7] und für Puppet über PuppetForge [8]. Ebenso findet man auf den Homepages der Werkzeuge ausreichend weiterführendes Material. Viel Spaß bei der Infrastruktur-Automatisierung!

Autor

Kai Weingärtner

Kai Weingärtner ist Senior Consultant bei der Opitz Consulting Deutschland GmbH. Er ist erfahren in der Entwicklung und Konzeption von Java-Enterprise-Anwendungen. Als Berater ist er in den Bereichen serviceorientierte...
>> Weiterlesen
botMessage_toctoc_comments_9210