Microservices-Architekturen, aber auch klassische Softwaresysteme sind auf die reibungslose Zusammenarbeit aller Komponenten angewiesen. Das schließt in der Regel auch Infrastrukturdienste wie Datenbanken, Messaging-Systeme oder HTTP-APIs mit ein. Entwicklerinnen und Entwickler sollten daher bei ihrer täglichen Arbeit nicht auf Integrationstests verzichten. Eine geeignete Testumgebung mit Docker-Containern lässt sich mit Testcontainers oder Docker Compose unkompliziert auch lokal aufsetzen und steuern.

Damit sich die Tests nicht negativ auf die Produktivität auswirken und den Entwicklungsflow unterbrechen, sollten sie möglichst schnell durchlaufen. Auf typischer Notebook-Hardware können bei Integrationstests aber schnell viele Minuten vergehen, bis alles kompiliert ist, alle Container gestartet, mit Testdaten befüllt und die Tests abgeschlossen sind. Wird die CPU darüber hinaus durch parallel laufende Prozesse wie dem für kollaboratives Remote Mob Programming notwendigen Screensharing zusätzlich belastet, gelangen auch leistungsstarke Notebooks schnell an ihre Grenzen. Auf Nicht-Linux-Systemen kommt verschärfend hinzu, dass Docker nur in einer virtuellen Umgebung mit limitierten Ressourcen läuft.

Es gibt jedoch einen einfachen Weg, die Tests zu beschleunigen: Die Docker Engine lässt sich separat auf einem leistungsfähigen Rechner installieren, der als Docker-Server dient. Testcontainers greift dann über die Docker-REST-API via HTTPS auf den Docker Daemon zu. Auf diese Weise lassen sich Container auf dem Server ausführen und sind dort über entsprechend gemappte Ports erreichbar. Der lokale Arbeitsplatzrechner ist entlastet und seine Kapazitäten stehen für die eigentlichen Tests bereit.

Testcontainers-Aufbau mit ausgelagerter Docker Engine
Testcontainers-Aufbau mit ausgelagerter Docker Engine

Installation des Docker-Servers

Das Einrichten und Konfigurieren eines Docker-Servers soll im Folgenden am Beispiel eines lokalen Systems gezeigt werden. Alternativ wäre auch ein vergleichbarer Cloud-Server denkbar. Der Rechner verfügt über vier CPU-Kerne, acht GByte RAM und ist mit einer SSD ausgestattet. Als Betriebssystem kommt die aktuelle LTS-Version von Ubuntu, 20.04 LTS x64, zum Einsatz. Linux eignet sich für die Aufgabe grundsätzlich besonders gut, da es Docker nativ unterstützt.

Um Docker auf Ubuntu zu installieren, sind dennoch eine Reihe von Befehlen erforderlich:

sudo apt update && \
DEBIAN_FRONTEND="noninteractive" TZ="Europe/Berlin" sudo apt install -y tzdata && \
sudo apt install -y apt-transport-https ca-certificates curl software-properties-common && \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - && \
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable" && \
sudo apt update && \
sudo apt install -y docker-ce && \
docker --version
Listing 1: Docker auf Ubuntu installieren

Docker HTTPS Endpoint aktivieren

Der Docker Daemon unterstützt verschiedene Sockets, um per Docker Engine API auf die Docker Engine zuzugreifen. Standardmäßig kommuniziert der Docker Client auf dem lokalen Unix IPC Socket unix:///var/run/docker.sock mit dem Docker Daemon – alternativ lässt sich auch ein reguläres TCP Socket verwenden.

Grundsätzlich würde es genügen, den Docker Daemon dockerd auf den TCP Port 2375 zu binden:

/usr/bin/dockerd -H=0.0.0.0:2375

Dieser Ansatz ist jedoch zu unsicher, da er einen unverschlüsselten und unautorisierten Zugriff auf den Docker Host und damit letztlich auf den root-Account des Betriebssystems erlaubt. Entwicklerinnen und Entwickler sollten den Zugriff daher per TLS-Client-Zertifikat absichern. Dazu sind auf dem Server die in Listing 2 aufgeführten OpenSSL-Befehle auszuführen, um Private-Keys und Zertifikate zu erstellen:

# Run as root
sudo -i

# Resolve hostname and IP addresses for server certificate.
MY_HOSTNAME=`hostname`  && \
MY_PRIVATE_IP=`hostname -i` && \
MY_PUBLIC_IP=`curl -s ifconfig.me/ip`  && \
echo "MY_HOSTNAME=$MY_HOSTNAME"  && \
echo "MY_PRIVATE_IP=$MY_PRIVATE_IP" && \
echo "MY_PUBLIC_IP=$MY_PUBLIC_IP"

# Create CA key and certificate
mkdir -p /etc/docker/certs && cd /etc/docker/certs && \
openssl rand -base64 32 > ca-passphrase.txt && \
openssl genrsa -aes256 -passout file:ca-passphrase.txt -out ca-key.pem 4096 && \
openssl req -new -x509 -days 1095 -key ca-key.pem -passin file:ca-passphrase.txt -sha256 -out ca.pem -subj "/C=DE/O=private/CN=$MY_HOSTNAME"

# Create server key and a signed certificate for hostname and IPs
echo subjectAltName = DNS:$MY_HOSTNAME,IP:$MY_PRIVATE_IP,IP:$MY_PUBLIC_IP,IP:127.0.0.1 > extfile.cnf && \
echo extendedKeyUsage = serverAuth >> extfile.cnf && \
openssl genrsa -out server-key.pem 4096 && \
openssl req -subj "/CN=$MY_HOSTNAME" -sha256 -new -key server-key.pem -out server.csr && \
openssl x509 -req -days 1095 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -passin file:ca-passphrase.txt -CAcreateserial -out server-cert.pem -extfile extfile.cnf

# Create client key and certificate
cd /etc/docker/certs && \
openssl genrsa -out key.pem 4096
openssl req -subj '/CN=client' -new -key key.pem -out client.csr
echo extendedKeyUsage = clientAuth > extfile-client.cnf
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -passin file:ca-passphrase.txt -CAcreateserial -out cert.pem -extfile extfile-client.cnf
chmod 644 key.pem
Listing 2: TLS-Absicherung per OpenSSL

Anschließend lässt sich TCP für den Docker Daemon aktivieren – in Ubuntu üblicherweise in systemd. Nach dem Start von

sudo systemctl edit docker.service

lässt sich im geöffneten Editor folgender Eintrag setzen:

[Service]
ExecStart=
ExecStart=/usr/bin/dockerd -H=fd:// -H=unix:///var/run/docker.sock --tlsverify --tlscacert=/etc/docker/certs/ca.pem --tlscert=/etc/docker/certs/server-cert.pem --tlskey=/etc/docker/certs/server-key.pem -H=0.0.0.0:2376

Nach einem Neustart mit

sudo systemctl daemon-reload && \
sudo systemctl restart docker.service

akzeptiert der Docker Daemon zertifizierte Connections auf dem Port 2376.

docker --tlsverify --tlscacert=/etc/docker/certs/ca.pem --tlscert=/etc/docker/certs/cert.pem --tlskey=/etc/docker/certs/key.pem -H=$MY_HOSTNAME:2376 system info

Achtung: Auch wenn der Zugriff auf den Docker-Host nun gesichert erfolgt, sind die durch Container geöffneten Ports gegebenenfalls öffentlich erreichbar. Der Server sollte daher in jedem Fall hinter einer Firewall liegen beziehungsweise nur über ein VPN erreichbar sein.

Client-Konfiguration

Zum Konfigurieren des Clients lassen sich nun der Client-Private-Key und das CA-Zertifikat auf den lokalen Rechner in das Verzeichnis ~/.docker/ installieren – beispielsweise durch Kopieren vom Docker Host per SCP. Dabei sind die erreichbare IP-Adresse beziehungsweise der Hostname des Docker-Hosts anzugeben:

# On the local machine
DOCKER_SERVER=...
mkdir -p ~/.docker/
scp ubuntu@$DOCKER_SERVER:/etc/docker/certs/{ca,key,cert}.pem ~/.docker/

Damit der Docker-Client weiß, welchen Docker-Host er ansprechen soll, ist in der Run Configuration je nach verwendeter Shell in ~/.zshrc oder ~/.bashrc noch die Umgebungsvariable DOCKER_HOST zu setzen. Zudem muss DOCKER_TLS_VERIFY signalisieren, dass TLS zum Einsatz kommt:

# Replace $DOCKER_SERVER with the hostname or IP address of the server
export DOCKER_HOST=tcp://$DOCKER_SERVER:2376
export DOCKER_TLS_VERIFY=1
export DOCKER_CERT_PATH=$HOME/.docker/

Anschließend lassen sich einem neuen Terminal-Fenster sämtliche docker-Befehle an den Docker-Server weiterreichen:

$ docker system info

...
Operating System: Ubuntu 20.04.1 LTS
OSType: linux
...

Verwenden des entfernten Docker-Hosts in Testcontainers

Testcontainers erkennt automatisch die Umgebungsvariable DOCKER_HOST – zusätzliche Konfigurationsschritte sind daher nicht notwendig. Zu prüfen ist jedoch, dass in den Tests die Container-Dienste nicht über localhost konfiguriert sind, sondern über die von den Testcontainern bereitgestellten Methoden.

Listing 3 zeigt ein Spring-Boot-Beispiel zum Verwenden eines MongoDB-Testcontainers, der die korrekte ReplicaSet-URL liefert:

static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:4.2");

static {
  mongoDBContainer.start();
}

@DynamicPropertySource
static void testcontainerProperties(DynamicPropertyRegistry registry) {
  registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl);
}
Listing 3: MongoDB-Testcontainer

Geschwindigkeitsvergleich

Um einen Eindruck von den erzielbaren Geschwindigkeitsvorteilen zu erhalten, dient ein realitätsnahes Spring-Boot-Projekt mit 224 Tests als Basis für einen Vergleich. Die Testkonfiguration für JUnit-Tests in einer Spring-Boot-Anwendung mit mehreren Spring Contexts enthält Testcontainer für MongoDB, Kafka und MockServer. Die verwendeten Server sind mit vier CPU-Kernen und acht GByte RAM ausgestattet. Der Vergleich liefert die jeweilige Testdurchführungsdauer für einen Docker-Host auf einem MacBook Pro (13 Zoll), einem dedizierten Server im gleichen Netzwerk und einer Cloud-Instanz. Um dabei sicherzustellen, dass alle Docker-Images lokal gecacht vorliegen, erfolgten sämtliche Testdurchläufe mehrfach.

Testlaufzeiten im Vergleich
Docker Host Testdauer (in Minuten)
Docker for Mac 4:53
Lokaler Server 2:53
AWS EC2 c5.xlarge 5:14

Der Geschwindigkeitsvorteil des lokalen Servers gegenüber dem Arbeitsplatzrechner beträgt zwei Minuten, was sich im täglichen Gebrauch enorm positiv auswirkt. Auf einen typischen Arbeitstag hochgerechnet können schnell 30 Minuten oder mehr zusammenkommen.

Das überraschend schlechte Abschneiden der EC2-Cloud-Instanz lässt sich vermutlich auf die deutlich höhere Netzwerklatenz zurückführen, da sowohl die Tests wie auch die Anwendung intensiv mit den Containern interagieren.

Fazit

Sorgfältige Integrationstests sind essenziell für den Erfolg von Software-Projekten. Testcontainers empfehlen sich dabei als exzellenter Weg, um die benötigten Infrastrukturdienste mit Docker-Containern aufzusetzen.

Dauern die Tests allerdings zu lange, leidet darunter die Gesamtproduktivität des Projekts, und Entwicklerinnen und Entwickler tendieren schlimmstenfalls dazu, auf das Schreiben weiterer Integrationstests zu verzichten.

Es lohnt sich daher in die Optimierung der Testdurchführungsdauer zu investieren. Ein separater, leistungsfähiger Server zum Ausführen der Docker-Container ist ein einfacher Weg, um die Geschwindigkeit der Tests signifikant zu verbessern.

TAGS