Überblick: Warum ein Secret-Hub?
GitOps will den gesamten Cluster-Zustand in Git – Secrets sind die Ausnahme. Passwörter, API-Keys und Zertifikate gehören in kein Repository, auch in kein privates: Git-Historie ist unlöschbar, Repos werden geklont, CI-Runner bekommen Zugriff.
Die üblichen Auswege im Vergleich
| Ansatz | Prinzip | Nachteil |
|---|---|---|
| Sealed Secrets | verschlüsselte Secrets in Git | Rotation aufwendig, Cluster-gebundener Schlüssel |
| SOPS / age | verschlüsselte Files in Git | Key-Verteilung, Entschlüsselung im CI |
| HashiCorp Vault | externer Secret-Manager | Betriebsaufwand, Lizenzkosten (Enterprise) |
| Cloud Secret Manager | AWS/GCP/Azure-Backend | Cloud-Bindung, air-gapped unmöglich |
| ESO + Namespace-Hub | zentraler K8s-Namespace als Quelle | kein externes System nötig |
Die Idee: Der ESO Kubernetes-Provider kann einen anderen Namespace im selben Cluster als Secret-Backend nutzen. Ein dedizierter Namespace secret-hub wird zum zentralen Tresor – Admins legen dort die echten Werte ab, App-Namespaces bekommen sie per ESO synchronisiert, in Git liegen nur ExternalSecret-Referenzen.
Was das Pattern kann
- Kein externes System – kein Vault, kein Cloud-Dienst, keine Lizenzkosten
- Air-gapped-tauglich – alles bleibt im Cluster
- Vertraute Primitive – Secret, ServiceAccount, RBAC
- GitOps-tauglich – nur Referenzen im Repo
- Migrationspfad – Backend später gegen Vault tauschbar, ohne die
ExternalSecret-Manifeste anzufassen
Was es nicht ist: ein Vault-Ersatz
- keine dynamischen Secrets (kurzlebige DB-Credentials on demand)
- kein Lease-Management, keine automatische Revocation
- keine automatische Rotation der Nutzdaten
- kein detailliertes Audit-Logging (nur K8s-API-Ebene)
- keine Transit Encryption, keine PKI-Engine
Für statische Secrets in einem klar abgegrenzten Cluster trotzdem eine tragfähige, wartungsarme Lösung.
Verfügbarkeit und Support
| Plattform | Installation | Support |
|---|---|---|
| OpenShift 4.20+ | GA im Red Hat Operator Catalog, per OLM (Subscription) | Red Hat |
| Vanilla K8s / KKP | Helm-Chart external-secrets oder deklarativ via ArgoCD | Community |
secret-hub trifft alle abhängigen Namespaces auf einmal.Architektur: Komponenten und Datenfluss
Drei Namespaces spielen zusammen: der ESO Operator beobachtet die CRDs, der secret-hub hält die Quelle, der App-Namespace bekommt die Kopie. Die Anwendung selbst merkt von alldem nichts – sie sieht nur ein normales Kubernetes-Secret.
Der Weg eines Secrets zur Laufzeit
- ExternalSecret lesenESO ermittelt den referenzierten
SecretStore. - Token holenESO liest das Bearer Token aus dem Secret
eso-reader-tokenim App-Namespace. - AuthentifizierenGegen die Kubernetes API als ServiceAccount
eso-reader. - Quelle lesenDas Quell-Secret aus dem
secret-hubabrufen. - Ziel schreibenNatives K8s-Secret im App-Namespace erzeugen oder aktualisieren.
- WiederholenNach Ablauf des
refreshIntervalbeginnt der Zyklus von vorn.
refreshInterval.Die Komponenten im Überblick
| Komponente | Ort | Aufgabe |
|---|---|---|
| ESO Operator | external-secrets | beobachtet clusterweit SecretStore, ClusterSecretStore, ExternalSecret, PushSecret; schreibt pro Reconcile native Secrets |
Secret db-credentials | secret-hub | Single Source of Truth, Typ Opaque |
ServiceAccount eso-reader | secret-hub | Lese-Identität; Rechte entstehen erst durchs RoleBinding |
| Role + RoleBinding | secret-hub | minimale Leserechte, namespace-gebunden |
Secret eso-reader-token | App-Namespace | Bearer Token unter Key token – das „Secret Zero“ |
| SecretStore | App-Namespace | Backend, Remote-Namespace, CA, Auth-Methode |
| ExternalSecret | App-Namespace | Referenz: „Hole X aus dem Store, erzeuge Secret Y“ |
| Pod | App-Namespace | konsumiert per secretKeyRef-Env oder Volume-Mount |
Der Namespace-Name external-secrets ist reine Helm-Konvention – der Operator kann überall laufen, die CRDs sind davon unabhängig.
Bearer Token: Besitz genügt
Das Token ist ein zeitlich begrenztes, signiertes JWT für eso-reader. „Bearer“ heißt Inhaber (RFC 6750): Wer das Token besitzt, darf es benutzen – ohne weiteren Identitätsnachweis. Das Token ist damit selbst ein hochsensibles Credential.
Architekturbild als ASCII-Skizze
┌────────────────────────┐
│ ESO Operator │
│ ns: external-secrets │
└───────┬────────┬───────┘
liest │ │ schreibt
┌───────────────┘ └───────────────┐
▼ ▼
┌────────────────────────┐ ┌──────────────────────────┐
│ ns: secret-hub │ │ ns: app-1 │
│ Zentraler Tresor │ │ │
│ Secret db-credentials │ │ Secret eso-reader-token │
│ SA eso-reader │ │ ▼ │
│ Role + RoleBinding │ │ SecretStore │
│ Bearer Token ─────────┼─manuell┼──▶ ▼ │
└────────────────────────┘ │ ExternalSecret │
│ ▼ │
│ Secret db-credentials │
│ ▼ │
│ Pod (env / volume) │
└──────────────────────────┘
Secret-Hub einrichten
Ein gewöhnlicher Namespace, der ausschließlich als Secret-Quelle dient: kein Workload, Schreibrechte nur für Admins oder CI/CD, App-ServiceAccounts haben keinerlei Zugriff. Optional per NetworkPolicies zusätzlich isoliert.
Fünf Ressourcen, ein Tresor
- Namespace
secret-hubMit Labelpurpose: secret-hub– reine Kennzeichnung. - Secret
db-credentialsDie echten Werte: username, password, host, port, dbname. Änderungen hier propagieren nach demrefreshIntervalautomatisch in alle verknüpften Namespaces. - ServiceAccount
eso-readerDie Lese-Identität – für sich genommen rechtelos. - Role
eso-secret-readerget/list/watchaufsecretspluscreateaufselfsubjectrulesreviews. - RoleBindingVerknüpft ServiceAccount und Role – erst jetzt existieren die Rechte.
Keine Schreib- oder Löschrechte, und weil die Role namespace-gebunden ist, gibt es keinen Zugriff auf Secrets außerhalb des secret-hub.
create auf selfsubjectrulesreviews kann der Kubernetes-Provider seine eigenen Rechte nicht validieren – der SecretStore wird nie Valid, in den ESO-Logs steht nur ein knappes forbidden. Die Leserechte auf Secrets allein reichen nicht.Komplette YAML: 01-secret-hub/secret-hub.yaml
Alle fünf Ressourcen in einem File, mit kubectl apply -f 01-secret-hub/secret-hub.yaml anwenden.
apiVersion: v1
kind: Namespace
metadata:
name: secret-hub
labels:
purpose: secret-hub
---
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: secret-hub
labels:
managed-by: admin
type: Opaque
stringData:
username: app-user
password: "Geheim!2026"
host: postgres.database.svc.cluster.local
port: "5432"
dbname: orders
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: eso-reader
namespace: secret-hub
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: eso-secret-reader
namespace: secret-hub
rules:
- apiGroups: [""]
resources: [secrets]
verbs: [get, list, watch]
- apiGroups: [authorization.k8s.io]
resources: [selfsubjectrulesreviews]
verbs: [create]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: eso-reader-binding
namespace: secret-hub
subjects:
- kind: ServiceAccount
name: eso-reader
namespace: secret-hub
roleRef:
kind: Role
name: eso-secret-reader
apiGroup: rbac.authorization.k8s.io
ESO installieren: Vanilla / KKP
Per Helm, danach Pods und CRDs prüfen.
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
-n external-secrets --create-namespace --wait --timeout 120s
kubectl get pods -n external-secrets
kubectl get crd | grep external-secrets.io
Drei Pods: Haupt-Controller, Cert-Controller (Webhook-Zertifikate), Validating Webhook. Fünf CRDs, u. a. externalsecrets, secretstores, clustersecretstores, pushsecrets.
ESO installieren: OpenShift 4.20+
Per OLM aus dem redhat-operators-Katalog; der Rest des Setups ist identisch.
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
name: external-secrets-operator
namespace: external-secrets
spec:
channel: stable
installPlanApproval: Automatic
name: external-secrets-operator
source: redhat-operators
sourceNamespace: openshift-marketplace
oc apply -f 00-eso-install/subscription.yaml
oc wait --for=condition=Available \
deployment/external-secrets-operator \
-n external-secrets --timeout=120s
Namespace (mit openshift.io/cluster-monitoring: "true") und eine leere OperatorGroup gehören dazu.
App-Namespace anbinden
Ein manueller Schritt (Token-Transfer), danach läuft alles deklarativ über Git: SecretStore zeigt auf den Hub, ExternalSecret beschreibt das Mapping, ESO erzeugt das Ziel-Secret.
Schritt 1 · Token-Transfer (der einzige manuelle Schritt)
Ein Token für eso-reader erzeugen und als Secret im App-Namespace ablegen – ohne dieses „Secret Zero“ kann ESO den Hub nicht lesen.
kubectl create namespace app-1
kubectl create secret generic eso-reader-token \
--namespace app-1 \
--from-literal=token="$(kubectl create token eso-reader \
--namespace secret-hub --duration=8760h)"
Rotieren geht mit demselben Befehl plus --dry-run=client -o yaml | kubectl apply -f -, das überschreibt das bestehende Secret.
# JWT-Claims pruefen: Ablauf, ServiceAccount, Issuer
kubectl get secret eso-reader-token -n app-1 -o jsonpath='{.data.token}' \
| base64 -d | cut -d. -f2 | base64 -d 2>/dev/null | jq
--duration mehr an, als das Server-Maximum des kube-apiservers erlaubt, gibt es keinen Fehler – das Token wird einfach kürzer ausgestellt. Wer sich auf „ein Jahr“ verlässt, steht ggf. viel früher mit SecretSyncedError da. Nach dem Erzeugen immer den exp-Claim prüfen.Schritt 2 · SecretStore und ExternalSecret
Der SecretStore definiert Backend, Remote-Namespace, CA (aus der kube-root-ca.crt-ConfigMap) und Auth. Das ExternalSecret mappt Remote-Keys auf lokale Keys – diese YAML ist GitOps-tauglich, weil sie nur Referenzen enthält.
SecretStore muss im selben Namespace liegen wie die ExternalSecret-Ressourcen, die ihn nutzen. Für clusterweite Nutzung gibt es den ClusterSecretStore (Abschnitt 4).YAML: 02-app-namespace/secret-store.yaml
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: secret-hub-store
namespace: app-1
spec:
provider:
kubernetes:
remoteNamespace: secret-hub
server:
caProvider:
type: ConfigMap
name: kube-root-ca.crt
key: ca.crt
auth:
token:
bearerToken:
name: eso-reader-token
key: token
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: db-credentials
namespace: app-1
spec:
refreshInterval: 1m
secretStoreRef:
kind: SecretStore
name: secret-hub-store
target:
name: db-credentials
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: db-credentials
property: username
- secretKey: password
remoteRef:
key: db-credentials
property: password
- secretKey: host
remoteRef:
key: db-credentials
property: host
- secretKey: port
remoteRef:
key: db-credentials
property: port
- secretKey: dbname
remoteRef:
key: db-credentials
property: dbname
Schritt 3 · Verifikation
Erst den Store, dann das ExternalSecret, dann das erzeugte Secret prüfen – in dieser Reihenfolge grenzt man Fehler am schnellsten ein.
kubectl get secretstore -n app-1 # erwartet: Valid
kubectl get externalsecret -n app-1 # erwartet: SecretSynced
kubectl describe externalsecret db-credentials -n app-1
kubectl get secret db-credentials -n app-1 \
-o jsonpath='{.data.username}' | base64 -d
Test-Anwendung: Secret als Env-Variablen konsumieren
Ein minimaler HTTP-Server zeigt die injizierten Werte an. Wichtig ist nur das Muster: valueFrom.secretKeyRef auf das von ESO erzeugte Secret – alternativ als Volume-Mount. Der ESO-Mechanismus ist für die App unsichtbar.
env:
- name: DB_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
kubectl port-forward svc/demo-app 8080:8080 -n app-1
curl http://localhost:8080/index.html
Auth-Varianten und erweiterte Konzepte
Das statische Bearer Token ist der nachvollziehbare Einstieg – für den Dauerbetrieb ist die ServiceAccount-Auth die bessere Wahl: kein Token-Transfer, keine Rotation, kein Secret Zero.
Bearer Token (statisch)
- Pro – einfach nachvollziehbar, funktioniert überall
- Contra – manueller Setup-Schritt
- Contra – Token läuft ab, Rotation nötig
- Contra – Secret Zero liegt im App-Namespace
ServiceAccount-Auth (empfohlen)
- Kein Transfer – ESO fordert Tokens selbst über die TokenRequest API an
- Keine Rotation – pro Zugriff ein frisches, kurzlebiges Token
- Kein Secret Zero – nichts Sensibles im App-Namespace
- Voll GitOps-tauglich – auch das Auth-Setup ist deklarativ
ServiceAccount-Auth einrichten
Seit Kubernetes 1.24 gibt es keine unbegrenzt gültigen ServiceAccount-Tokens mehr; die TokenRequest API stellt nur befristete aus. ESO nutzt genau das: Im SecretStore steht statt des Tokens der ServiceAccount – zusätzlich braucht der ESO-Controller das Recht, Tokens für eso-reader anzufordern.
YAML: SecretStore mit SA-Auth + Token-Requester-RBAC
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: secret-hub-store
namespace: app-1
spec:
provider:
kubernetes:
remoteNamespace: secret-hub
server:
caProvider:
type: ConfigMap
name: kube-root-ca.crt
key: ca.crt
auth:
serviceAccount:
name: eso-reader
namespace: secret-hub
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: eso-token-requester
namespace: secret-hub
rules:
- apiGroups: [""]
resources: [serviceaccounts/token]
resourceNames: [eso-reader]
verbs: [create]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: eso-token-requester
namespace: secret-hub
subjects:
- kind: ServiceAccount
name: external-secrets
namespace: external-secrets
roleRef:
kind: Role
name: eso-token-requester
apiGroup: rbac.authorization.k8s.io
Tokens unbegrenzt gültig machen? Offiziell nein. Das Maximum lässt sich per --service-account-max-token-expiration am kube-apiserver anheben, aber Managed-Cluster (EKS, GKE, AKS) erlauben das meist nicht, typisches Maximum ist ein Jahr – und sicherheitstechnisch ist es ein Anti-Pattern. Legacy-Tokens (kubernetes.io/service-account-token) sind unbegrenzt, aber deprecated.
ClusterSecretStore: ein Store für viele Namespaces
Sollen mehrere App-Namespaces aus demselben Hub lesen, ersetzt ein ClusterSecretStore die vielen einzelnen Stores. Die conditions steuern per namespaceSelector, wer ihn nutzen darf; im ExternalSecret steht dann kind: ClusterSecretStore.
conditions ist eine offene Tresortür: Ohne die Einschränkung kann jeder Namespace im Cluster alle Secrets aus dem Hub lesen. Es gibt keine Fehlermeldung, kein Event – nur einen stillen, erheblichen Sicherheitsverlust.conditions sind der Türsteher mit Gästeliste – nur Namespaces mit dem passenden Label (eso-access: "true") kommen an den Store.YAML: ClusterSecretStore mit namespaceSelector
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: secret-hub-cluster-store
spec:
provider:
kubernetes:
remoteNamespace: secret-hub
server:
caProvider:
type: ConfigMap
name: kube-root-ca.crt
key: ca.crt
namespace: secret-hub
auth:
serviceAccount:
name: eso-reader
namespace: secret-hub
conditions:
- namespaceSelector:
matchLabels:
eso-access: "true"
creationPolicy und deletionPolicy
Zwei Stellschrauben am target: Wie geht ESO mit dem Ziel-Secret um – und was passiert, wenn die Quelle verschwindet?
| creationPolicy | Verhalten |
|---|---|
| Owner | ESO erstellt und besitzt das Secret (Default) |
| Orphan | erstellt ohne OwnerReference |
| Merge | ergänzt bestehendes Secret, ohne es zu besitzen |
| None | erstellt kein Secret (nur für Templates) |
| deletionPolicy | Verhalten |
|---|---|
| Retain | Ziel-Secret bleibt bestehen |
| Delete | Ziel-Secret wird gelöscht |
| Merge | nur betroffene Keys werden entfernt |
dataFrom, Templates und PushSecret
dataFrom übernimmt ein komplettes Secret, ohne jeden Key einzeln zu mappen:
spec:
dataFrom:
- extract:
key: db-credentials
Templates formatieren das Ziel-Secret, etwa zu einem Connection-String:
target:
name: db-connection
creationPolicy: Owner
template:
engineVersion: v2
data:
connection-string: "postgresql://{{ .username }}:{{ .password }}@{{ .host }}:{{ .port }}/{{ .dbname }}"
PushSecret ist der umgekehrte Weg – ein von der App generiertes Credential zentral verfügbar machen:
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
name: push-to-hub
namespace: app-1
spec:
refreshInterval: 1m
secretStoreRefs:
- name: secret-hub-store
kind: SecretStore
selector:
secret:
name: generated-credentials
data:
- match:
secretKey: password
remoteRef:
remoteKey: app-1-credentials
property: password
GitOps-Integration mit ArgoCD
Das Pattern lebt von einer sauberen Trennung: Referenzen und Infrastruktur nach Git, Werte und Tokens niemals. ArgoCD deployt beides – ESO selbst und die App-Manifeste.
Gehört in Git
- SecretStore + ExternalSecret – reine Referenzen
- App-Manifeste – Deployment, Service
- Gateway-Infrastruktur – cert-manager-CRs, Gateway, HTTPRoutes
- ESO selbst – als ArgoCD-Application aufs Helm-Chart
Gehört nie in Git
- Die Secret-Werte – der Inhalt von
01-secret-hub/ - Das Bearer Token –
eso-reader-token
Bei ServiceAccount-Auth (Abschnitt 4) entfällt der zweite Punkt komplett – dann ist wirklich alles außer den Werten deklarativ.
ArgoCD und die von ESO erzeugten Secrets
ArgoCD markiert das ESO-Secret als „out of sync“, weil es nicht in Git steht. Abhilfe: die Differenz auf Applikations-Ebene ignorieren – oder das Secret per Template-Label ganz aus dem Tracking nehmen.
# Variante 1: Application ignoriert Secret-Daten
spec:
ignoreDifferences:
- group: ""
kind: Secret
jsonPointers:
- /data
# Variante 2: ESO setzt das Tracking-Label leer
spec:
target:
template:
metadata:
labels:
argocd.argoproj.io/instance: ""
YAML: ESO und Demo-App als ArgoCD-Applications
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: external-secrets
namespace: argocd
spec:
project: default
source:
repoURL: https://charts.external-secrets.io
chart: external-secrets
targetRevision: "*"
helm:
releaseName: external-secrets
valuesObject:
installCRDs: true
destination:
server: https://kubernetes.default.svc
namespace: external-secrets
syncPolicy:
automated:
selfHeal: true
syncOptions:
- CreateNamespace=true
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: eso-demo-app
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/ORG/eso-demo.git
targetRevision: main
path: manifests
directory:
recurse: true
destination:
server: https://kubernetes.default.svc
namespace: app-1
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- PruneLast=true
retry:
limit: 3
backoff:
duration: 5s
factor: 2
maxDuration: 3m
Ingress über die Gateway API
TLS-Secrets sind namespace-gebunden – die zentrale Frage ist deshalb: In welchem Namespace liegt das Zertifikat? Die App-Namespaces brauchen kein eigenes: Das Gateway terminiert TLS zentral, die HTTPRoutes verweisen nur per parentRef darauf.
Zwei Zertifikats-Patterns
| Pattern | Cert-Namespace | Gateway-Namespace | ReferenceGrant | Einsatz |
|---|---|---|---|---|
| A | gateway-infra | gateway-infra | nein | Standard, Gateway-Team verwaltet Certs |
| B | cert-store | gateway-infra | ja | Security-Team verwaltet Certs separat |
Bei Pattern B braucht es einen ReferenceGrant – und zwar im Namespace des Secrets, nicht beim Gateway. Im Gateway wird das Secret dann mit namespace: cert-store referenziert.
YAML: Certificate (Pattern A) und ReferenceGrant (Pattern B)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-issuer
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-tab-local
namespace: gateway-infra
spec:
secretName: wildcard-tab-local-tls
issuerRef:
name: selfsigned-issuer
kind: ClusterIssuer
commonName: "*.tab.local"
dnsNames:
- "*.tab.local"
- "tab.local"
duration: 8760h
renewBefore: 720h
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-gateway-to-certs
namespace: cert-store # liegt beim Secret!
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: gateway-infra
to:
- group: ""
kind: Secret
Gateway und HTTPRoute
Das Gateway lauscht auf 443 (TLS Terminate) und 80 (Redirect), erlaubt Routes aber nur aus Namespaces mit dem Label gateway-access: "true". Jede App bringt zwei HTTPRoutes mit: eine für HTTPS, eine für den 301-Redirect von HTTP.
kubectl label namespace app-1 gateway-access=true --overwrite
NotAllowedByListeners – der Listener-Selector filtert den Namespace einfach heraus, ohne dass beim Apply irgendetwas meckert.gatewayClassName muss zum installierten Controller passen: kgateway, cilium, eg (Envoy Gateway), istio, contour, nginx oder traefik.
YAML: Gateway mit HTTPS/HTTP-Listenern
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: main-gateway
namespace: gateway-infra
spec:
gatewayClassName: kgateway
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.tab.local"
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: wildcard-tab-local-tls
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"
- name: http
protocol: HTTP
port: 80
hostname: "*.tab.local"
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"
YAML: HTTPRoute + HTTP-zu-HTTPS-Redirect
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: demo-app-route
namespace: app-1
spec:
parentRefs:
- name: main-gateway
namespace: gateway-infra
sectionName: https
hostnames:
- demo.tab.local
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: demo-app
port: 8080
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: demo-app-redirect
namespace: app-1
spec:
parentRefs:
- name: main-gateway
namespace: gateway-infra
sectionName: http
hostnames:
- demo.tab.local
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
Verifikation direkt am Gateway, unabhängig von DNS:
GATEWAY_IP=$(kubectl get gateway main-gateway -n gateway-infra \
-o jsonpath='{.status.addresses[0].value}')
curl -k --resolve "demo.tab.local:443:${GATEWAY_IP}" https://demo.tab.local/
Betrieb: Komplettablauf, Troubleshooting, Bewertung
Der ganze Aufbau als chronologische Schrittfolge, die häufigsten Fehlerbilder mit Diagnosewegen – und eine ehrliche Einordnung, wann das Pattern trägt und wann nicht.
Der Komplettablauf in vier Phasen
- Phase A · ESO installierenHelm (Vanilla/KKP) oder OLM-Subscription (OpenShift 4.20+); Pods und die fünf CRDs verifizieren. → Abschnitt 2
- Phase B · Secret-Hub aufbauenNamespace, Quell-Secret, ServiceAccount, Role (inkl.
selfsubjectrulesreviews!), RoleBinding. → Abschnitt 2 - Phase C · App anbindenToken-Transfer (oder gleich SA-Auth), SecretStore, ExternalSecret; Status bis
SecretSyncedverfolgen. → Abschnitte 3–4 - Phase D · GitOps und IngressManifeste nach Git, ArgoCD-Applications,
ignoreDifferences, Gateway + HTTPRoutes + Namespace-Label. → Abschnitte 5–6
Troubleshooting: Symptom → Ursache → Lösung
| Symptom | Ursache | Lösung |
|---|---|---|
SecretSyncedError | Token abgelaufen oder falsch | Token neu erzeugen |
InvalidProviderConfig | remoteNamespace falsch | Namespace-Namen prüfen |
SecretStore not found | Store in anderem Namespace | Store in denselben NS legen |
forbidden in Logs | RBAC unvollständig | Role auf selfsubjectrulesreviews prüfen |
| Key nicht gefunden | property stimmt nicht | Keys im Quell-Secret prüfen |
NotAllowedByListeners | Namespace-Label fehlt | gateway-access=true setzen |
BackendNotFound | Service existiert nicht / heißt anders | kubectl get svc im Ziel-NS |
curl gegen die Gateway-IP das Richtige, der Browser aber nicht, liegt es an DNS oder Browser-Cache: /etc/hosts prüfen, DNS-Cache leeren (Chrome: chrome://net-internals/#dns, Firefox: about:networking#dns) oder Inkognito-Fenster.Diagnose-Befehle
Immer von der Quelle zur Senke: ESO-Status, Token, RBAC, dann Routing.
# ExternalSecret-Events und ESO-Logs
kubectl describe externalsecret db-credentials -n app-1
kubectl logs -n external-secrets deployment/external-secrets --tail=50
# Store-Status (erwartet: Valid)
kubectl get secretstore secret-hub-store -n app-1 -o yaml | yq '.status'
# Keys im Quell-Secret
kubectl get secret db-credentials -n secret-hub -o jsonpath='{.data}' | jq 'keys'
# Token-Ablauf: exp ist ein Unix-Timestamp
kubectl get secret eso-reader-token -n app-1 -o jsonpath='{.data.token}' \
| base64 -d | cut -d. -f2 | base64 -d 2>/dev/null | jq '.exp'
date -d @<TIMESTAMP>
# RBAC manuell testen: Lesen im Hub ja, anderswo Forbidden
TOKEN=$(kubectl get secret eso-reader-token -n app-1 \
-o jsonpath='{.data.token}' | base64 -d)
kubectl --token="$TOKEN" get secrets -n secret-hub # erwartet: Liste
kubectl --token="$TOKEN" get secrets -n kube-system # erwartet: Forbidden
# Gateway API: Accepted-Status aller Routes + attachedRoutes
kubectl get httproute -A -o custom-columns=\
'NS:.metadata.namespace,NAME:.metadata.name,ACCEPTED:.status.parents[0].conditions[?(@.type=="Accepted")].status,REASON:.status.parents[0].conditions[?(@.type=="Accepted")].reason'
kubectl get gateway main-gateway -n gateway-infra \
-o jsonpath='{range .status.listeners[*]}{.name}: attachedRoutes={.attachedRoutes}{"\n"}{end}'
Deinstallation und Aufräumen
Erst die Demo-Ressourcen rückwärts entfernen, dann ESO, zuletzt die CRDs. Blockieren Finalizer, vorher alle Custom Resources löschen.
kubectl delete -f 05-routes/ -f 04-gateway/ -f 03-test-app/ -f 02-app-namespace/
kubectl delete secret eso-reader-token -n app-1
kubectl delete -f 01-secret-hub/
kubectl delete namespace app-1 gateway-infra
helm uninstall external-secrets -n external-secrets
kubectl delete namespace external-secrets
# CRDs; bei haengenden Finalizern zuerst alle CRs loeschen
kubectl delete externalsecrets,secretstores,clustersecretstores,pushsecrets --all -A
kubectl get crds -o name | grep external-secrets.io | xargs kubectl delete
Geeignet für
- statische Secrets in einem abgegrenzten Cluster
- Teams ohne dedizierten Secret-Manager-Betrieb
- air-gapped und On-Prem-Umgebungen
- als Zwischenschritt vor einer Vault-Einführung
Weniger geeignet für
- Compliance-Umgebungen mit Audit-Trail-Anforderungen
- häufige Credential-Rotation
- Multi-Cluster-Setups mit zentralem Secret-Management (pro Cluster ein Hub oder Cross-Cluster-Zugriff nötig)
- dynamische Datenbank-Credentials
Exit-Strategie: Migration zu Vault
Der große Vorteil des Patterns: Beim Backend-Wechsel bleiben alle ExternalSecret-Manifeste und Deployments unverändert – nur der SecretStore wird umgestellt, die Apps konsumieren weiterhin ein normales Kubernetes-Secret.
# Vorher: Kubernetes-Provider
spec:
provider:
kubernetes:
remoteNamespace: secret-hub
# Nachher: Vault-Provider
spec:
provider:
vault:
server: "https://vault.example.com:8200"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "app-1-role"