| FOTO | AUTO | EDV | AUDIO |

Von Docker über Podman bis OpenShift — ein Reiseführer

Ein Tutorial für alle, die Container bisher nur vom Hörensagen kennen und wissen wollen, warum plötzlich alle davon reden. Von den Grundlagen über den Werkzeugvergleich bis zum fertig eingerichteten Podman-Server unter RHEL.

Wenn man in „Per Anhalter durch die Galaxis„ eines lernen kann, dann dieses: Ein guter Reiseführer ist nicht der, der alles erklärt, sondern der, der einem die Panik nimmt. Genau das ist der Anspruch dieses Dokuments. Container wirken von außen wie ein Haufen Buzzwords, hinter denen sich vermutlich Magie oder zumindest ein Vogonisches Bauamt verbirgt. Tatsächlich stecken dahinter ein paar erstaunlich handfeste Linux-Funktionen, die man an einem Nachmittag verstehen kann.

Die farbig hinterlegten Kästen benötigen das DokuWiki-Plugin wrap. Fehlt es, erscheint der Text unformatiert, bleibt aber lesbar.

Zeitbedarf: Kapitel 1–5 an einem Tag, der Rest je nach Vertiefung. Das HowTo in Kapitel 14 lässt sich in etwa zwei Stunden abarbeiten.


1. Einleitung

1.1 Was ist ein Container — und was nicht

Ein Container ist ein ganz normaler Prozess auf einem ganz normalen Linux-System, dem man vorgaukelt, er sei allein auf der Welt.

Das ist keine Vereinfachung für Einsteiger, das ist die ganze Wahrheit. Wenn du auf dem Host ps aux eingibst, siehst du die Prozesse deiner Container einfach in der Liste stehen. Sie laufen auf demselben Kernel, sie verwenden dieselbe CPU, sie werden vom selben Scheduler verwaltet. Der Unterschied ist nur: Der Prozess selbst sieht das nicht. Er hat seine eigene Sicht auf das Dateisystem, auf die Prozessliste, auf das Netzwerk und auf den Hostnamen.

Ein Container ist also kein:

  • kleiner virtueller Computer,
  • eigenes Betriebssystem,
  • Sicherheitsgefängnis mit Panzerglas,
  • magischer Ort, an dem Software plötzlich funktioniert, die vorher kaputt war.

Was ein Container hingegen tatsächlich mitbringt: ein fest verpacktes Dateisystem samt aller Bibliotheken, die die Anwendung braucht, und eine Beschreibung, wie sie zu starten ist. Das klingt unspektakulär, ist aber der Grund für den ganzen Wirbel — dazu gleich mehr.

1.2 Abgrenzung zur Virtualisierung

Der Vergleich mit virtuellen Maschinen ist der übliche Einstieg, und er ist auch der nützlichste.

Eine virtuelle Maschine simuliert Hardware. Darauf installiert man ein vollständiges Betriebssystem mit eigenem Kernel, eigenem Bootvorgang, eigenem systemd, eigener Paketverwaltung. Das kostet Speicher, es kostet Startzeit, und es kostet Pflegeaufwand — jede VM will gepatcht werden.

Ein Container teilt sich den Kernel mit dem Host. Er bringt nur das mit, was oberhalb des Kernels liegt: Bibliotheken, Konfiguration, die Anwendung selbst.

   Virtuelle Maschinen                    Container
 ┌──────┐ ┌──────┐ ┌──────┐          ┌──────┐ ┌──────┐ ┌──────┐
 │ App  │ │ App  │ │ App  │          │ App  │ │ App  │ │ App  │
 │ Libs │ │ Libs │ │ Libs │          │ Libs │ │ Libs │ │ Libs │
 │ Gast-│ │ Gast-│ │ Gast-│          └──────┘ └──────┘ └──────┘
 │ OS + │ │ OS + │ │ OS + │          ┌────────────────────────┐
 │Kernel│ │Kernel│ │Kernel│          │  Container-Laufzeit    │
 └──────┘ └──────┘ └──────┘          └────────────────────────┘
 ┌────────────────────────┐          ┌────────────────────────┐
 │      Hypervisor        │          │  Host-Kernel (Linux)   │
 ├────────────────────────┤          ├────────────────────────┤
 │       Hardware         │          │       Hardware         │
 └────────────────────────┘          └────────────────────────┘
Aspekt Virtuelle Maschine Container
Startzeit 30 Sekunden bis Minuten Millisekunden bis Sekunden
Größe mehrere GB 5 MB bis wenige hundert MB
Kernel eigener gemeinsam mit dem Host
Isolation sehr stark (Hardware-Ebene) gut, aber schwächer (Kernel-Ebene)
Fremdes Betriebssystem ja (Windows auf Linux) nein
Dichte pro Server Dutzende Hunderte
Patchen jedes Gastsystem einzeln Image neu bauen

Achtung: Der letzte Punkt in der Isolationszeile ist wichtiger, als er aussieht. Container teilen sich den Kernel. Eine Kernel-Lücke, die aus einem Container ausbricht, betrifft potenziell alle anderen auf demselben Host. Deshalb ist die Antwort auf „Container oder VM?“ oft „beides„ — Container laufen in der Praxis sehr häufig in virtuellen Maschinen. Das ist keine Doppelung, sondern gestaffelte Sicherheit.

1.3 Ein kurzer historischer Abriss

Container sind keine Erfindung von 2013, auch wenn es sich manchmal so anfühlt.

Jahr Meilenstein Was dazukam
1979 chroot in Unix V7 getrennte Sicht auf das Dateisystem
2000 FreeBSD Jails echte Isolation inklusive Netzwerk
2005 Solaris Zones Ressourcenverwaltung
2006 cgroups im Linux-Kernel (von Google) Begrenzung von CPU und Speicher
2008 LXC erste brauchbare Container unter Linux
2013 Docker das Image-Format und eine einfache Bedienung
2015 Open Container Initiative (OCI) Standardisierung
2018 Podman 1.0 Container ohne Daemon und ohne Root
2014–heute Kubernetes Orchestrierung über viele Hosts

Die eigentliche Leistung von Docker war nicht die Technik — Namespaces und cgroups gab es vorher. Die Leistung war die Verpackung: ein standardisiertes Image-Format, eine Registry zum Teilen und eine Kommandozeile, die man in zehn Minuten versteht. Das ist ungefähr so, als hätte jemand den Babelfisch erfunden — die Sprachen gab es vorher auch schon, sie waren nur unbenutzbar für alle, die nicht Jahre investieren wollten.

1.4 Voraussetzungen und Aufbau dieses Tutorials

Was du mitbringen solltest: Umgang mit der Linux-Kommandozeile, ein Gefühl für Dateirechte und Prozesse, Grundkenntnisse in Netzwerk (IP, Port, DNS). Programmieren musst du nicht können.

Was du brauchst: Ein Linux-System, auf dem du Root-Rechte hast. Ein Raspberry Pi 4 oder 5 reicht für fast alles in diesem Tutorial. Für Kapitel 14 brauchst du RHEL 9 (oder AlmaLinux/Rocky 9, das funktioniert identisch).

Welche Befehle stehen hier? Die Beispiele sind in Podman-Syntax geschrieben, mit dem Docker-Äquivalent in Klammern, wo es abweicht. Der Grund: Podman ist unter RHEL der Standard, und die Befehle sind zu über 95 Prozent identisch. Wer Docker nutzt, ersetzt gedanklich podman durch docker und liegt fast immer richtig.


2. Technische Grundlagen

Dieses Kapitel erklärt, wie die Illusion funktioniert. Man kann Container auch ohne dieses Wissen bedienen — aber sobald etwas nicht funktioniert, trennt sich hier die Spreu vom Weizen. Wer weiß, dass ein Container nur ein Prozess mit ein paar Namespaces ist, findet Fehler in Minuten statt in Stunden.

2.1 Namespaces — die Sicht des Prozesses auf die Welt

Namespaces sind eine Kernel-Funktion, die einem Prozess eine eigene, isolierte Sicht auf eine bestimmte Systemressource gibt. Der Linux-Kernel kennt derzeit diese:

Namespace Isoliert Praktische Wirkung
pid Prozess-IDs Im Container ist die Anwendung PID 1 und sieht keine Host-Prozesse
mnt Einhängepunkte Eigenes Wurzeldateisystem
net Netzwerk Eigene Interfaces, IP-Adressen, Ports, Routing
uts Hostname hostname im Container liefert etwas anderes
ipc Shared Memory, Semaphore Prozesse können nicht querfunken
user UID/GID-Zuordnung Grundlage für rootless — siehe Kapitel 6.2
cgroup Sicht auf die cgroup-Hierarchie Container sieht seine eigene Begrenzung
time Systemzeit-Offset selten genutzt

Das lässt sich direkt nachvollziehen:

# Prozessliste auf dem Host
ps aux | wc -l          # z.B. 180 Prozesse
 
# Prozessliste im Container
podman run --rm registry.access.redhat.com/ubi9/ubi ps aux
# -> genau ein Prozess, nämlich ps selbst, mit PID 1

Und der Beweis, dass trotzdem alles auf demselben Kernel läuft:

podman run --rm registry.access.redhat.com/ubi9/ubi uname -r
uname -r
# -> beide Male dieselbe Kernel-Version

Merksatz: Ein Container hat kein eigenes Betriebssystem. Er hat die Dateien eines Betriebssystems, aber der Kernel gehört dem Host. Genau deshalb kann man auf einem Ubuntu-Host ein RHEL-Image starten — die Bibliotheken kommen aus dem Image, der Kernel vom Host. Und genau deshalb kann man keinen Windows-Container auf einem Linux-Host starten: Da fehlt der passende Kernel.

2.2 cgroups — Ressourcenbegrenzung

Namespaces regeln, was ein Prozess sieht. Control Groups regeln, wie viel er verbrauchen darf. Ohne cgroups könnte ein einziger amoklaufender Container den ganzen Server lahmlegen — was in der Praxis ungefähr so unterhaltsam ist wie ein Vogonisches Gedicht.

# Halbe CPU, maximal 256 MB Arbeitsspeicher
podman run -d --name test \
  --cpus 0.5 \
  --memory 256m \
  --memory-swap 256m \
  docker.io/library/nginx:alpine
 
# Live-Verbrauch beobachten
podman stats

RHEL 9 und Ubuntu 22.04 aufwärts nutzen cgroups v2. Das ist relevant, weil rootless-Container mit v2 deutlich besser funktionieren — unter v1 waren Ressourcenlimits für nicht-privilegierte Benutzer praktisch nicht durchsetzbar.

# Prüfen, welche Version aktiv ist
stat -fc %T /sys/fs/cgroup/
# cgroup2fs = v2, tmpfs = v1

2.3 Capabilities, seccomp und SELinux

Drei weitere Schutzschichten, die zusammenwirken:

Capabilities zerlegen die Allmacht von Root in etwa 40 Einzelrechte — etwa CAP_NET_BIND_SERVICE (Ports unter 1024 belegen) oder CAP_SYS_ADMIN (praktisch alles). Container starten standardmäßig mit einer stark reduzierten Auswahl.

# Alle Capabilities entziehen, nur eine zurückgeben
podman run --cap-drop ALL --cap-add NET_BIND_SERVICE nginx

seccomp filtert Systemaufrufe. Der Standardprofil-Filter blockiert rund 60 der über 300 Syscalls — darunter solche, die kein normales Programm je braucht, die aber gern für Kernel-Exploits verwendet werden.

SELinux (RHEL) bzw. AppArmor (Ubuntu) kontrolliert den Zugriff auf Dateien und Geräte auf einer weiteren, unabhängigen Ebene. Unter RHEL bekommen Container den Typ container_t und dürfen damit von Haus aus fast nichts auf dem Host anfassen.

Achtung: Diese drei Schichten sind der Grund, warum ein Container-Ausbruch aufwendig ist — und warum man sie nicht abschalten sollte. Wer bei einem Problem reflexartig –privileged setzt, hat sämtliche Schutzmechanismen gleichzeitig deaktiviert. Das ist die Container-Variante von „chmod 777, dann geht's“.

2.4 Union-Dateisysteme und das Schichtenmodell

Ein Image besteht nicht aus einer großen Datei, sondern aus übereinandergelegten Schichten (Layers). Jede Schicht enthält nur die Änderungen gegenüber der darunterliegenden.

  ┌──────────────────────────────┐
  │ Schreibschicht (Container)   │  ← flüchtig, weg beim Löschen
  ├──────────────────────────────┤
  │ Schicht 4: App-Code          │  ┐
  ├──────────────────────────────┤  │
  │ Schicht 3: Abhängigkeiten    │  │ nur lesbar,
  ├──────────────────────────────┤  │ gemeinsam nutzbar
  │ Schicht 2: Laufzeitumgebung  │  │
  ├──────────────────────────────┤  │
  │ Schicht 1: Basis-Image       │  ┘
  └──────────────────────────────┘

Daraus folgen drei Dinge, die im Alltag ständig relevant sind:

  1. Speicherersparnis. Zehn Container aus demselben Image belegen den Image-Platz genau einmal. Nur die Schreibschichten sind individuell.
  2. Alles im Container ist flüchtig. Wird der Container gelöscht, ist die Schreibschicht weg. Wer Daten behalten will, braucht Volumes (Kapitel 3.4).
  3. Löschen macht Images nicht kleiner. Wenn Schicht 3 eine 500-MB-Datei anlegt und Schicht 4 sie löscht, ist sie in Schicht 3 immer noch enthalten. Das Image bleibt groß. Deshalb gehören dnf install und dnf clean all in eine RUN-Anweisung.

2.5 Was ein Container-Image wirklich ist

Ein Image ist im Kern:

  • ein Satz komprimierter tar-Archive (die Schichten),
  • eine Konfigurationsdatei in JSON (Startbefehl, Umgebungsvariablen, Arbeitsverzeichnis, Benutzer, exponierte Ports),
  • ein Manifest, das beides zusammenhält.

Das lässt sich ansehen:

podman inspect docker.io/library/nginx:alpine | less
podman history docker.io/library/nginx:alpine

Hinweis zu Tags und Digests: Ein Tag wie nginx:latest ist nur ein beweglicher Zeiger. Er kann morgen auf ein anderes Image zeigen. Der Digest (sha256:…) identifiziert ein Image dagegen eindeutig und unveränderlich. In Produktivumgebungen arbeitet man mit Digests — das ist der Unterschied zwischen „wir betreiben nginx„ und „wir betreiben genau dieses nginx“.

podman inspect --format '{{.Digest}}' nginx:alpine
podman run docker.io/library/nginx@sha256:abc123...

2.6 Die OCI-Standards

Damit nicht jeder Hersteller sein eigenes Format kocht, gibt es seit 2015 die Open Container Initiative. Sie definiert drei Spezifikationen:

Spezifikation Regelt
Image Spec Aufbau von Images: Schichten, Manifest, Konfiguration
Runtime Spec Wie ein Container aus einem entpackten Image gestartet wird
Distribution Spec Wie Registries Images ausliefern (das HTTP-API)

Praktische Konsequenz: Ein Image ist ein Image. Was Docker baut, läuft unter Podman. Was Podman baut, läuft unter Kubernetes und OpenShift. Was du auf deinem Raspberry Pi zusammenbastelst, läuft unverändert in einer Azure-Umgebung — vorausgesetzt, die Prozessorarchitektur passt.

Die eigentlichen Laufzeiten, die den Container am Ende starten, heißen runc (in C++/Go, der Klassiker) und crun (in C geschrieben, schneller und sparsamer, Standard unter RHEL).


3. Erste Schritte mit Containern

Genug Theorie. Ab hier wird getippt.

3.1 Container starten, stoppen, auflisten

# Der Klassiker
podman run docker.io/library/hello-world
 
# Etwas Nützlicheres: ein Webserver im Hintergrund
podman run -d --name meinweb -p 8080:80 docker.io/library/nginx:alpine
 
# Was läuft gerade?
podman ps
podman ps -a          # auch gestoppte
 
# Stoppen, starten, entfernen
podman stop meinweb
podman start meinweb
podman rm -f meinweb
Option Bedeutung
-d detached — läuft im Hintergrund
-it interaktiv mit Terminal
–rm Container nach Beenden automatisch löschen
–name fester Name statt zufälliger Wortkombination
-p HOST:CONTAINER Portweiterleitung
-v Volume oder Bind Mount
-e Umgebungsvariable

Tipp: Ohne –name vergibt Podman lustige Zufallsnamen wie nostalgic_hawking. Das ist charmant, aber in Skripten unbrauchbar. Gewöhne dir –name von Anfang an an.

3.2 Interaktiver Zugriff und Logs

# Shell in einem laufenden Container
podman exec -it meinweb /bin/sh
 
# Einmal-Container mit Shell, verschwindet danach spurlos
podman run -it --rm registry.access.redhat.com/ubi9/ubi /bin/bash
 
# Logs ansehen und mitverfolgen
podman logs meinweb
podman logs -f --tail 50 meinweb
 
# Prozesse im Container
podman top meinweb

Wichtig: podman exec ist ein Diagnosewerkzeug, kein Betriebsverfahren. Wenn du dich regelmäßig in einen Container einloggst, um dort etwas zu reparieren oder zu konfigurieren, läuft etwas grundsätzlich schief. Änderungen gehören ins Image oder in die Konfiguration — sonst sind sie beim nächsten Neustart weg. Container sind Vieh, keine Haustiere.

3.3 Portweiterleitung und Netzwerkgrundlagen

Ein Container hat sein eigenes Netzwerk-Namespace und damit seine eigene IP. Damit man ihn von außen erreicht, braucht es eine Weiterleitung:

podman run -d -p 8080:80 nginx:alpine
#              ↑    ↑
#              │    └── Port im Container
#              └─────── Port auf dem Host

Eigene Netzwerke sind der saubere Weg, mehrere Container zusammenarbeiten zu lassen:

podman network create appnetz
 
podman run -d --name db --network appnetz \
  -e POSTGRES_PASSWORD=geheim \
  docker.io/library/postgres:16
 
podman run -d --name app --network appnetz -p 8080:8080 meine-app:1.0

In einem selbst angelegten Netzwerk erreichen sich Container über ihren Namen: Die Anwendung verbindet sich einfach zu db:5432. Eine IP-Adresse muss niemand kennen — es gibt einen eingebauten DNS-Dienst.

Achtung: Das funktioniert nur in selbst angelegten Netzwerken. Im Standardnetzwerk gibt es keine Namensauflösung zwischen Containern. Das ist eine der häufigsten Stolperfallen überhaupt.

3.4 Volumes und Bind Mounts

Container sind flüchtig. Daten sollen es nicht sein. Dafür gibt es zwei Mechanismen:

# Named Volume - von Podman verwaltet, empfohlen für Datenbanken
podman volume create dbdaten
podman run -d --name db -v dbdaten:/var/lib/postgresql/data postgres:16
 
# Bind Mount - ein Host-Verzeichnis wird eingehängt
podman run -d -v /srv/web:/usr/share/nginx/html:ro,Z nginx:alpine
 
# Verwaltung
podman volume ls
podman volume inspect dbdaten
podman volume rm dbdaten
Named Volume Bind Mount
Ort von Podman verwaltet frei wählbar
Rechte/SELinux automatisch korrekt muss man selbst regeln
Sicherung über podman volume export normales Dateisystem-Backup
Typischer Einsatz Datenbanken, Anwendungsdaten Konfiguration, Entwicklung, statische Inhalte

Die SELinux-Falle unter RHEL. Ein Bind Mount ohne passenden SELinux-Kontext führt zu Permission denied — und zwar auch dann, wenn die Dateirechte offensichtlich stimmen. Die Lösung ist das Suffix Z (großes Z: exklusiv für diesen Container) oder z (kleines z: von mehreren Containern nutzbar):

-v /srv/web:/usr/share/nginx/html:ro,Z

Wer stattdessen SELinux deaktiviert, löst das Problem so, wie man ein klemmendes Fenster mit einem Vorschlaghammer löst.

3.5 Umgebungsvariablen und Konfiguration

podman run -d --name db \
  -e POSTGRES_USER=app \
  -e POSTGRES_PASSWORD=geheim \
  -e POSTGRES_DB=produktion \
  postgres:16
 
# Aus einer Datei
podman run -d --env-file ./app.env meine-app:1.0

Achtung: Passwörter als Umgebungsvariablen sind bequem und unsicher — sie stehen in podman inspect, in der Prozessliste und oft in den Logs. Für echte Geheimnisse gibt es podman secret (Kapitel 13.4).

3.6 Ressourcenlimits setzen

podman run -d --name app \
  --cpus 1.5 \
  --memory 512m \
  --memory-swap 512m \
  --pids-limit 200 \
  meine-app:1.0

Tipp: Setze Limits immer, auch auf dem Testsystem. Ein Container ohne Speicherlimit darf sich den gesamten Hauptspeicher nehmen, und dann entscheidet der OOM-Killer des Kernels, welcher Prozess sterben muss. Seine Auswahl ist selten die, die man sich gewünscht hätte.

3.7 Aufräumen

Container-Systeme neigen zur Vermüllung. Alte Images, verwaiste Volumes, gestoppte Container — irgendwann ist /var voll, und zwar immer freitags um 17 Uhr.

# Was belegt eigentlich Platz?
podman system df
 
# Gestoppte Container, ungenutzte Netzwerke und dangling Images
podman system prune
 
# Zusätzlich alle ungenutzten Images und Volumes - Vorsicht!
podman system prune -a --volumes

podman system prune -a –volumes löscht alle Volumes, die gerade an keinem laufenden Container hängen. Wenn deine Datenbank zufällig gestoppt ist, sind ihre Daten anschließend weg. Dieser Befehl gehört nicht in einen Cronjob.


4. Eigene Images bauen

4.1 Containerfile — Aufbau und Anweisungen

Ein Containerfile (identisch zu Dockerfile, nur ohne Markennamen) ist ein Rezept. Jede Zeile erzeugt potenziell eine neue Schicht.

Containerfile
# Basis-Image
FROM registry.access.redhat.com/ubi9/ubi-minimal:latest

# Metadaten
LABEL maintainer="andre@example.org" \
      version="1.0" \
      description="Beispielanwendung"

# Pakete installieren - alles in EINER Schicht
RUN microdnf install -y python3 python3-pip && \
    microdnf clean all && \
    rm -rf /var/cache/yum

# Arbeitsverzeichnis
WORKDIR /app

# Abhängigkeiten zuerst - wegen des Caches, siehe 4.2
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt

# Erst danach der Anwendungscode
COPY src/ ./src/

# Unprivilegierter Benutzer
RUN useradd -u 1001 -r -g 0 -m -d /app -s /sbin/nologin appuser && \
    chown -R 1001:0 /app && \
    chmod -R g=u /app
USER 1001

# Dokumentation des Ports (öffnet nichts!)
EXPOSE 8080

# Gesundheitsprüfung
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD curl -f http://localhost:8080/health || exit 1

# Startbefehl
ENTRYPOINT ["python3"]
CMD ["src/app.py"]
Anweisung Bedeutung
FROM Basis-Image
RUN Befehl beim Bauen ausführen
COPY Dateien aus dem Build-Kontext ins Image
ADD wie COPY, kann zusätzlich entpacken und URLs laden — meide es
WORKDIR Arbeitsverzeichnis setzen
ENV Umgebungsvariable im Image
USER Benutzer für alle folgenden Anweisungen und zur Laufzeit
EXPOSE reine Dokumentation, öffnet keinen Port
ENTRYPOINT das Programm, das läuft
CMD Standardargumente dafür (überschreibbar)
HEALTHCHECK Zustandsprüfung

Bauen und starten:

podman build -t meine-app:1.0 .
podman run -d -p 8080:8080 meine-app:1.0

4.2 Schichten verstehen und Cache nutzen

Podman und Docker speichern jede Schicht zwischen. Ändert sich eine Zeile im Containerfile, werden diese und alle folgenden Schichten neu gebaut, alle vorherigen kommen aus dem Cache.

Daraus folgt die wichtigste Bauregel überhaupt:

Sortiere vom Selten-Änderndem zum Oft-Änderndem.

Basis-Image und Systempakete ändern sich vielleicht monatlich. Abhängigkeiten wöchentlich. Der eigene Code zwanzigmal am Tag. Wer den Code vor die Abhängigkeiten stellt, installiert bei jeder Codeänderung alle Pakete neu — aus einem 8-Sekunden-Build wird ein 4-Minuten-Build. Zwanzigmal am Tag.

# Schlecht                          # Gut
COPY . .                           COPY requirements.txt .
RUN pip install -r req.txt         RUN pip install -r requirements.txt
                                   COPY src/ ./src/

Eine .containerignore (bzw. .dockerignore) hält Ballast aus dem Build-Kontext heraus:

.containerignore
.git
.gitignore
*.md
__pycache__/
node_modules/
tests/
.env

4.3 Multi-Stage-Builds

Die eleganteste Technik im ganzen Kapitel. Man baut in einer fetten Umgebung mit Compiler und Werkzeugen — und kopiert dann nur das Ergebnis in ein winziges Laufzeit-Image.

Containerfile.multistage
# ---------- Stufe 1: Bauen ----------
FROM registry.access.redhat.com/ubi9/go-toolset:latest AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /build/anwendung ./cmd/server

# ---------- Stufe 2: Laufzeit ----------
FROM registry.access.redhat.com/ubi9/ubi-micro:latest
COPY --from=builder /build/anwendung /usr/local/bin/anwendung
USER 1001
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/anwendung"]

Das Ergebnis: Aus einem Build-Image von rund 1,2 GB wird ein Laufzeit-Image von vielleicht 30 MB. Der Go-Compiler, die Quelltexte, die Testdaten und die Build-Abhängigkeiten sind im fertigen Image schlicht nicht vorhanden — und was nicht da ist, kann auch keine Sicherheitslücke haben.

4.4 Basis-Images auswählen

Basis Größe Paketmanager Wofür
ubi9/ubi ~220 MB dnf RHEL-Umfeld, voller Funktionsumfang, Red-Hat-Support
ubi9/ubi-minimal ~90 MB microdnf RHEL-Umfeld, schlank — guter Standard
ubi9/ubi-micro ~25 MB keiner nur mit Multi-Stage sinnvoll
alpine ~7 MB apk sehr klein, aber musl statt glibc
debian:slim ~75 MB apt breite Kompatibilität
gcr.io/distroless/* ~20 MB keiner maximale Härtung, keine Shell
scratch 0 MB keiner nur für statisch gelinkte Binärdateien

Die Alpine-Falle. Alpine verwendet musl libc statt der üblichen glibc. Das ist meistens egal — bis es das nicht mehr ist. Typische Symptome: DNS-Auflösung verhält sich anders, Python-Pakete mit C-Erweiterungen müssen mühsam kompiliert werden statt fertige Wheels zu nutzen, und exotische Laufzeitfehler tauchen erst in Produktion auf. Wenn die 80 MB Unterschied nicht wehtun, ist ubi-minimal oder debian:slim die ruhigere Wahl.

Die Red Hat Universal Base Images (UBI) sind übrigens frei verwendbar und weiterverteilbar — auch außerhalb einer RHEL-Subskription und auch kommerziell. Sie sind damit für RHEL-Umgebungen die naheliegende Wahl, weil Basis-Image und Host aus derselben Quelle stammen.

4.5 Gute Praxis

  • Nie als Root laufen lassen. USER 1001 gehört in jedes Containerfile. OpenShift erzwingt das ohnehin (Kapitel 9.2).
  • Feste Versionen verwenden. FROM ubi9/ubi-minimal:9.4 statt latest. „Latest„ ist kein Zustand, sondern ein Versprechen ohne Deckung.
  • Ein Prozess pro Container. Kein systemd, kein supervisord, kein SSH-Server im Container. Wer SSH in einen Container einbaut, hat eine VM gebaut und ist einen Umweg gegangen.
  • Keine Geheimnisse ins Image. Sie bleiben in der Schicht erhalten, auch wenn eine spätere Schicht sie löscht. Jeder mit Zugriff auf das Image kommt heran.
  • Cache und temporäre Dateien in derselben RUN-Anweisung aufräumen.
  • HEALTHCHECK definieren. Ein laufender Prozess ist nicht dasselbe wie ein funktionierender Dienst.

4.6 Tagging-Strategien

podman build -t meine-app:1.4.2 -t meine-app:1.4 -t meine-app:latest .

Bewährt hat sich die Kombination aus semantischer Version (1.4.2), Git-Commit (1.4.2-a3f9c1) und einem beweglichen Zeiger (stable). In Produktion referenziert man den Digest — dann ist unstrittig, was läuft.


5. Registries

5.1 Öffentliche Registries

Eine Registry ist ein Dateiserver für Images mit standardisiertem API.

Registry Betreiber Besonderheit
docker.io Docker Inc. größte Auswahl, Rate Limits für anonyme Nutzer
quay.io Red Hat integriertes Schwachstellen-Scanning
registry.access.redhat.com Red Hat UBI-Images, keine Anmeldung nötig
registry.redhat.io Red Hat Produkt-Images, Subskription erforderlich
ghcr.io GitHub eng an GitHub Actions gekoppelt
gcr.io Google Distroless-Images

Unter Podman muss man die Registry meist vollqualifiziert angeben. Das ist Absicht und ein Sicherheitsmerkmal: Bei Docker bedeutet nginx automatisch docker.io/library/nginx. Podman möchte, dass du dir darüber im Klaren bist, woher deine Software kommt.

podman pull docker.io/library/nginx:alpine     # eindeutig
podman pull nginx:alpine                       # fragt ggf. nach

Die Suchreihenfolge steht in /etc/containers/registries.conf.

5.2 Authentifizierung und Rate Limits

podman login docker.io
podman login registry.redhat.io
podman logout --all

Die Zugangsdaten landen bei rootless-Podman in ${XDG_RUNTIME_DIR}/containers/auth.json.

Hinweis: Docker Hub begrenzt anonyme Downloads pro IP-Adresse. In Firmennetzen teilen sich unter Umständen hunderte Rechner eine öffentliche IP — dann sind die Kontingente schnell aufgebraucht, und Builds schlagen scheinbar grundlos fehl. Abhilfe: anmelden, eine Spiegel-Registry betreiben oder auf Quay bzw. die Red-Hat-Registries ausweichen.

5.3 Eine eigene Registry betreiben

Für den Hausgebrauch reicht die Referenzimplementierung:

podman run -d --name registry \
  -p 5000:5000 \
  -v registrydaten:/var/lib/registry \
  docker.io/library/registry:2
 
podman tag meine-app:1.0 localhost:5000/meine-app:1.0
podman push localhost:5000/meine-app:1.0

Für ernsthaften Betrieb: Quay (Red Hat), Harbor (CNCF), Nexus oder GitLab Container Registry. Diese bringen Rechteverwaltung, Spiegelung, Aufbewahrungsregeln und Schwachstellen-Scanning mit.

5.4 Image-Signaturen und Vertrauensketten

Ein Image aus dem Internet ist erst einmal fremder Code, der bei dir Prozesse startet. Signaturen belegen Herkunft und Unversehrtheit.

# Signieren mit GPG
podman push --sign-by andre@example.org localhost:5000/meine-app:1.0

Die Datei /etc/containers/policy.json legt fest, welchen Quellen vertraut wird — bis hin zu „nur signierte Images aus der eigenen Registry dürfen laufen“. In regulierten Umgebungen ist das kein Luxus, sondern Pflichtprogramm.

Im Kubernetes-Umfeld hat sich zusätzlich sigstore/cosign etabliert, das ohne eigene Schlüsselverwaltung auskommt.

5.5 Schwachstellen-Scanning

# Trivy - schnell, weit verbreitet
podman run --rm -v /var/run/podman/podman.sock:/var/run/docker.sock \
  docker.io/aquasec/trivy:latest image meine-app:1.0
 
# Grype - Alternative
grype meine-app:1.0

Der eigentliche Gewinn beim Patchen. Auf einem klassischen Server pflegt man Pakete nach: dnf update, hoffen, dass nichts bricht. Bei Containern baut man das Image neu — mit aktualisiertem Basis-Image — und tauscht den laufenden Container aus. Geht etwas schief, startet man die alte Version. Der Rollback dauert Sekunden statt eines Wiederherstellungsvorgangs aus dem Backup.

Voraussetzung ist allerdings, dass der Neubau automatisiert und reproduzierbar ist. Ein Image, das nur Kollege Müller auf seinem Notebook bauen kann, ist keine Verbesserung — das ist ein Single Point of Failure mit Urlaubsanspruch.


6. Docker und Podman im Vergleich

Die Frage „Docker oder Podman?„ wird gern zur Glaubensfrage aufgeblasen. Sie ist keine. Beide bauen und starten OCI-Container, beide sprechen fast dieselbe Kommandozeile. Der Unterschied liegt in der Architektur — und der hat handfeste Konsequenzen.

6.1 Architektur: Daemon vs. daemonless

Docker arbeitet mit einem zentralen Hintergrunddienst. Der docker-Befehl ist nur ein Client, der über einen Socket mit dem dockerd-Daemon spricht. Der Daemon läuft als Root und ist Elternprozess aller Container.

Podman hat keinen Daemon. Der Befehl startet den Container direkt als Kindprozess der eigenen Shell und beendet sich. Ein Hilfsprozess namens conmon bleibt als Aufpasser zurück.

  Docker                              Podman
  ┌──────────────┐                    ┌──────────────┐
  │ docker (CLI) │                    │ podman       │
  └──────┬───────┘                    └──────┬───────┘
         │ REST über Socket                  │ fork/exec
  ┌──────▼───────┐                    ┌──────▼───────┐
  │ dockerd      │  ← als root        │ conmon       │
  │ (Daemon)     │                    └──────┬───────┘
  └──────┬───────┘                    ┌──────▼───────┐
  ┌──────▼───────┐                    │  Container   │  ← als du
  │  Container   │  ← Kind von dockerd└──────────────┘
  └──────────────┘

Warum das im Alltag zählt:

Docker Podman
Zentraler Angriffspunkt ja, dockerd als root nein
Daemon-Absturz reißt potenziell alles mit existiert nicht
Zugriff auf den Socket entspricht faktisch Root auf dem Host entfällt
Prozessbaum alle Container hängen am Daemon Container gehören dem Benutzer
systemd-Integration Docker verwaltet Neustarts selbst systemd verwaltet Container direkt

Der Docker-Socket ist eine Root-Shell mit Umweg. Wer Mitglied der Gruppe docker ist, kann einen Container starten, der das gesamte Host-Dateisystem einhängt — und ist damit faktisch Root. Das ist kein Fehler, sondern eine Konsequenz der Architektur. Genau diesen Umstand wollte Podman abschaffen.

6.2 Rootful und rootless

Das ist das Kernargument für Podman, deshalb etwas ausführlicher.

Bei rootless-Podman startet ein normaler Benutzer Container, ohne irgendwann Root-Rechte zu benötigen. Möglich macht das der User-Namespace: Innerhalb des Containers ist der Prozess UID 0 — auf dem Host wird diese UID aber auf einen ganz gewöhnlichen, unprivilegierten Bereich abgebildet.

cat /etc/subuid
# andre:100000:65536
 
# Bedeutung: Dem Benutzer andre gehören die Host-UIDs 100000 bis 165535.
# Container-UID 0    -> Host-UID 100000
# Container-UID 1000 -> Host-UID 101000

Praktisch heißt das: Bricht ein Prozess aus dem Container aus, steht er auf dem Host als UID 100000 da — ein Benutzer ohne jede Berechtigung, der nicht einmal seine eigenen Dateien wiederfindet. Verglichen mit „Angreifer ist root auf dem Host“ ist das ein erheblicher Unterschied.

Der Preis dafür sind ein paar Einschränkungen:

Einschränkung Lösung
Ports unter 1024 nicht belegbar Sysctl anpassen oder Reverse Proxy davorstellen
Kein Zugriff auf beliebige Host-Geräte bewusste Freigabe nötig
Manche Dateisysteme (NFS) machen Probleme lokale Volumes verwenden
Etwas langsameres Netzwerk für die meisten Lasten irrelevant
# Ports ab 80 auch für unprivilegierte Nutzer freigeben
echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/99-rootless.conf
sudo sysctl --system

Docker kennt inzwischen ebenfalls einen rootless-Modus, aber er ist eine nachgerüstete Betriebsart. Bei Podman ist er der Normalfall.

Zum Netzwerk unter rootless: Historisch übernahm slirp4netns die Anbindung. Seit Podman 5 ist pasta der Standard — deutlich schneller und mit dem angenehmen Nebeneffekt, dass die Quell-IP eingehender Verbindungen erhalten bleibt. Wer noch mit Podman 4.x arbeitet, kann pasta über –network pasta anfordern.

6.3 Kompatibilität der Kommandozeile

alias docker=podman

Damit funktionieren geschätzt 95 Prozent aller Docker-Anleitungen aus dem Netz unverändert. Podman bietet zusätzlich ein Docker-kompatibles API an, sodass Werkzeuge wie Testcontainers oder docker-compose bedient werden können:

systemctl --user enable --now podman.socket
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
docker-compose up -d

Was nicht funktioniert: Docker Swarm. Podman hat dafür kein Gegenstück und wird auch keines bekommen — für Orchestrierung verweist Red Hat auf Kubernetes.

6.4 Pods bei Podman

Ein Pod ist eine Gruppe von Containern, die sich Netzwerk-Namespace und damit die IP-Adresse teilen. Sie erreichen einander über localhost. Das Konzept stammt aus Kubernetes, und Podman hat es übernommen — daher der Name.

podman pod create --name webstack -p 8080:80
 
podman run -d --pod webstack --name datenbank \
  -e POSTGRES_PASSWORD=geheim postgres:16
 
podman run -d --pod webstack --name anwendung meine-app:1.0
# Die Anwendung erreicht die Datenbank unter localhost:5432
 
podman pod ps
podman pod stop webstack

Das ist mehr als eine Spielerei: Wer lokal mit Pods arbeitet, denkt bereits in Kubernetes-Strukturen — und kann die Konfiguration später direkt exportieren (Kapitel 15.1).

6.5 Das Podman-Umfeld

Docker ist ein Werkzeug für alles. Podman folgt der Unix-Philosophie und zerlegt die Aufgaben:

Werkzeug Aufgabe
Podman Container starten und verwalten
Buildah Images bauen, auch ohne Containerfile per Skript
Skopeo Images zwischen Registries kopieren, prüfen, signieren — ohne sie herunterzuladen
podman-compose Compose-Dateien verarbeiten

Besonders Skopeo ist im Betrieb Gold wert:

# Image prüfen, ohne es zu laden
skopeo inspect docker://docker.io/library/nginx:alpine
 
# Zwischen Registries kopieren, ohne lokalen Speicherplatz zu verbrauchen
skopeo copy docker://docker.io/library/nginx:alpine \
            docker://registry.firma.de/spiegel/nginx:alpine
 
# In eine Datei für den Transport ins abgeschottete Netz
skopeo copy docker://nginx:alpine oci-archive:nginx.tar

6.6 systemd-Integration und Quadlet

Hier zeigt Podman seine größte Stärke im klassischen Serverbetrieb.

Docker verwaltet Neustarts über eigene Richtlinien (–restart=always). Podman überlässt das systemd — dem Werkzeug, das auf dem Server ohnehin für genau diese Aufgabe zuständig ist. Seit Podman 4.4 gibt es dafür Quadlet: Man schreibt eine Unit-Datei im INI-Format, und ein systemd-Generator erzeugt daraus zur Startzeit die vollständige Service-Unit.

~/.config/containers/systemd/webserver.container
[Unit]
Description=Nginx Webserver
After=network-online.target

[Container]
Image=docker.io/library/nginx:alpine
PublishPort=8080:80
Volume=web-inhalt:/usr/share/nginx/html:ro,Z
AutoUpdate=registry

[Service]
Restart=always

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user start webserver.service
systemctl --user status webserver.service
journalctl --user -u webserver.service -f

Der Container ist damit ein vollwertiger systemd-Dienst: Er startet beim Booten, wird bei Absturz neu gestartet, protokolliert nach journald und lässt sich mit denselben Befehlen bedienen wie jeder andere Dienst auf dem System. Für einen Administrator, der seit Jahren mit systemctl arbeitet, ist das der kürzeste Weg vom Bekannten zum Neuen.

Hinweis: Der ältere Befehl podman generate systemd erzeugte Unit-Dateien statisch und gilt inzwischen als überholt. Bei Neuaufbauten führt kein Weg an Quadlet vorbei.

6.7 Docker Desktop, Podman Desktop und die Lizenzfrage

Beide Projekte sind Open Source. Der Unterschied liegt beim Desktop-Produkt für Windows und macOS: Docker Desktop erfordert seit 2021 eine kostenpflichtige Lizenz für Unternehmen ab einer bestimmten Größe (Stand der Regelung: mehr als 250 Beschäftigte oder mehr als 10 Millionen US-Dollar Jahresumsatz). Podman Desktop ist frei.

Für viele Unternehmen war das der eigentliche Auslöser, sich mit Podman zu beschäftigen. Ein selten ausgesprochener, aber wirksamer Migrationsgrund.

6.8 Gegenüberstellung: Wann welches Werkzeug

Situation Empfehlung Begründung
RHEL-Server im Unternehmen Podman mitgeliefert, unterstützt, systemd-integriert
Einzelner Linux-Server, Selbstbetrieb Podman rootless, Quadlet, kein Daemon
Raspberry Pi / Heimserver Podman oder Docker Podman sparsamer, Docker mehr Anleitungen im Netz
Entwicklung unter macOS/Windows in großer Firma Podman Desktop Lizenzfrage
Bestehende große Docker-Compose-Landschaft Docker vorerst funktionierende Systeme migriert man nicht ohne Grund
CI/CD-Pipeline ohne privilegierte Läufer Podman/Buildah Bauen ohne Daemon und ohne Root
Team, das ausschließlich Docker kennt Docker, dann schrittweise Werkzeugwechsel ist kein Selbstzweck
Kubernetes-Vorbereitung Podman Pods, kube generate, gleiche Denkweise

Die pragmatische Antwort: Das Image ist standardisiert, also ist die Entscheidung reversibel. Was du heute mit Docker baust, läuft morgen unter Podman und übermorgen in Kubernetes. Diese Frage verdient keine dreitägige Grundsatzdiskussion — sie verdient einen Nachmittag Ausprobieren.


7. Wenn ein Host nicht mehr reicht: Orchestrierung

7.1 Welche Probleme Orchestrierung löst

Solange alles auf einem Server läuft, ist die Welt überschaubar. Die Probleme beginnen bei Fragen wie:

  • Der Server fällt aus — wer startet die Container woanders?
  • Drei Instanzen der Anwendung laufen — wer verteilt die Anfragen?
  • Die Last verdoppelt sich — wer startet zusätzliche Instanzen?
  • Neue Version — wie tauscht man aus, ohne dass jemand einen Fehler sieht?
  • Auf welchem der zwanzig Server ist eigentlich noch Platz?
  • Ein Container ist zwar am Leben, antwortet aber nicht mehr — wer merkt das?

Ein Orchestrator beantwortet diese Fragen automatisch. Man beschreibt den Sollzustand („drei Instanzen dieses Images, erreichbar unter dieser Adresse„), und das System stellt ihn her und hält ihn aufrecht. Wer Ansible kennt: dasselbe Denkmuster, nur dauerhaft statt einmalig.

7.2 Zwischenstufen

Zwischen „ein Container von Hand“ und „Kubernetes-Cluster„ liegen mehrere sinnvolle Stufen, die gern übersprungen werden:

Stufe Werkzeug Leistet Grenze
1 podman run einzelne Container nichts überlebt einen Neustart
2 Quadlet / systemd Autostart, Neustart, Logs ein Host
3 Compose mehrere Container als Verbund ein Host
4 Podman-Pods geteilte Netzwerke, Kubernetes-Denke ein Host
5 Swarm / Nomad mehrere Hosts, einfach begrenztes Ökosystem
6 Kubernetes alles hoher Betriebsaufwand

Die Stufen 2 bis 4 decken erstaunlich viel ab. Ein einzelner ordentlich gepflegter Server mit Quadlet-Units und Git-Anbindung trägt problemlos zwanzig Dienste — mit einem Bruchteil des Aufwands eines Clusters. Genau diesen Aufbau beschreibt Kapitel 14.

7.3 Docker Swarm und Nomad

Docker Swarm ist in Docker eingebaut und in zehn Minuten eingerichtet. Es kann Cluster, Lastverteilung und rollierende Updates. Die Weiterentwicklung ist aber weitgehend zum Erliegen gekommen — für Neuaufbauten schwer zu empfehlen.

HashiCorp Nomad ist deutlich schlanker als Kubernetes, verwaltet neben Containern auch normale Binärdateien und VMs. Kleines Ökosystem, aber echte Anhängerschaft. Seit 2023 unter der BSL-Lizenz, was für manche Unternehmen ein Ausschlusskriterium ist.

7.4 Warum sich Kubernetes durchgesetzt hat

Nicht, weil es einfach wäre. Sondern weil es:

  • von Google aus jahrzehntelanger Erfahrung mit Containern entstand,
  • früh an eine herstellerneutrale Stiftung übergeben wurde (CNCF),
  • ein erweiterbares API hat, sodass Dritte es ausbauen können,
  • von allen großen Cloud-Anbietern als Dienst angeboten wird,
  • dadurch zum gemeinsamen Nenner wurde — und ein gemeinsamer Nenner gewinnt.

Netzwerkeffekte schlagen technische Eleganz. Das war bei VHS so und ist bei Kubernetes nicht anders.


8. Kubernetes

8.1 Architektur

Ein Kubernetes-Cluster besteht aus Control Plane (dem Gehirn) und Worker Nodes (den Arbeitstieren).

 ┌─────────────────── Control Plane ───────────────────┐
 │  kube-apiserver     ← einzige Eintrittstür          │
 │  etcd               ← Datenbank des Sollzustands    │
 │  kube-scheduler     ← entscheidet: welcher Node?    │
 │  controller-manager ← wacht über Soll vs. Ist       │
 └──────────────────────────┬──────────────────────────┘
                            │
      ┌─────────────────────┼─────────────────────┐
 ┌────▼──────┐        ┌─────▼─────┐         ┌─────▼─────┐
 │  Node 1   │        │  Node 2   │         │  Node 3   │
 │  kubelet  │        │  kubelet  │         │  kubelet  │
 │  kube-proxy        │  kube-proxy         │  kube-proxy
 │  containerd/CRI-O  │  ...                │  ...
 │  [Pods]   │        │  [Pods]   │         │  [Pods]   │
 └───────────┘        └───────────┘         └───────────┘

Das Grundprinzip ist eine Regelschleife: Der Controller vergleicht permanent Soll- und Ist-Zustand und handelt bei Abweichung. Fällt ein Node aus, sind plötzlich zwei statt drei Instanzen da — also startet der Controller eine neue woanders. Niemand muss eingreifen.

Hinweis zur Container-Laufzeit: Seit Version 1.24 spricht Kubernetes nicht mehr direkt mit Docker. Es verwendet containerd oder CRI-O. Das war seinerzeit Anlass für einige aufgeregte Schlagzeilen, hatte für Anwender aber keinerlei Folgen — die Images blieben ja dieselben.

8.2 Die zentralen Objekte

Objekt Aufgabe
Pod kleinste Einheit: ein oder mehrere Container mit gemeinsamer IP
ReplicaSet sorgt für die gewünschte Anzahl gleicher Pods
Deployment verwaltet ReplicaSets, ermöglicht rollierende Updates und Rollback
Service stabile interne Adresse und Lastverteilung
Ingress HTTP-Zugang von außen, Namensbasiert, TLS
StatefulSet für Zustandsbehaftetes wie Datenbanken
DaemonSet genau ein Pod je Node (Logsammler, Monitoring)
Job / CronJob einmalige bzw. wiederkehrende Aufgaben

Ein minimales Deployment:

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webserver
spec:
  replicas: 3
  selector:
    matchLabels:
      app: webserver
  template:
    metadata:
      labels:
        app: webserver
    spec:
      containers:
        - name: nginx
          image: docker.io/library/nginx:alpine
          ports:
            - containerPort: 80
          resources:
            requests:
              memory: "64Mi"
              cpu: "100m"
            limits:
              memory: "128Mi"
              cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
  name: webserver
spec:
  selector:
    app: webserver
  ports:
    - port: 80
      targetPort: 80
kubectl apply -f deployment.yaml
kubectl get pods -w
kubectl scale deployment webserver --replicas=5
kubectl rollout undo deployment webserver

8.3 ConfigMaps, Secrets, PersistentVolumes

  • ConfigMap — Konfiguration getrennt vom Image. Als Umgebungsvariable oder eingehängte Datei nutzbar.
  • Secret — dasselbe für Vertrauliches. Achtung: Secrets sind standardmäßig lediglich Base64-kodiert, also nicht verschlüsselt. Wer echten Schutz will, aktiviert Verschlüsselung in etcd oder nutzt einen externen Tresor wie HashiCorp Vault.
  • PersistentVolumeClaim — die Anwendung fordert Speicher an, ohne wissen zu müssen, ob dahinter NFS, iSCSI, Ceph oder eine Cloud-Festplatte steckt.

8.4 Namespaces, RBAC und Kontingente

Namespaces unterteilen den Cluster logisch (etwa nach Team oder Umgebung). RBAC regelt, wer was darf. ResourceQuotas und LimitRanges verhindern, dass ein Team den halben Cluster belegt.

8.5 Deklaratives Arbeiten

Der wichtigste Denkumschwung bei Kubernetes: Man führt keine Befehle aus, man beschreibt Zustände. Statt „starte drei Container“ sagt man „es sollen drei laufen„. Das System kümmert sich um den Rest — auch um drei Uhr nachts, wenn niemand hinschaut.

Konsequenz für die Praxis: Die YAML-Dateien gehören in Git, nicht auf einen Server. Wer per kubectl edit direkt am Cluster herumschraubt, hat den Sinn der Sache verfehlt und produziert einen Zustand, den niemand reproduzieren kann.

Mit steigender Größe wird die YAML-Menge unhandlich. Werkzeuge wie Helm (Paketmanager mit Vorlagen) und Kustomize (Überlagerungen je Umgebung, in kubectl eingebaut) schaffen Abhilfe.

8.6 Distributionen

Distribution Passt zu Anmerkung
k3s Raspberry Pi, Edge, kleine Cluster eine Binärdatei, ~70 MB, SQLite statt etcd
k0s ähnlich wie k3s zunehmend verbreitet
MicroShift Edge auf RHEL OpenShift-Kern, minimaler Fußabdruck
kubeadm eigenes Rechenzentrum der Standardweg, viel Handarbeit
OpenShift Unternehmen siehe Kapitel 9
EKS / AKS / GKE Cloud Control Plane wird verwaltet
kind / minikube Entwicklung Cluster im Container bzw. in der VM

8.7 Was Kubernetes tatsächlich kostet

Das wird in Vorträgen gern unterschlagen, deshalb hier deutlich:

Ein selbst betriebener Kubernetes-Cluster braucht dauerhaft Aufmerksamkeit für: Aktualisierungen der Control Plane (etwa dreimal jährlich, mit veralteten APIs als Stolperfalle), Zertifikate, Netzwerk (CNI-Plugin), Speicheranbindung (CSI-Treiber), Monitoring, Logsammlung, Backup von etcd, Rechteverwaltung und Netzwerkrichtlinien.

Als Faustregel gilt: eine halbe bis eine ganze Vollzeitstelle, nur für die Plattform. Nicht für die Anwendungen darauf.

Kubernetes löst Probleme, die man ab einer gewissen Größe hat. Wer diese Probleme nicht hat, tauscht sie gegen neue ein — und die neuen sind komplizierter. Drei Container auf einem Server rechtfertigen keinen Cluster, so gern man das im Lebenslauf stehen hätte.


9. OpenShift

9.1 Was OpenShift zu Kubernetes hinzufügt

OpenShift ist Kubernetes — mit allem, was man ohnehin nachrüsten müsste, bereits integriert, getestet und supportet.

Bereich Kubernetes pur OpenShift
Installation kubeadm, viel Handarbeit geführter Installer, auch für Bare Metal
Weboberfläche Dashboard nachrüsten vollwertige Konsole eingebaut
Zugang von außen Ingress-Controller wählen Routes, eingebaut
Registry selbst betreiben integriert
Bauen im Cluster externe Pipeline BuildConfig, Source-to-Image
Monitoring Prometheus/Grafana selbst vorkonfiguriert
Logsammlung ELK/Loki selbst integrierbar
Sicherheitsvorgaben Pod Security Standards SCCs, streng voreingestellt
CI/CD selbst wählen Pipelines (Tekton), GitOps (Argo CD)
Aktualisierung jede Komponente einzeln Cluster-Update als Ganzes
Support Community Red Hat

9.2 Sicherheit ab Werk

Der praktisch spürbarste Unterschied. OpenShift verwendet Security Context Constraints und weist jedem Projekt einen zufälligen UID-Bereich zu. Ein Container läuft dort nicht als UID 1001, sondern etwa als 1000680000 — und zwar ohne dass man das im Image vorhersehen könnte.

Der Klassiker bei der ersten OpenShift-Begegnung: Ein Image, das lokal einwandfrei läuft, startet im Cluster nicht. Ursache ist fast immer, dass es Root voraussetzt oder auf feste UIDs angewiesen ist.

Die Lösung ist kein Ausnahmerecht, sondern ein besseres Image:

  • Gruppe 0 (root-Gruppe) statt fester UID für Dateirechte verwenden
  • chmod -R g=u auf alle Verzeichnisse, in die geschrieben wird
  • keine Ports unter 1024 belegen
  • nicht in / oder Systemverzeichnisse schreiben

Ein Image, das unter OpenShift läuft, läuft überall. Der umgekehrte Schluss gilt nicht. Insofern ist die Strenge ein kostenloser Qualitätsprüfer.

9.3 Routes, Registry und Source-to-Image

Eine Route ist OpenShifts Antwort auf Ingress, nur einfacher:

oc expose service webserver
oc create route edge --service=webserver --hostname=app.firma.de

Source-to-Image (S2I) baut aus Quelltext direkt ein lauffähiges Image, ohne dass jemand ein Containerfile schreibt:

oc new-app python:3.11~https://git.firma.de/team/anwendung.git
oc logs -f bc/anwendung

Für Entwickler ist das bemerkenswert bequem: Git-URL rein, laufende Anwendung raus. Der Preis ist ein Stück weniger Kontrolle über den Bauvorgang.

9.4 Operatoren

Ein Operator ist Betriebswissen in Softwareform. Statt eine Datenbank von Hand aufzusetzen, zu sichern und zu aktualisieren, installiert man einen Operator, der genau das übernimmt — inklusive Ausfallsicherheit und Versionswechsel.

oc get csv -n openshift-operators

Der OperatorHub in der Konsole bietet Hunderte davon an. Das ist einer der stärksten Gründe für OpenShift: Betriebswissen, das man sonst mühsam selbst aufbauen müsste, kommt als installierbares Paket.

9.5 Betriebsmodelle

Variante Beschreibung Passt zu
OpenShift Container Platform selbst betrieben, eigenes RZ volle Kontrolle, Datenhoheit
ROSA verwaltet auf AWS Cloud-Strategie mit AWS
ARO verwaltet auf Azure Cloud-Strategie mit Azure, gemeinsamer Support Microsoft/Red Hat
OpenShift Dedicated von Red Hat verwaltet wenig eigenes Personal
MicroShift Edge, ein einzelner Knoten Filiale, Fahrzeug, Maschine
OpenShift Local (CRC) Notebook, Einzelknoten Entwicklung und Lernen

9.6 OpenShift Local zum Ausprobieren

crc setup
crc start --memory 16384 --cpus 6
eval $(crc oc-env)
oc login -u developer

Braucht mindestens 16 GB RAM und vier CPU-Kerne — auf einem Raspberry Pi wird das nichts. Für Lernzwecke aber der schnellste Weg zu einem echten OpenShift.

9.7 Lizenz- und Kostenbetrachtung

OpenShift wird nach Kernen bzw. Kernpaaren lizenziert. Die Listenpreise liegen im vierstelligen Bereich pro Kernpaar und Jahr, in der Praxis wird verhandelt. Der ehrliche Vergleich lautet nicht „Kubernetes kostenlos gegen OpenShift teuer“, sondern:

  • Kubernetes: keine Lizenzkosten, dafür Personalkosten für Integration, Betrieb und Aktualisierung — plus das Risiko, dass dieses Wissen an einzelnen Personen hängt.
  • OpenShift: Lizenzkosten, dafür weniger Integrationsaufwand, ein Ansprechpartner bei Problemen und definierte Lebenszyklen.

Ab einer gewissen Clustergröße kippt die Rechnung regelmäßig zugunsten von OpenShift. Darunter selten.

9.8 Wann OpenShift den Aufpreis rechtfertigt

Dafür spricht: regulierte Branchen mit Nachweispflichten (Versicherung, Bank, Gesundheitswesen), bestehende Red-Hat-Landschaft, mehrere Entwicklerteams mit Bedarf an Mandantentrennung, fehlendes Plattform-Team, Anforderung an belastbaren Support.

Dagegen spricht: kleine Umgebungen, ausgeprägtes eigenes Kubernetes-Know-how, starke Cloud-Bindung mit vorhandenem Managed-Angebot, knappes Budget bei überschaubarem Risiko.


10. Die Einordnungsfrage: Welche Umgebung wann

Das wichtigste Kapitel dieses Tutorials. Die Technik ist dokumentiert, die Angemessenheit nicht.

10.1 Die entscheidenden Kriterien

Kriterium Frage
Anzahl Dienste 3, 30 oder 300?
Verfügbarkeit Darf es nachts stehen? Ist ein Ausfall teuer?
Teamgröße Wer betreibt das, wenn diese eine Person Urlaub hat?
Betriebsreife Gibt es Monitoring, Backup, Änderungsprozesse?
Änderungsrate Ein Update im Quartal oder zwanzig am Tag?
Regulierung DORA, BaFin, DSGVO, ISO 27001?
Budget Lizenz- und Personalkosten

Die ehrlichste Frage lautet nicht „Was ist die beste Technik?„, sondern „Was können wir am Donnerstagabend um 22 Uhr noch reparieren, wenn die Kollegin mit dem Wissen im Urlaub ist?“

Diese Frage hat schon mehr Architekturentscheidungen richtig gestellt als jedes Whitepaper.

10.2 Stufe 1 — Raspberry Pi und Heimserver

Lage: 1–10 Dienste, eine Person, Ausfall ist ärgerlich, aber nicht teuer.

Empfehlung: Podman rootless mit Quadlet-Units. Kein Cluster, kein Compose-Wildwuchs.

Warum kein k3s? Man kann. k3s läuft auf einem Pi 4 mit 4 GB. Aber der Cluster verbraucht selbst schon spürbar Speicher und will gepflegt werden — für Pi-hole, Nextcloud und einen Medienserver ist das Aufwand ohne Gegenwert. Wer Kubernetes lernen möchte, ist mit k3s auf dem Pi hingegen bestens bedient. Das ist ein anderes Ziel und ein legitimes.

Zu beachten: ARM-Architektur. Nicht jedes Image gibt es für arm64.

podman run --platform linux/arm64 ...
podman manifest inspect docker.io/library/nginx:alpine

10.3 Stufe 2 — Einzelner Firmenserver

Lage: 5–30 Dienste, kleines Team, geplante Wartungsfenster sind akzeptabel, Nachvollziehbarkeit wird verlangt.

Empfehlung: RHEL mit rootless Podman, Quadlet und Git als einzige Quelle der Wahrheit. Genau der Aufbau aus Kapitel 14.

Warum das oft die richtige Antwort ist: Man bekommt Reproduzierbarkeit, Versionierung, Rollback, Trennung der Dienste, saubere Protokollierung und ein prüffähiges Änderungsverfahren — mit Werkzeugen, die jeder Linux-Administrator bereits kennt. Der Sprung zu Kubernetes bringt an dieser Stelle Hochverfügbarkeit, kostet aber ein Vielfaches an Komplexität.

Hochverfügbarkeit ist nicht kostenlos, und sie ist nicht immer nötig. Ein Dienst, der monatlich für zehn Minuten wegen eines Updates steht, hat eine Verfügbarkeit von 99,98 Prozent. Das reicht für die allermeisten internen Anwendungen aus. Die Frage ist nicht „Wollen wir Ausfallsicherheit?„ — natürlich will die jeder — sondern „Was kostet uns eine Stunde Stillstand, und was kostet die Vermeidung?“

10.4 Stufe 3 — Kleiner Cluster

Lage: 20–100 Dienste, Ausfälle sind teuer, es gibt ein Team von mehreren Personen.

Empfehlung: k3s auf 3–5 Knoten, oder ein verwaltetes Kubernetes, falls Cloud in Frage kommt.

Warum jetzt: Ab hier sind die Probleme echt, die Kubernetes löst — ein Serverausfall darf nicht mehr zum Ereignis werden, Rollouts sollen ohne Unterbrechung laufen, mehrere Personen brauchen abgestufte Rechte.

10.5 Stufe 4 — Unternehmens-Rechenzentrum

Lage: 100+ Dienste, mehrere Teams, regulierte Branche, dediziertes Plattform-Team.

Empfehlung: OpenShift oder ein professionell aufgesetztes Kubernetes.

Warum OpenShift hier oft gewinnt: Nachweispflichten. Wenn ein Prüfer wissen will, wer wann welche Version in Produktion gebracht hat, wer Zugriff hat und wie Schwachstellen behandelt werden, liefert OpenShift die Antworten aus dem Karton. Bei selbst gebautem Kubernetes ist das eine Integrationsaufgabe — machbar, aber sie kostet.

10.6 Stufe 5 — Cloud und Hybrid

Lage: Elastische Last, mehrere Standorte oder Anbieter, Cloud-Strategie ist gesetzt.

Empfehlung: Verwaltetes Kubernetes (AKS, EKS, GKE) oder ROSA/ARO.

Zu bedenken: Verwaltete Dienste nehmen Arbeit ab, aber nicht die Verantwortung. Anwendungen, Netzwerkrichtlinien, Rechte, Kosten und Datenschutz bleiben beim Betreiber. Und: Datenübertragung nach außen kostet Geld. Rechnungen mit vierstelligen Beträgen für Egress-Traffic sind in Cloud-Projekten ein wiederkehrendes Motiv.

10.7 Entscheidungsmatrix

Podman einzeln Podman + Quadlet + Git k3s Kubernetes OpenShift
Dienste 1–5 5–30 20–100 50+ 100+
Personen im Betrieb 1 1–3 2–5 3–8 5+
Ausfallsicherheit nein eingeschränkt ja ja ja
Einarbeitungszeit Stunden Tage Wochen Monate Monate
Laufender Aufwand sehr gering gering mittel hoch mittel (bezahlt)
Lizenzkosten keine keine keine keine ja
Prüffähigkeit selbst bauen über Git gut selbst bauen selbst bauen eingebaut
Raspberry Pi ja ja ja nein nein

10.8 Typische Fehlentscheidungen

  • Kubernetes für drei Container. Der Klassiker. Man tauscht ein einfaches Problem gegen ein kompliziertes und nennt es Fortschritt.
  • Compose-Dateien in Produktion. Compose ist ein Entwicklungswerkzeug. Kein Autostart, keine Neustartlogik, keine ordentliche Protokollierung.
  • Alles in einen Container. Wer Datenbank, Anwendung und Webserver in ein Image packt, hat eine schlecht wartbare VM gebaut.
  • Kein Backup, weil „Container sind ja ersetzbar„. Container schon. Volumes nicht.
  • latest in Produktion. Man weiß dann nicht, was läuft. Bei einem Vorfall ist das ein echtes Problem.
  • Die Plattform statt des Problems optimieren. Sechs Monate Cluster-Aufbau, während die Fachabteilung noch immer auf ihre Anwendung wartet.

11. Warum sich Containerisierung lohnt

Bisher ging es um das Wie. Jetzt um das Warum — und zwar mit Beispielen, nicht mit Adjektiven.

11.1 Betrieb: reproduzierbare Auslieferung

Vorher: Eine Anwendung wird auf einen Server ausgerollt. Sie braucht Python 3.9, eine bestimmte Bibliotheksversion und drei Umgebungsvariablen. Dokumentiert ist das in einer Wiki-Seite von 2021. Der Server läuft seit vier Jahren, in dieser Zeit hat niemand mehr genau nachvollziehen können, was alles darauf installiert wurde. Man nennt das einen „gewachsenen Server“, und alle sind sich einig, dass man ihn besser nicht anfasst.

Nachher: Die Anwendung ist ein Image. Das Image enthält Python 3.9 und die exakte Bibliotheksversion. Es wird aus einem Containerfile gebaut, das in Git liegt. Jeder kann es bauen, jeder kann es lesen, und was auf dem Testsystem lief, läuft auch in Produktion — es ist bitweise dasselbe.

11.2 Betrieb: Rollback in Sekunden

# Neue Version
podman stop app && podman rm app
podman run -d --name app registry.firma.de/app:2.4.0
 
# Es klemmt. Zurück.
podman stop app && podman rm app
podman run -d --name app registry.firma.de/app:2.3.8

Zwei Befehle, wenige Sekunden. Zum Vergleich der klassische Weg: Paket zurückrollen, Konfiguration wiederherstellen, Datenbankmigration rückabwickeln, hoffen. Der Unterschied ist nicht graduell.

Der Nebeneffekt ist wichtiger als der Effekt. Wenn ein Rollback fünf Sekunden dauert, verliert das Ausrollen seinen Schrecken. Teams, die schnell zurückkönnen, liefern häufiger aus. Wer häufiger ausliefert, liefert kleinere Änderungen aus. Kleinere Änderungen brechen seltener — und wenn doch, weiß man sofort, welche es war.

Das ist der eigentliche Grund, warum Container die Art verändert haben, wie Software betrieben wird. Nicht die Technik. Die veränderte Risikorechnung.

11.3 Betrieb: Ressourcendichte

Auf einem Server mit 32 GB RAM laufen vielleicht 15 VMs mit je 2 GB — von denen jede rund 500 MB allein für ihr Betriebssystem verbraucht. Macht 7,5 GB Overhead, bevor irgendeine Anwendung eine einzige Anfrage bearbeitet hat.

Dieselben 15 Dienste als Container: 15-mal 50 bis 200 MB. Auf demselben Blech passen statt 15 Diensten problemlos 60. Bei einem Serverpreis von 8.000 Euro ist das kein akademischer Unterschied.

11.4 Entwicklung: identische Umgebung überall

Das Problem: Ein Azubi startet am Montag. Bis die Entwicklungsumgebung steht, vergehen zwei Tage — Datenbank installieren, Version anpassen, Bibliotheken nachziehen, drei Fehlermeldungen googeln, einmal Kollegen fragen.

Die Lösung:

compose.yaml
services:
  db:
    image: docker.io/library/postgres:16
    environment:
      POSTGRES_PASSWORD: entwicklung
      POSTGRES_DB: appdb
    volumes:
      - dbdaten:/var/lib/postgresql/data

  cache:
    image: docker.io/library/redis:7-alpine

  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - db
      - cache
    volumes:
      - ./src:/app/src:Z

volumes:
  dbdaten:
podman-compose up -d

Aus zwei Tagen werden zwei Minuten. Und alle im Team haben exakt dieselbe Umgebung — inklusive derselben Postgres-Nebenversion, was genau die Sorte Fehler verhindert, die man sonst erst in Produktion findet.

11.5 Entwicklung: „Läuft bei mir" endgültig beenden

Der Satz „Bei mir funktioniert es aber„ hat eine gewisse Ähnlichkeit mit dem Unendlichkeitsantrieb: Beide erzeugen aus einem unwahrscheinlichen Zustand heraus Ergebnisse, die niemand reproduzieren kann. Der Unterschied ist, dass der Unendlichkeitsantrieb wenigstens unterhaltsam ist.

Mit Containern verschwindet der Satz. Nicht, weil Fehler verschwinden, sondern weil die Umgebung aufhört, eine Variable zu sein. Wenn es im Container klemmt, klemmt es überall — und damit lässt es sich untersuchen.

11.6 Sicherheit: minimale Angriffsfläche

Ein klassischer Anwendungsserver enthält typischerweise: einen SSH-Dienst, einen Cron-Daemon, einen Mailer, Paketverwaltung, Compiler, Editoren, diverse Bibliotheken aus der Grundinstallation — und irgendwo darin die eigentliche Anwendung.

Ein Container aus einem Multi-Stage-Build enthält: die Anwendung und ihre Laufzeitbibliotheken. Sonst nichts. Keine Shell, kein Paketmanager, kein Compiler.

Für einen Angreifer, der Code ausführen kann, ist das ein erheblicher Unterschied. Kein curl zum Nachladen, kein Paketmanager zum Installieren, keine Shell zum Weiterarbeiten. Das verhindert keinen Einbruch, aber es macht die Ausweitung eines Angriffs mühsam — und Mühsal ist im Sicherheitsbereich eine harte Währung.

11.7 Sicherheit: Patchen durch Neubau

# Basis-Image aktualisieren, Image neu bauen, austauschen
podman build --pull -t app:2.4.1 .
podman push registry.firma.de/app:2.4.1

Der Container ist unveränderlich. Es gibt keine über Jahre gewachsene Konfiguration, keine manuell nachgezogene Bibliothek, keine vergessene Testdatei aus 2019. Jeder Neubau erzeugt einen definierten Zustand.

Für Compliance-Anforderungen ist das erheblich: Die Frage „Läuft auf diesem System noch eine verwundbare Version von Bibliothek X?“ ist bei Containern eine Abfrage gegen die Image-Liste. Auf gewachsenen Servern ist sie eine Suchaktion mit ungewissem Ausgang.

11.8 Nachvollziehbarkeit

podman inspect --format '{{.ImageDigest}}' app

Der Digest beantwortet die Frage „Was läuft hier genau?„ eindeutig — nicht ungefähr, nicht dem Namen nach, sondern kryptografisch belegbar. In Verbindung mit einer signierten Registry und einem Git-Verlauf ergibt das eine lückenlose Kette von der Codeänderung bis zum laufenden Prozess.

In regulierten Umgebungen ist genau diese Kette der Punkt, an dem Prüfungen sonst unangenehm werden.

11.9 Wo Container nicht die richtige Antwort sind

Damit dieses Kapitel keine Werbebroschüre wird:

  • Anwendungen mit direktem Hardwarezugriff — spezielle Karten, Echtzeitanforderungen, exotische Treiber.
  • Zustandsbehaftete Software mit eigener Clusterlogik — geht, ist aber oft unnötig kompliziert. Eine gut gepflegte Oracle-Instanz auf einer VM ist keine Schande.
  • Windows-Anwendungen — Windows-Container existieren, brauchen aber Windows-Hosts und sind ein eigenes Thema.
  • Ein einzelner, stabiler Dienst, der seit acht Jahren läuft und nie geändert wird. Wenn nichts wehtut, muss man nichts heilen.
  • Teams ohne Linux-Grundlagen. Container setzen Linux-Verständnis voraus, sie ersetzen es nicht. Wer Namespaces, Dateirechte und Netzwerk nicht kennt, wird beim ersten Permission denied im Dunkeln stehen.

12. Praxisbeispiele

12.1 Datenbank auf Abruf

# Postgres für zwanzig Minuten, danach spurlos verschwunden
podman run -d --rm --name testdb \
  -e POSTGRES_PASSWORD=test \
  -p 5432:5432 \
  docker.io/library/postgres:16
 
# ... arbeiten ...
 
podman stop testdb    # weg, inklusive aller Testdaten

Wer schon einmal eine Datenbank auf seinem Arbeitsrechner installiert und Monate später beim Aufräumen wiedergefunden hat, weiß den Unterschied zu schätzen.

12.2 Reproduzierbare Build-Umgebungen

Ein Projekt braucht GCC 9, ein anderes GCC 13. Früher hieß das: zwei Rechner oder viel Verdruss.

podman run --rm -v "$PWD":/src:Z -w /src docker.io/gcc:9 make
podman run --rm -v "$PWD":/src:Z -w /src docker.io/gcc:13 make

Beide Toolchains nebeneinander, keine gegenseitige Beeinflussung, kein Eingriff ins Hostsystem.

12.3 Eine Testumgebung pro Feature-Branch

BRANCH=$(git rev-parse --abbrev-ref HEAD)
PORT=$((8000 + $(git rev-list --count HEAD) % 1000))
 
podman build -t "app:${BRANCH}" .
podman run -d --name "app-${BRANCH}" -p "${PORT}:8080" "app:${BRANCH}"
echo "Testumgebung: http://localhost:${PORT}"

In CI/CD-Pipelines gebaut, bekommt jeder Merge Request eine eigene, vollständig funktionsfähige Umgebung. Der Fachbereich kann eine Änderung anschauen, bevor sie zusammengeführt wird — statt sie sich anhand von Bildschirmfotos vorzustellen.

12.4 Altanwendungen kontrolliert weiterbetreiben

Es gibt in jedem Unternehmen diese eine Anwendung. Sie ist geschäftskritisch, sie braucht PHP 5.6 und eine Bibliothek, die seit 2016 nicht mehr gepflegt wird, und der Entwickler ist längst woanders. Sie ist die Marvin-Anwendung: niemand mag sie, alle brauchen sie, und sie wird alle überleben.

Ein Container ist hier eine ehrliche Zwischenlösung:

Containerfile.legacy
FROM docker.io/library/php:5.6-apache
COPY anwendung/ /var/www/html/
# Keine Illusionen: das ist ein Käfig, keine Heilung

Die veraltete Umgebung ist eingesperrt, sie verunreinigt keinen aktuellen Server, sie lässt sich mit Netzwerkregeln abschotten und auf einen Reverse Proxy mit aktuellem TLS setzen. Das Sicherheitsproblem ist nicht gelöst — es ist eingegrenzt und wird beherrschbar, bis der Ersatz steht.

12.5 Werkzeuge ohne Installation nutzen

# Schwachstellenscan, ohne Trivy zu installieren
podman run --rm docker.io/aquasec/trivy:latest image nginx:alpine
 
# YAML prüfen
podman run --rm -v "$PWD":/data:Z docker.io/cytopia/yamllint .
 
# LaTeX kompilieren ohne 4 GB TeX Live auf dem Rechner
podman run --rm -v "$PWD":/doc:Z -w /doc docker.io/texlive/texlive \
  pdflatex dokument.tex

Der Rechner bleibt sauber, die Werkzeuge sind trotzdem da. Und wenn man sie nicht mehr braucht, verschwinden sie mit einem podman image rm.

12.6 Selbstgehostete Dienste auf dem Raspberry Pi

Ein realistischer Aufbau auf einem Pi 5 mit 8 GB:

Dienst Zweck RAM
Pi-hole DNS-Filter ~80 MB
Nextcloud Dateien und Kalender ~500 MB
Home Assistant Hausautomation ~400 MB
Vaultwarden Passwortverwaltung ~50 MB
Jellyfin Medienserver ~300 MB
Uptime Kuma Überwachung ~100 MB

Alle als Quadlet-Units, alle rootless, alle starten beim Booten. Gesamt etwa 1,5 GB — auf einem Gerät für unter 100 Euro. Vor zehn Jahren wären dafür fünf Rechner nötig gewesen, und das Netzteil hätte mehr gekostet als heute der ganze Pi.


13. Sicherheit im Containerbetrieb

13.1 Rootless als Standard

podman info | grep -i rootless
podman unshare cat /proc/self/uid_map

Falls doch einmal rootful nötig ist (etwa für Ports unter 1024 ohne Sysctl-Anpassung oder für bestimmte Netzwerkfunktionen), sollte das eine bewusste, dokumentierte Ausnahme sein — nicht der Standardweg.

13.2 Read-only und Capabilities

podman run -d \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges \
  --user 1001 \
  meine-app:1.0
Option Wirkung
–read-only Dateisystem des Containers nicht beschreibbar
–tmpfs gezielt ein beschreibbares Verzeichnis im RAM
–cap-drop ALL alle Sonderrechte entziehen
–security-opt no-new-privileges kein Rechtezuwachs über setuid-Programme
–user feste unprivilegierte UID

13.3 SELinux und Volumes

# Falsch - führt zu Permission denied
podman run -v /srv/daten:/daten nginx
 
# Richtig
podman run -v /srv/daten:/daten:Z nginx      # exklusiv
podman run -v /srv/daten:/daten:z nginx      # geteilt
 
# Kontext prüfen
ls -Z /srv/daten

–privileged und –security-opt label=disable schalten die Schutzmechanismen ab. Sie tauchen in Anleitungen im Netz auf, weil sie jedes Rechteproblem sofort beseitigen — so, wie das Abklemmen der Feuermelder jedes Fehlalarmproblem beseitigt. Wenn du eine solche Option in einer Anleitung findest, ist das ein Hinweis darauf, dass der Autor das eigentliche Problem nicht gelöst hat.

13.4 Secrets korrekt handhaben

printf 'MeinGeheimesPasswort' | podman secret create db_passwort -
podman secret ls
 
podman run -d --name db \
  --secret db_passwort,type=env,target=POSTGRES_PASSWORD \
  postgres:16

Damit steht das Passwort weder in der Prozessliste noch in podman inspect noch in der Shell-Historie. In Quadlet-Units referenziert man es über Secret=db_passwort,type=env,target=POSTGRES_PASSWORD.

13.5 Netzwerksegmentierung

podman network create --internal backend
podman network create frontend
 
# Datenbank: nur im internen Netz, keine Verbindung nach außen
podman run -d --name db --network backend postgres:16
 
# Anwendung: in beiden Netzen
podman run -d --name app --network backend,frontend -p 8080:8080 app:1.0

–internal verhindert jede Verbindung ins Internet. Eine kompromittierte Datenbank kann damit nichts nachladen und nichts abfließen lassen.

13.6 Lieferkette

  • Images nur aus bekannten Registries beziehen
  • Basis-Images auf feste Versionen festnageln, nicht auf latest
  • regelmäßig scannen, nicht nur einmal beim Bauen
  • in Produktion Digests statt Tags verwenden
  • eigene Images signieren
  • einen internen Spiegel betreiben, damit man nicht von der Verfügbarkeit fremder Registries abhängt

Ein Image aus dem Internet ist fremder Code, der auf deinem Server Prozesse startet. Ein Image mit 50 Millionen Downloads ist deshalb nicht automatisch vertrauenswürdig — es ist nur beliebt. Wer bei Software aus dem Netz sonst genau hinschaut, sollte das bei Containern erst recht tun. Ein Handtuch bei sich zu haben ist gut; zu wissen, wo es herkommt, ist besser.

13.7 Protokollierung und Auditierbarkeit

# Podman-Ereignisse
podman events --since 24h
 
# Container-Logs über journald
journalctl --user -u webserver.service --since today
 
# Auditd auf Container-Aktivitäten
sudo auditctl -w /usr/bin/podman -p x -k container_exec

Für regulierte Umgebungen sind drei Dinge nachzuweisen: was läuft (Image-Digest), wer es gestartet hat (journald, auditd) und woher es kommt (Registry und Signatur). Alle drei sind mit Bordmitteln erreichbar — man muss sie nur von Anfang an einschalten und nicht erst, wenn die Prüfung angekündigt ist.


14. HowTo: Podman-Server mit Git-Repository unter RHEL

Dieses Kapitel ist bewusst so geschrieben, dass es eigenständig abgearbeitet werden kann. Wer die vorherigen Kapitel übersprungen hat, kommt hier trotzdem durch. Dafür wiederholt es an einigen Stellen Dinge, die vorher schon erklärt wurden.

Zeitbedarf: etwa zwei Stunden. Voraussetzung: RHEL 9 (oder AlmaLinux/Rocky 9), Root-Zugang, ein erreichbarer Git-Server.

14.1 Zielbild

Am Ende steht ein Server, auf dem gilt:

  • Container laufen rootless unter einem eigenen Dienstbenutzer.
  • Die einzige Quelle der Wahrheit ist ein Git-Repository. Was dort nicht steht, existiert nicht.
  • Container sind systemd-Dienste — sie starten beim Booten und werden bei Absturz neu gestartet.
  • Ein Deployment ist ein Skriptaufruf: auschecken, bauen, neu starten.
  • Alles landet in journald und ist damit nachvollziehbar.
  • Ein Rollback ist ein git checkout plus Neustart.
   Git-Server (GitLab/Gitea/GitHub)
        │  git pull (Deploy-Key, nur lesen)
        ▼
 ┌──────────────────────────────────────────────┐
 │  RHEL 9 — Benutzer: conops (kein Root)       │
 │                                              │
 │  ~/repo/            ← Git-Arbeitskopie       │
 │  ~/.config/containers/systemd/  ← Quadlet    │
 │  ~/.local/share/containers/     ← Images     │
 │                                              │
 │  systemd --user                              │
 │    ├── webapp.service      (Container)       │
 │    ├── datenbank.service   (Container)       │
 │    └── podman-auto-update.timer              │
 │                                              │
 │  firewalld ── 80/443 ──► Reverse Proxy       │
 └──────────────────────────────────────────────┘

14.2 RHEL 9 vorbereiten

# Als root
dnf update -y
 
dnf install -y \
  podman \
  buildah \
  skopeo \
  git-core \
  policycoreutils-python-utils \
  firewalld
 
systemctl enable --now firewalld
 
# Versionen prüfen
podman --version
git --version

Hinweis: Podman 4.4 oder neuer ist Pflicht, sonst gibt es kein Quadlet. RHEL 9.2 und aufwärts liefern das mit. Bei älteren Ständen hilft dnf update podman.

14.3 Dienstbenutzer anlegen

# Als root
useradd --create-home --shell /bin/bash --comment "Container-Betrieb" conops
 
# subuid/subgid prüfen - RHEL 9 vergibt sie normalerweise automatisch
grep conops /etc/subuid /etc/subgid
# Erwartet etwa: conops:100000:65536

Falls die Einträge fehlen:

usermod --add-subuids 100000-165535 --add-subgids 100000-165535 conops
loginctl terminate-user conops 2>/dev/null || true
su - conops -c 'podman system migrate'

Lingering aktivieren — ohne das funktioniert nichts. Standardmäßig beendet systemd alle Benutzerdienste, sobald sich der Benutzer abmeldet. Deine Container würden also beim Ausloggen sterben und nach einem Neustart nicht wiederkommen.

Das ist mit Abstand der häufigste Fehler bei diesem Aufbau. Wenn nach einem Reboot alles weg ist: hier nachsehen.

loginctl enable-linger conops
loginctl show-user conops | grep Linger
# Linger=yes

14.4 Podman rootless einrichten und prüfen

Ab jetzt wird als conops gearbeitet. Wichtig ist eine vollwertige Anmeldesitzung:

# Als root - saubere Sitzung öffnen
machinectl shell conops@
# Alternativ per SSH direkt als conops anmelden

Achtung: Ein einfaches su - conops liefert keine korrekte D-Bus-Sitzung, und systemctl –user scheitert dann mit „Failed to connect to bus“. Falls du dennoch mit su arbeitest:

export XDG_RUNTIME_DIR=/run/user/$(id -u)
export DBUS_SESSION_BUS_ADDRESS=unix:path=${XDG_RUNTIME_DIR}/bus

Funktionsprüfung:

podman info --format '{{.Host.Security.Rootless}}'   # true
 
podman run --rm registry.access.redhat.com/ubi9/ubi echo "Es funktioniert."
 
# UID-Abbildung ansehen
podman unshare cat /proc/self/uid_map
 
systemctl --user status

14.5 Git einrichten und Deploy-Key hinterlegen

Der Server soll nur lesen dürfen. Ein Deploy-Key mit Lesezugriff ist dafür genau das richtige Mittel.

# Als conops
ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -N "" -C "conops@$(hostname -s)"
cat ~/.ssh/deploy_key.pub

Den öffentlichen Schlüssel im Git-Server hinterlegen:

  • GitLab: Projekt → Settings → Repository → Deploy Keys (ohne Schreibrecht)
  • Gitea/Forgejo: Repository → Einstellungen → Deploy-Schlüssel
  • GitHub: Repository → Settings → Deploy keys (Häkchen „Allow write access„ nicht setzen)

SSH-Konfiguration:

~/.ssh/config
Host git.firma.de
    HostName git.firma.de
    User git
    IdentityFile ~/.ssh/deploy_key
    IdentitiesOnly yes
chmod 600 ~/.ssh/config ~/.ssh/deploy_key
ssh -T git@git.firma.de       # Begrüßung erwartet

14.6 Aufbau des Repositories

container-betrieb/
├── README.md
├── quadlet/
│   ├── webapp.container
│   ├── datenbank.container
│   ├── appdaten.volume
│   └── appnetz.network
├── images/
│   └── webapp/
│       ├── Containerfile
│       └── src/
├── config/
│   └── nginx.conf
├── scripts/
│   ├── deploy.sh
│   └── rollback.sh
└── docs/
    └── betriebshandbuch.md

Die Quadlet-Dateien:

quadlet/appnetz.network
[Unit]
Description=Internes Netzwerk der Anwendung

[Network]
NetworkName=appnetz
Subnet=10.89.10.0/24
quadlet/appdaten.volume
[Unit]
Description=Persistente Daten der Datenbank

[Volume]
VolumeName=appdaten
quadlet/datenbank.container
[Unit]
Description=PostgreSQL Datenbank
After=network-online.target
Requires=appdaten-volume.service appnetz-network.service

[Container]
Image=docker.io/library/postgres:16.3
ContainerName=datenbank
Network=appnetz.network
Volume=appdaten.volume:/var/lib/postgresql/data:Z
Environment=POSTGRES_DB=appdb
Environment=POSTGRES_USER=app
Secret=db_passwort,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U app -d appdb
HealthInterval=30s
HealthRetries=3
Memory=1g
AutoUpdate=registry

[Service]
Restart=always
TimeoutStartSec=120

[Install]
WantedBy=default.target
quadlet/webapp.container
[Unit]
Description=Webanwendung
After=datenbank.service
Requires=datenbank.service

[Container]
Image=localhost/webapp:aktuell
ContainerName=webapp
Network=appnetz.network
PublishPort=8080:8080
Environment=DB_HOST=datenbank
Environment=DB_NAME=appdb
Environment=DB_USER=app
Secret=db_passwort,type=env,target=DB_PASSWORD
Volume=%h/repo/config/nginx.conf:/etc/nginx/nginx.conf:ro,Z
HealthCmd=curl -f http://localhost:8080/health || exit 1
HealthInterval=30s
ReadOnly=true
Tmpfs=/tmp:rw,size=64m
NoNewPrivileges=true
DropCapability=ALL
AddCapability=NET_BIND_SERVICE
Memory=512m

[Service]
Restart=always

[Install]
WantedBy=default.target

Hinweis zu den Namen: Quadlet erzeugt aus webapp.container den Dienst webapp.service, aus appdaten.volume den Dienst appdaten-volume.service und aus appnetz.network den Dienst appnetz-network.service. Diese Namen braucht man in Requires= und After=. Das %h in Pfaden ist eine systemd-Ersetzung für das Heimatverzeichnis.

14.7 Repository auschecken

# Als conops
cd ~
git clone git@git.firma.de:betrieb/container-betrieb.git repo
cd ~/repo
 
mkdir -p ~/.config/containers/systemd

Die Quadlet-Dateien werden verlinkt, nicht kopiert. So wirkt ein git pull unmittelbar:

for f in ~/repo/quadlet/*.container ~/repo/quadlet/*.volume ~/repo/quadlet/*.network; do
    ln -sf "$f" ~/.config/containers/systemd/
done
 
ls -l ~/.config/containers/systemd/

Erzeugte Units kontrollieren, bevor irgendetwas startet:

/usr/libexec/podman/quadlet -dryrun -user

14.8 Secret anlegen und Image bauen

# Datenbankpasswort als Podman-Secret
read -rsp "DB-Passwort: " pw && printf '%s' "$pw" | podman secret create db_passwort - && unset pw
podman secret ls
 
# Image bauen
cd ~/repo/images/webapp
podman build -t localhost/webapp:$(git rev-parse --short HEAD) .
podman tag localhost/webapp:$(git rev-parse --short HEAD) localhost/webapp:aktuell
podman images

Hinweis: Der Tag aktuell ist ein beweglicher Zeiger, den die Quadlet-Unit referenziert. Der Commit-Tag daneben bleibt erhalten und ermöglicht den Rollback aus 14.12. So muss man die Unit-Datei bei jedem Deployment nicht anfassen.

14.9 Container als systemd-Dienst starten

systemctl --user daemon-reload
 
systemctl --user start datenbank.service
systemctl --user start webapp.service
 
systemctl --user status webapp.service
podman ps

Quadlet-Units werden nicht mit systemctl enable aktiviert. Sie sind zur Laufzeit erzeugt, und generierte Units lassen sich nicht aktivieren. Der Autostart kommt ausschließlich aus dem Abschnitt

[Install]
WantedBy=default.target

in der .container-Datei. Nach daemon-reload ist das erledigt. Wer hier systemctl –user enable webapp.service versucht, bekommt eine Fehlermeldung und sucht anschließend lange an der falschen Stelle.

Neustart-Test — der einzige Test, der zählt:

sudo reboot
# nach dem Hochfahren, als conops:
systemctl --user status webapp.service
podman ps

14.10 Persistente Daten und SELinux

Für Bind Mounts außerhalb des Heimatverzeichnisses setzt man den SELinux-Kontext dauerhaft:

# Als root
mkdir -p /srv/containerdaten
chown -R conops:conops /srv/containerdaten
 
semanage fcontext -a -t container_file_t "/srv/containerdaten(/.*)?"
restorecon -Rv /srv/containerdaten
ls -Z /srv/containerdaten

In der Quadlet-Unit dann:

Volume=/srv/containerdaten/uploads:/app/uploads:Z

Hinweis: Bei rootless-Podman gehören die Dateien auf dem Host der abgebildeten UID, nicht der Container-UID. Sieht man Dateien mit Besitzer 100999 — das ist normal und richtig. Für Zugriff aus dem Container hilft podman unshare:

podman unshare chown -R 1001:1001 /srv/containerdaten/uploads

14.11 Netzwerk, Firewall und Reverse Proxy

Rootless-Container dürfen standardmäßig keine Ports unter 1024 belegen. Es gibt zwei Wege.

Weg A — Sysctl anpassen (einfach, für kleine Aufbauten):

# Als root
echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-rootless-ports.conf
sysctl --system

Weg B — Reverse Proxy als root-Dienst (empfohlen, weil TLS und Zertifikate zentral liegen):

# Als root
dnf install -y nginx
/etc/nginx/conf.d/webapp.conf
server {
    listen 443 ssl http2;
    server_name app.firma.de;

    ssl_certificate     /etc/pki/tls/certs/app.firma.de.crt;
    ssl_certificate_key /etc/pki/tls/private/app.firma.de.key;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
# Als root
setsebool -P httpd_can_network_connect 1     # sonst blockt SELinux den Proxy
systemctl enable --now nginx
 
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
firewall-cmd --list-all

Achtung: httpd_can_network_connect ist der SELinux-Schalter, an dem Reverse-Proxy-Aufbauten unter RHEL regelmäßig scheitern. Ohne ihn liefert nginx ein 502 Bad Gateway, obwohl die Anwendung einwandfrei läuft.

14.12 Deployment-Skript

scripts/deploy.sh
#!/usr/bin/env bash
#
# Deployment: Repository aktualisieren, Image bauen, Dienste neu starten.
# Aufruf:  ./scripts/deploy.sh [branch]
#
set -euo pipefail
 
REPO="${HOME}/repo"
BRANCH="${1:-main}"
LOG="${HOME}/deploy.log"
 
log() { printf '%(%F %T)T  %s\n' -1 "$*" | tee -a "$LOG"; }
 
log "=== Deployment gestartet (Branch: ${BRANCH}) ==="
 
# --- 1. Stand sichern, um zurückrollen zu koennen ---
cd "$REPO"
ALT_COMMIT="$(git rev-parse --short HEAD)"
log "Aktueller Stand: ${ALT_COMMIT}"
 
# --- 2. Repository aktualisieren ---
git fetch --all --prune
git checkout "$BRANCH"
git pull --ff-only
NEU_COMMIT="$(git rev-parse --short HEAD)"
 
if [[ "$ALT_COMMIT" == "$NEU_COMMIT" ]]; then
    log "Keine Aenderungen. Deployment beendet."
    exit 0
fi
log "Neuer Stand: ${NEU_COMMIT}"
 
# --- 3. Quadlet-Verknuepfungen auffrischen ---
mkdir -p "${HOME}/.config/containers/systemd"
find "${HOME}/.config/containers/systemd" -type l -delete
for f in "${REPO}"/quadlet/*.{container,volume,network,pod}; do
    [[ -e "$f" ]] && ln -sf "$f" "${HOME}/.config/containers/systemd/"
done
 
# --- 4. Image bauen ---
log "Baue Image ${NEU_COMMIT} ..."
podman build --pull \
    -t "localhost/webapp:${NEU_COMMIT}" \
    "${REPO}/images/webapp"
podman tag "localhost/webapp:${NEU_COMMIT}" localhost/webapp:aktuell
 
# --- 5. Dienste neu starten ---
systemctl --user daemon-reload
systemctl --user restart webapp.service
 
# --- 6. Zustand pruefen ---
log "Warte auf Gesundheitsstatus ..."
for i in {1..30}; do
    if podman healthcheck run webapp &>/dev/null; then
        log "Deployment erfolgreich: ${ALT_COMMIT} -> ${NEU_COMMIT}"
        podman image prune -f &>/dev/null || true
        exit 0
    fi
    sleep 2
done
 
# --- 7. Automatischer Rollback ---
log "FEHLER: Gesundheitspruefung fehlgeschlagen. Rollback auf ${ALT_COMMIT}."
podman tag "localhost/webapp:${ALT_COMMIT}" localhost/webapp:aktuell
git checkout "$ALT_COMMIT"
systemctl --user daemon-reload
systemctl --user restart webapp.service
log "Rollback abgeschlossen."
exit 1
chmod +x ~/repo/scripts/deploy.sh
~/repo/scripts/deploy.sh main

Das ist der eigentliche Gewinn dieses Aufbaus. Ein Deployment ist ein Skriptaufruf. Es ist wiederholbar, es protokolliert sich selbst, und es rollt bei einem Fehlschlag automatisch zurück. Der Git-Verlauf beantwortet lückenlos, wer wann was in Produktion gebracht hat.

Damit hast du auf einem einzelnen Server ohne Cluster, ohne Lizenzkosten und ohne neue Werkzeuge das erreicht, was anderswo unter „GitOps“ firmiert — mit git, systemd und einem Shell-Skript.

14.13 Automatische Aktualisierung

Für Container, die aus einer Registry kommen (etwa die Datenbank), kann Podman selbstständig auf neue Images prüfen:

systemctl --user enable --now podman-auto-update.timer
systemctl --user list-timers
 
# Probelauf ohne Änderung
podman auto-update --dry-run
 
# Manuell auslösen
podman auto-update

Voraussetzung ist AutoUpdate=registry in der Quadlet-Unit. Schlägt der Start des neuen Images fehl, rollt Podman selbstständig auf das vorherige zurück.

Achtung: Für selbst gebaute Images ist das der falsche Weg — die kommen über deploy.sh. Und in kritischen Umgebungen möchte man Aktualisierungen bewusst auslösen statt sie nachts automatisch geschehen zu lassen. Der Timer ist bequem, aber Bequemlichkeit und Kontrolle sind hier gegenläufig.

14.14 Protokollierung und Zustandsprüfung

# Logs eines Dienstes
journalctl --user -u webapp.service -f
journalctl --user -u webapp.service --since "1 hour ago"
 
# Alle Container-Dienste
journalctl --user -t webapp --since today
 
# Gesundheitsstatus
podman healthcheck run webapp
podman inspect webapp --format '{{.State.Health.Status}}'
 
# Ressourcenverbrauch
podman stats --no-stream

Ein einfaches Überwachungsskript für Cron oder einen systemd-Timer:

scripts/check.sh
#!/usr/bin/env bash
set -uo pipefail
 
FEHLER=0
for dienst in datenbank webapp; do
    if ! systemctl --user is-active --quiet "${dienst}.service"; then
        echo "KRITISCH: ${dienst}.service laeuft nicht"
        FEHLER=1
        continue
    fi
    status="$(podman inspect "$dienst" --format '{{.State.Health.Status}}' 2>/dev/null || echo unbekannt)"
    if [[ "$status" != "healthy" && "$status" != "unbekannt" ]]; then
        echo "WARNUNG: ${dienst} ist ${status}"
        FEHLER=1
    fi
done
exit $FEHLER

14.15 Sicherung und Wiederherstellung

Zu sichern sind drei Dinge — und nur drei:

Was Wie Warum
Git-Repository auf dem Git-Server enthält die gesamte Konfiguration
Volumes podman volume export die eigentlichen Daten
Secrets getrennt und verschlüsselt nicht im Repository
scripts/backup.sh
#!/usr/bin/env bash
set -euo pipefail
 
ZIEL="/srv/backup/container"
DATUM="$(date +%F)"
mkdir -p "${ZIEL}/${DATUM}"
 
# Datenbank konsistent sichern
podman exec datenbank pg_dump -U app appdb \
    | gzip > "${ZIEL}/${DATUM}/appdb.sql.gz"
 
# Volumes
for vol in $(podman volume ls -q); do
    podman volume export "$vol" | gzip > "${ZIEL}/${DATUM}/${vol}.tar.gz"
done
 
# Liste der laufenden Images samt Digest - fuer die Nachweispflicht
podman ps --format '{{.Names}} {{.Image}} {{.ImageID}}' \
    > "${ZIEL}/${DATUM}/laufende-images.txt"
 
# Aufbewahrung: 30 Tage
find "$ZIEL" -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +

Eine Datenbank sichert man nicht durch Kopieren ihres Volumes im laufenden Betrieb. Das Ergebnis ist ein inkonsistenter Stand, der beim Zurückspielen mit einiger Wahrscheinlichkeit nicht startet — und das merkt man erfahrungsgemäß genau dann, wenn man ihn braucht. Für Datenbanken immer den mitgelieferten Dump-Mechanismus verwenden (pg_dump, mysqldump, mongodump).

Und: Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. Sie ist eine Hoffnung mit Speicherverbrauch.

Wiederherstellung auf einem neuen Server:

# 1. Kapitel 14.2 bis 14.5 erneut abarbeiten
# 2. Repository auschecken
git clone git@git.firma.de:betrieb/container-betrieb.git ~/repo
# 3. Secrets neu anlegen
# 4. Volumes zurückspielen
gunzip -c appdaten.tar.gz | podman volume import appdaten -
# 5. Starten
~/repo/scripts/deploy.sh main

14.16 Die zehn wichtigsten Befehle im Alltag

# 1. Was läuft?
podman ps
 
# 2. Zustand eines Dienstes
systemctl --user status webapp.service
 
# 3. Logs verfolgen
journalctl --user -u webapp.service -f
 
# 4. Neu starten
systemctl --user restart webapp.service
 
# 5. Deployment
~/repo/scripts/deploy.sh main
 
# 6. Ressourcenverbrauch
podman stats --no-stream
 
# 7. In den Container schauen (nur zur Diagnose!)
podman exec -it webapp /bin/sh
 
# 8. Speicherplatz prüfen
podman system df
 
# 9. Aufräumen
podman image prune -f
 
# 10. Rollback auf einen bekannten Stand
cd ~/repo && git checkout <commit> && systemctl --user daemon-reload \
  && systemctl --user restart webapp.service

14.17 Fehlersuche

Symptom Wahrscheinliche Ursache Prüfung
Nach Reboot läuft nichts Lingering fehlt loginctl show-user conops \| grep Linger
Failed to connect to bus keine echte Sitzung machinectl shell conops@ statt su
Unit not found nach daemon-reload Quadlet-Datei fehlerhaft /usr/libexec/podman/quadlet -dryrun -user
Permission denied bei Volumes SELinux-Kontext ls -Z, Suffix Z ergänzen
Port kann nicht belegt werden Port unter 1024 Sysctl oder Reverse Proxy
Container startet und stirbt sofort Prozess beendet sich podman logs <name>
502 Bad Gateway vom Proxy SELinux-Boolean getsebool httpd_can_network_connect
Container erreichen sich nicht falsches Netzwerk podman network inspect appnetz
no space left on device alte Images podman system df, dann prune
systemctl enable scheitert Quadlet-Unit ist generiert [Install]-Abschnitt nutzen
Image wird nicht aktualisiert Tag zeigt auf alten Stand podman pull bzw. build –pull

Ein Diagnoseskript für den Ernstfall:

#!/usr/bin/env bash
echo "=== Podman ===";        podman --version; podman info --format '{{.Host.Security.Rootless}}'
echo "=== Lingering ===";     loginctl show-user "$USER" | grep -i linger
echo "=== Dienste ===";       systemctl --user list-units 'podman*' '*.service' --no-pager | head -30
echo "=== Container ===";     podman ps -a
echo "=== Quadlet ===";       /usr/libexec/podman/quadlet -dryrun -user 2>&1 | head -40
echo "=== Speicher ===";      podman system df
echo "=== SELinux ===";       getenforce; ausearch -m AVC -ts recent 2>/dev/null | tail -20
echo "=== Git ===";           git -C "$HOME/repo" log --oneline -5

15. Ausblick

15.1 Von Podman zu Kubernetes

Wenn der eine Server irgendwann nicht mehr reicht, ist der Weg kürzer als gedacht:

podman kube generate webapp datenbank > k8s-anwendung.yaml
podman kube play k8s-anwendung.yaml
kubectl apply -f k8s-anwendung.yaml

Das Ergebnis ist keine produktionsreife Kubernetes-Konfiguration — Ressourcenanforderungen, Ausfallsicherheit und Speicherklassen muss man nacharbeiten. Aber als Ausgangspunkt und als Lernhilfe ist es ausgezeichnet: Man sieht unmittelbar, wie sich die eigene Podman-Konfiguration in Kubernetes-Objekte übersetzt.

15.2 GitOps

Der Aufbau aus Kapitel 14 ist GitOps im Kleinen: Git ist die Quelle der Wahrheit, ein Automatismus stellt den beschriebenen Zustand her. In Kubernetes übernehmen das Argo CD oder Flux — mit dem Zusatz, dass sie permanent nachprüfen und Abweichungen selbstständig korrigieren.

15.3 Container in CI/CD

.gitlab-ci.yml
stages: [build, test, deploy]

build:
  stage: build
  image: quay.io/podman/stable
  script:
    - podman build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
    - podman login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - podman push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

scan:
  stage: test
  script:
    - trivy image --exit-code 1 --severity HIGH,CRITICAL \
        "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

Buildah und Podman sind hier im Vorteil: Sie bauen Images ohne privilegierten Läufer und ohne Daemon — anders als der klassische Docker-in-Docker-Aufbau, der einen privilegierten Container erfordert.

15.4 Weiterführendes

Thema Quelle
Podman man podman, docs.podman.io
Quadlet man podman-systemd.unit — die beste verfügbare Referenz
Kubernetes kubernetes.io/docs, Interactive Tutorials
OpenShift docs.redhat.com, Developer Sandbox (kostenlos)
Sicherheit CIS Benchmarks für Docker und Kubernetes
Zertifizierung Red Hat EX188 (Containers), EX280 (OpenShift), CKA/CKAD

Der praktischste Tipp zum Schluss: man podman-systemd.unit ist die vollständige Referenz aller Quadlet-Optionen und deutlich aktueller als das meiste, was man im Netz findet. Container-Anleitungen im Internet altern schnell — die Handbuchseite auf dem eigenen System passt immer zur installierten Version.


Anhang A: Befehls-Spickzettel Docker ↔ Podman

Aufgabe Docker Podman
Container starten docker run podman run
Liste docker ps -a podman ps -a
Logs docker logs -f podman logs -f
Shell docker exec -it podman exec -it
Image bauen docker build -t podman build -t oder buildah bud
Images docker images podman images
Herunterladen docker pull podman pull
Hochladen docker push podman push
Anmelden docker login podman login
Netzwerk anlegen docker network create podman network create
Volume anlegen docker volume create podman volume create
Aufräumen docker system prune podman system prune
Details docker inspect podman inspect
Compose docker compose up podman-compose up
Autostart –restart=always Quadlet + systemd
Pods podman pod create
Registry-Werkzeug skopeo
Kubernetes-Export podman kube generate

Anhang B: Podman ↔ Kubernetes ↔ OpenShift

Podman Kubernetes OpenShift
Umfang ein Host Cluster Cluster mit Zubehör
Kleinste Einheit Container/Pod Pod Pod
Konfiguration CLI, Quadlet YAML-Objekte YAML + eigene Objekte
Autostart systemd Controller Controller
Lastverteilung nein Service Service + Route
Ausfallsicherheit nein ja ja
Skalierung manuell automatisch automatisch
Weboberfläche Podman Desktop Dashboard nachrüsten eingebaut
Rechteverwaltung Unix-Rechte RBAC RBAC + SCC
Registry extern extern integriert
CI/CD extern extern Pipelines integriert
Einarbeitung Stunden Monate Monate
Lizenzkosten keine keine ja
Raspberry Pi ja k3s nein

Anhang C: Entscheidungsbaum

Wie viele Dienste?
│
├─ 1–5  ─────────────────────────► Podman + Quadlet
│                                  (Kapitel 14, verkürzt)
│
├─ 5–30 ─┬─ Ausfall verkraftbar? ─ ja ──► Podman + Quadlet + Git
│        │                                (Kapitel 14 vollständig)
│        └────────────────────────  nein ─► k3s (3 Knoten)
│
├─ 30–100 ─┬─ eigenes Plattform-Team? ─ ja ──► Kubernetes
│          └───────────────────────────  nein ─► OpenShift oder Managed
│
└─ 100+ ─┬─ reguliert / Nachweispflicht? ─ ja ──► OpenShift
         ├─ Cloud-Strategie gesetzt? ────── ja ──► AKS/EKS/GKE, ROSA/ARO
         └─────────────────────────────────  sonst ► Kubernetes, professionell betrieben

Querfragen, die die Antwort kippen können:
  * Wer betreibt das im Urlaub?              → eine Stufe kleiner wählen
  * Gibt es Monitoring und Backup schon?     → sonst zuerst das bauen
  * Ist Hochverfügbarkeit wirklich gefordert
    oder nur gewünscht?                      → ehrlich beantworten
  * Wie oft wird ausgeliefert?               → selten = kleinere Stufe genügt

Anhang D: Vorlagen zum Abtippen

D.1 Containerfile (Python, unprivilegiert, OpenShift-tauglich)

Containerfile
FROM registry.access.redhat.com/ubi9/ubi-minimal:9.4

LABEL maintainer="betrieb@firma.de" \
      io.containers.autoupdate="registry"

RUN microdnf install -y python3 python3-pip shadow-utils && \
    microdnf clean all && \
    rm -rf /var/cache/*

WORKDIR /app

COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt

COPY src/ ./src/

# Gruppe 0 statt fester UID -> laeuft auch unter OpenShift
RUN chgrp -R 0 /app && chmod -R g=u /app
USER 1001

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"

ENTRYPOINT ["python3"]
CMD ["src/app.py"]

D.2 Quadlet-Unit (Minimalvariante)

~/.config/containers/systemd/dienst.container
[Unit]
Description=Beispieldienst
After=network-online.target

[Container]
Image=registry.firma.de/team/dienst:1.0
ContainerName=dienst
PublishPort=8080:8080
Volume=dienst-daten.volume:/data:Z
Environment=LOG_LEVEL=info
Memory=512m
ReadOnly=true
Tmpfs=/tmp:rw,size=64m
NoNewPrivileges=true
DropCapability=ALL
AutoUpdate=registry

[Service]
Restart=always
TimeoutStartSec=90

[Install]
WantedBy=default.target

Anhang E: Glossar

Begriff Bedeutung
Bind Mount Host-Verzeichnis wird in den Container eingehängt
Buildah Werkzeug zum Bauen von Images ohne Daemon
Capability einzelnes Teilrecht aus der Allmacht von Root
cgroup Kernel-Funktion zur Ressourcenbegrenzung
CNI / CNCF Netzwerk-Schnittstelle / Cloud Native Computing Foundation
conmon Aufpasserprozess für einen Podman-Container
CRI-O schlanke Container-Laufzeit für Kubernetes
Digest kryptografische Prüfsumme, identifiziert ein Image eindeutig
Image unveränderliche Vorlage, aus der Container entstehen
Ingress / Route HTTP-Zugang von außen in Kubernetes bzw. OpenShift
Namespace (Kernel) isolierte Sicht eines Prozesses auf eine Ressource
Namespace (Kubernetes) logische Unterteilung eines Clusters
OCI Open Container Initiative, Standardisierungsgremium
Operator Software, die Betriebswissen für einen Dienst automatisiert
Pod Gruppe von Containern mit gemeinsamem Netzwerk
Quadlet systemd-Integration für Podman-Container
Registry Server zur Verteilung von Images
Rootless Containerbetrieb ohne Root-Rechte
runc / crun die eigentliche Container-Laufzeit
SCC Security Context Constraints — OpenShifts Sicherheitsvorgaben
Skopeo Werkzeug für Registry-Operationen ohne lokalen Download
UBI Universal Base Image von Red Hat, frei verwendbar
Volume von der Container-Laufzeit verwalteter Datenspeicher

Ende des Tutorials. Und denk daran: Wer ein Handtuch dabei hat, weiß, wo seine Volumes liegen — und hat vermutlich auch ein Backup davon.