refactor(k8s): minimale, flache Manifeste zum Kopieren

Kustomize-Struktur (base/overlays) entfernt. Stattdessen nummerierte
Einzeldateien, ausrollbar mit "kubectl apply -f k8s/":
00-namespace, 01-config, 02-storage (SMB-PVs/PVCs Postgres + Medien),
03-postgres (StatefulSet + Service), 04-app (Deployment mit
Migrations-initContainer + Service), 05-ingress, 06-worker,
07-networkpolicy. Secrets als secrets.yaml.example (echte secrets.yaml
ist gitignored). Images mit Tag latest aus der Gitea-Pipeline.
Kurzanleitung in k8s/README.md, Betriebsdoku angepasst.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 01:12:26 +02:00
1 parent c84eb1e740
commit 91791b791f
20 files changed
+332 -494

No files matched your search

+3
View File
@@ -23,3 +23,6 @@ npm-debug.log*
# Laufzeit-Cache des Design-Hooks (impeccable) # Laufzeit-Cache des Design-Hooks (impeccable)
.impeccable/hook.cache.json .impeccable/hook.cache.json
# Echte Kubernetes-Secrets (Vorlage: k8s/secrets.yaml.example)
k8s/secrets.yaml
+12 -28
View File
@@ -11,13 +11,13 @@ Einmalig einrichten:
- ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets) - ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets)
- einen Runner mit dem Label `docker-builder` bereitstellen (wie bei Wabenhain) - einen Runner mit dem Label `docker-builder` bereitstellen (wie bei Wabenhain)
Ein Release entsteht dann so: `git tag v0.1.0 && git push origin v0.1.0`, anschließend den Tag im Overlay setzen. Die Manifeste in `k8s/` nutzen den Tag `latest`. Für feste Versionen dort `:latest` durch z. B. `:0.1.0` ersetzen (`git tag v0.1.0 && git push origin v0.1.0`).
**Manuell:** **Manuell:**
```bash ```bash
docker build --target runner -t git.encry.de/nick.rosenloecher/artista-modelcamp:0.1.0 . docker build --target runner -t git.encry.de/nick.rosenloecher/artista-modelcamp:latest .
docker build --target tools -t git.encry.de/nick.rosenloecher/artista-modelcamp-tools:0.1.0 . docker build --target tools -t git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest .
docker push … # beide Images, immer mit identischem Tag docker push … # beide Images, immer mit identischem Tag
``` ```
@@ -32,39 +32,23 @@ Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber
Eine eigene Sicherung im Cluster gibt es nicht, die SMB-Freigabe wird auf dem NAS gesichert. Eine eigene Sicherung im Cluster gibt es nicht, die SMB-Freigabe wird auf dem NAS gesichert.
1. **Namespace:** `kubectl create namespace artista` und `kubectl label namespace artista pod-security.kubernetes.io/enforce=restricted` **Schritt für Schritt zum Kopieren: [k8s/README.md](../k8s/README.md).** Die Manifeste liegen flach in `k8s/` (`00-…` bis `07-…`) und werden mit `kubectl apply -f k8s/` ausgerollt. Die Secrets kommen aus `k8s/secrets.yaml.example` und werden nicht eingecheckt.
2. **Overlay anpassen** (`k8s/overlays/production/`): Image-Tag, Domain in `kustomization.yaml` (APP_URL) und `ingress.yaml`, `ingressClassName` (`kubectl get ingressclass`), ClusterIssuer (Beispiel `letsencrypt-prod`) und TLS-Secret-Name. Die HTTP→HTTPS-Umleitung erwartet das Manifest in Traefik selbst.
3. **SMB-Speicher** (`k8s/overlays/production/smb-volumes.yaml`):
1. csi-driver-smb installieren, Befehl steht im Kopf der Datei.
2. Auf dem NAS die Ordner `modelcamp-postgres` (leer) und `modelcamp-media` anlegen und `source` in der Datei anpassen.
3. SMB-Secret anlegen: `kubectl -n artista create secret generic artista-modelcamp-smb --from-literal=username='…' --from-literal=password='…'`. Empfohlen ist ein eigener NAS-Benutzer, der nur diese Ordner sieht.
4. Das NAS muss SMB 3.1.1 mit Verschlüsselung erlauben (`seal`), z. B. Synology unter „SMB-Verschlüsselung für Übertragung“, Windows mit `Set-SmbShare -EncryptData $true`.
Die Rechte setzen die `mountOptions`, auf SMB wirkt `fsGroup` nicht: Postgres `uid=70`, `0700`/`0600`; Medien `uid=1001`, für die App nur lesbar (`0550`/`0440`). Zu PostgreSQL:
- **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden.
**PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden. Gemildert wird das durch `hard`, `cache=strict`, Verschlüsselung und ein 120 Sekunden langes, sauberes Herunterfahren. Die NAS-Sicherung ist nur mit **atomaren Snapshots** der Freigabe (Btrfs/ZFS) konsistent. Ein Dateikopie-Backup eines laufenden Datenverzeichnisses lässt sich nicht zuverlässig wiederherstellen. Zusätzlicher Schutz bei Bedarf: gelegentlich einen `pg_dump` ziehen (Abschnitt 7). - **Gemildert** wird das durch `hard`, `cache=strict`, SMB-Verschlüsselung (`seal`) und ein 120 Sekunden langes, sauberes Herunterfahren. Die Rechte setzen die `mountOptions`, weil `fsGroup` auf SMB nicht wirkt: Postgres `uid=70`, `0700`; Medien `uid=1001`, nur lesbar.
4. **Secret anlegen:** Befehl steht in `k8s/secret.example.yaml`, besser über Sealed Secrets oder den External Secrets Operator. - **Sicherung:** Die NAS-Sicherung ist für die laufende Datenbank nur mit **atomaren Snapshots** der Freigabe (Btrfs/ZFS) konsistent. Zusätzlicher Schutz bei Bedarf: gelegentlich einen `pg_dump` ziehen (Abschnitt 7).
5. **Anwenden:** `kubectl apply -k k8s/overlays/production`. Der initContainer migriert die Datenbank vor dem App-Start.
6. **Erstes Admin-Konto:**
```bash
kubectl -n artista run admin-create --rm -it --restart=Never --labels=artista/db-client=true \
--image=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:0.1.0 \
--overrides='{"spec":{"containers":[{"name":"admin-create","image":"git.encry.de/nick.rosenloecher/artista-modelcamp-tools:0.1.0","command":["tsx","scripts/create-admin.ts","--email","[email protected]","--name","Dein Name"],"envFrom":[{"configMapRef":{"name":"artista-modelcamp-config"}},{"secretRef":{"name":"artista-modelcamp-secrets"}}]}]}}'
```
Das einmalige Passwort wird ausgegeben. Bei der ersten Anmeldung unter `/admin` wird die 2FA eingerichtet.
## 3. Update ausrollen ## 3. Update ausrollen
```bash ```bash
# Neuen Tag im Overlay setzen und anwenden. Jeder neue App-Pod migriert per kubectl -n artista rollout restart deploy/artista-modelcamp-app
# initContainer "migrate" zuerst die Datenbank; scheitert das, startet er nicht
# und die alten Pods bleiben aktiv (maxUnavailable: 0).
kubectl apply -k k8s/overlays/production
kubectl -n artista rollout status deploy/artista-modelcamp-app kubectl -n artista rollout status deploy/artista-modelcamp-app
# Migrationsausgabe bei Bedarf: kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migrationsausgabe
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate
``` ```
Jeder neue App-Pod migriert per initContainer zuerst die Datenbank. Scheitert das, startet der Pod nicht, und die alten Pods laufen weiter.
Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können. Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können.
## 4. Stripe ## 4. Stripe
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: artista
labels:
pod-security.kubernetes.io/enforce: restricted
+15
View File
@@ -0,0 +1,15 @@
# Nicht geheime Einstellungen. ANPASSEN: APP_URL, MAIL_FROM, ADMIN_NOTIFY_EMAIL.
apiVersion: v1
kind: ConfigMap
metadata:
name: artista-modelcamp-config
namespace: artista
data:
APP_ENV: "production"
APP_URL: "https://modelcamp.example.org"
MAIL_TRANSPORT: "smtp"
MAIL_FROM: "ARTISTA MODELCAMP <[email protected]>"
ADMIN_NOTIFY_EMAIL: "[email protected]"
SMTP_PORT: "587"
SMTP_SECURE: "false"
MEDIA_DIR: "/app/media"
+74
View File
@@ -0,0 +1,74 @@
# Speicher auf der SMB-Freigabe (benoetigt csi-driver-smb, siehe README).
# ANPASSEN: "source" (//<nas>/<freigabe>/<ordner>). Ordner vorher anlegen.
#
# PostgreSQL auf SMB ist eine bewusste Entscheidung (Risiko siehe
# docs/BETRIEB.md). NAS-Backups sind fuer die laufende Datenbank nur mit
# atomaren Snapshots (Btrfs/ZFS) konsistent.
# --- PostgreSQL-Daten ---
apiVersion: v1
kind: PersistentVolume
metadata:
name: artista-modelcamp-postgres
spec:
capacity:
storage: 10Gi
accessModes: ["ReadWriteOnce"]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
mountOptions: [vers=3.1.1, seal, hard, cache=strict, uid=70, gid=70, dir_mode=0700, file_mode=0600, mfsymlinks, nobrl]
csi:
driver: smb.csi.k8s.io
volumeHandle: artista-modelcamp-postgres
volumeAttributes:
source: //nas.example.lan/artista/modelcamp-postgres
nodeStageSecretRef:
name: artista-modelcamp-smb
namespace: artista
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: artista-modelcamp-postgres
namespace: artista
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: ""
volumeName: artista-modelcamp-postgres
resources:
requests:
storage: 10Gi
---
# --- Medien (Bilder/Videos; App liest nur, Dateien direkt aufs NAS legen) ---
apiVersion: v1
kind: PersistentVolume
metadata:
name: artista-modelcamp-media
spec:
capacity:
storage: 5Gi
accessModes: ["ReadWriteMany"]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
mountOptions: [vers=3.1.1, seal, uid=1001, gid=1001, dir_mode=0550, file_mode=0440, mfsymlinks, nobrl]
csi:
driver: smb.csi.k8s.io
volumeHandle: artista-modelcamp-media
volumeAttributes:
source: //nas.example.lan/artista/modelcamp-media
nodeStageSecretRef:
name: artista-modelcamp-smb
namespace: artista
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: artista-modelcamp-media
namespace: artista
spec:
accessModes: ["ReadWriteMany"]
storageClassName: ""
volumeName: artista-modelcamp-media
resources:
requests:
storage: 5Gi
@@ -1,30 +1,20 @@
# PostgreSQL fuer Buchungen, Zahlungen, Gutscheine und Protokoll.
#
# Datenverzeichnis: PVC "artista-modelcamp-postgres-data" (storage.yaml), im
# Produktions-Overlay auf SMB. Das ist eine BEWUSSTE, vom Betreiber
# akzeptierte Ausnahme von der Empfehlung "Blockspeicher": SMB garantiert
# Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Die
# Mount-Optionen in smb-volumes.yaml sind darauf ausgelegt; Risiken und
# Hinweise zur Sicherung stehen in docs/BETRIEB.md.
apiVersion: apps/v1 apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
name: artista-modelcamp-postgres name: artista-modelcamp-postgres
namespace: artista
spec: spec:
serviceName: artista-modelcamp-postgres serviceName: artista-modelcamp-postgres
replicas: 1 replicas: 1
selector: selector:
matchLabels: matchLabels:
app.kubernetes.io/name: artista-modelcamp-postgres app: artista-modelcamp-postgres
template: template:
metadata: metadata:
labels: labels:
app.kubernetes.io/name: artista-modelcamp-postgres app: artista-modelcamp-postgres
spec: spec:
automountServiceAccountToken: false terminationGracePeriodSeconds: 120 # sauber herunterfahren vor dem Aushaengen
# Beim Beenden sauber herunterfahren (Checkpoint), bevor das Volume
# ausgehaengt wird — auf Netzwerkspeicher besonders wichtig.
terminationGracePeriodSeconds: 120
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 70 runAsUser: 70
@@ -44,17 +34,11 @@ spec:
valueFrom: valueFrom:
secretKeyRef: { name: artista-modelcamp-secrets, key: DB_PASSWORD } secretKeyRef: { name: artista-modelcamp-secrets, key: DB_PASSWORD }
volumeMounts: volumeMounts:
# Ab PostgreSQL 18 liegt PGDATA unter /var/lib/postgresql/18/docker.
- name: data - name: data
mountPath: /var/lib/postgresql mountPath: /var/lib/postgresql
readinessProbe: readinessProbe:
exec: { command: ["pg_isready", "-U", "artista"] } exec: { command: ["pg_isready", "-U", "artista"] }
periodSeconds: 5 periodSeconds: 5
livenessProbe:
exec: { command: ["pg_isready", "-U", "artista"] }
initialDelaySeconds: 60
periodSeconds: 20
failureThreshold: 6
resources: resources:
requests: { cpu: 100m, memory: 256Mi } requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 1Gi } limits: { memory: 1Gi }
@@ -63,15 +47,16 @@ spec:
capabilities: { drop: ["ALL"] } capabilities: { drop: ["ALL"] }
volumes: volumes:
- name: data - name: data
persistentVolumeClaim: { claimName: artista-modelcamp-postgres-data } persistentVolumeClaim: { claimName: artista-modelcamp-postgres }
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: artista-modelcamp-postgres name: artista-modelcamp-postgres
namespace: artista
spec: spec:
clusterIP: None clusterIP: None
selector: selector:
app.kubernetes.io/name: artista-modelcamp-postgres app: artista-modelcamp-postgres
ports: ports:
- port: 5432 - port: 5432
+85
View File
@@ -0,0 +1,85 @@
# Website. Der initContainer migriert vor jedem Start die Datenbank.
# Update nach neuem Image: kubectl -n artista rollout restart deploy/artista-modelcamp-app
apiVersion: apps/v1
kind: Deployment
metadata:
name: artista-modelcamp-app
namespace: artista
spec:
replicas: 2
selector:
matchLabels:
app: artista-modelcamp-app
template:
metadata:
labels:
app: artista-modelcamp-app
db-client: "true"
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
seccompProfile: { type: RuntimeDefault }
initContainers:
- name: migrate
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest
imagePullPolicy: Always
command: ["prisma", "migrate", "deploy"]
envFrom:
- secretRef: { name: artista-modelcamp-secrets }
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
containers:
- name: app
image: git.encry.de/nick.rosenloecher/artista-modelcamp:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3000
envFrom:
- configMapRef: { name: artista-modelcamp-config }
- secretRef: { name: artista-modelcamp-secrets }
readinessProbe:
httpGet: { path: /api/health/ready, port: http }
periodSeconds: 10
livenessProbe:
httpGet: { path: /api/health, port: http }
initialDelaySeconds: 20
periodSeconds: 20
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { memory: 768Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- name: media
mountPath: /app/media
readOnly: true
- name: cache
mountPath: /app/.next/cache
- name: tmp
mountPath: /tmp
volumes:
- name: media
persistentVolumeClaim: { claimName: artista-modelcamp-media }
- name: cache
emptyDir: {}
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: artista-modelcamp-app
namespace: artista
spec:
selector:
app: artista-modelcamp-app
ports:
- name: http
port: 80
targetPort: http
+24
View File
@@ -0,0 +1,24 @@
# ANPASSEN: Domain (2x, auch APP_URL in 01-config.yaml), ClusterIssuer.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: artista-modelcamp
namespace: artista
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
tls:
- hosts: [modelcamp.example.org]
secretName: artista-modelcamp-tls
rules:
- host: modelcamp.example.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: artista-modelcamp-app
port:
name: http
+6 -15
View File
@@ -1,43 +1,34 @@
# Worker: gibt abgelaufene Reservierungen frei (nach Rueckfrage bei Stripe), # Alle 10 Minuten: faellige Raten einziehen, abgelaufene Reservierungen
# zieht faellige Raten ein, trennt Zahlungsmethoden nach der letzten Rate und # freigeben, E-Mails nachsenden. Ohne ihn funktioniert Ratenzahlung nicht.
# versendet den E-Mail-Ausgang. Ohne ihn laufen Ratenzahlung und
# Reservierungsfreigabe nicht. Idempotent; Forbid verhindert Ueberlappung.
apiVersion: batch/v1 apiVersion: batch/v1
kind: CronJob kind: CronJob
metadata: metadata:
name: artista-modelcamp-worker name: artista-modelcamp-worker
namespace: artista
spec: spec:
schedule: "*/10 * * * *" schedule: "*/10 * * * *"
timeZone: "Europe/Berlin"
concurrencyPolicy: Forbid concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate: jobTemplate:
spec: spec:
backoffLimit: 1 backoffLimit: 1
activeDeadlineSeconds: 900
template: template:
metadata: metadata:
labels: labels:
artista/db-client: "true" db-client: "true"
spec: spec:
restartPolicy: Never restartPolicy: Never
automountServiceAccountToken: false
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1001 runAsUser: 1001
seccompProfile: { type: RuntimeDefault } seccompProfile: { type: RuntimeDefault }
containers: containers:
- name: worker - name: worker
image: artista-modelcamp-tools image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest
imagePullPolicy: Always
command: ["tsx", "scripts/worker.ts"] command: ["tsx", "scripts/worker.ts"]
envFrom: envFrom:
- configMapRef: { name: artista-modelcamp-config } - configMapRef: { name: artista-modelcamp-config }
- secretRef: { name: artista-modelcamp-secrets } - secretRef: { name: artista-modelcamp-secrets }
resources:
requests: { cpu: 50m, memory: 192Mi }
limits: { memory: 512Mi }
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] } capabilities: { drop: ["ALL"] }
+35
View File
@@ -0,0 +1,35 @@
# Datenbank nur fuer App und Worker erreichbar; App nur ueber Traefik.
# ANPASSEN: Namespace deines Traefik ("kubectl get pods -A | grep traefik").
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: artista-modelcamp-postgres
namespace: artista
spec:
podSelector:
matchLabels:
app: artista-modelcamp-postgres
ingress:
- from:
- podSelector:
matchLabels:
db-client: "true"
ports:
- port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: artista-modelcamp-app
namespace: artista
spec:
podSelector:
matchLabels:
app: artista-modelcamp-app
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik
ports:
- port: 3000
+37
View File
@@ -0,0 +1,37 @@
# Kubernetes (RKE2, Traefik, cert-manager)
## Einmalig
1. **csi-driver-smb installieren:**
```bash
helm repo add csi-driver-smb https://raw.githubusercontent.com/kubernetes-csi/csi-driver-smb/master/charts
helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system
```
2. **Auf dem NAS** die Ordner `artista/modelcamp-postgres` (leer) und `artista/modelcamp-media` anlegen und SMB-Verschlüsselung erlauben.
3. **Anpassen:**
- `nas.example.lan` in `02-storage.yaml`
- `modelcamp.example.org` in `01-config.yaml` und `05-ingress.yaml`
- den Traefik-Namespace in `07-networkpolicy.yaml`
4. **Secrets:** `cp k8s/secrets.yaml.example k8s/secrets.yaml`, die Werte eintragen.
5. **Ausrollen:**
```bash
kubectl apply -f k8s/00-namespace.yaml
kubectl apply -f k8s/secrets.yaml
kubectl apply -f k8s/
```
6. **Admin anlegen** (E-Mail und Name ersetzen; das einmalige Passwort wird ausgegeben, die 2FA richtest du bei der ersten Anmeldung unter `/admin` ein):
```bash
kubectl -n artista run admin-create --rm -i --restart=Never \
--image=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest --labels=db-client=true \
--overrides='{"spec":{"securityContext":{"runAsNonRoot":true,"runAsUser":1001,"seccompProfile":{"type":"RuntimeDefault"}},"containers":[{"name":"admin-create","image":"git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest","command":["tsx","scripts/create-admin.ts","--email","[email protected]","--name","Dein Name"],"envFrom":[{"configMapRef":{"name":"artista-modelcamp-config"}},{"secretRef":{"name":"artista-modelcamp-secrets"}}],"securityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]}}}]}}'
```
## Update
Bei jedem Push auf `main` baut die Gitea-Pipeline die Images mit dem Tag `latest`. Danach:
```bash
kubectl -n artista rollout restart deploy/artista-modelcamp-app
```
Der initContainer migriert die Datenbank automatisch.
-133
View File
@@ -1,133 +0,0 @@
# Next.js-Anwendung. Zustandslos: Buchungen, Kapazitaet, Gutscheine,
# Reservierungen, Rate Limits, E-Mail-Ausgang und Zahlungsjobs liegen in
# PostgreSQL — beliebig viele Replikate moeglich.
#
# Migrationen laufen als eigener Schritt VOR dem App-Container: initContainer
# "migrate" (Werkzeug-Image, `prisma migrate deploy`). Scheitert er, startet
# die App nicht. Mehrere Replikate gleichzeitig sind unkritisch — Prisma
# serialisiert Migrationen ueber eine Datenbank-Sperre.
apiVersion: apps/v1
kind: Deployment
metadata:
name: artista-modelcamp-app
spec:
replicas: 2
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
selector:
matchLabels:
app.kubernetes.io/name: artista-modelcamp-app
template:
metadata:
labels:
app.kubernetes.io/name: artista-modelcamp-app
artista/db-client: "true"
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile: { type: RuntimeDefault }
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: artista-modelcamp-app
initContainers:
- name: migrate
image: artista-modelcamp-tools
command: ["prisma", "migrate", "deploy"]
envFrom:
- secretRef: { name: artista-modelcamp-secrets }
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
containers:
- name: app
image: artista-modelcamp
ports:
- name: http
containerPort: 3000
envFrom:
- configMapRef: { name: artista-modelcamp-config }
- secretRef: { name: artista-modelcamp-secrets }
readinessProbe:
httpGet: { path: /api/health/ready, port: http }
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
# Bewusst ohne Datenbank: eine DB-Stoerung soll nicht zu
# Neustartschleifen fuehren.
httpGet: { path: /api/health, port: http }
initialDelaySeconds: 20
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { memory: 768Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- name: media
mountPath: /app/media
readOnly: true
# Reiner Zwischenspeicher fuer optimierte Bilder: fluechtig ok.
- name: next-cache
mountPath: /app/.next/cache
- name: tmp
mountPath: /tmp
volumes:
- name: media
persistentVolumeClaim: { claimName: artista-modelcamp-media }
- name: next-cache
emptyDir: { sizeLimit: 512Mi }
- name: tmp
emptyDir: { sizeLimit: 64Mi }
---
apiVersion: v1
kind: Service
metadata:
name: artista-modelcamp-app
spec:
selector:
app.kubernetes.io/name: artista-modelcamp-app
ports:
- name: http
port: 80
targetPort: http
---
# Die App nimmt Verbindungen nur vom Ingress-Controller an. Ohne diese Regel
# koennte jeder Pod im Cluster den Service direkt ansprechen und per
# gefaelschtem X-Real-Ip-Header die Rate Limits (inkl. Admin-Login/2FA)
# umgehen. Namespace des Traefik-Controllers anpassen
# ("kubectl get pods -A | grep traefik").
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: artista-modelcamp-app-ingress
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: artista-modelcamp-app
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik
ports:
- port: 3000
# Kubelet-Probes kommen vom Knoten und sind von NetworkPolicies je nach
# CNI (Canal/Calico in RKE2) ausgenommen.
-39
View File
@@ -1,39 +0,0 @@
# Der Namespace "artista" wird einmalig von Hand angelegt:
# kubectl create namespace artista
# kubectl label namespace artista pod-security.kubernetes.io/enforce=restricted
#
# Nicht geheime Einstellungen. Geheimnisse: k8s/secret.example.yaml.
apiVersion: v1
kind: ConfigMap
metadata:
name: artista-modelcamp-config
data:
APP_ENV: "production"
APP_URL: "https://modelcamp.example.org"
DATABASE_POOL_SIZE: "10"
MAIL_TRANSPORT: "smtp"
MAIL_FROM: "ARTISTA MODELCAMP <[email protected]>"
ADMIN_NOTIFY_EMAIL: ""
SMTP_PORT: "587"
SMTP_SECURE: "false"
MEDIA_DIR: "/app/media"
EXTERNAL_VIDEO_PROVIDER: "none"
---
# Die Datenbank ist nur fuer Pods mit dem Label artista/db-client erreichbar
# (App inkl. Migrations-initContainer, Worker).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: artista-modelcamp-postgres-access
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: artista-modelcamp-postgres
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchLabels:
artista/db-client: "true"
ports:
- port: 5432
-15
View File
@@ -1,15 +0,0 @@
# Basis fuer RKE2. Nicht direkt anwenden — ueber ein Overlay
# (k8s/overlays/production), das Domain, Images und TLS setzt.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: artista
resources:
- config.yaml
- storage.yaml
- postgres.yaml
- app.yaml
- worker.yaml
labels:
- pairs:
app.kubernetes.io/part-of: artista-modelcamp
includeSelectors: false
-27
View File
@@ -1,27 +0,0 @@
# Die beiden dauerhaften Speicher. Im Produktions-Overlay werden sie fest an
# die SMB-PersistentVolumes gebunden (k8s/overlays/production/smb-volumes.yaml).
# Eine separate Sicherung gibt es nicht: die SMB-Freigabe wird auf dem NAS
# selbst mehrfach gesichert.
# PostgreSQL-Datenverzeichnis.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: artista-modelcamp-postgres-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
---
# Freigegebene Bilder und Videos. ReadWriteMany, weil mehrere App-Replikate
# lesen.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: artista-modelcamp-media
spec:
accessModes: ["ReadWriteMany"]
resources:
requests:
storage: 5Gi
-34
View File
@@ -1,34 +0,0 @@
# Ingress fuer Traefik mit cert-manager.
# Anpassen: Host (auch APP_URL in kustomization.yaml), ingressClassName
# ("kubectl get ingressclass"), ClusterIssuer, TLS-Secret-Name.
# HTTP->HTTPS-Umleitung erwartet dieses Manifest EntryPoint-weit in Traefik
# (Helm: ports.web.redirections.entryPoint.to=websecure).
#
# X-Forwarded-For/X-Real-Ip: Rate Limits verlassen sich darauf, dass Traefik
# vom Client mitgeschickte Werte verwirft. Dafuer im Traefik-EntryPoint
# forwardedHeaders.trustedIPs nur auf echte vorgelagerte Proxys setzen.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: artista-modelcamp
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- modelcamp.example.org
secretName: artista-modelcamp-tls
rules:
- host: modelcamp.example.org
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: artista-modelcamp-app
port:
name: http
@@ -1,47 +0,0 @@
# Produktions-Overlay. Anpassen: Images/Tags, Domain, IngressClass,
# ClusterIssuer und TLS-Secret-Name. Anwenden:
# kubectl apply -k k8s/overlays/production
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: artista
resources:
- ../../base
- ingress.yaml
# PostgreSQL-Daten und Medien auf SMB.
# Ohne SMB: diese Zeile und die beiden PVC-Patches unten entfernen.
- smb-volumes.yaml
images:
# Gebaut von .gitea/workflows/docker-build.yml: Git-Tag "v0.1.0" pushen
# ergibt Image-Tag "0.1.0" (Alternativen: "sha-<kurz>", "latest").
# Beide Images immer mit identischem Tag ausrollen.
- name: artista-modelcamp
newName: git.encry.de/nick.rosenloecher/artista-modelcamp
newTag: "0.1.0"
- name: artista-modelcamp-tools
newName: git.encry.de/nick.rosenloecher/artista-modelcamp-tools
newTag: "0.1.0"
patches:
# PVCs fest an die SMB-PersistentVolumes binden (statisch, ohne StorageClass).
- target: { kind: PersistentVolumeClaim, name: artista-modelcamp-media }
patch: |-
- op: add
path: /spec/storageClassName
value: ""
- op: add
path: /spec/volumeName
value: artista-modelcamp-media-smb
- target: { kind: PersistentVolumeClaim, name: artista-modelcamp-postgres-data }
patch: |-
- op: add
path: /spec/storageClassName
value: ""
- op: add
path: /spec/volumeName
value: artista-modelcamp-postgres-smb
- target: { kind: ConfigMap, name: artista-modelcamp-config }
patch: |-
- op: replace
path: /data/APP_URL
value: https://modelcamp.example.org
-89
View File
@@ -1,89 +0,0 @@
# PersistentVolumes auf einer SMB-Freigabe (csi-driver-smb, smb.csi.k8s.io).
#
# VORAUSSETZUNG: csi-driver-smb im Cluster installiert, z. B.:
# helm repo add csi-driver-smb https://raw.githubusercontent.com/kubernetes-csi/csi-driver-smb/master/charts
# helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system
# und das Zugangs-Secret "artista-modelcamp-smb" (k8s/secret.example.yaml).
#
# ANZUPASSEN: "source" (//<nas>/<freigabe>/<ordner>) je Volume. Die Ordner
# muessen auf der Freigabe bereits existieren; der Postgres-Ordner leer.
#
# Beide Verbindungen verschluesselt (SMB 3.1.1 + seal); das NAS muss
# SMB-Verschluesselung erlauben. fsGroup wirkt auf SMB nicht: Besitz und
# Rechte setzen uid/gid/dir_mode/file_mode. reclaimPolicy Retain: Loeschen
# von PVC/PV laesst die Dateien auf dem NAS.
# Keine eigene Sicherung im Cluster: die Freigabe wird auf dem NAS gesichert.
# --- PostgreSQL-Datenverzeichnis ------------------------------------------------
# BEWUSSTE AUSNAHME (Entscheidung des Betreibers): Datenbank auf SMB.
# Risiko: SMB garantiert Sperren/fsync nicht in jedem Fall wie ein lokales
# Dateisystem; bei Netzwerk- oder NAS-Ausfall waehrend Schreibvorgaengen kann
# die Datenbank beschaedigt werden. Die Optionen unten minimieren das:
# hard E/A wartet bei Netzstoerung statt Fehler an PostgreSQL zu
# melden (kein "soft"-Abbruch mitten im Schreiben)
# cache=strict strikte Cache-Kohaerenz, fsync geht bis zum Server
# nobrl keine Byte-Range-Locks (PostgreSQL braucht sie nicht)
# uid/gid 70, 0700/0600 PostgreSQL verlangt ein privates Datenverzeichnis
# Sicherung: nur NAS-Snapshots, die atomar sind (Btrfs/ZFS-Snapshot der ganzen
# Freigabe), sind fuer ein laufendes Datenverzeichnis konsistent. Einfaches
# Dateikopieren waehrend des Betriebs ergibt KEINE brauchbare Sicherung.
apiVersion: v1
kind: PersistentVolume
metadata:
name: artista-modelcamp-postgres-smb
spec:
capacity:
storage: 10Gi
accessModes: ["ReadWriteOnce"]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
mountOptions:
- vers=3.1.1
- seal
- hard
- cache=strict
- uid=70
- gid=70
- dir_mode=0700
- file_mode=0600
- mfsymlinks
- nobrl
csi:
driver: smb.csi.k8s.io
volumeHandle: artista-modelcamp-postgres-smb
volumeAttributes:
source: //nas.example.lan/artista/modelcamp-postgres
nodeStageSecretRef:
name: artista-modelcamp-smb
namespace: artista
---
# --- Medien (Bilder/Videos) ------------------------------------------------------
# Die App liest nur. Neue Bilder legst du direkt ueber die SMB-Freigabe ab,
# z. B. vom Windows-Explorer aus.
apiVersion: v1
kind: PersistentVolume
metadata:
name: artista-modelcamp-media-smb
spec:
capacity:
storage: 5Gi
accessModes: ["ReadWriteMany"]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
mountOptions:
- vers=3.1.1
- seal
- uid=1001 # Containerbenutzer "nextjs" (Dockerfile)
- gid=1001
- dir_mode=0550 # App liest nur; Schreiben ausschliesslich ueber das NAS
- file_mode=0440
- mfsymlinks
- nobrl
csi:
driver: smb.csi.k8s.io
volumeHandle: artista-modelcamp-media-smb
volumeAttributes:
source: //nas.example.lan/artista/modelcamp-media
nodeStageSecretRef:
name: artista-modelcamp-smb
namespace: artista
-45
View File
@@ -1,45 +0,0 @@
# VORLAGE — nicht mit echten Werten ins Repository einchecken.
# Anlegen z. B. per:
# kubectl -n artista create secret generic artista-modelcamp-secrets \
# --from-literal=DB_PASSWORD='…' \
# --from-literal=DATABASE_URL='postgresql://artista:…@artista-modelcamp-postgres:5432/artista' \
# --from-literal=APP_SECRET="$(openssl rand -base64 48)" \
# --from-literal=BETTER_AUTH_SECRET="$(openssl rand -base64 48)" \
# --from-literal=STRIPE_SECRET_KEY='sk_test_…' \
# --from-literal=STRIPE_WEBHOOK_SECRET='whsec_…' \
# --from-literal=SMTP_HOST='…' --from-literal=SMTP_USER='…' --from-literal=SMTP_PASSWORD='…'
# Besser noch: Sealed Secrets oder External Secrets Operator.
apiVersion: v1
kind: Secret
metadata:
name: artista-modelcamp-secrets
namespace: artista
type: Opaque
stringData:
DB_PASSWORD: "<starkes-passwort>"
# Passwort URL-kodiert einsetzen, falls Sonderzeichen enthalten sind.
DATABASE_URL: "postgresql://artista:<starkes-passwort>@artista-modelcamp-postgres:5432/artista"
APP_SECRET: "<mind-32-zeichen-zufall>"
BETTER_AUTH_SECRET: "<anderer-mind-32-zeichen-zufall>"
STRIPE_SECRET_KEY: "<sk_test_… oder sk_live_…>"
STRIPE_WEBHOOK_SECRET: "<whsec_…>"
SMTP_HOST: "<smtp.example.org>"
SMTP_USER: "<benutzer>"
SMTP_PASSWORD: "<passwort>"
---
# Zugang zur SMB-Freigabe fuer die PersistentVolumes
# (k8s/overlays/production/smb-volumes.yaml). Eigener, auf die Artista-
# Ordner beschraenkter NAS-Benutzer empfohlen.
# kubectl -n artista create secret generic artista-modelcamp-smb \
# --from-literal=username='…' --from-literal=password='…'
apiVersion: v1
kind: Secret
metadata:
name: artista-modelcamp-smb
namespace: artista
type: Opaque
stringData:
username: "<nas-benutzer>"
password: "<nas-passwort>"
# Optional bei Domaenenkonten:
# domain: "<domaene>"
+28
View File
@@ -0,0 +1,28 @@
# Kopieren nach k8s/secrets.yaml (ist in .gitignore), Werte eintragen,
# dann: kubectl apply -f k8s/secrets.yaml
# Zufallswerte: openssl rand -base64 48
apiVersion: v1
kind: Secret
metadata:
name: artista-modelcamp-secrets
namespace: artista
stringData:
DB_PASSWORD: "PASSWORT"
DATABASE_URL: "postgresql://artista:PASSWORT@artista-modelcamp-postgres:5432/artista"
APP_SECRET: "ZUFALL-MIND-32-ZEICHEN"
BETTER_AUTH_SECRET: "ANDERER-ZUFALL-MIND-32-ZEICHEN"
STRIPE_SECRET_KEY: "sk_test_..."
STRIPE_WEBHOOK_SECRET: "whsec_..."
SMTP_HOST: "smtp.example.org"
SMTP_USER: "benutzer"
SMTP_PASSWORD: "passwort"
---
# Zugang zur SMB-Freigabe (fuer 02-storage.yaml)
apiVersion: v1
kind: Secret
metadata:
name: artista-modelcamp-smb
namespace: artista
stringData:
username: "NAS-BENUTZER"
password: "NAS-PASSWORT"