Container – Nicht nur Kisten auf einem Schiff
Dieser Artikel handelt von meiner persönlichen Geschichte mit Anwendungs-Deployments, beginnend im Jahr 2010. Die Entwicklung der Deployment-Methoden, zusammen mit meinen persönlichen Erfahrungen und Meinungen, wird gezeigt. Am Ende gibt es ein kleines Beispiel für die Funktionsweise von Docker.
Dieser Artikel wurde ursprünglich auf Englisch verfasst. Sie lesen hier die übersetzte Version. Das englische Original finden Sie hier: Originalartikel lesen
PHP auf einem einsamen Laptop
Seit meinen frühen Jahren war ich fasziniert davon, etwas zu hosten, das von jedem im Internet aufgerufen werden kann.
Und als ich schließlich in die HTL kam, erwarb ich das notwendige Know-how, um einen Apache-Webserver einzurichten und Port-Forwarding am Router zu konfigurieren.
Der Webserver lief auf einem Acer Aspire von 2005 mit einer Installation des damaligen Xubuntu.
Apache wurde mit dem Ubuntu Package Manager APT installiert: apt update && apt install apache2
Git oder FTP nutzte ich zu diesem Zeitpunkt noch nicht, daher wurde alles direkt auf dem Acer Aspire entwickelt und über einen USB-Stick gesichert. Rückblickend wünschte ich, die Lehrer hätten uns dazu ermutigt, Git zu verwenden. Heute nutze ich es praktisch für alles. Auch wenn es ein sehr simples und starres Setup war, für mich war es das Größte. Ich liebte es und freute mich darauf, am nächsten Tag in die Schule zu gehen, um meinen Klassenkameraden zu zeigen, was ich gebaut hatte. Dieses Setup war sehr einfach, daher war es auch kein Problem, es auf eine neue Maschine zu übertragen. Auch Updates waren unkompliziert.
Fünf Admins und ein Hetzner Dedicated Server
Ein Jahr später entschied sich einer meiner Mitschüler, bei Hetzner einen dedizierten Root-Server zu mieten, und lud andere Interessierte ein. Ich war einer der Glücklichen.
Wir nutzten keine Virtualisierung, alles lief direkt auf dem dedizierten Server. Wir hosteten SSH, Apache, Apache-PHP und einige Minecraft-Server. In den darauffolgenden Jahren experimentierte ich mit Java-Servern (Tomcat), ASP.NET und anderen C#-Anwendungen. Es gab keine Backup-Strategie, alles wurde per SFTP auf den Server geschoben. Jeder Server-User hatte sein eigenes Ding am Laufen, Migration war keine Option.
Abgesehen von den Minecraft-Maps gab es sowieso nichts wirklich Wertvolles zu migrieren, da die meisten Deployments (zumindest meine) nur Proof-of-Concepts waren. Ich erwähne das, weil Portabilität und Zuverlässigkeit damals für mich keine Rolle spielten – niemand, nicht einmal ich selbst, war wirklich von den Deployments abhängig. Einzige Ausnahme waren vielleicht die Minecraft-Server (die aber nicht von mir betreut wurden).
Als wir die Schule abschlossen, gaben wir den Hetzner-Server auf, da jeder seinen eigenen Weg ging und wir leider den Kontakt verloren.
Hallo aus der Cloud
Als ich nach Graz zog, um an der TU Graz Informatik zu studieren, mietete ich meinen eigenen kleinen Cloud-Server bei Hetzner. Anders als bei meinen bisherigen Hosting-Versuchen wollte ich diesmal produktionsreife Services haben, um einige SaaS-Dienste zu ersetzen – Vendor-Lock-in war nie mein Ding.
Ich setzte GitLab Community Edition für meine Git-Repositories auf, einen ownCloud-Server als Dropbox-Ersatz, und einen Web-Shell-Service, um während der Arbeitszeit eine Linux-Shell verfügbar zu haben (ich arbeitete damals in einer Windows-Firma).
Ich nutzte Apache mit Let’s Encrypt zum TLS-Terminate der Services. Es war einiges an Konfiguration und Bastelei nötig, bis alles sauber lief. Diesmal wurde alles über den Paketmanager des Systems und die offiziellen Release-Deployments der jeweiligen Technologien installiert.
Warum liegt ein PC unter dem Bett?
Um 2017 wurde ich sicherheitsbewusster und wollte meine persönlichen Daten besser schützen. Ich wollte unbedingt meine Daten besitzen. Eine meiner Maßnahmen war, mein Deployment von der Hetzner-Cloud auf einen lokalen Server in meiner Wohnung zu verlegen.
Das war das erste Mal, dass ich von der fehlenden Portabilität meines Setups genervt war. Noch schlimmer: Ich hatte nicht dokumentiert, wie ich das letzte Hetzner-Deployment eingerichtet hatte. Also setzte ich diesmal alles von Grund auf neu auf und importierte die User-Daten manuell, weil es Versionsunterschiede gab und ich Probleme mit Migrationen hatte.
Während des Setups begann ich zu dokumentieren, welche Befehle ich ausführte und welche Config-Files ich anlegte. Falls die Festplatte ausfallen sollte, konnte ich alles neu aufsetzen. Auf diesem Server installierte ich zusätzlich einen DNS-Server, DHCP-Server und einen Dienst, der meine externe IP bei einem DynDNS-Provider aktualisierte. Das war meine erste echte Migration – und ich spürte den Schmerz von fehlender Portabilität, Versionierung, Umgebungs-Unterschieden und mangelnder Dokumentation.
Docker, Docker – was ist Docker?
Um 2018, also fünf Jahre zu spät (ja, Docker wurde im März 2013 veröffentlicht!), entdeckte ich Docker. Docker ist eine Container-Plattform, mit der komplette Anwendungen in ihrer idealen Umgebung mit allen Abhängigkeiten und einem stabilen Konfigurations-Interface via Environment-Variablen verpackt werden können.
Als ich feststellte, dass es Container für alle Anwendungen gab, die ich nutzte, beschloss ich, alles noch einmal neu aufzusetzen – diesmal mit Containern. Da meine Dokumentation des letzten Setups durch den Umstieg auf Docker nutzlos geworden war, entschied ich mich, Ansible für die Server-Einrichtung zu verwenden. Jedes Deployment lief in einem eigenen Docker-Container, selbst Certbot und der Reverse Proxy.
Statt Apache nutzte ich nun nginx, da mir die Config-Struktur besser gefiel. Es war großartig, Container zu verwenden, denn ich wusste, sie würden auch auf jedem neuen Linux-Server mit Docker-Runtime einfach laufen. Docker unterstützt Containernetze, wodurch Container miteinander kommunizieren können. Alle Dienste, die einen Reverse Proxy brauchten, waren mit dem nginx-Docker-Netzwerk verbunden. Auch wenn Ansible langsam war und die schwach typisierte Natur von YAML Bugs verursachte, funktionierte das Container-Management wunderbar.
Dank der Portabilität fügte ich weitere Deployments hinzu:
- Einen Datenbank-Server für meine Anwendungen
- Einen Monitoring-Stack mit Prometheus, Grafana etc.
- MQTT-Broker für meine Datenplattform
- Eine Docker-Registry für meine eigenen Container
An diesem Punkt war ich komplett begeistert. Ich mountete alle Verzeichnisse mit persistenten Daten in ein einziges Directory, sodass ich alles in einem einzigen Backup-Job sichern konnte. Wenn ich den Server migrieren wollte, musste ich nur noch die persistenten Daten und die Docker-Config kopieren.
Halbwegs zu Kubernetes
In den folgenden Monaten entwickelte ich einen ASP.NET-Service, der über die Docker-API laufende Container auf neue Versionen aktualisieren konnte. Diesen Service nutzte ich in der CI-Pipeline, um automatisch frisch gebaute Images zu deployen.
Der ASP.NET-Deploy-Service enthielt ein einfaches Berechtigungs-Management, um Pipelines auf bestimmte Container zu beschränken. Später fügte ich die Möglichkeit hinzu, Deployments deklarativ in einer JSON-Datei zu beschreiben. Außerdem musste ich bei jedem neuen Deployment (meist neue Services meiner Datenplattform) die Reverse-Proxy-Config anpassen.
Es wurde mir schon früh klar: Ich baue mir hier eigentlich Kubernetes nach. Warum bin ich also nicht direkt auf Kubernetes umgestiegen?
Die Antwort ist simpel: Einen Kubernetes-Cluster aufzusetzen ist nicht schwer, ihn aktuell zu halten aber schon!
Im Gegensatz zu Docker-Updates per apt
gibt es regelmäßige manuelle Aufgaben – besonders wenn etwas bricht.
Alles änderte sich, als ich K3s entdeckte. K3s ist eine Kubernetes-Distribution, die alles in einem einzigen Binary mitliefert. Das Update ist so einfach wie Binary austauschen und Service neu starten (lässt sich automatisieren). Ein echtes No-Brainer! Fast sofort begann ich mit der Migration von Docker zu K3s.
Container überall – orchestriert, gemanagt, resilient
Heute betreibe ich einen Homelab-Server mit vollständiger Festplattenverschlüsselung unter Linux und dem KVM-Hypervisor.
Ich habe 2 virtuelle Maschinen:
- Eine Router-VM für VPN, Routing und Firewalling aller Netzwerke (Server, Home, Devices, IoT, Gäste)
- Eine VM für meinen K3s-Single-Node-Cluster
So habe ich mehr Kontrolle über die Firewall. Kubernetes und auch Docker nutzen iptables für Portforwarding und Einschränkungen. Einige Regeln (v.a. Forward-Regeln) können aber eigene Regeln stören. Darum darf nur die Router-VM ins K3s-Netz. Damit habe ich volle Kontrolle über jeden Bit Traffic.
Kubernetes mit allem Drum und Dran (Ingress, Loadbalancer, Cert-Manager, Configs, Secrets...) macht Deployments und Monitoring super einfach. Deployments sind extrem portabel und reproduzierbar.
Bei RiKuWe haben wir eigene Tools, um Kubernetes-Deployments (und vieles mehr) noch automatisierter, sicherer und vorhersehbarer zu machen.
Container: Ein technischer Blick unter die Haube
Zum Abschluss werfen wir einen Blick hinter Dockers Maske. Wir exportieren ein Debian-Image aus Docker und mounten es manuell mit Linux-Standardtools. Jeder sollte das nachmachen können (ich nutze übrigens Arch).
Container-Image ziehen und exportieren
Wir verwenden die Docker-Runtime, um das Image zu ziehen und in eine Tar-Datei zu exportieren; das kann auch mit anderen Tools gemacht werden. Docker-Registries arbeiten über das HTTP-Protokoll.
docker pull debian:trixie
docker image save debian:trixie -o /tmp/debian.tar
ls /tmp/debian.tar
# ╭───┬─────────────────┬──────┬──────────┬───────────────╮
# │ # │ name │ type │ size │ modified │
# ├───┼─────────────────┼──────┼──────────┼───────────────┤
# │ 0 │ /tmp/debian.tar │ file │ 123.8 MB │ 2 minutes ago │
# ╰───┴─────────────────┴──────┴──────────┴───────────────╯
Entpacken und mounten
Wir entpacken das gespeicherte Container-Image in ein Verzeichnis auf unserer Maschine.
mkdir /tmp/image /tmp/container
tar xf /tmp/debian.tar --directory /tmp/image
ls /tmp/image
# ╭───┬───────────────┬──────┬───────┬──────────────╮
# │ # │ name │ type │ size │ modified │
# ├───┼───────────────┼──────┼───────┼──────────────┤
# │ 0 │ blobs │ dir │ 60 B │ 3 weeks ago │
# │ 1 │ index.json │ file │ 362 B │ 55 years ago │
# │ 2 │ manifest.json │ file │ 459 B │ 55 years ago │
# │ 3 │ oci-layout │ file │ 31 B │ 55 years ago │
# │ 4 │ repositories │ file │ 89 B │ 55 years ago │
# ╰───┴───────────────┴──────┴───────┴──────────────╯
Es gibt eine Datei namens manifest.json, die Informationen über die Debian-Layer und deren Mount-Reihenfolge enthält.
cat /tmp/image/manifest.json
# [
# {
# "Config": "blobs/sha256/047bd8d819400f5dab52d40aa2c424109b6627b9e4ec27c113a308e72aac0e7b",
# "RepoTags": [
# "debian:trixie"
# ],
# "Layers": [
# "blobs/sha256/cb9eb84282d037ad85b02cf671ef6ca766c43bc957f88b048d16dd6deb6e68b8"
# ],
# ...
# }
# ]
In der manifest.json gibt es nur einen Layer. Wir entpacken ihn in das Ziel-Root-Verzeichnis des Containers.
tar xf ./image/blobs/sha256/cb9eb84282d037ad85b02cf671ef6ca766c43bc957f88b048d16dd6deb6e68b8 --directory /tmp/container
ls /tmp/container
# ╭────┬───────┬─────────┬────────┬────────────────╮
# │ # │ name │ type │ size │ modified │
# ├────┼───────┼─────────┼────────┼────────────────┤
# │ 0 │ bin │ symlink │ 7 B │ 3 months ago │
# │ 1 │ boot │ dir │ 40 B │ 3 months ago │
# │ 2 │ dev │ dir │ 60 B │ 45 seconds ago │
# │ 3 │ etc │ dir │ 1.2 kB │ 3 weeks ago │
# │ 4 │ home │ dir │ 40 B │ 3 months ago │
# │ 5 │ lib │ symlink │ 7 B │ 3 months ago │
# │ 6 │ lib64 │ symlink │ 9 B │ 3 months ago │
# │ 7 │ media │ dir │ 40 B │ 3 weeks ago │
# │ 8 │ mnt │ dir │ 40 B │ 3 weeks ago │
# │ 9 │ opt │ dir │ 40 B │ 3 weeks ago │
# │ 10 │ proc │ dir │ 40 B │ 3 weeks ago │
# │ 11 │ root │ dir │ 100 B │ a minute ago │
# │ 12 │ run │ dir │ 60 B │ 3 weeks ago │
# │ 13 │ sbin │ symlink │ 8 B │ 3 months ago │
# │ 14 │ srv │ dir │ 40 B │ 3 weeks ago │
# │ 15 │ sys │ dir │ 40 B │ 3 months ago │
# │ 16 │ tmp │ dir │ 40 B │ 3 weeks ago │
# │ 17 │ usr │ dir │ 240 B │ 3 weeks ago │
# │ 18 │ var │ dir │ 260 B │ 3 weeks ago │
# ╰────┴───────┴─────────┴────────┴────────────────╯
Der extrahierte Layer scheint das Root-Dateisystem unseres Containers zu sein. Soweit so gut – nun muss unser Container einige Verzeichnisse des Hostsystems teilen. Wir verwenden mount, um sie in den Container einzubinden. Diese Verzeichnisse werden benötigt, um den Userspace-Anwendungen die notwendigen Ressourcen für den Zugriff auf die Hardware des Containers bereitzustellen.
In diesem Beispiel habe ich die kompletten Verzeichnisse eingebunden, sodass alles gemountet ist. In einem echten Container wird nur ein Teil dieser Verzeichnisse eingebunden. Um Netzwerkfunktionen im Container zu ermöglichen, erstellt Docker virtuelle Netzwerkgeräte, die in den Container eingebunden werden.
sudo mount --bind /proc "/tmp/container/proc"
sudo mount --bind /sys "/tmp/container/sys"
sudo mount --bind /dev "/tmp/container/dev"
sudo mount --bind /run "/tmp/container/run"
Jetzt ist es an der Zeit, eine Shell innerhalb unseres Docker-Containers zu starten und zu testen, ob er funktioniert.
sudo chroot /tmp/container
apt update && apt install screenfetch
screenfetch
Der Befehl chroot weist den Linux-Kernel an, /tmp/container als neues Root-Verzeichnis für die aktuelle Shell-Sitzung zu behandeln. Wir aktualisieren nur den Paketmanager und installieren screenfetch, ein Tool zum Anzeigen von Systeminformationen.
root@node01:~# screenfetch
_,met$$$$$gg. root@test.container.rikuwe.com
,g$$$$$$$$$$$$$$$P. OS: Debian
,g$$P"" """Y$$.". Kernel: x86_64 Linux 6.16.4-arch1-1
,$$P' `$$$. Uptime: 3h 26m
',$$P ,ggs. `$$b: Packages: 218
`d$$' ,$P"' . $$$ Shell: bash 5.2.37
$$P d$' , $$P Resolution: No X Server
$$: $$. - ,d$$' WM: Not Found
$$\; Y$b._ _,d$P' Disk: 0 / 32G (0%)
Y$$. `.`"Y$$$$P"' CPU: AMD Ryzen AI 7 350 w/ Radeon 860M @ 16x 5.09091GHz
`$$b "-.__ GPU:
`Y$$ RAM: 10667MiB / 64082MiB
`Y$$.
`$$b.
`Y$$b.
`"Y$b._
`""""
Die Tatsache, dass apt
funktioniert und screenfetch
glaubt, es sei auf einer Debian-Maschine ausgeführt worden, zeigt uns, dass unser Debian-Container funktionsfähig ist.
Ich hoffe, dir hat dieser kleine Exkurs in die Welt von Docker und Linux-Container-Images gefallen. Hab einen schönen Tag – und bleib neugierig.