Das Prinzip
In Kubernetes laufen 8 Services (kein Eureka – ADR-0007: k8s-DNS
ersetzt es), das Angular-Frontend als nginx-Container und die Infra
(Postgres/Kafka/Keycloak/Observability) als k8s-Workloads mit genau den Namen, die die
vorhandenen Helm-values erwarten. Ein deploy.ps1 orchestriert die 8 Releases.
Basis ist das generische Chart infra/helm/library-service + die
values/-Dateien, die es schon gibt.
Postgres · Kafka · Keycloak · Prometheus/Grafana/Loki
Den k3s-Guide (Cluster-Grundlagen) und den Microservices-Stack-Guide (Kafka-/Keycloak-Fallen, das Muster). Diese Anleitung fuellt jeden Befehl fuer dieses konkrete Projekt aus.
k3s ist Kubernetes – nur als schlanke Ein-Datei-Distribution. Dieselben
Manifeste, dasselbe kubectl, dieselbe API (Application Programming Interface). Im Text meint k3s die
konkrete Distribution, die hier auf dem Hetzner-Server laeuft (inkl. ihrer Extras wie
local-path-Storage und der ServiceLB). k8s meint Kubernetes
allgemein – die API, die Konzepte, alles was auf jeder Distribution gleich ist
(networking.k8s.io/v1, „k8s-DNS-Discovery“ …). Und
SPRING_PROFILES_ACTIVE=k8s ist einfach der Name, den das Projekt fuer sein
Cluster-Profil gewaehlt hat – kein eigenes System.
01 1 Node oder Cluster?
| 1 Node | 3 Nodes | |
|---|---|---|
| Server | CPX52 (16 vCPU / 32 GB) | 3× CPX42 (8 / 16) + Load Balancer |
| Reicht fuer | alles: 8 JVMs (~5 GB) + Kafka + Keycloak + Postgres + Observability (~6 GB) + Overhead | dasselbe, plus Reserve & Ausfallsicherheit |
| HA (Node darf ausfallen) | nein | ja (mit 3 Server-Nodes / embedded etcd) |
| Storage | k3s local-path – einfach | Longhorn oder Infra per nodeSelector pinnen |
| ca. €/Monat | ~26 + Backup | ~48 + Backup |
Empfehlung: mit 1 Node starten. „Cluster bilden, wenn's sein muss“ = wenn du HA brauchst oder aus 32 GB (Gigabyte) rauswaechst – der Umstieg ist im Abschnitt Auf 3 Nodes wachsen, die Workloads bleiben identisch.
02 k3s-Cluster aufsetzen
Setzt Server-Basis
voraus. Details im
k3s-Guide.
Hier die Kurzform – --disable traefik, weil wir ingress-nginx
nehmen (die values-Dateien nutzen className: nginx).
#cloud-config
package_update: true
runcmd:
- 'IP=$(curl -s http://169.254.169.254/hetzner/v1/metadata/public-ipv4); curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server --disable traefik --tls-san $IP --write-kubeconfig-mode 644" sh -'
hcloud firewall create --name lib-fw
$ip = (Invoke-RestMethod 'https://ipv4.icanhazip.com').Trim()
hcloud firewall add-rule lib-fw --direction in --protocol tcp --port 22 --source-ips "$ip/32"
hcloud firewall add-rule lib-fw --direction in --protocol tcp --port 6443 --source-ips "$ip/32"
hcloud firewall add-rule lib-fw --direction in --protocol tcp --port 80 --source-ips 0.0.0.0/0,::/0
hcloud firewall add-rule lib-fw --direction in --protocol tcp --port 443 --source-ips 0.0.0.0/0,::/0
hcloud server create --name lib-node1 --type cpx52 --image ubuntu-24.04 `
--ssh-key laptop --firewall lib-fw --user-data-from-file cloud-init.yaml
$srv = (hcloud server ip lib-node1).Trim()
New-Item -ItemType Directory -Force $env:USERPROFILE\.kube | Out-Null
scp root@${srv}:/etc/rancher/k3s/k3s.yaml $env:USERPROFILE\.kube\biblio.yaml
(Get-Content $env:USERPROFILE\.kube\biblio.yaml) -replace '127\.0\.0\.1',$srv | Set-Content $env:USERPROFILE\.kube\biblio.yaml
$env:KUBECONFIG = "$env:USERPROFILE\.kube\biblio.yaml"
kubectl get nodes # Ready?
Drei Werkzeuge in einem Block – hcloud baut Firewall und Server,
PowerShell holt die kubeconfig ab und biegt sie um:
| Schalter | gehört zu | bewirkt |
|---|---|---|
--direction in--source-ips "$ip/32" |
firewall add-rule | Hetzner-Firewalls filtern eingehend; ausgehend bleibt offen. /32 ist genau eine Adresse – die eigene, gerade von icanhazip.com geholte. SSH und die k3s-API sind damit nur vom eigenen Anschluss erreichbar. Wechselt die Adresse (DSL-Zwangstrennung, anderes Netz), muss die Regel nachgezogen werden. |
--port 6443 |
firewall add-rule | Der k3s-API-Server. Er gehört nicht ins offene Netz: wer ihn erreicht, redet mit dem Cluster-Admin-Endpunkt. Deshalb dieselbe /32-Quelle wie SSH, während 80 und 443 mit 0.0.0.0/0,::/0 für alle offen sind (IPv4 und IPv6 – beides muss dastehen). |
--user-data-from-file cloud-init.yaml |
server create | Übergibt die cloud-init-Datei von oben. Sie läuft einmal beim allerersten Start und installiert k3s – ein späterer Neustart führt sie nicht erneut aus. Ohne diesen Schalter entsteht ein leeres Ubuntu. |
--firewall lib-fw / --ssh-key laptop |
server create | Hängt Firewall und hinterlegten SSH-Schlüssel schon bei der Erstellung an. Wichtig ist die Reihenfolge: die Firewall greift damit ab der ersten Sekunde, statt dass der Server ein paar Minuten offen im Netz steht. |
` am Zeilenende |
PowerShell | Das Zeilenfortsetzungszeichen – PowerShells Gegenstück zum \ der Shell. Es muss das letzte Zeichen der Zeile sein; ein Leerzeichen dahinter bricht den Befehl still in zwei Teile. |
-replace '127\.0\.0\.1',$srv |
PowerShell | k3s schreibt in seine kubeconfig 127.0.0.1 – vom Server aus richtig, vom Laptop aus zeigt es ins Leere. Der Ausdruck ersetzt sie durch die öffentliche IP. Die Punkte sind maskiert, weil -replace mit regulären Ausdrücken arbeitet und ein nackter Punkt jedes Zeichen träfe. |
--tls-san $IP(in der cloud-init-Datei) |
k3s | Nimmt die öffentliche IP in das API-Zertifikat auf. Fehlt sie, scheitert jeder kubectl-Aufruf vom Laptop mit certificate is valid for 127.0.0.1, not <IP> – die Umschreibung oben allein reicht also nicht. |
--write-kubeconfig-mode 644 |
k3s | Macht /etc/rancher/k3s/k3s.yaml lesbar. Standard ist 600 für root – das scp aus der nächsten Zeile bliebe sonst an Permission denied hängen. |
--disable traefik |
k3s | k3s bringt Traefik als Ingress mit. Dieser Stack nutzt ingress-nginx (className: nginx in den values-Dateien); beide gleichzeitig würden sich um Port 80 und 443 streiten. |
Lokal noch: winget install Kubernetes.kubectl Helm.Helm OpenJS.NodeJS
(Node fuer den Frontend-Build), und podman/docker fuer die Image-Builds
(oder die CI baut, siehe unten).
03 Cluster-Grundausstattung
kubectl create namespace library
# Ingress-Controller (Typ LoadBalancer -> k3s ServiceLB bindet die Nodes-IP an 80/443)
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm install ingress-nginx ingress-nginx/ingress-nginx -n ingress-nginx --create-namespace `
--set controller.service.type=LoadBalancer
# TLS
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager -n cert-manager --create-namespace --set crds.enabled=true
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@seb4u.com
privateKeySecretRef:
name: letsencrypt-key
solvers:
- http01:
ingress:
class: nginx
04 Infra deployen (Bitnami-Charts)
fullnameOverride sorgt dafuer, dass die Services exakt postgres,
redis, kafka, keycloak heissen – so wie die
values-Dateien es erwarten.
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm install postgres bitnami/postgresql -n library `
--set fullnameOverride=postgres `
--set auth.username=library --set auth.password='<db-pw>' --set auth.database=library `
--set primary.persistence.size=10Gi
helm install redis bitnami/redis -n library `
--set fullnameOverride=redis --set auth.enabled=false --set architecture=standalone
helm install kafka bitnami/kafka -n library `
--set fullnameOverride=kafka --set listeners.client.protocol=PLAINTEXT `
--set controller.persistence.size=8Gi
helm install keycloak bitnami/keycloak -n library `
--set fullnameOverride=keycloak --set service.ports.http=8080 `
--set auth.adminUser=admin --set auth.adminPassword='<kc-admin-pw>' `
--set postgresql.enabled=false `
--set externalDatabase.host=postgres --set externalDatabase.user=library `
--set externalDatabase.password='<db-pw>' --set externalDatabase.database=keycloak_db `
--set production=true --set proxy=edge `
--set 'extraEnvVars[0].name=KC_HOSTNAME' --set 'extraEnvVars[0].value=auth.seb4u.com'
# Observability
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm install obs prometheus-community/kube-prometheus-stack -n library `
--set grafana.adminPassword='<grafana-pw>'
helm install loki grafana/loki-stack -n library --set promtail.enabled=true
Sechs Installationen. Die Werte unterscheiden sich bewusst von der lokalen CRC-/OKD-Variante – hier laufen die Dienste dauerhaft und öffentlich:
| Schalter | gilt für | bewirkt |
|---|---|---|
helm repo add bitnami …helm repo update |
Vorbereitung | Ein Chart-Repository ist ein Verzeichnis fertiger Installationspakete – wie eine Paketquelle für apt, nur für Kubernetes. add trägt die Adresse unter dem Kurznamen bitnami ein, update holt die Liste der verfügbaren Versionen. Erst danach versteht Helm die Schreibweise bitnami/postgresql. Beides ist einmalig pro Rechner nötig, nicht pro Cluster – und schadet nicht, wenn man es wiederholt. |
-n library |
alle Aufrufe | Der Namespace – die Schublade im Cluster, in der diese Anwendung liegt. Ohne die Angabe landet alles in default, wo es sich mit allem anderen mischt. Der Namensraum muss vorher existieren; Helm legt ihn nicht selbst an (außer mit --create-namespace). |
helm install (ohne --install) |
hier bewusst | Anders als in den anderen Anleitungen steht hier das schlichte helm install: es soll beim zweiten Lauf mit cannot re-use a name abbrechen. Diese Dienste laufen dauerhaft mit echten Daten – ein versehentliches erneutes Ausrollen wäre hier kein Komfort, sondern ein Risiko. Für Aktualisierungen ist helm upgrade der richtige Aufruf. |
--set fullnameOverride=postgres usw. |
alle vier Infra-Charts | Erzwingt den Service-Namen exakt so. Helm hängt sonst den Release-Namen davor (postgres-postgresql); die values-Dateien der Anwendung kennen aber nur postgres, redis, kafka und keycloak. |
--set primary.persistence.size=10Gi--set controller.persistence.size=8Gi |
Postgres, Kafka | Grösse des PersistentVolume. Anders als lokal bleibt persistence hier eingeschaltet – ein neu erstellter Pod darf keine Daten kosten. Die Grösse lässt sich bei k3s nachträglich nicht ohne Weiteres ändern, deshalb lieber gleich grosszuegig. |
--set auth.enabled=false--set architecture=standalone |
Redis | Kein Passwort, kein Replica-Set. Redis ist nur im Cluster-Netz erreichbar und dient als Cache – ein Replikat kostet einen zweiten Pod, ohne dass etwas davon abhängt. |
--set listeners.client.protocol=PLAINTEXT |
Kafka | Kein TLS zwischen den Diensten und Kafka. Vertretbar, solange Kafka keinen Service ausserhalb des Clusters bedient – es gibt hier bewusst keine Route darauf. |
--set postgresql.enabled=false--set externalDatabase.… |
Keycloak | Das Keycloak-Chart würde sonst eine zweite Postgres-Instanz mitinstallieren. Der erste Schalter schaltet die ab, die vier externalDatabase.* zeigen auf die vorhandene mit eigener Datenbank keycloak_db. |
--set production=true--set proxy=edge |
Keycloak | Der entscheidende Unterschied zur lokalen Variante: production=true verlangt HTTPS und einen festen Hostnamen. proxy=edge sagt Keycloak, dass TLS beim Ingress endet und es selbst unverschlüsselt bedient werden darf – ohne das wehrt es jede Anfrage als unsicher ab. |
--set 'extraEnvVars[0].name=KC_HOSTNAME'--set 'extraEnvVars[0].value=auth.…' |
Keycloak | Nagelt den Hostnamen fest, den Keycloak in Weiterleitungen und im iss-Claim der Tokens verwendet. Unter production=true ist er Pflicht. Die Anführungszeichen sind nötig, weil PowerShell die eckigen Klammern sonst selbst auswertet; der Index [0] adressiert den ersten Eintrag der Liste – Name und Wert müssen denselben Index tragen, sonst entstehen zwei halbe Einträge. |
--set promtail.enabled=true |
loki-stack | Startet den Log-Sammler auf jedem Node. Ohne ihn läuft Loki, bekommt aber nichts zu sehen – die Suche in Grafana bliebe dauerhaft leer. |
Bitnami-Kafka horcht auf kafka:9092 (nicht :29092 wie der
Compose-interne Listener), Bitnami-Keycloak via service.ports.http=8080 wieder auf
keycloak:8080. Das gleicht das Hetzner-Overlay in Schritt 7 aus.
05 Postgres-DBs & Keycloak-Realm
# die 7 logischen DBs (Flyway in jedem Service legt danach die Schemas an, V1__init_*.sql):
kubectl exec -n library postgres-0 -- bash -c `
'for db in catalog_db member_db lending_db reservation_db fine_db notification_db keycloak_db; do PGPASSWORD=<db-pw> psql -U library -h localhost -tc "SELECT 1 FROM pg_database WHERE datname=\"$db\"" | grep -q 1 || PGPASSWORD=<db-pw> psql -U library -h localhost -c "CREATE DATABASE $db"; done'
$kc = kubectl get pod -n library -l app.kubernetes.io/name=keycloak -o name
kubectl cp infra/keycloak/realm-library.json library/$($kc -replace 'pod/',''):/tmp/realm.json
kubectl exec -n library $kc -- kcadm.sh config credentials `
--server http://localhost:8080 --realm master --user admin --password '<kc-admin-pw>'
kubectl exec -n library $kc -- kcadm.sh create realms -f /tmp/realm.json
Vier Zeilen, in denen dreimal derselbe Pod angesprochen wird – nur jedes Mal in einer anderen Schreibweise:
| Konstrukt | Zeile | bewirkt |
|---|---|---|
-l app.kubernetes.io/name=keycloak |
1 | Sucht über ein Label statt über den Namen. Der echte Pod-Name endet auf eine zufällige Zeichenfolge oder eine laufende Nummer und ändert sich bei jedem Neustart – das Label bleibt. Deshalb funktioniert dieselbe Zeile auch nach einem helm upgrade noch. |
-o name |
1 | Gibt statt der Tabelle nur die Kennung aus, und zwar in der Form pod/keycloak-0. Praktisch für kubectl exec, das diese Schreibweise direkt versteht – unpraktisch für kubectl cp, das sie nicht mag. Daher die Zeile darunter. |
$($kc -replace 'pod/','') |
2 | Schneidet das Präfix wieder ab. kubectl cp erwartet namespace/podname:pfad – mit pod/ darin entstünde library/pod/keycloak-0 und der Pod wäre nicht zu finden. Das $(…) drumherum ist nötig, weil PowerShell in einer Zeichenkette sonst nur einfache Variablen ersetzt, keine Ausdrücke. |
kubectl cp <lokal> <ns>/<pod>:<pfad> |
2 | Kopiert die Realm-Datei in den Pod. Setzt tar im Zielcontainer voraus – bei ganz schlanken Images scheitert es daran mit einer Meldung, die den Grund nicht nennt. Unter Git Bash wandelt die Umgebung ausserdem :/tmp/… in einen Windows-Pfad um; dort hilft ein doppelter Schrägstrich (:://tmp/…). Unter PowerShell tritt das nicht auf. |
kcadm.sh config credentials |
3 | Meldet sich an und legt das Ergebnis in einer Konfigurationsdatei ab, die der nächste Aufruf wiederfindet. --realm master ist der Verwaltungs-Realm, aus dem heraus andere Realms bearbeitet werden – nicht der, den wir gerade anlegen. --server http://localhost:8080 geht von innen gegen den eigenen Port und umgeht damit den Ingress samt Hostnamen-Frage. |
kcadm.sh create realms -f /tmp/realm.json |
4 | Legt den Realm aus der kopierten Datei an. Dieser Aufruf ist nicht wiederholbar: existiert der Realm schon, endet er mit Conflict detected. Beim erneuten Aufsetzen entweder vorher delete realms/library oder die Meldung bewusst übergehen. |
` am Zeilenende |
3 | Zeilenfortsetzung – PowerShells Gegenstück zum \ der Shell. Es muss das letzte Zeichen der Zeile sein; ein Leerzeichen dahinter zerlegt den Befehl still in zwei unvollständige Teile. |
Kontrolle: curl -s https://auth.seb4u.com/realms/library/.well-known/openid-configuration
– der issuer darin muss exakt die URL (Uniform Resource Locator) aus dem Overlay (Schritt 7) sein.
06 Images bauen & nach GHCR
echo $env:GH_PAT | podman login ghcr.io -u dein-gh-user --password-stdin
$sha = git rev-parse --short HEAD
$mods = 'catalog-service','member-service','lending-service','reservation-service',
'fine-service','notification-service','config-server','api-gateway'
foreach ($m in $mods) {
podman build -f library-platform/Dockerfile --build-arg MODULE=$m `
-t "ghcr.io/dein-gh-user/$m:$sha" library-platform
podman push "ghcr.io/dein-gh-user/$m:$sha"
}
kubectl create secret docker-registry ghcr -n library `
--docker-server=ghcr.io --docker-username=dein-gh-user --docker-password=$env:GH_PAT
kubectl patch serviceaccount default -n library `
-p '{\"imagePullSecrets\":[{\"name\":\"ghcr\"}]}'
07 Hetzner-Helm-Overlay
Eine neue Overlay-Datei – wird als zweite -f hinter die
service-spezifische Datei gehaengt. Setzt Prod-naehere Ressourcen und die
Infra-Adressen/Issuer fuer diese Umgebung. Die DB-URLs stehen bereits richtig in den
<service>-values.yaml (jdbc:postgresql://postgres:5432/…) –
also kein per-Service-Overlay noetig.
replicaCount: 2
autoscaling:
enabled: true
resources:
requests: { cpu: 300m, memory: 512Mi }
limits: { cpu: "1", memory: 768Mi }
probes:
liveness: { initialDelaySeconds: 90 }
readiness: { initialDelaySeconds: 45 }
env:
# Bitnami-Kafka-Client-Port ist 9092 (nicht 29092 wie der Compose-interne Listener):
SPRING_KAFKA_BOOTSTRAP_SERVERS: "kafka:9092"
# iss-Claim (Browser-seitig) und Schluesselabruf zeigen auf die externe Keycloak-URL:
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "https://auth.seb4u.com/realms/library"
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "https://auth.seb4u.com/realms/library/protocol/openid-connect/certs"
08 Die 8 Services deployen
param([string]$Tag = (git rev-parse --short HEAD))
# 'Continue', weil kubectl/helm/git ihre Ausgabe nach stderr schreiben und
# PowerShell 5.1 das unter 'Stop' als Abbruch wertet. Fehler ueber $LASTEXITCODE.
$ErrorActionPreference = 'Continue'
$NS = 'library'
$REPO = 'ghcr.io/dein-gh-user'
$DBPW = $env:LIB_DB_PW # vorher: $env:LIB_DB_PW = '<db-pw>'
$svcs = 'config-server','api-gateway','catalog-service','member-service','lending-service',
'reservation-service','fine-service','notification-service'
# DB-Secrets (config-server + api-gateway haben keine DB):
foreach ($s in $svcs) {
if ($s -in 'config-server','api-gateway') { continue }
kubectl create secret generic "$s-db-credentials" -n $NS `
--from-literal=SPRING_DATASOURCE_USERNAME=library `
--from-literal=SPRING_DATASOURCE_PASSWORD=$DBPW `
--dry-run=client -o yaml | kubectl apply -f -
}
# Deploy (config-server zuerst):
foreach ($s in $svcs) {
helm upgrade --install $s infra/helm/library-service -n $NS `
-f infra/helm/values/$s-values.yaml `
-f infra/helm/values/env/hetzner-values.yaml `
--set image.repository="$REPO/$s" --set image.tag=$Tag
if ($LASTEXITCODE -ne 0) { throw "helm upgrade $s fehlgeschlagen ($LASTEXITCODE)" }
if ($s -eq 'config-server') { kubectl -n $NS rollout status deploy/config-server --timeout=180s }
}
kubectl get pods -n $NS
Kurz, aber mit ein paar Kniffen, die den Unterschied zwischen „läuft einmal“ und „läuft jedes Mal“ ausmachen:
| Konstrukt | Stelle | bewirkt |
|---|---|---|
$svcs = 'config-server','api-gateway',… |
Kopf | Kommagetrennt ergibt in PowerShell ein Array – die Liste, über die beide Schleifen darunter laufen. Sie ist die einzige Stelle, an der ein neuer Dienst eingetragen werden muss; die Reihenfolge bestimmt zugleich die Reihenfolge des Ausrollens, weshalb config-server vorn steht. |
foreach ($s in $svcs) { … } |
zweimal | Zwei getrennte Durchläufe statt eines: erst alle Secrets, dann alle Deployments. Das ist Absicht – ein Pod, der startet, bevor sein Secret existiert, geht in eine Neustart-Schleife und braucht Minuten, um sich davon zu erholen. |
param([string]$Tag = (git rev-parse --short HEAD)) |
Zeile 1 | Der Vorgabewert ist ein Ausdruck, kein fester Text – er wird bei jedem Aufruf neu ausgewertet. Ohne Argument deployt das Skript also genau den gerade ausgecheckten Commit; mit -Tag v1.2.0 etwas anderes. Der Bezug zwischen laufendem Container und Quellstand entsteht allein an dieser Stelle. |
$ErrorActionPreference = 'Continue' |
Kopf | Bewusst nicht 'Stop': kubectl, helm und git schreiben auch normale Meldungen nach stderr, und PowerShell 5.1 wertet das unter Stop als abbrechenden Fehler. Deshalb prüft das Skript unten $LASTEXITCODE von Hand – das ist bei externen Programmen die einzige verlässliche Aussage über Erfolg. |
$DBPW = $env:LIB_DB_PW |
Kopf | Das Passwort steht in einer Umgebungsvariablen, nicht im Skript. Damit landet es weder im Repository noch in der PowerShell-Historie – und ein git diff vor dem Commit zeigt es nicht versehentlich mit. |
--dry-run=client -o yaml | kubectl apply -f - |
Secrets | Der wichtigste Kniff im Skript. kubectl create secret allein scheitert beim zweiten Lauf mit AlreadyExists. --dry-run=client lässt kubectl das Objekt nur berechnen, ohne den Cluster zu fragen, -o yaml gibt es aus, und apply -f - liest es von der Standardeingabe und legt es an oder aktualisiert es. Dieselben drei Schalter machen aus jedem create ein wiederholbares apply. |
--from-literal=… |
Secrets | Wert direkt aus der Kommandozeile statt aus einer Datei (--from-file). kubectl kodiert ihn selbst nach Base64 – wer das Passwort von Hand vorkodiert, bekommt ein doppelt kodiertes Secret und einen Dienst, der sich mit unverständlichem Fehler nicht anmelden kann. |
if ($s -in 'config-server','api-gateway') { continue } |
Secrets | Diese beiden haben keine Datenbank. Ein leeres Secret wäre harmlos, aber der continue hält die Liste ehrlich: was im Cluster steht, entspricht dem, was gebraucht wird. |
-f …/$s-values.yaml-f …/env/hetzner-values.yaml |
helm upgrade | Zwei Wertedateien in dieser Reihenfolge – die spätere gewinnt. Die erste beschreibt den Dienst (Port, Probes, Ressourcen), die zweite die Umgebung (Hostnamen, Replikate, Grenzen). Deshalb genügt für einen zweiten Cluster eine weitere Datei statt acht geänderter. |
--set image.tag=$Tag |
helm upgrade | --set gewinnt gegen beide Dateien. Der Tag ist der einzige Wert, der sich bei jedem Deployment ändert – er gehört deshalb nicht in eine versionierte Datei, sondern hierher. |
helm upgrade --install |
helm upgrade | Installiert beim ersten Lauf, aktualisiert bei jedem weiteren. helm install allein bricht beim zweiten Mal ab – dasselbe Muster wie bei den Secrets eine Zeile darueber. |
throw "… fehlgeschlagen" |
nach jedem Dienst | Bricht die Schleife wirklich ab. Ohne diese Zeile liefe sie unter ErrorActionPreference = 'Continue' stur weiter und meldete am Ende ein kubectl get pods, das gesund aussieht, während ein Dienst nie aktualisiert wurde. |
rollout status deploy/config-server --timeout=180s |
Sonderfall | Nur für den config-server: alle anderen Dienste holen beim Start ihre Konfiguration von ihm. Startet er nicht zuerst, gehen sie mit Connection refused in eine Neustart-Schleife und brauchen mehrere Minuten, um sich davon zu erholen. --timeout verhindert, dass das Skript bei einem echten Problem endlos wartet. |
$env:LIB_DB_PW = '<db-pw>'
.\scripts\deploy.ps1
discovery-server wird in k8s bewusst nicht deployt (ADR-0007). Das Chart
aktiviert automatisch SPRING_PROFILES_ACTIVE=k8s, was den Eureka-Client abschaltet
und k8s-DNS-Discovery nutzt.
Eureka (Netflix / Spring Cloud) ist ein Service-Discovery-Server –
ein Telefonbuch fuer Microservices. Jeder Service meldet sich beim Start an
(„ich bin catalog-service, erreichbar unter 10.0.0.5:8080“), andere
fragen Eureka nach der aktuellen Adresse. Loest das Problem, dass IPs (Internet-Protocol-Adressen)/Ports sich beim Neustart
oder Skalieren aendern.
In Kubernetes ueberfluessig: jeder Service hat dort schon einen
festen DNS-Namen (catalog-service.library.svc.cluster.local)
und eine ClusterIP, die automatisch auf alle Pods verteilt. Eureka obendrauf waere ein
zweites Telefonbuch fuer denselben Job – redundant, eine Fehlerquelle mehr. Darum:
discovery-server gar nicht deployen, SPRING_PROFILES_ACTIVE=k8s schaltet
den Client ab.
Lokal / bei docker compose (Stufe A/B, kein k8s-DNS) laeuft Eureka dagegen
mit – deshalb existiert der discovery-server im Repo ueberhaupt.
09 Frontend bauen & deployen
Das Frontend hat API- und Keycloak-URL hartkodiert
(src/app/core/api/api-config.ts, …/auth/auth.config.ts). Vor dem
Build auf die Cloud-URLs setzen. redirectUri ist bereits dynamisch
(window.location.origin).
(Get-Content src/app/core/api/api-config.ts) `
-replace 'http://localhost:8080','https://api.seb4u.com' | Set-Content src/app/core/api/api-config.ts
(Get-Content src/app/core/auth/auth.config.ts) `
-replace 'http://localhost:8180','https://auth.seb4u.com' | Set-Content src/app/core/auth/auth.config.ts
npm ci
npm run build # -> dist/library-frontend/browser/
# Dockerfile
FROM nginx:1.27-alpine
COPY dist/library-frontend/browser/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
# nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
location / { try_files $uri $uri/ /index.html; } # SPA-Fallback
}
podman build -t "ghcr.io/dein-gh-user/library-frontend:$sha" library-frontend
podman push "ghcr.io/dein-gh-user/library-frontend:$sha"
kubectl create deployment frontend -n library --image="ghcr.io/dein-gh-user/library-frontend:$sha"
kubectl expose deployment frontend -n library --port=80
10 Keycloak-Client & CORS
- Im Keycloak-Admin (
https://auth.seb4u.com, Realmlibrary, Clientlibrary-frontend): Valid redirect URIs (Uniform Resource Identifiers) =https://bibliothek.seb4u.com/*, Web origins =https://bibliothek.seb4u.com. - CORS am api-gateway: pruefen, ob
api-gateway/application.ymlimk8s-Profil die Frontend-Origin erlaubt. Falls localhost-fest – per Env nachziehen (im api-gateway-Overlay):SPRING_CLOUD_GATEWAY_GLOBALCORS_CORSCONFIGURATIONS_[/**]_ALLOWEDORIGINS: "https://bibliothek.seb4u.com".
11 DNS, TLS, End-to-End pruefen
kubectl get svc -n ingress-nginx ingress-nginx-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
Drei A-Records auf diese IP (Internet Protocol): bibliothek., api., auth.seb4u.com.
Liegt seb4u.com als Full-Zone bei Cloudflare (DNS + Registrar –
siehe Domain zu Cloudflare umziehen), legst du die drei A-Records
(bibliothek, api, auth) direkt in der bestehenden Zone
an – „DNS only“ (grau), damit ingress-nginx + cert-manager
das Let’s-Encrypt-Zertifikat holen können. Kein Konto, keine Nameserver.
Proxy (orange) optional erst nach dem ersten erfolgreichen Zertifikat, dann
SSL (Secure Sockets Layer)/TLS-Modus „Full (strict)“.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: biblio
annotations:
cert-manager.io/cluster-issuer: letsencrypt
spec:
ingressClassName: nginx
tls:
- hosts: [ bibliothek.seb4u.com, api.seb4u.com, auth.seb4u.com ]
secretName: biblio-tls
rules:
- host: bibliothek.seb4u.com
http: { paths: [ { path: /, pathType: Prefix, backend: { service: { name: frontend, port: { number: 80 } } } } ] }
- host: api.seb4u.com
http: { paths: [ { path: /, pathType: Prefix, backend: { service: { name: api-gateway, port: { number: 8080 } } } } ] }
- host: auth.seb4u.com
http: { paths: [ { path: /, pathType: Prefix, backend: { service: { name: keycloak, port: { number: 8080 } } } } ] }
https://bibliothek.seb4u.com oeffnen →
Login erika.mustermann / member123 → Katalog laedt → eine
Ausleihe → in Grafana die Metrik sehen. Debug:
kubectl get pods,ingress,certificate -n library,
kubectl describe certificate -n library,
kubectl logs.
12 Backups & Betrieb
- Postgres: ein
CronJob, derpg_dumpall(oder je DBpg_dump) macht und perrclonezu einer Hetzner Storage Box oder Backblaze B2 schiebt – ausserhalb des Clusters (Grundausstattung). - Keycloak-Realm: regelmaessig exportieren (
kcadm.sh get realms/library). - k3s-State:
/var/lib/rancher/k3s/server/db/(SQLite) bzw. etcd-Snapshots sichern. - Skalieren:
autoscaling.enabled: trueist im Overlay gesetzt –kubectl get hpa -n library,kubectl top podsbeobachten. - Update: neuer
$sha→.\scripts\deploy.ps1 -Tag <sha>. Rollback eines Service:helm rollback catalog-service -n library. - Wegwerf-Session: Node-Snapshot (enthaelt Cluster + Images) oder alle
lib-*-Server loeschen und mitdeploy.ps1in ~20 Min neu aufbauen.
+ CI/CD (GitOps)
Der container-build-Job der CI baut aktuell nur. Push + Deploy anhaengen:
push-and-deploy:
needs: [container-build]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: echo "${{ secrets.GHCR_PAT }}" | podman login ghcr.io -u ${{ github.actor }} --password-stdin
- run: |
for m in catalog-service member-service lending-service reservation-service \
fine-service notification-service config-server api-gateway; do
podman build -f library-platform/Dockerfile --build-arg MODULE=$m \
-t ghcr.io/${{ github.repository_owner }}/$m:${{ github.sha }} library-platform
podman push ghcr.io/${{ github.repository_owner }}/$m:${{ github.sha }}
done
- run: echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > kc.yaml
- env: { KUBECONFIG: kc.yaml }
run: |
for s in config-server api-gateway catalog-service member-service lending-service \
reservation-service fine-service notification-service; do
helm upgrade --install "$s" infra/helm/library-service -n library \
-f infra/helm/values/$s-values.yaml -f infra/helm/values/env/hetzner-values.yaml \
--set image.repository=ghcr.io/${{ github.repository_owner }}/$s \
--set image.tag=${{ github.sha }}
done
Zwei Ebenen in einer Datei: GitHub wertet zuerst alles in ${{ }} aus
und schreibt das Ergebnis in ein Shell-Skript – erst dann läuft die Shell.
Die meisten Verständnisfragen hier kommen daher.
| Konstrukt | Stelle | bewirkt |
|---|---|---|
needs: [container-build] |
Kopf | Dieser Job startet erst, wenn container-build erfolgreich war. Ohne die Zeile liefen beide gleichzeitig – GitHub führt Jobs standardmässig parallel aus, Schritte innerhalb eines Jobs dagegen nacheinander. |
if: github.ref == 'refs/heads/main' |
Kopf | Der Job wird nur auf dem Hauptzweig ausgeführt; auf allen anderen übersprungen (und gilt trotzdem als erfolgreich). So bauen und testen Feature-Branches, ohne auszurollen. In if: stehen die ${{ }} ausnahmsweise nicht – dort wird ohnehin ausgewertet. |
runs-on: ubuntu-latest |
Kopf | Ein frischer, leerer Rechner für jeden Lauf. Nichts überlebt zwischen zwei Läufen – deshalb das checkout als ersten Schritt, und deshalb muss die kubeconfig unten jedes Mal neu entstehen. |
uses: actions/checkout@v4 |
Schritt 1 | Holt das Repository auf den Runner. Klingt selbstverständlich, ist es aber nicht: ohne diesen Schritt ist das Arbeitsverzeichnis leer, und podman build fände weder Dockerfile noch Quellcode. Das @v4 ist die Version der Action. |
${{ github.sha }} gegen $m |
überall | Die wichtigste Unterscheidung in dieser Datei. ${{ }} wertet GitHub aus, bevor die Shell startet – im fertigen Skript steht dann der nackte Wert. $m dagegen ist eine Variable der Shell und existiert nur innerhalb der Schleife. Deshalb funktioniert die Mischung in einer Zeile: der Commit-Hash ist da schon eingesetzt, der Modulname wird erst beim Durchlauf ersetzt. Umgekehrt gilt: eine Schleifenvariable in ${{ }} zu schreiben, kann nicht gehen – GitHub kennt sie nicht. |
${{ secrets.GHCR_PAT }} |
Anmeldung | Ein in den Repository-Einstellungen hinterlegter Wert. GitHub setzt ihn ein und ersetzt ihn zugleich in der Protokollausgabe durch Sterne – ein versehentliches echo verrät ihn also nicht. Das gilt nur für Werte aus secrets, nicht für selbst zusammengebaute. |
| podman login --password-stdin |
Anmeldung | Das Token kommt über die Standardeingabe statt als Argument. Argumente sind in der Prozessliste sichtbar – die Standardeingabe nicht. Aus demselben Grund warnt docker login -p. |
--build-arg MODULE=$m… library-platform am Ende |
Build | Ein einziges Dockerfile baut alle acht Dienste; welcher, entscheidet das Build-Argument. Das library-platform ganz am Ende ist kein vergessener Schalter, sondern der Build-Kontext: das Verzeichnis, das an den Bauvorgang übergeben wird. -f zeigt auf das Dockerfile darin. |
run: | |
mehrzeilig | Der senkrechte Strich ist YAML für „alles Folgende wörtlich, mit Zeilenumbrüchen“. Nur so lässt sich eine Schleife schreiben. Der \ am Zeilenende bleibt trotzdem nötig, weil die Shell darin ganz normal Zeile für Zeile liest. |
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > kc.yaml |
Zugang | Die kubeconfig ist mehrzeilig und enthält Zertifikate – als Klartext-Secret wäre sie unhandlich, deshalb liegt sie Base64-kodiert dort und wird hier zurückverwandelt. -d ist entschlüsseln (auf macOS heisst es -D). Base64 ist keine Verschlüsselung – die Datei liegt danach im Klartext auf dem Runner, und wer dort einen beliebigen Schritt einschleusen kann, hat vollen Cluster-Zugang. Deshalb gehört ein solcher Zugang zu einem Dienstkonto mit eng gesetzten Rechten, nicht zum Cluster-Admin. |
env: { KUBECONFIG: kc.yaml } |
Deploy-Schritt | Setzt die Variable nur für diesen Schritt. kubectl und helm lesen sie von sich aus. Ein relativer Pfad genügt, weil jeder Schritt im selben Arbeitsverzeichnis startet. |
-f …/$s-values.yaml-f …/env/hetzner-values.yaml |
helm | Zwei Wertedateien in dieser Reihenfolge – die spätere gewinnt. Die erste beschreibt den Dienst, die zweite die Umgebung. Deshalb genügt für einen zweiten Cluster eine weitere Datei statt acht geänderter. |
helm upgrade --install |
helm | Installiert beim ersten Lauf, aktualisiert bei jedem weiteren – Voraussetzung dafür, dass die Pipeline bei jedem Push erneut laufen darf. helm install allein bräche beim zweiten Mal ab. |
Secrets: GHCR_PAT, KUBECONFIG_B64. GitOps-Alternative:
Argo CD im Cluster, das die 8 Releases aus einem Git-Repo synct –
git push = Deploy. Jenkins (aus shared-infra/) auf
einem eigenen kleinen Server oder als Cluster-Workload.
Statt k3s + deploy.ps1: dasselbe Projekt über die projekteigene
vierstufige Jenkins-Pipeline auf den
OKD-Single-Node
– mit OKD-interner Registry, ServiceAccount-Rechten und allen 17 Stolpersteinen
(Bitnami-Paywall, verschachteltes Podman, config-server-Profil, Probe-Timing …) dokumentiert.
+ Auf 3 Nodes wachsen
Wenn HA noetig ist oder 32 GB nicht mehr reichen. Die Workloads bleiben unveraendert.
- Privates Netz:
hcloud network create, alle Nodes hineinhaengen – Node-zu-Node-Traffic nicht uebers Internet. - Storage zuerst:
local-pathklebt Daten an einen Node. Vor dem Multi-Node Longhorn installieren und als Default-StorageClass – oder die stateful Infra (Postgres, Kafka) pernodeSelectorauflib-node1pinnen. - Weitere Nodes: als k3s-Agents joinen
(
K3S_URL/K3S_TOKEN, siehe k3s-Guide „Mehrere Nodes“). - HA-Control-Plane: 3 Server-Nodes mit
--cluster-init(embedded etcd) statt 1 Server + 2 Agents – dann darf ein Node ausfallen, ohne dass die API weg ist. Davor ein Hetzner Load Balancer auf Port 6443 der 3 Server.
+ Kosten
Preise sind Richtwerte. Hetzner hat 2026 zweimal erhöht (zuletzt 15. Juni: CX/CAX +30–40 %, CPX/CCX über 100 %). Aktuelle Server-Preise, alle Nebenposten, ein Rechner und Beispielrechnungen stehen zentral in Kosten & Budget.
| Setup | Server | + Extras | ca. €/Tag | ca. €/Monat |
|---|---|---|---|---|
| 1 Node | CPX42 (8/16) + IPv4 (Internet Protocol Version 4), oder CAX31 ARM (Advanced RISC Machines) ~21 falls frei | Domain + Storage Box (~4) | ~2,3 | ~70 |
| 1 Node, Infra managed | CPX42 (8/16) | + Neon Postgres + Grafana Cloud (Free) | ~2,3 | ~70 |
| 3 Nodes HA | 3×CPX42 (8/16) | + Load Balancer + IPv4×3 + Backup | ~7,3 | ~221 |
Managed-Infra (Neon Postgres, Grafana Cloud, CloudAMQP, Confluent) verschiebt RAM-Last
aus dem Cluster – damit reicht oft ein CPX42 als 1 Node. Details im
Microservices-Stack-Guide, Abschnitt Managed-Infra.