Kai Ole Hartwig
8 Min. Lesezeit
Niedrig

Pod-Zertifikate statt geteilter Geheimnisse

Für alle, die in Kubernetes Dienste betreiben, die sich mit Passwörtern oder API-Keys ausweisen. Kubernetes 1.37 gibt jedem Pod ein eigenes Zertifikat. Den Signer dafür musst du selbst stellen. Ich habe einen gebaut und CrowdSec damit von geteilten Passwörtern auf mTLS umgestellt.

01 — Die Ausgangslage

Auf einer k3s-Plattform mit mehreren Mandanten läuft CrowdSec zentral. Eine LAPI entscheidet über Sperren. Agenten lesen Logs, Bouncer setzen die Sperren durch. Jeder Teilnehmer weist sich mit einem geteilten Geheimnis aus. Der Wert liegt beim Client und bei der LAPI, und beide Kopien müssen stimmen.

Dreimal stimmten sie nicht, und dreimal fiel es niemandem auf. Ein Bouncer bekam 403 und lief offen weiter. Ein Firewall-Bouncer beendete sich und nahm seine nftables-Regeln mit. Ein Agent meldete nichts, weil seine Maschine fehlte. Sorgfältigeres Synchronisieren hätte die Ursache nicht behoben. Die Ursache ist die zweite Kopie.

02 — Was Kubernetes 1.37 liefert

Pod Certificates sind mit 1.37 stabil (KEP-4317), ClusterTrustBundles ebenfalls (KEP-3257). Ein Pod fordert sein Zertifikat über ein Projected Volume an:

 

volumes:
  - name: lapi-tls
    projected:
      sources:
        - podCertificate:
            signerName: koh.ole-hartwig.eu/workload
            keyType: ECDSAP256
            keyPath: tls.key
            certificateChainPath: tls.crt
            userAnnotations:
              koh.ole-hartwig.eu/dns-names: crowdsec-loki.monitoring.svc,crowdsec-loki.monitoring.svc.cluster.local
        - clusterTrustBundle:
            signerName: koh.ole-hartwig.eu/workload
            labelSelector: {}
            path: ca.crt

 

Der kubelet erzeugt den Schlüssel selbst. Er legt einen PodCertificateRequest mit dem öffentlichen Schlüssel an und schreibt Zertifikat und Schlüssel in das Volume. Kein Secret enthält den Schlüssel je. Der Pod startet erst, wenn das Zertifikat da ist. Nach etwa zwei Dritteln der Laufzeit holt der kubelet ein neues und tauscht die Dateien aus.

Das ClusterTrustBundle verteilt die CA an jeden Pod, der dem Signer vertrauen soll. Wer das Zertifikat ausstellt, lässt Kubernetes offen. Das ist die Aufgabe des Signers.

03 — Der Signer

cert-manager kann Pod-Zertifikate noch nicht ausstellen (cert-manager#8378). Also habe ich einen eigenen Signer geschrieben, gut 1.500 Zeilen Go ohne Tests. Er beobachtet die Requests für seinen Signer-Namen, prüft sie gegen eine Policy und signiert mit einem Schlüssel in AWS KMS.

Die Policy ist eine Datei. Was dort nicht steht, wird abgelehnt:

 

{
  "signerName": "koh.ole-hartwig.eu/workload",
  "trustDomain": "koh.ole-hartwig.eu",
  "dnsNamesAnnotation": "koh.ole-hartwig.eu/dns-names",
  "lifetime": "24h",
  "refreshAt": 0.66,
  "keyTypes": ["ECDSAP256", "ED25519"],
  "grants": [
    {"namespace": "monitoring", "serviceAccount": "crowdsec-loki", "usage": "server",
     "dnsNames": ["crowdsec-loki.monitoring.svc", "crowdsec-loki.monitoring.svc.cluster.local"]},
    {"namespace": "kube-system", "serviceAccount": "traefik", "usage": "client", "ou": "crowdsec-agent"}
  ]
}

 

Die Identität hängt am ServiceAccount, nicht am Pod. Der CN lautet namespace/serviceaccount, dazu kommt eine SPIFFE-URI. Die OU sagt der Gegenseite, welche Rolle der Client hat. DNS-Namen wünscht sich der Pod per Annotation. Der Signer stellt nur Namen aus, die der Grant erlaubt.

Der CA-Schlüssel ist ein KMS-Schlüssel vom Typ ECC_NIST_P256. Er verlässt KMS nie. Die IAM-Policy erlaubt genau eine Operation:

 

"Action": "kms:Sign",
"Condition": {
  "StringEquals": {
    "kms:SigningAlgorithm": "ECDSA_SHA_256",
    "kms:MessageType": "DIGEST"
  }
}

 

Die CA selbst erzeugt ein Unterbefehl einmal von Hand. Sie trägt kritische Name Constraints: nur svc, svc.cluster.local und die URI-Domain. Ein Zertifikat für eine öffentliche Domain würde auch ein fehlerhafter Signer nicht gültig ausstellen können. Der Signer läuft mit zwei Replikas. Der Status-Write ist bedingt, deshalb beantwortet jeder Request genau eine Replika. Ein Alarm feuert, sobald ein Request länger als fünf Minuten wartet.

04 — Die Regeln des API-Servers

Bis zum ersten Zertifikat brauchte es drei Releases. Die ersten beiden stellten nichts aus. Der API-Server lehnte jeden Status-Write ab. Mein Code loggte die Ablehnung nicht. Die Regeln stehen in der Validierung des API-Servers, nicht in der Dokumentation:

Danach kam das erste Zertifikat 28 Millisekunden nach dem Start. Der Fake-API-Server in meinen Tests erzwingt heute jede dieser Regeln. Für jede gibt es eine Mutation, die den alten Fehler zurückholt und den Test rot werden lässt.

05 — CrowdSec auf mTLS

Die LAPI bekommt ihr Server-Zertifikat aus dem Volume oben. CrowdSecs Startskript kennt die nötigen Variablen:

 

USE_TLS=true
LAPI_CERT_FILE=/run/lapi-tls/tls.crt
LAPI_KEY_FILE=/run/lapi-tls/tls.key
CACERT_FILE=/run/lapi-tls/ca.crt
AGENTS_ALLOWED_OU=crowdsec-agent

 

Die Agenten bekommen ein Client-Zertifikat und statt AGENT_USERNAME und AGENT_PASSWORD nur noch CLIENT_CERT_FILE und CLIENT_KEY_FILE. CrowdSec benennt die Maschine nach dem Zertifikat: kube-system/traefik@10.42.0.30. Der Mandant steht damit in jedem Alert.

Die LAPI hat einen einzigen Listener. Also musste ich vorher messen, wie sich die Clients beim Umschalten verhalten. Ein laufender Client überlebt, wenn die Gegenseite auf TLS wechselt. Er meldet einen Fehler pro Anfrage und macht weiter. Ein Client, der mit dem falschen Protokoll startet, beendet sich. Die Umstellung lief deshalb in zwei Schritten: zuerst die LAPI, danach alle Clients gegen einen Listener, der schon TLS sprach.

Die zweite Messung war wichtiger. CrowdSec liest sein Zertifikat genau einmal, beim Start. Ohne Gegenmittel hätte die LAPI nach 24 Stunden ein abgelaufenes Zertifikat ausgeliefert. SIGHUP lädt Server und Agent im laufenden Prozess neu, gemessen in etwa zwei Sekunden. Der Einstiegspunkt meines Images schickt das Signal, sobald sich die Dateien ändern:

 

abdruck() { cat $tls_dateien | cksum; }
alt=$(abdruck)
while sleep 60; do
  neu=$(abdruck)
  [ "$neu" = "$alt" ] && continue
  [ "$(cat /proc/1/comm)" = crowdsec ] || continue
  kill -HUP 1 && alt=$neu
done

 

Verglichen wird der Inhalt, nicht der Zeitstempel. Der kubelet tauscht das Volume über einen Symlink aus. Das Signal geht nur an einen Prozess namens crowdsec. Unter tini oder einer Shell als PID 1 würde SIGHUP den Container beenden.

06 — Was es gekostet hat

Ein Knoten lief fünf Minuten ohne Paketfilter. Beim ersten Schritt verschluckte ein {{- if }} im Helm-Template eine Leerzeile in der ConfigMap des Firewall-Bouncers. Den Wert, den ich ändern wollte, hatte ich geprüft. Die Datei war trotzdem anders. Reloader startete den Bouncer neu, gegen eine LAPI, mit der er noch nicht sprechen konnte. Der CI-Job für Render-Diffs verglich nur die Mandanten, nicht die Plattform. Heute vergleicht er jedes Objekt und nennt bei jeder geänderten ConfigMap das Workload, das neu startet.

Zwei Befunde waren älter als der Umbau. Die TYPO3-Anbindung hatte die LAPI nie erreicht, weil eine Netzwerkregel nur den Proxy durchließ. Und der Proxy lief unter dem Standard-ServiceAccount seines Namensraums. Ein Zertifikat für diesen Account hätte jedem Pod dort eine Identität gegeben, mit der man Sperren auslösen kann. Er hat jetzt einen eigenen.

07 — Was bleibt

Alle sieben Agenten melden sich mit Zertifikat an. Sechs Passwörter, drei ExternalSecrets und die Schleife, die sie registriert hat, sind gelöscht.

Die Bouncer behalten ihre API-Keys, jetzt über TLS. Der Firewall-Bouncer liest ein Zertifikat einmal und kennt kein SIGHUP. Ein Neustart alle 16 Stunden würde jedes Mal seine Regeln abräumen. Den Fix dafür habe ich upstream eingereicht: go-cs-bouncer#62 lädt das Client-Zertifikat neu und schließt dabei offene Verbindungen. crowdsec#4709 macht dasselbe für die LAPI. Sind beide veröffentlicht, fällt der Signal-Umweg im Image weg.

Die Mandanten-internen Zertifikate bleiben bei cert-manager. Eine eigene CA je Mandant ist dort die Isolation. Mit einer gemeinsamen CA müsste jede Verbindung die Gegenseite einzeln festnageln. Pod-Zertifikate nutze ich für Identitäten, die Namensräume überqueren.

Den Signer gibt es als Open Source unter Apache-2.0: github.com/ohartwig/pod-cert-signer, mit signierten Releases und einem Image auf ghcr.io. Im Repository stehen die Policy-Referenz und Beispiel-Manifeste, dazu die Entscheidungen hinter dem Design. Was er kann und für wen er passt, steht auf der Projektseite.

Häufige Fragen

Warum nicht cert-manager?+

cert-manager kann Pod-Zertifikate noch nicht ausstellen. Das Ticket ist seit Januar 2026 offen. Sobald es das kann, lohnt sich ein Blick darauf. Eine CA je Namensraum hätte dann beide Vorteile: Schlüssel außerhalb der API und Isolation über die Vertrauensbasis.

Braucht der Signer AWS?+

In meiner Umsetzung ja: der CA-Schlüssel liegt in AWS KMS und wird über IRSA erreicht. Die Policy, die Prüfung der Requests und das Zertifikatsprofil hängen nicht daran. Ein anderes Schlüssel-Backend braucht nur eine Implementierung von crypto.Signer.

Was passiert, wenn der Signer ausfällt?+

Laufende Pods merken nichts, ihre Zertifikate gelten 24 Stunden. Neue Pods mit einem Pod-Zertifikat starten nicht, bis der Signer antwortet. Deshalb laufen zwei Replikas, und ein Alarm feuert nach fünf Minuten Wartezeit.

Fazit

Pod-Zertifikate lohnen sich dort, wo ein geteiltes Geheimnis heute an zwei Stellen gepflegt wird und Namensräume überquert. Der aufwendige Teil ist nicht der Signer. Aufwendig ist die Frage, was die Software mit einem erneuerten Zertifikat tatsächlich macht. Die beantwortet nur eine Messung.

Geteilte Geheimnisse in Ihrem Cluster? Ich finde die Stellen.

Eine Durchsicht Ihrer Dienste: wo Passwörter und Keys doppelt gepflegt werden, welche Verbindungen sich für Pod-Zertifikate eignen und was die Software beim Erneuern tut.

Termin buchen →

Über den Autor

Foto von Kai Ole Hartwig.

Kai Ole Hartwig

Freiberuflicher DevSecOps-Berater · OnlyOle Consulting

Programmiert seit 2002 – autodidaktisch gelernt, 2012 mit KO-Web selbständig gemacht. Über 100 Projekte, Fokus auf Security, Performance, Automatisierung und Qualität. Heute freiberuflich: DevSecOps-Beratung, Schulungen und Softwareentwicklung.