1. Docker

In diesem ersten Bericht werden die grundlegenden Konzepte von Docker detailliert betrachtet. Zunächst werden die theoretischen Grundlagen von Docker erläutert, einschließlich der Containerisierungstechnologie, der Docker-Engine sowie wichtigen STandardbefehlen. Anschließend wird anhand eines Beispiels die Anwendung von Docker demonstriert. Hierzu wird eine Docker-Anwendung vorgestellt, die aus mehreren Containern besteht und eine Webanwendung mit einem Flask-Backend und einer MongoDB-Datenbank bereitstellt. Die Anwendung wird mithilfe eines NGINX-Reverse-Proxys bereitgestellt.

Zunächst soll jedoch auf den Unterschied zwischen Docker und einer virtuellen Maschine eingegangen werden.

1.1. Recap Virtuelle Maschinen

Eine virtuelle Maschine ist eine virtuelle Darstellung oder Emulation eines physischen Computers.

Die Virtualisierung ermöglicht die Erstellung mehrerer virtueller Maschinen auf einer einzigen physischen Maschine, wobei jede dieser virtuellen Maschinen über ein eigenes Betriebssystem und jeweils eigene Anwendungen verfügt. Eine VM kann nicht direkt mit einem physischen Computer (der Hardware) interagieren. Sie benötigt vielmehr eine schlanke Softwareschicht, den so genannten Hypervisor, der die Koordination zwischen ihr und der ihr zugrunde liegenden physischen Hardware übernimmt. Der Hypervisor ordnet jeder VM physische Rechenressourcen wie Prozessoren, RAM, persistenten Speicher und Speicherkapazitäten zu. Er hält jede VM von den übrigen getrennt, damit sie sich nicht gegenseitig beeinträchtigen. [ibm.com]

VM Funktionsweise

https://www.ionos.de/digitalguide/fileadmin/DigitalGuide/Screenshots_2018/DE-virtuelle-maschine.png

[ionos.de]

Jeder virtuellen Maschine liegt eine Hypervisor-Software (auch Virtual-Machine-Monitor, VMM) zugrunde. Der Hypervisor läuft als Anwendung auf dem Host-Betriebssystem (Hosted Hypervisor) oder setzt direkt auf der Hardware der physischen Maschine auf (Bare-Metal-Hypervisor) und verwaltet die vom Wirtssystem zur Verfügung gestellten Hardware-Ressourcen. Dabei erzeugt die Hypervisor-Software eine Abstraktionsschicht zwischen physischer Hardware und virtueller Maschine. Jede VM läuft isoliert vom Wirtssystem und von anderen Gastsystemen in einer eigenen virtuellen Umgebung.

VM Vorteile

  • Ressourceneinsatz und verbesserter ROI: Da mehrere VMs auf einem einzigen physischen Computer ausgeführt werden, müssen Kunden nicht jedes Mal einen neuen Server kaufen, wenn sie ein anderes Betriebssystem ausführen möchten, und können mit jeder Hardwarekomponente, die sie bereits besitzen, eine höhere Rendite erzielen.

  • Skalierung: Mit Cloud-Computing ist es einfach, mehrere Kopien derselben virtuellen Maschine bereitzustellen, um eine Zunahme der Belastung besser bewältigen zu können.

  • Portierbarkeit: VMs können nach Bedarf zwischen den physischen Computern in einem Netz verschoben werden. Dadurch ist es möglich, Workloads auf Server zu verteilen, die über freie Rechenleistung verfügen.

  • Flexibilität: Die Erstellung einer VM ist schneller und einfacher als die Installation eines Betriebssystems auf einem physischen Server, da Sie eine VM mit bereits installiertem Betriebssystem einfach klonen können.

  • Sicherheit: Im Vergleich zu Betriebssystemen, die direkt auf der Hardware ausgeführt werden, verbessern VMs die Sicherheit in mehrfacher Hinsicht. Eine VM ist eine Datei, die von einem externen Programm auf Schadsoftware gescannt werden kann.

1.2. Docker

Die Kernfunktionalität von Docker besteht in der Container-Virtualisierung von Anwendungen. Diese steht im Gegensatz zur Virtualisierung mit virtuellen Maschinen (VM). Mit Docker wird der Anwendungs-Code inklusive aller Abhängigkeiten in ein sogenanntes Image gepackt. Die Docker-Software führt die so verpackte Anwendung in einem Docker-Container aus. Images lassen sich zwischen Systemen bewegen und auf jedem System ausführen, auf dem Docker läuft.

Wie beim Einsatz einer virtuellen Maschine (VM) liegt ein Hauptaugenmerk bei Docker-Containern auf der Isolierung der laufenden Anwendung. Anders als bei VMs wird jedoch kein komplettes Betriebssystem virtualisiert. Stattdessen weist Docker jedem Container gewisse Betriebssystem- und Hardware-Ressourcen zu. Aus einem Docker-Image lassen sich beliebig viele Container erzeugen und parallel betreiben. So werden skalierbare Cloud-Dienste realisiert.

Docker-Engine

Die Docker-Engine läuft auf einem lokalen System oder einem Server und besteht aus zwei Komponenten:

  • Docker-Daemon dockerd: Läuft dauerhaft im Hintergrund und lauscht auf Zugriffe über die Docker-Engine-API. Auf entsprechende Befehle hin verwaltet dockerd Docker-Container und weitere Docker-Objekte.

  • Docker-Client docker: Dabei handelt es sich um ein Kommandozeilenprogramm. Der Docker-Client steuert Docker-Engine und stellt Befehle für Erstellung und Aufbau von Docker-Containern sowie Erstellung, Bezug und Versionierung von Docker-Images bereit.

[ionos.de]

Docker und VM im Vergleich

Docker

VM

kein komplettes Betriebssystem

sicherer (kein gemeinsames OS)

Teilt sich Kernel mit anderen

Sandbox-Funktion

leichtgewichtig und kompakt

startet schneller als VM

Verbund von mehreren Containern

mehrere Instanzen desselben Images möglich

x

Schwierige Persistenz von Daten

Belastung von Ressourcen

x

Nicht alle Anwendungen profitieren von Containern

Performance

x

Sicherheit (gemeinsamer Linux-Kernel)

[geekflare.com]

[open-telekom-cloud.com]

1.3. Dockerfile

Basis eines jeden Docker Containers ist sein Image. Ein Docker-Image ist eine leichtgewichtige, ausführbare Softwareeinheit, die alles enthält, was benötigt wird, um eine bestimmte Anwendung oder Umgebung auszuführen. Es enthält das Betriebssystem, die Laufzeit, Bibliotheken, Abhängigkeiten und die Anwendung selbst.

Ein Docker-Image wird normalerweise von einer sogenannten Dockerfile-Datei erstellt, die die Anweisungen zum Zusammenstellen des Images enthält. Dies kann beispielsweise das Herunterladen von Basisimages, das Installieren von Softwarepaketen, das Kopieren von Dateien und das Festlegen von Umgebungsvariablen umfassen.

# Base stage
FROM node:18.16.0 as base

WORKDIR /app

COPY src ./src
COPY db ./db

COPY package*.json ./

# install dependencies needed for image processing
RUN apt-get update && \
apt-get install -y graphicsmagick
RUN apt-get install -y ghostscript

RUN npm install --only=production

# Development stage
FROM base as development

ENV NODE_ENV=development

RUN npm install --only=development

CMD ["npm", "run", "dev"]

# Production stage
FROM base as production

ENV NODE_ENV=production

CMD ["npm", "start"]

Dieses Dockerfile wird verwendet um ein Image für ein App Backend zu erstellen. Es nutzt als Basis Node.js welches dann mit den Backspezifieschen Daten erweitert wird.

1.4. Docker Image

Bei einem Docker-Image handelt es sich um eine schreibgeschützte Vorlage zum Erzeugen eines oder mehrerer identischer Container. Docker-Images werden eingesetzt, um Anwendungen zu bündeln und auszuliefern.

Für den Bezug von Docker-Images kommen verschiedene Repositories zum Einsatz. Es gibt sowohl öffentliche als auch nichtöffentliche Repositories. Über die Docker-Kommandos ‚docker pull‘ und ‚docker push‘ wird ein Image von einem Repository bezogen bzw. dort abgelegt.

Docker-Images sind in Schichten (Layers) aufgebaut. Jedes Layer repräsentiert eine spezifische Änderung am Image. Damit ergibt sich eine durchgehende Versionierung der Images, was ein Rollback zu einem früheren Zustand ermöglicht. Zur Erzeugung eines neuen Images kann ein existierendes Image als Grundlage genutzt werden.

1.5. Docker Container

Ein Docker Container ist eine Einheit, die eine Anwendung und alle ihre Abhängigkeiten enthält. Er isoliert die Software von der Umgebung und garantiert, dass sie auf jedem System gleich funktioniert. Dies ist unabhängig vom Betriebssystem, der Hardware oder der Infrastruktur, auf der die Container laufen.

[Container]

Container-Betriebssystem und Union File System

Ein Docker-Container enthält ein minimales Betriebssystem und ein Union File System. Das Betriebssystem ist in der Regel eine lightweight Version eines Linux-Kernels. Wie oben beschrieben, besteht ein Docker-Image besteht aus mehreren schreibgeschützten Layers. Wenn ein Image in einem Container gestartet wird, fügt Docker eine neues beschreibbares Layer an der Spitze des Stapels hinzu. Dies wird als Union File System bezeichnet. Jedes Mal, wenn eine Datei geändert wird, erstellt Docker eine Kopie der Datei von den schreibgeschützten Layers bis in das oberste beschreibbare Layer. Dadurch bleibt die ursprüngliche (schreibgeschützte) Datei unverändert. Wenn ein Container gelöscht wird, geht das obere beschreibbare Layer verloren. Das bedeutet, dass alle Änderungen, die nach dem Start des Containers vorgenommen wurden, nicht mehr vorhanden sind. Um Daten zu persistieren, auch wenn ein Docker-Container gelöscht wird, können Volumes verwendet werden.

Volumes

Volumes sind der bevorzugte Mechanismus zum Persistieren von Daten, die von Docker-Containern erzeugt und verwendet werden. Sie existieren unabhängig vom Lebenszyklus des Containers, was bedeutet, dass Volumes existieren können, auch wenn der Container gelöscht wird. Außerdem sind Volumes eine praktische Möglichkeit, Daten zwischen dem Host und dem Container auszutauschen. Sie können aber auch zwischen Containern geteilt werden. Die Volumes kännen mit Docker CLI-Befehlen oder der Docker API verwaltet werden. Es gibt Befehle zum Erstellen, Auflisten, Untersuchen und Entfernen von Volumes.

Ports und Umgebungsvariablen

Ports ermöglichen die Kommunikation zwischen dem Docker-Container und der Außenwelt. Docker-Container können Ports öffnen, um Netzwerkanfragen zu empfangen und auf sie zu reagieren. Umgebungsvariablen können verwendet werden, um konfigurierbare Werte an den Container zu übergeben.

1.6. Container Netzwerke

Container können miteinander kommunizieren, indem sie in einem gemeinsamen Netzwerk verbunden werden. Dieses Netzwerk ist ein virtuelles Netzwerk, das von Docker verwaltet wird. Sobald sich Container im selben Netz befinden, können sie über die IP-Adressen der Container oder auch deren Namen miteinander kommunizieren. Da die Ports eines Containers standarmäßig nicht nach außen freigegeben sind, müssen diese explizit freigegeben werden. Dies kann durch die Angabe von Ports im Dockerfile oder durch die Verwendung von Flags der Docker-CLI-Befehle (--publish bzw. -p) erfolgen. Dadurch wird eine Firewall-Regel auf dem Host erstellt, die den eingehenden Datenverkehr auf den angegebenen Port an den Container weiterleitet.

[DockerNetworks]

1.7. Docker Compose

Mehrere Container miteinander zu vernetzen und zu organisieren kann schnell komplex werden. Docker Compose stellt ein Konzept vor, welches das Management von mehreren Containern einfach gestaltet. Neben Containers können in einer Compose-File auch Netzwerke, Speicherumgebungen oder andere Konfigurationen angegeben werden. Mithilfe nur einer YAML-Datei kann man mehrere Regeln für verschiedene Container definieren. In dieser Datei kann man beispielsweise das Image, das verwendete Netzwerk oder Ressourcenlimitierungen angeben. Zusätzlich ermöglicht Docker Compose auch das direkte Anlegen von Umgebungsvariablen.

[ComposeHow]

Beispielsweise können für Container, welche für Datenbankanwendungen verwendet werden, direkt der Datenbankname, sowie der Username angegeben werden:

services:
  database:
    image: "postgres:${POSTGRES_VERSION}"
    environment:
      DB: mydb
      USER: "${USER}"

[ComposeIntro]

Zusätzlich ermöglicht Docker Compose das einfache teilen und dublizieren von Konfigurationen. Diese Konfigurationen werden beim Start eines Services gecached. Somit kann ein Container bei einem Neustart sehr schnell gestartet werden.

[DockerWhy]

1.8. Beispiel Pyramid App

Als Beispeil für den Produktiven Einsatz verwenden wir die Docker Scripts für die Pyramid App und dessen Backend. Anhand des bereits gezeigten Dockerfiles und diesem docker-compose.yml script kann die Funktionsweise von Docker und der Vorteil seiner modularen Struktur gezeigt werden.

version: "3"

services:
  pyramid-api:
    build:
      context: .
      target: development
    container_name: "pyramid-api"
    restart: always
    expose:
      - "3000"
    networks:
      - api-backend
      - nginx-connector
    volumes:
      - api_data:/app/src
      - api_db:/app/db
    environment:
      MONGODB_USERNAME: ${MONGODB_USERNAME}
      MONGODB_PASSWORD: ${MONGODB_PASSWORD}

  mongo:
    image: mongo:6.0
    container_name: "pyramid-mongodb"
    restart: always
    networks:
      - api-backend
    expose:
      - 27017/tcp
    volumes:
      - mongodb:/data/db
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USERNAME}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD}
      MONGO_INITDB_DATABASE: admin

  nginx_reverse_proxy:
    image: 'jc21/nginx-proxy-manager:2.11.1'
    container_name: "pyramid-nginx"
    restart: always
    ports:
      - "80:80"
      - "443:443"
    networks:
      - nginx-connector
    volumes:
      - nginx_data:/data
      - nginx_ssl:/etc/letsencrypt

volumes:
  mongodb:
  api_db:
  api_data:
  nginx_data:
  nginx_ssl:

networks:
  api-backend:
  nginx-connector:

Das Backend dieser App besteht aus 3 Container, der Api mit der Logik, einer MongoDB welche die Daten Speicher und einem Nginx Reverse Proxy der die SSL Verschlüsselung übernimmt und die Anfragen an den richtigen API Container weiterleitet.

Die tatsächlichen Daten der Container werden in Volumes gespeichert. Die Container untereinander sind mittels zwei verschiedenen Netzwerken verbunden, um die Datensicherheit weiter zu erhöhen.

1.9. Anwendung der Docker-Grundlagen anhand eines Beispiels

Um die theoretischen Konzepte von Docker in der Praxis zu demonstrieren, haben wir eine Beispielanwendung entwickelt, die aus drei Containern besteht: einem Flask-Backend, einer MongoDB-Datenbank und einem NGINX-Reverse-Proxy. Die Anwendung dient als einfaches Setup, um die Kommunikation zwischen Containern und die Verwendung von Docker-Compose zu veranschaulichen.

Flask-Backend

Der Flask-Container dient als Backend für die bereitgestellte Anwendung. Dieser Container wird aus einem individuell definierten Dockerfile erstellt, das auf einem schlanken Python 3.10-Image basiert und in unserem Dockerfile Repository zu finden ist. Das Dockerfile enthält die notwendigen Anweisungen zum Erstellen des Images, wie das Installieren aller benötigten Python Pakete aus der requirements.txt-Datei und das Kopieren des Anwendungs-Codes in den Container. Der Flask-Container wird im Docker-Compose-File als Service definiert und mit dem Netzwerk api-backend verbunden, um mit dem nginx-Proxy kommunizieren zu können, sowie dem Netzwerk mongodb-network, um auf die MongoDB-Datenbank zugreifen zu können. Um den Flask-Server richtig zu konfigurieren, werden Umgebungsvariablen verwendet, die im Docker-Compose-File definiert sind. Diese Variablen enthalten die Verbindungsdaten zur MongoDB-Datenbank und den Port, auf dem der Flask-Server lauscht. Dieser Port wird im Docker-Compose-File freigegeben, um den Zugriff auf den Server seitens des Proxys zu ermöglichen ohne den Port jedoch auf dem Host-System zu veröffentlichen. Zusätzlich werden lokale Verzeichnisse auf Volumes im Container gemappt. In diesem Fall wird das lokale Verzeichnis ./flask in das Container-Verzeichnis /app gemountet. Um den Container ordnungsgemäß zu beenden, wird für Flask ein spezielles Stop-Signal benötigt, SIGINT. Folgender Code zeigt den Ausschnitt aus dem Docker-Compose-File, der den Flask-Service definiert: (Das gesamte Docker-Compose-File ist im Compose Repository verfügbar)

services:
  flask-backend:
    build:
      context: ./flask
    container_name: "flask-backend"
    stop_signal: SIGINT
    environment:
      - FLASK_SERVER_PORT=9090
      - MONGODB_URI=mongodb://mongo:27017
    restart: always
    networks:
      - api-backend
      - mongodb-network
    expose:
      - 9090
    volumes:
      - ./flask:/app
    depends_on:
      - mongo

MongoDB-Datenbank

Der Datenbank-Container basiert auf einem offiziellen MongoDB-Image, das von Docker-Hub bezogen wird. Der Container wird nur mit dem Netzwerk mongodb-network verbunden, sodass der Flask-Container auf die Datenbank zugreifen kann, aber vom nginx-Proxy isoliert ist. Auch für diesen Container wird ein Volume angelegt. Im folgenden ist der entsprechende Abschnitt aus dem Docker-Compose-File zu sehen:

mongo:
  image: mongo:6.0
  container_name: "mongodb"
  restart: always
  networks:
    - mongodb-network
  volumes:
    - ./mongodb_data:/data/db

NGINX-Reverse-Proxy

Der NGINX-Reverse-Proxy-Container wird aus einem offiziellen NGINX-Image erstellt und dient als Schnittstelle zwischen dem Backend und der Außenwelt. Der Proxy leitet Anfragen an den Flask-Server weiter und übernimmt die SSL-Verschlüsselung. Die Flask-Website ist über Port 80 der Proxy-Adresse erreichbar. Der NGINX-Container wird mit dem Netzwerk api-backend verbunden, um mit dem Flask-Backend zu kommunizieren.

nginx_reverse_proxy:
  image:  nginx
  container_name: "nginx"
  restart: always
  ports:
    - "80:80"
  networks:
    - api-backend
  environment:
    - FLASK_SERVER_ADDR=flask-backend:9090
  command: /bin/bash -c "envsubst < /tmp/nginx.conf > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"
  volumes:
    - ./nginx/nginx.conf:/tmp/nginx.conf
  depends_on:
    - flask-backend

Um die Konfiguration des NGINX-Containers zu vereinfachen, wurde ein Konfigurationsfile erstellt, welches mittels eines volumes in den Container gemountet wird. Um die Konfiguration vorzunehmen, muss das File zunächst durch die default-Konfiguration von NGINX ersetzt werden, was durch den command-Befehl im Compose-File ausgeführt wird. Anschließend wird der NGINX-Server gestartet. daemon off verhindert, dass der NGINX-Server nur im Hintergrund gestartet wird, was für die Verwendung in einem Container nicht sinnvoll ist. Die NGINX-Konfiguration sieht wie folgt aus:

server {
  listen       80;
  server_name  localhost;

      location / {
              proxy_pass http://$FLASK_SERVER_ADDR;
      }
  include /etc/nginx/extra-conf.d/*.conf;
}

Daraus wird ersichtlich, dass der NGINX-Server auf Port 80 lauscht und Anfragen an den Flask-Server weiterleitet.

[NGINX]

Test der Anwendung

Um die Anwendung zu testen, kann das Docker-Compose-File mit dem Befehl docker-compose up gestartet werden. Die Flask-Website ist dann über die Adresse http://localhost erreichbar und sollte die Meldung ‚MongoDB is available‘ anzeigen. Um die Anwendung zu beenden, kann der Befehl docker-compose down verwendet werden.

Der Testcode der Flask-Anwendung ist in unserem Repository einsehbar. Dort ist eine simple Methode implementiert, die den Status des MongoDB-Servers überprüft und eine entsprechende Meldung zurückgibt.

Zusammenfassend zeigt dieses Beispiel, wie Docker-Container miteinander kommunizieren und wie Docker-Compose verwendet wird, um mehrere Container zu verwalten und zu orchestrieren.

[DockerCompose]

1.10. Standardbefehle

Docker Repositories

  • docker pull <image>:<tag>: Lädt ein Image von einem Repository herunter.

  • docker search <image>: Sucht nach einem Image in einem Repository (standardmäßig DockerHub).

Images

  • docker images: Zeigt alle Images an.

  • docker rmi <image>:<tag>: Löscht ein Image.

  • docker build -t <image>:<tag> .: Erstellt ein Image aus einem Dockerfile (muss im aktuellen Verzeichnis liegen).

Container

  • docker run <image>:<tag>: Startet einen Container aus einem Image.
    • -d: Startet den Container im Hintergrund („detached mode“).

    • -p <host-port>:<container-port>: Veröffentlicht einen Port des Containers auf den Host.

    • -v <host-path>:<container-path>: Bindet ein Volume.

    • -it: Startet den Container im interaktiven Modus.

    • --name <name>: Gibt dem Container einen Namen.

    • --network <network>: Verbindet den Container mit einem Netzwerk.

    • --gpus=all: Aktiviert GPU-Unterstützung.

    • --network=host: Verwendet das Host-Netzwerk.

  • docker stop <container>: Stoppt einen Container.

  • docker exec -it <container> <command>: Führt ein Kommando in einem laufenden Container aus.

  • docker rm <container>: Löscht einen Container.

  • docker ps: Zeigt alle laufenden Container an.

  • docker ps -a: Zeigt alle Container an.

Netzwerke

  • docker network create <network>: Erstellt ein Netzwerk.

  • docker network ls: Zeigt alle Netzwerke an.

Volumes

  • docker volume create <volume>: Erstellt ein Volume.

  • docker volume ls: Zeigt alle Volumes an.

  • docker volume rm <volume>: Löscht ein Volume.

Docker Compose

  • docker-compose up: Startet alle Services in einem Docker-Compose-File.

  • docker-compose down: Stoppt alle Services in einem Docker-Compose-File.

  • docker-compose ps: Zeigt alle Services in einem Docker-Compose-File an.

1.11. Literaturangaben