Flexible Pipeline

Docker: Implementierung von Continuous Deployment
Kommentare

Continuous Deployment kann je nach Bedarf in verschiedenen Implementierungen eingesetzt werden. Wem Shell-Skripte zu kryptisch und ISO-Images zu dick sind, der kann mithilfe von Docker eine flexible Pipeline konstruieren. Der folgende Artikel zeigt aus Entwicklersicht, wie leichtgewichtig das Deployment einer Java-Webapplikation sein kann.

Im Rahmen der Entwicklung eines neuen Produkts der EUROPACE-Plattform bei Hypoport wurden die vorhandenen Lösungen zum Build und Rollout einer JVM-basierten Anwendung evaluiert. Bestehende Varianten wurden mithilfe von typischen Skriptsprachen (Shell/Bash, Ruby, Python) und Maven- oder Gradle-Plug-ins implementiert, während Jetbrains TeamCity als Continuous-Integration-Tool eingesetzt wurde. Unser Team bewertete verschiedene Lösungen, um nach dem Motto „you build it, you run it“ (siehe das Gespräch mit dem Amazon CTO Werner Vogels) das Produkt zu entwickeln und langfristig Support zu leisten.

Während die Evaluation und der Entwurf einer Deployment-Pipeline für unser Produkt im Kontext einer produktübergreifenden Plattform betrachtet wurden, kam Docker als Lösungsvariante ins Spiel. Die darunterliegende Idee war attraktiv für die aus Entwicklersicht relevanten Bedürfnisse: Wir forderten Flexibilität und aktuelle Software bei konsistentem Verhalten über verschiedene Stages. Auch der Wunsch, nach DevOps-Grundsätzen arbeiten zu können, wurde durch die Docker-Konzepte begünstigt.

Docker in einer wachsenden DevOps-Kultur

Unsere Unternehmensstruktur war schon offen gegenüber der DevOps-Philosophie, dennoch gab es eine Trennung der Aufgaben von Operations und Development. Operations stellte den Entwicklern typischerweise Server oder virtuelle Maschinen mit einem Oracle Enterprise Linux (OEL) in Version 6.4 zur Verfügung. Docker benötigt auf OEL 6.4 einen als Betaversion deklarierten Kernel ab Version 3.8.x, außerdem mussten die Docker-Packages bei verfügbaren Updates manuell in ein internes YUM-Repository eingepflegt werden. Die Packages sind in einem so genannten EPEL-Repository verfügbar. Mit der vor kurzem freigegebenen Version OEL 7 wird sich der offizielle Support für Docker verbessern und weniger manuelle Eingriffe erfordern.

Stellen Sie Ihre Fragen zu diesen oder anderen Themen unseren entwickler.de-Lesern oder beantworten Sie Fragen der anderen Leser.

Aufgrund der Dynamik, die unser Team beim Umgang mit Docker einforderte, war Operations sehr offen für mehr Freiheiten seitens der Entwickler. Wir mussten uns also ganz wie gewünscht auf mehr Freiheit und damit einhergehend auch mehr Verantwortung einstellen. Aus der Zusammenarbeit zwischen Operations und Development wuchs der Grundsatz, dass Docker aus Operations-Sicht die Anwendung darstellt. Bezüglich Monitoring und Incident-Management konnten wir also eine saubere Verantwortungsverteilung definieren. Unser Team übernahm den Support für die Docker-Installation und alles, was innerhalb der Docker-Container passiert.

Trotz dieser Aufgabenteilung blieb es notwendig, keine Grenzen zwischen den Teams entstehen zu lassen: Durch Docker entsteht für Entwickler stärkerer Lernbedarf hinsichtlich der für Operations gewohnten Tätigkeiten. Die Entwickler mussten lernen, dass Docker keine Lösung für Monitoring oder Security darstellt: Die Docker-Entwickler weisen regelmäßig darauf hin, dass man sich der sicherheitsrelevanten Konsequenzen aus der Verwendung von Linux-Containern bewusst sein muss. Allerdings gibt es auch schon entsprechende Tipps, z. B. im Container laufende Services nicht mit root-Rechten laufen zu lassen. Einen detaillierten Überblick liefert Jérôme Petazzoni. Operations wiederum musste sich darauf einstellen, dass kürzere Update-Zyklen und auch das Loslassen von gewohnten Tätigkeiten zu einem Wandel des Tagesgeschäfts führten. Operations und Development hatten also den Bedarf und die Chance, voneinander zu lernen und Neues zu erfahren.

Für unser Produkt galt also, die Docker-Images zu definieren, sie automatisiert zu bauen, zu verteilen und auf den Test- und Produktionsservern in Containern zu starten. Die Möglichkeit, auch lokal die Docker-Container wie auf einem Produktionsserver zu testen, führte zu einem schnellen Entwicklungszyklus. Speziell das Ausprobieren verschiedener Features, das schnelle Einbinden weiterer Services und der einfach verwendbare Docker-Client trugen dazu bei, dass Docker uns selten mit Problemen oder Überraschungen konfrontierte.

Die im Folgenden beschriebenen Konzepte entsprechen nicht immer den allgemein als Best Practice bekannten Konzepten. Ein Hauptgrund dafür liegt in unserem Versuch, eine möglichst minimale statt einer möglichst abstrakten Lösung zu finden. Hier wird zunächst der aktuelle Stand unserer Implementierung beschrieben, am Ende des Artikels werden wir noch einige Alternativen aufzeigen.

Basis-Images

Wir nutzen für unser Produkt mehrere selbst definierte Basis-Images, die letztlich auf einem offiziellen Ubuntu Image aufbauen. Basis Images ermöglichen es uns, unternehmensspezifische Anforderungen hinsichtlich apt-cacher (ein interner Proxy zum Cachen von Debian-Paketen) oder Security einzubinden. Außerdem nutzen wir je ein Layer für die allgemeine Konfiguration eines Supervisord und des notwendigen JDK. Der Supervisord ersetzt den in Docker-Containern normalerweise nicht laufenden Init-Prozess des Betriebssystems und ermöglicht das Starten mehrerer Kindprozesse. Wie der Supervisord in den Sub-Images für die konkret zu startenden Prozesse verwendet wird, zeigen wir weiter unten. Unsere Images werden in Dockerfiles definiert. Listing 1 zeigt einen Ausschnitt aus dem allgemeinen Basis-Image. Es bereitet per ONBUILD schon die Installation und Konfiguration des Supervisord vor, sodass direkte Sub-Images ein Default-Kommando erhalten. Dieser Default hat zunächst keinen Effekt und lässt sich problemlos überschreiben.

Listing 1: „hypoport/base“
FROM docker-registry.hypoport.local:5000/ubuntu:14.04
ADD ./91hypoport /etc/apt/apt.conf
RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y curl supervisor && apt-get clean && rm -rf /tmp/* /var/tmp/*
ADD supervisord.d/supervisord.conf /etc/supervisord.conf
ONBUILD CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

Listing 1 zeigt außerdem, wie wir uns bemühen, das Basis-Image möglichst klein zu halten: Nachdem wir den Paketmanager apt so konfiguriert haben, dass der unternehmensinterne apt-cacher verwendet wird, müssen wir vor der Paketinstallation die Paketinformationen aktualisieren. Die dabei anfallenden temporären Dateien werden später nicht mehr benötigt und daher wieder innerhalb derselben RUN-Anweisung entfernt. Der Docker Daemon schreibt dadurch nur die im Rahmen der Paketinstallation vorgenommenen Änderungen in den neuen Image Layer. Auf GitHub werden bereits elegantere Lösungen zur Image-Optimierung diskutiert.

Aufbauend auf dem Image aus Listing 1 haben wir das produktspezifische JDK in einem eigenen Basis-Image definiert. Listing 2 zeigt, wie wir per FROM-Anweisung das interne Basis-Image verwenden und das JDK aus einem neben dem Dockerfile liegenden Archiv ins Image packen.

Listing 2: „kreditsmart/base“
FROM docker-registry.hypoport.local:5000/hypoport/base
ADD jdk-7u51-linux-x64.tar.gz /opt
RUN ln -s /opt/jdk1.7.0_51 /opt/java
ENV JAVA_HOME /opt/java

Das ADD Kommando wird hier nicht nur als COPY verwendet, sondern wir verwenden implizit dessen Möglichkeit, unterstützte Archive auszupacken. Wir müssen dazu vor dem Build des Images dafür sorgen, dass das korrekte Archiv unter dem referenzierten Dateinamen im Build-Kontext liegt. Der Build-Kontext entspricht dem Ordner, in dem das Dockerfile liegt und wird vom Docker-Client als Tar-Archiv zum Docker Daemon hochgeladen. Man erkennt dies beispielsweise an der Konsolenausgabe „Sending build context to Docker daemon“, und dies wird spätestens dann relevant, wenn man mit einem Docker Daemon auf einem anderen Rechner per Remote-API kommuniziert. Um keine unnötigen Dateien (z. B. einen .git/-Ordner) zu übertragen, verwendet man die .dockerignoreDatei.

In den Dockerfiles sieht man auch, dass wir eine interne Docker Registry verwenden. Deren Pflege zieht einige Konsequenzen nach sich, dazu aber später mehr. Durch die Verwendung der Basis-Images verschieben wir allgemeine Aspekte aus dem ständig neu gebauten Application Image in relativ selten modifizierte Layer. Unser Application Image besteht dann nur noch aus dem Einpacken der geänderten Artefakte.

Docker enthält ein Caching der Image Layer auf Basis von Hashes der Kommandos im Dockerfile und der beteiligten Daten. Ein Build kann dadurch bei wiederholter Ausführung extrem schnell werden. Das Caching ist auch beim Pull und Push aktiv, sodass nur geänderte Layer übertragen werden. Anstelle einer typischen virtuellen Größe des Application Images von ca. 650 MB werden dadurch neben einigen Metadaten für unser Projekt nur ca. 80 MB im Netzwerk übertragen. Dies entspricht ungefähr der Größe unserer Artefakte, die im Rahmen eines Gradle Build entstehen, und die wir im Application Image mit den Basis-Images vereinen.

Application Image mit logstash Forwarder

Aufbauend auf dem Image aus Listing 2 haben wir ein weiteres Image definiert, das bei jedem Build in Teamcity mit einer neuen Version unseres Produkts erstellt wird. Listing 3 zeigt das dazu passende Dockerfile. Um das oben erwähnte Layer Caching schon beim Build noch besser auszunutzen, könnte man die EXPOSE– und CMD-Kommandos vor den ADD– und RUN-Kommandos einfügen.

Listing 3: „kreditsmart“
FROM docker-registry.hypoport.local:5000/kreditsmart/base

ADD logstash-forwarder /opt/logstash-forwarder/
RUN chmod +x /opt/logstash-forwarder/start-logstash-forwarder.sh
RUN chmod +x /opt/logstash-forwarder/logstash-forwarder
ADD supervisord.d/logstash-forwarder.ini /etc/supervisord.d/logstash-forwarder.ini

ADD kreditsmart-backend.jar /opt/kreditsmart-backend/kreditsmart-backend.jar
ADD supervisord.d/kreditsmart-backend.ini /etc/supervisord.d/kreditsmart-backend.ini
ADD kreditsmart-frontend.jar /opt/kreditsmart-frontend/kreditsmart-frontend.jar
ADD supervisord.d/kreditsmart-frontend.ini /etc/supervisord.d/kreditsmart-frontend.ini

EXPOSE 8080
EXPOSE 8090

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

Wir laden die per ADD-Kommando hinzugefügten jar-Dateien mithilfe eines Gradle-Skripts aus unserem Nexus-Artefakt-Repository. Im Docker-Build-Kontext werden die ins Image zu packenden Dateien neben dem Dockerfile erwartet. Wir vermeiden dadurch eine Aktualisierung des Dockerfiles bei jeder neuen Version unserer Applikation. Obwohl es als Best Practice gilt, Docker Images mit nur einem Service zu starten, enthält unser Image neben dem logstash Forwarder sogar zwei Spring-Boot-Applikationen. Beide Applikationen werden in einem Gradle-Multimodul gebaut und gleichzeitig ausgerollt, sodass eine künstliche Aufteilung in zwei Images aktuell keine größeren Vorteile bieten würde. Der oben schon vorgestellte Supervisord wird hier mithilfe von Konfigurationsdateien und einer passenden CMD-Anweisung als Default-Prozess aktiviert.

Der logstash-Forwarder ist ein Tool zum Verteilen unserer Logdateien an eine logstash-Instanz. Der im Artikel „Docker: zentralisiertes Logging mit dem ELK-Stack“ detaillierter beschriebene logstash-Service läuft bei uns in einem eigenen Container, während der logstash Forwarder einen leichtgewichtigen Prozess im Application-Container darstellt. Er ist in der Lage, Logeinträge mit selbstdefinierten Attributen zu ergänzen, bevor die Einträge an den logstash-Server geschickt werden. Wir nutzen dieses Feature, um beispielsweise Access-Logs und Application-Logs möglichst früh zu kategorisieren. Listing 4 zeigt einen Ausschnitt aus der Konfiguration des logstash Forwarders. Der Abschnitt network konfiguriert die Serveradresse des logstash-Servers und ein Zertifikat zur Absicherung der Kommunikation mit logstash. Die Adresse localhost:5043 dient als Platzhalter, sie wird bei einem Deployment bzw. zum Start des Docker-Containers passend zur aktuellen Stage ersetzt. Unter files sieht man zwei Abschnitte, die die Application-Logs und Access-Logs jedes Service sammeln und mit einem Feld type kategorisieren.

Listing 4: „logstash-forwarder.conf.template“
{
  "network": {
    "servers": [ "localhost:5043" ],
    "ssl ca": "./logstash.crt"
  },
  "files": [    {
      "paths": [        "/…/logs/*.log"      ],
      "fields": {        "type": "application-log"      }
    },
    {
      "paths": [        "/…/logs/access/*-access.log"      ],
      "fields": {        "type": "tomcat-access"      }
    }  ]
}

Container- und Host-Konfiguration

Docker ermöglicht neben der Containerkonfiguration (z. B. per CMD) auch eine Host-Konfiguration zwecks Portmapping oder Volume Binds. Wir nutzen Volume Binds für die Auslagerung der Logdateien auf einen Mountpoint des Hostsystems. Neben dem logstash Forwarder hilft uns das Auslagern der Logdateien, sie auch unabhängig von logstash durchsuchen zu können. Da wir Load Balancing über verschiedene Hosts betreiben, ist das manuelle Auswerten der Logdateien allerdings nur in Einzelfällen sinnvoll. logstash dient uns also als Log-Aggregator.

Auch das Portmapping unserer Container definieren wir manuell. Wir überlassen nicht dem Docker Daemon die Suche nach freien Ports auf dem Hostsystem, sondern haben eigene Mappings definiert. Wir nutzen sie für die Konfiguration unseres Blue-/Green-Deployments. Aus dem Beispiel in Listing 3 ergibt sich pro Host ein Portmapping wie in Tabelle 1. Der vor unseren Docker Hosts laufende HAProxy kennt die entsprechenden Backends mit ihren jeweiligen Ports und muss nicht auf eine zentrale Registry wie etcd zurückgreifen. Im Rahmen eines Rollouts wird die statische HAProxy-Konfiguration so aktualisiert, dass beispielsweise vom Blue Backend auf das Green Backend umgeschaltet wird.

Tabelle 1: Portmapping pro Stage auf einem Host

Tabelle 1: Portmapping pro Stage auf einem Host

Wir verwenden dieselben Docker Images sowohl für unsere Production Stage als auch für unsere Development- bzw. Test Stage. Um je nach Umgebung externe Service- oder Datenbank-URIs konfigurieren zu können, verwenden wir Stage-spezifische Spring Profiles. Diese lassen sich per Environment-Parameter konfigurieren, sodass wir beim Starten unserer Docker-Container nur die passenden Environment-Parameter abhängig von der aktuellen Stage setzen müssen. Um mehrere Parameter in Dateien zusammenzufassen, verwenden wir –env-files. Eine vollständige Anweisung zum Start eines Containers sieht dann wie folgt aus:

docker -H tcp://prod1.hypoport.local:2375 run -d --name=blue -v /.../logs/blue:/opt/kreditsmart-backend/logs:rw -p 8090:8090 -v /.../logs/blue:/opt/kreditsmart-frontend/logs:rw -p 8080:8080 --env-file env-prod.properties docker-registry.hypoport.local:5000/kreditsmart:2014-09-01T12-00-00_2f873c00da1b9e2c294a96046e237358b8a75abb

Die Anweisung sieht auf den ersten Blick erschreckend komplex aus. Die Komplexität relativiert sich allerdings durch die Verwendung von Groovy Script und geeigneter Variablen. Außerdem arbeiten wir an einer Integration eines Gradle-Plug-ins, mit dem Ziel, den Docker Daemon auf unseren Servern per Remote-API via HTTP anzusprechen; mehr Details dazu im Infokasten „Verwendung des Remote-API“. Die Komplexität wird so ins Plug-in verschoben, und nebenbei lösen wir uns von der Abhängigkeit zu einem auf dem CI-Server installierten Docker-Client.

Private Docker Registries

Oben wurde bereits die private Docker Registry erwähnt. Wir nutzen sie statt einer externen Registry wie Docker Hub oder Quay. Das Aufsetzen einer dedizierten Registry gelingt sehr einfach mit dem offiziell verfügbaren Docker Image. Die in der Registry hinterlegten Images werden bei uns per Volume Mapping auf dem Registry Host gespeichert, sodass wir bequem Updates der Registry ohne Datenverlust durchführen können. Die Registry hat sich im Betrieb als stabil erwiesen.

Bei intensiver Nutzung der Registry im Umfeld eines Continuous Deployment Setups stellt sich wenig überraschend heraus, dass ein Löschen alter Images notwendig wird. Das Registry-API bietet bereits entsprechende Endpoints zum Löschen von Tags, sodass wir ein Cleanup-Skript geschrieben haben, das bis auf die aktuellsten vier Images alle übrigen aus einer definierten Liste von Repositorys löscht. Leider wirkt sich das Löschen der Tags nur auf die Metadaten innerhalb der Registry aus, und es werden keine Image Layer entfernt. Auf Github gibt es bereits diverse entsprechende Issues, die hinsichtlich der Auswirkung auf die offizielle Registry und anderer Aspekte diskutiert werden. Bevor diese Issues gelöst sind, hat man allerdings die Möglichkeit, manuell die nicht mehr benötigten Layer vom Storage zu löschen. Dazu muss man die entsprechenden Image-IDs den Dateipfaden zuordnen und die gewünschten Ordner löschen. Man muss auch berücksichtigen, dass es nicht reicht, nur die zum gelöschten Tag passenden Layer zu entfernen, sondern dasss man oft auch weitere übergeordnete Images löschen muss. Ein Cleanup-Skript müsste also den Image Tree zur Wurzel hin so weit löschen, bis ein noch benötigter Layer erreicht wird. In den erwähnten Github-Issues werden schon Cleanup-Skripte erwähnt, die die oben beschriebenen Anforderungen lösen können.

Wir haben in eine solche Analyse der Registry-Inhalte bisher noch keine Zeit investiert, weil wir einerseits hoffen, dass die offizielle Registry bald eine Lösung bereitstellt, und wir andererseits unsere Registry sehr schnell von einer frischen Instanz wiederherstellen können. Wir löschen also bei Bedarf die Registry und die darin enthaltenen Repositorys komplett und sorgen teilautomatisiert dafür, dass alle notwendigen Basis-Images wieder verfügbar sind. Zur Wiederherstellung haben wir Gradle-Skripte geschrieben, die wir bei Bedarf per Teamcity anstoßen. Spätestens wenn weitere Teams mit der Registry arbeiten, wird ein solch radikales Vorgehen jedoch nicht mehr praktikabel sein.

Ein weiterer Aspekt bei der Verwendung einer zentralen Registry und gemeinsam genutzten Basis-Images betrifft die Benennung und das Tagging der Images. Docker erlaubt eine Benennung der Images nach dem folgenden Schema: [registry:port/][username/]name[:tag].

Die optionale Wahl eines Usernames kann für die Zuordnung zu Teams, Projekten oder generell Namespacing genutzt werden. Tags helfen wiederum dabei, verschiedene Versionen eines Repositorys zu verwalten. Diese drei Eigenschaften eines Repository-Namens können also ähnlich wie von Maven bekannt als Gruppe:ArtifaktId:Version gelesen werden. Bei Verwendung einer privaten Registry muss nur noch deren Host-Adresse eingefügt werden. Ein vollständiges Beispiel ist in Listing 3 zu sehen, „kreditsmart“ lautet in diesem Fall unser Produktname:  FROM docker-registry.hypoport.local:5000/kreditsmart/base

Die Docker Registry lässt sich als Mirror der offiziellen Registry konfigurieren. Dazu müssen beim Start der Registry die Index- und Registry-URIs gesetzt werden. Das kann per Konfigurationsdatei oder per Environment-Variable erfolgen, beispielsweise wie folgt:

docker run –d –p 5000:5000 –e MIRROR_SOURCE=https://registry-1.docker.io -e MIRROR_SOURCE_INDEX=https://index.docker.io registry:0.8.1

Wenn die private Registry so konfiguriert ist, müssen die lokal verwendeten Repository-URIs entsprechend angepasst werden. Ein Pull des offiziellen Ubuntu 14.04 Images sieht bei einer unter docker-registry.hypoport.local laufenden Registry wie folgendes Kommando aus:

docker pull docker-registry.hypoport.local:5000/ubuntu:14.04

Durch die Mirror-Funktion wird die Registry das gewünschte Ubuntu Image von der offiziellen Registry laden. Ein Push eigener Images erfolgt entsprechend, allerdings prüft die private Registry für die eigenen Image Layer, ob sie bereits in der offiziellen Registry verfügbar sind. Ist das nicht der Fall, wird versucht, die Layer zur offiziellen Registry zu pushen. Erst wenn die dafür notwendige Authentifizierung fehlschlägt, nutzt die private Registry den lokal konfigurierten Storage. Dieses Verhalten ist also zu beachten, wenn man private Images nicht öffentlich verfügbar machen möchte. Wir evaluieren noch Alternativen, unter Umständen ist jedoch ein Feature Request sinnvoll, der den Push zur öffentlichen Registry für alle oder für benannte Repositorys explizit deaktiviert.

Serverprovisionierung

Einführend wurde schon angedeutet, dass wir für unser Host-Betriebssystem OEL 6.4 einige Details konfigurieren mussten. Puppet wird bei uns zur Provisionierung und Konfigurierung unserer Server verwendet. Wir haben für Docker passende Klassen definiert, die wir pro Host nur noch per include einbinden müssen. Die grundlegende Konfiguration für Docker bleibt recht übersichtlich, nur Details wie das von Docker benötigte iptables mussten aktiviert werden, und ein dedizierter Mount Point für Docker Images und Container wurde konfiguriert.

Eine nicht nur Puppet betreffende Eigenheit betrifft das Finden der externen IP-Adresse eines Hosts. Puppet ermittelt hostspezifische Eigenschaften mithilfe des Tools Facter. Dieses wiederum versucht eine eindeutige IP-Adresse zu finden, die man oft als primäre oder externe IP-Adresse in den Puppet-Modulen verwendet. Wie im Artikel auf blog-it.hypoport.de beschrieben, können sich die durch Facter gefundenen IP-Adressen nach Installation des Docker-Packages und dem damit zusätzlich vorhandenen Interface docker0 ändern. Passende Lösungsverschläge sind im genannten Artikel erwähnt und können je nach Bedarf berücksichtigt werden.

Grundlegender war die Notwendigkeit, einen unter OEL 6.4 als Beta deklarierten Kernel einzubinden. Im Betrieb stellte sich außerdem heraus, dass einige Probleme auf Dateisystemebene nur mit einem Kernel auf aktuellem Patch-Level gelöst werden. Wir verwenden aktuell eine Kernel-Version 3.8.x, für die allerdings noch einige Issues ungelöst sind, wie beispielsweise auf GitHub ersichtlich. Neben dem Betriebssystemsetup musste auch ein von Fedora-/RedHat-Maintainern erzeugtes Docker Package installiert werden. Leider sind die mit OEL 6 kompatiblen Pakete nur stark verzögert gegenüber den offiziellen Docker-Paketen verfügbar, und man erhält derzeit von Oracle nur Support für bereits veraltete Pakete.

Image Deployment

Wie sieht nun ein Deployment aus, nachdem unsere Produktartefakte im von uns verwendeten Artefakt-Repository Nexus verfügbar sind? Wir verwenden für die Implementierung unserer Pipeline durchgängig Gradle und damit einhergehend auch Groovy. Unsere Pipeline ist aktuell in mehrere Schritte unterteilt, Abbildung 1 zeigt einen Überblick.

Abb. 1: Deployment-Pipeline im Überblick

Abb. 1: Deployment-Pipeline im Überblick

Nach dem ersten Schritt für Gradle build und publish gibt es zwei Schritte für Integrationstests zwischen unseren eigenen Services und den anderen Services der Plattform. Der erste Docker betreffende Schritt baut das in Listing 3 beschriebene Image und lädt es in unsere Docker Registry. Die darauffolgenden beiden Schritte führen das Deployment auf unseren Test- und Produktionsservern durch. Folgende Docker-spezifischen Tasks werden ausgeführt:

  1. Artefakt-Download und Pull des Base Images
  2. Build per Dockerfile
  3. Push zur privaten Registry
  4. Cleanup (Löschen des auf dem Build Agent liegenden Images)
  5. Pull des neuen Images auf den Zielservern
  6. Stop und Rm des alten Containers
  7. Run des neuen Images
  8. Cleanup (Löschen des alten Images)
  9. Umschalten des Load Balancers (Blue/Green) auf die neue Version

Die Tasks 5 bis 8 werden auf den Zielservern durchgeführt. Dazu wird der dort laufende Docker Daemon per HTTP angesprochen, sodass es für uns nicht mehr notwendig ist, per SSH oder mit ähnlichen Mitteln auf den Test- und Produktionsservern Shell-Skripte auszuführen. Gegenüber dem gewohnten Umgang mit Shell-Skripten bieten sich dadurch verschiedene Vorteile. Je weniger Bedarf sich für ein SSH-Log-in bietet, umso sicherer kann ein Server konfiguriert werden. Außerdem sind Shell-Skripte traditionell schlechter getestet, was sich in einer Pipeline kostenintensiv und sicherheitsrelevant auswirken kann. Die Verwendung des Docker-Remote-API birgt natürlich selbst wieder Sicherheitsrisiken, denen man entsprechend begegnen muss. Je nach Umgebung sollte man vermeiden, die Docker Ports öffentlich verfügbar zu machen. Sollte dies dennoch notwendig sein, bietet Docker die Möglichkeit, jede Kommunikation mit dem Remote-API durch TLS zu sichern.

Verwendung des Remote-API

Trotz der Möglichkeit, Shell-Skripte durch Docker-Kommandos zu ersetzen, müssen auch diese orchestriert werden. Gradle bietet uns dazu die Möglichkeit, per Groovy beliebige Prozesse auszuführen. Wir haben ursprünglich unsere Gradle-Skripte so entworfen, dass auf unseren CI-Servern ein Docker-Client vorausgesetzt wurde. Dieser Client konnte wie das folgende Beispiel aufgerufen werden:

def process = "docker run –d –p 5984:5984 couchdb".execute()

Um uns auch von der Abhängigkeit zum lokalen Docker-Client zu lösen, haben wir die bestehenden Skripte so angepasst, dass wir nun mit dem Remote-API direkt per HTTP kommunizieren. Dazu können wir den in Groovy verfügbaren HTTP-Client verwenden und mit einem Docker Daemon auf einem beliebigen Server Builds starten oder das Deployment durchführen. Anstatt unsere Build-Skripte mit den dann umfangreicheren Kommandos aufzublähen, haben wir den Code in ein Gradle-Docker-Plug-in ausgelagert. Ein netter Nebeneffekt ist die dadurch erleichterte Testbarkeit der Build-Skripte. Die verfügbaren Tasks sind derzeit noch stark an das Remote-API angelehnt, ein höheres Abstraktionslevel ist jedoch geplant.

Skalierung und Verfügbarkeit

Docker wird oft auch als Alternative zu virtuellen Maschinen (VM) verwendet. Anstelle einer Menge von VMs bietet es sich an, einen einzelnen Host oder eine einzelne VM mit einer Menge von Docker-Containern zu betreiben. Wir haben für unser Setup eine Mischform gewählt. Zwecks Load Balancing per vorgeschaltetem HAProxy haben wir zwei VMs eingerichtet, auf denen aber jeweils sowohl eine Blue-, als auch eine Green-Version laufen. Abbildung 2 zeigt das Setup inklusive der CouchDB- und logstash-Instanzen.

Abb. 2: Serverinfrastruktur

Abb. 2: Serverinfrastruktur

Da durch den HAProxy sichergestellt wird, dass nur jeweils ein Set (entweder Blue oder Green) von extern erreichbar ist, stören die alten Versionen unserer Anwendung den Betrieb nicht. Erst im Rahmen eines Deployments wird die nicht mehr verwendete Stage gestoppt. Diese Vorgehensweise ermöglicht es, alte Versionen im Rahmen einer Fehlersuche noch unverändert vorzufinden oder sogar Rollbacks durchzuführen. Um im Fall eines Rollbacks möglichst flexibel zu bleiben, verwenden wir die CouchDB als Event Store.

Die Verteilung auf verschiedene VMs oder Host-Systeme ist auch zur Ausfallsicherheit relevant. Im Fall eines Defekts eines der beiden Hosts kann die Anwendung bei vertretbarer Last noch verfügbar bleiben. Unter Umständen kann man der noch laufenden VM mehr Ressourcen zuordnen und den ausgefallenen Host mithilfe eines weiteren Docker-Containers ersetzen. Solche Details werden in einem hier beschriebenen Setup mit nur zwei Instanzen bewusster entschieden als in einem größeren Cluster. Für größere Cluster ist es oft nicht mehr relevant, ob einzelne Serviceinstanzen ausfallen, da sie schon dynamisch ersetzbar entworfen sind. Entsprechend finden sich passende Tools, die sogar zugeschnitten auf Docker-Container einen Cluster aufbauen und beobachten können, um abhängig von den verfügbaren Containern Service-Discovery und Load Balancing zu implementieren. Je nach Anwendungsbedarf helfen verschiedene Tools wie Fig, Consul, Serf oder Shipyard.

Ausblick

Die beschriebene Implementierung unserer Pipeline ist im Laufe von ca. sechs Monaten entstanden. Aufgrund der unglaublichen Dynamik im Docker-Umfeld entstehen oft neue Tools, neue Konzepte und auch elegantere oder flexiblere Lösungen. Die Wahl dieser Tools und Konzepte ist stark vom konkreten Projektumfeld abhängig. Wir beobachten die Entwicklung des Docker-Ökosystems sehr intensiv und passen unsere Implementierung bei Bedarf an. Einige Möglichkeiten für die nahe Zukunft sollen hier kurz erwähnt werden: Fig wurde von uns unter dem Aspekt evaluiert, die Orchestrierung und das Verlinken unserer Container zu übernehmen. Wir sind aktuell noch nicht von Fig überzeugt und werden deshalb nach weiteren Alternativen Ausschau halten.

Die Services in unserem Application Image können in einzelne Container verschoben werden. Speziell der logstash Forwarder ist nur aus historischen Gründen noch im Image enthalten.

Consul wird ebenfalls bei uns evaluiert und ersetzt möglicherweise die statische Verlinkung unserer Services durch eine dynamischere Service-Registry.

Die genannten Kernel-Probleme werden wir demnächst durch einen Wechsel auf Ubuntu-Host-Systeme mit aktuellerem Kernel lösen. Auch die Wahl des Dateisystems hat großen Einfluss auf Stabilität und Performance. Einen interessanten Vergleich liefert der Artikel von Project Atomic.

Docker muss nicht nur als Laufzeitumgebung verwendet werden, sondern ein Docker-Container kann auch als reines Build-System dienen. Wir testen in einem anderen Projekt unserer Plattform bereits diese Möglichkeit, um einen auf Node.js basierenden Build möglichst plattformübergreifend in ein Maven-Projekt einzubinden.

Spannend werden die teamübergreifende Nutzung einer privaten Registry oder die Pflege gemeinsam genutzter Basis-Images. Speziell sicherheitsrelevante Änderungen sollten zeitnah in die Application Images wandern, während projektspezifische Anpassungen andere Projekte nicht beeinflussen sollten.

Fazit

Die Orchestrierung der Pipeline und das Setup unserer Container sind stellenweise noch optimierbar. Gerade für strukturelle Optimierungen spielt ein auf Docker aufbauendes Konzept seine Stärken aus: Wir können uns darauf verlassen, dass ein docker build oder ein docker run stets funktionieren. Der für die Pipeline entstandene Code ist übersichtlich und leicht änderbar.

Docker ermöglicht ohne größeres lokales Setup das Ausprobieren verschiedener Tools oder Services. Als Full Stack Developer ist man zwar gewohnt, verschiedene Sprachen, Development Kits, Package Manager und dergleichen lokal zu installieren, ist aber mit Docker in der Lage, komplette Build-Systeme in einem Image zu beschreiben. Die Entwicklersysteme können dadurch weitgehend frei von projektspezifischen Paketen bleiben.

Das Verwenden von Dockerfiles führt dazu, dass viele Shell-Skripte für den Build und das Deployment überflüssig werden. Das Remote-API trägt außerdem dazu bei, dass ein Shell-Log-in auf den Servern nur in Ausnahmefällen notwendig ist. Insgesamt wird unser Deployment dadurch stabiler und sicherer.

Obwohl in der Kombination mit OEL6 noch Kernel-Probleme zu berücksichtigen sind, sind wir mit der Entscheidung für Docker sehr zufrieden. Die Zufriedenheit geht so weit, dass wir andere interne Services in Docker-Container migriert haben, beispielsweise ein Dashboard unseres Projekt-/CI-Status und ein interner Blog.


Inside Docker: Wenn Sie mehr über Docker wissen möchten, empfehlen wir Ihnen das Entwickler Magazin Spezial Vol. 2: Docker zum leichten Einstieg in die Container-Virtualisierung.

docker-coverMit Docker feiern Linux-Container momentan ein eindrucksvolles Comeback. Während der Einsatz von virtuellen Maschinen viele Vor-, aber auch zahlreiche Nachteile mit sich bringt, ist Docker eine leichtgewichtige, containerbasierte Alternative, die die System-Level-Virtualisierung auf ein neues Level hebt. Dabei ergänzt Docker das Deployment von Betriebssystemen und Webanwendungen um die Lösungen, die man beim Original schmerzlich vermisst. In diesem Jahr hat Docker eine hohe Dynamik entwickelt und wird in allen aktuellen Linux-Distributionen wie Redhat, SUSE oder Ubuntu ausgeliefert. Firmen wie Spotify, Google, BBC, eBay und seit kurzem auch Zalando setzen Docker bereits produktiv ein. Das Entwickler Magazin Spezial „Docker“ informiert kompetent über diese revolutionäre Technologie, von der viele meinen, dass sie eine neue Ära in der IT einläuten wird. Wir erklären technische Hintergründe, demonstrieren Best Practices und zeigen, wie Docker effektiv eingesetzt werden kann. Das Sonderheft vereint umfangreiches Wissen über die wichtigsten Aspekte von Docker, spannende Ideen für eigene Docker-Projekte und wertvolle Impulse für ihre strategische Planung.

 

Aufmacherbild: Black pipeline construction. 3d render image via Shutterstock / Urheberrecht: 3DMAVR

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -