Bereits vor über zwei Jahren hat sich mein geschätzter Kollege und Vorgänger in dieser Kolumne mit den Grundlagen von Docker beschäftigt. Und obwohl zwei Jahre in der IT eine Ewigkeit darstellen, sind die dort enthaltenen Informationen immer noch gültig und dienen als guter Einstieg in das Thema Docker.

Zur Erinnerung: Docker wird auf einem sogenannten Docker-Host installiert. Über den Docker-Client kann man Images bauen, deren Inhalte in Dockerfiles beschrieben werden. Ein Image wird dabei später über seinen Namen und gegebenenfalls die Version identifiziert.

Möchte man ein Docker-Image auf einem Docker-Host ausführen, genügt dafür der Aufruf von docker run image-name. Ist das Image auf dem Host noch nicht vorhanden, lädt Docker es automatisch aus einer Registry herunter. Standardmäßig wird hierfür die öffentliche Registry genutzt. Es lässt sich aber auch eine eigene verwenden.

Beim Start eines Containers können Ports exportiert werden. Diese sind anschließend über den Docker-Host von außen erreichbar. Damit Container untereinander kommunizieren können, nutzt man sogenannte Links oder virtuelle Docker-Netzwerke.

Szenario

Als Szenario für diesen Artikel dient ein System, das aus zwei Services besteht.

package de.mvitz.dce.frontend;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
public final class FrontendController {
  private final RedisTemplate<String, Long> redisTemplate;
  private final String hostname;

  public FrontendController(RedisTemplate<String, Long> redisTemplate,
      @Value("${hostname}") String hostname) {
    this.redisTemplate = redisTemplate;
    this.hostname = hostname;
  }

  @GetMapping(produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
  public Map<String, ?> index() {
    final long count = redisTemplate.opsForValue()
      .increment("counter", 1);

    final Map<String, Object> result = new HashMap<>();
    result.put("count", count);
    result.put("instance", hostname);
    return result;
  }
}
Listing 1: Code des Frontend-Controllers

Starten und Stoppen des Systems mit Docker

Der erste Schritt, um das System nun auf einem Docker-Host laufen zu lassen, besteht darin, für beide Services jeweils ein Docker-Image zu bauen und dieses anschließend zu starten.

Für Redis gibt es bereits eine Vielzahl an fertigen Images im Docker-Hub. Um Aufwand zu sparen, wird deshalb das offizielle Docker-Image genutzt. Zum Starten eines Containers mit Redis genügt der Aufruf docker run -d --name redis redis. Ist das Image noch nicht auf dem Docker-Host vorhanden, lädt dieser es herunter. Nach kurzer Zeit ist der Container dann erfolgreich gestartet.

Um das Frontend als Container zu starten, muss zuerst ein Image erzeugt werden. Natürlich gibt es hierfür kein fertiges Image und somit wird ein eigenes Dockerfile (s. Listing 2) benötigt.

FROM airhacks/java
ADD target/frontend.jar /frontend.jar
EXPOSE 8080
CMD java -jar /frontend.jar
Listing 2: Dockerfile für das Frontend

Dabei wird als Basis ein Image genutzt, in dem bereits ein JDK installiert wurde – hier airhacks/java. Anschließend fügen wir unser zuvor mit Maven gebautes JAR hinzu und teilen Docker noch mit, dass der Service auf dem Port 8080 horcht. Als Letztes geben wir noch den Befehl an, mit dem das Frontend innerhalb des Docker-Containers gestartet wird.

Aus diesem Dockerfile können wir nun mit den Befehlen docker build -t frontend . ein Image erzeugen und dieses anschließend mit docker run -d --name frontend --link redis:redis --env SPRING_REDIS_HOST=redis -p 8080:8080 frontend starten.

Anschließend ist das System unter der IP des Docker-Hosts und dem Port 8080 erreichbar. Listing 3 zeigt eine exemplarische Abfragesequenz mit curl.

$ curl http://192.168.99.100:8080
{"instance":"e9bf3372b7b3","count":1}
$ curl http://192.168.99.100:8080
{"instance":"e9bf3372b7b3","count":2}
Listing 3: Exemplarischer Aufruf des Systems

Um das komplette System nun wieder sauber zu stoppen, reicht der Befehl docker stop frontend redis. Zusätzlich müssen diese Container vor dem nächsten Start noch mit dem Befehl docker rm frontend redis entfernt werden. Dies ist notwendig, damit Docker beim nächsten Start die zugewiesenen Namen erneut verwenden kann.

Bereits hier kann man erkennen, dass schon die Verwaltung eines kleinen Systems über einzelne Docker-Befehle mühsam ist. Es müssen sämtliche Images einzeln gebaut und gestartet werden. Beim Starten muss auf die richtige Reihenfolge und die korrekte Angabe von Links, exportierten Ports und Umgebungsvariablen geachtet werden. Zudem vergisst man auch beim Stoppen des Systems schnell, die Container zusätzlich zu entfernen.

Der erste Reflex eines Entwicklers ist es, an dieser Stelle die notwendigen Befehle zu automatisieren. Glücklicherweise wurde dies auch von Docker erkannt und bietet deshalb mit Docker Compose ein Tool an, das genau für diesen Use-Case entwickelt wurde.

Docker Compose

Um das System mit Docker Compose verwalten zu können, wird die Datei docker-compose.yml angelegt. Innerhalb dieser Datei wird das komplette System in YAML beschrieben (s. Listing 4).

version: '3'

services:
  database:
    image: redis
  frontend:
    build: .
    links:
      - database
    environment:
      - SPRING_REDIS_HOST=database
    ports:
      - 8080:8080
Listing 4: Docker Compose-Beschreibung des Systems

Auf der ersten Ebene werden dabei die vorhandenen Services des Systems aufgelistet. Jeder Service enthält wiederum diverse Eigenschaften, die Docker benötigt, um das Image zu finden oder zu bauen. Und auch Umgebungsvariablen, exportiere Ports und die richtigen Verlinkungen zwischen den Services werden hier definiert. Durch die Angabe der Links kann Docker zudem die richtige Reihenfolge zum Starten herausfinden und erkennt dabei sogar zirkuläre Abhängigkeiten.

Das Starten des gesamten Systems kann nun mit dem einzelnen Befehl docker-compose up -d erledigt werden und auch das anschließende Stoppen ist dank des Befehls docker-compose down sehr einfach. Docker Compose übernimmt hierbei zusätzlich zum Stoppen auch direkt das Entfernen der gestoppten Container. Die Option -d sorgt dabei dafür, dass die Container im Hintergrund laufen.

Betrachtet man nach dem Start einmal die laufenden Container (s. Listing 5), so stellt man fest, dass die Container und das Frontend-Image ein Präfix und die Container zudem ein Suffix enthalten.

$ docker ps
CONTAINER ID   IMAGE          NAMES
b5443b56b572   dce_frontend   dce_frontend_1
2244a09993a6   redis          dce_database_1
Listing 5: Laufende Container mit Docker Compose

Das Präfix dient dazu, das System auf einem Docker-Host mehrfach starten zu können. Als Standard nutzt Docker Compose den Namen des Ordners, in dem sich die YAML-Datei befindet, als Präfix. Dies lässt sich jedoch durch die Angabe von -p NAME oder über die Umgebungsvariable COMPOSE_PROJECT_NAME ändern. Zusätzlich dürfen zwischen den Systemen keine Kollisionen entstehen. Ein zweites Starten des Systems mit dem Namen dce2 führt zum Beispiel dazu, dass das Frontend aufgrund des exportierten Ports 8080 nicht startet. Die zweite Datenbank läuft jedoch trotzdem. Docker Compose unterstützt somit kein atomares Starten des Systems. Der Versuch, anschließend das nur halb gestartete System wieder zu stoppen, funktioniert jedoch erfolgreich und hinterlässt keine Spuren (s. Listing 6).

$ docker-compose -p dce2 up -d
...
ERROR: for frontend  Cannot start service frontend: driver failedprogramming external connectivity on endpoint dce2_frontend_1 (e459de512596823f33bdb6a0498749370bd6c6eec683da8f709f9f7c99f39cb7): Bind for 0.0.0.0:8080 failed: port is already allocated
ERROR: Encountered errors while bringing up the project.
$ docker ps
CONTAINER ID   IMAGE          NAMES
3b6948326349   redis          dce2_database_1
b5443b56b572   dce_frontend   dce_frontend_1
2244a09993a6        redis               dce_database_1
$ docker-compose -p dce2 down
Stopping dce2_database_1 ... done
Removing dce2_frontend_1 ... done
Removing dce2_database_1 ... done
Removing network dce2_default
$ docker ps
CONTAINER ID   IMAGE          NAMES
b5443b56b572   dce_frontend   dce_frontend_1
2244a09993a6   redis          dce_database_1
Listing 6: Kollision beim parallelen Start

Skalieren von Services

Neben dem einfachen Starten und Stoppen bietet Docker Compose die Möglichkeit, einzelne Services innerhalb des Systems zu skalieren, indem mehrere Container für ein Image instanziert werden. Dies ist auch der Grund für das Suffix am Containernamen.

Docker Compose kennt hierzu das Kommando scale. Man könnte daher annehmen, dass sich eine zweite Instanz des Frontend-Containers einfach per Befehl docker-compose scale frontend=2 oder ähnlichem starten ließe. Leider funktioniert das praktisch nicht, da es zu einem Portkonflikt kommt.

Um also das Frontend zu skalieren, wird ein Load-Balancer benötigt. Wie für Redis gibt es auch hierfür verschiedene fertige Lösungen im Docker-Hub. Letztendlich habe ich mich für dockercloud/haproxy entschieden. Nach dem Hinzufügen des Load-Balancers als dritten Service (s. Listing 7) lässt sich nun das Frontend um eine zweite Instanz erweitern, und anschließend werden die Requests abwechselnd von den beiden Instanzen beantwortet (s. Listing 8).

version: '3'

services:
  database:
    image: redis
  frontend:
    build: .
    links:
      - database
    environment:
      - SPRING_REDIS_HOST=database
  load-balancer:
    image: dockercloud/haproxy
    links:
      - frontend
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    ports:
      - 80:80
Listing 7: Docker Compose-System mit Load-Balancer
$ curl http://192.168.99.100:80
{"instance":"f91527a1fa9f","count":1}
$ curl http://192.168.99.100:80
{"instance":"0c715336c9fb","count":2}
Listing 8: Systemaufrufe über den Load-Balancer

Ausfallsicherheit und Deployments mit Docker Compose

Neben der Skalierung unterstützt Docker Compose auch bei den Themen Ausfallsicherheit und Deployment. Beides funktioniert jedoch nur in Kombination mit Docker Swarm (s. Kasten „Docker Swarm”).

Docker Swarm

Docker Swarm

Mit Docker Swarm (1) lassen sich mehrere Docker-Hosts zu einem „Swarm“ zusammenschließen. Nach außen hin verhält sich ein solcher Schwarm wie ein einzelner Docker-Host, intern jedoch kann zum Beispiel der Ausfall eines Docker-Hosts durch einen Neustart aller dort vorher laufenden Container auf einem anderen Docker-Host kompensiert werden.

Seit Version 1.13 von Docker Swarm und Version 3 des Docker Compose-Dateiformates ist es nun auch möglich, beides zusammen zu verwenden. Somit kann ein System aus mehreren Services innerhalb eines Schwarms deployt und gemanagt werden.

Für die Ausfallsicherheit sorgt hierbei eine Kombination aus dem mit Docker Version 1.12 eingeführten Healthcheck und der in der Docker Compose-Datei angegebenen Restart Policy. Somit lässt sich zum Beispiel konfigurieren, dass ein Container, dessen Healthcheck fehlschlägt, automatisch neu gestartet wird. Einen guten Einstieg zu Healthchecks bietet der Blog Post „Test-drive Docker Healthcheck in 10 minutes“ von Alex Ellis.

Zusätzlich lässt sich über die Anzahl der Replikas sicherstellen, dass dauerhaft mehr als eine Instanz eines jeden Services läuft. Docker Swarm sorgt zudem dafür, dass nicht alle Replicas auf demselben Docker-Host laufen.

Die Kombination aus Docker Compose und Swarm bietet zudem die Möglichkeit zu definieren, wie Container aktualisiert werden sollen. Somit können Updates ohne Downtime ausgerollt werden. Mit geschickter Wahl der Konfigurationswerte lassen sich sogar sogenannte „Canary Releases“ umsetzen.

Weitere Features von Docker Compose

Neben Services lassen sich mit Docker Compose auch Docker Networks verwalten. Zum Einstieg bieten sich hier die Blog Posts „Understanding Docker Networking Drivers And Their Use Cases“ sowie „Docker Networking and DNS“ an.

Außerdem lassen sich Docker Compose-Dateien auch wiederverwenden oder für die Nutzung in verschiedene Umgebungen konfigurieren. Als Einstiegspunkt bietet sich hierfür die offizielle Dokumentation an.

Fazit

Die manuelle Verwaltung eines größeren Systems aus mehreren Services mit Docker wird schnell unübersichtlich und kompliziert. Docker bietet hierzu mit Docker Compose die Möglichkeit, ein solches System kompakt innerhalb einer Datei zu definieren und anschließend mit wenigen Befehlen zu verwalten.

In Kombination mit Docker Swarm ist es seit Kurzem nun auch möglich, ein mit Compose definiertes System in Produktion zu überführen. An dieser Stelle tritt Docker jedoch sehr stark in Konkurrenz zu Container-Cluster-Managern wie Kubernetes oder Mesos. Die Zeit wird zeigen, welches System sich durchsetzt.


  1. https://www.docker.com/products/docker–swarm  ↩