Helm Charts¶
Das Kamerplanter Helm-Chart basiert auf der bjw-s common library und definiert alle Kubernetes-Ressourcen in einem einzigen Chart. Container-Images und das Chart selbst werden als OCI-Artefakte über die GitHub Container Registry bereitgestellt.
Registry-Übersicht¶
| Artefakt | OCI-URL |
|---|---|
| Helm-Chart | oci://ghcr.io/nolte/charts/kamerplanter |
| Backend-Image | ghcr.io/nolte/kamerplanter-backend |
| Frontend-Image | ghcr.io/nolte/kamerplanter-frontend |
Chart-Informationen¶
name: kamerplanter
type: application
version: 0.2.1-dev # Chart-Version im develop-Baum — Vorabkanal
appVersion: "1.0.0" # Anwendungs-Version
-dev ist der Entwicklungskanal, kein Release
Der Zusatz -dev ist kein Schreibfehler. Der develop-Baum trägt immer eine Vorabversion mit diesem Zusatz, und helm push leitet den OCI-Tag wörtlich aus dieser Zeile ab. Der Tag oci://ghcr.io/nolte/charts/kamerplanter:0.2.1-dev wird deshalb bei jedem Merge nach develop überschrieben, der helm/ berührt — das ist der Zweck dieses Kanals.
Ein Release publiziert dagegen unter der reinen Version ohne Zusatz, etwa 0.1.0. Die beiden Kanäle sind disjunkt und können sich nicht überschneiden: Der Vorab-Bezeichner dev ist im Release-Pfad gesperrt, ein Tag v0.3.0-dev wird abgewiesen. -rc und -beta bleiben erlaubt.
Erzwungen ist allein der Bezeichner dev, nicht die Nummer davor. 0.2.1 benennt das beabsichtigte nächste Release, ohne dass irgendetwas den Wert vor der veröffentlichten Linie hält — sobald v0.2.1 erscheint, sortiert 0.2.1-dev darunter, und kein Turnus hebt ihn an. Das ist Absicht: Jede -dev-Nummer ist kollisionssicher, ein regelmäßiger Bump brächte nichts.
Verwende in keinem Deployment eine -dev-Version. Pinne eine veröffentlichte Version oder den Manifest-Digest. Warum das keine Theorie ist, und wie beide Kanäle abgesichert sind: CI/CD — Zwei Kanäle.
Abhängigkeiten¶
| Dependency | Version | Quelle | Zweck |
|---|---|---|---|
| common (bjw-s) | 5.2.1 | bjw-s-labs Helm-Charts | Library-Chart für einheitliche Kubernetes-Ressourcen |
| valkey | 0.12.0 | OCI: ghcr.io/valkey-io/valkey-helm | Redis-kompatibler Cache + Celery-Broker |
| ollama | 1.87.0 | otwld/ollama-helm | Lokale LLM-Inferenz, standardmäßig aus (ollama.enabled: false) |
Ollama-Subchart standardmäßig aus
Der Ollama-Helm-Chart (otwld/ollama-helm) ist als dritte Abhängigkeit eingetragen, wird aber nur mit ollama.enabled: true gerendert. Ohne diesen Schalter enthält das Release kein Ollama; wie die Betriebsprofile Ollama einsetzen, steht unter Betriebsprofile → Profi.
Chart-Struktur¶
helm/kamerplanter/
├── Chart.yaml # Chart-Metadaten und Abhängigkeiten
├── Chart.lock # Pinned Dependency-Versionen
├── values.yaml # Standard-Werte (Produktion)
├── values-dev.yaml # Override für Entwicklung
├── templates/
│ └── common.yaml # bjw-s Library-Loader
└── charts/
├── common-5.2.1.tgz # bjw-s Common Library
├── valkey-0.12.0.tgz # Valkey Sub-Chart
└── ollama-1.87.0.tgz # Ollama Sub-Chart (standardmäßig aus)
Das Chart nutzt den bjw-s common.loader.all-Ansatz: Alle Kubernetes-Ressourcen (Deployments, StatefulSets, Services, ConfigMaps, Ingress) werden deklarativ über values.yaml definiert — es gibt keine eigenen Templates.
Konfigurationsreferenz¶
Controller (Deployments & StatefulSets)¶
Backend¶
controllers:
backend:
type: deployment
replicas: 1 # Chart-Default; für Produktion i. d. R. auf 2 erhöhen (siehe Kubernetes-Deployment)
strategy: RollingUpdate
containers:
main:
image:
repository: ghcr.io/nolte/kamerplanter-backend
tag: 0.2.1@sha256:af9bec… # unveränderlicher Digest, vom Release-Job gesetzt — siehe "Bestimmte Image-Version pinnen"
envFrom:
- secret: kamerplanter-secrets # ARANGODB_PASSWORD, JWT_SECRET_KEY, FERNET_KEY, ERASURE_TOMBSTONE_SALT, LOG_PSEUDONYM_SALT
env:
ARANGODB_HOST: "..."
ARANGODB_PORT: "8529"
ARANGODB_DATABASE: "kamerplanter"
ARANGODB_USERNAME: '{{ .Values.database.arangodb.appUsername }}' # kamerplanter
REDIS_URL: "redis://kamerplanter-valkey:6379/0"
CORS_ORIGINS: '["..."]'
DEBUG: "false"
KAMERPLANTER_MODE: "light" # oder "full" (Chart-Default)
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 2Gi # Bild-Uploads (REQ-034) dekodieren im Speicher — 512Mi OOMKillt beim Upload
ARANGODB_PASSWORD, JWT_SECRET_KEY, FERNET_KEY, ERASURE_TOMBSTONE_SALT, LOG_PSEUDONYM_SALT kommen NIE aus env:
Der reale Chart deklariert diese fünf Werte absichtlich nicht im env:-Block — sie kommen ausschließlich per envFrom: - secret: kamerplanter-secrets aus einem vorher angelegten Kubernetes-Secret. Ohne dieses Secret (bzw. mit einem unveränderten Default-Wert darin) verweigert das Backend bei DEBUG=false den Start; der Celery-Worker-Controller bezieht dasselbe Secret und prüft LOG_PSEUDONYM_SALT ebenso streng. Details: Kubernetes-Deployment — Pflicht-Secrets anlegen, Konfigurationsmatrix — Pflicht-Secrets.
Celery-Worker¶
Der Worker verarbeitet drei Warteschlangen (Issue #2128):
| Queue | Inhalt |
|---|---|
critical | Aufbewahrungsfristen und Löschungen (NFR-011), Sicherheits- und Datenschutz-Aufräumläufe, Benachrichtigungen, Frostwarnungen, Aktor-Regelkreis |
celery | alles Übrige — Celerys Standard-Queue unter ihrem bisherigen Namen |
bulk | lange, externe Läufe: Datensatz- und Referenzbild-Erfassung, Stammdaten-Anreicherung, Glossar-Vorwärmen, Storage-Migrationen |
Der Chart startet den Worker mit -Q critical,celery,bulk. Eine Queue, die kein Worker liest, behält ihre Aufgaben für immer in Valkey — ohne Fehlermeldung. Wenn du die Worker-args in deinen Values überschreibst, übernimm die Queue-Liste vollständig oder lass -Q ganz weg (dann liest der Worker alle Queues, die die Anwendung deklariert).
Ein Worker pro Queue¶
Mit einem Schalter bekommt jede Queue ihren eigenen Worker:
| Deployment | Queue |
|---|---|
celery-worker | critical |
celery-worker-default | celery |
celery-worker-bulk | bulk |
Ohne den Schalter (Standard) liest der eine celery-worker alle drei Queues. Ein langer Lauf in bulk belegt dann einen seiner zwei Prozesse; der zweite arbeitet weiter. Mit dem Schalter kann eine Datensatz- oder Bilderfassung eine Erinnerung, eine Löschung oder eine Frostwarnung gar nicht mehr aufhalten.
Die beiden zusätzlichen Worker sind Kopien von celery-worker: Der Chart erzeugt sie beim Rendern aus den zusammengeführten Values. Alles, was du in einem Overlay, per --set oder im ArgoCD-valuesObject an controllers.celery-worker setzt (Image, Umgebung, securityContext, Probes, Ressourcen, Pod-Optionen) und an networkpolicies.celery-worker (Egress), gilt damit auch für die Kopien. Jede Policy, die celery-worker als Gegenstelle zulässt (ArangoDB, Valkey, inference-service), lässt auch die Kopien zu, und jedes Volume des Workers wird auch in die Kopien gemountet. Nur die Queue unterscheidet sich. Eine einzelne Kopie stimmst du über controllers.<kopie>.<schlüssel> ab, z. B. controllers.celery-worker-bulk.containers.main.resources; der Schlüssel wird über die Kopie gelegt. Ohne den Schalter gibt es keine Kopien, solche Schlüssel bleiben dann wirkungslos.
Zwei Pods mehr auf dem Node des Backends
Jeder zusätzliche Worker fordert wie celery-worker 100m CPU und 512 Mi Speicher an (Limit 2 Gi). Mit dem Standard-Volume ReadWriteOnce laufen alle Pods, die die Anhänge mounten, auf einem Node — dem des Backends. Plane dessen Speicher dafür ein, oder stelle auf S3 um; dann verteilen sich die Worker frei.
Schaltet ein Profil celery-worker ab (die ki- und recognition-Overlays), entstehen auch keine Kopien. Überschreibst du die Worker-args selbst, gilt weiter: Jede Queue braucht mindestens einen Worker, der sie liest.
Bestätigung erst nach dem Lauf. Aufräumläufe, deren zweiter Durchlauf nichts mehr findet (abgelaufene Tokens, Einwilligungen, Audit-Einträge, Löschnachweise, IP-Anonymisierung, abgelaufene Overrides), bestätigen ihre Nachricht erst, wenn sie fertig sind. Verschwindet ein Worker-Pod mitten im Lauf, vergibt Valkey die Nachricht nach der Sichtbarkeitsfrist (gut vier Stunden) neu, statt den Lauf bis zum nächsten Tag zu verlieren. Benachrichtigungen, der Aktor-Regelkreis und Löschläufe bestätigen weiter beim Start — ein zweiter Lauf würde dort doppelt benachrichtigen oder schalten.
Bereitschaft (Issue #2154). Der Worker gilt erst als bereit, wenn er Aufgaben annimmt: Mit WORKER_READY_FILE prüft er beim Start seine ArangoDB-Anmeldung (bis zu 60 Sekunden), schreibt die Datei, sobald sein Consumer läuft, und Startup- und Readiness-Probe testen sie (test -f, kostet praktisch nichts). Kann sich ein neuer Worker nach einem Upgrade nicht anmelden — falsches Passwort, Anwendungskonto nicht angelegt —, beendet er sich, wird nie bereit, und der Rollout behält den alten Worker. Die Liveness-Probe fragt mit inspect ping -d "celery@${HOSTNAME}" gezielt den eigenen Worker; ein inspect ping ohne Ziel gelingt, solange irgendein Worker antwortet.
Jede Aufgabe hat ein Zeitlimit als Notbremse: 30 Minuten (weich, die Aufgabe kann aufräumen), nach 35 Minuten wird der Prozess beendet. Läufe, die fortsetzbar sind oder bewusst gestartet werden (Löschläufe, Datenexport, Storage-Migration, Datensatz-Erfassung), haben drei Stunden. Jeder Worker-Prozess reserviert nur noch eine Nachricht im Voraus statt vier.
Frontend¶
controllers:
frontend:
type: deployment
replicas: 1 # Chart-Default; für Produktion i. d. R. auf 2 erhöhen
containers:
main:
image:
repository: ghcr.io/nolte/kamerplanter-frontend
tag: latest@sha256:fea5a3…
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
Das Frontend wird hinter nginx ausgeliefert. Die nginx-Konfiguration wird automatisch als ConfigMap gemountet und leitet /api/-Anfragen an das Backend weiter.
ArangoDB¶
controllers:
arangodb:
type: statefulset
replicas: 1 # Single-Node (kein Cluster)
containers:
main:
image:
repository: arangodb
tag: "3.12.9"
env:
ARANGO_ROOT_PASSWORD: # nur ARANGO_ROOT_PASSWORD, nicht das ganze Secret
valueFrom:
secretKeyRef:
name: '{{ .Values.database.arangodb.rootPasswordSecret }}'
key: ARANGO_ROOT_PASSWORD
optional: true
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
statefulset:
volumeClaimTemplates:
- name: data
accessMode: ReadWriteOnce
size: 5Gi # Anpassbar
advancedMounts:
main:
- path: /var/lib/arangodb3
Services¶
service:
backend:
controller: backend
ports:
http:
port: 8000
frontend:
controller: frontend
ports:
http:
port: 80 # Service-Port bleibt 80 (Ingress/DNS)
targetPort: 8080 # nginx-unprivileged-Container lauscht auf 8080, nicht 80
arangodb:
controller: arangodb
ports:
http:
port: 8529
Ingress¶
ingress:
main:
enabled: true # ohne hosts entsteht kein Ingress
hosts:
- host: pflanzen.example.com
paths:
- path: / # auch /api: das Frontend-nginx leitet es ans Backend weiter
pathType: Prefix
service:
identifier: frontend
Leite /api nicht direkt auf den Backend-Service: Die NetworkPolicy des Backends lässt nur das Frontend auf Port 8000 zu, und das Frontend-nginx setzt die Upload-Grenze sowie den X-Forwarded-For-Eintrag, auf den TRUSTED_PROXY_HOPS: "1" abgestimmt ist.
TLS
Für HTTPS füge eine tls-Sektion hinzu und verwende z.B. cert-manager mit Let's Encrypt:
Upload-Grenze (Request-Body)¶
Das Frontend-nginx nimmt Request-Bodys bis storage.maxFileSizeMb + 1 MiB an (Standard: 26m). Das zusätzliche MiB deckt die Multipart-Hülle eines Uploads ab: Ein Anhang mit der vollen erlaubten Größe kommt beim Backend an, ein etwas größerer bekommt dort eine verständliche 413-Antwort, und alles über der Grenze lehnt nginx selbst mit 413 ab. Ohne diese Einstellung lag die Grenze bei nginx' Standard von 1 MiB, und jeder größere Upload scheiterte schon am Proxy.
Das Chart setzt am Ingress main dieselbe Grenze als Annotation nginx.ingress.kubernetes.io/proxy-body-size, weil ingress-nginx sonst ebenfalls bei 1 MiB abschneidet. Beides folgt storage.maxFileSizeMb: Wenn du die Upload-Grenze anhebst, wachsen nginx und Ingress mit. Traefik und Contour/Envoy werten die Annotation nicht aus und setzen von sich aus keine Body-Grenze; dort greift die Grenze des Frontend-nginx, solange / und /api über das Frontend laufen. Nutzt du einen Ingress unter einem anderen Schlüssel als main oder einen eigenen HTTPProxy, setze die Grenze dort selbst auf denselben Wert.
Das Backend setzt dieselbe Grenze noch einmal selbst, bevor es einen Upload liest. Das greift, wenn ein Request am Frontend-nginx vorbeikommt — über kubectl port-forward, aus dem Cluster oder über einen Ingress ohne Body-Grenze. Ein Upload mit größerer Content-Length bekommt 413 (REQUEST_BODY_TOO_LARGE), ohne dass das Backend ein Byte liest; ein Upload ohne Längenangabe wird an der Grenze abgebrochen. Alle anderen Requests (JSON, Formulare) dürfen höchstens 1 MiB groß sein.
Uploads über 1 MiB legt das Backend während des Lesens in /tmp ab, im Chart ein Speicher-Volume mit 64 MiB. Damit gleichzeitige Uploads es nicht füllen, teilen sie sich ein Budget von 52 MiB (UPLOAD_SPOOL_BUDGET_MB). Es zählt die Bytes, die schon angekommen sind: Bei der Standardgrenze laufen zwei volle Uploads gleichzeitig, ein weiterer wartet bis zu 30 Sekunden (UPLOAD_SPOOL_WAIT_SECONDS) und bekommt danach 503 mit Retry-After. Hebst du storage.maxFileSizeMb über 51 an, setzt das Chart das Budget auf eine volle Upload-Größe, also läuft nur noch ein großer Upload zur Zeit. Die Größe von /tmp folgt nicht automatisch: Ab etwa 60 MiB Upload-Grenze musst du persistence.backend-tmp.sizeLimit selbst anheben.
Valkey (Redis-kompatibler Cache)¶
Valkey ist zugleich Cache und Celery-Broker: In ihm warten die Aufgaben auf den Worker. Das Chart bringt dafür diese Standardwerte mit:
valkey:
deploymentStrategy: Recreate
dataStorage:
enabled: true
requestedSize: 1Gi
keepPvc: true
valkeyConfig: |
maxmemory 256mb
maxmemory-policy noeviction
appendonly yes
appendfsync everysec
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 640Mi # mindestens das Doppelte von maxmemory
- Eigenes Volume: Valkey legt seine Daten auf ein
PersistentVolumeClaim(kamerplanter-valkey, 1 GiB,ReadWriteOnce). Es bleibt beihelm uninstallerhalten. Die Größe heißt im Valkey-ChartrequestedSize— einen Schlüsselsizeliest es nicht. Recreate: Der alte Valkey-Pod endet, bevor der neue startet. EinReadWriteOnce-Volume hängt an einem Node; ein neuer Pod daneben käme auf einem anderen Node nicht an das Volume.noevictionstatt einer Verdrängungsstrategie: Ist der Speicher voll, lehnt Valkey neue Schreibzugriffe ab, statt still wartende Aufgaben zu löschen. Ein voller Speicher fällt so als Fehler auf.appendonly yesschreibt jede Änderung mit (einmal pro Sekunde auf die Platte); nach einem Neustart sind auch die Aufgaben der letzten Sekunden wieder da.- Speicherlimit mindestens doppelt so groß wie
maxmemory(Standard 640 MiB bei 256 MB): Für das Umschreiben des Append-Only-Logs und für Snapshots startet Valkey einen Kindprozess, der den Datenbestand kopiert; jede Seite, die sich währenddessen ändert, belegt den Speicher doppelt. Dazu kommen der Puffer für das Umschreiben und die Puffer der Verbindungen, die nicht untermaxmemoryzählen. Liegt das Limit zu knapp, beendet Kubernetes Valkey, bevor Valkey selbst ablehnen kann. Hebst dumaxmemoryan, heberesources.limits.memorymit an — die Render-Prüfung des Charts verlangt mindestens das Doppelte. - Überwache den Speicher: Mit
noevictionmeldet ein voller Valkey Fehler (OOM command not allowed), statt Aufgaben zu verlieren — Backend und Worker können dann keine Aufgaben mehr einreihen. Richte einen Alarm aufused_memoryim Verhältnis zumaxmemoryein (zum Beispiel ab 80 %), damit du vorher eingreifst.
Beim Upgrade
Bis zu dieser Version lag /data von Valkey auf einem flüchtigen emptyDir: Das Chart setzte valkey.dataStorage.size, einen Schlüssel, den das Valkey-Chart nicht kennt, und legte deshalb kein Volume an. Jeder Neustart von Valkey verlor die wartenden Aufgaben. Das Upgrade legt das Volume neu an und startet Valkey einmal neu (Recreate).
- Einmaliger Verlust wartender Aufgaben: Was beim Neustart in der Warteschlange steht, geht verloren. Lass die Warteschlange vorher leerlaufen — etwa Celery Beat auf 0 skalieren (
kubectl scale deployment kamerplanter-celery-beat --replicas=0), warten, bis der Worker alles abgearbeitet hat, dann upgraden; Beat startet mit dem Upgrade wieder. Geplante Aufgaben stellt Beat ohnehin beim nächsten Termin wieder ein. - StorageClass ist Voraussetzung: Das Volume nennt keine StorageClass. Hat dein Cluster keine Standard-StorageClass, bleibt das Volume und damit Valkey
Pending— ohne Broker reiht keine Komponente mehr Aufgaben ein. Prüfe das mitkubectl get storageclass(Markierung(default)), oder nenne eine StorageClass ausdrücklich invalkey.dataStorage.className. - Neuinstallation: Das Volume bleibt bei
helm uninstallerhalten. Valkey spielt beim Start sein Append-Only-Log ab — auch die Aufgaben einer alten Installation. Lösche das PVCkamerplanter-valkeyvor einer Neuinstallation, wenn diese nicht weiterlaufen sollen.
Valkey ohne Passwort und ohne TLS
Backend, Worker und Beat verbinden sich über REDIS_URL (redis://kamerplanter-valkey:6379/0) ohne Passwort und ohne Verschlüsselung; der Valkey-Chart läuft mit seinem Standard ohne Anmeldung (auth.enabled: false) und ohne TLS. Geschützt ist Valkey nur durch die NetworkPolicies des Charts: Nur Backend, Worker und Beat dürfen den Valkey-Pod erreichen. Schalte die NetworkPolicies deshalb nicht ab, und betreibe Kamerplanter nur in einem Cluster, dessen CNI NetworkPolicies durchsetzt.
Umgebungsvariablen¶
| Variable | Pflicht | Standard | Beschreibung |
|---|---|---|---|
ARANGODB_HOST | Ja | — | Hostname des ArangoDB-Service |
ARANGODB_PORT | Ja | 8529 | Port des ArangoDB-Service |
ARANGODB_DATABASE | Ja | kamerplanter | Datenbankname |
ARANGODB_USERNAME | Ja | kamerplanter | Datenbank-Benutzer — im Chart das Anwendungskonto aus database.arangodb.appUsername, das der Container app-user im ArangoDB-Pod anlegt (Lese-/Schreibrecht nur auf der Anwendungsdatenbank). root stellt das alte Verhalten her. |
ARANGODB_PASSWORD | Ja | — | Datenbank-Passwort. Kommt im Chart aus dem Secret kamerplanter-secrets (envFrom), nicht aus env:. |
ARANGO_ROOT_PASSWORD | Ja | — | ArangoDB-Root-Passwort. Liest nur der ArangoDB-Pod (Secret aus database.arangodb.rootPasswordSecret, Standard kamerplanter-secrets). Ein eigener Wert, getrennt von ARANGODB_PASSWORD, wird empfohlen. |
JWT_SECRET_KEY | Ja | — | JWT-Signierschlüssel, aus kamerplanter-secrets. Boot-Blocker bei DEBUG=false, wenn der Chart-interne Default unverändert bleibt. |
FERNET_KEY | Ja | — | Verschlüsselungsschlüssel für OIDC-Provider-Secrets, aus kamerplanter-secrets. Boot-Blocker bei DEBUG=false, wenn leer oder ungültig — auch für den Celery-Worker-Controller, der denselben Wert wie das Backend per envFrom aus kamerplanter-secrets bezieht; Backend und Celery-Worker müssen denselben Schlüssel verwenden. |
ERASURE_TOMBSTONE_SALT | Ja | — | DSGVO-Pseudonymisierungs-Salt (≥ 32 Zeichen) für Tombstone-Hash, Löschantrags-Schlüssel und Mandanten-Slug-Digest, aus kamerplanter-secrets. Boot-Blocker bei DEBUG=false, wenn leer oder zu kurz. Darf nach der ersten Kontolöschung nie mehr geändert werden. |
LOG_PSEUDONYM_SALT | Ja | — | Salt (≥ 32 Zeichen) ausschließlich für die Log-Pseudonyme (subject=-Referenzen, email_sha256-Digests, requested_by_subject), aus kamerplanter-secrets. Boot-Blocker bei DEBUG=false, wenn leer oder zu kurz — auch für den Celery-Worker-Controller, der denselben Wert per envFrom bezieht. Darf im Gegensatz zu ERASURE_TOMBSTONE_SALT rotiert werden. |
INTERNAL_SERVICE_TOKEN | Bedingt | — | Nur Pflicht, sobald KNOWLEDGE_SERVICE_ENABLED=true oder INFERENCE_SERVICE_ENABLED=true gesetzt ist, ebenfalls aus kamerplanter-secrets. Bei INFERENCE_SERVICE_ENABLED gilt dieselbe Pflicht auch für den Celery-Worker-Controller — er führt die planmäßige DSGVO-Löschung beigetragener Referenzbilder aus und braucht denselben Zugang wie das Backend, das sie schreibt (siehe Bilderkennung in Betrieb nehmen). |
REDIS_URL | Ja | — | Valkey/Redis-Verbindungs-URL |
CORS_ORIGINS | Ja | — | Erlaubte Origins als JSON-Array |
DEBUG | Nein | false | Debug-Modus aktivieren. Deaktiviert bei true zusätzlich den Boot-Blocker der sechs Zeilen oben — niemals in Produktion setzen. |
KAMERPLANTER_MODE | Nein | full | light (ohne Auth, ein Nutzer) oder full (mit JWT-Auth und Mandantenverwaltung). Der Chart setzt diese Variable am Backend-Controller standardmäßig nicht — es gilt der Python-seitige Default full. Am Frontend-InitContainer ist sie fest auf full gesetzt und muss für den Light-Modus explizit überschrieben werden. |
REQUIRE_EMAIL_VERIFICATION | Nein | true | E-Mail-Verifikation bei Registrierung. Ohne ausgehenden Mailversand (EMAIL_ADAPTER ist console) setzt du false ausdrücklich — sonst kann sich ein selbst registriertes Konto nicht anmelden, weil die Bestätigungsmail nie ankommt. |
Vollständige Liste aller Pflicht-Secrets je aktivierter Funktion: Konfigurationsmatrix — Pflicht-Secrets.
Entwicklungs-Overrides (values-dev.yaml)¶
Für die lokale Entwicklung existiert eine separate Values-Datei, die Skaffold automatisch verwendet:
| Einstellung | Produktion (values.yaml) | Entwicklung (values-dev.yaml) |
|---|---|---|
| Replicas (Backend/Frontend) | 1 (Chart-Default — für Produktion i. d. R. manuell auf 2 erhöht, siehe Kubernetes-Deployment) | 1 |
| Update-Strategie | RollingUpdate | Recreate |
| DEBUG | false | true |
| Resource Limits | Streng | Großzügig |
| Frontend-Port | 80 (nginx) | 5173 (Vite Dev Server) |
| ArangoDB PVC | 5 Gi (Chart-Default) | 2 Gi |
| Ingress-Host | (konfigurierbar) | kamerplanter.local |
Häufige Anpassungen¶
Ressourcen reduzieren (kleiner Cluster / Raspberry Pi)¶
controllers:
backend:
replicas: 1
containers:
main:
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
frontend:
replicas: 1
containers:
main:
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
arangodb:
containers:
main:
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
Bestimmte Image-Version pinnen¶
Ein veröffentlichtes Chart bringt bereits gepinnte Digests mit (der Release-Job setzt sie beim Packen) — du überschreibst sie nur, wenn du eine andere Version willst als die, die das Chart mitliefert. Dann aber vollständig, also mit Digest:
controllers:
backend:
containers:
main:
image:
tag: "1.2.3@sha256:c6689b…" # (1)!
frontend:
containers:
main:
image:
tag: "1.2.3@sha256:6727d2…"
- Digest ermitteln:
docker buildx imagetools inspect ghcr.io/nolte/kamerplanter-backend:1.2.3
Ein Override ohne Digest macht das Pinning des Charts rückgängig
Der Override gewinnt gegen den Chart-Default. tag: "1.2.3" — ohne den Teil hinter dem @ — ersetzt eine unveränderliche Referenz durch einen Namen, der neu gepusht werden kann. Zusammen mit pullPolicy: IfNotPresent liefert ein Node dann unter Umständen weiter alte Bytes aus, ohne dass es irgendwo auffällt. Genau daran hing der inference-service-Vorfall.
Und latest gar nicht
latest bewegt sich bei jedem Push auf develop. Eine Referenz, die sich bewegt, kann nicht zurückgerollt werden: „das vorherige Image" löst sich auf das aktuelle auf. Siehe Deployment und Rollback.
Storage-Konfiguration (NFR-013)¶
Kamerplanter speichert alle Binärdaten (Fotos, Importe, Exporte) über einen austauschbaren Storage-Adapter. Die Wahl des Backends und die zugehörige Kubernetes-Persistenz steuerst du über den Block storage in deinen Values: Das Chart leitet daraus die STORAGE_*-Variablen von Backend und Celery-Worker, das PVC und dessen Mounts ab.
Ältere Chart-Stände lasen den Block storage nicht
Ältere Chart-Stände lasen den Block storage nicht: storage.backend: s3 lieferte trotzdem local-fs mit PVC aus. Wer S3 bisher über eigene env-Einträge (STORAGE_BACKEND, STORAGE_S3_*) und ein zusätzliches envFrom eingerichtet hat, behält diese Einträge — sie überschreiben die Werte aus storage. Stelle beim nächsten Upgrade auf storage.backend: s3 um, sonst legt das Chart weiterhin das (dann unbenutzte) PVC an.
Geteilter Betrieb: S3 ist Pflicht
Das PVC backend-attachments wird von Backend und Celery-Worker gemountet. Ein ReadWriteOnce-Volume hängt an genau einem Node: Landet ein zweiter Pod auf einem anderen Node, bleibt er mit Multi-Attach error in ContainerCreating hängen. Für mehr als eine Backend- oder Worker-Replica — und für jeden Betrieb mit mehreren Mandanten auf einem Cluster mit mehreren Nodes — nutze storage.backend: s3. Das Chart verweigert das Rendern, wenn controllers.backend.replicas oder controllers.celery-worker.replicas größer als 1 ist und die Anhänge auf einem ReadWriteOnce-Volume liegen. Ausweg ohne S3: ReadWriteMany mit einer RWX-fähigen StorageClass, oder — nur auf einem Cluster mit genau einem Node — storage.localFs.singleNode: true.
Auch mit einer einzigen Replica gilt auf einem Cluster mit mehreren Nodes: Backend und Worker müssen auf demselben Node laufen, und ein Rolling Update (maxSurge: 1) startet den neuen Pod nur, wenn er auf demselben Node landet. Das Chart sorgt dafür (Issue #2153): Backend- und Worker-Pods tragen das Label kamerplanter.io/attachments-volume und verlangen per podAffinity einen Pod mit diesem Label auf demselben Node (topologyKey: kubernetes.io/hostname). Ein neuer Pod landet so auf dem Node, an dem das Volume hängt, und das Rolling Update bleibt unterbrechungsfrei — kein Recreate nötig. Passt der neue Pod dort nicht mehr hin (CPU, Speicher), bleibt er Pending mit FailedScheduling statt mit Multi-Attach error; der alte Pod läuft weiter. Mit storage.backend: s3 oder ReadWriteMany rendert die Bedingung auf kubernetes.io/os — ein Label mit demselben Wert auf jedem Node, also keine Einschränkung.
Local Filesystem (Standard)¶
Im Default-Betrieb legt das Chart automatisch das PVC backend-attachments an und mountet es in den Backend- und Celery-Worker-Pods unter /data/attachments. Das PVC trägt helm.sh/resource-policy: keep und die ArgoCD-Sync-Option Prune=false,Delete=false: Weder helm uninstall noch ein ArgoCD-Prune (etwa nach dem Umstellen auf S3) noch das Löschen der ArgoCD-Application löschen die Anhänge.
storage:
backend: local-fs # Standard; kein externes Storage nötig
maxFileSizeMb: 25
presignTtlSeconds: 900
virusScan:
enabled: false
endpoint: ""
localFs:
root: /data/attachments # Container-interner Mount-Pfad
pvc:
size: 20Gi # Chart-Default; nach Bedarf erhöhen
accessMode: ReadWriteOnce # Für Single-Replica (Standard)
storageClass: "" # Leer = Cluster-Default
singleNode: false # true nur auf einem Cluster mit genau einem Node
Multi-Replica-Betrieb (Backend- oder Worker-Replicas > 1) ohne S3:
storage:
localFs:
pvc:
accessMode: ReadWriteMany # RWX-fähige StorageClass erforderlich
storageClass: longhorn # Oder: nfs, cephfs, etc.
Signing-Secret bei RWX zwingend
Bei mehr als einer Backend-Replica muss STORAGE_LOCALFS_SIGNING_SECRET als stabiles Kubernetes-Secret gesetzt sein. Ohne dieses Secret generiert jeder Pod ein eigenes ephemeres Signing-Secret — Token-Downloads schlagen fehl, wenn die Validierungsanfrage einen anderen Pod erreicht als die Signierung.
kubectl create secret generic kamerplanter-storage-signing \
--from-literal=STORAGE_LOCALFS_SIGNING_SECRET="$(openssl rand -hex 32)" \
--namespace kamerplanter
Im Chart über envFrom referenzieren:
S3-kompatibel (Production)¶
Nicht-geheime S3-Parameter werden direkt in values.yaml gesetzt. Die Credentials kommen ausschließlich aus einem Secret — idealerweise vom External Secrets Operator (ESO) erzeugt, nie als Klartext in Git. Das Chart liest daraus genau die zwei in credentialsRef genannten Schlüssel (secretKeyRef), nicht das ganze Secret. Mit storage.backend: s3 rendert das Chart kein PVC und keinen Mount.
Bestehende Anhänge zuerst migrieren
Beim Umstellen von local-fs auf s3 liegen die bisherigen Dateien noch im PVC. Kopiere sie vor dem Umschalten mit dem Migrationswerkzeug im Backend-Pod (python -m scripts.storage.migrate --from local-fs --to s3 --checksum-verify, siehe Speicher konfigurieren). Erst danach storage.backend: s3 setzen; das alte PVC bleibt stehen (siehe oben) und du löschst es von Hand, wenn der Prüfsummen-Vergleich grün ist.
storage:
backend: s3
maxFileSizeMb: 25
presignTtlSeconds: 900
s3:
endpointUrl: https://s3.eu-central-1.amazonaws.com
region: eu-central-1
bucket: kamerplanter-prod
usePathStyle: false # true für MinIO und Nicht-AWS-Anbieter
forceTls: true
kmsKeyId: "" # Optional: Customer-Managed Key (SSE-KMS)
# S3-Credentials via External Secrets Operator (NIEMALS Klartext)
credentialsRef:
secretName: storage-s3-credentials
accessKeyIdKey: STORAGE_S3_ACCESS_KEY_ID
secretAccessKeyKey: STORAGE_S3_SECRET_ACCESS_KEY
External Secrets Operator — ESO-Secret:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: storage-s3-credentials
namespace: kamerplanter
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend # Oder AWS Secrets Manager, etc.
kind: ClusterSecretStore
target:
name: storage-s3-credentials
creationPolicy: Owner
data:
- secretKey: STORAGE_S3_ACCESS_KEY_ID
remoteRef:
key: kamerplanter/storage
property: access_key_id
- secretKey: STORAGE_S3_SECRET_ACCESS_KEY
remoteRef:
key: kamerplanter/storage
property: secret_access_key
Ohne ESO: manuelles Kubernetes-Secret
Wenn kein External Secrets Operator verfügbar ist, lege das Secret manuell an:
kubectl create secret generic storage-s3-credentials \
--from-literal=STORAGE_S3_ACCESS_KEY_ID="dein-access-key" \
--from-literal=STORAGE_S3_SECRET_ACCESS_KEY="dein-secret-key" \
--namespace kamerplanter
Fehlt das Secret bei storage.backend: s3, starten die Pods trotzdem (die Referenzen sind optional, damit ein local-fs-Release ohne dieses Secret auskommt), aber der Storage-Health-Check schlägt fehl und der Pod wird nicht Ready — der Rollout bleibt stehen, die alten Pods bedienen weiter.
NetworkPolicy für S3-Endpoints¶
Eine eigene Storage-NetworkPolicy gibt es nicht. Backend und Celery-Worker erreichen einen öffentlichen S3-Endpunkt über ihre allgemeine Egress-Regel (networkpolicies.backend und networkpolicies.celery-worker: Ports 80/443 nach 0.0.0.0/0 ohne RFC1918-Netze und ohne 169.254.0.0/16 — die Cloud-Metadata-Adresse bleibt gesperrt). Ein Endpunkt im Cluster oder im privaten Netz (MinIO, Ceph RGW) ist damit nicht erreichbar: Ergänze in deinen Values eine Egress-Regel für genau diesen Endpunkt in beiden Policies.
MinIO im Cluster¶
storage:
backend: s3
s3:
endpointUrl: http://minio.kamerplanter.svc:9000
region: us-east-1
bucket: kamerplanter
usePathStyle: true
forceTls: false
allowPrivateEndpoint: true # Erlaubt nicht öffentlich erreichbaren Endpunkt
credentialsRef:
secretName: storage-s3-credentials
accessKeyIdKey: STORAGE_S3_ACCESS_KEY_ID
secretAccessKeyKey: STORAGE_S3_SECRET_ACCESS_KEY
Egress für Benachrichtigungskanäle (Apprise)¶
Kamerplanter prüft Apprise-Ziele beim Speichern und beim Senden (Schema-Positivliste, Literal-Hosts, aufgelöste Adressen von Gotify-, Matrix- und ntfy-Servern). Zwei Dinge liegen außerhalb des Prozesses und sind Sache der Netzwerk-Policy des Betreibers:
- Apprise folgt HTTP-Redirects und führt bei Matrix die
.well-known-Erkennung selbst aus; beides lässt sich im Backend nicht kontrollieren. - Zwischen der DNS-Prüfung und dem Verbindungsaufbau bleibt ein kleines Zeitfenster (DNS-Rebinding).
Die DNS-Auflösung dieser Prüfung ist begrenzt, damit ein Nutzer mit nie antwortenden Hostnamen andere nicht ausbremst: Speichern und Senden haben je einen eigenen Pool mit 8 Threads, ein Nutzer belegt darin höchstens 3 gleichzeitig, und eine Antwort muss innerhalb von 3 s kommen. Was darüber hinausgeht, wird sofort abgelehnt (fail closed), nicht eingereiht. Beim Speichern werden Antworten pro Hostname kurz zwischengespeichert (60 s, ein Fehlschlag 10 s); beim Senden löst Kamerplanter jedes Mal frisch auf und übernimmt aus dem Zwischenspeicher nur eine Sperre. Die Werte sind fest eingebaut; es gibt dafür keine Einstellung.
Ziele in privaten Netzen (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10 und fc00::/7) lehnt Kamerplanter im full-Modus standardmäßig ab, beim Speichern und beim Senden. Im Cluster sind das keine Heimnetz-Geräte, sondern der API-Server, ArangoDB und Valkey auf Pod-IPs. Mit APPRISE_ALLOW_PRIVATE_TARGETS=true gibst du sie frei; im light-Modus sind sie ohne Einstellung erlaubt, mit false sperrst du sie auch dort. Loopback, Link-Local und Cloud-Metadaten bleiben immer gesperrt.
Das Chart schränkt den Egress von backend und celery-worker deshalb bereits ein (networkpolicies.backend und networkpolicies.celery-worker in values.yaml): Ziel 0.0.0.0/0 ohne 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 und 169.254.0.0/16, nur auf den Ports 80, 443, 465 und 587. Das schließt Cloud-Metadaten und Cluster-interne Ziele aus, auch wenn eine Weiterleitung dorthin führt. 100.64.0.0/10 steht nicht in dieser Ausnahmeliste; liegen Pods deines Clusters dort, schützt nur die Prüfung im Backend.
LAN-Gotify oder -ntfy braucht zwei Freigaben
Ein Gotify oder ntfy im Heimnetz (192.168.x.x) lehnt Kamerplanter im full-Modus ab, und der Standard-Egress des Charts blockiert es zusätzlich. Setze APPRISE_ALLOW_PRIVATE_TARGETS=true in der Umgebung von backend und celery-worker und ergänze in deinem valuesObject eine Egress-Regel für genau diese Adresse und diesen Port in networkpolicies.backend.rules.egress und networkpolicies.celery-worker.rules.egress. Die Freigabe gilt für alle Nutzer und alle privaten Adressen, also auch für Pod-IPs im Cluster; die Egress-Regel ist dann die eigentliche Schranke. Ohne Cluster-NetworkPolicy (Docker Compose) gilt keine Egress-Schranke; dann bleibt nur die Prüfung im Backend.
Ausgehende Verbindungen zu Home Assistant und InvenTree¶
Kamerplanter prüft die URL von Home Assistant und InvenTree nicht nur beim Speichern: Jede Verbindung prüft die Adressen, zu denen sie aufgebaut wird, und verbindet sich nur mit einer davon. Eine DNS-Antwort, die sich zwischen Prüfung und Verbindung ändert (DNS-Rebinding), führt deshalb nicht an eine interne Adresse. Cloud-Metadaten und Link-Local bleiben immer gesperrt, private Adressen nur mit HA_ALLOW_PRIVATE_ENDPOINT bzw. INVENTREE_ALLOW_PRIVATE_ENDPOINT. Weiterleitungen folgt keine dieser Verbindungen, Web-Push ebenfalls nicht.
Web-Push ist nicht angeheftet
Den Push-Endpunkt prüft Kamerplanter beim Speichern und vor jedem Senden, beim Verbinden wird der Name aber erneut aufgelöst. Ein Name, der dann auf eine interne Adresse zeigt, scheitert am TLS-Zertifikat, bevor ein HTTP-Request gesendet wird; die TCP-Verbindung dorthin wird aber geöffnet. Die Egress-Regel des Charts (ohne 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16) ist hier die eigentliche Schranke.
Kein HTTP-Proxy für diese Verbindungen
Für Home Assistant und InvenTree gelten HTTP_PROXY und HTTPS_PROXY nicht: Ein Proxy würde den Namen selbst auflösen, und genau das soll die Verbindung nicht aus der Hand geben. Braucht dein Cluster für ausgehenden Verkehr einen Proxy, gib Home Assistant und InvenTree über eine Egress-Regel direkt frei.
Virenscan (optional)¶
ClamAV muss als separates Deployment im Cluster laufen. Das Backend blockiert einen Upload, wenn der Scanner einen Fund meldet.
Häufige Provider-Konfigurationen¶
storage:
backend: s3
s3:
endpointUrl: https://fsn1.your-objectstorage.com
region: eu-central
bucket: mein-kamerplanter-bucket
usePathStyle: false
forceTls: true
credentialsRef:
secretName: storage-s3-credentials
accessKeyIdKey: STORAGE_S3_ACCESS_KEY_ID
secretAccessKeyKey: STORAGE_S3_SECRET_ACCESS_KEY
Metriken (Prometheus)¶
Ein Schalter: monitoring.enabled: true. Damit setzt das Chart METRICS_PORT=9464 am Backend, ergänzt den Service-Port metrics, legt einen ServiceMonitor an und die NetworkPolicy backend-metrics, die genau die Prometheus-Pods auf diesen Port lässt.
monitoring:
enabled: true
scrapeInterval: 30s
prometheus:
namespace: monitoring # Namespace, in dem Prometheus läuft
podName: prometheus # Wert des Labels app.kubernetes.io/name der Prometheus-Pods
serviceMonitorLabels:
release: kube-prometheus-stack # was der serviceMonitorSelector des Operators erwartet
Prometheus Operator erforderlich
Der ServiceMonitor ist eine Ressource des Prometheus Operators (monitoring.coreos.com/v1). Fehlt dessen CRD im Cluster, schlägt die Installation mit eingeschaltetem monitoring fehl. Deshalb ist der Standard false.
Die Metriken sind nicht öffentlich: Sie laufen auf einem eigenen Port, nie als Route der API. Der Ingress führt / und /api zum Frontend-nginx, und der leitet nur an Port 8000 weiter. Die Backend-Policy lässt weiter nur das Frontend auf 8000 zu, sodass Prometheus über den Scrape-Weg nicht an nginx vorbei auf die API kommt.
Ausgeliefert werden http_request_duration_seconds (Histogramm) und http_requests_total mit den Labels method, handler (Routenmuster, nie der Pfad; unmatched für eine Anfrage ohne Route) und status, http_requests_in_progress sowie Prozess-Metriken (Speicher, CPU, Dateideskriptoren). Ein Mandant ist bewusst kein Label — das vervielfacht jede Zeitreihe mit der Mandantenzahl und legt die Mandantenliste jedem offen, der scrapen darf. Für die Sicht pro Mandant tragen die Logzeilen tenant=ten_….
Dazu kommen die Längen der Celery-Warteschlangen: celery_queue_length mit dem Label queue (critical, celery, bulk) zählt die Aufgaben, die im Valkey auf einen Worker warten, und celery_broker_up ist 1, wenn der letzte Lesezugriff auf Valkey geklappt hat. Das Backend liest die Längen beim Scrapen, nicht bei einer Anfrage, und höchstens alle 5 Sekunden. Ist Valkey nicht erreichbar, fehlen die Warteschlangen-Werte in diesem Scrape (kein alter Wert) und celery_broker_up steht auf 0; der Scrape selbst schlägt nicht fehl. Jedes Backend-Replikat liefert dieselben Zahlen — fasse sie mit max, nicht mit sum zusammen.
Noch nicht enthalten: Metriken aus dem Celery-Worker selbst (Laufzeiten, Fehler je Aufgabe), Recording- und Alert-Regeln (PrometheusRule) aus NFR-007.
Skalierung (HPA und PDB)¶
Ohne weitere Einstellungen läuft jede Komponente mit einem Pod. Autoscaler (HorizontalPodAutoscaler) und Disruption-Budget (PodDisruptionBudget) schaltest du pro Controller dazu; das Chart rendert sie nur, wenn du sie setzt.
storage:
backend: s3 # Voraussetzung für mehr als einen Backend-Pod
# ...
controllers:
backend:
horizontalPodAutoscaler:
minReplicas: 2 # ersetzt replicas
maxReplicas: 6
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
podDisruptionBudget:
minAvailable: 1
frontend:
replicas: 2
podDisruptionBudget:
minAvailable: 1
- Autoscaler: Mit
horizontalPodAutoscalerentfälltreplicasaus dem Deployment;minReplicasübernimmt seine Rolle. So setzt einhelm upgradeoder ein ArgoCD-Sync die Pod-Zahl nicht auf den festen Wert zurück. Der Autoscaler braucht den metrics-server im Cluster. - Ein neuer Backend-Pod hilft erst nach seinem Start — bis zu einigen Minuten, siehe Startdauer des Backends. Kurze Lastspitzen fängt der Autoscaler deshalb nicht ab.
- Celery-Worker: CPU ist für den Worker das falsche Signal — er wartet meist auf externe Dienste. Skaliere ihn nach der Länge seiner Warteschlange, siehe Worker-Autoscaling nach Queue-Länge.
Das Chart verweigert die Darstellung (helm template, helm upgrade, ArgoCD) mit einer Meldung, wenn eine Einstellung im Cluster still schiefgehen würde:
| Einstellung | Warum abgelehnt |
|---|---|
Backend oder Celery-Worker mit mehr als einem Pod (replicas oder maxReplicas) auf dem Standard-Volume ReadWriteOnce | Ein zweiter Pod auf einem anderen Node startet nie (Multi-Attach error). Lösung: S3 oder ReadWriteMany. |
Mehr als ein Pod oder ein Autoscaler für celery-beat, arangodb, vectordb oder timescaledb | Zwei Beat-Pods führen jede geplante Aufgabe doppelt aus, auch Lösch- und Aufbewahrungsläufe. Zwei ArangoDB-, VectorDB- oder TimescaleDB-Pods sind zwei getrennte Datenbanken, kein Cluster. |
podDisruptionBudget.minAvailable gleich oder größer als die Pod-Zahl, auch als Prozentwert ("100%"; Kubernetes rundet auf ganze Pods auf, "50%" von einem Pod ist dieser Pod), oder maxUnavailable: 0 bzw. "0%" | Kubernetes darf dann keinen Pod verdrängen; jedes Leeren eines Nodes und jedes Cluster-Upgrade bleibt hängen. Prozentwerte, die mindestens einen Pod verdrängbar lassen (minAvailable: "50%" bei zwei Pods, maxUnavailable: "25%"), sind erlaubt. |
minReplicas gleich oder größer als maxReplicas bei einem Autoscaler | Der Autoscaler kann die Pod-Zahl nie ändern. Für eine feste Zahl setzt du replicas. |
Eine Metrik type: External ohne external.metric.name oder ohne Ziel (target.type: Value mit value bzw. AverageValue mit averageValue) | Der Autoscaler liest nichts und skaliert nie. |
Der Celery-Beat bleibt immer ein einzelner Pod mit der Strategie Recreate: Der alte Pod endet, bevor der neue startet.
Worker-Autoscaling nach Queue-Länge¶
Die Celery-Worker kann das Chart nach der Zahl der wartenden Aufgaben skalieren. Dafür braucht es drei Dinge, von denen das Chart zwei mitbringt:
- Die Metrik
celery_queue_length{queue}vom Backend — eingeschaltet mitmonitoring.enabled: true(siehe Metriken). - Den Prometheus Adapter im Cluster, der diese Reihe als External Metric (
external.metrics.k8s.io) bereitstellt. Den installierst und konfigurierst du selbst; das Chart installiert ihn nicht. - Die Autoscaler der Worker — eingeschaltet mit
celeryWorkers.queueAutoscaling.enabled: true.
storage:
backend: s3 # Voraussetzung für mehr als einen Worker-Pod
# ...
monitoring:
enabled: true # Pflicht: liefert celery_queue_length
celeryWorkers:
dedicatedQueues: true # optional: ein Worker je Queue, siehe oben
queueAutoscaling:
enabled: true
metricName: celery_queue_length # Name, unter dem der Adapter die Reihe anbietet
workers:
celery-worker: # liest critical (bzw. alle drei ohne dedicatedQueues)
minReplicas: 1
maxReplicas: 3
targetPerReplica: 20 # wartende Aufgaben pro Worker-Pod
celery-worker-default: # liest celery
minReplicas: 1
maxReplicas: 3
targetPerReplica: 20
celery-worker-bulk: # liest bulk
minReplicas: 1
maxReplicas: 2
targetPerReplica: 5
Jeder Worker bekommt einen HorizontalPodAutoscaler (autoscaling/v2) mit genau einer Metrik vom Typ External. Welche Warteschlangen sie auswählt, liest das Chart aus dem -Q des Workers: Ohne dedicatedQueues skaliert der eine Worker nach der Summe aller drei Warteschlangen, mit dedicatedQueues jeder Worker nach seiner eigenen. Das Ziel ist AverageValue: wartende Aufgaben geteilt durch die Pods des Workers. Ein Eintrag für celery-worker-default oder celery-worker-bulk wirkt nur mit dedicatedQueues: true; mit enabled: false in einem Eintrag nimmst du einen einzelnen Worker heraus.
Regel für den Prometheus Adapter (Values des Charts prometheus-community/prometheus-adapter):
rules:
external:
- seriesQuery: 'celery_queue_length{namespace!="",queue!=""}'
resources:
overrides:
namespace: {resource: namespace}
name:
matches: "^celery_queue_length$"
as: "celery_queue_length" # = celeryWorkers.queueAutoscaling.metricName
# Jedes Backend-Replikat meldet dieselben Zahlen: max, nicht sum.
metricsQuery: 'max by (queue) (<<.Series>>{<<.LabelMatchers>>})'
Ob der Adapter die Reihe liefert, prüfst du so (Namespace anpassen):
kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1/namespaces/kamerplanter/celery_queue_length?labelSelector=queue%20in%20(critical)"
kubectl describe hpa kamerplanter-celery-worker
Ohne Adapter skaliert nichts
Fehlt der Adapter oder seine Regel, meldet der Autoscaler FailedGetExternalMetric und behält die aktuelle Pod-Zahl. Dasselbe gilt, solange Valkey nicht erreichbar ist: Die Warteschlangen-Werte fehlen dann, und der Autoscaler skaliert weder hoch noch herunter.
Herunterskalieren beendet laufende Aufgaben
Ein Worker-Pod, der beim Herunterskalieren stoppt, beendet seine laufenden Aufgaben innerhalb der Termination-Grace-Period des Pods (ohne Einstellung 30 Sekunden); danach wird er beendet. Eine Aufgabe, die schon beim Start bestätigt wurde, ist dann verloren; eine, die erst nach dem Lauf bestätigt wird, läuft nach dem Visibility-Timeout erneut. Deshalb skaliert das Chart standardmäßig langsam herunter (behavior: 10 Minuten Stabilisierung, höchstens ein Pod je 5 Minuten). Für celery-worker-bulk mit Läufen bis zu 3 Stunden setzt du controllers.celery-worker-bulk.pod.terminationGracePeriodSeconds hoch oder hältst minReplicas auf der Zahl, die er normalerweise braucht.
Das Chart verweigert die Darstellung, wenn queueAutoscaling.enabled ohne monitoring.enabled oder ohne metricName gesetzt ist, ein Eintrag kein maxReplicas oder kein targetPerReplica über 0 hat, minReplicas kleiner als 1 oder nicht kleiner als maxReplicas ist, ein Worker zusätzlich einen eigenen controllers.<worker>.horizontalPodAutoscaler trägt oder der Name kein Worker des Charts ist. Mehr als ein Worker-Pod auf dem Standard-Volume ReadWriteOnce wird wie bei jedem Autoscaler abgelehnt.
Secrets aus einem Secret-Store¶
Statt kamerplanter-secrets von Hand anzulegen, kann der External Secrets Operator es aus deinem Secret-Store (Vault, AWS Secrets Manager, …) schreiben. Das Chart bringt dafür ein ExternalSecret mit, das standardmäßig aus ist:
externalSecrets:
app:
enabled: true
secretStoreRef:
kind: ClusterSecretStore # oder SecretStore im selben Namespace
name: mein-secret-store
dataFrom:
- extract:
key: kamerplanter/app # Eintrag im Store mit den Pflicht-Schlüsseln, ohne ARANGO_ROOT_PASSWORD
- Jeder Schlüssel des Store-Eintrags wird ein Eintrag in
kamerplanter-secrets, und Backend, Worker und Beat lesen dieses Secret vollständig perenvFrom. Lege deshalb nur die Pflicht-Schlüssel in den Eintrag (Pflicht-Secrets), nichtARANGO_ROOT_PASSWORD: Das Root-Passwort gehört in ein eigenes Secret (eigener Store-Eintrag mit einem zweitenExternalSecretoder von Hand angelegt), das du indatabase.arangodb.rootPasswordSecreteinträgst. - Ein
ClusterSecretStoreliefert ohne Einschränkung an jeden Namespace im Cluster. Beschränke ihn überspec.conditions(namespacesodernamespaceSelector) auf den Kamerplanter-Namespace, oder nutze einenSecretStoreim selben Namespace (secretStoreRef.kind: SecretStore). - Ohne
secretStoreRef.namebricht die Darstellung mit einer Meldung ab. - Der Operator und seine CRDs (
external-secrets.io/v1) müssen im Cluster installiert sein. - Fehlt der Eintrag später im Store, bleibt das letzte Secret erhalten (
deletionPolicy: Retain) — Pods starten weiter. Retaingilt nur für einen gelöschten Store-Eintrag. Beihelm uninstallverschwindet dasExternalSecret, und weil es mitcreationPolicy: OwnerEigentümer vonkamerplanter-secretsist, löscht die Garbage Collection von Kubernetes das Secret mit. Brauchst du die Werte danach noch, liegen sie im Store.
Umstieg von einem handgemachten Secret
Mit creationPolicy: Owner (Standard) übernimmt der Operator ein bereits vorhandenes, von Hand angelegtes kamerplanter-secrets nicht ohne Weiteres. Lösche das alte Secret im selben Schritt (laufende Pods behalten ihre Werte bis zum nächsten Neustart), oder setze externalSecrets.app.target.creationPolicy: Merge — dann schreibt der Operator die Schlüssel in das vorhandene Secret.
Weitere Secrets — die S3-Zugangsdaten (storage.s3.credentialsRef), das Backup (backup.s3.credentialsRef) und das Root-Passwort der Datenbank (database.arangodb.rootPasswordSecret) — legst du auf dieselbe Weise als weitere Einträge unter externalSecrets an.
TimescaleDB (Sensordaten)¶
TimescaleDB speichert Sensor-Zeitreihen. Sie ist optional und standardmäßig aus; ohne sie arbeitet Kamerplanter ohne Messreihen-Speicher. Du schaltest sie mit einem einzigen Wert ein:
Damit rendert das Chart die Datenbank (StatefulSet, ein Pod, 10 GiB Volume), ihren Service und ihre NetworkPolicy, und Backend, Worker und Beat erhalten TIMESCALEDB_ENABLED=true samt Adresse. Nur Backend und Worker dürfen die Datenbank auf Port 5432 erreichen.
Vorher ergänzt du in kamerplanter-secrets den Schlüssel TIMESCALEDB_PASSWORD:
kubectl patch secret kamerplanter-secrets -n kamerplanter --type merge \
-p "{\"stringData\":{\"TIMESCALEDB_PASSWORD\":\"$(openssl rand -hex 24)\"}}"
- Die Datenbank liest nur diesen einen Schlüssel aus dem Secret; Backend und Worker erhalten ihn wie alle anderen über
envFrom. - Fehlt der Schlüssel, startet der Datenbank-Pod nicht (
CreateContainerConfigError). Das Backend verweigert den Start mit dem eingebauten Standardpasswort, sobaldDEBUG=falsegilt. - Mit
externalSecrets.applegst du den Schlüssel in denselben Store-Eintrag wie die übrigen Pflicht-Schlüssel. - Die Datenbank läuft ohne Root-Rechte (Benutzer
postgres, uid 70) mit schreibgeschütztem Dateisystem; beschreibbar sind nur das Daten-Volume, das Socket-Verzeichnis und/tmp. - Mehr als ein Pod ist nicht möglich: Ein zweiter Pod wäre eine zweite, getrennte Datenbank. Das Chart verweigert
replicasüber 1 und einen Autoscaler. - Das Volume bleibt bei
helm uninstallerhalten. Eine Sicherung der Messreihen bringt das Chart nicht mit (siehe Backup und Wiederherstellung).
Externe Datenbanken¶
Statt der mitgelieferten ArangoDB und des Valkey-Subcharts kannst du Datenbanken außerhalb des Charts anbinden, etwa einen verwalteten Dienst oder eine Instanz in einem anderen Namespace. Beide Schalter sind standardmäßig aus; ohne sie rendert das Chart wie bisher.
ArangoDB¶
database.arangodb.external.enabled: true ist der einzige Schalter. Das ArangoDB-StatefulSet, sein Service, seine NetworkPolicy und seine Volumes entfallen. Backend, Celery-Worker, Celery-Beat und das Backup verbinden sich mit scheme://host:port:
database:
arangodb:
appUsername: kamerplanter
external:
enabled: true
host: arango.example.net # nur der Hostname, ohne Schema
port: 8529
scheme: https # http | https
cidr: 203.0.113.10/32 # Adressbereich der Datenbank (NetworkPolicy)
- Das Chart legt in einer externen Datenbank nichts an. Die Anwendungsdatenbank (
kamerplanter) und das Konto ausappUsernamemit Lese-/Schreibrecht darauf müssen existieren;ARANGODB_PASSWORDinkamerplanter-secretsist sein Passwort. scheme: httpssetztARANGODB_SCHEME=https. Das Backend prüft das Serverzertifikat gegen die Zertifikate im Image. Für eine eigene Zertifizierungsstelle legst du deren Zertifikat in den Container und nennst den Pfad inREQUESTS_CA_BUNDLE. Das gilt nur für die Anwendung:arangodumpim Backup-Job liest diese Variable nicht, mit eigener Zertifizierungsstelle deckt das Chart das Backup also nicht ab.- Das Backup (
backup.enabled) sichert dann die externe Datenbank und verbindet sich beihttpsper TLS.
Valkey¶
valkey.enabled: false schaltet das Valkey-Subchart ab. Backend, Worker und Beat bekommen dann database.valkey.external.url als REDIS_URL:
valkey:
enabled: false
database:
valkey:
external:
url: "rediss://:$(VALKEY_PASSWORD)@valkey.example.net:6380/0"
cidr: 203.0.113.20/32
- Erlaubt sind
redis://undrediss://(TLS). - Ein Passwort gehört nie in die Values. Lege es als Schlüssel in
kamerplanter-secretsab (hierVALKEY_PASSWORD) und verweise mit$(SCHLÜSSEL)darauf; Kubernetes setzt den Wert beim Containerstart ein. Die Zeichen/ # ? @im Passwort musst du URL-kodieren, sonst verweigert das Backend den Start.
NetworkPolicies¶
Mit der Default-Deny-Basis erreichen die Pods eine externe Datenbank nur über eine eigene Egress-Policy. Das Chart rendert sie mit dem jeweiligen Schalter:
| Policy | Pods | Ziel | Ports |
|---|---|---|---|
arangodb-external | Backend, alle Celery-Worker, Backup | database.arangodb.external.cidr | 8529, 443 |
valkey-external | Backend, alle Celery-Worker, Celery-Beat | database.valkey.external.cidr | 6379, 6380 |
cidrist deshalb Pflicht. Erreichst du die Datenbank über einen Namespace- oder Pod-Selektor, ersetzenetworkpolicies.<policy>.rules.egressdurch deine Regel, oder schalte die Policy mitenabled: falseab und bring deine eigene mit.- Läuft die Datenbank auf einem anderen Port, ersetze ebenfalls
rules.egress. Ports in einer NetworkPolicy müssen Zahlen sein; aus einem Template entstünde ein Text, den Kubernetes als Portnamen liest.
Render-Prüfungen¶
Das Chart verweigert die Darstellung, wenn eine Kombination im Cluster still scheitern würde:
- externe ArangoDB ohne
host, mit einem Schema imhost, mit einem anderen Schema alshttp/httpsoder zusätzlichcontrollers.arangodb.enabled: true valkey.enabled: falseohneurloder mit einer URL, die nicht mitredis://bzw.rediss://beginnt, und eine gesetzteurlbei laufendem Valkey-Subchart- eine Egress-Policy ohne
cidroder ohne den Port, den du konfiguriert hast
Siehe auch¶
- Kubernetes-Deployment — Schritt-für-Schritt-Anleitung
- Umgebungsvariablen — Vollständige Referenz aller Umgebungsvariablen
- Speicher konfigurieren (Object Storage) — Admin-UI und Migration