Ein gehärteter Cluster ist einer, der nie ungehärtet war
01.09.2026
Thema: alle Beiträge zu Kubernetes
Vier Zeilen k3s-Config, die vor der Installation auf der Platte liegen: secrets-encryption, Pod Security Standards, tls-san. Warum Härten vor dem ersten Start das Zeitfenster schließt, das Nachhärten offen lässt.
Inhaltsverzeichnis
- Härten, bevor der Cluster existiert
- Warum der Schnellstart-Einzeiler zwei Probleme hat
- Die vier Zeilen liegen auf der Platte, bevor k3s startet
- secrets-encryption: base64 ist keine Verschlüsselung
- Pod Security Standards: privilegiert kommt nicht drauf
- Der Zombie-Pod: warum nachträglich nicht reicht
- Die API, die es von außen nicht gibt
- Der Wegwerf-Beweis
- Fazit
Härten, bevor der Cluster existiert
Ein gehärteter Cluster ist einer, der nie ungehärtet war. Das klingt nach Wortspiel, ist aber eine handfeste Betriebsentscheidung: Die Härtung liegt als Code auf der Platte, bevor k3s installiert wird, nicht als nachträglicher Handgriff auf einem laufenden Cluster. Der Unterschied entscheidet, ob es je ein Zeitfenster gab, in dem der Cluster offen lag.
Das ist Teil 2 der Wegwerf-Cluster-Serie. In Teil 1 ist ein Hetzner-Server entstanden, der von außen keinen einzigen offenen TCP-Port hat: WireGuard als einzige Tür, provisioniert mit OpenTofu und cloud-init. Auf genau diesen Server zieht jetzt Kubernetes ein, als k3s, und zwar von der ersten Sekunde an gehärtet.
Der ganze Trick sind vier Zeilen Konfiguration und die Reihenfolge, in der sie auf die Platte kommen. Der Rest dieses Beitrags zeigt, was jede Zeile tut, wie du es nachprüfst, und warum dieselbe Härtung nachträglich eingeschaltet ein Loch lässt, das der Weg über cloud-init gar nicht erst aufmacht.
Warum der Schnellstart-Einzeiler zwei Probleme hat
Der übliche Weg zu k3s ist ein Einzeiler, so wie ihn jedes Tutorial zeigt:
curl -sfL https://get.k3s.io | sh -
Nach ein paar Sekunden läuft ein Cluster. Er hat nur zwei Eigenschaften, die man beim Schnellstart nicht sieht.
Erstens: k3s ist zertifiziertes Kubernetes, dieselbe API und dasselbe kubectl wie beim großen Standard-Kubernetes. Was sich unterscheidet, ist der Unterbau. Statt eines verteilten etcd benutzt k3s eine SQLite-Datenbank, eine einzelne Datei auf der Platte, mit der eingebetteten Komponente kine als Übersetzungsschicht dazwischen. Der komplette Cluster-Zustand, inklusive aller Secrets, ist damit eine Datei. Was das im Angriffsfall bedeutet, steht in Ein Pod, und der Server gehört ihm: ein privilegierter Pod, ein strings auf diese Datei, und die Secrets stehen im Klartext da. Und bevor jemand einwendet, dann nimm doch das große Kubernetes: In etcd liegen die Secrets ohne eigene Konfiguration genauso unverschlüsselt, nur hinter etcdctl statt hinter strings.
Zweitens: Der Einzeiler installiert, was gerade aktuell ist. Zwei Installationen an zwei Tagen ergeben zwei verschiedene Cluster. Für einen Cluster, den ich jederzeit wegwerfen und identisch neu bauen will, ist das unbrauchbar.
Beide Probleme löst dieselbe Idee: k3s nicht von Hand starten, sondern aus einer Konfigurationsdatei, die vor der Installation feststeht.
Die vier Zeilen liegen auf der Platte, bevor k3s startet
k3s liest beim Start die Datei /etc/rancher/k3s/config.yaml. Und cloud-init kann Dateien auf die Platte legen, bevor irgendetwas installiert wird. Aus diesen zwei Tatsachen entsteht die ganze Härtung. Der write_files-Block im cloud-init legt die Config an, der runcmd-Block installiert danach:
write_files:
- path: /etc/rancher/k3s/config.yaml
content: |
secrets-encryption: true
tls-san:
- 10.101.0.1
disable:
- traefik
kube-apiserver-arg:
- admission-control-config-file=/etc/rancher/k3s/psa.yaml
runcmd:
- curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION="v1.36.3+k3s1" sh -s -
Erst legt cloud-init die Härtung auf die Platte, dazu die im letzten Argument referenzierte Admission-Datei, die festlegt, welche Pods der Cluster zulässt. Dann, und erst dann, läuft die Installation, mit gepinnter Version. Ein Upgrade ist ab hier eine Code-Änderung mit Commit-Historie, kein Zufall des Installationstags.
Das Entscheidende ist die Reihenfolge: Es gibt keine Sekunde, in der auf diesem Server ein ungehärtetes Kubernetes läuft. Kein Zeitfenster, kein Nachhärten, kein Runbook. Und der ungehärtete Cluster, mit dem man vielleicht angefangen hat? Der wird nicht nachgehärtet, sondern weggeworfen und neu gebaut, seit Teil 1 ein einziges Kommando.
secrets-encryption: base64 ist keine Verschlüsselung
Die erste Zeile, secrets-encryption: true, sorgt dafür, dass k3s jedes Secret verschlüsselt, bevor es die Datenbank erreicht. Kubernetes-Secrets sind ansonsten nur base64-kodiert, und base64 ist eine Umschrift, keine Verschlüsselung: Jeder liest sie in beide Richtungen. Die Probe aufs Exempel, dasselbe Secret auf dem gehärteten Cluster:
kubectl create secret generic db-password \
--from-literal=password=sesam-oeffne-dich-4711
Und dieselbe Suche in der Datenbank-Datei, die auf dem ungehärteten Cluster noch den Klartext lieferte:
sudo strings /var/lib/rancher/k3s/server/db/state.db | grep -c sesam
0
Null Treffer. Das Secret ist da, kubectl liest es, die Anwendung liest es, aber in der Datei auf der Platte steht es nur noch verschlüsselt. Der Cluster bestätigt das auch selbst:
sudo k3s secrets-encrypt status
Encryption Status: Enabled
Ein Satz zur Ehrlichkeit, damit diese Zeile nicht überschätzt wird: Der Schlüssel, mit dem k3s verschlüsselt, liegt auf demselben Server, in einer Datei neben der Datenbank. Wer als root auf der laufenden Maschine steht, kommt weiter an alles. Was secrets-encryption wirklich schützt, sind die Wege, auf denen die Datenbank den Server verlässt: das Backup, der Snapshot, das kopierte Volume, die ausgemusterte Platte. Genau die Sorte Weg, die man nicht auf dem Schirm hat. Für den Angreifer auf der Maschine selbst gilt weiter Teil 1: Er soll gar nicht erst draufkommen, die Tür ist WireGuard.
Und noch ein Grund, warum diese Zeile von Anfang an in der Config steht: Nachträglich einschalten geht zwar, aber es verschlüsselt erst mal nur, was danach geschrieben wird. Die Bestandssecrets musst du eigens umschreiben lassen, und bis dahin lag alles offen. Von Anfang an gesetzt, gab es dieses Fenster nie.
Pod Security Standards: privilegiert kommt nicht drauf
Die zweite Baustelle eines frischen Kubernetes, egal ob groß oder klein: Es lässt per Default jeden Pod zu, auch einen privilegierten, der auf dem Host praktisch alles darf. Was ein solcher Pod anrichtet, zeigt der Beitrag zum Container-Ausbruch im Detail. Die Admission-Datei aus dem cloud-init schaltet die Pod Security Standards cluster-weit scharf:
defaults:
enforce: baseline
warn: restricted
audit: restricted
exemptions:
namespaces:
- kube-system
Baseline wird durchgesetzt: keine privilegierten Container, kein Host-Netzwerk, keine Host-Pfade. Restricted, die strengste Stufe, wird gewarnt und protokolliert, blockiert aber nicht. Und kube-system ist ausgenommen, dort brauchen Systemkomponenten mehr Rechte. Die Probe ist ein bewusst privilegierter Pod:
kubectl run psa-test --image=busybox --restart=Never \
--overrides='{"spec":{"containers":[{"name":"psa-test","image":"busybox","securityContext":{"privileged":true}}]}}'
Error from server (Forbidden): pods "psa-test" is forbidden:
violates PodSecurity "baseline:latest": privileged
Abgelehnt, bevor der Pod überhaupt existiert. Ein privilegierter Container kommt auf diesen Cluster nicht drauf, auch nicht aus Versehen, auch nicht durch ein kopiertes Manifest aus irgendeinem Tutorial. Und weil die Datei vor der Installation auf der Platte lag, galt diese Regel ab dem allerersten Pod.
Eine Einschränkung gehört dazu, damit man sich nicht zu früh sicher fühlt: Die Admission-Datei setzt nur den Standard für den ganzen Cluster. Jeder einzelne Namespace kann ihn mit einem eigenen Label überschreiben, bis hoch zu privileged. Deshalb gehört ein zweiter Schutz dazu: Ein normaler Benutzer darf seine Namespaces nicht selbst beschriften dürfen. Wer nur Pods ausrollen soll, darf nicht zugleich die Sicherheitsstufe seines Namespace hochsetzen. Das regelt RBAC, die Rechteverwaltung von Kubernetes, und die schreibst du genauso als Code wie die vier Config-Zeilen.
Der Zombie-Pod: warum nachträglich nicht reicht
Hier steckt der eigentliche Grund für die These vom nie ungehärteten Cluster. Pod Security Standards lassen sich jederzeit nachträglich einschalten, mit einem einzigen Label und ohne Neustart. Nur schützt das rückwirkend nichts. Der Versuch: ein Namespace, per Label unter den Cluster-Default zurückgesetzt, mit einem privilegierten Pod darin, der auf dem ungehärteten Stand gestartet ist.
kubectl label --overwrite ns nachtraeglich \
pod-security.kubernetes.io/enforce=baseline
Ab sofort ist der Namespace scharf. Und der privilegierte Pod, der vorher gestartet ist?
kubectl get pod -n nachtraeglich
NAME READY STATUS RESTARTS AGE
altlast 1/1 Running 0 4m
Läuft weiter. Pod Security Standards sind eine Zugangskontrolle, sie prüfen beim Erzeugen eines Pods. Was schon läuft, prüft niemand nach. Der Cluster meldet also Vollzug, und gleichzeitig läuft ein privilegierter Container darauf. Auffliegen tut das erst, wenn der Pod aus irgendeinem Grund neu gebaut wird, beim nächsten Node-Neustart, beim nächsten Rollout, oder wenn der Admin die Warnungen liest:
kubectl delete pod altlast -n nachtraeglich
kubectl run altlast -n nachtraeglich --image=busybox --restart=Never \
--overrides='{"spec":{"containers":[{"name":"altlast","image":"busybox","securityContext":{"privileged":true},"command":["sleep","3600"]}]}}'
Error from server (Forbidden): pods "altlast" is forbidden:
violates PodSecurity "baseline:latest": privileged
Jetzt greift die Regel. Zwischen dem Label und diesem Moment können Wochen liegen, und in diesen Wochen sah der Cluster gehärtet aus, ohne es zu sein. Wer nachträglich härtet, weiß nicht, was noch aus der Zeit davor läuft. Wer vor dem ersten Start härtet, muss es nicht wissen.
Die API, die es von außen nicht gibt
Bleibt die größte Tür, die eine Kubernetes-Installation normalerweise aufreißt: die API auf Port 6443. Wer die erreicht, redet mit dem Cluster. Auf diesem Server gilt für 6443 dasselbe wie für SSH in Teil 1. Von außen, gegen die öffentliche IP:
nc -z -v -w3 203.0.113.10 6443
Connection timed out
Timeout, nicht refused. Die Firewall aus Teil 1 ist unverändert, zwei Regeln, Ping und WireGuard. Für 6443 ist nichts geöffnet, also existiert der Port von außen nicht. Der Unterschied zwischen Timeout und refused ist dabei kein Zufall, sondern Beweis, dass die Firewall wirklich dranhängt, nachzulesen in Connection refused oder Timeout. Durch den Tunnel dagegen:
kubectl get nodes
NAME STATUS ROLES VERSION
week-0007-lab Ready control-plane,master v1.36.3+k3s1
Der Cluster antwortet, auf der Tunnel-Adresse, mit der gepinnten Version. Dafür sorgt die dritte der vier Config-Zeilen, tls-san: Sie trägt die Tunnel-Adresse ins API-Zertifikat ein. Ohne den Eintrag würde kubectl an der Zertifikatsprüfung abbrechen, denn das Zertifikat wüsste nichts von der 10.101.0.1. Die API bindet zwar weiter auf allen Interfaces, aber der einzige Weg zu ihr führt durch WireGuard.
Die vierte Zeile, disable: traefik, ist dieselbe Logik: Der Cluster ist leer, es gibt nichts auszuliefern, also läuft auch kein Ingress-Controller, und 80 und 443 bleiben zu. Nur öffnen, was läuft, dieselbe Regel wie bei der Firewall in Teil 1.
Der Wegwerf-Beweis
Der Test, dem dieser Aufbau seinen Namen verdankt: Auf dem Cluster liegt ein Secret, die Härtung ist aktiv, alles läuft. Ich werfe ihn weg und baue ihn neu.
tofu destroy -target=hcloud_server.lab
tofu apply
Server weg, Cluster weg, alle Zertifikate weg. OpenTofu baut den Server neu, dieselbe IP, dieselbe Firewall, und cloud-init installiert dasselbe gehärtete k3s in derselben gepinnten Version. Zwei Dinge sind danach frisch, und beide gehören zum Alltag. Die SSH-Host-Keys, das kennt man aus Teil 1: ssh-keygen -R und den neuen Fingerprint einmal bewusst bestätigen. Neu in Teil 2: Auch die Cluster-Zertifikate sind frisch, die alte kubeconfig ist damit ungültig und wird einmal neu vom Server geholt.
ssh-keygen -R 10.101.0.1
scp root@10.101.0.1:/etc/rancher/k3s/k3s.yaml ~/.kube/config-lab
sed -i 's/127.0.0.1/10.101.0.1/' ~/.kube/config-lab
export KUBECONFIG=~/.kube/config-lab
kubectl get nodes
Zwei Handgriffe lohnen die Erklärung. Die kubeconfig lege ich bewusst neben meine normale Konfiguration, als config-lab, damit der Testserver meine echte kubeconfig nie anfasst, und KUBECONFIG verweist darauf. Und das sed dazwischen: In der Datei steht als Server noch 127.0.0.1, denn k3s schreibt sie für den Blick von der Maschine selbst; das sed tauscht die Adresse gegen die Tunnel-Adresse. Dass das Zertifikat diese Adresse akzeptiert, ist kein Zufall, genau dafür steht sie seit dem cloud-init im tls-san.
Gleicher Name, gleiche Version, gleiche Härtung. Nicht ungefähr, sondern weil jede dieser Eigenschaften in einer Datei steht, die in Git liegt. Der Cluster von vorhin ist tot, und es vermisst ihn niemand. Das ist der Unterschied zwischen einem Cluster, den du besitzt, und einem Klick-Zustand, den keiner mehr reproduzieren kann.
Fazit
- Die Härtung gehört auf die Platte, bevor k3s installiert wird. cloud-init schreibt
/etc/rancher/k3s/config.yaml, dann erst läuft die Installation. Es gibt keine ungehärtete Sekunde. secrets-encryption: trueverschlüsselt Secrets, bevor sie in die SQLite-Datenbank gehen. Es schützt die Wege, auf denen die Datenbank den Server verlässt, nicht gegen root auf der laufenden Maschine.- Eine Admission-Datei setzt Pod Security Standards ab dem ersten Pod durch. Ein privilegierter Pod wird abgelehnt, bevor er entsteht. RBAC muss verhindern, dass Benutzer ihre Namespaces selbst umlabeln.
- Nachträglich härten lässt ein Loch:
secrets-encryptionverschlüsselt nur Neues, und ein bereits laufender privilegierter Pod läuft weiter, weil die Zugangskontrolle nur beim Erzeugen prüft. tls-sanmacht die API nur durch den WireGuard-Tunnel erreichbar,disable: traefikhält 80 und 443 zu, solange nichts ausliefert. Nur öffnen, was läuft.- Der Wegwerf-Beweis: destroy, apply, und derselbe gehärtete Cluster steht wieder, weil alles in Git liegt.
Das ist die Zwei-Säulen-Idee an der Laufzeit-Schicht der Plattform. Erste Säule, Verständnis: Du weißt, was jede der vier Zeilen tut und wie du es nachprüfst. Zweite Säule, offene Lizenzen: k3s gehört der CNCF, nicht einem Hersteller, und die komplette Härtung sind offene Bordmittel, kein Enterprise-Feature. Auf diesem Cluster entsteht als Nächstes GitOps und Secrets-Management, und im nächsten Teil der Serie wird das komplette Projekt ein öffentliches Repository, Apache-2.0-lizenziert, der Server aus Teil 1 und der Cluster von heute als Code zum Mitnehmen.
Das Video zu diesem Beitrag zeigt jede Messung im Terminal, samt dem Live-Wegwurf des Clusters. Den k3s-Härtungs-Spickzettel mit den vier Config-Zeilen, der Admission-Datei und den Prüf-Kommandos gibt es über den Newsletter.
© 2026 Trutz Software Consulting GmbH. CC BY 4.0: Dieser Text darf mit Namensnennung weiterverwendet und bearbeitet werden, auch kommerziell. Code-Beispiele darin stehen unter Apache 2.0. Ausgenommen sind Marke und Logo sowie gekennzeichnete Zitate Dritter. Was das genau heißt.
Newsletter
Erhalte praxisnahe Tutorials, Best Practices und echte Lösungen für die häufigsten DevOps-Probleme direkt in Dein Postfach.
Datenschutz: Deine E-Mail-Adresse wird DSGVO-konform in Deutschland gespeichert. Keine Spam, nur relevante Inhalte. Eine E-Mail alle 1 bis 2 Wochen.