Compare commits
81
Commits
9442cc49d1
...
3b110d3d3f
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -14,3 +14,5 @@ data/
|
|||||||
*.tfstate*
|
*.tfstate*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
deploy/compose/*/secrets/
|
deploy/compose/*/secrets/
|
||||||
|
platform/proxmox/ansible/inventory.generated.yml
|
||||||
|
platform/proxmox/ansible/monitoring-targets.generated/
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# AGENTS.md – Arbeitsweise für Coding-Agenten (Claude Code, Codex, Hermes …)
|
||||||
|
|
||||||
|
Dieses Repo beschreibt den Proxmox-/k3s-Cluster **knaster** und den Router-Service `knaster`. Wer hier als Agent
|
||||||
|
arbeitet, ändert **Infrastruktur, die läuft**. Erst lesen: [project-map.md](project-map.md) (was wo läuft),
|
||||||
|
[known-issues.md](known-issues.md) (bekannte Fallen), der Aufbauplan
|
||||||
|
[docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md](docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md)
|
||||||
|
(was offen ist) und [docs/BETRIEB.md](docs/BETRIEB.md) (wie man es betreibt).
|
||||||
|
|
||||||
|
## Sprache und Stil
|
||||||
|
|
||||||
|
- Deutsch in Doku, Kommentaren, Commit-Nachrichten und Antworten; Umlaute ausschreiben (ä, ö, ü, ß).
|
||||||
|
- Code so schreiben wie der umgebende Code: Kommentardichte, Benennung, Muster (Playbook-Kopf mit Zweck und Aufruf,
|
||||||
|
Manifest-Kopf mit Zweck und Herkunft).
|
||||||
|
- Daten mit Datum notieren (`19.10.`), keine relativen Angaben („gestern“).
|
||||||
|
|
||||||
|
## Der Weg jeder Änderung
|
||||||
|
|
||||||
|
1. **Im Repo ändern** (auf dem Admin-Laptop bzw. dem Gerät der verwaltenden Person). Nie direkt auf ops oder in
|
||||||
|
einer VM editieren; `/root/cluster-knaster` auf ops und `/opt/knaster/repo` auf infra sind Kopien.
|
||||||
|
2. **Prüfen:** `make test` (bzw. `pytest core`), `make lint`, bei Ansible `ansible-playbook --syntax-check` auf ops,
|
||||||
|
bei Prometheus `promtool check config`/`check rules` (läuft im Prometheus-Image auf infra).
|
||||||
|
3. **Committen und pushen** (Branch laut Plan, derzeit `plan/aufbau-2026-10-12`). Nur eigene Dateien stagen –
|
||||||
|
es arbeiten oft mehrere Sitzungen parallel im selben Repo. Vor dem Push `git pull --rebase`.
|
||||||
|
4. **Auf ops ausrollen:** `ssh ops`, `cd /root/cluster-knaster && git pull`, dann in `platform/proxmox/ansible`
|
||||||
|
das passende Playbook. `kubectl`, `tofu` und Ansible gibt es **nur auf ops**.
|
||||||
|
5. **Live prüfen** (Pods bereit, Dienst antwortet, Prometheus-Ziel `up`) und erst dann abhaken.
|
||||||
|
6. **Plan und Doku nachziehen:** Checkbox im Aufbauplan mit Datum und Befund, Betriebswissen in `docs/BETRIEB.md`,
|
||||||
|
neue Fallen in `known-issues.md`, neue Dienste/Adressen in `project-map.md`.
|
||||||
|
|
||||||
|
## Zugang
|
||||||
|
|
||||||
|
- Einstieg ist **ops** (`192.168.100.173`, Bastion). Von dort erreicht man alle Knoten, VMs und infra mit dem
|
||||||
|
Schlüssel `root@ops`; Ansible-Inventar `platform/proxmox/ansible/inventory.yml` (+ `inventory.generated.yml`).
|
||||||
|
- Wer verwaltet, steht in `sites/knaster/proxmox.yaml` unter `access.ops_ssh_keys` (Playbook `playbooks/admins.yml`,
|
||||||
|
Übergabe: BETRIEB §15). Proxmox-Weboberfläche nur mit persönlichem Konto und TOTP.
|
||||||
|
- Muster für Befehle vom Laptop: `ssh ops '<befehl>'`, `ssh ops 'cd /root/cluster-knaster/platform/proxmox/ansible && ansible-playbook playbooks/<x>.yml'`.
|
||||||
|
In Ansible-Ad-hoc-Befehlen keine Go-Templates (`{{.Names}}`) – Ansible wertet `{{ }}` selbst aus; besser ein
|
||||||
|
kleines Skript mit `-m script`.
|
||||||
|
|
||||||
|
## Feste Regeln
|
||||||
|
|
||||||
|
- **Keine Geheimnisse im Repo.** Tokens und Passwörter liegen auf ops unter `/root/.config/knaster/` (0600), im LXC
|
||||||
|
infra unter `/opt/knaster/infra/.env` bzw. `secrets/`, beim Menschen in Password Depot. Playbooks lesen sie von dort,
|
||||||
|
Ausgaben mit `no_log: true`.
|
||||||
|
- **Dry-Run:** Healer und Router laufen mit `KNASTER_DRY_RUN=true`, `enabled_actions: []`. Nicht ohne ausdrückliche
|
||||||
|
Freigabe des Nutzers ändern.
|
||||||
|
- **IP-Literale statt DNS-Namen** in Konfigurationen (Netz `192.168.100.0/24`, Gateway `.1`, DNS `.120`). Adressen der
|
||||||
|
GPU-VMs und der k3s-VM kommen per DHCP; Prometheus-Ziele deshalb per file_sd, nicht fest.
|
||||||
|
- **GPUs:** In jeder GPU-VM ist Index 0 die Quadro M4000, Index 1 die P2000 (gpu-pve52 hat nur die M4000).
|
||||||
|
`nvidia.com/gpu` in k3s vergibt **nur die M4000**. Die P2000 gehört llama.cpp (fest per UUID). Neue GPU-Pods
|
||||||
|
bekommen also eine M4000; eine P2000 nur nach Absprache.
|
||||||
|
- **Was nach k3s umgezogen ist, nicht wieder per Compose starten:** `playbooks/nex-speech.yml` und
|
||||||
|
`playbooks/gpu-llamacpp.yml` brechen ab (Schalter nur für den Rückweg).
|
||||||
|
- **Images pinnen** (exakte Version, kein `latest`); bei k3s-Pods mit eigenem Build `imagePullPolicy: Never` und Import
|
||||||
|
per `docker save | k3s ctr images import -` (Muster: Rolle `nex-speech-k3s`, `gpu-llamacpp` `tasks/k3s.yml`).
|
||||||
|
- **Infra-LXC hat 4 GB RAM:** neue Prometheus-Jobs mit `metric_relabel_configs` auf das Nötige begrenzen.
|
||||||
|
- **Nach außen wirkende oder schwer umkehrbare Schritte** (Produktion auf appsrv08, DNS/DHCP, Löschen, Firewall,
|
||||||
|
Neustart von Knoten) vorher mit dem Nutzer abstimmen. Die Firewall-Rolle hat einen Totmannschalter, trotzdem
|
||||||
|
vorher prüfen, dass die eigene Adresse in `firewall.management` steht.
|
||||||
|
- **Namespace `nex`** gehört NEX (nex-admin legt dort Agenten an). Änderungen dort mit dem NEX-Team abstimmen.
|
||||||
|
|
||||||
|
## Muster im Repo
|
||||||
|
|
||||||
|
| Aufgabe | Vorlage |
|
||||||
|
|---|---|
|
||||||
|
| Neuer Dienst in k3s | `deploy/k8s/<dienst>/` + `playbooks/<dienst>-k3s.yml` (z. B. `nex-searxng`, `monitoring`) |
|
||||||
|
| Dienst mit GPU/Host-Netz in einer VM | `deploy/k8s/nex-speech/`, `deploy/k8s/llamacpp/llamacpp.yaml.j2` |
|
||||||
|
| Zugang für eine Anwendung zur k8s-API | `deploy/k8s/nex/admin-access.yaml` (ServiceAccount, Role, Token-Secret), Token-Datei auf ops |
|
||||||
|
| Neues Prometheus-Ziel | `deploy/prometheus/prometheus.yml`; wechselnde Adressen über `platform/proxmox/scripts/monitoring-targets.py` |
|
||||||
|
| Neuer Alert | `deploy/prometheus/alerts.yml` **und** passende Regel in `config/healer-rules.yaml` (Test erzwingt das) |
|
||||||
|
| Grafana-Dashboard | `deploy/grafana/dashboards/*.json`, Datasource-uid `prometheus`, Herkunft in `provisioning/dashboards/knaster.yml` |
|
||||||
|
|
||||||
|
## Testen unter Windows
|
||||||
|
|
||||||
|
- `.venv\Scripts\python -m pytest core` – `test_tts_server` scheitert unter Windows (kein Piper), das ist bekannt.
|
||||||
|
- `yamllint` meldet auf geänderten Dateien „wrong new line character“, weil Git die Arbeitskopie mit CRLF auscheckt.
|
||||||
|
Für eine echte Prüfung den Stand aus Git prüfen: `git show HEAD:<datei> | .venv/Scripts/yamllint -s -c .yamllint.yml -`.
|
||||||
@@ -20,7 +20,7 @@ validate:
|
|||||||
$(VENV)/bin/knaster placement --inventory sites/knaster/inventory.yaml --catalog catalog > /dev/null
|
$(VENV)/bin/knaster placement --inventory sites/knaster/inventory.yaml --catalog catalog > /dev/null
|
||||||
|
|
||||||
compose-check:
|
compose-check:
|
||||||
@for d in infra gpu-vllm gpu-llamacpp; do \
|
@for d in infra gpu-vllm gpu-llamacpp nex-speech; do \
|
||||||
(cd deploy/compose/$$d && cp .env.example .env.ci && \
|
(cd deploy/compose/$$d && cp .env.example .env.ci && \
|
||||||
POSTGRES_PASSWORD=ci VLLM_MODEL=ci SLOT_ALIAS=ci docker compose --env-file .env.ci \
|
POSTGRES_PASSWORD=ci VLLM_MODEL=ci SLOT_ALIAS=ci docker compose --env-file .env.ci \
|
||||||
--profile embed --profile chat --profile dashboards --profile proxmox config -q; \
|
--profile embed --profile chat --profile dashboards --profile proxmox config -q; \
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ Anfrage → n8n → Router-Service ──▶ Stufe 3 Embedding-Classifier (CPU)
|
|||||||
|
|
||||||
| Dokument | Inhalt |
|
| Dokument | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [AGENTS.md](AGENTS.md) | **Einstieg für Agenten und neue Admins**: Arbeitsweise, Regeln, Muster |
|
||||||
|
| [project-map.md](project-map.md) | Was wo läuft: Rechner, Adressen, Dienste, Ports, Namespaces, Repo, Orte der Geheimnisse |
|
||||||
|
| [known-issues.md](known-issues.md) | Bekannte Probleme und Fallen |
|
||||||
|
| [docs/NOTFALL-ADMIN-UEBERGABE.md](docs/NOTFALL-ADMIN-UEBERGABE.md) | Notfall: Verwaltung an eine andere Person übergeben (für die Geschäftsführung) |
|
||||||
| [docs/PLAN.md](docs/PLAN.md) | Architektur und Betrieb: Proxmox, Pflege, Autohealing, Lifecycle, Finetuning, Problemerkennung, Portabilität |
|
| [docs/PLAN.md](docs/PLAN.md) | Architektur und Betrieb: Proxmox, Pflege, Autohealing, Lifecycle, Finetuning, Problemerkennung, Portabilität |
|
||||||
| [docs/adr/](docs/adr/) | Architekturentscheidungen |
|
| [docs/adr/](docs/adr/) | Architekturentscheidungen |
|
||||||
| [docs/spec/](docs/spec/) | Spezifikationen der Komponenten |
|
| [docs/spec/](docs/spec/) | Spezifikationen der Komponenten |
|
||||||
|
|||||||
+9
-4
@@ -1,4 +1,6 @@
|
|||||||
# Modell-Katalog (SPEC-inventory §2). vram_gb = Gewichte + Mindest-KV-Cache (Richtwerte, per Kalibrierung prüfen).
|
# Modell-Katalog (SPEC-inventory §2). vram_gb = Gewichte + Mindest-KV-Cache (Richtwerte, per Kalibrierung prüfen).
|
||||||
|
# Gemessen am 12.10.2026 (Task 3.3, ADR-0006): llama-server b6000/CUDA 12.4 in gpu-pve48 beim Katalog-Kontext,
|
||||||
|
# belegter VRAM inkl. CUDA-Kontext, auf M4000 und P2000 gleich. Ungemessene Einträge bleiben Richtwerte.
|
||||||
# Ist ein Modell nicht mehr aktuell, wird ein neuer Eintrag ergänzt. Der Wechsel läuft über die Lifecycle-Pipeline.
|
# Ist ein Modell nicht mehr aktuell, wird ein neuer Eintrag ergänzt. Der Wechsel läuft über die Lifecycle-Pipeline.
|
||||||
# Engine-Grenzen gelten für alle Modelle der Engine (ADR-0006).
|
# Engine-Grenzen gelten für alle Modelle der Engine (ADR-0006).
|
||||||
engines:
|
engines:
|
||||||
@@ -24,24 +26,27 @@ models:
|
|||||||
lora: true
|
lora: true
|
||||||
qwen3-8b-q4km:
|
qwen3-8b-q4km:
|
||||||
kind: llm
|
kind: llm
|
||||||
vram_gb: 7
|
vram_gb: 7.8 # gemessen: 7918 MiB (7,73 GiB, aufgerundet) bei 16384 Kontext → nur M4000 (8 GB), auf P2000 scheitert der KV-Cache
|
||||||
min_cc: 5.0
|
min_cc: 5.0
|
||||||
engines: [llamacpp]
|
engines: [llamacpp]
|
||||||
context: 16384
|
context: 16384
|
||||||
|
note: "llama-bench 12.10. (pp512/tg128 t/s): M4000 115/10,0, P2000 152/9,0 (nur mit 512 Kontext)"
|
||||||
qwen3-4b-q4km:
|
qwen3-4b-q4km:
|
||||||
kind: llm
|
kind: llm
|
||||||
vram_gb: 4
|
vram_gb: 4.1 # gemessen: 4153 MiB bei 8192 Kontext
|
||||||
min_cc: 5.0
|
min_cc: 5.0
|
||||||
engines: [llamacpp]
|
engines: [llamacpp]
|
||||||
context: 8192
|
context: 8192
|
||||||
cpu_ok: true # langsam, aber als Notfall-Verifier nutzbar
|
cpu_ok: true # langsam, aber als Notfall-Verifier nutzbar
|
||||||
|
note: "llama-bench 12.10. (pp512/tg128 t/s): P2000 271/14,5, M4000 197/15,1"
|
||||||
qwen3-embedding-0.6b-q8:
|
qwen3-embedding-0.6b-q8:
|
||||||
kind: embedding
|
kind: embedding
|
||||||
vram_gb: 1.5
|
vram_gb: 2.8 # gemessen: 2827 MiB mit -c 8192 -ub 8192 (Compose-Einstellung, großer Rechenpuffer)
|
||||||
min_cc: 5.0
|
min_cc: 5.0
|
||||||
engines: [llamacpp]
|
engines: [llamacpp]
|
||||||
context: 8192
|
context: 8192
|
||||||
cpu_ok: true
|
cpu_ok: true
|
||||||
|
note: "llama-bench 12.10. pp512 (Embedding): P2000 1009 t/s, M4000 929 t/s"
|
||||||
bge-m3-f16:
|
bge-m3-f16:
|
||||||
kind: embedding
|
kind: embedding
|
||||||
vram_gb: 2.5
|
vram_gb: 2.5
|
||||||
@@ -74,4 +79,4 @@ models:
|
|||||||
vram_gb: 0.2
|
vram_gb: 0.2
|
||||||
engines: [onnx]
|
engines: [onnx]
|
||||||
cpu_ok: true
|
cpu_ok: true
|
||||||
note: "schnell auf CPU (≪ Echtzeit), solide Qualität; piper-tts 1.3+ ist GPL-3.0 (eigener Dienst)"
|
note: "schnell auf CPU, gemessen 12.10. mit knaster bench tts (4 vCPU i7-6700): RTF 0,17 (241 Zeichen), 0,11 (596 Zeichen); piper-tts 1.3+ ist GPL-3.0 (eigener Dienst)"
|
||||||
|
|||||||
@@ -51,3 +51,24 @@ rules:
|
|||||||
match: {alertname: DiskAlmostFull}
|
match: {alertname: DiskAlmostFull}
|
||||||
ladder:
|
ladder:
|
||||||
- {action: notify, params: {text: "Platte auf {instance} ({mountpoint}) fast voll", severity: warning}, attempts: 1}
|
- {action: notify, params: {text: "Platte auf {instance} ({mountpoint}) fast voll", severity: warning}, attempts: 1}
|
||||||
|
|
||||||
|
# k3s (Task 6.5): nur melden; Pods startet Kubernetes selbst neu
|
||||||
|
- id: k3s-node-not-ready
|
||||||
|
match: {alertname: K3sNodeNotReady}
|
||||||
|
ladder:
|
||||||
|
- {action: notify, params: {text: "k3s-Knoten {node} nicht bereit – VM und k3s-agent prüfen", severity: high}, attempts: 1}
|
||||||
|
|
||||||
|
- id: k3s-pod-crashloop
|
||||||
|
match: {alertname: K3sPodCrashLooping}
|
||||||
|
ladder:
|
||||||
|
- {action: notify, params: {text: "Pod {namespace}/{pod} ({container}) startet immer wieder neu", severity: warning}, attempts: 1}
|
||||||
|
|
||||||
|
- id: k3s-deployment-unavailable
|
||||||
|
match: {alertname: K3sDeploymentUnavailable}
|
||||||
|
ladder:
|
||||||
|
- {action: notify, params: {text: "Deployment {namespace}/{deployment} hat zu wenige bereite Pods", severity: warning}, attempts: 1}
|
||||||
|
|
||||||
|
- id: k3s-memory-near-limit
|
||||||
|
match: {alertname: K3sContainerMemoryNearLimit}
|
||||||
|
ladder:
|
||||||
|
- {action: notify, params: {text: "{namespace}/{pod} ({container}) über 90 % des Speicherlimits", severity: warning}, attempts: 1}
|
||||||
|
|||||||
+120
-24
@@ -24,13 +24,103 @@ def test_healer_labels_available_on_alerts():
|
|||||||
"""Labels, die Healer-Regeln brauchen, müssen von Metrik oder Scrape-Target kommen."""
|
"""Labels, die Healer-Regeln brauchen, müssen von Metrik oder Scrape-Target kommen."""
|
||||||
prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text())
|
prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text())
|
||||||
jobs = {j["job_name"]: j for j in prom["scrape_configs"]}
|
jobs = {j["job_name"]: j for j in prom["scrape_configs"]}
|
||||||
for job in ("node", "gpu", "llamacpp"):
|
for job in ("node", "gpu", "vllm"):
|
||||||
for sc in jobs[job]["static_configs"]:
|
for sc in jobs[job].get("static_configs", []):
|
||||||
assert {"slot", "pve_node", "vmid"} <= set(sc["labels"]), (job, sc)
|
assert {"slot", "pve_node", "vmid"} <= set(sc["labels"]), (job, sc)
|
||||||
|
# GPU-VMs kommen per file_sd; die Labels setzt platform/proxmox/scripts/monitoring-targets.py
|
||||||
|
generated = _monitoring_targets()
|
||||||
|
for job in ("node", "gpu"):
|
||||||
|
files = [f for sd in jobs[job]["file_sd_configs"] for f in sd["files"]]
|
||||||
|
assert files == [f"/etc/knaster-sd/{job}.json"]
|
||||||
|
assert generated[job]
|
||||||
|
for entry in generated[job]:
|
||||||
|
assert {"slot", "pve_node", "vmid"} <= set(entry["labels"]), (job, entry)
|
||||||
from knaster.canary import OPS_LABELS
|
from knaster.canary import OPS_LABELS
|
||||||
assert set(OPS_LABELS) == {"docker_host", "container", "pve_node", "vmid"}
|
assert set(OPS_LABELS) == {"docker_host", "container", "pve_node", "vmid"}
|
||||||
|
|
||||||
|
|
||||||
|
def _targets_module():
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"monitoring_targets", REPO_ROOT / "platform/proxmox/scripts/monitoring-targets.py")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _monitoring_targets() -> dict:
|
||||||
|
mod = _targets_module()
|
||||||
|
registry = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text(encoding="utf-8"))
|
||||||
|
gpu_vms = { # Form wie `tofu output -json gpu_vms`
|
||||||
|
"gpu-pve44": {"node": "pve44", "vmid": 244, "ipv4": "192.168.100.186"},
|
||||||
|
"gpu-pve48": {"node": "pve48", "vmid": 248, "ipv4": "192.168.100.183"},
|
||||||
|
"gpu-pve52": {"node": "pve52", "vmid": 252, "ipv4": "192.168.100.174"},
|
||||||
|
"gpu-pve46": {"node": "pve46", "vmid": 246, "ipv4": None},
|
||||||
|
}
|
||||||
|
return mod.build_targets(gpu_vms, registry)
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitoring_targets_from_tofu_output():
|
||||||
|
t = _monitoring_targets()
|
||||||
|
assert [e["targets"][0] for e in t["node"]] == [
|
||||||
|
"192.168.100.186:9100", "192.168.100.183:9100", "192.168.100.174:9100"] # gpu-pve46 ohne IP fehlt
|
||||||
|
assert t["gpu"][0]["targets"] == ["192.168.100.186:9835"]
|
||||||
|
pve44 = t["node"][0]["labels"]
|
||||||
|
assert pve44["instance"] == "gpu-pve44" and pve44["vmid"] == "244" and pve44["pve_node"] == "pve44"
|
||||||
|
assert pve44["slot"] == "embed-default" and pve44["slots"] == "embed-default,rerank-default"
|
||||||
|
assert t["node"][2]["labels"]["slot"] == "none" # gpu-pve52 trägt noch keinen Slot
|
||||||
|
|
||||||
|
|
||||||
|
def _k3s_node(name: str, ip: str | None, ready: str = "True") -> dict:
|
||||||
|
addresses = [{"type": "Hostname", "address": name}] + ([{"type": "InternalIP", "address": ip}] if ip else [])
|
||||||
|
return {"metadata": {"name": name},
|
||||||
|
"status": {"addresses": addresses, "conditions": [{"type": "Ready", "status": ready}]}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_k3s_targets_from_kubectl_nodes():
|
||||||
|
nodes = {"items": [ # Form wie `kubectl get nodes -o json`
|
||||||
|
_k3s_node("gpu-pve52", "192.168.100.174"),
|
||||||
|
_k3s_node("k3s", "192.168.100.124"),
|
||||||
|
_k3s_node("gpu-pve44", "192.168.100.186", ready="False"),
|
||||||
|
_k3s_node("gpu-pve46", None),
|
||||||
|
]}
|
||||||
|
t = _targets_module().build_k3s_targets(nodes)
|
||||||
|
assert [(e["targets"][0], e["labels"]["instance"]) for e in t["kubelet"]] == [
|
||||||
|
("192.168.100.186:10250", "gpu-pve44"), ("192.168.100.174:10250", "gpu-pve52"),
|
||||||
|
("192.168.100.124:10250", "k3s")] # gpu-pve46 ohne IP fehlt
|
||||||
|
assert t["kube-state-metrics"] == [
|
||||||
|
{"targets": ["192.168.100.124:30808"], "labels": {"instance": "kube-state-metrics"}}] # Dienst-VM bevorzugt
|
||||||
|
t = _targets_module().build_k3s_targets({"items": [nodes["items"][0], nodes["items"][2]]})
|
||||||
|
assert t["kube-state-metrics"][0]["targets"] == ["192.168.100.174:30808"] # erster bereiter Knoten
|
||||||
|
assert _targets_module().build_k3s_targets({}) == {"kubelet": [], "kube-state-metrics": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_k3s_jobs_use_generated_targets_and_nodeport():
|
||||||
|
prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text())
|
||||||
|
jobs = {j["job_name"]: j for j in prom["scrape_configs"]}
|
||||||
|
mod = _targets_module()
|
||||||
|
written = set(mod.build_k3s_targets({}))
|
||||||
|
for job, sd in {"kubelet": "kubelet", "cadvisor": "kubelet", "kube-state-metrics": "kube-state-metrics"}.items():
|
||||||
|
assert jobs[job]["file_sd_configs"] == [{"files": [f"/etc/knaster-sd/{sd}.json"]}]
|
||||||
|
assert sd in written
|
||||||
|
for job in ("kubelet", "cadvisor"):
|
||||||
|
assert jobs[job]["scheme"] == "https" and jobs[job]["tls_config"]["ca_file"].startswith("/etc/knaster-k3s/")
|
||||||
|
docs = list(yaml.safe_load_all((DEPLOY / "k8s/monitoring/kube-state-metrics.yaml").read_text()))
|
||||||
|
svc = next(d for d in docs if d["kind"] == "Service")
|
||||||
|
assert svc["spec"]["ports"][0]["nodePort"] == mod.KSM_NODEPORT
|
||||||
|
|
||||||
|
|
||||||
|
DASHBOARDS = sorted((DEPLOY / "grafana/dashboards").glob("*.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", DASHBOARDS, ids=[p.name for p in DASHBOARDS])
|
||||||
|
def test_grafana_dashboards_parse_with_unique_uid(path):
|
||||||
|
d = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
assert d["uid"] and d["title"] and d["panels"]
|
||||||
|
assert [x for x in DASHBOARDS if json.loads(x.read_text(encoding="utf-8"))["uid"] == d["uid"]] == [path]
|
||||||
|
|
||||||
|
|
||||||
def test_alertmanager_targets_router_endpoint():
|
def test_alertmanager_targets_router_endpoint():
|
||||||
am = yaml.safe_load((DEPLOY / "alertmanager/alertmanager.yml").read_text())
|
am = yaml.safe_load((DEPLOY / "alertmanager/alertmanager.yml").read_text())
|
||||||
url = am["receivers"][0]["webhook_configs"][0]["url"]
|
url = am["receivers"][0]["webhook_configs"][0]["url"]
|
||||||
@@ -46,17 +136,15 @@ def test_compose_files_parse(role):
|
|||||||
|
|
||||||
|
|
||||||
def test_ops_labels_consistent_between_registry_and_prometheus():
|
def test_ops_labels_consistent_between_registry_and_prometheus():
|
||||||
"""Das erste Scrape-Ziel eines Slots trägt dieselben ops-Labels wie die Registry (Healer-Ziel)."""
|
|
||||||
reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text())
|
reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text())
|
||||||
prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text())
|
prom = yaml.safe_load((DEPLOY / "prometheus/prometheus.yml").read_text())
|
||||||
first: dict[str, dict] = {}
|
vllm = {sc["labels"]["slot"]: sc["labels"] for sc in
|
||||||
for sc in next(j for j in prom["scrape_configs"] if j["job_name"] == "llamacpp")["static_configs"]:
|
next(j for j in prom["scrape_configs"] if j["job_name"] == "vllm")["static_configs"]}
|
||||||
first.setdefault(sc["labels"]["slot"], sc["labels"])
|
for slot, labels in vllm.items():
|
||||||
assert first
|
|
||||||
for slot, labels in first.items():
|
|
||||||
ops = reg["slots"][slot]["ops"]
|
ops = reg["slots"][slot]["ops"]
|
||||||
assert {k: ops[k] for k in ("docker_host", "container", "pve_node", "vmid")} == \
|
if "vmid" not in ops:
|
||||||
{k: labels[k] for k in ("docker_host", "container", "pve_node", "vmid")}, slot
|
continue # Ada-Slots ohne Proxmox-VM (D3); die Prometheus-Ziele folgen in Task 5.2
|
||||||
|
assert ops["vmid"] == labels["vmid"] and ops["container"] == labels["container"]
|
||||||
|
|
||||||
|
|
||||||
WORKFLOWS = sorted((REPO_ROOT / "n8n/workflows").glob("*.json"))
|
WORKFLOWS = sorted((REPO_ROOT / "n8n/workflows").glob("*.json"))
|
||||||
@@ -164,30 +252,38 @@ def test_proxmox_site_consistent_with_registry_and_prometheus():
|
|||||||
reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text())
|
reg = yaml.safe_load((REPO_ROOT / "sites/knaster/registry.yaml").read_text())
|
||||||
vms = site["vms"]
|
vms = site["vms"]
|
||||||
for vm in vms.values():
|
for vm in vms.values():
|
||||||
|
assert vm["gpus"], vm # eine GPU-VM pro Knoten mit allen Karten des Knotens (D2)
|
||||||
for gpu in vm["gpus"]:
|
for gpu in vm["gpus"]:
|
||||||
assert gpu in site["pci_mappings"]
|
assert gpu in site["pci_mappings"]
|
||||||
assert site["pci_mappings"][gpu]["node"] == vm["node"] # Mapping liegt auf dem Host der VM
|
assert site["pci_mappings"][gpu]["node"] == vm["node"] # Mapping liegt auf dem Host der VM
|
||||||
assert vm["driver_generation"] in {"legacy", "modern"}
|
assert vm["driver_generation"] in {"legacy", "modern"}
|
||||||
assert vm["node"] in site["nodes"] and site["nodes"][vm["node"]]["gpu_host"]
|
gpus = [gpu for vm in vms.values() for gpu in vm["gpus"]]
|
||||||
gpus = [g for vm in vms.values() for g in vm["gpus"]]
|
|
||||||
assert len(gpus) == len(set(gpus)) # keine GPU doppelt durchgereicht
|
assert len(gpus) == len(set(gpus)) # keine GPU doppelt durchgereicht
|
||||||
assert set(gpus) == set(site["pci_mappings"]) # jede gemappte Karte steckt in einer VM
|
assert set(gpus) == set(site["pci_mappings"]) # jede gemappte Karte steckt in einer VM
|
||||||
by_vmid = {str(vm["vmid"]): (name, vm) for name, vm in {**vms, **site["containers"]}.items()}
|
by_vmid = {str(vm["vmid"]): (name, vm) for name, vm in {**vms, **site["containers"]}.items()}
|
||||||
for slot, spec in reg["slots"].items():
|
for slot, spec in reg["slots"].items():
|
||||||
ops = spec.get("ops") or {}
|
ops = spec.get("ops")
|
||||||
if "vmid" not in ops: # z. B. Ada-Worker unter Windows (D3), kein Proxmox-Gast
|
if not ops or "vmid" not in ops:
|
||||||
assert "pve_node" not in ops, slot
|
continue # Ada-Slots ohne Proxmox-VM (D3, Worker-Registry v2)
|
||||||
continue
|
|
||||||
name, vm = by_vmid[ops["vmid"]]
|
name, vm = by_vmid[ops["vmid"]]
|
||||||
assert vm["node"] == ops["pve_node"], slot
|
assert vm["node"] == ops["pve_node"], slot
|
||||||
assert ops["docker_host"] == name, slot
|
assert ops["docker_host"] == name, slot
|
||||||
for c in site["containers"].values():
|
# vLLM und PyTorch nur auf modernen Karten (ADR-0006)
|
||||||
if c.get("ha"):
|
if spec["engine"] in {"vllm", "pytorch"} and name in vms:
|
||||||
assert site["nodes"][c["node"]]["zfs_tank"] and c["storage"] == "tank"
|
assert vm["driver_generation"] == "modern", slot
|
||||||
assert all(site["nodes"][t]["zfs_tank"] for t in c["replicate_to"])
|
|
||||||
vmids = [str(v["vmid"]) for v in vms.values()] + [str(c["vmid"]) for c in site["containers"].values()]
|
vmids = [str(v["vmid"]) for v in vms.values()] + [str(c["vmid"]) for c in site["containers"].values()]
|
||||||
assert len(vmids) == len(set(vmids))
|
assert len(vmids) == len(set(vmids))
|
||||||
# vLLM nur auf modernen Karten
|
|
||||||
for vm in vms.values():
|
|
||||||
if vm["role"] == "gpu-vllm":
|
def test_ops_admin_keys_in_site_file():
|
||||||
assert vm["driver_generation"] == "modern"
|
"""Weg 1: Admins kommen mit eigenem Schlüssel auf ops (playbooks/admins.yml)."""
|
||||||
|
access = yaml.safe_load((REPO_ROOT / "sites/knaster/proxmox.yaml").read_text())["access"]
|
||||||
|
keys, revoked = access["ops_ssh_keys"], access["ops_ssh_keys_revoked"]
|
||||||
|
assert keys # nie leer, sonst sperrt sich die Verwaltung aus
|
||||||
|
names = [k["name"] for k in keys]
|
||||||
|
assert len(names) == len(set(names))
|
||||||
|
for k in [*keys, *revoked]:
|
||||||
|
parts = k["key"].split()
|
||||||
|
assert parts[0] in {"ssh-ed25519", "ssh-rsa"} and len(parts) >= 2, k["name"]
|
||||||
|
assert "PRIVATE" not in k["key"], k["name"]
|
||||||
|
assert not {k["key"].split()[1] for k in keys} & {k["key"].split()[1] for k in revoked}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from knaster.cli import main
|
from knaster.cli import main
|
||||||
from knaster.inventory.placement import load_catalog, load_nodes, plan
|
from knaster.inventory.placement import load_catalog, load_nodes, plan
|
||||||
@@ -31,34 +35,6 @@ def test_parsers():
|
|||||||
PVE_GPU_HOSTS = {"pve44", "pve46", "pve48", "pve52"}
|
PVE_GPU_HOSTS = {"pve44", "pve46", "pve48", "pve52"}
|
||||||
|
|
||||||
|
|
||||||
def test_site_knaster_matches_real_nodes():
|
|
||||||
"""Aufbauplan Task 0.4: Site-Dateien beschreiben die echten Knoten, nicht mehr die Annahmen pve-ada/pve1–4."""
|
|
||||||
import ipaddress
|
|
||||||
|
|
||||||
from knaster.util import load_yaml
|
|
||||||
|
|
||||||
nodes = {n.name: n for n in load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml")}
|
|
||||||
assert set(nodes) == {"pve44", "pve46", "pve48", "pve52", "pve61", "gode-pc-05", "gode-pc-06"}
|
|
||||||
pve = load_yaml(REPO_ROOT / "sites/knaster/proxmox.yaml")
|
|
||||||
old_net = ipaddress.ip_network("10.20.0.0/24")
|
|
||||||
ips = []
|
|
||||||
for section in ("vms", "containers"):
|
|
||||||
ips += [ipaddress.ip_interface(g["ip"]).ip for g in pve[section].values()]
|
|
||||||
ips += [ipaddress.ip_address(pve["cluster"][k]) for k in ("gateway", "dns")]
|
|
||||||
assert not [ip for ip in ips if ip in old_net]
|
|
||||||
assert {m["node"] for m in pve["pci_mappings"].values()} <= PVE_GPU_HOSTS
|
|
||||||
assert {v["node"] for v in pve["vms"].values()} <= PVE_GPU_HOSTS
|
|
||||||
# eine GPU-VM pro GPU-Knoten mit allen Karten des Knotens (D2)
|
|
||||||
assert sorted(v["node"] for v in pve["vms"].values()) == sorted(PVE_GPU_HOSTS)
|
|
||||||
for vm in pve["vms"].values():
|
|
||||||
mapped = {name for name, m in pve["pci_mappings"].items() if m["node"] == vm["node"]}
|
|
||||||
assert set(vm["gpus"]) == mapped
|
|
||||||
gpus_inv = [g for g in nodes[vm["node"]].gpus]
|
|
||||||
assert len(vm["gpus"]) == len(gpus_inv)
|
|
||||||
# Ada-Rechner bleiben Windows-Arbeitsplätze (D3): keine Infra-/CPU-Rollen
|
|
||||||
assert not nodes["gode-pc-05"].cpu_roles and not nodes["pve61"].cpu_roles
|
|
||||||
|
|
||||||
|
|
||||||
def test_placement_knaster():
|
def test_placement_knaster():
|
||||||
roles, models = load_catalog(REPO_ROOT / "catalog")
|
roles, models = load_catalog(REPO_ROOT / "catalog")
|
||||||
p = plan(load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml"), roles, models)
|
p = plan(load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml"), roles, models)
|
||||||
@@ -77,12 +53,29 @@ def test_placement_knaster():
|
|||||||
assert p.free_gpus == []
|
assert p.free_gpus == []
|
||||||
assert len(p.disabled_gpus) == 4 and all("M4000" in g for g in p.disabled_gpus)
|
assert len(p.disabled_gpus) == 4 and all("M4000" in g for g in p.disabled_gpus)
|
||||||
tts = {a.role: a for a in p.assignments if a.role.startswith("tts")}
|
tts = {a.role: a for a in p.assignments if a.role.startswith("tts")}
|
||||||
assert tts["tts-hq"].mode == "shared" and tts["tts-hq"].node == by_role["gpu-flex"][0].node
|
assert tts["tts-hq"].mode == "shared" and tts["tts-hq"].node == by_role["gpu-flex"][0].node == "gode-pc-06"
|
||||||
assert "qwen3-tts-1.7b" in tts["tts-hq"].models_fitting
|
assert "qwen3-tts-1.7b" in tts["tts-hq"].models_fitting
|
||||||
assert tts["tts-cpu"].node == by_role["infra"][0].node
|
assert tts["tts-cpu"].node == by_role["infra"][0].node
|
||||||
assert tts["tts-cpu"].models_fitting == ["piper-de-thorsten-medium"]
|
assert tts["tts-cpu"].models_fitting == ["piper-de-thorsten-medium"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_site_knaster_matches_real_nodes():
|
||||||
|
"""Site-Dateien beschreiben die echten Knoten (Stand 12.10.2026), nicht mehr die Annahmen pve-ada/pve1–pve4."""
|
||||||
|
names = {n.name for n in load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml")}
|
||||||
|
assert names == {"pve44", "pve46", "pve48", "pve52", "pve61", "gode-pc-05", "gode-pc-06"}
|
||||||
|
text = (REPO_ROOT / "sites/knaster/proxmox.yaml").read_text(encoding="utf-8")
|
||||||
|
old_net = ipaddress.ip_network("10.20.0.0/24")
|
||||||
|
ips = re.findall(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", text)
|
||||||
|
assert [ip for ip in ips if ipaddress.ip_address(ip) in old_net] == []
|
||||||
|
site = yaml.safe_load(text)
|
||||||
|
gpu_nodes = {"pve44", "pve46", "pve48", "pve52"}
|
||||||
|
assert {m["node"] for m in site["pci_mappings"].values()} <= gpu_nodes
|
||||||
|
assert {vm["node"] for vm in site["vms"].values()} <= gpu_nodes
|
||||||
|
# Ada-Rechner und Kiosk nehmen keine CPU-Rollen (Placement, cpu_roles)
|
||||||
|
nodes = {n.name: n for n in load_nodes(REPO_ROOT / "sites/knaster/inventory.yaml")}
|
||||||
|
assert not nodes["gode-pc-05"].cpu_roles and not nodes["gode-pc-06"].cpu_roles and not nodes["pve61"].cpu_roles
|
||||||
|
|
||||||
|
|
||||||
def test_nodes_without_cpu_roles_get_no_infra():
|
def test_nodes_without_cpu_roles_get_no_infra():
|
||||||
"""Arbeitsplätze und Kiosk nehmen keine CPU-Rollen, auch wenn sie den meisten RAM haben."""
|
"""Arbeitsplätze und Kiosk nehmen keine CPU-Rollen, auch wenn sie den meisten RAM haben."""
|
||||||
roles, models = load_catalog(REPO_ROOT / "catalog")
|
roles, models = load_catalog(REPO_ROOT / "catalog")
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
# Alertmanager → Router-Service (Healer). Kurzes repeat_interval treibt die Eskalationsleiter voran;
|
# Alertmanager → Router-Service (Healer). Kurzes repeat_interval treibt die Eskalationsleiter voran;
|
||||||
# Cooldowns pro Stufe regelt der Healer selbst.
|
# Cooldowns pro Stufe regelt der Healer selbst. Der Router läuft mit KNASTER_DRY_RUN=true.
|
||||||
route:
|
route:
|
||||||
receiver: knaster-healer
|
receiver: knaster-healer
|
||||||
group_by: [alertname, slot, instance]
|
group_by: [alertname, slot, instance]
|
||||||
group_wait: 30s
|
group_wait: 30s
|
||||||
group_interval: 1m
|
group_interval: 1m
|
||||||
repeat_interval: 5m
|
repeat_interval: 5m
|
||||||
|
# Vorbereitet, noch NICHT aktiv (Task 5.2, braucht den Nutzer: Workflow knaster-notify in n8n
|
||||||
|
# inaktiv importieren, Header-Auth setzen, aktivieren). Achtung: knaster-notify erwartet {text, severity, data},
|
||||||
|
# Alertmanager sendet sein eigenes Format – entweder den Workflow anpassen oder nur über den Router melden
|
||||||
|
# (Healer-Aktion notify → NOTIFY_WEBHOOK_URL). Danach einkommentieren:
|
||||||
|
# routes:
|
||||||
|
# - receiver: knaster-healer
|
||||||
|
# continue: true
|
||||||
|
# - receiver: n8n-notify
|
||||||
|
# matchers: ['severity=~"high|warning"']
|
||||||
|
|
||||||
receivers:
|
receivers:
|
||||||
- name: knaster-healer
|
- name: knaster-healer
|
||||||
@@ -16,3 +25,12 @@ receivers:
|
|||||||
authorization:
|
authorization:
|
||||||
type: Bearer
|
type: Bearer
|
||||||
credentials_file: /etc/alertmanager/router_token
|
credentials_file: /etc/alertmanager/router_token
|
||||||
|
|
||||||
|
# - name: n8n-notify
|
||||||
|
# webhook_configs:
|
||||||
|
# - url: https://n8n.mindboost.app/webhook/knaster/notify
|
||||||
|
# send_resolved: true
|
||||||
|
# http_config:
|
||||||
|
# authorization:
|
||||||
|
# type: Bearer
|
||||||
|
# credentials_file: /etc/alertmanager/n8n_token # Datei in /opt/knaster/infra/secrets, Mount ergänzen
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
COMPOSE_PROFILES=embed
|
||||||
LLAMACPP_REF=b6000
|
LLAMACPP_REF=b6000
|
||||||
|
LLAMACPP_COMMIT=4762ad7316dcdec20016ab5985fb46a27902204d
|
||||||
CUDA_VERSION=12.4.0
|
CUDA_VERSION=12.4.0
|
||||||
CUDA_DOCKER_ARCH=52;61
|
CUDA_DOCKER_ARCH=52;61
|
||||||
MODELS_DIR=/srv/models
|
MODELS_DIR=/srv/models
|
||||||
@@ -6,3 +8,7 @@ EMBED_GGUF=Qwen3-Embedding-0.6B-Q8_0.gguf
|
|||||||
EMBED_POOLING=last
|
EMBED_POOLING=last
|
||||||
RERANK_GGUF=bge-reranker-v2-m3-Q8_0.gguf
|
RERANK_GGUF=bge-reranker-v2-m3-Q8_0.gguf
|
||||||
CHAT_GGUF=Qwen3-4B-Q4_K_M.gguf
|
CHAT_GGUF=Qwen3-4B-Q4_K_M.gguf
|
||||||
|
# Karte je Dienst: Index oder UUID aus `nvidia-smi -L` (Rolle gpu-llamacpp setzt die UUID der P2000)
|
||||||
|
EMBED_GPU=1
|
||||||
|
RERANK_GPU=1
|
||||||
|
CHAT_GPU=1
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
# Rollen "gpu-embed" und "gpu-small" (VMs mit Quadro P2000 / M4000): llama.cpp-Server.
|
# Rollen "gpu-embed" und "gpu-small" (GPU-VMs mit Quadro P2000 + M4000): llama.cpp-Server (Task 6.1).
|
||||||
# Maxwell (sm_52) und Pascal (sm_61) brauchen CUDA 12.x – daher eigener Build mit festen Architekturen.
|
# Maxwell (sm_52) und Pascal (sm_61) brauchen CUDA 12.x – daher eigener Build mit festen Architekturen,
|
||||||
# Profile: embed (Embedding + Rerank), chat (Verifier/Signatur), z. B. `docker compose --profile embed up -d`
|
# derselbe Stand wie im Benchmark (ADR-0006): b6000 = Commit 4762ad7, CUDA 12.4.0, sm_52;61.
|
||||||
|
# Profile: embed (Embedding + Rerank, gpu-pve44/46), chat (verifier-small/signature-extractor, gpu-pve48).
|
||||||
|
# Ausgerollt von der Ansible-Rolle gpu-llamacpp (.env mit COMPOSE_PROFILES und Karten-UUIDs).
|
||||||
|
#
|
||||||
|
# Kartenwahl: je Dienst device_ids (UUID oder Index aus `nvidia-smi -L`). Ohne Angabe Index 1 = P2000
|
||||||
|
# (in gpu-pve44/46/48 ist Index 0 die M4000, sie bleibt frei). `count: 1` griffe die M4000.
|
||||||
|
# Netz: Host-Netz wie nex-speech. Veröffentlichte Docker-Ports liefen über DNAT und umgingen ufw;
|
||||||
|
# so gelten spätere ufw-Regeln der VM (Task 4.4).
|
||||||
|
# Datenschutz: kein --verbose (der loggt Prompts), /slots aus (zeigt Prompt-Inhalte), keine Web-Oberfläche.
|
||||||
name: knaster-gpu-llamacpp
|
name: knaster-gpu-llamacpp
|
||||||
|
|
||||||
|
x-logging: &logging
|
||||||
|
driver: local
|
||||||
|
options: {max-size: "5m", max-file: "2"}
|
||||||
|
|
||||||
x-llamacpp: &llamacpp
|
x-llamacpp: &llamacpp
|
||||||
build:
|
build:
|
||||||
context: https://github.com/ggml-org/llama.cpp.git#${LLAMACPP_REF:-b6000}
|
context: https://github.com/ggml-org/llama.cpp.git#${LLAMACPP_COMMIT:-4762ad7316dcdec20016ab5985fb46a27902204d}
|
||||||
dockerfile: .devops/cuda.Dockerfile
|
dockerfile: .devops/cuda.Dockerfile
|
||||||
target: server
|
target: server
|
||||||
args:
|
args:
|
||||||
CUDA_VERSION: ${CUDA_VERSION:-12.4.0}
|
CUDA_VERSION: ${CUDA_VERSION:-12.4.0}
|
||||||
CUDA_DOCKER_ARCH: ${CUDA_DOCKER_ARCH:-52;61}
|
CUDA_DOCKER_ARCH: ${CUDA_DOCKER_ARCH:-52;61}
|
||||||
image: knaster/llamacpp-server:${LLAMACPP_REF:-b6000}-cu12
|
image: knaster/llamacpp-server:${LLAMACPP_REF:-b6000}-cu${CUDA_VERSION:-12.4.0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
read_only: true
|
||||||
|
tmpfs: [/tmp:size=64m]
|
||||||
labels: {autoheal: "true"}
|
labels: {autoheal: "true"}
|
||||||
volumes:
|
volumes:
|
||||||
- ${MODELS_DIR:-/srv/models}:/models:ro
|
- ${MODELS_DIR:-/srv/models}:/models:ro
|
||||||
deploy:
|
logging: *logging
|
||||||
resources:
|
|
||||||
reservations:
|
|
||||||
devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
llamacpp-embed:
|
llamacpp-embed:
|
||||||
@@ -27,60 +39,61 @@ services:
|
|||||||
container_name: llamacpp-embed
|
container_name: llamacpp-embed
|
||||||
profiles: ["embed"]
|
profiles: ["embed"]
|
||||||
command: ["-m", "/models/${EMBED_GGUF:-Qwen3-Embedding-0.6B-Q8_0.gguf}", "--embedding",
|
command: ["-m", "/models/${EMBED_GGUF:-Qwen3-Embedding-0.6B-Q8_0.gguf}", "--embedding",
|
||||||
"--pooling", "${EMBED_POOLING:-last}", "-c", "8192", "-ub", "8192", "-ngl", "99",
|
"--pooling", "${EMBED_POOLING:-last}", "-c", "8192", "-b", "4096", "-ub", "4096", "-np", "1", # ub 8192 sprengt die P2000 (Task 6.1)
|
||||||
"--alias", "embed-default", "--metrics", "--host", "0.0.0.0", "--port", "8081"]
|
"-ngl", "99", "--alias", "embed-default", "--metrics", "--no-slots", "--no-webui",
|
||||||
ports: ["8081:8081"]
|
"--host", "0.0.0.0", "--port", "8081"]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices: [{driver: nvidia, device_ids: ["${EMBED_GPU:-1}"], capabilities: [gpu]}]
|
||||||
healthcheck: &hc
|
healthcheck: &hc
|
||||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8081/health"]
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8081/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 120s
|
||||||
|
|
||||||
llamacpp-rerank:
|
llamacpp-rerank:
|
||||||
<<: *llamacpp
|
<<: *llamacpp
|
||||||
container_name: llamacpp-rerank
|
container_name: llamacpp-rerank
|
||||||
profiles: ["embed"]
|
profiles: ["embed"]
|
||||||
command: ["-m", "/models/${RERANK_GGUF:-bge-reranker-v2-m3-Q8_0.gguf}", "--reranking", "-c", "8192",
|
command: ["-m", "/models/${RERANK_GGUF:-bge-reranker-v2-m3-Q8_0.gguf}", "--reranking",
|
||||||
"-ub", "8192", "-ngl", "99", "--alias", "rerank-default", "--metrics", "--host", "0.0.0.0",
|
"-c", "8192", "-b", "8192", "-ub", "8192", "-np", "1", "-ngl", "99", "--alias", "rerank-default",
|
||||||
"--port", "8082"]
|
"--metrics", "--no-slots", "--no-webui", "--host", "0.0.0.0", "--port", "8082"]
|
||||||
ports: ["8082:8082"]
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices: [{driver: nvidia, device_ids: ["${RERANK_GPU:-1}"], capabilities: [gpu]}]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
<<: *hc
|
<<: *hc
|
||||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8082/health"]
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8082/health"]
|
||||||
|
|
||||||
|
# Qwen3 ohne Denkphase (enable_thinking=false): Verifier und Signatur brauchen kurze, direkte Antworten,
|
||||||
|
# und der Canary-Prober fragt mit max_tokens=5 nach "OK".
|
||||||
llamacpp-chat:
|
llamacpp-chat:
|
||||||
<<: *llamacpp
|
<<: *llamacpp
|
||||||
container_name: llamacpp-chat
|
container_name: llamacpp-chat
|
||||||
profiles: ["chat"]
|
profiles: ["chat"]
|
||||||
command: ["-m", "/models/${CHAT_GGUF:-Qwen3-4B-Q4_K_M.gguf}", "-c", "8192", "-ngl", "99",
|
command: ["-m", "/models/${CHAT_GGUF:-Qwen3-4B-Q4_K_M.gguf}", "-c", "8192", "-np", "1", "-ngl", "99",
|
||||||
"--alias", "verifier-small", "--metrics", "--host", "0.0.0.0", "--port", "8080"]
|
"--jinja", "--chat-template-kwargs", "{\"enable_thinking\": false}",
|
||||||
ports: ["8080:8080"]
|
"--alias", "verifier-small", "--metrics", "--no-slots", "--no-webui",
|
||||||
|
"--host", "0.0.0.0", "--port", "8080"]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices: [{driver: nvidia, device_ids: ["${CHAT_GPU:-1}"], capabilities: [gpu]}]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
<<: *hc
|
<<: *hc
|
||||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
|
||||||
|
|
||||||
gpu-exporter:
|
# Startet Container mit Label autoheal=true neu, wenn ihr Healthcheck dauerhaft scheitert
|
||||||
image: utkuozdemir/nvidia_gpu_exporter:1.2.1
|
# (restart: unless-stopped greift nur beim Absturz). Läuft in jedem Profil mit.
|
||||||
restart: unless-stopped
|
|
||||||
ports: ["9835:9835"]
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
reservations:
|
|
||||||
devices: [{driver: nvidia, count: all, capabilities: [gpu, utility]}]
|
|
||||||
|
|
||||||
node-exporter:
|
|
||||||
image: prom/node-exporter:v1.8.1
|
|
||||||
restart: unless-stopped
|
|
||||||
command: ["--path.rootfs=/host", "--collector.textfile.directory=/textfile"]
|
|
||||||
pid: host
|
|
||||||
volumes:
|
|
||||||
- /:/host:ro,rslave
|
|
||||||
- /var/lib/node_exporter/textfile:/textfile:ro
|
|
||||||
ports: ["9100:9100"]
|
|
||||||
|
|
||||||
autoheal:
|
autoheal:
|
||||||
image: willfarrell/autoheal:1.2.0
|
image: willfarrell/autoheal:1.2.0
|
||||||
|
container_name: llamacpp-autoheal
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment: {AUTOHEAL_CONTAINER_LABEL: autoheal}
|
environment: {AUTOHEAL_CONTAINER_LABEL: autoheal}
|
||||||
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
|
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
|
||||||
|
logging: *logging
|
||||||
|
|
||||||
|
# Exporter (node_exporter 9100, nvidia_gpu_exporter 9835) laufen nativ in der VM (Rolle gpu-exporters).
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
|
# Vorlage für deploy/compose/infra. Im Cluster schreibt die Ansible-Rolle infra-stack die Datei
|
||||||
|
# /opt/knaster/infra/.env (0600) im LXC infra; Geheimnisse erzeugt sie dort mit `openssl rand`.
|
||||||
KNASTER_VERSION=dev
|
KNASTER_VERSION=dev
|
||||||
KNASTER_SITE=knaster
|
KNASTER_SITE=knaster
|
||||||
|
KNASTER_STATE_DIR=.
|
||||||
KNASTER_API_TOKEN=change-me
|
KNASTER_API_TOKEN=change-me
|
||||||
KNASTER_DRY_RUN=true
|
KNASTER_DRY_RUN=true
|
||||||
KNASTER_CANARY_INTERVAL=60
|
KNASTER_CANARY_INTERVAL=60
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
LITELLM_API_KEY=change-me
|
GRAFANA_ADMIN_PASSWORD=change-me
|
||||||
LITELLM_MASTER_KEY=change-me
|
GRAFANA_KIOSK_PASSWORD=change-me
|
||||||
OPENROUTER_API_KEY=
|
# D4: LiteLLM mit PSE auf appsrv08, kein eigener LiteLLM im Stack.
|
||||||
RERANK_URL=http://gpu-embed-1:8082
|
# TODO (Nutzer, Task 6.3): Port festlegen. Der LiteLLM läuft auf appsrv08 nur im internen Netz
|
||||||
|
# (`litellm:4000`); am 12.10. waren von ops aus 4000/4001/8000 auf 192.168.100.29 geschlossen.
|
||||||
|
# Port auf appsrv08 für 192.168.100.0/24 freigeben (oder per Traefik) und hier eintragen.
|
||||||
|
LITELLM_BASE_URL=http://192.168.100.29:4000
|
||||||
|
LITELLM_API_KEY=
|
||||||
|
RERANK_URL=
|
||||||
NOTIFY_WEBHOOK_URL=
|
NOTIFY_WEBHOOK_URL=
|
||||||
KNASTER_JOB_WEBHOOK_URL=
|
KNASTER_JOB_WEBHOOK_URL=
|
||||||
PORTAINER_HOST=
|
PORTAINER_HOST=
|
||||||
@@ -15,7 +23,4 @@ PORTAINER_KEY=
|
|||||||
PROXMOX_URL=
|
PROXMOX_URL=
|
||||||
PROXMOX_TOKEN_ID=
|
PROXMOX_TOKEN_ID=
|
||||||
PROXMOX_TOKEN_SECRET=
|
PROXMOX_TOKEN_SECRET=
|
||||||
KNASTER_WEBHOOK_AUTH=Bearer change-me
|
KNASTER_WEBHOOK_AUTH=
|
||||||
# Ontologie-Abgleich (nur lesend: GET /api/v1/workflows); leer = Abgleich aus
|
|
||||||
N8N_HOST=
|
|
||||||
N8N_API_KEY=
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Rolle "infra" (LXC/VM auf einem i7-Node): Router, LiteLLM, Postgres, Monitoring.
|
# Rolle "infra" (LXC 101 auf pve44, HA nach pve48/pve52): Router, TTS, Postgres und Monitoring in einem Stack.
|
||||||
# Start: cp .env.example .env && docker compose up -d
|
# Kein eigener LiteLLM (D4): Der Router nutzt den LiteLLM mit PSE auf appsrv08 (LITELLM_BASE_URL).
|
||||||
|
#
|
||||||
|
# Lokal: cp .env.example .env && docker compose up -d
|
||||||
|
# Cluster: Ansible-Rolle infra-stack (platform/proxmox/ansible). Zustand und Geheimnisse liegen dort
|
||||||
|
# außerhalb des Repos in KNASTER_STATE_DIR=/opt/knaster/infra (.env, secrets/, file_sd/), Aufruf:
|
||||||
|
# docker compose -p knaster-infra --env-file /opt/knaster/infra/.env -f <repo>/deploy/compose/infra/docker-compose.yml up -d
|
||||||
name: knaster-infra
|
name: knaster-infra
|
||||||
|
|
||||||
x-restart: &restart
|
x-restart: &restart
|
||||||
@@ -13,29 +18,15 @@ services:
|
|||||||
context: ../../..
|
context: ../../..
|
||||||
dockerfile: deploy/docker/router.Dockerfile
|
dockerfile: deploy/docker/router.Dockerfile
|
||||||
image: knaster/router:${KNASTER_VERSION:-dev}
|
image: knaster/router:${KNASTER_VERSION:-dev}
|
||||||
env_file: [{path: .env, required: false}]
|
env_file: [{path: "${KNASTER_STATE_DIR:-.}/.env", required: false}]
|
||||||
environment:
|
environment:
|
||||||
KNASTER_DB_URL: postgresql+psycopg://knaster:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt}@postgres:5432/knaster
|
KNASTER_DB_URL: postgresql+psycopg://knaster:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt}@postgres:5432/knaster
|
||||||
KNASTER_SITE_DIR: /app/sites/${KNASTER_SITE:-knaster}
|
KNASTER_SITE_DIR: /app/sites/${KNASTER_SITE:-knaster}
|
||||||
LITELLM_BASE_URL: http://litellm:4000
|
KNASTER_DRY_RUN: ${KNASTER_DRY_RUN:-true}
|
||||||
|
LITELLM_BASE_URL: ${LITELLM_BASE_URL:?LITELLM_BASE_URL fehlt (LiteLLM auf appsrv08, D4)}
|
||||||
ports: ["8080:8080"]
|
ports: ["8080:8080"]
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: {condition: service_healthy}
|
postgres: {condition: service_healthy}
|
||||||
litellm: {condition: service_started}
|
|
||||||
|
|
||||||
litellm:
|
|
||||||
<<: *restart
|
|
||||||
image: ghcr.io/berriai/litellm:${LITELLM_TAG:-main-stable} # vor Deployment auf feste Version pinnen
|
|
||||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
|
||||||
env_file: [{path: .env, required: false}]
|
|
||||||
volumes:
|
|
||||||
- ../../litellm/config.yaml:/app/config.yaml:ro
|
|
||||||
ports: ["4000:4000"]
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4000/health/liveliness', timeout=4)"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
tts-piper:
|
tts-piper:
|
||||||
<<: *restart
|
<<: *restart
|
||||||
@@ -53,7 +44,7 @@ services:
|
|||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
<<: *restart
|
<<: *restart
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:0.8.7-pg16-trixie
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: knaster
|
POSTGRES_USER: knaster
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt}
|
||||||
@@ -66,42 +57,74 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# --- Monitoring ---------------------------------------------------------------------------------
|
||||||
prometheus:
|
prometheus:
|
||||||
<<: *restart
|
<<: *restart
|
||||||
image: prom/prometheus:v2.53.0
|
image: prom/prometheus:v3.13.4
|
||||||
command:
|
command:
|
||||||
- --config.file=/etc/prometheus/prometheus.yml
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
- --storage.tsdb.retention.time=15d
|
- --storage.tsdb.retention.time=15d
|
||||||
- --web.enable-lifecycle
|
- --web.enable-lifecycle
|
||||||
volumes:
|
volumes:
|
||||||
- ../../prometheus:/etc/prometheus:ro
|
- ../../prometheus:/etc/prometheus:ro
|
||||||
|
# file_sd: Ziele der GPU-VMs (DHCP, IPs wechseln), erzeugt auf ops aus `tofu output -json gpu_vms`
|
||||||
|
- ${KNASTER_STATE_DIR:-.}/file_sd:/etc/knaster-sd:ro
|
||||||
|
# Token und CA für kubelet/cAdvisor in k3s (Task 6.5), nur dieser Unterordner der Geheimnisse
|
||||||
|
- ${KNASTER_STATE_DIR:-.}/secrets/k3s:/etc/knaster-k3s:ro
|
||||||
- prometheus:/prometheus
|
- prometheus:/prometheus
|
||||||
|
extra_hosts: ["host.docker.internal:host-gateway"] # node-exporter des LXC läuft im Host-Netz
|
||||||
ports: ["9090:9090"]
|
ports: ["9090:9090"]
|
||||||
|
|
||||||
alertmanager:
|
alertmanager:
|
||||||
<<: *restart
|
<<: *restart
|
||||||
image: prom/alertmanager:v0.27.0
|
image: prom/alertmanager:v0.34.1
|
||||||
command: ["--config.file=/etc/alertmanager/alertmanager.yml"]
|
command:
|
||||||
|
- --config.file=/etc/alertmanager/alertmanager.yml
|
||||||
|
- --storage.path=/alertmanager
|
||||||
volumes:
|
volumes:
|
||||||
- ../../alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
- ../../alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||||
- ./secrets/router_token:/etc/alertmanager/router_token:ro
|
- ${KNASTER_STATE_DIR:-.}/secrets/router_token:/etc/alertmanager/router_token:ro
|
||||||
|
- alertmanager:/alertmanager
|
||||||
ports: ["9093:9093"]
|
ports: ["9093:9093"]
|
||||||
|
|
||||||
grafana:
|
grafana:
|
||||||
<<: *restart
|
<<: *restart
|
||||||
image: grafana/grafana:11.1.0
|
image: grafana/grafana:12.4.12
|
||||||
profiles: ["dashboards"]
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: admin
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD fehlt} # wirkt nur beim ersten Start
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_USERS_DEFAULT_THEME: dark
|
||||||
|
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||||
|
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
|
||||||
|
GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
|
||||||
|
GF_NEWS_NEWS_FEED_ENABLED: "false"
|
||||||
|
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/knaster-uebersicht.json
|
||||||
volumes:
|
volumes:
|
||||||
|
- ../../grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ../../grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
- grafana:/var/lib/grafana
|
- grafana:/var/lib/grafana
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
|
depends_on: [prometheus]
|
||||||
|
|
||||||
pve-exporter:
|
pve-exporter:
|
||||||
<<: *restart
|
<<: *restart
|
||||||
image: prompve/prometheus-pve-exporter:${PVE_EXPORTER_TAG:-latest} # vor Deployment pinnen
|
image: prompve/prometheus-pve-exporter:3.10.1
|
||||||
profiles: ["proxmox"]
|
# Token monitoring@pve!prom (PVEAuditor) als PVE_USER/PVE_TOKEN_NAME/PVE_TOKEN_VALUE/PVE_VERIFY_SSL
|
||||||
|
env_file: [{path: "${KNASTER_STATE_DIR:-.}/secrets/pve-exporter.env", required: false}]
|
||||||
|
expose: ["9221"]
|
||||||
|
|
||||||
|
node-exporter:
|
||||||
|
<<: *restart
|
||||||
|
image: prom/node-exporter:v1.12.1
|
||||||
|
command:
|
||||||
|
- --path.rootfs=/host
|
||||||
|
- --web.listen-address=:9100
|
||||||
|
network_mode: host
|
||||||
|
pid: host
|
||||||
volumes:
|
volumes:
|
||||||
- ./secrets/pve.yml:/etc/prometheus/pve.yml:ro
|
- /:/host:ro # ohne rslave: im unprivilegierten LXC nicht erlaubt
|
||||||
ports: ["9221:9221"]
|
|
||||||
|
|
||||||
autoheal:
|
autoheal:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -115,4 +138,5 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres: {}
|
postgres: {}
|
||||||
prometheus: {}
|
prometheus: {}
|
||||||
|
alertmanager: {}
|
||||||
grafana: {}
|
grafana: {}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# NEX-Sprachdienste (deploy/compose/nex-speech). Keine Geheimnisse nötig, kein API-Key.
|
||||||
|
MODELS_DIR=/srv/models
|
||||||
|
# Erkennung (whisper.cpp, GPU)
|
||||||
|
STT_PORT=8178
|
||||||
|
STT_GPU=0
|
||||||
|
WHISPER_TAG=v1.9.4
|
||||||
|
WHISPER_COMMIT=927cfce34f31707e17f2bff35c349632fb9e2c3a
|
||||||
|
CUDA_ARCHS=52;61
|
||||||
|
WHISPER_MODEL_FILE=ggml-large-v3-turbo.bin
|
||||||
|
WHISPER_BEAM_SIZE=5
|
||||||
|
# z. B. -nfa (Flash-Attention aus)
|
||||||
|
WHISPER_EXTRA_ARGS=
|
||||||
|
# Ausgabe (Piper, CPU)
|
||||||
|
TTS_PORT=8179
|
||||||
|
PIPER_TTS_VERSION=1.8.0
|
||||||
|
PIPER_VOICES=de_DE-thorsten-high,de_DE-kerstin-low
|
||||||
|
PIPER_DEFAULT_VOICE=de_DE-thorsten-high
|
||||||
|
# 0 = native Rate der Stimme (thorsten-high 22050 Hz, kerstin-low 16000 Hz)
|
||||||
|
PCM_SAMPLE_RATE=24000
|
||||||
|
TTS_MAX_CHARS=5000
|
||||||
|
TTS_PARALLEL=2
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# NEX-Sprachdienste (Task 6.2, Auftrag docs/auftraege/2026-10-12-sprachmodelle-nex.md)
|
||||||
|
# nex-stt Erkennung: whisper.cpp whisper-server (CUDA 12, sm_52/sm_61), Port 8178
|
||||||
|
# nex-tts Ausgabe: Piper (CPU) hinter OpenAI-kompatiblem Wrapper, Port 8179
|
||||||
|
# Beide im Host-Netz: Veröffentlichte Docker-Ports laufen über DNAT/FORWARD und umgehen ufw.
|
||||||
|
# So gelten die ufw-Regeln der VM (8178/8179 nur von 192.168.100.29), siehe Ansible-Rolle nex-speech.
|
||||||
|
# Datenschutz: kein Access-Log, keine Texte im Log, Temp-Dateien nur im tmpfs.
|
||||||
|
name: nex-speech
|
||||||
|
|
||||||
|
x-logging: &logging
|
||||||
|
driver: local
|
||||||
|
options: {max-size: "5m", max-file: "2"}
|
||||||
|
|
||||||
|
services:
|
||||||
|
nex-stt:
|
||||||
|
build:
|
||||||
|
context: ./whisper
|
||||||
|
args:
|
||||||
|
WHISPER_TAG: ${WHISPER_TAG:-v1.9.4}
|
||||||
|
WHISPER_COMMIT: ${WHISPER_COMMIT:-927cfce34f31707e17f2bff35c349632fb9e2c3a}
|
||||||
|
CUDA_ARCHS: ${CUDA_ARCHS:-52;61}
|
||||||
|
image: knaster/nex-whisper:${WHISPER_TAG:-v1.9.4}-cu12.6
|
||||||
|
container_name: nex-stt
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
read_only: true
|
||||||
|
environment:
|
||||||
|
WHISPER_PORT: ${STT_PORT:-8178}
|
||||||
|
WHISPER_MODEL: /models/whisper/${WHISPER_MODEL_FILE:-ggml-large-v3-turbo.bin}
|
||||||
|
WHISPER_LANGUAGE: de
|
||||||
|
WHISPER_BEAM_SIZE: ${WHISPER_BEAM_SIZE:-5}
|
||||||
|
WHISPER_EXTRA_ARGS: ${WHISPER_EXTRA_ARGS:-}
|
||||||
|
volumes:
|
||||||
|
- ${MODELS_DIR:-/srv/models}/whisper:/models/whisper:ro
|
||||||
|
tmpfs:
|
||||||
|
- /run/nex-whisper:size=512m,mode=0700
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices: [{driver: nvidia, device_ids: ["${STT_GPU:-0}"], capabilities: [gpu]}]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:$${WHISPER_PORT}/health >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 300s
|
||||||
|
logging: *logging
|
||||||
|
|
||||||
|
nex-tts:
|
||||||
|
build: ./piper
|
||||||
|
image: knaster/nex-tts-piper:${PIPER_TTS_VERSION:-1.8.0}
|
||||||
|
container_name: nex-tts
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
read_only: true
|
||||||
|
environment:
|
||||||
|
TTS_PORT: ${TTS_PORT:-8179}
|
||||||
|
PIPER_VOICES_DIR: /voices
|
||||||
|
PIPER_VOICES: ${PIPER_VOICES:-de_DE-thorsten-high,de_DE-kerstin-low}
|
||||||
|
PIPER_DEFAULT_VOICE: ${PIPER_DEFAULT_VOICE:-de_DE-thorsten-high}
|
||||||
|
PCM_SAMPLE_RATE: ${PCM_SAMPLE_RATE:-24000}
|
||||||
|
TTS_MAX_CHARS: ${TTS_MAX_CHARS:-5000}
|
||||||
|
TTS_PARALLEL: ${TTS_PARALLEL:-2}
|
||||||
|
volumes:
|
||||||
|
- ${MODELS_DIR:-/srv/models}/piper:/voices:ro
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=64m
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits: {cpus: "3", memory: 2G} # ein Kern bleibt für whisper-server und das System
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import os, urllib.request; urllib.request.urlopen(f\"http://127.0.0.1:{os.environ.get('TTS_PORT', '8179')}/health\", timeout=4)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
logging: *logging
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# NEX-Sprachausgabe: Piper (CPU) hinter einem OpenAI-kompatiblen Wrapper (app.py).
|
||||||
|
# Basis per Digest gepinnt; ffmpeg aus Debian (libmp3lame, libopus).
|
||||||
|
FROM python:3.12-slim-bookworm@sha256:34386ef0cb081344d7ec1c103ba398e6e9f64e9ab3a1509accc92a4e24a07258
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
# /app vorher anlegen: COPY --chmod würde den Elternordner sonst ohne x-Bit erzeugen
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --chmod=0644 requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir --disable-pip-version-check -r /app/requirements.txt
|
||||||
|
COPY --chmod=0644 app.py /app/app.py
|
||||||
|
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
|
||||||
|
USER nobody
|
||||||
|
# Kein Access-Log (Datenschutz), nur Warnungen und Fehler
|
||||||
|
CMD ["sh", "-c", "exec uvicorn app:app --host ${TTS_HOST:-0.0.0.0} --port ${TTS_PORT:-8179} --no-access-log --log-level warning --timeout-graceful-shutdown 5"]
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""OpenAI-kompatible Sprachausgabe für NEX: POST /v1/audio/speech mit Piper auf der CPU.
|
||||||
|
|
||||||
|
Schlanker Wrapper um piper-tts (Python-API) und ffmpeg:
|
||||||
|
- Formate: mp3 (Standard), opus (Ogg-Container), aac, flac, pcm (int16 LE mono) und wav.
|
||||||
|
- mp3/opus/aac/flac/pcm werden satzweise gestreamt (chunked), wav kommt vollständig mit
|
||||||
|
korrektem Header (kompatibel mit `knaster bench tts` und dem Canary-Prober).
|
||||||
|
- pcm wird auf PCM_SAMPLE_RATE umgerechnet (Standard 24000 Hz wie bei OpenAI); die Rate steht in
|
||||||
|
`X-Audio-Sample-Rate` und in `Content-Type: audio/pcm; rate=<Hz>`.
|
||||||
|
- Datenschutz: Text und Audio werden weder gespeichert noch geloggt; alles läuft über Pipes im RAM.
|
||||||
|
|
||||||
|
Umgebung:
|
||||||
|
PIPER_VOICES_DIR Verzeichnis mit <stimme>.onnx und <stimme>.onnx.json (Standard /voices)
|
||||||
|
PIPER_VOICES Kommagetrennte Stimmen-IDs (Standard de_DE-thorsten-high,de_DE-kerstin-low)
|
||||||
|
PIPER_DEFAULT_VOICE Standardstimme (Standard: erste aus PIPER_VOICES)
|
||||||
|
PCM_SAMPLE_RATE Rate für response_format=pcm, 0 = native Rate der Stimme (Standard 24000)
|
||||||
|
TTS_MAX_CHARS Höchstlänge von input in Zeichen (Standard 5000)
|
||||||
|
TTS_PARALLEL Gleichzeitige Synthesen (Standard 2)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import wave
|
||||||
|
from collections.abc import AsyncGenerator, AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||||
|
from piper import PiperVoice, SynthesisConfig
|
||||||
|
|
||||||
|
log = logging.getLogger("nex-tts")
|
||||||
|
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "WARNING").upper(),
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
VOICES_DIR = Path(os.environ.get("PIPER_VOICES_DIR", "/voices"))
|
||||||
|
VOICE_IDS = [v.strip() for v in os.environ.get("PIPER_VOICES", "de_DE-thorsten-high,de_DE-kerstin-low").split(",")
|
||||||
|
if v.strip()]
|
||||||
|
DEFAULT_VOICE = os.environ.get("PIPER_DEFAULT_VOICE") or VOICE_IDS[0]
|
||||||
|
PCM_RATE = int(os.environ.get("PCM_SAMPLE_RATE", "24000"))
|
||||||
|
MAX_CHARS = int(os.environ.get("TTS_MAX_CHARS", "5000"))
|
||||||
|
PARALLEL = threading.Semaphore(int(os.environ.get("TTS_PARALLEL", "2"))) # CPU nicht überbuchen
|
||||||
|
|
||||||
|
# ffmpeg-Ausgabeparameter je Format: (Argumente, Content-Type)
|
||||||
|
ENCODERS: dict[str, tuple[list[str], str]] = {
|
||||||
|
"mp3": (["-c:a", "libmp3lame", "-b:a", "64k", "-id3v2_version", "0", "-write_xing", "0", "-f", "mp3"], "audio/mpeg"),
|
||||||
|
# Ogg-Seiten klein halten (20 ms), sonst puffert der Muxer bis zu 1 s
|
||||||
|
"opus": (["-ar", "48000", "-c:a", "libopus", "-b:a", "32k", "-f", "ogg", "-page_duration", "20000"], "audio/ogg"),
|
||||||
|
"aac": (["-c:a", "aac", "-b:a", "64k", "-f", "adts"], "audio/aac"),
|
||||||
|
"flac": (["-c:a", "flac", "-f", "flac"], "audio/flac"),
|
||||||
|
}
|
||||||
|
FORMATS = {*ENCODERS, "pcm", "wav"}
|
||||||
|
|
||||||
|
voices: dict[str, PiperVoice] = {}
|
||||||
|
aliases: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_voices() -> None:
|
||||||
|
for vid in VOICE_IDS:
|
||||||
|
model = VOICES_DIR / f"{vid}.onnx"
|
||||||
|
voice = PiperVoice.load(str(model), config_path=str(model) + ".json", use_cuda=False)
|
||||||
|
voices[vid] = voice
|
||||||
|
# Kurzname: de_DE-thorsten-high → thorsten-high und thorsten
|
||||||
|
short = vid.split("-", 1)[1] if "-" in vid else vid
|
||||||
|
aliases.setdefault(short, vid)
|
||||||
|
aliases.setdefault(short.rsplit("-", 1)[0], vid)
|
||||||
|
# Aufwärmen, damit der erste echte Aufruf nicht die ONNX-Initialisierung bezahlt
|
||||||
|
for _ in voice.synthesize("Hallo."):
|
||||||
|
pass
|
||||||
|
if DEFAULT_VOICE not in voices:
|
||||||
|
raise RuntimeError(f"Standardstimme {DEFAULT_VOICE} ist nicht geladen")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_voice(name: str | None) -> str:
|
||||||
|
if not name or name == "default":
|
||||||
|
return DEFAULT_VOICE
|
||||||
|
if name in voices:
|
||||||
|
return name
|
||||||
|
if name in aliases:
|
||||||
|
return aliases[name]
|
||||||
|
# OpenAI-Clients schicken oft "alloy" o. Ä.: nicht scheitern, sondern Standardstimme nehmen
|
||||||
|
log.warning("Stimme %r unbekannt, nutze %s", name[:40], DEFAULT_VOICE)
|
||||||
|
return DEFAULT_VOICE
|
||||||
|
|
||||||
|
|
||||||
|
async def synth_pcm(voice: PiperVoice, text: str, cfg: SynthesisConfig) -> AsyncIterator[bytes]:
|
||||||
|
"""Piper satzweise in einem Thread rechnen und die int16-Blöcke asynchron weiterreichen."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
queue: asyncio.Queue[bytes | BaseException | None] = asyncio.Queue()
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def work() -> None:
|
||||||
|
try:
|
||||||
|
with PARALLEL:
|
||||||
|
for chunk in voice.synthesize(text, syn_config=cfg):
|
||||||
|
if stop.is_set():
|
||||||
|
break
|
||||||
|
loop.call_soon_threadsafe(queue.put_nowait, chunk.audio_int16_bytes)
|
||||||
|
except BaseException as exc: # noqa: BLE001 – an den Aufrufer weitergeben
|
||||||
|
loop.call_soon_threadsafe(queue.put_nowait, exc)
|
||||||
|
finally:
|
||||||
|
loop.call_soon_threadsafe(queue.put_nowait, None)
|
||||||
|
|
||||||
|
loop.run_in_executor(None, work)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
item = await queue.get()
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
if isinstance(item, BaseException):
|
||||||
|
raise item
|
||||||
|
yield item
|
||||||
|
finally:
|
||||||
|
stop.set() # Client weg oder fertig: Thread bricht nach dem laufenden Satz ab
|
||||||
|
|
||||||
|
|
||||||
|
async def encode(pcm: AsyncIterator[bytes], rate: int, out_args: list[str]) -> AsyncIterator[bytes]:
|
||||||
|
"""Rohes PCM per ffmpeg (stdin → stdout) umwandeln und dabei weiterstreamen."""
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||||
|
"-f", "s16le", "-ar", str(rate), "-ac", "1", "-i", "pipe:0",
|
||||||
|
*out_args, "-flush_packets", "1", "pipe:1",
|
||||||
|
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||||
|
assert proc.stdin is not None and proc.stdout is not None
|
||||||
|
|
||||||
|
async def feed() -> None:
|
||||||
|
try:
|
||||||
|
async for block in pcm:
|
||||||
|
proc.stdin.write(block)
|
||||||
|
await proc.stdin.drain()
|
||||||
|
finally:
|
||||||
|
proc.stdin.close()
|
||||||
|
|
||||||
|
feeder = asyncio.create_task(feed())
|
||||||
|
try:
|
||||||
|
while data := await proc.stdout.read(8192):
|
||||||
|
yield data
|
||||||
|
await feeder
|
||||||
|
if await proc.wait() != 0:
|
||||||
|
raise RuntimeError(f"ffmpeg beendet mit {proc.returncode}")
|
||||||
|
finally:
|
||||||
|
if not feeder.done():
|
||||||
|
feeder.cancel()
|
||||||
|
if proc.returncode is None:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def primed(stream: AsyncGenerator[bytes, None]) -> AsyncIterator[bytes]:
|
||||||
|
"""Erst den ersten Audioblock abwarten, dann die Header senden.
|
||||||
|
|
||||||
|
So misst die Zeit bis zum ersten Byte echtes Audio, und ein Fehler beim Start wird noch
|
||||||
|
als HTTP-Fehler gemeldet. Fehler mitten im Stream werden nur ohne Text protokolliert.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
first = await anext(stream)
|
||||||
|
except StopAsyncIteration:
|
||||||
|
first = b""
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.error("Synthese fehlgeschlagen: %s", type(exc).__name__)
|
||||||
|
raise HTTPException(500, "Synthese fehlgeschlagen") from None
|
||||||
|
|
||||||
|
async def rest() -> AsyncIterator[bytes]:
|
||||||
|
try:
|
||||||
|
if first:
|
||||||
|
yield first
|
||||||
|
async for data in stream:
|
||||||
|
yield data
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Synthese abgebrochen: %s", type(exc).__name__)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await stream.aclose()
|
||||||
|
|
||||||
|
return rest()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
|
load_voices()
|
||||||
|
log.warning("nex-tts bereit: Stimmen %s, Standard %s, PCM %s Hz", ", ".join(voices), DEFAULT_VOICE,
|
||||||
|
PCM_RATE or "nativ")
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="nex-tts (Piper)", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"status": "ok", "voices": list(voices), "default": DEFAULT_VOICE}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
def models() -> dict:
|
||||||
|
return {"object": "list", "data": [{"id": "piper", "object": "model", "owned_by": "knaster"}]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/audio/voices")
|
||||||
|
def list_voices() -> dict:
|
||||||
|
return {"voices": [{"id": vid, "sample_rate": v.config.sample_rate} for vid, v in voices.items()],
|
||||||
|
"default": DEFAULT_VOICE}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/audio/speech")
|
||||||
|
async def speech(request: Request) -> Response:
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "Body muss JSON sein") from None
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Body muss ein JSON-Objekt sein")
|
||||||
|
# Unbekannte Felder (model, instructions, stream_format, …) werden ignoriert.
|
||||||
|
text = body.get("input")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise HTTPException(400, "Feld input fehlt oder ist leer")
|
||||||
|
if len(text) > MAX_CHARS:
|
||||||
|
raise HTTPException(400, f"input ist länger als {MAX_CHARS} Zeichen")
|
||||||
|
fmt = str(body.get("response_format") or "mp3").lower()
|
||||||
|
if fmt not in FORMATS:
|
||||||
|
raise HTTPException(400, f"response_format {fmt[:20]!r} nicht unterstützt ({', '.join(sorted(FORMATS))})")
|
||||||
|
try:
|
||||||
|
speed = float(body.get("speed") or 1.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "speed muss eine Zahl sein") from None
|
||||||
|
speed = min(max(speed, 0.25), 4.0)
|
||||||
|
|
||||||
|
vid = resolve_voice(body.get("voice") if isinstance(body.get("voice"), str) else None)
|
||||||
|
voice = voices[vid]
|
||||||
|
rate = voice.config.sample_rate
|
||||||
|
cfg = SynthesisConfig(length_scale=(voice.config.length_scale or 1.0) / speed)
|
||||||
|
headers = {"X-Voice": vid, "Cache-Control": "no-store"}
|
||||||
|
|
||||||
|
if fmt == "wav":
|
||||||
|
# Vollständig mit korrektem Header (kurze Texte; für Streaming pcm/mp3/opus nehmen)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with wave.open(buf, "wb") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(rate)
|
||||||
|
async for block in synth_pcm(voice, text, cfg):
|
||||||
|
w.writeframes(block)
|
||||||
|
return Response(buf.getvalue(), media_type="audio/wav", headers=headers)
|
||||||
|
|
||||||
|
if fmt == "pcm":
|
||||||
|
out_rate = PCM_RATE or rate
|
||||||
|
headers |= {"X-Audio-Sample-Rate": str(out_rate), "X-Audio-Channels": "1", "X-Audio-Format": "s16le"}
|
||||||
|
media = f"audio/pcm; rate={out_rate}"
|
||||||
|
if out_rate == rate:
|
||||||
|
stream = synth_pcm(voice, text, cfg)
|
||||||
|
else:
|
||||||
|
stream = encode(synth_pcm(voice, text, cfg), rate, ["-ar", str(out_rate), "-f", "s16le"])
|
||||||
|
return StreamingResponse(await primed(stream), media_type=media, headers=headers)
|
||||||
|
|
||||||
|
out_args, media = ENCODERS[fmt]
|
||||||
|
stream = encode(synth_pcm(voice, text, cfg), rate, out_args)
|
||||||
|
return StreamingResponse(await primed(stream), media_type=media, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def _http_error(_: Request, exc: HTTPException) -> JSONResponse:
|
||||||
|
# OpenAI-Fehlerformat, ohne den Eingabetext zu wiederholen
|
||||||
|
return JSONResponse({"error": {"message": exc.detail, "type": "invalid_request_error"}},
|
||||||
|
status_code=exc.status_code)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Feste Versionen für den NEX-Sprachausgabe-Wrapper (Stand 12.10.2026)
|
||||||
|
piper-tts==1.8.0 # GPL-3.0-or-later, eigener Dienst (siehe catalog/models.yaml)
|
||||||
|
onnxruntime==1.30.0
|
||||||
|
fastapi==0.141.1
|
||||||
|
pydantic==2.13.5
|
||||||
|
uvicorn==0.53.0
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# whisper.cpp whisper-server für Maxwell (sm_52, Quadro M4000) und Pascal (sm_61, Quadro P2000).
|
||||||
|
# CUDA 12 ist Pflicht: CUDA 13 unterstützt Maxwell/Pascal nicht mehr. Treiber R580 in der VM.
|
||||||
|
# Version und Commit sind fest; ein Wechsel ist ein bewusster Schritt (Tag und Commit gemeinsam ändern).
|
||||||
|
ARG CUDA_DEVEL=nvidia/cuda:12.6.3-devel-ubuntu24.04@sha256:392c0df7b577ecae17a17f6ba7f2009c217bb4422f8431c053ae9af61a8c148a
|
||||||
|
ARG CUDA_RUNTIME=nvidia/cuda:12.6.3-runtime-ubuntu24.04@sha256:92906d87596d638d35015c6353053121bd299d25943b875763321653884ba924
|
||||||
|
|
||||||
|
FROM ${CUDA_DEVEL} AS build
|
||||||
|
ARG WHISPER_TAG=v1.9.4
|
||||||
|
ARG WHISPER_COMMIT=927cfce34f31707e17f2bff35c349632fb9e2c3a
|
||||||
|
ARG CUDA_ARCHS="52;61"
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git cmake build-essential ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /src
|
||||||
|
RUN git clone --depth 1 --branch "${WHISPER_TAG}" https://github.com/ggml-org/whisper.cpp.git . \
|
||||||
|
&& test "$(git rev-parse HEAD)" = "${WHISPER_COMMIT}"
|
||||||
|
# Statisch gegen ggml/whisper linken: eine einzelne Binary, keine .so-Kopien in der Laufzeit-Stufe.
|
||||||
|
# libcuda kommt zur Laufzeit vom Treiber; beim Bauen reicht der Stub.
|
||||||
|
RUN cmake -B build \
|
||||||
|
-DGGML_CUDA=ON \
|
||||||
|
-DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \
|
||||||
|
-DBUILD_SHARED_LIBS=OFF \
|
||||||
|
-DWHISPER_BUILD_TESTS=OFF \
|
||||||
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DCMAKE_EXE_LINKER_FLAGS="-L/usr/local/cuda/lib64/stubs" \
|
||||||
|
&& cmake --build build --target whisper-server -j"$(nproc)" \
|
||||||
|
&& strip build/bin/whisper-server
|
||||||
|
|
||||||
|
FROM ${CUDA_RUNTIME}
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg curl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /src/build/bin/whisper-server /usr/local/bin/whisper-server
|
||||||
|
# ffmpeg-Hülle: whisper-server ruft "ffmpeg … 2>&1" auf und schriebe sonst Banner und
|
||||||
|
# Container-Metadaten des Uploads ins Log. Die Hülle liegt vor /usr/bin im PATH.
|
||||||
|
COPY --chmod=0755 ffmpeg-quiet.sh /usr/local/bin/ffmpeg
|
||||||
|
COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Start von whisper-server für NEX (Spracherkennung, OpenAI-kompatibel).
|
||||||
|
# - Parameter laut Auftrag: -l de -bs 5 --convert, ohne --print-realtime (kein Text im Log).
|
||||||
|
# - Temp-Dateien nur im tmpfs; Reste (Fehlerpfad von whisper-server) werden regelmäßig gelöscht.
|
||||||
|
# - Nach dem Start einmal mit 1 s Stille aufwärmen.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
MODEL="${WHISPER_MODEL:-/models/whisper/ggml-large-v3-turbo.bin}"
|
||||||
|
PORT="${WHISPER_PORT:-8178}"
|
||||||
|
HOST="${WHISPER_HOST:-0.0.0.0}"
|
||||||
|
TMP_DIR="${WHISPER_TMP_DIR:-/run/nex-whisper}"
|
||||||
|
BEAM="${WHISPER_BEAM_SIZE:-5}"
|
||||||
|
LANGUAGE="${WHISPER_LANGUAGE:-de}"
|
||||||
|
# Zusätzliche Schalter, z. B. "-nfa" (Flash-Attention aus) oder "-t 4"
|
||||||
|
EXTRA="${WHISPER_EXTRA_ARGS:-}"
|
||||||
|
|
||||||
|
mkdir -p "$TMP_DIR"
|
||||||
|
chmod 0700 "$TMP_DIR"
|
||||||
|
|
||||||
|
# Aufräumer: Dateien älter als 10 Minuten im tmpfs löschen
|
||||||
|
( while sleep 300; do find "$TMP_DIR" -type f -mmin +10 -delete 2>/dev/null || true; done ) &
|
||||||
|
cleaner=$!
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
whisper-server -m "$MODEL" -l "$LANGUAGE" -bs "$BEAM" --convert --tmp-dir "$TMP_DIR" \
|
||||||
|
--inference-path /v1/audio/transcriptions --host "$HOST" --port "$PORT" $EXTRA &
|
||||||
|
pid=$!
|
||||||
|
|
||||||
|
stop() { kill -TERM "$pid" 2>/dev/null || true; kill "$cleaner" 2>/dev/null || true; }
|
||||||
|
trap stop TERM INT
|
||||||
|
|
||||||
|
# Warten, bis das Modell geladen ist (/health liefert bis dahin 503)
|
||||||
|
i=0
|
||||||
|
until curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; do
|
||||||
|
kill -0 "$pid" 2>/dev/null || { echo "whisper-server beendet sich beim Start" >&2; wait "$pid" || exit $?; exit 1; }
|
||||||
|
i=$((i + 1))
|
||||||
|
[ "$i" -le 600 ] || { echo "whisper-server nach 600 s nicht bereit" >&2; stop; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Aufwärmen: 1 s Stille, Ergebnis wird verworfen
|
||||||
|
warm="$TMP_DIR/warmup.wav"
|
||||||
|
if ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -c:a pcm_s16le -y "$warm" \
|
||||||
|
&& curl -fsS -o /dev/null -F "file=@${warm}" -F response_format=json \
|
||||||
|
"http://127.0.0.1:${PORT}/v1/audio/transcriptions"; then
|
||||||
|
echo "whisper-server bereit und aufgewärmt (Port ${PORT})"
|
||||||
|
else
|
||||||
|
echo "Aufwärmen fehlgeschlagen, Dienst läuft trotzdem" >&2
|
||||||
|
fi
|
||||||
|
rm -f "$warm"
|
||||||
|
|
||||||
|
# Auf whisper-server warten; nach einem Signal erneut warten, bis er wirklich beendet ist
|
||||||
|
rc=0
|
||||||
|
wait "$pid" || rc=$?
|
||||||
|
while kill -0 "$pid" 2>/dev/null; do wait "$pid" || rc=$?; done
|
||||||
|
kill "$cleaner" 2>/dev/null || true
|
||||||
|
exit "$rc"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Leise ffmpeg-Hülle für whisper-server (--convert): kein Banner, keine Metadaten, nur Fehler.
|
||||||
|
exec /usr/bin/ffmpeg -hide_banner -nostdin -loglevel error "$@"
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
{
|
||||||
|
"uid": "k3s-pods",
|
||||||
|
"title": "k3s – Pods",
|
||||||
|
"tags": [
|
||||||
|
"knaster",
|
||||||
|
"k3s"
|
||||||
|
],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"editable": false,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": {
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "namespace",
|
||||||
|
"label": "Namespace",
|
||||||
|
"type": "query",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"query": "label_values(kube_namespace_status_phase, namespace)",
|
||||||
|
"refId": "ns"
|
||||||
|
},
|
||||||
|
"definition": "label_values(kube_namespace_status_phase, namespace)",
|
||||||
|
"refresh": 2,
|
||||||
|
"includeAll": true,
|
||||||
|
"multi": true,
|
||||||
|
"sort": 1,
|
||||||
|
"current": {
|
||||||
|
"selected": true,
|
||||||
|
"text": [
|
||||||
|
"nex"
|
||||||
|
],
|
||||||
|
"value": [
|
||||||
|
"nex"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Pods laufend",
|
||||||
|
"description": "",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum(kube_pod_status_phase{namespace=~\"$namespace\", phase=\"Running\"}) or vector(0)",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"decimals": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Pods nicht bereit",
|
||||||
|
"description": "",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 4,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum(kube_pod_status_ready{namespace=~\"$namespace\", condition=\"false\"}) or vector(0)",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"decimals": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Neustarts (24 h)",
|
||||||
|
"description": "",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 8,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "round(sum(increase(kube_pod_container_status_restarts_total{namespace=~\"$namespace\"}[24h]))) or vector(0)",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"decimals": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Speicher (Arbeitssatz)",
|
||||||
|
"description": "",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum(container_memory_working_set_bytes{job=\"cadvisor\", namespace=~\"$namespace\"})",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "bytes",
|
||||||
|
"decimals": null,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "CPU (Kerne)",
|
||||||
|
"description": "",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum(rate(container_cpu_usage_seconds_total{job=\"cadvisor\", namespace=~\"$namespace\"}[5m]))",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"decimals": null,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "k3s-Knoten bereit",
|
||||||
|
"description": "",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 20,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum(kube_node_status_condition{condition=\"Ready\", status=\"true\"}) or vector(0)",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"decimals": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Speicher je Pod",
|
||||||
|
"description": "Arbeitssatz (container_memory_working_set_bytes), danach richtet sich das OOM-Limit",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 9
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (pod) (container_memory_working_set_bytes{job=\"cadvisor\", namespace=~\"$namespace\"})",
|
||||||
|
"legendFormat": "{{pod}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "bytes",
|
||||||
|
"custom": {
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"lineWidth": 1,
|
||||||
|
"showPoints": "never"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "CPU je Pod",
|
||||||
|
"description": "Kerne, Mittel über 5 min",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 9
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (pod) (rate(container_cpu_usage_seconds_total{job=\"cadvisor\", namespace=~\"$namespace\"}[5m]))",
|
||||||
|
"legendFormat": "{{pod}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short",
|
||||||
|
"custom": {
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"lineWidth": 1,
|
||||||
|
"showPoints": "never"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "table",
|
||||||
|
"title": "Pods",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 13,
|
||||||
|
"w": 24,
|
||||||
|
"h": 9
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "max by (pod, node) (kube_pod_info{namespace=~\"$namespace\"})",
|
||||||
|
"legendFormat": "",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "B",
|
||||||
|
"expr": "sum by (pod) (kube_pod_container_status_restarts_total{namespace=~\"$namespace\"})",
|
||||||
|
"legendFormat": "",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "C",
|
||||||
|
"expr": "sum by (pod) (container_memory_working_set_bytes{job=\"cadvisor\", namespace=~\"$namespace\"})",
|
||||||
|
"legendFormat": "",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "D",
|
||||||
|
"expr": "sum by (pod) (kube_pod_container_resource_requests{namespace=~\"$namespace\", resource=\"memory\"})",
|
||||||
|
"legendFormat": "",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "E",
|
||||||
|
"expr": "sum by (pod) (kube_pod_container_resource_limits{namespace=~\"$namespace\", resource=\"memory\"})",
|
||||||
|
"legendFormat": "",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "merge",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "organize",
|
||||||
|
"options": {
|
||||||
|
"excludeByName": {
|
||||||
|
"Time": true,
|
||||||
|
"Value #A": true
|
||||||
|
},
|
||||||
|
"indexByName": {
|
||||||
|
"pod": 0,
|
||||||
|
"node": 1,
|
||||||
|
"Value #B": 2,
|
||||||
|
"Value #C": 3,
|
||||||
|
"Value #D": 4,
|
||||||
|
"Value #E": 5
|
||||||
|
},
|
||||||
|
"renameByName": {
|
||||||
|
"pod": "Pod",
|
||||||
|
"node": "Knoten",
|
||||||
|
"Value #B": "Neustarts",
|
||||||
|
"Value #C": "Speicher",
|
||||||
|
"Value #D": "Request",
|
||||||
|
"Value #E": "Limit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byRegexp",
|
||||||
|
"options": "Speicher|Request|Limit"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "unit",
|
||||||
|
"value": "bytes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Neustarts"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.cellOptions",
|
||||||
|
"value": {
|
||||||
|
"type": "color-background"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "thresholds",
|
||||||
|
"value": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "transparent",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": [
|
||||||
|
{
|
||||||
|
"displayName": "Speicher",
|
||||||
|
"desc": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Pods je Knoten (alle Namespaces)",
|
||||||
|
"description": "",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 22,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (node) (kube_pod_info)",
|
||||||
|
"legendFormat": "{{node}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"custom": {
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"lineWidth": 1,
|
||||||
|
"showPoints": "never"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Speicher der Pods je Knoten (alle Namespaces)",
|
||||||
|
"description": "",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 22,
|
||||||
|
"w": 12,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum by (instance) (container_memory_working_set_bytes{job=\"cadvisor\"})",
|
||||||
|
"legendFormat": "{{instance}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "bytes",
|
||||||
|
"custom": {
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"lineWidth": 1,
|
||||||
|
"showPoints": "never"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "right",
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"max"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"type": "bargauge",
|
||||||
|
"title": "Belegung der Volumes (PVC)",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 30,
|
||||||
|
"w": 24,
|
||||||
|
"h": 6
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "max by (persistentvolumeclaim) (kubelet_volume_stats_used_bytes{namespace=~\"$namespace\"} / kubelet_volume_stats_capacity_bytes{namespace=~\"$namespace\"})",
|
||||||
|
"legendFormat": "{{persistentvolumeclaim}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"min": 0,
|
||||||
|
"max": 1,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 0.9
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"displayMode": "gradient",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
{
|
||||||
|
"uid": "knaster-uebersicht",
|
||||||
|
"title": "knaster – Übersicht",
|
||||||
|
"tags": [
|
||||||
|
"knaster"
|
||||||
|
],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"editable": false,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": {
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Proxmox-Knoten online",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "count(max by (id) (pve_up{id=~\"node/.*\"}) == 1) or vector(0)",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Gäste laufend",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 4,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "count(max by (id) (pve_up{id=~\"(qemu|lxc)/.*\"}) == 1) or vector(0)",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "GPUs gemeldet",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 8,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "count(count by (instance, uuid) (nvidia_smi_index)) or vector(0)",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Ziele nicht erreichbar",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "count(up{job!~\"vllm|llamacpp\"} == 0) or vector(0)",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Aktive Alerts",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "count(ALERTS{alertstate=\"firing\"}) or vector(0)",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Slots verfügbar",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 20,
|
||||||
|
"y": 0,
|
||||||
|
"w": 4,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "sum(knaster_slot_available) or vector(0)",
|
||||||
|
"legendFormat": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "none",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Knoten",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "max by (id) (pve_up{id=~\"node/.*\"})",
|
||||||
|
"legendFormat": "{{id}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"0": {
|
||||||
|
"text": "aus",
|
||||||
|
"color": "red"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"text": "an",
|
||||||
|
"color": "green"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "GPU-VMs und infra (node_exporter)",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 4,
|
||||||
|
"w": 12,
|
||||||
|
"h": 4
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "up{job=~\"node|infra-node\"}",
|
||||||
|
"legendFormat": "{{instance}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"0": {
|
||||||
|
"text": "aus",
|
||||||
|
"color": "red"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"text": "an",
|
||||||
|
"color": "green"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "CPU je Knoten",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 8,
|
||||||
|
"w": 12,
|
||||||
|
"h": 7
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "max by (id) (pve_cpu_usage_ratio{id=~\"node/.*\"})",
|
||||||
|
"legendFormat": "{{id}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"min": 0,
|
||||||
|
"max": 1
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "RAM je Knoten",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 8,
|
||||||
|
"w": 12,
|
||||||
|
"h": 7
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "max by (id) (pve_memory_usage_bytes{id=~\"node/.*\"}) / max by (id) (pve_memory_size_bytes{id=~\"node/.*\"})",
|
||||||
|
"legendFormat": "{{id}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"min": 0,
|
||||||
|
"max": 1
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "GPU-Temperatur",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 15,
|
||||||
|
"w": 8,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "max by (instance, uuid) (nvidia_smi_temperature_gpu) * on (instance, uuid) group_left(name) max by (instance, uuid, name) (nvidia_smi_gpu_info)",
|
||||||
|
"legendFormat": "{{instance}} {{name}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "celsius"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "GPU-Auslastung",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 8,
|
||||||
|
"y": 15,
|
||||||
|
"w": 8,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "max by (instance, uuid) (nvidia_smi_utilization_gpu_ratio) * on (instance, uuid) group_left(name) max by (instance, uuid, name) (nvidia_smi_gpu_info)",
|
||||||
|
"legendFormat": "{{instance}} {{name}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"min": 0,
|
||||||
|
"max": 1
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "GPU-Speicher belegt",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 16,
|
||||||
|
"y": 15,
|
||||||
|
"w": 8,
|
||||||
|
"h": 8
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "max by (instance, uuid) (nvidia_smi_memory_used_bytes / nvidia_smi_memory_total_bytes) * on (instance, uuid) group_left(name) max by (instance, uuid, name) (nvidia_smi_gpu_info)",
|
||||||
|
"legendFormat": "{{instance}} {{name}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"min": 0,
|
||||||
|
"max": 1
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Slots (Router)",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 23,
|
||||||
|
"w": 12,
|
||||||
|
"h": 5
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "knaster_slot_available",
|
||||||
|
"legendFormat": "{{slot}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"options": {
|
||||||
|
"0": {
|
||||||
|
"text": "aus",
|
||||||
|
"color": "red"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"text": "an",
|
||||||
|
"color": "green"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"textMode": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"type": "table",
|
||||||
|
"title": "Aktive Alerts",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 12,
|
||||||
|
"y": 23,
|
||||||
|
"w": 12,
|
||||||
|
"h": 5
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "ALERTS{alertstate=\"firing\"}",
|
||||||
|
"legendFormat": "",
|
||||||
|
"instant": true,
|
||||||
|
"format": "table"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "organize",
|
||||||
|
"options": {
|
||||||
|
"excludeByName": {
|
||||||
|
"Time": true,
|
||||||
|
"Value": true,
|
||||||
|
"__name__": true,
|
||||||
|
"alertstate": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"templating": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
# Dashboards aus deploy/grafana/dashboards (im Container /var/lib/grafana/dashboards).
|
||||||
|
# Herkunft der Community-Dashboards (grafana.com, geladen am 12.10.2026; ${DS_PROMETHEUS} → uid prometheus, __inputs entfernt):
|
||||||
|
# proxmox.json ID 10347 „Proxmox via Prometheus“ Revision 5 (für prometheus-pve-exporter)
|
||||||
|
# node-exporter-full.json ID 1860 „Node Exporter Full“ Revision 45
|
||||||
|
# nvidia-gpu.json ID 14574 „Nvidia GPU Metrics“ Revision 15 (für utkuozdemir/nvidia_gpu_exporter)
|
||||||
|
# knaster-uebersicht.json eigenes Dashboard (Startseite, für den Kiosk in Task 5.3)
|
||||||
|
# k3s-pods.json eigenes Dashboard (Task 6.5): Pods je Namespace (Vorgabe nex), kube-state-metrics + cAdvisor
|
||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: knaster
|
||||||
|
folder: knaster
|
||||||
|
type: file
|
||||||
|
disableDeletion: true
|
||||||
|
allowUiUpdates: false
|
||||||
|
updateIntervalSeconds: 60
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Feste uid "prometheus": Die Dashboards in deploy/grafana/dashboards verweisen darauf.
|
||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
uid: prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Kubernetes-Manifeste (k3s, D6)
|
||||||
|
|
||||||
|
Ausgerollt von ops mit `kubectl` (Rolle `k3s-apps`, Playbook `playbooks/k3s.yml`):
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `runtimeclass-nvidia.yaml` | RuntimeClass `nvidia` (Runtime legt k3s in den GPU-VMs selbst an) |
|
||||||
|
| `nvidia-device-plugin.yaml` | NVIDIA Device Plugin v0.20.1, nur auf Knoten mit Label `knaster.gpu=true`; liefert `nvidia.com/gpu`, seit 19.10. nur für Index 0 (M4000) |
|
||||||
|
| `portainer-agent.yaml` | Portainer Agent 2.45.2 (NodePort 30778), Quelle downloads.portainer.io/ce2-45 |
|
||||||
|
| `nex-speech/` | NEX-Sprachdienste auf gpu-pve52 (Playbook `playbooks/nex-speech-k3s.yml`) |
|
||||||
|
| `nex-searxng/` | NEX-Websuche SearXNG, NodePort 30880 (Playbook `playbooks/nex-searxng-k3s.yml`), Einstellungen aus dem nex-Repo |
|
||||||
|
| `portainer/` | Portainer CE 2.45.1 auf der Dienst-VM k3s, Datenbank von appsrv08 übernommen (Playbook `playbooks/portainer-k3s.yml` mit Rollback BE→CE), NodePort 30779 |
|
||||||
|
| `nex/` | Namespace nex für Agenten im Cluster: Brücke zu den NEX-Diensten auf appsrv08 (Services ohne Selector), Zugang für nex-admin (ServiceAccount, Role), Pull-Secret Gitea (Playbook `playbooks/nex-k3s.yml`); nex-Spec 2026-10-19 |
|
||||||
|
| `llamacpp/` | llama.cpp-Server je GPU-VM (Namespace `knaster`), Vorlage `llamacpp.yaml.j2` mit der UUID der P2000 je Knoten (Playbook `playbooks/gpu-llamacpp-k3s.yml`, Task 6.1) |
|
||||||
|
| `monitoring/` | kube-state-metrics v2.19.1 (NodePort 30808) und ServiceAccount `prometheus-infra` (nur `nodes/metrics`) für Prometheus auf infra (Playbook `playbooks/monitoring-k3s.yml`, Task 6.5) |
|
||||||
|
| `rustdesk/` | RustDesk-Server hbbs/hbbr, Service LoadBalancer 21115–21119 auf jeder Knoten-Adresse (Playbook `playbooks/rustdesk-k3s.yml`), Schlüssel im Secret `rustdesk-keys` |
|
||||||
|
|
||||||
|
Portainer: Umgebung „Kubernetes → Agent“, Adresse `<IP eines Knotens>:30778`.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# llama.cpp-Server je GPU-VM in k3s (Task 6.1, D6), gerendert von der Rolle gpu-llamacpp (tasks/k3s.yml) je Knoten.
|
||||||
|
# Ein Pod je Knoten; im Profil embed laufen Embedding und Rerank als zwei Container auf derselben P2000.
|
||||||
|
# Kartenwahl: kein nvidia.com/gpu (das Device Plugin verwaltet nur die M4000, Index 0), sondern die UUID der P2000
|
||||||
|
# per NVIDIA_VISIBLE_DEVICES, ermittelt und geprüft über den Kartennamen (ADR-0006: M4000 bleibt frei).
|
||||||
|
# Host-Netz mit den Ports wie zuvor im Compose (8080/8081/8082): Prometheus-Ziele (file_sd) und ufw bleiben gültig.
|
||||||
|
# Image aus dem Compose-Build, in das containerd von k3s importiert (imagePullPolicy Never).
|
||||||
|
# Datenschutz wie im Compose: kein --verbose, /slots aus, keine Web-Oberfläche.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: llamacpp-{{ inventory_hostname }}
|
||||||
|
namespace: knaster
|
||||||
|
labels: {app: llamacpp, knaster.node: {{ inventory_hostname }}}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy: {type: Recreate} # Host-Ports und VRAM der P2000 können nur einmal belegt sein
|
||||||
|
selector:
|
||||||
|
matchLabels: {app: llamacpp, knaster.node: {{ inventory_hostname }}}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: {app: llamacpp, knaster.node: {{ inventory_hostname }}}
|
||||||
|
spec:
|
||||||
|
runtimeClassName: nvidia
|
||||||
|
hostNetwork: true
|
||||||
|
dnsPolicy: ClusterFirstWithHostNet
|
||||||
|
nodeSelector:
|
||||||
|
kubernetes.io/hostname: {{ inventory_hostname }}
|
||||||
|
containers:
|
||||||
|
{% for svc in llamacpp_k3s_containers %}
|
||||||
|
- name: {{ svc.name }}
|
||||||
|
image: {{ llamacpp_image }}
|
||||||
|
imagePullPolicy: Never
|
||||||
|
args: {{ svc.args | to_json }}
|
||||||
|
env:
|
||||||
|
- {name: NVIDIA_VISIBLE_DEVICES, value: "{{ llamacpp_gpu_env[svc.gpu_var] }}"}
|
||||||
|
- {name: NVIDIA_DRIVER_CAPABILITIES, value: "compute,utility"}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 500m, memory: 1Gi}
|
||||||
|
limits: {memory: {{ svc.memory_limit }}}
|
||||||
|
securityContext:
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumeMounts:
|
||||||
|
- {name: models, mountPath: /models, readOnly: true}
|
||||||
|
- {name: tmp, mountPath: /tmp}
|
||||||
|
startupProbe:
|
||||||
|
httpGet: {path: /health, port: {{ svc.port }}, host: 127.0.0.1}
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 60
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: {path: /health, port: {{ svc.port }}, host: 127.0.0.1}
|
||||||
|
periodSeconds: 30
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: {path: /health, port: {{ svc.port }}, host: 127.0.0.1}
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 3
|
||||||
|
{% endfor %}
|
||||||
|
volumes:
|
||||||
|
- name: models
|
||||||
|
hostPath: {path: {{ llamacpp_models_dir }}, type: Directory}
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {sizeLimit: 64Mi}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: knaster
|
||||||
|
labels:
|
||||||
|
knaster.app: llamacpp
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# kube-state-metrics v2.19.1 (Task 6.5): Zustand der Objekte (Pods, Deployments, Knoten, PVCs) für Prometheus auf infra.
|
||||||
|
# NodePort 30808 auf jedem Knoten; Prometheus fragt genau ein Ziel ab (file_sd, monitoring-targets.py).
|
||||||
|
# Nur die Ressourcen, die Dashboard und Alerts brauchen (infra hat 4 GB RAM).
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: kube-state-metrics
|
||||||
|
namespace: kube-system
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: kube-state-metrics
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [nodes, pods, namespaces, persistentvolumeclaims]
|
||||||
|
verbs: [list, watch]
|
||||||
|
- apiGroups: [apps]
|
||||||
|
resources: [deployments, daemonsets, statefulsets, replicasets]
|
||||||
|
verbs: [list, watch]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: kube-state-metrics
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: kube-state-metrics
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: kube-state-metrics
|
||||||
|
namespace: kube-system
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: kube-state-metrics
|
||||||
|
namespace: kube-system
|
||||||
|
labels: {app: kube-state-metrics}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels: {app: kube-state-metrics}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: {app: kube-state-metrics}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: kube-state-metrics
|
||||||
|
automountServiceAccountToken: true
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65534
|
||||||
|
seccompProfile: {type: RuntimeDefault}
|
||||||
|
containers:
|
||||||
|
- name: kube-state-metrics
|
||||||
|
image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.19.1
|
||||||
|
args:
|
||||||
|
- --resources=nodes,pods,namespaces,persistentvolumeclaims,deployments,daemonsets,statefulsets,replicasets
|
||||||
|
- --port=8080
|
||||||
|
- --telemetry-port=8081
|
||||||
|
ports:
|
||||||
|
- {name: metrics, containerPort: 8080}
|
||||||
|
- {name: telemetry, containerPort: 8081}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: {path: /readyz, port: 8081}
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: {path: /livez, port: 8080}
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 10m, memory: 64Mi}
|
||||||
|
limits: {memory: 192Mi}
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities: {drop: [ALL]}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: kube-state-metrics
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector: {app: kube-state-metrics}
|
||||||
|
ports:
|
||||||
|
- {name: metrics, port: 8080, targetPort: metrics, nodePort: 30808}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Lesezugang für Prometheus auf infra (Task 6.5): kubelet /metrics und /metrics/cadvisor auf Port 10250.
|
||||||
|
# Der kubelet prüft das Token per SubjectAccessReview gegen nodes/metrics; mehr darf der ServiceAccount nicht.
|
||||||
|
# Das Token (Secret prometheus-infra-token) legt playbooks/monitoring-k3s.yml auf ops ab, die Rolle
|
||||||
|
# infra-stack kopiert es nach /opt/knaster/infra/secrets/k3s/.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: prometheus-infra
|
||||||
|
namespace: kube-system
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: prometheus-infra-token
|
||||||
|
namespace: kube-system
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/service-account.name: prometheus-infra
|
||||||
|
type: kubernetes.io/service-account-token
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: prometheus-infra
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [nodes/metrics]
|
||||||
|
verbs: [get]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: prometheus-infra
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: prometheus-infra
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: prometheus-infra
|
||||||
|
namespace: kube-system
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# NEX-Websuche (SearXNG), umgezogen von appsrv08 (Portainer-Stack nex, Dienst searxng) nach k3s (D6-Test, 15.10.2026).
|
||||||
|
# Quelle der Einstellungen: nex-Repo deploy/nex/searxng/settings.yml (Kopie hier, als ConfigMap eingehängt).
|
||||||
|
# SEARXNG_SECRET liegt im Secret nex-searxng (legt playbooks/nex-searxng-k3s.yml an, nicht im Repo).
|
||||||
|
# Zustandslos, kein Volume. Erreichbar im LAN über NodePort 30880 auf jeder Knoten-Adresse; die Hermes-Agenten
|
||||||
|
# auf appsrv08 brauchen dann SEARXNG_URL=http://<Knoten-IP>:30880 statt http://nex-searxng:8080.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: nex-searxng
|
||||||
|
namespace: nex
|
||||||
|
labels: {app: nex-searxng}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels: {app: nex-searxng}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: {app: nex-searxng}
|
||||||
|
spec:
|
||||||
|
# /etc/searxng muss beschreibbar sein (Image legt dort Dateien an): emptyDir, settings.yml aus der ConfigMap kopiert
|
||||||
|
initContainers:
|
||||||
|
- name: settings
|
||||||
|
image: searxng/searxng:2026.10.4-d48c4b555
|
||||||
|
command: ["sh", "-c", "cp /config/settings.yml /etc/searxng/settings.yml"]
|
||||||
|
volumeMounts:
|
||||||
|
- {name: config, mountPath: /config, readOnly: true}
|
||||||
|
- {name: etc, mountPath: /etc/searxng}
|
||||||
|
containers:
|
||||||
|
- name: searxng
|
||||||
|
image: searxng/searxng:2026.10.4-d48c4b555
|
||||||
|
ports:
|
||||||
|
- {name: http, containerPort: 8080}
|
||||||
|
env:
|
||||||
|
- name: SEARXNG_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: {name: nex-searxng, key: SEARXNG_SECRET}
|
||||||
|
- {name: GRANIAN_HOST, value: "0.0.0.0"} # IPv4: sonst erscheint die Absender-Adresse nur als ::ffff
|
||||||
|
- {name: GRANIAN_LOG_ACCESS_ENABLED, value: "true"}
|
||||||
|
- {name: GRANIAN_LOG_ACCESS_FMT, value: '[%(time)s] %(addr)s "%(method)s %(path)s" %(status)d %(dt_ms).0fms'} # ohne Query-String: keine Suchbegriffe im Log
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 100m, memory: 128Mi}
|
||||||
|
limits: {memory: 256Mi}
|
||||||
|
volumeMounts:
|
||||||
|
- {name: etc, mountPath: /etc/searxng}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: {path: /healthz, port: http}
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: {path: /healthz, port: http}
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap: {name: nex-searxng-settings}
|
||||||
|
- name: etc
|
||||||
|
emptyDir: {sizeLimit: 16Mi}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: nex-searxng
|
||||||
|
namespace: nex
|
||||||
|
labels: {app: nex-searxng}
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector: {app: nex-searxng}
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 8080, targetPort: http, nodePort: 30880}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# SearXNG für die NEX-Agenten (Plan docs/plans/2026-10-06-nex-searxng.md). Nur intern im Netz llm-internal erreichbar.
|
||||||
|
# Quelle für den Dienst searxng in deploy/nex/docker-compose.appsrv08.yml: dort als base64 im Command eingebettet
|
||||||
|
# (keine Bind-Mounts im Portainer-Stack). Nach jeder Änderung neu einbetten: base64 -w0 deploy/nex/searxng/settings.yml
|
||||||
|
# secret_key kommt aus der Env SEARXNG_SECRET (Stack-Variable NEX_SEARXNG_SECRET), nicht aus dieser Datei.
|
||||||
|
# Engines am 2026-10-06 von appsrv08 aus getestet: duckduckgo und bing liefern, wikipedia ergänzt Lexikon-Treffer;
|
||||||
|
# google („access denied“), brave („too many requests“), qwant (CAPTCHA) blockiert, startpage/mojeek im Image inaktiv.
|
||||||
|
use_default_settings:
|
||||||
|
engines:
|
||||||
|
keep_only: [duckduckgo, bing, wikipedia]
|
||||||
|
|
||||||
|
general:
|
||||||
|
instance_name: NEX-Suche
|
||||||
|
enable_metrics: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
limiter: false # nur interne Clients (Agenten-Container), kein Bot-Schutz nötig
|
||||||
|
image_proxy: false
|
||||||
|
public_instance: false
|
||||||
|
|
||||||
|
search:
|
||||||
|
safe_search: 1
|
||||||
|
default_lang: de
|
||||||
|
autocomplete: ""
|
||||||
|
formats: [html, json] # Hermes fragt GET /search?q=…&format=json&pageno=1 (plugins/web/searxng/provider.py)
|
||||||
|
|
||||||
|
outgoing:
|
||||||
|
request_timeout: 3.0
|
||||||
|
max_request_timeout: 6.0
|
||||||
|
|
||||||
|
engines:
|
||||||
|
- name: bing
|
||||||
|
disabled: false # im Image standardmäßig aus
|
||||||
|
# Wikipedia liefert sonst nur eine Infobox; Hermes liest ausschließlich "results"
|
||||||
|
- name: wikipedia
|
||||||
|
display_type: ["infobox", "list"]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: nex
|
||||||
|
labels:
|
||||||
|
knaster.app: nex-speech
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# NEX-Spracherkennung (whisper.cpp, M4000) auf gpu-pve52, umgezogen von Docker Compose (Task 6.2, D6).
|
||||||
|
# Host-Netz wie zuvor: Port 8178 auf der VM-Adresse, die ufw-Regel der VM (nur appsrv08) gilt weiter.
|
||||||
|
# Image stammt aus dem Compose-Build und wurde in das containerd von k3s importiert (Rolle nex-speech-k3s).
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: nex-stt
|
||||||
|
namespace: nex
|
||||||
|
labels: {app: nex-stt}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy: {type: Recreate} # Host-Port 8178 kann nur einmal belegt sein
|
||||||
|
selector:
|
||||||
|
matchLabels: {app: nex-stt}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: {app: nex-stt}
|
||||||
|
spec:
|
||||||
|
runtimeClassName: nvidia
|
||||||
|
hostNetwork: true
|
||||||
|
dnsPolicy: ClusterFirstWithHostNet
|
||||||
|
nodeSelector:
|
||||||
|
kubernetes.io/hostname: gpu-pve52
|
||||||
|
containers:
|
||||||
|
- name: whisper
|
||||||
|
image: knaster/nex-whisper:v1.9.4-cu12.6
|
||||||
|
imagePullPolicy: Never
|
||||||
|
env:
|
||||||
|
- {name: WHISPER_PORT, value: "8178"}
|
||||||
|
- {name: WHISPER_MODEL, value: /models/whisper/ggml-large-v3-turbo.bin}
|
||||||
|
- {name: WHISPER_LANGUAGE, value: de}
|
||||||
|
- {name: WHISPER_BEAM_SIZE, value: "5"}
|
||||||
|
- {name: WHISPER_EXTRA_ARGS, value: ""}
|
||||||
|
resources:
|
||||||
|
limits: {nvidia.com/gpu: 1}
|
||||||
|
securityContext:
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
volumeMounts:
|
||||||
|
- {name: models, mountPath: /models/whisper, readOnly: true}
|
||||||
|
- {name: run, mountPath: /run/nex-whisper}
|
||||||
|
startupProbe:
|
||||||
|
httpGet: {path: /health, port: 8178, host: 127.0.0.1}
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 60
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: {path: /health, port: 8178, host: 127.0.0.1}
|
||||||
|
periodSeconds: 30
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: {path: /health, port: 8178, host: 127.0.0.1}
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: models
|
||||||
|
hostPath: {path: /srv/models/whisper, type: Directory}
|
||||||
|
- name: run
|
||||||
|
emptyDir: {medium: Memory, sizeLimit: 512Mi}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# NEX-Sprachausgabe (Piper, CPU) auf gpu-pve52, Host-Netz Port 8179 (ufw: nur appsrv08).
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: nex-tts
|
||||||
|
namespace: nex
|
||||||
|
labels: {app: nex-tts}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy: {type: Recreate}
|
||||||
|
selector:
|
||||||
|
matchLabels: {app: nex-tts}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: {app: nex-tts}
|
||||||
|
spec:
|
||||||
|
hostNetwork: true
|
||||||
|
dnsPolicy: ClusterFirstWithHostNet
|
||||||
|
nodeSelector:
|
||||||
|
kubernetes.io/hostname: gpu-pve52
|
||||||
|
containers:
|
||||||
|
- name: piper
|
||||||
|
image: knaster/nex-tts-piper:1.8.0
|
||||||
|
imagePullPolicy: Never
|
||||||
|
env:
|
||||||
|
- {name: TTS_PORT, value: "8179"}
|
||||||
|
- {name: PIPER_VOICES_DIR, value: /voices}
|
||||||
|
- {name: PIPER_VOICES, value: "de_DE-thorsten-high,de_DE-kerstin-low"}
|
||||||
|
- {name: PIPER_DEFAULT_VOICE, value: de_DE-thorsten-high}
|
||||||
|
- {name: PCM_SAMPLE_RATE, value: "24000"}
|
||||||
|
- {name: TTS_MAX_CHARS, value: "5000"}
|
||||||
|
- {name: TTS_PARALLEL, value: "2"}
|
||||||
|
resources:
|
||||||
|
limits: {cpu: "3", memory: 2Gi} # ein Kern bleibt für whisper-server und das System
|
||||||
|
requests: {cpu: "500m", memory: 512Mi}
|
||||||
|
securityContext:
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
volumeMounts:
|
||||||
|
- {name: voices, mountPath: /voices, readOnly: true}
|
||||||
|
- {name: tmp, mountPath: /tmp}
|
||||||
|
startupProbe:
|
||||||
|
httpGet: {path: /health, port: 8179, host: 127.0.0.1}
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 24
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: {path: /health, port: 8179, host: 127.0.0.1}
|
||||||
|
periodSeconds: 30
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: {path: /health, port: 8179, host: 127.0.0.1}
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: voices
|
||||||
|
hostPath: {path: /srv/models/piper, type: Directory}
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {sizeLimit: 64Mi}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Zugang von nex-admin (läuft auf appsrv08) zum Namespace nex: Agenten als Deployment/PVC/Secret/Service anlegen,
|
||||||
|
# exec, Logs, Skalieren (nex-Spec 2026-10-19 Cluster-Agenten, D265). Kein cluster-admin, nur dieser Namespace.
|
||||||
|
# Token: Secret nex-admin-token (Typ service-account-token, dauerhaft); playbooks/nex-k3s.yml legt es als Datei auf
|
||||||
|
# ops ab (/root/.config/knaster/nex-admin-k8s.token) – von dort in die Stack-Variable NEX_ADMIN_K8S_TOKEN.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: nex-admin
|
||||||
|
namespace: nex
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: nex-admin
|
||||||
|
namespace: nex
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [pods, pods/log]
|
||||||
|
verbs: [get, list, watch, create, delete]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [pods/exec]
|
||||||
|
verbs: [create, get]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [services, persistentvolumeclaims, secrets]
|
||||||
|
verbs: [get, list, create, update, patch, delete]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [endpoints, events]
|
||||||
|
verbs: [get, list]
|
||||||
|
- apiGroups: [apps]
|
||||||
|
resources: [deployments, deployments/scale]
|
||||||
|
verbs: [get, list, watch, create, update, patch, delete]
|
||||||
|
- apiGroups: [metrics.k8s.io]
|
||||||
|
resources: [pods]
|
||||||
|
verbs: [get, list]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: nex-admin
|
||||||
|
namespace: nex
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: nex-admin
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: nex-admin
|
||||||
|
namespace: nex
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: nex-admin-token
|
||||||
|
namespace: nex
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/service-account.name: nex-admin
|
||||||
|
type: kubernetes.io/service-account-token
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Brücke zu den NEX-Diensten auf appsrv08 (nex-Spec 2026-10-19 Cluster-Agenten, D259).
|
||||||
|
# Die Agenten-Pods im Namespace nex sprechen dieselben Namen wie die Container im Docker-Netz llm-internal auf
|
||||||
|
# appsrv08 (nex-router, nex-know, nex-access, grp-mcp-intern); hier zeigen Services ohne Selector mit festen
|
||||||
|
# EndpointSlices (v1 Endpoints ist seit 1.33 veraltet) auf die Host-Ports von appsrv08 (nur auf 192.168.100.29 veröffentlicht, Stacks nex und grp-mcp-intern).
|
||||||
|
# Zieht ein Dienst in den Cluster (Weg 3), ersetzt sein echter Service den Eintrag – der Name bleibt.
|
||||||
|
# LiteLLM bleibt ohne Brücke: Agenten sprechen nur den Router, der Router LiteLLM (nex AGENTS.md: kein Pfad an
|
||||||
|
# LiteLLM vorbei, und kein zusätzlicher Host-Port dafür).
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: nex-router
|
||||||
|
namespace: nex
|
||||||
|
labels: {knaster.app: nex-bridge}
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 4100, targetPort: 4100}
|
||||||
|
---
|
||||||
|
apiVersion: discovery.k8s.io/v1
|
||||||
|
kind: EndpointSlice
|
||||||
|
metadata:
|
||||||
|
name: nex-router
|
||||||
|
namespace: nex
|
||||||
|
labels: {knaster.app: nex-bridge, kubernetes.io/service-name: nex-router}
|
||||||
|
addressType: IPv4
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 4100, protocol: TCP}
|
||||||
|
endpoints:
|
||||||
|
- addresses: ["192.168.100.29"]
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: nex-know
|
||||||
|
namespace: nex
|
||||||
|
labels: {knaster.app: nex-bridge}
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 4200, targetPort: 4200}
|
||||||
|
---
|
||||||
|
apiVersion: discovery.k8s.io/v1
|
||||||
|
kind: EndpointSlice
|
||||||
|
metadata:
|
||||||
|
name: nex-know
|
||||||
|
namespace: nex
|
||||||
|
labels: {knaster.app: nex-bridge, kubernetes.io/service-name: nex-know}
|
||||||
|
addressType: IPv4
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 14200, protocol: TCP} # Host-Port 14200 auf appsrv08 (4200 dort schon belegt); Service-Port bleibt 4200
|
||||||
|
endpoints:
|
||||||
|
- addresses: ["192.168.100.29"]
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: nex-access
|
||||||
|
namespace: nex
|
||||||
|
labels: {knaster.app: nex-bridge}
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 4300, targetPort: 4300}
|
||||||
|
---
|
||||||
|
apiVersion: discovery.k8s.io/v1
|
||||||
|
kind: EndpointSlice
|
||||||
|
metadata:
|
||||||
|
name: nex-access
|
||||||
|
namespace: nex
|
||||||
|
labels: {knaster.app: nex-bridge, kubernetes.io/service-name: nex-access}
|
||||||
|
addressType: IPv4
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 4300, protocol: TCP}
|
||||||
|
endpoints:
|
||||||
|
- addresses: ["192.168.100.29"]
|
||||||
|
---
|
||||||
|
# GRP-MCP (Rolle projektleitung): Stack grp-mcp-intern auf appsrv08, Dienst gateway, Container-Port 8790
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: grp-mcp-intern
|
||||||
|
namespace: nex
|
||||||
|
labels: {knaster.app: nex-bridge}
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 8790, targetPort: 8790}
|
||||||
|
---
|
||||||
|
apiVersion: discovery.k8s.io/v1
|
||||||
|
kind: EndpointSlice
|
||||||
|
metadata:
|
||||||
|
name: grp-mcp-intern
|
||||||
|
namespace: nex
|
||||||
|
labels: {knaster.app: nex-bridge, kubernetes.io/service-name: grp-mcp-intern}
|
||||||
|
addressType: IPv4
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 8790, protocol: TCP}
|
||||||
|
endpoints:
|
||||||
|
- addresses: ["192.168.100.29"]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# NVIDIA Device Plugin v0.20.1 (Quelle: deployments/static/nvidia-device-plugin.yml des Projekts),
|
||||||
|
# ergänzt um runtimeClassName nvidia und nodeSelector knaster.gpu=true (nur die GPU-VMs).
|
||||||
|
# Meldet je Knoten die Ressource nvidia.com/gpu, die Pods anfordern können.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: DaemonSet
|
||||||
|
metadata:
|
||||||
|
name: nvidia-device-plugin-daemonset
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
name: nvidia-device-plugin-ds
|
||||||
|
updateStrategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
name: nvidia-device-plugin-ds
|
||||||
|
spec:
|
||||||
|
runtimeClassName: nvidia
|
||||||
|
nodeSelector:
|
||||||
|
knaster.gpu: "true"
|
||||||
|
tolerations:
|
||||||
|
- key: nvidia.com/gpu
|
||||||
|
operator: Exists
|
||||||
|
effect: NoSchedule
|
||||||
|
priorityClassName: system-node-critical
|
||||||
|
containers:
|
||||||
|
- image: nvcr.io/nvidia/k8s-device-plugin:v0.20.1
|
||||||
|
name: nvidia-device-plugin-ctr
|
||||||
|
env:
|
||||||
|
- name: FAIL_ON_INIT_ERROR
|
||||||
|
value: "false"
|
||||||
|
# Nur Index 0 (Quadro M4000 in allen GPU-VMs, geprüft in playbooks/gpu-llamacpp-k3s.yml) wird als
|
||||||
|
# nvidia.com/gpu vergeben. Die P2000 gehört fest den llama.cpp-Pods (per UUID, Task 6.1).
|
||||||
|
- name: NVIDIA_VISIBLE_DEVICES
|
||||||
|
value: "0"
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: device-plugin
|
||||||
|
mountPath: /var/lib/kubelet/device-plugins
|
||||||
|
volumes:
|
||||||
|
- name: device-plugin
|
||||||
|
hostPath:
|
||||||
|
path: /var/lib/kubelet/device-plugins
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: portainer
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: portainer-sa-clusteradmin
|
||||||
|
namespace: portainer
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: portainer-crb-clusteradmin
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: cluster-admin
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: portainer-sa-clusteradmin
|
||||||
|
namespace: portainer
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: portainer-agent
|
||||||
|
namespace: portainer
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector:
|
||||||
|
app: portainer-agent
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
protocol: TCP
|
||||||
|
port: 9001
|
||||||
|
targetPort: 9001
|
||||||
|
nodePort: 30778
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: portainer-agent-headless
|
||||||
|
namespace: portainer
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
selector:
|
||||||
|
app: portainer-agent
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: portainer-agent
|
||||||
|
namespace: portainer
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: portainer-agent
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: portainer-agent
|
||||||
|
spec:
|
||||||
|
serviceAccountName: portainer-sa-clusteradmin
|
||||||
|
containers:
|
||||||
|
- name: portainer-agent
|
||||||
|
image: portainer/agent:2.45.2
|
||||||
|
imagePullPolicy: Always
|
||||||
|
env:
|
||||||
|
- name: LOG_LEVEL
|
||||||
|
value: DEBUG
|
||||||
|
- name: AGENT_CLUSTER_ADDR
|
||||||
|
value: "portainer-agent-headless"
|
||||||
|
- name: KUBERNETES_POD_IP
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: status.podIP
|
||||||
|
ports:
|
||||||
|
- containerPort: 9001
|
||||||
|
protocol: TCP
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Portainer CE im Cluster, Umzug von appsrv08 (Portainer EE 2.45.1) mit übernommener Datenbank (Benutzer,
|
||||||
|
# API-Schlüssel, Umgebungen, Stacks). Läuft auf der Dienst-VM k3s (tank, HA), Daten auf local-path dieser VM.
|
||||||
|
# Erst-Befüllung und Rollback BE→CE macht playbooks/portainer-k3s.yml aus /root/.config/knaster/portainer_data.tgz.
|
||||||
|
# Erreichbar: https://<Knoten-IP>:30779 (NodePort), von außen über den nginx auf appsrv08 (portainer.mygodel.de).
|
||||||
|
# Dieselbe Version wie auf appsrv08 (2.45.1): die Datenbank wird beim Start nicht migriert.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: portainer-ce
|
||||||
|
labels:
|
||||||
|
knaster.app: portainer
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: portainer-data
|
||||||
|
namespace: portainer-ce
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 2Gi
|
||||||
|
---
|
||||||
|
# Portainer verwaltet den eigenen Cluster über ein ServiceAccount (Umgebung „local“ wird beim ersten Start angelegt).
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: portainer-sa-clusteradmin
|
||||||
|
namespace: portainer-ce
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: portainer-ce-crb
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: cluster-admin
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: portainer-sa-clusteradmin
|
||||||
|
namespace: portainer-ce
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: portainer
|
||||||
|
namespace: portainer-ce
|
||||||
|
labels: {app: portainer-ce}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy: {type: Recreate}
|
||||||
|
selector:
|
||||||
|
matchLabels: {app: portainer-ce}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: {app: portainer-ce}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: portainer-sa-clusteradmin
|
||||||
|
nodeSelector:
|
||||||
|
knaster.role: control # Dienst-VM k3s: tank, HA, Replikation
|
||||||
|
containers:
|
||||||
|
- name: portainer
|
||||||
|
image: portainer/portainer-ce:2.45.1
|
||||||
|
ports:
|
||||||
|
- {name: https, containerPort: 9443}
|
||||||
|
- {name: edge, containerPort: 8000}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 50m, memory: 128Mi}
|
||||||
|
limits: {memory: 512Mi}
|
||||||
|
volumeMounts:
|
||||||
|
- {name: data, mountPath: /data}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: {path: /api/system/status, port: https, scheme: HTTPS}
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: {path: /api/system/status, port: https, scheme: HTTPS}
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim: {claimName: portainer-data}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: portainer
|
||||||
|
namespace: portainer-ce
|
||||||
|
labels: {app: portainer-ce}
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector: {app: portainer-ce}
|
||||||
|
ports:
|
||||||
|
- {name: https, port: 9443, targetPort: https, nodePort: 30779}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Runtime "nvidia" legt k3s in seinem containerd an, sobald nvidia-container-runtime in der VM liegt.
|
||||||
|
# Pods mit runtimeClassName: nvidia bekommen die GPU-Bibliotheken eingehängt.
|
||||||
|
apiVersion: node.k8s.io/v1
|
||||||
|
kind: RuntimeClass
|
||||||
|
metadata:
|
||||||
|
name: nvidia
|
||||||
|
handler: nvidia
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# RustDesk-Server (ID-Server hbbs + Relay hbbr) in k3s, Umzug vom Portainer-Stack „rustdesk“ auf appsrv08
|
||||||
|
# (Phase 6.0, 15.10.2026). Schlüsselpaar id_ed25519 kommt aus dem Secret rustdesk-keys (legt
|
||||||
|
# playbooks/rustdesk-k3s.yml aus /root/.config/knaster/rustdesk/ auf ops an; nicht im Repo). Mit demselben
|
||||||
|
# Schlüssel bleiben die in den Clients hinterlegten Public Keys gültig.
|
||||||
|
#
|
||||||
|
# Erreichbar über Service-Typ LoadBalancer (k3s ServiceLB): Ports 21115–21119/tcp und 21116/udp auf jeder
|
||||||
|
# Knoten-Adresse. Die Clients tragen als ID-Server die Adresse ein, die das Playbook als Relay-Adresse setzt
|
||||||
|
# (rustdesk_public_host, Standard: die k3s-VM). Erst mit fester Adresse/DNS-Namen für die Clients umstellen.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: rustdesk
|
||||||
|
labels:
|
||||||
|
knaster.app: rustdesk
|
||||||
|
---
|
||||||
|
# Datenbank (ID-Registrierungen) auf local-path: bindet den Pod an den Knoten, auf dem er zuerst lief.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: rustdesk-data
|
||||||
|
namespace: rustdesk
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: rustdesk
|
||||||
|
namespace: rustdesk
|
||||||
|
labels: {app: rustdesk}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy: {type: Recreate} # ein Datenverzeichnis, ein Pod
|
||||||
|
selector:
|
||||||
|
matchLabels: {app: rustdesk}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: {app: rustdesk}
|
||||||
|
spec:
|
||||||
|
nodeSelector:
|
||||||
|
knaster.gpu: "true" # nicht auf die kleine k3s-VM (2 Kerne, 3 GB)
|
||||||
|
containers:
|
||||||
|
- name: hbbs
|
||||||
|
image: rustdesk/rustdesk-server:1.1.14
|
||||||
|
# Relay-Adresse, die hbbs den Clients nennt; setzt das Playbook (RELAY_HOST) – muss von den Clients erreichbar sein.
|
||||||
|
# Image ohne Shell (scratch), deshalb $(VAR)-Ersetzung durch Kubernetes statt sh -c.
|
||||||
|
command: ["hbbs", "-r", "$(RELAY_HOST):21117", "-k", "_"]
|
||||||
|
env:
|
||||||
|
- {name: RELAY_HOST, value: "__RELAY_HOST__"}
|
||||||
|
workingDir: /root
|
||||||
|
ports:
|
||||||
|
- {name: nat-test, containerPort: 21115, protocol: TCP}
|
||||||
|
- {name: id-tcp, containerPort: 21116, protocol: TCP}
|
||||||
|
- {name: id-udp, containerPort: 21116, protocol: UDP}
|
||||||
|
- {name: web, containerPort: 21118, protocol: TCP}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 50m, memory: 64Mi}
|
||||||
|
limits: {memory: 256Mi}
|
||||||
|
volumeMounts:
|
||||||
|
- {name: data, mountPath: /root}
|
||||||
|
- {name: keys, mountPath: /root/id_ed25519, subPath: id_ed25519, readOnly: true}
|
||||||
|
- {name: keys, mountPath: /root/id_ed25519.pub, subPath: id_ed25519.pub, readOnly: true}
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket: {port: id-tcp}
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket: {port: id-tcp}
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 3
|
||||||
|
- name: hbbr
|
||||||
|
image: rustdesk/rustdesk-server:1.1.14
|
||||||
|
command: ["hbbr", "-k", "_"]
|
||||||
|
workingDir: /root
|
||||||
|
ports:
|
||||||
|
- {name: relay, containerPort: 21117, protocol: TCP}
|
||||||
|
- {name: relay-web, containerPort: 21119, protocol: TCP}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 50m, memory: 64Mi}
|
||||||
|
limits: {memory: 256Mi}
|
||||||
|
volumeMounts:
|
||||||
|
- {name: keys, mountPath: /root/id_ed25519, subPath: id_ed25519, readOnly: true}
|
||||||
|
- {name: keys, mountPath: /root/id_ed25519.pub, subPath: id_ed25519.pub, readOnly: true}
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket: {port: relay}
|
||||||
|
periodSeconds: 10
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim: {claimName: rustdesk-data}
|
||||||
|
- name: keys
|
||||||
|
secret:
|
||||||
|
secretName: rustdesk-keys
|
||||||
|
defaultMode: 0400
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: rustdesk
|
||||||
|
namespace: rustdesk
|
||||||
|
labels: {app: rustdesk}
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
selector: {app: rustdesk}
|
||||||
|
ports:
|
||||||
|
- {name: nat-test, port: 21115, targetPort: 21115, protocol: TCP}
|
||||||
|
- {name: id-tcp, port: 21116, targetPort: 21116, protocol: TCP}
|
||||||
|
- {name: id-udp, port: 21116, targetPort: 21116, protocol: UDP}
|
||||||
|
- {name: relay, port: 21117, targetPort: 21117, protocol: TCP}
|
||||||
|
- {name: web, port: 21118, targetPort: 21118, protocol: TCP}
|
||||||
|
- {name: relay-web, port: 21119, targetPort: 21119, protocol: TCP}
|
||||||
@@ -47,3 +47,34 @@ groups:
|
|||||||
for: 15m
|
for: 15m
|
||||||
labels: {severity: warning}
|
labels: {severity: warning}
|
||||||
annotations: {summary: "{{ $labels.instance }} {{ $labels.mountpoint }} < 10 % frei"}
|
annotations: {summary: "{{ $labels.instance }} {{ $labels.mountpoint }} < 10 % frei"}
|
||||||
|
|
||||||
|
# k3s (Task 6.5): nur melden, der Healer greift nicht ein (config/healer-rules.yaml, Aktion notify)
|
||||||
|
- name: knaster-k3s
|
||||||
|
rules:
|
||||||
|
- alert: K3sNodeNotReady
|
||||||
|
expr: kube_node_status_condition{condition="Ready", status="true"} == 0
|
||||||
|
for: 5m
|
||||||
|
labels: {severity: high}
|
||||||
|
annotations: {summary: "k3s-Knoten {{ $labels.node }} nicht bereit"}
|
||||||
|
|
||||||
|
- alert: K3sPodCrashLooping
|
||||||
|
# max_over_time: zwischen zwei Neustarts steht der Container kurz nicht in CrashLoopBackOff (Test 19.10.)
|
||||||
|
expr: max by (namespace, pod, container) (max_over_time(kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"}[5m])) >= 1
|
||||||
|
for: 10m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations: {summary: "{{ $labels.namespace }}/{{ $labels.pod }} ({{ $labels.container }}) startet immer wieder neu"}
|
||||||
|
|
||||||
|
- alert: K3sDeploymentUnavailable
|
||||||
|
expr: max by (namespace, deployment) (kube_deployment_spec_replicas) > max by (namespace, deployment) (kube_deployment_status_replicas_available)
|
||||||
|
for: 15m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations: {summary: "Deployment {{ $labels.namespace }}/{{ $labels.deployment }} hat zu wenige bereite Pods"}
|
||||||
|
|
||||||
|
- alert: K3sContainerMemoryNearLimit
|
||||||
|
expr: >
|
||||||
|
max by (namespace, pod, container) (container_memory_working_set_bytes{job="cadvisor"})
|
||||||
|
/ on (namespace, pod, container)
|
||||||
|
max by (namespace, pod, container) (kube_pod_container_resource_limits{resource="memory"}) > 0.9
|
||||||
|
for: 10m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations: {summary: "{{ $labels.namespace }}/{{ $labels.pod }} ({{ $labels.container }}) über 90 % des Speicherlimits"}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Scrape-Ziele. Die Labels an den GPU-Zielen (slot, docker_host, container, pve_node, vmid) landen in den
|
# Scrape-Ziele. Die Labels an den GPU-Zielen (slot, docker_host, container, pve_node, vmid) landen in den
|
||||||
# Alerts und damit beim Healer. IP-Literale aus sites/knaster/proxmox.yaml (Global Constraints, Aufbauplan).
|
# Alerts und damit beim Healer.
|
||||||
# ANNAHME bis Task 0.2 (Gast-IPs) und Task 3.3 (welcher Slot auf welcher P2000 läuft).
|
# GPU-VMs (job node, gpu, llamacpp): file_sd, weil die DHCP-Adressen wechseln (D5). Die Dateien erzeugt
|
||||||
# Ada-Worker (GODE-PC-05/06, Windows, D3) werden nicht gescrapt: ihre Verfügbarkeit meldet die
|
# platform/proxmox/scripts/monitoring-targets.py auf ops aus `tofu output -json gpu_vms` und der Registry;
|
||||||
# Worker-Registry v2 (Task 6.4), der Canary-Prober des Routers prüft die Slots zusätzlich.
|
# die Ansible-Rolle infra-stack kopiert sie nach /opt/knaster/infra/file_sd (im Container /etc/knaster-sd).
|
||||||
global:
|
global:
|
||||||
scrape_interval: 30s
|
scrape_interval: 30s
|
||||||
evaluation_interval: 30s
|
evaluation_interval: 30s
|
||||||
@@ -14,50 +14,91 @@ alerting:
|
|||||||
- static_configs: [{targets: ["alertmanager:9093"]}]
|
- static_configs: [{targets: ["alertmanager:9093"]}]
|
||||||
|
|
||||||
scrape_configs:
|
scrape_configs:
|
||||||
|
- job_name: prometheus
|
||||||
|
static_configs: [{targets: ["localhost:9090"]}]
|
||||||
|
|
||||||
|
- job_name: alertmanager
|
||||||
|
static_configs: [{targets: ["alertmanager:9093"]}]
|
||||||
|
|
||||||
- job_name: knaster-router
|
- job_name: knaster-router
|
||||||
metrics_path: /metrics
|
metrics_path: /metrics
|
||||||
static_configs: [{targets: ["router:8080"]}]
|
static_configs: [{targets: ["router:8080"]}]
|
||||||
|
|
||||||
# Erstes Ziel je Slot = Healer-Ziel in sites/knaster/registry.yaml (ops)
|
# Ada-Rechner (Windows, D3): Anbindung über die Worker-Registry v2 (Task 6.4), bis dahin nicht erreichbar
|
||||||
|
- job_name: vllm
|
||||||
|
static_configs:
|
||||||
|
- targets: ["gpu-main:8000"]
|
||||||
|
labels: {slot: tier1-general, docker_host: gpu-main, container: vllm, pve_node: pve-ada, vmid: "201"}
|
||||||
|
- targets: ["gpu-flex:8000"]
|
||||||
|
labels: {slot: tier1-flex, docker_host: gpu-flex, container: vllm, pve_node: pve-ada, vmid: "202"}
|
||||||
|
|
||||||
|
# llama-server (--metrics) in den GPU-VMs (Task 6.1): Embedding 8081 und Rerank 8082 in gpu-pve44/46,
|
||||||
|
# Chat 8080 in gpu-pve48. Ziele aus monitoring-targets.py (Profile laut Inventar-Gruppe gpu_llamacpp).
|
||||||
- job_name: llamacpp
|
- job_name: llamacpp
|
||||||
static_configs:
|
file_sd_configs: [{files: [/etc/knaster-sd/llamacpp.json]}]
|
||||||
- targets: ["192.168.100.224:8081"]
|
|
||||||
labels: {slot: embed-default, docker_host: gpu-pve44, container: llamacpp-embed, pve_node: pve44, vmid: "244"}
|
|
||||||
- targets: ["192.168.100.226:8081"]
|
|
||||||
labels: {slot: embed-default, docker_host: gpu-pve46, container: llamacpp-embed, pve_node: pve46, vmid: "246"}
|
|
||||||
- targets: ["192.168.100.224:8082"]
|
|
||||||
labels: {slot: rerank-default, docker_host: gpu-pve44, container: llamacpp-rerank, pve_node: pve44, vmid: "244"}
|
|
||||||
- targets: ["192.168.100.228:8080"]
|
|
||||||
labels: {slot: verifier-small, docker_host: gpu-pve48, container: llamacpp-chat, pve_node: pve48, vmid: "248"}
|
|
||||||
|
|
||||||
|
# nvidia_gpu_exporter (nvidia-smi, auch für Maxwell/Pascal; DCGM kann Maxwell nicht) in jeder GPU-VM
|
||||||
- job_name: gpu
|
- job_name: gpu
|
||||||
static_configs:
|
file_sd_configs: [{files: [/etc/knaster-sd/gpu.json]}]
|
||||||
- targets: ["192.168.100.224:9835"]
|
|
||||||
labels: {slot: embed-default, pve_node: pve44, vmid: "244"}
|
|
||||||
- targets: ["192.168.100.226:9835"]
|
|
||||||
labels: {slot: embed-default, pve_node: pve46, vmid: "246"}
|
|
||||||
- targets: ["192.168.100.228:9835"]
|
|
||||||
labels: {slot: verifier-small, pve_node: pve48, vmid: "248"}
|
|
||||||
- targets: ["192.168.100.232:9835"]
|
|
||||||
labels: {slot: "", pve_node: pve52, vmid: "252"} # nur M4000, außer Betrieb bis Task 3.3
|
|
||||||
|
|
||||||
- job_name: node # Labels werden für GpuXidError → Healer (VM-Neustart) gebraucht
|
# node_exporter in jeder GPU-VM; Labels werden für GpuXidError → Healer (VM-Neustart) gebraucht
|
||||||
static_configs:
|
- job_name: node
|
||||||
- targets: ["192.168.100.224:9100"]
|
file_sd_configs: [{files: [/etc/knaster-sd/node.json]}]
|
||||||
labels: {slot: embed-default, pve_node: pve44, vmid: "244"}
|
|
||||||
- targets: ["192.168.100.226:9100"]
|
|
||||||
labels: {slot: embed-default, pve_node: pve46, vmid: "246"}
|
|
||||||
- targets: ["192.168.100.228:9100"]
|
|
||||||
labels: {slot: verifier-small, pve_node: pve48, vmid: "248"}
|
|
||||||
- targets: ["192.168.100.232:9100"]
|
|
||||||
labels: {slot: "", pve_node: pve52, vmid: "252"}
|
|
||||||
|
|
||||||
|
# node_exporter im LXC infra selbst (Host-Netz des LXC)
|
||||||
|
- job_name: infra-node
|
||||||
|
static_configs:
|
||||||
|
- targets: ["host.docker.internal:9100"]
|
||||||
|
labels: {instance: infra}
|
||||||
|
|
||||||
|
# prometheus-pve-exporter: jeder Knoten liefert die Cluster-Sicht (cluster=1) und seine eigenen Werte (node=1).
|
||||||
|
# Alle fünf als Ziel, damit die Cluster-Sicht auch bei einem ausgefallenen Knoten bleibt (Dashboard wählt eine instance).
|
||||||
- job_name: proxmox
|
- job_name: proxmox
|
||||||
metrics_path: /pve
|
metrics_path: /pve
|
||||||
params: {module: [default]}
|
params: {module: [default], cluster: ["1"], node: ["1"]}
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["192.168.100.210", "192.168.100.209", "192.168.100.212", "192.168.100.207", "192.168.100.214"]
|
- {targets: ["192.168.100.210"], labels: {pve: pve44}}
|
||||||
|
- {targets: ["192.168.100.209"], labels: {pve: pve46}}
|
||||||
|
- {targets: ["192.168.100.212"], labels: {pve: pve48}}
|
||||||
|
- {targets: ["192.168.100.207"], labels: {pve: pve52}}
|
||||||
|
- {targets: ["192.168.100.214"], labels: {pve: pve61}}
|
||||||
relabel_configs:
|
relabel_configs:
|
||||||
- {source_labels: [__address__], target_label: __param_target}
|
- {source_labels: [__address__], target_label: __param_target}
|
||||||
- {source_labels: [__param_target], target_label: instance}
|
- {source_labels: [__param_target], target_label: instance}
|
||||||
- {target_label: __address__, replacement: "pve-exporter:9221"}
|
- {target_label: __address__, replacement: "pve-exporter:9221"}
|
||||||
|
|
||||||
|
# k3s (Task 6.5): kubelet direkt auf jedem Knoten (10250), TLS gegen die k3s-CA, Token des ServiceAccounts
|
||||||
|
# prometheus-infra (nur nodes/metrics). Ziele per file_sd aus `kubectl get nodes` (monitoring-targets.py auf ops).
|
||||||
|
# infra hat 4 GB RAM: von kubelet und cAdvisor nur die Reihen, die Dashboard „k3s – Pods“ und Alerts brauchen.
|
||||||
|
- job_name: kubelet
|
||||||
|
scheme: https
|
||||||
|
authorization: {credentials_file: /etc/knaster-k3s/token}
|
||||||
|
tls_config: {ca_file: /etc/knaster-k3s/ca.crt}
|
||||||
|
file_sd_configs: [{files: [/etc/knaster-sd/kubelet.json]}]
|
||||||
|
metric_relabel_configs:
|
||||||
|
- source_labels: [__name__]
|
||||||
|
regex: kubelet_running_pods|kubelet_running_containers|kubelet_volume_stats_(used|capacity)_bytes
|
||||||
|
action: keep
|
||||||
|
|
||||||
|
- job_name: cadvisor
|
||||||
|
scheme: https
|
||||||
|
metrics_path: /metrics/cadvisor
|
||||||
|
authorization: {credentials_file: /etc/knaster-k3s/token}
|
||||||
|
tls_config: {ca_file: /etc/knaster-k3s/ca.crt}
|
||||||
|
file_sd_configs: [{files: [/etc/knaster-sd/kubelet.json]}]
|
||||||
|
metric_relabel_configs:
|
||||||
|
- source_labels: [__name__]
|
||||||
|
regex: container_cpu_usage_seconds_total|container_memory_working_set_bytes|container_memory_rss|container_oom_events_total|container_network_(receive|transmit)_bytes_total
|
||||||
|
action: keep
|
||||||
|
# nur Pods (keine systemd-Slices, keine Docker-Container der Compose-Stacks)
|
||||||
|
- source_labels: [pod]
|
||||||
|
regex: ""
|
||||||
|
action: drop
|
||||||
|
# CPU/Speicher je Container: Pod-Summe und Pause-Container verwerfen (Netz gibt es nur je Pod)
|
||||||
|
- source_labels: [__name__, container]
|
||||||
|
regex: "container_(cpu|memory|oom)[^;]*;(POD)?"
|
||||||
|
action: drop
|
||||||
|
|
||||||
|
- job_name: kube-state-metrics
|
||||||
|
honor_labels: true # namespace/pod/node kommen aus den Metriken selbst
|
||||||
|
file_sd_configs: [{files: [/etc/knaster-sd/kube-state-metrics.json]}]
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Anmeldung am Proxmox-Cluster „knaster“ (Vertretung)
|
||||||
|
|
||||||
|
Stand 13.10.2026. Zugangsdaten liegen in Password Depot, nicht in dieser Datei und nicht im Repo.
|
||||||
|
|
||||||
|
## 1. Einträge in Password Depot (seit 13.10.2026 vorhanden)
|
||||||
|
|
||||||
|
Eintrag **„Proxmox knaster – r.rapp@pve“**:
|
||||||
|
- Benutzer: `r.rapp`
|
||||||
|
- Passwort (Kopie auch in `it/proxmox/.env`, Zeile `PVE_ADMIN_R_RAPP_PASS`)
|
||||||
|
- Einmalpasswort (TOTP) im Feld „Einmalpasswort / 2FA“: Password Depot zeigt den 6-stelligen Code an.
|
||||||
|
- Notizen: Recovery-Keys (je einmal gültig). Die Datei `tfa-r.rapp.txt` ist gelöscht, Password Depot ist die einzige Quelle.
|
||||||
|
|
||||||
|
Zweiter Eintrag **„Proxmox knaster – root (Notfall)“**: Passwörter `PVE44_PASS` … `PVE61_PASS` aus `.env`.
|
||||||
|
|
||||||
|
Muss die Verwaltung im Notfall ganz an eine andere Person gehen: [NOTFALL-ADMIN-UEBERGABE.md](NOTFALL-ADMIN-UEBERGABE.md).
|
||||||
|
|
||||||
|
## 2. Anmelden (Weboberfläche)
|
||||||
|
|
||||||
|
1. Nur vom **Admin-Laptop** (WLAN 192.168.100.151 oder Dock 192.168.100.181) oder über den Steuerrechner `ops`. Von anderen Rechnern sperrt die Firewall die Weboberfläche. Soll ein anderer PC dauerhaft Zugriff bekommen: seine IP in `cluster-knaster/sites/knaster/proxmox.yaml` unter `firewall.management` eintragen und auf ops `ansible-playbook playbooks/cluster.yml` laufen lassen.
|
||||||
|
2. Browser: `https://192.168.100.210:8006` (pve44). Jeder Knoten geht, die Oberfläche zeigt den ganzen Cluster. Zertifikatswarnung verschwindet nach Import der CA (`certutil -user -addstore Root C:\Users\r.rapp\it\proxmox\pve-root-ca.pem`).
|
||||||
|
3. Benutzername `r.rapp`, Passwort aus Password Depot, Realm **„Proxmox VE authentication server“** (nicht „Linux PAM“).
|
||||||
|
4. Zweiter Schritt: 6-stelligen Code aus Password Depot (oder der Authenticator-App) eingeben.
|
||||||
|
|
||||||
|
## 3. Wenn der Code nicht geht
|
||||||
|
|
||||||
|
- **TOTP-Dialog eingefroren, rotes Symbol:** Eine Passwortmanager-Erweiterung (Bitwarden, Chrome-ID `nngceckbapebfimnlniiiahkandclblb`) legt ihre „Speichern?“-Leiste über den Dialog. Abhilfe: Inkognito-Fenster oder Edge, dauerhaft die Knoten-IPs in Bitwarden unter „Ausgeschlossene Domains“ eintragen. Die Anmeldung selbst ist in Ordnung (13.10. per API geprüft).
|
||||||
|
|
||||||
|
- Reiter „Recovery Key“ wählen und einen Recovery-Key aus Password Depot eingeben. Danach einen neuen TOTP anlegen: Oben rechts Benutzer → „TFA“, oder per Skript `cluster-knaster/platform/proxmox/scripts/pve-admin-user.sh r.rapp` nach Löschen der alten Einträge (siehe `docs/BETRIEB.md` Abschnitt 8).
|
||||||
|
- Letzter Ausweg: `root`, Realm „Linux PAM standard authentication“, Passwort des jeweiligen Knotens. root hat keine 2FA und ist nur für Notfälle gedacht.
|
||||||
|
|
||||||
|
## 4. Nur Kommandozeile
|
||||||
|
|
||||||
|
`ssh pve44` (Alias aus `~/.ssh/config`, Schlüssel `it/proxmox/keys/id_ed25519_pve`, ohne Passphrase). Funktioniert ebenfalls nur vom Admin-Laptop oder von `ops`.
|
||||||
|
|
||||||
|
## 5. Was im Vertretungsfall reicht
|
||||||
|
|
||||||
|
- Zustand prüfen: `cluster-knaster/platform/proxmox/scripts/healthcheck.sh` (Git-Bash auf dem Laptop). Meldet OK/FEHLER je Punkt.
|
||||||
|
- Updates und Neustarts: `docs/BETRIEB.md` Abschnitt 6, immer nur ein Knoten nach dem anderen.
|
||||||
|
- Benachrichtigungen des Clusters kommen per Mail an `ki@mygodel.de`.
|
||||||
+205
-22
@@ -1,4 +1,4 @@
|
|||||||
# Betrieb: Skill-Lebenszyklus, Hermes, Pflege, Freischaltung
|
# Betrieb: Skill-Lebenszyklus, Hermes, Pflege, Freischaltung, Proxmox-Cluster
|
||||||
|
|
||||||
## 1. Lebenszyklus eines Skills
|
## 1. Lebenszyklus eines Skills
|
||||||
|
|
||||||
@@ -72,32 +72,215 @@ Freitext, der keinem Befehl entspricht, bildet ein kleines Modell auf einen Befe
|
|||||||
| Treiber/Engine | Proxmox-Snapshot der GPU-VM (vor jedem Update anlegen) |
|
| Treiber/Engine | Proxmox-Snapshot der GPU-VM (vor jedem Update anlegen) |
|
||||||
| Konfiguration | Git-Revert und Neustart des Router-Containers |
|
| Konfiguration | Git-Revert und Neustart des Router-Containers |
|
||||||
|
|
||||||
## 6. Ontologie pflegen
|
## 6. Proxmox: Updates und Neustarts (rollierend, Knoten für Knoten)
|
||||||
|
|
||||||
Die Ontologie (`ontology/know.yaml`) beschreibt die Begriffe der Datenhaltung. Details in `docs/spec/SPEC-ontology.md`.
|
Stand 12.10.2026, getestet mit dem Wartungsmodus auf pve44. Gilt für pve44, pve46, pve48, pve52 und pve61.
|
||||||
|
|
||||||
- **Kern** (Klassen, Beziehungen) ändert sich nur per Commit.
|
**Wichtig vorab**
|
||||||
- **Vokabulare** wachsen über die Service-Routine. Sie gleicht n8n nur lesend mit der Ontologie ab und schlägt neue Werte vor:
|
- Auf den GPU-Knoten gehört die M4000 (Boot-Grafik) der VM. Nach dem frühen Start bleibt der lokale Bildschirm schwarz. Zugang nur per SSH oder Weboberfläche; das GRUB-Menü ist beim Start noch sichtbar (Notzugang: Eintrag bearbeiten, `initcall_blacklist=sysfb_init` entfernen).
|
||||||
|
- pve61 hängt am Netz nur über die USB-NIC im Dock. Neustart nur, wenn jemand vor Ort ist. Kommt er nach etwa 2 Minuten nicht zurück: Dock aus- und wieder einstecken.
|
||||||
|
- Nie zwei Knoten gleichzeitig. Mit 5 Stimmen bleibt das Quorum bei einem fehlenden Knoten erhalten.
|
||||||
|
|
||||||
|
**Ablauf je Knoten**
|
||||||
|
1. Vorher: `pvecm status` (Quorate: Yes), `ha-manager status`, `pvesr status` (alle Jobs OK).
|
||||||
|
2. Wartungsmodus: `ha-manager crm-command node-maintenance enable <knoten>`. HA-Gäste ziehen um (`infra`: Test 36 s nach pve48).
|
||||||
|
3. GPU-VM des Knotens sauber stoppen: `qm shutdown <vmid>` (244/246/248/252). Sie hat kein HA und kann nicht wandern (Passthrough).
|
||||||
|
4. Update: `platform/proxmox/scripts/bootstrap-node.sh <knoten>` vom Laptop (idempotent, meldet `Neustart nötig`), dann `reboot`.
|
||||||
|
5. Nachher prüfen: Kernel, `systemctl --failed`, `ip -br a show vmbr0`, NTP; auf GPU-Knoten `lspci -nnk -d 10de:` → überall `vfio-pci`.
|
||||||
|
6. GPU-VM startet von selbst (`onboot`). Danach auf ops: `cd /root/cluster-knaster/platform/proxmox/tofu && . /root/.config/knaster/tofu.env && bash ../scripts/refresh-vm-access.sh` (DHCP-Adressen können wechseln, Hostschlüssel kommen über den Gast-Agenten) und `ansible gpu_vms -m shell -a "nvidia-smi -L"`.
|
||||||
|
7. Wartungsmodus aus: `ha-manager crm-command node-maintenance disable <knoten>`. HA holt `infra` auf seinen Heimatknoten pve44 zurück (Test 41 s).
|
||||||
|
|
||||||
|
**GPU-VMs aktualisieren**
|
||||||
|
- Pakete per `apt` in der VM, die NVIDIA-Pakete sind auf `hold` (R580). Vor Treiberwechseln einen Snapshot anlegen: `qm snapshot <vmid> vor-treiber`.
|
||||||
|
- Nach einem VM-Neustart immer `refresh-vm-access.sh`, solange es keine DHCP-Reservierungen gibt (D5).
|
||||||
|
|
||||||
|
## 7. Infra-Stack und Monitoring (LXC `infra`)
|
||||||
|
|
||||||
|
Stand 12.10.2026 (Task 5.2). Der LXC `infra` (vmid 101, HA, Heimat pve44, Replikation nach pve48/pve52) führt einen Compose-Stack `knaster-infra`: Router (Dry-Run), TTS (Piper), Postgres, Prometheus, Alertmanager, Grafana, pve-exporter, node-exporter, autoheal. Kein eigener LiteLLM (D4).
|
||||||
|
|
||||||
|
**Adressen** (DHCP, die IP kann sich nach einem Umzug ändern; aktuelle IP: `ansible infra -m debug -a var=ansible_host` auf ops oder `refresh-vm-access.sh`)
|
||||||
|
|
||||||
|
| Dienst | URL | Anmeldung |
|
||||||
|
|---|---|---|
|
||||||
|
| Grafana | `http://<infra-IP>:3000` | `admin` bzw. `kiosk` (Viewer) |
|
||||||
|
| Prometheus | `http://<infra-IP>:9090` | ohne (nur LAN) |
|
||||||
|
| Alertmanager | `http://<infra-IP>:9093` | ohne (nur LAN) |
|
||||||
|
| Router | `http://<infra-IP>:8080` | Bearer-Token |
|
||||||
|
|
||||||
|
**Wo liegt was**
|
||||||
|
- Konfiguration im Repo: `deploy/compose/infra`, `deploy/prometheus`, `deploy/alertmanager`, `deploy/grafana` (Datasource, Dashboards). Im LXC als Kopie unter `/opt/knaster/repo` (wird bei jedem Ansible-Lauf abgeglichen, dort nichts ändern).
|
||||||
|
- Geheimnisse nur im LXC, nie im Repo: `/opt/knaster/infra/.env` (0600) mit `POSTGRES_PASSWORD`, `KNASTER_API_TOKEN`, `GRAFANA_ADMIN_PASSWORD`, `GRAFANA_KIOSK_PASSWORD` (beim ersten Lauf mit `openssl rand` erzeugt, danach nie überschrieben). Lesen: `ssh root@<infra-IP> grep ^GRAFANA_KIOSK_PASSWORD= /opt/knaster/infra/.env` (für den Kiosk, Task 5.3).
|
||||||
|
- `/opt/knaster/infra/secrets/router_token` (Kopie von `KNASTER_API_TOKEN` für den Alertmanager) und `secrets/pve-exporter.env` (Kopie von ops `/root/.config/knaster/pve-exporter.env`, Token `monitoring@pve!prom`, PVEAuditor).
|
||||||
|
- Einstellungen ohne Geheimnis (Dry-Run, `LITELLM_BASE_URL` …) stehen in `platform/proxmox/ansible/roles/infra-stack/defaults/main.yml` und werden bei jedem Lauf in die `.env` geschrieben.
|
||||||
|
|
||||||
|
**Ausrollen** (auf ops, idempotent): `cd /root/cluster-knaster && git pull`, Adressen auffrischen (Abschnitt 6, Schritt 6), dann in `platform/proxmox/ansible`:
|
||||||
|
- `ansible-playbook playbooks/monitoring.yml` – Exporter in den GPU-VMs (ohne Treiber-Rolle, kein Neustart) und Stack im LXC.
|
||||||
|
- Ein Image-Update heißt: Tag im Compose-File ändern, committen, Playbook laufen lassen.
|
||||||
|
|
||||||
|
**Ziele der GPU-VMs (file_sd) auffrischen**
|
||||||
|
Die GPU-VMs haben DHCP-Adressen (D5). `platform/proxmox/scripts/monitoring-targets.py` schreibt aus `tofu output -json gpu_vms` und der Registry `node.json` und `gpu.json` (Labels `instance` = VM-Name, `pve_node`, `vmid`, `slot`, `slots`). Nach jedem VM-Neustart:
|
||||||
|
```bash
|
||||||
|
cd /root/cluster-knaster/platform/proxmox/tofu && . /root/.config/knaster/tofu.env && bash ../scripts/refresh-vm-access.sh
|
||||||
|
cd ../ansible && ansible-playbook playbooks/monitoring-targets.yml
|
||||||
```
|
```
|
||||||
> routine
|
Prometheus liest die Dateien in `/opt/knaster/infra/file_sd` selbst neu, ein Neustart ist nicht nötig.
|
||||||
Befunde:
|
|
||||||
- Ontologie [high]: KAN - Kanbau API Gateway ruft `get_kanbau_project_patterns` auf, die Operation gibt es in KNOW nicht
|
|
||||||
Offene Vorschläge:
|
|
||||||
- P-21 [low] Mail-Kategorie: neuer Wert „bauvorhaben“
|
|
||||||
- P-22 [low] Mail-Kategorie: neuer Wert „MPA“
|
|
||||||
|
|
||||||
> genehmige P-21 → neues Konzept
|
**Exporter in den GPU-VMs:** `prometheus-node-exporter` (Port 9100, Ubuntu-Paket, liest `/var/lib/node_exporter/textfile` mit den Xid-Zählern) und `nvidia_gpu_exporter` (Port 9835, nvidia-smi; DCGM unterstützt Maxwell nicht). ufw-Regeln mit Kommentar `knaster-monitoring` erlauben beide Ports aus `192.168.100.0/24`; ufw selbst wird von der Rolle nicht ein- oder ausgeschaltet.
|
||||||
> ordne P-21 sonstiges zu → „bauvorhaben“ wird Schreibweise von „sonstiges“
|
|
||||||
> lehne P-22 ab: Workflow-Name → Wert wird verworfen und nicht wieder vorgeschlagen
|
|
||||||
```
|
|
||||||
|
|
||||||
Danach im Repo übernehmen, z. B. wöchentlich:
|
**Alerts:** Alertmanager meldet an den Router (`/v1/healer/alertmanager`, Healer im Dry-Run). Der Receiver für n8n `knaster-notify` ist in `deploy/alertmanager/alertmanager.yml` nur vorbereitet (auskommentiert). Test: `docker exec knaster-infra-alertmanager-1 amtool --alertmanager.url=http://localhost:9093 alert add test severity=warning` und `… alert query`.
|
||||||
|
|
||||||
```
|
**Dashboards** (Ordner `knaster`, Startseite „knaster – Übersicht“): Proxmox via Prometheus (grafana.com 10347), Node Exporter Full (1860), Nvidia GPU Metrics (14574), knaster – Übersicht (eigenes), k3s – Pods (eigenes, Task 6.5).
|
||||||
knaster ontology apply # Entscheidungen → know.yaml, Version +1
|
|
||||||
knaster ontology export --ttl ontology/know.ttl
|
**k3s-Pods (Task 6.5, seit 19.10.):** Prometheus fragt drei Jobs ab. `kubelet` und `cadvisor` gehen direkt an Port 10250 jedes k3s-Knotens (TLS gegen die k3s-CA, Token des ServiceAccounts `prometheus-infra`, darf nur `nodes/metrics` lesen). `kube-state-metrics` geht an NodePort 30808 (bevorzugt auf der Dienst-VM k3s). Die Ziele erzeugt `monitoring-targets.py` aus `kubectl get nodes` auf ops (`kubelet.json`, `kube-state-metrics.json`), sie werden mit `playbooks/monitoring-targets.yml` aufgefrischt wie die der GPU-VMs. Token und CA: auf ops `/root/.config/knaster/k3s-prometheus.token` und `k3s-ca.crt`, im LXC `/opt/knaster/infra/secrets/k3s/` (nur dieser Ordner ist in Prometheus eingehängt). Von cAdvisor bleiben nur CPU, Speicher, OOM und Netz je Pod (`metric_relabel_configs`), weil infra nur 4 GB hat. Alerts `K3s*` (Knoten nicht bereit, CrashLoop, Deployment ohne bereite Pods, Speicher > 90 % des Limits) melden nur, der Healer greift nicht ein. Ausrollen: `ansible-playbook playbooks/monitoring-k3s.yml` (Cluster-Teil, dann Infra-Stack).
|
||||||
git commit -am "ontology: Entscheidungen übernommen"
|
|
||||||
```
|
**Achtung HA-Umzug und ZFS-Seiten-Cache (gefunden 12.10.2026 bei der HA-Probe)**
|
||||||
|
Nach `zfs receive` (Replikation) liefert ein Knoten für Dateien, die dort früher per mmap geöffnet waren, weiter den alten Inhalt aus seinem Seiten-Cache (ZFS 2.4.4, Kernel 7.0.14; Beleg auf pve44: `zfs diff <snap>` leer, `written` 0, md5 der Datei trotzdem ungleich der im Snapshot). Folge beim ersten Umzug nach pve48: dockerd las veraltete bbolt-Datenbanken, brach mit `panic: invalid freelist page` ab, danach fehlten Netzwerk und Volume-Labels. Betroffen ist jeder replizierte LXC mit mmap-Nutzern (Docker, Prometheus-TSDB, später `grp-test`), sobald er auf einen Knoten zurückkehrt, auf dem er schon lief.
|
||||||
|
- Schutz im Container: Dienst `knaster-drop-stale-cache.service` (Rolle `infra-stack`) verwirft vor containerd/docker per `posix_fadvise(DONTNEED)` den Cache aller Dateien des Containers. Protokoll: `journalctl -u knaster-drop-stale-cache -b` (zeigt geänderte Datenbank-Prüfsummen).
|
||||||
|
- Eine Lösung auf den Knoten (OpenZFS-Fehler melden bzw. Update, Hookscript) entscheidet der Nutzer.
|
||||||
|
|
||||||
|
**Wiederherstellung, falls Docker nach einem Umzug nicht startet** (im LXC, nichts löschen):
|
||||||
|
1. `systemctl stop docker containerd`; `journalctl -u docker -b | grep -A12 panic` zeigt die betroffene Datenbank (z. B. `buildkit/cache.db`).
|
||||||
|
2. Die defekte Datenbank bzw. das Verzeichnis beiseiteschieben, z. B. `mv /var/lib/docker/buildkit /var/lib/docker/buildkit.defekt-<datum>`.
|
||||||
|
3. `systemctl start docker`, dann `docker compose -p knaster-infra --env-file /opt/knaster/infra/.env -f /opt/knaster/repo/deploy/compose/infra/docker-compose.yml up -d --force-recreate` (Volumes bleiben, kein `down -v`).
|
||||||
|
4. Danach auf ops `ansible-playbook playbooks/monitoring.yml --limit infra`.
|
||||||
|
|
||||||
|
## 8. Zugang: Benutzer, Rollen, 2FA, Token (Phase 4.1/4.2)
|
||||||
|
|
||||||
|
Stand 13.10.2026, Quelle `sites/knaster/proxmox.yaml` (`access`), ausgerollt mit `playbooks/cluster.yml` (Rolle `pve-cluster`).
|
||||||
|
|
||||||
|
| Konto | Zweck | Rechte | Geheimnis liegt |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `r.rapp@pve` | persönlicher Admin (Weboberfläche) | Gruppe `admins` = Administrator auf `/` | Passwort `PVE_ADMIN_R_RAPP_PASS` in `it/proxmox/.env`; Passwort, TOTP und Recovery-Keys in Password Depot (Datei `tfa-r.rapp.txt` am 13.10. gelöscht) |
|
||||||
|
| `root@pam` | Notfallzugang, SSH-Schlüssel, Ansible | alles | Passwörter `PVExx_PASS` in `.env`, Schlüssel `keys/id_ed25519_pve` |
|
||||||
|
| `tofu@pve!ops` | OpenTofu auf ops | VM-, Storage-, Mapping-, Pool-Rechte | ops `/root/.config/knaster/tofu.env` |
|
||||||
|
| `monitoring@pve!prom` | pve-exporter | PVEAuditor | ops `/root/.config/knaster/pve-exporter.env`, Kopie in infra |
|
||||||
|
| `healer@pve!knaster` | Healer (SPEC-healer) | Rolle `KnasterHealer` (`VM.PowerMgmt`, `VM.Audit`) nur auf `/pool/gpu` | ops `/root/.config/knaster/healer.env` |
|
||||||
|
|
||||||
|
- **Anmeldung Weboberfläche:** Benutzer `r.rapp`, Realm „Proxmox VE authentication server“, danach TOTP-Code. Die Recovery-Keys gelten je einmal.
|
||||||
|
- **TOTP:** liegt in Password Depot (Feld „Einmalpasswort / 2FA“). Für neue Konten schreibt `pve-admin-user.sh` eine Datei `tfa-<name>.txt`; Secret und Recovery-Keys daraus in Password Depot übernehmen und die Datei löschen.
|
||||||
|
- **Anmeldung und Vertretung:** `docs/ANMELDUNG.md` (auch Browser-Falle: Bitwarden-Leiste blockiert den TOTP-Dialog, Inkognito oder Domain ausschließen).
|
||||||
|
- **Neues Admin-Konto:** Name in `access.admin_users` eintragen, `playbooks/cluster.yml` auf ops, dann vom Laptop `platform/proxmox/scripts/pve-admin-user.sh <name>` (setzt Passwort, TOTP, Recovery-Keys; idempotent).
|
||||||
|
- **2FA für root@pam** ist bewusst nicht gesetzt: root bleibt Notfallzugang und läuft über SSH-Schlüssel. Wenn gewünscht, gleiches Vorgehen über `pvesh create /access/tfa/root@pam` – vorher den Code in einer App haben, sonst sperrt man sich aus der Weboberfläche aus (SSH bleibt).
|
||||||
|
- **Token rotieren:** `pveum user token remove <user> <token>`, Datei auf ops löschen, Playbook erneut (Healer) bzw. Token neu anlegen und Datei schreiben (tofu, monitoring).
|
||||||
|
- Verloren gegangenes TOTP: `pvesh get /access/tfa/<user>` listet die Einträge, `pvesh delete /access/tfa/<user>/<id>` entfernt sie, danach das Skript erneut.
|
||||||
|
|
||||||
|
## 9. Benachrichtigungen (Phase 4.3)
|
||||||
|
|
||||||
|
Ziel `knaster-mail` (Sendmail an `ki@mygodel.de`, aus `access.mail_to`), Matcher `knaster-alles` ohne Filter (Backup, Fencing, Replikation, Paket-Updates), `default-matcher` abgeschaltet (sonst doppelt). Postfix auf den Knoten liefert direkt an die MX von mygodel.de (13.10. getestet: `250 … Queued mail for delivery`). Kein SMTP-Relay, keine Zugangsdaten.
|
||||||
|
|
||||||
|
- Test: `pvesh create /cluster/notifications/targets/knaster-mail/test` auf einem Knoten, Zustellung im Journal: `journalctl -u postfix --since -10min | grep status=`.
|
||||||
|
- Landet die Mail im Spam: Absender `root@pveXX.buero-godel.local` hat kein SPF. Dann in Exchange Online eine Transportregel oder einen Connector für die Knoten-IPs anlegen.
|
||||||
|
- Empfänger ändern: `access.mail_to` in der Site-Datei, dann `pvesh set /cluster/notifications/endpoints/sendmail/knaster-mail --mailto <adresse>` (das Playbook legt nur an, es ändert bestehende Ziele nicht).
|
||||||
|
|
||||||
|
## 10. Firewall (Phase 4.4)
|
||||||
|
|
||||||
|
Datacenter-Firewall aktiv auf allen Knoten (`/etc/pve/firewall/cluster.fw`, Vorlage `roles/pve-cluster/templates/cluster.fw.j2`). Eingehend DROP, ausgehend ACCEPT.
|
||||||
|
|
||||||
|
- **IPSET `admin`:** die fünf Knoten, Admin-Laptop `.151` (WLAN) und `.181` (USB-LAN im Dock), appsrv08 `.29`, ops `.173`, infra `.188`. Nur diese erreichen 22, 8006, 3128, 5900–5999 und 60000–60050. Alle anderen Adressen im LAN bekommen auf diesen Ports DROP, Ping aus dem LAN geht. Corosync zwischen den Knoten erlaubt Proxmox selbst.
|
||||||
|
- **Warum eigene Regeln statt IPSET `management`:** Proxmox ergänzt `management` immer um das lokale Netz (`local_network`, hier das ganze /24). Eine Einschränkung auf einzelne Adressen wirkt deshalb nur mit ACCEPT für `+admin` und anschließendem DROP für das /24 (13.10. geprüft: aus einer GPU-VM sind 22 und 8006 zu, vom Laptop und aus infra offen).
|
||||||
|
- **DHCP-Risiko (D5):** Seit 14.10. haben ops (.173) und infra (.188) feste Adressen (infra über `containers.infra.ip` in der Site-Datei und tofu, ops per `pct set 100 -net0 …ip=…` auf pve46; DNS .120, Suchdomäne buero-godel.local). Am DC sind sie noch nicht reserviert, der Support soll die Reservierungen nachziehen (Task 0.2). Seit 19.10. hat der Laptop Reservierungen (WLAN .151, Dock .181), ebenso pve44 und pve52. Für infra, pve46 und pve48 ist laut DC derzeit keine Reservierung möglich; sie sind statisch eingestellt, das Restrisiko ist eine doppelt vergebene Adresse. 14.10. passiert: Laptop kam per Dock mit .181 und war ausgesperrt, Rückweg über ops hat funktioniert. Wechselt eine Adresse, ist diese Quelle ausgesperrt. Rückweg: von einer noch zugelassenen Adresse (z. B. `ssh ops`, dann `ssh pve44`) die Adresse in `sites/knaster/proxmox.yaml` ändern und das Playbook laufen lassen, oder direkt `cluster.fw` bearbeiten. Letzter Rückweg: Konsole am Knoten, dort `pve-firewall stop` bzw. `enable: 0` in `cluster.fw`.
|
||||||
|
- **Totmannschalter:** Das Playbook stellt bei jeder Änderung einen Timer (`knaster-fw-totmann`, 600 s), der `enable: 0` schreibt, und hebt ihn erst nach erfolgreicher Prüfung (SSH und 8006 zu allen Knoten von ops aus) wieder auf. Bricht der Lauf ab, geht die Firewall von selbst wieder aus.
|
||||||
|
- Neue Verwaltungsquelle (z. B. zweiter Laptop): Eintrag unter `firewall.management`, Playbook laufen lassen.
|
||||||
|
- Gast-Firewalls (VM/LXC) sind nicht aktiviert; die GPU-VMs schützen ihre Ports mit ufw (Abschnitt 7).
|
||||||
|
|
||||||
|
## 11. Zertifikat der Weboberfläche (Phase 4.6)
|
||||||
|
|
||||||
|
Die Knotenzertifikate stammen von der Cluster-CA (`/etc/pve/pve-root-ca.pem`, gültig bis 2036) und enthalten die IP-Adresse als SAN. Es reicht, die CA einmal auf dem Laptop als vertrauenswürdig einzutragen, dann sind `https://192.168.100.2xx:8006` für alle fünf Knoten ohne Warnung erreichbar.
|
||||||
|
|
||||||
|
- Kopie der CA: `it/proxmox/pve-root-ca.pem` (SHA-256 `A2:71:C0:AB:…:77:3E`, zum Abgleich `openssl x509 -in … -noout -fingerprint -sha256`).
|
||||||
|
- Import ohne Adminrechte (Benutzerspeicher, Windows fragt einmal nach): `certutil -user -addstore Root C:\Users\r.rapp\it\proxmox\pve-root-ca.pem`. Danach Browser neu starten.
|
||||||
|
- Kein ACME/Let's Encrypt: die Knoten haben keine öffentlichen Namen (IP-Regel, kein DNS).
|
||||||
|
|
||||||
|
## 12. Netzwerk-Prüfung (Stand 13.10.2026)
|
||||||
|
|
||||||
|
| Knoten | NIC | Treiber | Link | Bridge |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| pve44, pve46, pve48, pve52 | Intel onboard `nic0` | `e1000e` | 1 GBit/s | `vmbr0` → `nic0`, kein VLAN-aware |
|
||||||
|
| pve61 | USB-NIC im Dock `enx3ce1a1c0839d` | `r8152` | 1 GBit/s | `vmbr0` → USB-NIC, ein Link-Reset seit Start |
|
||||||
|
|
||||||
|
- Keine `e1000e`-Hänger („Detected Hardware Unit Hang“) im Kernel-Log. Sollten sie auftreten: `ethtool -K nic0 tso off gso off` als bekannter Ausweg, dann in `/etc/network/interfaces` festhalten.
|
||||||
|
- **VLAN:** nicht aktiviert (Global Constraint „vorerst kein VLAN“). Wenn nötig: `bridge-vlan-aware yes` und `bridge-vids 2-4094` auf `vmbr0`, `ifreload -a`; auf pve61 nur vor Ort (USB-NIC).
|
||||||
|
- Eine zweite NIC für ein getrenntes Corosync- oder Migrationsnetz gibt es auf keinem Knoten. Migration läuft `secure` über das LAN.
|
||||||
|
- Feste Adressen: Knoten sind statisch, Gäste und Laptop per DHCP (D5 offen, Task 0.2). Erst mit Reservierungen ist die Firewall-Liste dauerhaft stabil.
|
||||||
|
|
||||||
|
## 13. Testvorgehen
|
||||||
|
|
||||||
|
`platform/proxmox/scripts/healthcheck.sh` (Laptop oder ops, nur lesend) prüft in einem Lauf: Quorum und Stimmen, HA-Dienste, Replikation, Firewall aktiv und Regel `+admin`, Matcher, Benutzer und 2FA; je Knoten Kernel, fehlgeschlagene Dienste, Knoten-Firewall, NTP, `vmbr0`, Platz auf `/`, `tank`, Zertifikatslaufzeit, GPU-Bindung an `vfio-pci`, NIC-Hänger, Weboberfläche; dazu den Negativtest aus einer GPU-VM. Ausgabe `OK`/`FEHLER` je Prüfung, Exit 1 bei Fehlern.
|
||||||
|
|
||||||
|
Wann laufen lassen:
|
||||||
|
1. Nach jedem Playbook-Lauf gegen die Knoten (`site.yml`, `cluster.yml`) und nach jedem Neustart (Abschnitt 6, Schritt 5).
|
||||||
|
2. Vor einer Änderung an Firewall, Benutzern oder Netzwerk als Vergleichsbasis.
|
||||||
|
3. Wöchentlich als Routine; Grafana (Abschnitt 7) deckt den Rest kontinuierlich ab.
|
||||||
|
|
||||||
|
Manuelle Ergänzungen, die das Skript nicht abdeckt: Anmeldung mit `r.rapp@pve` und TOTP in der Weboberfläche, Testmail (Abschnitt 9), Dock-Test an pve61 (Task 1.3, vor Ort), Wartungsmodus-Probe (Abschnitt 6).
|
||||||
|
|
||||||
|
## 14. Kubernetes (k3s) und Portainer (D6, seit 15.10.2026)
|
||||||
|
|
||||||
|
Die GPU-VMs bilden mit der Dienst-VM `k3s` einen k3s-Cluster (v1.36.5+k3s1). Steuerung auf `k3s` (VM 110 auf pve48, tank, HA mit Replikation nach pve52 und pve44, 2 Kerne, 3 GB), Agents `gpu-pve44/46/48/52`. Docker bleibt in den VMs für noch nicht umgezogene Compose-Stacks (llama.cpp auf pve48, Exporter).
|
||||||
|
|
||||||
|
| Was | Wo |
|
||||||
|
|---|---|
|
||||||
|
| Server-Adresse | per DHCP (heute 192.168.100.124, MAC `bc:24:11:a5:d5:52`); Agents nutzen den Namen `k3s.knaster` aus `/etc/hosts` (Rolle k3s-agent). Ändert sich die Adresse: `refresh-vm-access.sh`, dann `playbooks/k3s.yml` |
|
||||||
|
| Token und kubeconfig | nur auf ops: `/root/.config/knaster/k3s.env`, `/root/.kube/config`; `kubectl` liegt auf ops |
|
||||||
|
| Manifeste | `deploy/k8s/` (RuntimeClass `nvidia`, NVIDIA Device Plugin v0.20.1, Portainer Agent 2.45.2, `nex-speech/`, `nex-searxng/`, `rustdesk/`) |
|
||||||
|
| Playbooks | `playbooks/k3s.yml` (Server, Agents, Grundausstattung), `playbooks/nex-speech-k3s.yml`, `playbooks/nex-searxng-k3s.yml`, `playbooks/rustdesk-k3s.yml` (Umzüge), `playbooks/monitoring-k3s.yml` (Pod-Monitoring) |
|
||||||
|
| Monitoring | kube-state-metrics v2.19.1 in `kube-system` (NodePort 30808), Prometheus auf infra liest kubelet/cAdvisor; Grafana-Dashboard „k3s – Pods“ (Abschnitt 7). `kubectl top` zeigt weiter den metrics-server von k3s |
|
||||||
|
| GPU in Pods | `runtimeClassName: nvidia` + `resources.limits: {nvidia.com/gpu: 1}` vergibt seit 19.10. nur die M4000 (Device Plugin sieht nur Index 0, allocatable 1/1/1/1). Die P2000 gehört fest llama.cpp (`NVIDIA_VISIBLE_DEVICES=<UUID>`, kein `nvidia.com/gpu`). Knoten-Labels `knaster.gpu=true`, `knaster.gpu.m4000=1`, `knaster.gpu.p2000=1` |
|
||||||
|
| Portainer | Umgebung „Kubernetes → Agent“ mit Adresse `<IP eines Knotens>:30778`, z. B. `192.168.100.124:30778` (Agent-Pod läuft im Namespace `portainer`) |
|
||||||
|
|
||||||
|
- **Zuteilung (seit 15.10.):** jede GPU-VM hat alle 8 Threads und 20 GB ihres Knotens (`vms.*.cores/memory_mb` in der Site-Datei), der Cluster zeigt damit 34 vCPU und 83 GB. Der Rest je Knoten (32 GB): Proxmox ~3 GB, ZFS-ARC 2 GB (`cluster.zfs_arc_max_mb`, Rolle pve-host, sofort wirksam), dazu ops 2 GB (pve46), infra 4 GB (pve44), k3s 3 GB (pve48). RAM der GPU-VMs ist wegen PCI-Passthrough fest reserviert. Ändern: Site-Datei, auf ops `tofu apply` (startet nicht automatisch neu, `reboot_after_update = false`), dann je Knoten `qm reboot <vmid>` nacheinander und `kubectl get node` abwarten. Knapp wird es nur im HA-Fall, wenn infra und k3s gleichzeitig auf denselben Ersatzknoten ziehen.
|
||||||
|
- **Prüfen:** auf ops `kubectl get nodes` (fünf Knoten Ready), `kubectl get pods -A`, `kubectl -n nex get pods` (nex-stt, nex-tts auf gpu-pve52).
|
||||||
|
- **NEX-Sprachdienste** laufen seit 15.10. als Deployments im Namespace `nex` mit Host-Netz, Ports 8178/8179 auf der VM-Adresse wie zuvor; ufw der VM gilt weiter (nur appsrv08). Umzugstest 15.10.: TTS → STT Rundlauf liefert den Text exakt, M4000 belegt, Compose-Stack ist angehalten (Rückweg: `kubectl -n nex delete deploy nex-stt nex-tts`, dann in der VM `docker compose -f /opt/nex-speech/docker-compose.yml start`). `playbooks/nex-speech.yml` bricht deshalb ab, außer mit `-e nex_speech_compose=true`.
|
||||||
|
- **NEX-Websuche (SearXNG)** läuft seit 15.10. zusätzlich in k3s (Namespace `nex`, NodePort 30880 auf jeder Knoten-Adresse, `playbooks/nex-searxng-k3s.yml`, Einstellungen = Kopie aus dem nex-Repo `deploy/nex/searxng/settings.yml`). Der Dienst auf appsrv08 läuft parallel weiter, bis die Hermes-Agenten dort auf `SEARXNG_URL=http://<Knoten-IP>:30880` umgestellt sind (nex-Admin `onboarding`), danach den Dienst `searxng` im Portainer-Stack `nex` entfernen.
|
||||||
|
- **Portainer CE im Cluster** (seit 15.10., Namespace `portainer-ce`, Pod auf der Dienst-VM k3s, Daten auf deren local-path, `https://192.168.100.124:30779`): Datenbank aus dem Volume `portainer_data` von appsrv08 (Portainer EE 2.45.1) übernommen, mit dem offiziellen Werkzeug `portainer-ee --rollback-to-ce` auf Community Edition zurückgesetzt (laut Portainer-Doku nur nach CE→BE-Upgrade möglich, hat bei der frischen BE-Installation trotzdem funktioniert; CE verweigert eine BE-Datenbank sonst mit Hinweis). Benutzer, Passwörter, API-Schlüssel und Umgebungen sind erhalten, der Schlüssel aus `it/.hermes.env` funktioniert unverändert. Sicherung `/root/.config/knaster/portainer_data.tgz` auf ops (15.10., 2,5 MB). **Umgebungen:** Nr. 4 „godel-local“ war der Docker-Socket von appsrv08 und ist im Cluster tot; auf appsrv08 läuft dafür `portainer_agent` (2.45.1, nur `192.168.100.29:9001`), den ein Admin als neue Umgebung „appsrv08“ (Agent) anlegt und für `godel-deployer` freigibt. Die Kubernetes-Umgebung (Nr. 5, Agent `:30778`) bleibt. **Noch auf appsrv08:** Portainer EE (`:9443`) läuft parallel weiter, nginx leitet `portainer.mygodel.de` dorthin; Umstellung = `proxy_pass` in `/var/docker/nginx/etc/nginx/conf.d/portainer.conf` auf `https://192.168.100.124:30779`, `nginx -t`, Reload (Runbook `IT Verbesserungen/GRP_Linux/Portainer-DNS-Runbook.md`), dann EE stoppen. Für nex-Admin und `.hermes.env` (URL `:9443`, Endpoint 4) zusätzlich Weiterleitung `:9443` → Cluster oder Werte anpassen. Lizenz: CE hat keine Knotengrenze.
|
||||||
|
- **Agenten im Cluster (NEX)** (seit 19.10., nex-Spec `docs/specs/2026-10-19-nex-cluster-agenten-design.md` im nex-Repo): Neue Hermes-Agenten entstehen als Deployment im Namespace `nex`, angelegt von nex-admin auf appsrv08 über die Kubernetes-API (`playbooks/nex-k3s.yml`: ServiceAccount `nex-admin` mit Role nur für diesen Namespace, Token auf ops `/root/.config/knaster/nex-admin-k8s.token`, Pull-Secret `gitea-registry` aus `/root/.config/knaster/gitea-registry.env`). Die Pods erreichen die NEX-Dienste auf appsrv08 über Services ohne Selector (`deploy/k8s/nex/bridge.yaml`: nex-router 4100, nex-know 14200 (Host-Port, Service-Port 4200), nex-access 4300, grp-mcp-intern 8790 → `192.168.100.29`, dort als Host-Ports nur auf dieser Adresse veröffentlicht). Nutzeradressen bleiben `https://192.168.100.29:<port>/`: Traefik auf appsrv08 leitet an den NodePort `<port>+20000` weiter. Agenten-Volumes liegen auf `local-path` (an den ersten Knoten gebunden).
|
||||||
|
- **RustDesk-Server** (ID-Server hbbs + Relay hbbr) läuft seit 15.10. zusätzlich in k3s (Namespace `rustdesk`, ein Pod auf einem GPU-Knoten, Datenbank auf `local-path`, Service-Typ LoadBalancer: Ports 21115–21119/tcp und 21116/udp auf **jeder** Knoten-Adresse). Das Schlüsselpaar ist dasselbe wie auf appsrv08 (Kopie auf ops `/root/.config/knaster/rustdesk/`, im Cluster als Secret `rustdesk-keys`), der Public Key der Clients `BPhsgjAI8yicpNRA3YsnBzfx78JUuNglrYZSZANVCUY=` bleibt gültig. hbbs nennt den Clients als Relay die Adresse `rustdesk_public_host` (Standard: k3s-VM, Variable im Playbook). **Umstellung der Clients steht aus:** sie tragen heute `192.168.100.29` als ID-Server; nötig sind eine feste Adresse (DHCP-Reservierung der k3s-VM oder DNS-Name am DC) und danach die neue Adresse in den installierten Clients sowie im Installer-Namen des Portainer-Stacks `rustdesk-updater` (`SERVER_HOST`). Bis dahin läuft der Stack `rustdesk` auf appsrv08 weiter.
|
||||||
|
- **llama.cpp** (Task 6.1, seit 19.10.) läuft in k3s, Namespace `knaster`, ein Deployment `llamacpp-<vm>` je GPU-VM: Embedding 8081 + Rerank 8082 auf gpu-pve44/46 (zwei Container, eine P2000), Chat 8080 auf gpu-pve48. Host-Netz, Ports und Prometheus-Ziele wie zuvor im Compose. Ausrollen: `playbooks/gpu-llamacpp-k3s.yml` (prüft, dass Index 0 die M4000 ist, wendet das Device Plugin an, dann VM für VM). Rückweg je VM: `kubectl -n knaster delete deploy llamacpp-<vm>`, in der VM `docker compose --project-directory /opt/knaster/gpu-llamacpp start`; `playbooks/gpu-llamacpp.yml` nur mit `-e llamacpp_compose=true`. Embedding mit `-ub 4096`, weil `-ub 8192` 5 GB Rechenpuffer bräuchte.
|
||||||
|
- **Images** aus Compose-Builds landen per `docker save | k3s ctr images import -` im containerd von k3s (Rolle nex-speech-k3s); kein Registry nötig. Für neue Dienste ohne Build reicht `image:` aus Docker Hub oder nvcr.io.
|
||||||
|
- **ufw in den VMs:** die Rolle k3s-agent erlaubt 10250/tcp, 8472/udp, 30000–32767/tcp aus dem LAN und alles aus dem Pod-Netz 10.42.0.0/16.
|
||||||
|
- **Aktualisieren:** `k3s_version` in `roles/k3s-server/defaults` und `roles/k3s-agent/defaults` anheben, `playbooks/k3s.yml`; erst Server, dann Agents (so läuft das Playbook). Kubernetes-Versionen immer nur eine Minor-Stufe je Schritt.
|
||||||
|
- **Stromausfall:** VM 110 startet automatisch (onboot, HA). Agents verbinden sich neu, Pods starten mit den Knoten. Sollte `k3s` auf einen anderen Knoten umziehen, bleibt die Adresse (gleiche MAC), sobald die DHCP-Reservierung am DC steht.
|
||||||
|
|
||||||
|
## 15. Verwaltung übergeben oder teilen (seit 19.10.2026, „Weg 1“)
|
||||||
|
|
||||||
|
Die Verwaltung hängt an Personen, nicht an einem Gerät. Einstieg für jede Person ist **ops** (Bastion): eigener
|
||||||
|
SSH-Schlüssel dort, von ops aus laufen `kubectl`, Ansible und tofu und erreichen alle Knoten und Gäste. Ein Agent
|
||||||
|
(Claude Code o. Ä.) arbeitet auf dem Gerät der Person im Repo und nach [AGENTS.md](../AGENTS.md).
|
||||||
|
|
||||||
|
**Neue Person aufnehmen**
|
||||||
|
1. Die Person erzeugt auf ihrem Gerät einen Schlüssel (`ssh-keygen -t ed25519 -C <name>-<gerät>-<datum>`) und gibt
|
||||||
|
nur den öffentlichen Teil (`.pub`) weiter.
|
||||||
|
2. Eintrag in `sites/knaster/proxmox.yaml` unter `access.ops_ssh_keys` (`{name, key}`), committen, auf ops
|
||||||
|
`git pull` und `ansible-playbook playbooks/admins.yml` (zeigt danach die Fingerabdrücke).
|
||||||
|
3. Weboberfläche: Name in `access.admin_users`, `playbooks/cluster.yml`, dann `scripts/pve-admin-user.sh <name>`
|
||||||
|
(Passwort, TOTP, Recovery-Keys; Ergebnis in Password Depot). Geräteadresse(n) in `firewall.management`, erneut
|
||||||
|
`playbooks/cluster.yml` (Totmannschalter, Abschnitt 10).
|
||||||
|
4. Repo: Schreibzugang in Gitea (`gitea.mindboost.team/rorapp/cluster-knaster`) für die Person.
|
||||||
|
5. SSH-Konfiguration auf dem Gerät:
|
||||||
|
```
|
||||||
|
Host ops
|
||||||
|
HostName 192.168.100.173
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/<schlüssel>
|
||||||
|
IdentitiesOnly yes
|
||||||
|
```
|
||||||
|
Alles Weitere läuft über ops (`ssh ops`, dann `ssh pve44`, `kubectl …`). Prüfen: `ssh ops kubectl get nodes`.
|
||||||
|
6. Zugänge, die nicht im Repo stehen, über Password Depot übergeben (Proxmox-root-Notfall, Grafana-Admin aus
|
||||||
|
`/opt/knaster/infra/.env`).
|
||||||
|
|
||||||
|
**Person oder Gerät entfernen**
|
||||||
|
1. Eintrag von `access.ops_ssh_keys` nach `access.ops_ssh_keys_revoked` verschieben, `playbooks/admins.yml`.
|
||||||
|
2. Name aus `access.admin_users` streichen und das Konto in Proxmox löschen (`pveum user delete <name>@pve`; das
|
||||||
|
Playbook legt nur an), Adresse aus `firewall.management`, `playbooks/cluster.yml`.
|
||||||
|
3. Gitea-Zugang entziehen. Hatte die Person Zugang zu Geheimnissen (Password Depot, ops), die betroffenen Token
|
||||||
|
rotieren (Abschnitt 8).
|
||||||
|
|
||||||
|
**Was ops nicht ersetzt:** Den Schlüssel des Admin-Laptops (`rrapp-laptop-2026-10-08`) gibt es zusätzlich direkt auf
|
||||||
|
den Knoten (`/etc/pve/priv/authorized_keys`), in infra und in den VMs. Neue Personen brauchen das nicht; bei einer
|
||||||
|
vollständigen Übergabe dort ebenfalls entfernen.
|
||||||
|
|
||||||
|
**Notfall (bisherige Person nicht erreichbar):** `docs/NOTFALL-ADMIN-UEBERGABE.md` (Unterlage für die Geschäftsführung, ohne Passwörter): Firewall-Freigabe vor Ort an pve61, Schlüssel über die Proxmox-Shell von pve46 (`pct enter 100`) auf ops eintragen, danach wie oben dauerhaft machen.
|
||||||
|
|
||||||
|
**Später („Weg 2“, Phase 9):** Verwaltung über nex-admin mit freigegebenen Aktionen auf ops statt persönlicher
|
||||||
|
Shell, siehe Aufbauplan.
|
||||||
|
|
||||||
Ohne `N8N_HOST`/`N8N_API_KEY` (oder `KNASTER_ONTOLOGY_SNAPSHOT`) ist der Abgleich aus. Fällt n8n aus, läuft die Routine weiter und meldet das.
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Vorgehen pro Baustein: **Spec → Implementierung → Tests → Commit**. Bis zu
|
|||||||
|
|
||||||
## Offene Punkte vor dem Deployment
|
## Offene Punkte vor dem Deployment
|
||||||
|
|
||||||
- Image-Tags pinnen: LiteLLM (`main-stable`) und pve-exporter (`latest`). vLLM-, llama.cpp- und Treiber-Versionen gegen den aktuellen Stand prüfen.
|
- ~~Image-Tags pinnen: LiteLLM (`main-stable`) und pve-exporter (`latest`).~~ 12.10.2026: Infra-Stack vollständig gepinnt, LiteLLM entfällt (D4). Offen: vLLM-, llama.cpp- und Treiber-Versionen gegen den aktuellen Stand prüfen.
|
||||||
- LiteLLM-Wildcard-Routen (`tier1-general/*`) gegen die eingesetzte Version testen.
|
- LiteLLM-Wildcard-Routen (`tier1-general/*`) gegen die eingesetzte Version testen.
|
||||||
- Den n8n Chat-Trigger nach dem Import prüfen (Parameter der Knotenversion).
|
- Den n8n Chat-Trigger nach dem Import prüfen (Parameter der Knotenversion).
|
||||||
- `sites/knaster/*`: Hosts, VM-IDs, IPs und PCI-Adressen sind Annahmen und werden durch den Inventarlauf ersetzt.
|
- `sites/knaster/*`: Hosts, VM-IDs, IPs und PCI-Adressen sind Annahmen und werden durch den Inventarlauf ersetzt.
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Notfall: Verwaltung des Clusters „knaster“ an eine andere Person übergeben
|
||||||
|
|
||||||
|
Stand 19.10.2026. Für die Geschäftsführung als Notfallunterlage. **In diesem Dokument stehen keine Passwörter.**
|
||||||
|
Alle Geheimnisse liegen in Password Depot; hier steht nur, wo sie liegen und was man damit tut.
|
||||||
|
|
||||||
|
Technische Einzelheiten für die übernehmende Person: `docs/BETRIEB.md` Abschnitt 15, `AGENTS.md`, `project-map.md`
|
||||||
|
im Repository `cluster-knaster`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Worum es geht
|
||||||
|
|
||||||
|
Der Cluster „knaster“ (fünf Proxmox-Server mit KI-Diensten) wird von einer Person verwaltet, heute **Robert Rapp**.
|
||||||
|
Fällt diese Person aus (Krankheit, Austritt, nicht erreichbar), muss eine andere Person die Verwaltung übernehmen
|
||||||
|
können. Dafür braucht sie vier Zugänge:
|
||||||
|
|
||||||
|
| Zugang | Wofür | Woher im Notfall |
|
||||||
|
|---|---|---|
|
||||||
|
| **Proxmox-Konto mit TOTP** (Einmalcode) | Weboberfläche der Server: Zustand, Neustart, Konsole | Notfall: `root`-Passwort aus Password Depot; danach eigenes Konto |
|
||||||
|
| **Freigabe der Geräteadresse in der Firewall** | Ohne Freigabe ist die Weboberfläche vom eigenen Rechner aus gesperrt | vor Ort am Server pve61 (Abschnitt 4, Schritt 2) |
|
||||||
|
| **SSH-Schlüssel auf dem Steuerrechner „ops“** | Alle Verwaltungsarbeiten (Kubernetes, Ansible, Updates) | über die Proxmox-Weboberfläche eintragen (Schritt 4) |
|
||||||
|
| **Gitea-Zugang** zum Repository `cluster-knaster` | Konfiguration ändern und dauerhaft festhalten | Gitea-Administrator (siehe unten) |
|
||||||
|
|
||||||
|
## 2. Was vorher bereitliegen muss (einmal prüfen, dann jährlich)
|
||||||
|
|
||||||
|
- [ ] Die Geschäftsführung (oder eine benannte Vertretung) hat Zugriff auf Password Depot mit den Einträgen
|
||||||
|
**„Proxmox knaster – root (Notfall)“** (Passwörter `PVE44_PASS` … `PVE61_PASS`) und
|
||||||
|
**„Proxmox knaster – r.rapp@pve“** (Passwort, Einmalcode, Recovery-Keys).
|
||||||
|
- [ ] Bekannt ist, wo der Server **pve61** steht (Microsoft Surface mit eigenem Bildschirm, im Dock am Netzwerk):
|
||||||
|
Standort: ______________________________
|
||||||
|
- [ ] Gitea-Administrator für `gitea.mindboost.team` (kann Personen Zugang zum Repository geben):
|
||||||
|
Name / Kontakt: ______________________________
|
||||||
|
- [ ] Benannte übernehmende Person(en) mit Grundkenntnissen in Linux und SSH:
|
||||||
|
______________________________
|
||||||
|
|
||||||
|
## 3. Geplante Übergabe (die bisherige Person ist noch erreichbar)
|
||||||
|
|
||||||
|
Dauer etwa 30–60 Minuten, beide gemeinsam. Die bisherige Person führt die neue nach `docs/BETRIEB.md` Abschnitt 15
|
||||||
|
durch: Schlüssel eintragen, Proxmox-Konto mit TOTP anlegen, Geräteadresse freigeben, Gitea-Zugang. Danach ist
|
||||||
|
Abschnitt 4 nicht nötig. Das ausgefüllte Übergabeblatt (Abschnitt 6) zur Notfallunterlage legen.
|
||||||
|
|
||||||
|
## 4. Notfall: die bisherige Person ist nicht erreichbar
|
||||||
|
|
||||||
|
Die übernehmende Person (im Folgenden **B**) braucht: einen eigenen Laptop im Büronetz, Zugriff auf Password Depot
|
||||||
|
(über die Geschäftsführung) und einmal Zugang zum Server pve61 vor Ort.
|
||||||
|
|
||||||
|
**Schritt 1 – Laptop vorbereiten (B, 5 Minuten).** Unter Windows in „Git Bash“ (oder Linux/macOS im Terminal):
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -C "<name>-<geraet>-<datum>" # z. B. m.muster-laptop-2026-11-03, Enter für Standardpfad
|
||||||
|
cat ~/.ssh/id_ed25519.pub # öffentlicher Schlüssel, eine Zeile: ssh-ed25519 AAAA… <name>
|
||||||
|
ipconfig # eigene IPv4-Adresse im Netz 192.168.100.x notieren
|
||||||
|
```
|
||||||
|
Nur der **öffentliche** Schlüssel (`.pub`) wird weitergegeben, nie die Datei ohne Endung.
|
||||||
|
|
||||||
|
**Schritt 2 – Adresse von B in der Firewall freigeben (vor Ort an pve61, 5 Minuten).**
|
||||||
|
1. Am Surface anmelden: Benutzer `root`, Passwort `PVE61_PASS` aus Password Depot.
|
||||||
|
2. Datei öffnen: `nano /etc/pve/firewall/cluster.fw`
|
||||||
|
3. Im Block `[IPSET admin]` eine Zeile ergänzen, z. B. `192.168.100.57 # Notfall m.muster 2026-11-03`
|
||||||
|
4. Speichern (`Strg+O`, Enter, `Strg+X`). Die Änderung gilt nach wenigen Sekunden auf allen Servern.
|
||||||
|
|
||||||
|
Diese Freigabe ist vorläufig: Der nächste Lauf von `playbooks/cluster.yml` schreibt die Datei aus dem Repository neu.
|
||||||
|
Darum in Schritt 5 dauerhaft eintragen.
|
||||||
|
|
||||||
|
**Schritt 3 – Weboberfläche (B, am eigenen Laptop).** Browser: `https://192.168.100.210:8006`
|
||||||
|
(Zertifikatswarnung bestätigen). Benutzer `root`, Realm **„Linux PAM standard authentication“**, Passwort
|
||||||
|
`PVE44_PASS` aus Password Depot.
|
||||||
|
|
||||||
|
**Schritt 4 – Schlüssel von B auf dem Steuerrechner ops eintragen.**
|
||||||
|
1. Links im Baum den Server **pve46** wählen, oben **„Shell“**.
|
||||||
|
2. In der Shell:
|
||||||
|
```bash
|
||||||
|
pct enter 100
|
||||||
|
echo "ssh-ed25519 AAAA… <öffentlicher Schlüssel von B, ganze Zeile>" >> /root/.ssh/authorized_keys
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
3. Am Laptop von B prüfen: `ssh root@192.168.100.173 kubectl get nodes` → fünf Zeilen mit `Ready`.
|
||||||
|
|
||||||
|
Ab hier kann B den Cluster verwalten. Alle weiteren Arbeiten laufen über ops (siehe `AGENTS.md`).
|
||||||
|
|
||||||
|
**Schritt 5 – Dauerhaft machen (B, mit Gitea-Zugang).**
|
||||||
|
1. Gitea-Administrator gibt B Schreibzugang zu `gitea.mindboost.team/rorapp/cluster-knaster`; Repository klonen.
|
||||||
|
2. In `sites/knaster/proxmox.yaml` eintragen (Beispiel in Abschnitt 6):
|
||||||
|
Schlüssel unter `access.ops_ssh_keys`, Name unter `access.admin_users`, Adresse unter `firewall.management`.
|
||||||
|
3. Committen und pushen, dann auf ops:
|
||||||
|
```bash
|
||||||
|
cd /root/cluster-knaster && git pull
|
||||||
|
cd platform/proxmox/ansible
|
||||||
|
ansible-playbook playbooks/admins.yml
|
||||||
|
ansible-playbook playbooks/cluster.yml
|
||||||
|
```
|
||||||
|
4. Eigenes Proxmox-Konto mit TOTP: vom Laptop `platform/proxmox/scripts/pve-admin-user.sh <name>`
|
||||||
|
(setzt Passwort, Einmalcode und Recovery-Keys; die erzeugte Datei `tfa-<name>.txt` in Password Depot übernehmen
|
||||||
|
und löschen). Ab dann nicht mehr als `root` anmelden.
|
||||||
|
|
||||||
|
**Schritt 6 – Bisherige Person austragen (nur bei Austritt).**
|
||||||
|
Nach `docs/BETRIEB.md` Abschnitt 15 „Person oder Gerät entfernen“: Schlüssel nach `ops_ssh_keys_revoked`, Konto
|
||||||
|
`r.rapp@pve` löschen, alte Laptop-Adressen aus `firewall.management`, Gitea-Zugang entziehen. Zusätzlich die
|
||||||
|
root-Passwörter aller Server ändern (Password Depot aktualisieren) und die Token in `/root/.config/knaster/` auf ops
|
||||||
|
erneuern (BETRIEB Abschnitt 8). Der Laptop-Schlüssel der bisherigen Person liegt auch direkt auf den Servern
|
||||||
|
(`/etc/pve/priv/authorized_keys`), in infra und in den VMs und muss dort ebenfalls entfernt werden.
|
||||||
|
|
||||||
|
## 5. Was man im Notfall nicht tun sollte
|
||||||
|
|
||||||
|
- Die Firewall **nicht** ganz abschalten, solange es nicht anders geht (`pve-firewall stop` nur als letzter Ausweg,
|
||||||
|
danach sofort wieder `pve-firewall start`).
|
||||||
|
- Keine Server neu installieren oder Festplatten anfassen; Daten und KI-Dienste laufen weiter, auch ohne Verwaltung.
|
||||||
|
- Keine Passwörter oder privaten Schlüssel per Mail oder Chat verschicken.
|
||||||
|
- Server nur einzeln und nacheinander neu starten (`docs/BETRIEB.md` Abschnitt 6).
|
||||||
|
|
||||||
|
## 6. Beispiel: ausgefülltes Übergabeblatt (fiktive Person)
|
||||||
|
|
||||||
|
| Punkt | Beispiel | Wo es festgehalten wird |
|
||||||
|
|---|---|---|
|
||||||
|
| Person | Max Muster, IT, max.muster@… | dieses Blatt |
|
||||||
|
| Proxmox-Konto | `m.muster@pve`, Gruppe `admins`, TOTP eingerichtet am 03.11.2026 | Passwort, Einmalcode, Recovery-Keys in Password Depot, Eintrag „Proxmox knaster – m.muster@pve“ |
|
||||||
|
| Geräte und Adressen | Laptop WLAN `192.168.100.57`, Dock `192.168.100.58` (beim DC reservieren lassen) | `sites/knaster/proxmox.yaml` → `firewall.management` |
|
||||||
|
| SSH-Schlüssel | `ssh-ed25519 AAAAC3Nz…Xy m.muster-laptop-2026-11-03` | `sites/knaster/proxmox.yaml` → `access.ops_ssh_keys` |
|
||||||
|
| Gitea | Benutzer `mmuster`, Schreibzugang zu `rorapp/cluster-knaster`, erteilt von ________ | Gitea |
|
||||||
|
| Prüfung | `ssh ops kubectl get nodes` → 5× Ready; Anmeldung Weboberfläche mit TOTP ok | Datum, Unterschrift: ________ |
|
||||||
|
|
||||||
|
Eintrag in `sites/knaster/proxmox.yaml` (Ausschnitt):
|
||||||
|
```yaml
|
||||||
|
access:
|
||||||
|
admin_users: [r.rapp, m.muster]
|
||||||
|
ops_ssh_keys:
|
||||||
|
- {name: r.rapp-laptop, key: "ssh-ed25519 AAAAC3Nz…QcoJ rrapp-laptop-2026-10-08"}
|
||||||
|
- {name: m.muster-laptop, key: "ssh-ed25519 AAAAC3Nz…Xy m.muster-laptop-2026-11-03"}
|
||||||
|
ops_ssh_keys_revoked: []
|
||||||
|
firewall:
|
||||||
|
management:
|
||||||
|
- {ip: 192.168.100.57, comment: Laptop m.muster WLAN}
|
||||||
|
- {ip: 192.168.100.58, comment: Laptop m.muster Dock}
|
||||||
|
```
|
||||||
|
|
||||||
|
SSH-Konfiguration auf dem Laptop von B (`~/.ssh/config`):
|
||||||
|
```
|
||||||
|
Host ops
|
||||||
|
HostName 192.168.100.173
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
|
IdentitiesOnly yes
|
||||||
|
```
|
||||||
|
Danach genügt `ssh ops`. Von ops aus erreicht man alle Server und Gäste (`ssh pve44`, `kubectl …`, `ansible-playbook …`).
|
||||||
|
|
||||||
|
## 7. Prüfliste nach der Übernahme
|
||||||
|
|
||||||
|
- [ ] `ssh ops kubectl get nodes` zeigt fünf Knoten `Ready`.
|
||||||
|
- [ ] Weboberfläche mit eigenem Konto und Einmalcode, Übersicht ohne rote Server.
|
||||||
|
- [ ] Grafana `http://192.168.100.188:3000` öffnet (Passwort `admin` steht in `/opt/knaster/infra/.env` auf infra).
|
||||||
|
- [ ] Proxmox-Benachrichtigungen kommen an (Mail an `ki@mygodel.de`; Empfänger bei Bedarf in
|
||||||
|
`access.mail_to` ändern).
|
||||||
|
- [ ] Übergabeblatt ausgefüllt und zur Notfallunterlage gelegt; Password Depot aktualisiert.
|
||||||
+41
-31
@@ -33,7 +33,6 @@ Ziel: Ein starkes LLM (OpenRouter) erarbeitet neue Aufgaben einmalig und bricht
|
|||||||
- **GPU-Nodes als VM.** Grund: Die alten Karten (Maxwell M4000, Pascal P2000) brauchen fest gepinnte Treiber mit CUDA 12.x, die Ada-Karten profitieren von aktuellen Treibern. Mit VMs lebt beides sauber getrennt auf einem Cluster. Treiber-Updates werden per Snapshot rückrollbar.
|
- **GPU-Nodes als VM.** Grund: Die alten Karten (Maxwell M4000, Pascal P2000) brauchen fest gepinnte Treiber mit CUDA 12.x, die Ada-Karten profitieren von aktuellen Treibern. Mit VMs lebt beides sauber getrennt auf einem Cluster. Treiber-Updates werden per Snapshot rückrollbar.
|
||||||
- **CPU-Infrastruktur als LXC oder leichte VM** (n8n, Postgres, LiteLLM, Monitoring). Das spart RAM auf den i7-Nodes.
|
- **CPU-Infrastruktur als LXC oder leichte VM** (n8n, Postgres, LiteLLM, Monitoring). Das spart RAM auf den i7-Nodes.
|
||||||
- Innerhalb der VMs bzw. LXCs läuft Docker Compose, ein Compose-Projekt pro Rolle.
|
- Innerhalb der VMs bzw. LXCs läuft Docker Compose, ein Compose-Projekt pro Rolle.
|
||||||
- **Stand Aufbauplan (D2):** eine GPU-VM pro Knoten mit allen Karten des Knotens statt einer VM pro Rolle. Im Cluster stecken nur noch Maxwell und Pascal, die Ada-Rechner bleiben Windows-Arbeitsplätze (ADR-0001 und ADR-0006, jeweils Nachtrag).
|
|
||||||
|
|
||||||
### 1.2 Was Proxmox zusätzlich ermöglicht
|
### 1.2 Was Proxmox zusätzlich ermöglicht
|
||||||
|
|
||||||
@@ -48,35 +47,45 @@ Ziel: Ein starkes LLM (OpenRouter) erarbeitet neue Aufgaben einmalig und bricht
|
|||||||
|
|
||||||
### 1.3 Rollenverteilung (aktuelle Hardware)
|
### 1.3 Rollenverteilung (aktuelle Hardware)
|
||||||
|
|
||||||
Stand: Aufbauplan vom 12.10.2026 (`docs/superpowers/plans/2026-10-12-aufbau-cluster-knaster.md`). Die Knoten stehen in `sites/knaster/inventory.yaml`. Welche P2000 welche Rolle bekommt, legt erst der Benchmark-Tag fest (Task 3.3).
|
Stand 12.10.2026, per SSH geprüft. Proxmox-Cluster `knaster` mit fünf Knoten:
|
||||||
|
|
||||||
| Rolle | Hardware | Engine | Aufgabe |
|
| Knoten | IP | Hardware (Kerne/Threads, RAM) | GPUs |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `gpu-main` | RTX 4000 Ada (20 GB) in GODE-PC-05, Windows | llama-server über Worker-Registry v2 (D3); vLLM/Multi-LoRA erst ab Phase 9 | Slot `tier1-general`, wenn der Worker online ist, sonst tier0 |
|
| pve44 | 192.168.100.210 | ThinkStation P310, i7-6700 (4/8), 32 GB | Quadro M4000 (8 GB) + Quadro P2000 (5 GB) |
|
||||||
| `gpu-flex` | RTX 4000 Ada (20 GB) in GODE-PC-06, Windows | wie `gpu-main` | `tier1-flex`; Training erst ab Phase 9 |
|
| pve46 | 192.168.100.209 | ThinkStation P310, i7-6700 (4/8), 32 GB | M4000 + P2000 |
|
||||||
| `gpu-embed` | 2× Quadro P2000 (5 GB) in `gpu-pve44` und `gpu-pve46` | llama.cpp (Embedding/Rerank) | `embed-default`, `rerank-default`, redundant auf zwei Knoten |
|
| pve48 | 192.168.100.212 | ThinkStation P310, i7-6700 (4/8), 32 GB | M4000 + P2000 |
|
||||||
| `gpu-small` | Quadro P2000 (5 GB) in `gpu-pve48` | llama.cpp | `verifier-small`, `signature-extractor` (3–4B) |
|
| pve52 | 192.168.100.207 | ThinkStation P320, i7-7700 (4/8), 32 GB | nur M4000 |
|
||||||
| – | 4× Quadro M4000 (8 GB), je eine in pve44/46/48/52 | – | **vorerst außer Betrieb** (ADR-0006), werden aber an die GPU-VM durchgereicht; nach dem Benchmark nur für llama.cpp-Rollen |
|
| pve61 | 192.168.100.214 | Surface Book, i5-8350U (4/8), 8 GB | keine NVIDIA (Intel UHD 620, Kiosk) |
|
||||||
| `tts-hq` | teilt die Ada in GODE-PC-06 | PyTorch | `tts-de-hq`: Qwen3-TTS 1.7B, erst ab Phase 9 (WSL2, D3) |
|
|
||||||
| `tts-cpu` | Infra-LXC (CPU) | Piper (ONNX) | `tts-de`: Sprachausgabe, vorerst einziger TTS-Slot |
|
|
||||||
| `infra` | LXC `infra` auf pve44 (`tank`, HA nach pve52/pve48) | Docker | Router, Postgres+pgvector, Healer, Prometheus/Grafana/Alertmanager; **kein eigener LiteLLM** (D4) |
|
|
||||||
| `ops` | LXC auf pve46 | – | Ansible, OpenTofu, Repo-Clone |
|
|
||||||
| `strong` | extern | OpenRouter **über den LiteLLM mit PSE auf appsrv08** | `tier0-strong`, Skill-Autor (Hermes) |
|
|
||||||
|
|
||||||
pve61 (Surface Book, 8 GB) nimmt keine Gäste auf und zeigt nur das Grafana-Dashboard als Kiosk. n8n läuft weiter auf n8n.mindboost.app, LiteLLM auf appsrv08 (D4).
|
Jeder GPU-Knoten hat **eine** GPU-VM mit allen Karten (`gpu-pve44`, `gpu-pve46`, `gpu-pve48`, `gpu-pve52`, ADR-0001 Nachtrag). Die beiden RTX 4000 Ada (20 GB) stecken nicht im Cluster, sondern je eine in den Windows-Arbeitsplätzen GODE-PC-05 und GODE-PC-06 (64 GB RAM, ADR-0006 Nachtrag). Die Zuordnung unten ist die Ausgabe von `knaster placement` mit `sites/knaster/inventory.yaml`.
|
||||||
|
|
||||||
|
| Rolle | Hardware | Ort | Engine | Aufgabe |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `gpu-main` | RTX 4000 Ada (20 GB) | gode-pc-05 (Windows) | vLLM, Multi-LoRA; unter Windows vorerst llama-server (D3, vorgeschlagen) | Slot `tier1-general` plus Skill-Adapter |
|
||||||
|
| `gpu-flex` | RTX 4000 Ada (20 GB) | gode-pc-06 (Windows) | vLLM / Unsloth; unter Windows vorerst llama-server (D3, vorgeschlagen) | tagsüber zweiter Slot oder Reserve, nachts LoRA-Training |
|
||||||
|
| `gpu-embed` | 2× Quadro P2000 (5 GB) | `gpu-pve44`, `gpu-pve46` | llama.cpp (Embedding/Rerank) | `embed-default`, `rerank-default`, redundant auf zwei Knoten |
|
||||||
|
| `gpu-small` | Quadro P2000 (5 GB) | `gpu-pve48` | llama.cpp | `verifier-small`, `signature-extractor` (3–4B) |
|
||||||
|
| – | 4× Quadro M4000 (8 GB) | je eine in den vier GPU-VMs | – | **vorerst außer Betrieb** (ADR-0006), durchgereicht, aber `disabled`; nur nach Benchmark für llama.cpp-Rollen |
|
||||||
|
| `tts-hq` | teilt die Ada von `gpu-flex` | gode-pc-06 | PyTorch | `tts-de-hq`: Qwen3-TTS 1.7B (ADR-0006), unter Windows erst nach der WSL2-Entscheidung |
|
||||||
|
| `tts-cpu` | Infra-LXC (CPU) | `infra` | Piper (ONNX) | `tts-de`: schnelle Sprachausgabe, Fallback |
|
||||||
|
| `infra` | LXC, 4 Kerne, 8 GB | pve44 in `tank`, HA nach pve52/pve48 | Docker | n8n, Postgres+pgvector, Redis, Router, Healer, Langfuse, Prometheus/Grafana; LiteLLM auf appsrv08 (D4, vorgeschlagen) |
|
||||||
|
| `strong` | extern | – | OpenRouter | `tier0-strong` über den LiteLLM mit PSE auf appsrv08, Skill-Autor (Hermes) |
|
||||||
|
|
||||||
|
Das Placement schlägt für `infra` pve52 vor, weil es CPU-Rollen auf Knoten ohne aktive GPU-Rolle legt. Festgelegt ist pve44 (`sites/knaster/proxmox.yaml`), pve52 ist Replikations- und HA-Ziel.
|
||||||
|
|
||||||
Wichtig: Auf Maxwell und Pascal läuft kein vLLM (das braucht Compute Capability ≥ 7.0), und CUDA 13 unterstützt sie nicht mehr. Daher auf diesen Nodes Treiber und Images pinnen. Auch PyTorch hat dort keine Zukunft mehr (2.14 ist die letzte Version mit CUDA-12-Wheels). Deshalb laufen auf alten Karten nur llama.cpp-Workloads (ADR-0006).
|
Wichtig: Auf Maxwell und Pascal läuft kein vLLM (das braucht Compute Capability ≥ 7.0), und CUDA 13 unterstützt sie nicht mehr. Daher auf diesen Nodes Treiber und Images pinnen. Auch PyTorch hat dort keine Zukunft mehr (2.14 ist die letzte Version mit CUDA-12-Wheels). Deshalb laufen auf alten Karten nur llama.cpp-Workloads (ADR-0006).
|
||||||
|
|
||||||
### 1.4 Netzwerk und Storage (entschieden, siehe ADR-0002 mit Nachtrag D1)
|
### 1.4 Netzwerk und Storage (entschieden, siehe ADR-0002)
|
||||||
|
|
||||||
Ausgangslage: **1 GbE**, flaches Netz `192.168.100.0/24`. Die Knoten sind mit ext4 und LVM-thin auf einer System-SSD installiert, kein ZFS.
|
Ausgangslage: **1 GbE** zwischen den Knoten, flaches Netz `192.168.100.0/24` (Gateway `.1`, DNS `.120`, kein VLAN). Pro Knoten **eine oder zwei SSDs mit 256 GB**, pve48 zusätzlich eine leere 128-GB-SSD (Tabelle in 1.3).
|
||||||
|
|
||||||
- **Kein Ceph.** Ceph braucht für brauchbare Latenz und Rebuild-Zeiten mindestens 10 GbE und mehr Platz pro OSD. Mit 1 GbE und kleinen SSDs würde jeder Schreibvorgang über das Netz gebremst und ein Rebuild das Netz tagelang belegen.
|
- **Kein Ceph.** Ceph braucht für brauchbare Latenz und Rebuild-Zeiten mindestens 10 GbE und mehr Platz pro OSD. Mit 1 GbE und kleinen SSDs würde jeder Schreibvorgang über das Netz gebremst und ein Rebuild das Netz tagelang belegen.
|
||||||
- **Variante B (D1, entschieden 12.10.):** Die System-SSD bleibt (`local`, `local-lvm` für die GPU-VMs). Auf pve44, pve48 und pve52 wird die zweite SSD nach Freigabe zur Einzel-SSD `tank`. pve46 und pve61 bleiben ohne ZFS. Der ARC ist auf 4 GiB begrenzt.
|
- **System auf ext4/LVM-thin, ZFS auf der zweiten SSD** (D1 = Variante B, ADR-0002 Nachtrag). SSD 1 trägt das Proxmox-System mit `local` und `local-lvm`. Die zweite SSD ist auf pve44, pve48 und pve52 der ZFS-Pool `tank` (Einzel-SSD), abgesichert über Replikation. pve46 und pve61 haben nur eine Platte und bleiben ohne ZFS.
|
||||||
- **ZFS-Replikation** (Proxmox-Job, alle 15 Min.) für HA-Gäste (`infra`) zwischen den `tank`-Knoten. Zusammen mit Proxmox HA startet der Gast nach einem Knotenausfall mit höchstens 15 Min. altem Stand. Für Postgres wird das zusätzlich durch PBS-Backups und WAL-Archivierung abgefedert.
|
- **ZFS-Replikation** (Proxmox-Job, alle 15 Min.) nur zwischen pve44, pve48 und pve52: `infra` liegt auf pve44 in `tank` und wird nach pve52 und pve48 repliziert. Zusammen mit Proxmox HA startet der LXC nach einem Knotenausfall dort mit maximal 15 Min. altem Stand. Für Postgres wird das zusätzlich durch PBS-Backups und WAL-Archivierung abgefedert.
|
||||||
- **Modelle liegen lokal** auf der GPU-VM, in einem eigenen Disk-Image (`models`, 60 GB, nicht repliziert, nicht gesichert). Quelle ist Hugging Face oder ein optionaler **Modell-Mirror** auf einem Infra-Node (HTTP/NFS, nur lesend). 10 GB über 1 GbE dauern etwa 90 s, das ist für Modellwechsel unkritisch.
|
- **Modelle liegen lokal** auf der GPU-VM (auf `local-lvm`), in einem eigenen Disk-Image (`models`, nicht repliziert, nicht gesichert). Quelle ist Hugging Face oder ein optionaler **Modell-Mirror** auf einem Infra-Node (HTTP/NFS, nur lesend). 10 GB über 1 GbE dauern etwa 90 s, das ist für Modellwechsel unkritisch.
|
||||||
- **Proxmox Backup Server** als VM `pbs` auf pve52, Datastore auf einem externen Datenträger oder NAS (Task 5.1). Gesichert werden Postgres, Konfiguration und Datensätze, keine Modellgewichte.
|
- **Proxmox Backup Server** als VM oder LXC auf dem Node mit dem meisten freien SSD-Platz, oder besser auf einer externen Platte bzw. einem NAS. Gesichert werden Postgres, Konfiguration und Datensätze, keine Modellgewichte.
|
||||||
- **Netz (D5):** Die Knoten behalten .207–.214. Gäste bekommen feste IPs aus einem Block außerhalb des DHCP-Bereichs, vorgeschlagen ist `.220–.239` (Prüfung in Task 0.2). Konfigurationen nutzen IP-Literale, keine DNS-Namen. **VLANs** (`mgmt`, `svc`, `ext`) kommen erst später, wenn der Switch das zulässt.
|
- **IP-Adressen:** Die Knoten behalten .207–.214. Gäste bekommen vorerst DHCP-Adressen (D5 zurückgestellt); feste Gast-IPs müssen vor Phase 6 stehen, weil STT und die LiteLLM-Slots IP-Literale nutzen.
|
||||||
|
- **Netztrennung per VLAN** (Proxmox SDN) ist zurückgestellt, bis der Switch das zulässt: `mgmt` (Proxmox, PBS), `svc` (Dienste untereinander), `ext` (Zugriff von außen auf n8n und Router). Ohne Bandbreitengewinn, aber Replikation und Backups ließen sich zeitlich steuern, damit sie den Inferenz-Traffic nicht stören.
|
||||||
|
|
||||||
**Speicherbudget pro Rolle (Richtwerte):**
|
**Speicherbudget pro Rolle (Richtwerte):**
|
||||||
|
|
||||||
@@ -86,7 +95,7 @@ Ausgangslage: **1 GbE**, flaches Netz `192.168.100.0/24`. Die Knoten sind mit ex
|
|||||||
| `gpu-embed` / `gpu-small` | 15–25 GB | Embedding-, Rerank- und kleine GGUF-Modelle (je 0,5–3 GB), Images |
|
| `gpu-embed` / `gpu-small` | 15–25 GB | Embedding-, Rerank- und kleine GGUF-Modelle (je 0,5–3 GB), Images |
|
||||||
| `infra` | 40–80 GB | Postgres (Fälle, Traces), Prometheus (15 Tage Retention), Langfuse |
|
| `infra` | 40–80 GB | Postgres (Fälle, Traces), Prometheus (15 Tage Retention), Langfuse |
|
||||||
|
|
||||||
Die SSD-Verteilung steht in `sites/knaster/inventory.yaml` (Bestandsaufnahme vom 12.10.).
|
`gpu-main` und `gpu-flex` liegen auf den Windows-Rechnern. Eine GPU-VM hat 40 GB Systemplatte und 60 GB Modellplatte auf `local-lvm` (141 GB je Knoten), `infra` 60 GB in `tank` (238 GB). Die SSD-Verteilung steht in `sites/knaster/inventory.yaml`, die Gäste in `sites/knaster/proxmox.yaml`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -309,14 +318,15 @@ Die Portabilität beruht also auf den **Daten und der Pipeline**, nicht auf Gewi
|
|||||||
- Infrastruktur heilt deterministisch, ohne LLM-Entscheidungen (ADR-0005)
|
- Infrastruktur heilt deterministisch, ohne LLM-Entscheidungen (ADR-0005)
|
||||||
- Alte GPUs nur llama.cpp, M4000 vorerst aus, TTS mit Piper (CPU) und Qwen3-TTS (Ada) (ADR-0006)
|
- Alte GPUs nur llama.cpp, M4000 vorerst aus, TTS mit Piper (CPU) und Qwen3-TTS (Ada) (ADR-0006)
|
||||||
|
|
||||||
### Entscheidungen 2026-10 (Aufbauplan)
|
### Entscheidungen 2026-10
|
||||||
| # | Thema | Stand |
|
|
||||||
|---|---|---|
|
Aus dem [Aufbauplan](superpowers/plans/2026-10-12-aufbau-cluster-knaster.md), Abschnitt „Entscheidungen“:
|
||||||
| D1 | Speicher: Variante B, keine Neuinstallation, `tank` auf pve44/48/52 | **entschieden** 12.10. Gelöscht wird erst nach ausdrücklicher Freigabe der alten Windows-Platten |
|
|
||||||
| D2 | Eine Ubuntu-VM pro GPU-Knoten mit allen Karten (ADR-0001, Nachtrag) | empfohlen, Bestätigung offen (Task 0.3) |
|
- **D1 Speicher – umgesetzt (12.10.):** Variante B ohne Neuinstallation. System-SSD ext4/LVM-thin mit `local-lvm`, zweite SSD als ZFS-Pool `tank` auf pve44, pve48 und pve52. Replikation und HA nur zwischen diesen drei, pve46 und pve61 ohne ZFS (ADR-0002, Nachtrag).
|
||||||
| D3 | Ada-Rechner bleiben Windows, Anbindung über die Worker-Registry v2 (ADR-0006, Nachtrag) | empfohlen, Bestätigung offen |
|
- **D2 GPU als VM – entschieden (12.10.):** eine Ubuntu-24.04-VM pro GPU-Knoten mit allen Karten des Knotens (`gpu-pve44` 244, `gpu-pve46` 246, `gpu-pve48` 248, `gpu-pve52` 252), darin ein Docker-Container pro Rolle (ADR-0001, Nachtrag).
|
||||||
| D4 | Kein eigener LiteLLM, der Router nutzt den LiteLLM mit PSE auf appsrv08 | empfohlen, Bestätigung offen |
|
- **D3 Ada-Rechner – vorgeschlagen, Bestätigung offen:** GODE-PC-05/06 bleiben Windows und stellen ihre Leistung über die Worker-Registry v2 bereit (ADR-0006, Nachtrag; Umsetzung in Task 6.4).
|
||||||
| D5 | Knoten-IPs bleiben, Gäste aus `.220–.239`, kein VLAN | empfohlen, Bestätigung offen. Der IP-Block wird in Task 0.2 geprüft |
|
- **D4 LiteLLM – vorgeschlagen, Bestätigung offen:** kein eigener LiteLLM im knaster-Infra. Der Router nutzt den LiteLLM mit PSE auf appsrv08, die lokalen Slots werden dort als Modelle eingetragen.
|
||||||
|
- **D5 IP-Plan – zurückgestellt (12.10.):** Knoten behalten .207–.214, Gäste vorerst per DHCP. Feste Gast-IPs (Vorschlag `.220–.239`) über Reservierungen bzw. einen Ausschluss im DHCP-Server, spätestens vor Phase 6.
|
||||||
|
|
||||||
### Offen
|
### Offen
|
||||||
1. Erstes Basismodell für `tier1-general` (8–14B, gut quantisierbar, LoRA-freundlich, gutes Deutsch). Entscheidung per Kalibrierungslauf.
|
1. Erstes Basismodell für `tier1-general` (8–14B, gut quantisierbar, LoRA-freundlich, gutes Deutsch). Entscheidung per Kalibrierungslauf.
|
||||||
@@ -324,4 +334,4 @@ Die Portabilität beruht also auf den **Daten und der Pipeline**, nicht auf Gewi
|
|||||||
3. Verifier-Strategie pro Aufgabentyp: Code-Checks, Schema, kleines Judge-Modell, Stichproben durch das starke Modell
|
3. Verifier-Strategie pro Aufgabentyp: Code-Checks, Schema, kleines Judge-Modell, Stichproben durch das starke Modell
|
||||||
4. Schwellwerte: Graduierung (Start 90 %), Fallzahl fürs LoRA-Training (Start 300), Canary-Anteil (Start 10 %)
|
4. Schwellwerte: Graduierung (Start 90 %), Fallzahl fürs LoRA-Training (Start 300), Canary-Anteil (Start 10 %)
|
||||||
5. Kanal für den Pflege-Agenten: n8n Chat, Telegram oder Matrix
|
5. Kanal für den Pflege-Agenten: n8n Chat, Telegram oder Matrix
|
||||||
6. Ziel-Speicher des Proxmox Backup Servers (Aufbauplan Task 5.1, Empfehlung: externer Datenträger oder NAS)
|
6. Standort des Proxmox Backup Servers (intern auf SSD oder extern)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0001: GPU-Nodes als VM, Infrastruktur als LXC
|
# ADR-0001: GPU-Nodes als VM, Infrastruktur als LXC
|
||||||
|
|
||||||
- Status: angenommen, ergänzt am 2026-10-13 (Nachtrag D2, Bestätigung offen)
|
- Status: angenommen
|
||||||
- Datum: 2026-10-11
|
- Datum: 2026-10-11
|
||||||
|
|
||||||
## Kontext
|
## Kontext
|
||||||
@@ -16,14 +16,11 @@ Im Cluster stecken GPUs aus drei Generationen: Maxwell (M4000), Pascal (P2000) u
|
|||||||
- Keine Live-Migration für GPU-VMs. Bei einem Ausfall springt der Router auf einen anderen Slot oder auf Stufe 0.
|
- Keine Live-Migration für GPU-VMs. Bei einem Ausfall springt der Router auf einen anderen Slot oder auf Stufe 0.
|
||||||
- Treiber-Updates laufen pro VM, abgesichert durch einen Snapshot.
|
- Treiber-Updates laufen pro VM, abgesichert durch einen Snapshot.
|
||||||
|
|
||||||
## Nachtrag 2026-10-13: eine GPU-VM pro Knoten (Aufbauplan D2, Bestätigung offen)
|
## Nachtrag 2026-10-12: eine GPU-VM pro Knoten (D2)
|
||||||
Die echte Hardware (Aufbauplan, Ausgangslage) ändert die Voraussetzungen:
|
- Statt einer VM pro Rolle bekommt jeder GPU-Knoten **eine** Ubuntu-24.04-VM, an die alle Karten des Knotens durchgereicht werden: `gpu-pve44` (vmid 244), `gpu-pve46` (246), `gpu-pve48` (248), `gpu-pve52` (252). Darin läuft pro Rolle ein Docker-Container.
|
||||||
- Im Proxmox-Cluster stecken nur Maxwell und Pascal (pve44, pve46, pve48: M4000 + P2000, pve52: nur M4000). Die beiden RTX 4000 Ada sitzen in Windows-Arbeitsplätzen außerhalb des Clusters (D3, ADR-0006). Das Argument „gemischte Generationen in einem Host“ entfällt.
|
- Gründe:
|
||||||
- Die Knoten haben 32 GB RAM. Eine VM pro Rolle würde den RAM mehrfach fest reservieren.
|
- **RAM:** Passthrough pinnt den gesamten VM-RAM. Eine VM mit 12 GB pro Knoten lässt auf 32 GB mehr für Container und Failover übrig als mehrere kleine VMs.
|
||||||
|
- **Nur Maxwell/Pascal im Cluster:** Die Ada-Karten bleiben in den Windows-Rechnern (ADR-0006, Nachtrag). M4000 und P2000 laufen mit demselben Legacy-Treiber (R580, CUDA 12.x) und passen deshalb in eine VM. Getrennte Treiber je Generation sind nicht mehr nötig.
|
||||||
Deshalb gilt, sobald D2 bestätigt ist:
|
- **Host-Kernel ohne NVIDIA:** Der Treiber (R580) kommt als Ubuntu-Paket in der VM und ist per Snapshot rückrollbar. Proxmox-Kernel-Updates berühren ihn nicht.
|
||||||
- **Pro GPU-Knoten eine Ubuntu-24.04-VM** (`gpu-pve44` … `gpu-pve52`, vmid 244–252) mit **allen** Karten des Knotens, 12 GB RAM fest, R580 (`driver_generation: legacy`). Darin laufen die Rollen als Docker-Compose-Dienste.
|
- Mappings heißen `gpu-<karte>-<knoten>` (z. B. `gpu-m4000-pve44`), Quelle ist `sites/knaster/proxmox.yaml`. Die M4000 werden mit durchgereicht, bleiben im Inventar aber `disabled` (ADR-0006).
|
||||||
- Resource Mappings heißen `gpu-<typ>-<knoten>` (z. B. `gpu-p2000-pve44`).
|
- Folgen: Ein Neustart der GPU-VM trifft alle Rollen des Knotens. Der Router weicht dann auf die zweite `gpu-embed`-Instanz bzw. auf Stufe 0 aus. Die RAM-Richtwerte oben (6–8 GB, 24–32 GB) gelten nicht mehr.
|
||||||
- Ausgeschaltete Karten (M4000) werden trotzdem durchgereicht. So braucht ihr Wiedereinsatz nach dem Benchmark keine Änderung an der VM.
|
|
||||||
- Infra bleibt LXC (`infra`, vmid 101), dazu kommt ein kleiner `ops`-LXC (vmid 100) für Ansible und OpenTofu.
|
|
||||||
- Die oben genannten RAM-Richtwerte für vLLM-VMs entfallen. Das RAM-Budget je Knoten steht im Aufbauplan.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0002: ZFS lokal + Replikation statt Ceph
|
# ADR-0002: ZFS lokal + Replikation statt Ceph
|
||||||
|
|
||||||
- Status: angenommen, ergänzt am 2026-10-13 (Nachtrag D1, entschieden)
|
- Status: angenommen
|
||||||
- Datum: 2026-10-11
|
- Datum: 2026-10-11
|
||||||
|
|
||||||
## Kontext
|
## Kontext
|
||||||
@@ -17,12 +17,9 @@ Die Nodes sind mit **1 GbE** verbunden. Jede physische Maschine hat 2–3 SSDs m
|
|||||||
- Modellwechsel verursachen Downloads (10 GB ≈ 90 s über 1 GbE). Das ist akzeptabel.
|
- Modellwechsel verursachen Downloads (10 GB ≈ 90 s über 1 GbE). Das ist akzeptabel.
|
||||||
- Die Speicherbudgets pro Rolle stehen in PLAN.md, Abschnitt 1.4.
|
- Die Speicherbudgets pro Rolle stehen in PLAN.md, Abschnitt 1.4.
|
||||||
|
|
||||||
## Nachtrag 2026-10-13: Variante B ohne Neuinstallation (Aufbauplan D1, entschieden am 12.10.)
|
## Nachtrag 2026-10-12: Umsetzung als Variante B (D1)
|
||||||
Die Knoten sind bereits mit ext4 und LVM-thin auf der System-SSD installiert. Entschieden ist **Variante B**:
|
- Keine Neuinstallation. Die System-SSD bleibt bei ext4 mit LVM-thin: `local` für System, ISOs und Vorlagen, `local-lvm` für die Platten der Gäste.
|
||||||
- Die System-SSD bleibt, wie sie ist (`local` und `local-lvm`). Die GPU-VMs liegen auf `local-lvm`.
|
- Die zweite SSD ist der ZFS-Pool `tank` (Einzel-SSD, kein Mirror) auf **pve44, pve48 und pve52**. pve46 und pve61 haben nur eine Platte und bleiben ohne ZFS. Die leere 128-GB-SSD `sdc` in pve48 ist noch nicht verplant.
|
||||||
- Auf **pve44, pve48 und pve52** wird die zweite SSD (bisher altes Windows) nach ausdrücklicher Freigabe remote zur Einzel-SSD `tank` (`ashift=12`, `lz4`). Auf pve48 bleibt `sdc` unberührt.
|
- Replikation und HA laufen nur zwischen pve44, pve48 und pve52. `infra` (LXC, vmid 101) liegt auf pve44 in `tank` und wird nach pve52 und pve48 repliziert.
|
||||||
- **pve46** (eine Platte) und **pve61** (Kiosk) bekommen kein ZFS. Dort laufen nur Gäste ohne HA (GPU-VM, `ops`).
|
- Die GPU-VMs liegen auf `local-lvm` des eigenen Knotens. Sie werden nicht repliziert, Modelle lassen sich neu laden.
|
||||||
- HA-Gäste (`infra`) liegen auf `tank` und werden alle 15 Min. auf die anderen `tank`-Knoten repliziert.
|
- Folgen: Fällt eine System-SSD aus, wird der Knoten neu installiert. Gesichert sind dann nur die replizierten Gäste in `tank` und die PBS-Backups. Variante A (Neuinstallation mit ZFS-Mirror, vor Ort) lässt sich später je Knoten nachholen.
|
||||||
- Der ZFS-ARC wird auf 4 GiB begrenzt.
|
|
||||||
- Einen Spiegel für die Systemplatte gibt es nicht. Fällt eine System-SSD aus, wird der Knoten neu installiert, die HA-Gäste laufen repliziert weiter.
|
|
||||||
- Variante A (Neuinstallation mit ZFS-RAID1 vor Ort) lässt sich später pro Knoten nachholen.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0006: Alte GPUs nur für llama.cpp, M4000 vorerst außer Betrieb, TTS auf Ada und CPU
|
# ADR-0006: Alte GPUs nur für llama.cpp, M4000 vorerst außer Betrieb, TTS auf Ada und CPU
|
||||||
|
|
||||||
- Status: angenommen, ergänzt am 2026-10-13 (Nachtrag D3, Bestätigung offen)
|
- Status: angenommen
|
||||||
- Datum: 2026-10-12
|
- Datum: 2026-10-12
|
||||||
|
|
||||||
## Kontext
|
## Kontext
|
||||||
@@ -21,8 +21,57 @@
|
|||||||
- Bei `gpu-flex` muss sich der Lifecycle die Karte mit TTS teilen. Während des Trainings gehen sowohl `tier1-flex` als auch `tts-de-hq` auf `unavailable`, und LiteLLM fällt auf `tts-de` zurück.
|
- Bei `gpu-flex` muss sich der Lifecycle die Karte mit TTS teilen. Während des Trainings gehen sowohl `tier1-flex` als auch `tts-de-hq` auf `unavailable`, und LiteLLM fällt auf `tts-de` zurück.
|
||||||
- Die tatsächlichen RTF-Werte misst `knaster bench tts` beim Deployment. Sie ersetzen die Schätzungen im Katalog.
|
- Die tatsächlichen RTF-Werte misst `knaster bench tts` beim Deployment. Sie ersetzen die Schätzungen im Katalog.
|
||||||
|
|
||||||
## Nachtrag 2026-10-13: Ada-Rechner bleiben Windows (Aufbauplan D3, Bestätigung offen)
|
## Nachtrag 2026-10-12: Ada-Rechner bleiben Windows (D3, vorgeschlagen, Bestätigung offen)
|
||||||
Die beiden RTX 4000 Ada stecken in **GODE-PC-05 und GODE-PC-06** (je eine Karte, 64 GB RAM). Das sind Windows-Arbeitsplätze, tagsüber haben Mitarbeitende Vorrang. Sie laufen schon heute als LLM-Worker (`LocalLLMWorker.ps1`, llama-server mit CUDA).
|
- Die beiden RTX 4000 Ada (20 GB) stecken nicht in einem Proxmox-Knoten, sondern je eine in den Windows-Arbeitsplätzen GODE-PC-05 und GODE-PC-06 (64 GB RAM). Sie bleiben Windows, Mitarbeitende haben tagsüber Vorrang.
|
||||||
- Die Slots `tier1-general` und `tier1-flex` lösen künftig über die **Worker-Registry v2** auf einen Worker auf, der gerade online ist. Ist keiner online, geht die Anfrage an tier0 über den PSE-LiteLLM (Aufbauplan Task 6.4). Bis dahin behalten sie in der Registry ihre Engine.
|
- Vorschlag: `LocalLLMWorker.ps1` mit llama-server (CUDA) meldet sich über die Worker-Registry v2. `tier1-general` (gode-pc-05) und `tier1-flex` (gode-pc-06) lösen auf einen Worker auf, der online ist, sonst auf Stufe 0 über den LiteLLM mit PSE.
|
||||||
- **vLLM, Multi-LoRA, QLoRA und Qwen3-TTS (`tts-de-hq`)** laufen unter Windows nicht nativ. Sie kommen erst mit einer späteren Entscheidung über WSL2 bzw. nächtliches vLLM (Phase 9). Bis dahin ist `tts-de` (Piper, CPU) der einzige aktive TTS-Slot.
|
- In `sites/knaster/registry.yaml` behalten die Ada-Slots vorerst ihre Engine, nur `pve_node` und `vmid` entfallen. Die Umstellung folgt in Task 6.4.
|
||||||
- Im Inventar stehen die Rechner mit `cpu_roles: false`. Das Placement verplant dort nur die GPU und nie Infra- oder CPU-Fallback-Rollen.
|
- Folgen: vLLM und PyTorch laufen unter Windows nicht nativ. `tts-de-hq` (Qwen3-TTS), Multi-LoRA und QLoRA auf `gpu-flex` warten auf eine spätere WSL2-Entscheidung (Phase 9). Bis dahin trägt `tts-de` (Piper) die Sprachausgabe allein.
|
||||||
|
|
||||||
|
## Nachtrag 2026-10-12: Benchmark (Task 3.3, ohne Strommessung)
|
||||||
|
Gemessen in `gpu-pve48` (pve48, VM 248: 4 vCPU i7-6700, 12 GB RAM, Treiber 580.178.04), beide Karten nacheinander, nie gleichzeitig.
|
||||||
|
- **Builds:** llama.cpp `b6000` (Commit `4762ad7`, Compose-Pin aus `deploy/compose/gpu-llamacpp`), `.devops/cuda.Dockerfile` Stufe `build`, `CUDA_VERSION=12.4.0`, `CUDA_DOCKER_ARCH=52;61`. whisper.cpp `v1.9.5` (Commit `d1be6fd`) auf `nvidia/cuda:12.4.0-devel-ubuntu22.04`, `CMAKE_CUDA_ARCHITECTURES=52;61` (braucht `-Wl,--allow-shlib-undefined`, sonst fehlt `libcuda` beim Linken im Container).
|
||||||
|
- **Modelle** (liegen in `/srv/models` von `gpu-pve48`, dort auch `SOURCES.txt`):
|
||||||
|
|
||||||
|
| Datei | Quelle (Hugging Face, Revision) | SHA-256 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Qwen3-Embedding-0.6B-Q8_0.gguf` | `Qwen/Qwen3-Embedding-0.6B-GGUF@370f27d` | `06507c7b42688469c4e7298b0a1e16deff06caf291cf0a5b278c308249c3e439` |
|
||||||
|
| `Qwen3-4B-Q4_K_M.gguf` | `Qwen/Qwen3-4B-GGUF@bc64014` | `7485fe6f11af29433bc51cab58009521f205840f5b4ae3a32fa7f92e8534fdf5` |
|
||||||
|
| `Qwen3-8B-Q4_K_M.gguf` | `Qwen/Qwen3-8B-GGUF@7c41481` | `d98cdcbd03e17ce47681435b5150e34c1417f50b5c0019dd560e4882c5745785` |
|
||||||
|
| `ggml-large-v3-turbo.bin` (nach der Messung gelöscht, nicht im Katalog) | `ggerganov/whisper.cpp@5359861` | `1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69` |
|
||||||
|
|
||||||
|
**llama.cpp** (`llama-bench`, `-ngl 99`, Werte t/s ohne/mit Flash-Attention; VRAM = belegter Speicher mit `llama-server` beim Katalog-Kontext). Als „7B Q4“ wurde das Katalogmodell `qwen3-8b-q4km` (8B) gemessen.
|
||||||
|
|
||||||
|
| Karte | Modell | pp512 | tg128 | VRAM (Kontext) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| M4000 (CC 5.2) | Qwen3-Embedding 0.6B Q8 | 929 | – | 2,8 GiB (8192, `-ub 8192`) |
|
||||||
|
| P2000 (CC 6.1) | Qwen3-Embedding 0.6B Q8 | 1009 | – | 2,8 GiB (8192, `-ub 8192`) |
|
||||||
|
| M4000 | Qwen3-4B Q4_K_M | 197 / 204 | 15,1 / 15,3 | 4,1 GiB (8192) |
|
||||||
|
| P2000 | Qwen3-4B Q4_K_M | 271 / 275 | 14,5 / 14,8 | 4,1 GiB (8192) |
|
||||||
|
| M4000 | Qwen3-8B Q4_K_M | 115 / 118 | 10,0 / 10,1 | 7,7 GiB (16384) |
|
||||||
|
| P2000 | Qwen3-8B Q4_K_M | 152 / 135 | 9,0 / 8,2 | **passt nicht** bei 16384 (KV-Cache scheitert), nur mit 512 Kontext (4,9 GiB) |
|
||||||
|
|
||||||
|
**whisper.cpp** `large-v3-turbo`, `-l de`, Standard-Dekodierung (Beam 5), Testaudio selbst mit Piper erzeugt (Hausverwaltungs-Brief, keine Kundendaten, danach gelöscht). Rechenzeit ohne Modell-Laden (Laden: P2000 1,1 s, M4000 0,7 s; ein Server lädt nur einmal), Median aus 3 Läufen nach einem Aufwärmlauf:
|
||||||
|
|
||||||
|
| Karte | 10-s-Clip | 33-s-Clip, umgerechnet je 10 s | VRAM | Flash-Attention aus |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P2000 | 3,5 s | 2,5 s | 2,0–2,2 GiB | 3,9 s bzw. 2,9 s |
|
||||||
|
| M4000 | 2,7 s | 2,1 s | 2,0–2,2 GiB | 3,2 s bzw. 2,3 s |
|
||||||
|
|
||||||
|
Erkennung fehlerfrei bis auf „Milltonnen“ (statt „Mülltonnen“); synthetische Sprache ist leichter als echtes Diktat. Zum Vergleich der Dienst `nex-stt` auf der M4000 in `gpu-pve52` (v1.9.4, Task 6.2): 10 s → 2,94 s.
|
||||||
|
|
||||||
|
**Piper** (`knaster bench tts` gegen das Image aus `deploy/docker/tts-piper.Dockerfile`, `de_DE-thorsten-medium`, 4 vCPU): RTF 0,17 bei 241 Zeichen (≈ 17 s Audio, 2,9 s Rechenzeit), RTF 0,11 bei 596 Zeichen (≈ 33 s Audio, 3,6 s).
|
||||||
|
|
||||||
|
**Leistungsaufnahme laut GPU-Sensor** (`nvidia-smi`, nur die Karte, keine Steckdosenmessung): M4000 Leerlauf 13,5 W (P8), unter llama.cpp 94–102 W, unter whisper 92–99 W. P2000 Leerlauf 6,5 W, unter Last 46–52 W (Grenze 75 W). Ohne Persistence-Modus zeigt ein einzelner `nvidia-smi`-Aufruf kurz P0 mit 46 W bzw. 20 W an; erst nach einigen Sekunden fällt die Karte auf P8.
|
||||||
|
|
||||||
|
Weitere Befunde:
|
||||||
|
- **CC 5.2 rechnet:** llama.cpp b6000 und whisper.cpp 1.9.5 laufen mit CUDA 12.4 auf der M4000, Flash-Attention eingeschlossen.
|
||||||
|
- Die Token-Erzeugung (tg) ist auf beiden Karten etwa gleich schnell und liegt deutlich unter der Speicherbandbreite. Die Karte ist dabei voll ausgelastet (P2000 99 %), es liegt also nicht an der CPU der VM. b6000 ist über ein Jahr alt (aktuell `b11574`); vor einer Anhebung des Compose-Pins nachmessen.
|
||||||
|
- Die P2000 hängt in pve48 nur mit PCIe 3.0 x4 an (M4000 x16). Das bremst nur das Laden.
|
||||||
|
- **Kartenwahl in Compose:** `deploy/compose/gpu-llamacpp` reserviert `count: 1`. Docker gibt dann die erste Karte heraus, und das ist die M4000 (geprüft: `docker run --gpus 1 … nvidia-smi -L` → Quadro M4000). Für die P2000 in Task 6.1 `device_ids` (Index 1 oder UUID) statt `count` setzen.
|
||||||
|
|
||||||
|
### Empfehlung: M4000 wieder aufnehmen – **ja, aber nur gezielt**
|
||||||
|
- **pve52 (nur eine M4000): ja**, für STT (whisper.cpp, 2,1 s je 10 s, schneller als die P2000). Ohne sie hat der Knoten keine GPU-Rolle, und die M4000 läuft dort für `nex-stt` ohnehin.
|
||||||
|
- **pve44, pve46, pve48: vorerst nein** für die heutigen Rollen. Embedding und Verifier laufen auf der P2000 gleich schnell oder schneller (Prefill +37 %) bei etwa halber Leistungsaufnahme. Bedingung: Embedding und Rerank passen mit `-ub 8192` gemeinsam auf die P2000 (Embedding allein 2,8 GiB, Rerank ungemessen). Passt das nicht, kommt Rerank auf die M4000 in derselben VM (dann „ja“ für pve44/46), oder `-ub` wird gesenkt. Die M4000 lohnt sich dort nur für Rollen, die mehr als 5 GB brauchen: `qwen3-8b-q4km` mit 16k Kontext (7,7 GiB), eine zweite STT-Instanz oder ein zweiter Verifier zur Lastverteilung.
|
||||||
|
- Nur für llama.cpp und whisper.cpp; PyTorch, vLLM und TTS bleiben ausgeschlossen (Entscheidung 1 und 3 unverändert).
|
||||||
|
- Eine gesperrte, aber eingebaute M4000 verbraucht im Leerlauf weiter etwa 13 W, solange der Treiber in der VM geladen ist. Bleibt sie dauerhaft ungenutzt, ausbauen.
|
||||||
|
- Vorbehalt: Die Steckdosenmessung je Knoten (Leerlauf und Volllast) steht aus. Das Flag `disabled` im Inventar ist nicht geändert; die Freigabe für pve52 setzt der Nutzer nach dieser Messung.
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
# Auftrag: Sprachmodelle (Spracherkennung + Sprachausgabe) für NEX auf dem knaster-Cluster
|
||||||
|
|
||||||
|
Eingang 12.10.2026 (vom NEX-Team, ohne Schlüssel). Ausführung **nach** Task 3.2 (GPU-VMs mit Treiber), gern durch einen Subagenten. Im Plan als Task 6.5 geführt.
|
||||||
|
|
||||||
|
## Abgleich mit dem echten Cluster (Stand 12.10.2026, vor Ausführung lesen)
|
||||||
|
|
||||||
|
Der Auftrag geht von einem älteren Bild aus. Es gilt:
|
||||||
|
|
||||||
|
| Auftrag sagt | Wirklichkeit |
|
||||||
|
|---|---|
|
||||||
|
| 2 Knoten + QDevice | 5 Knoten (pve44, pve46, pve48, pve52, pve61), 5 Stimmen, kein QDevice |
|
||||||
|
| Knoten A 2× P2000, Knoten B 2× M4000, GPU per LXC oder VM | Eine Ubuntu-24.04-VM pro GPU-Knoten mit allen Karten (D2): `gpu-pve44`, `gpu-pve46`, `gpu-pve48` je M4000 + P2000, `gpu-pve52` nur M4000. Treiber R580 in der VM |
|
||||||
|
| Feste IP per DHCP-Reservierung | D5 ist zurückgestellt. Gäste laufen per DHCP, der Nutzer hat keinen Zugriff auf den DHCP-Server. **Vor der Abnahme klären:** Reservierung durch die DHCP-Verwaltung oder statische IP außerhalb des DHCP-Bereichs |
|
||||||
|
|
||||||
|
Daraus folgt für die Umsetzung:
|
||||||
|
- **Erkennung:** whisper.cpp (CUDA 12, `CMAKE_CUDA_ARCHITECTURES="52;61"`) auf einer **M4000**. Naheliegend ist `gpu-pve52`: Dort steckt nur die M4000, und Placement verteilt keine andere Rolle dorthin. Die P2000 bleiben für embed/rerank (pve44, pve46) und `gpu-small` (pve48). Die M4000 sind im Inventar `disabled` bis zum Benchmark-Tag (Task 3.3); die Messwerte aus 4. Abnahme liefern genau diesen Benchmark für whisper und gehören in Task 3.3.
|
||||||
|
- **Ausgabe Stufe 1 (Piper, CPU):** laut Registry `tts-de` im Infra-LXC (`infra`, Task 5.2). Solange `infra` nicht steht, als Container in derselben GPU-VM wie die Erkennung (CPU reicht).
|
||||||
|
- **Firewall „nur von 192.168.100.29“:** Proxmox-Firewall an der Netzkarte der VM (`firewall=1`, Regeln für 8178/8179) plus `ufw` in der VM.
|
||||||
|
- Globale Vorgabe aus dem Plan passt: whisper-server auf Port 8178, nur von `.29`, `stt.openai.base_url` nur als IP-Literal.
|
||||||
|
|
||||||
|
## Auftrag (Originaltext)
|
||||||
|
|
||||||
|
NEX sind Hermes-Agenten auf appsrv08 (192.168.100.29). Sprache läuft dort: Hermes → nex-router → LiteLLM → Sprach-Backend. Das Backend ist austauschbar (Eintrag in LiteLLM). Der Dienst auf dem Cluster ist das **Rückfall-Modell**. Solange kein Cloud-Anbieter Guthaben hat, ist er der einzige aktive Weg. Die NEX-Seite trägt danach nur IP:Port und Modellnamen in LiteLLM ein. Gebraucht werden zwei **OpenAI-kompatible** HTTP-Dienste im internen Netz. Ein Dienst für beides ist auch in Ordnung.
|
||||||
|
|
||||||
|
Formate laut Hermes-Code: mp3 als Standard, Opus für Sprachnachrichten, rohes PCM mit Sample-Rate-Header für das Streaming.
|
||||||
|
|
||||||
|
### Hardware und Grenzen
|
||||||
|
- Treiber **R580**, die letzte Serie für Maxwell/Pascal.
|
||||||
|
- **faster-whisper/CTranslate2 laufen nicht auf CC 5.2.** Auf der M4000 nur whisper.cpp, und zwar als **CUDA-12-Build**, nicht CUDA 13 (`-DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES="52;61"`).
|
||||||
|
- PyTorch-Unterstützung für sm_50/sm_61 in aktuellen Wheels **vorher prüfen** (`python -c "import torch; print(torch.cuda.get_arch_list())"`), nicht annehmen. Keine Tensor-Cores: bei faster-whisper `int8` oder `int8_float32`.
|
||||||
|
|
||||||
|
### 1. Spracherkennung (Pflicht)
|
||||||
|
- Modell **Whisper large-v3-turbo**, Sprache fest `de`.
|
||||||
|
- Empfehlung M4000: whisper.cpp `whisper-server` mit `-m ggml-large-v3-turbo.bin -l de -bs 5 --convert --inference-path /v1/audio/transcriptions`, **ohne** `--print-realtime`, `--tmp-dir` auf tmpfs (z. B. `/run/nex-whisper`). Nach dem Start einmal mit 1 s Stille aufwärmen.
|
||||||
|
- Alternative P2000: speaches (faster-whisper) mit `deepdml/faster-whisper-large-v3-turbo-ct2`, `int8_float32`.
|
||||||
|
- Optional mitmessen: `primeline/whisper-large-v3-turbo-german`.
|
||||||
|
- Schnittstelle: `POST /v1/audio/transcriptions`, multipart
|
||||||
|
- Felder: `file`, `model`, `language=de`, `response_format`.
|
||||||
|
- `file` muss wav, mp3, ogg/opus, webm und m4a annehmen (Umwandlung mit ffmpeg).
|
||||||
|
- `response_format`: `json` → `{"text": "…"}`, `text`, `verbose_json` (wenn möglich mit Wort-Wahrscheinlichkeiten).
|
||||||
|
- Unbekannte Felder ignorieren, nicht mit Fehler antworten.
|
||||||
|
|
||||||
|
### 2. Sprachausgabe (Pflicht Stufe 1, optional Stufe 2)
|
||||||
|
- **Stufe 1:** Piper Deutsch, CPU reicht. Stimmen: `de_DE-thorsten-high` und eine weibliche deutsche Stimme. Genauen Namen nennen. Server OpenAI-kompatibel, z. B. speaches (kann Piper) oder ein schlanker Wrapper.
|
||||||
|
- Schnittstelle: `POST /v1/audio/speech`
|
||||||
|
- JSON-Body `{model, voice, input, response_format, speed}`.
|
||||||
|
- `response_format` muss können: `mp3` (Standard), `opus` (im Ogg-Container), `wav`, `flac`, `pcm` (rohes int16, little-endian, mono; Sample-Rate im Header `X-Audio-Sample-Rate` oder in `Content-Type: audio/pcm; rate=<Hz>`).
|
||||||
|
- Antwort möglichst gestreamt (chunked), damit die Wiedergabe früh beginnt.
|
||||||
|
- **Stufe 2 (optional, GPU, nach PyTorch-Prüfung):** eine natürlichere deutsche Stimme.
|
||||||
|
- **Lizenz für kommerzielle Nutzung prüfen.** Nach Stand des NEX-Teams sind XTTS-v2 (Coqui Public Model License) und die Gewichte von Fish Speech/OpenAudio **nicht kommerziell**.
|
||||||
|
- Chatterbox Multilingual (MIT) ist ein Kandidat.
|
||||||
|
- Kokoro hat nach diesem Stand keine deutsche Stimme. Prüfen, nicht voraussetzen.
|
||||||
|
|
||||||
|
### 3. Für beide Dienste
|
||||||
|
- `GET /health` → 200.
|
||||||
|
- Feste IP in `192.168.100.0/24` (siehe Abgleich oben). Ports zum Beispiel 8178 (Erkennung) und 8179 (Ausgabe).
|
||||||
|
- Firewall: Zugriff **nur von 192.168.100.29**. LiteLLM läuft dort in einem Bridge-Netz und erscheint mit der Host-IP.
|
||||||
|
- Kein API-Key nötig. Erzwingt der Server einen, einen festen Dummy-Wert nennen (kein Geheimnis).
|
||||||
|
- Autostart nach Neustart (systemd oder Docker `restart: unless-stopped`), Energiesparen aus, Sicherheitsupdates.
|
||||||
|
- **Datenschutz:** keine Ablage von Audio oder Text; Temp-Dateien nur im RAM, alte regelmäßig löschen; **keine Transkripte oder vorgelesenen Texte in Logs.**
|
||||||
|
|
||||||
|
### 4. Abnahme
|
||||||
|
Von appsrv08 aus prüfen, Testaudio ohne Kundendaten, danach löschen.
|
||||||
|
- **Erkennung:**
|
||||||
|
- `curl -s -w ' %{time_total}s\n' -F file=@t10.wav -F model=whisper -F language=de -F response_format=json http://IP:8178/v1/audio/transcriptions`
|
||||||
|
- Text muss deutsch und plausibel sein.
|
||||||
|
- Median aus 3 Läufen: 10 s Audio **≤ 4 s** (Ziel ≤ 3 s), 60 s Audio **≤ 12 s**. Den ersten Aufruf getrennt notieren.
|
||||||
|
- **Ausgabe:** `curl -s -o t.mp3 -w ' TTFB %{time_starttransfer}s gesamt %{time_total}s\n' -H 'Content-Type: application/json' -d '{"model":"piper","voice":"de_DE-thorsten-high","input":"Guten Tag, hier ist Ihr NEX-Assistent. Die Baubesprechung in Haslach ist am vierten November.","response_format":"mp3"}' http://IP:8179/v1/audio/speech`
|
||||||
|
- Ergebnis abspielbar, Zeit bis zum ersten Byte möglichst ≤ 1 s.
|
||||||
|
- Dasselbe mit `pcm` (Sample-Rate-Header prüfen) und `opus`.
|
||||||
|
- **Last:** 2 Erkennungen gleichzeitig. Mit `nvidia-smi` prüfen, dass GPU-Last und VRAM steigen.
|
||||||
|
|
||||||
|
### 5. Rückmeldung (genau so)
|
||||||
|
Je Dienst: Basis-URL (IP:Port) · Knoten und GPU · Software mit Version oder Image-Digest · Modellname(n), wie im Feld `model` erwartet · Stimmen-IDs · unterstützte `response_format` · PCM-Sample-Rate · Messwerte (Median, erster Aufruf, VRAM, RAM) · Autostart nach Neustart getestet (ja/nein) · Auffälligkeiten.
|
||||||
|
|
||||||
|
### Gegenseite (NEX, zur Information)
|
||||||
|
Die Agenten fragen nur noch neutrale Namen an: `nex-stt` für die Erkennung, `nex-tts` für die Ausgabe. Welches Backend dahinter antwortet und in welcher Reihenfolge, legt allein LiteLLM fest: heute das Cluster, später ein Cloud-Anbieter davor und das Cluster als Rückfall. nex-router 0.2.3 wird um die Sprachausgabe (gestreamt) erweitert. Nach der Rückmeldung trägt das NEX-Team die Endpunkte in LiteLLM ein und testet die Kette. Live erst mit dem neuen Router-Image.
|
||||||
|
|
||||||
|
## Rückmeldung (Stand 12.10.2026, Task 6.2)
|
||||||
|
|
||||||
|
Umgesetzt in `gpu-pve52` (VM 252 auf pve52). Getestet in der VM (localhost) und von ops mit einer temporären ufw-Regel, die danach wieder entfernt wurde. Von appsrv08 noch **nicht** geprüft, das bleibt für das NEX-Team. Testaudio kam aus Piper (zwei Sätze zur Baubesprechung, keine Kundendaten) und wurde danach gelöscht.
|
||||||
|
|
||||||
|
**Feste IP fehlt (D5):**
|
||||||
|
- Die VM hat ihre Adresse per DHCP: heute `192.168.100.174`, MAC `bc:24:11:92:4b:eb`.
|
||||||
|
- Die Adresse hat den Neustart überstanden, ist aber nicht garantiert.
|
||||||
|
- Vor dem Eintrag in LiteLLM: Reservierung auf diese MAC einrichten oder eine statische Adresse vergeben.
|
||||||
|
|
||||||
|
**Energiesparen und Updates:**
|
||||||
|
- Die VM hat kein Suspend und kein Energiesparen (Ubuntu-Server-Cloud-Image).
|
||||||
|
- Die GPU läuft im Leerlauf in P5 mit 17 W. Der Kontext bleibt geladen, weil whisper-server läuft.
|
||||||
|
- Die Energieeinstellungen der Proxmox-Knoten habe ich laut Vorgabe nicht angefasst.
|
||||||
|
- Sicherheitsupdates laufen per `unattended-upgrades`.
|
||||||
|
|
||||||
|
**API-Key:** Keiner nötig, kein Dummy-Wert. Beide Dienste ignorieren das Feld `model`, jeder Wert geht.
|
||||||
|
|
||||||
|
### Erkennung (`nex-stt`)
|
||||||
|
|
||||||
|
**Basis-URL:** `http://192.168.100.174:8178`
|
||||||
|
- Pfad: `POST /v1/audio/transcriptions`
|
||||||
|
- Zustand: `GET /health` → `{"status":"ok"}`, solange das Modell lädt: 503
|
||||||
|
|
||||||
|
**Knoten und GPU:** pve52, VM `gpu-pve52`, Quadro M4000 (8 GB, CC 5.2), Treiber 580.178.04
|
||||||
|
|
||||||
|
**Software:**
|
||||||
|
- whisper.cpp `whisper-server` **v1.9.4** (Commit `927cfce34f31707e17f2bff35c349632fb9e2c3a`)
|
||||||
|
- Build: CUDA 12.6.3, `-DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES="52;61"`, statisch gelinkt
|
||||||
|
- Image `knaster/nex-whisper:v1.9.4-cu12.6`, ID `sha256:be4987ff63df…`
|
||||||
|
- Basis-Images per Digest gepinnt: `nvidia/cuda:12.6.3-{devel,runtime}-ubuntu24.04`
|
||||||
|
- ffmpeg 6.1.1 für `--convert`
|
||||||
|
|
||||||
|
**Modell:** `ggml-large-v3-turbo.bin` (f16)
|
||||||
|
- Quelle: HF `ggerganov/whisper.cpp`, Revision `5359861c…`
|
||||||
|
- sha256 `1fc70f77…e2bc69`, gegen Hugging Face geprüft
|
||||||
|
- Feld `model`: beliebig, z. B. `whisper` oder `nex-stt`
|
||||||
|
|
||||||
|
**Parameter:**
|
||||||
|
- `-l de -bs 5 --convert --inference-path /v1/audio/transcriptions --tmp-dir /run/nex-whisper`
|
||||||
|
- `--tmp-dir` liegt auf tmpfs (512 MB)
|
||||||
|
- ohne `--print-realtime`, Flash-Attention an
|
||||||
|
- Nach jedem Start wird mit 1 s Stille aufgewärmt.
|
||||||
|
|
||||||
|
**Eingangsformate:** wav, mp3, ogg/opus, webm/opus und m4a/aac, alle über ffmpeg getestet.
|
||||||
|
|
||||||
|
**`response_format`:**
|
||||||
|
- `json` → `{"text": "…"}`
|
||||||
|
- `text`
|
||||||
|
- `verbose_json`: Segmente mit `words[].probability`, `avg_logprob`, `no_speech_prob`, dazu `language_probabilities`
|
||||||
|
- außerdem `srt` und `vtt`
|
||||||
|
|
||||||
|
**Unbekannte Felder werden ignoriert.** Geprüft mit `model=gpt-4o-transcribe`, `timestamp_granularities[]=word`, `foo=bar` und `stream=false`, Antwort jeweils 200.
|
||||||
|
|
||||||
|
**PCM-Sample-Rate:** entfällt; ffmpeg wandelt intern auf 16 kHz mono.
|
||||||
|
|
||||||
|
**Messwerte (M4000, `-bs 5`):**
|
||||||
|
|
||||||
|
| Messung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| 10 s Audio, erster Aufruf nach Start und Aufwärmen | 3,00 s (nach `docker restart`), 2,94 s (erster Aufruf der Abnahmeserie) |
|
||||||
|
| 10 s Audio, Median aus 3 Läufen | **2,94 s** (Grenze 4 s, Ziel ≤ 3 s erreicht) |
|
||||||
|
| 60 s Audio, erster Aufruf | 8,63 s |
|
||||||
|
| 60 s Audio, Median aus 3 Läufen | **8,63 s** (Grenze 12 s) |
|
||||||
|
| 5 s Audio, von ops über das Netz | 2,43 s |
|
||||||
|
| Start bis bereit (Modell im Cache, inkl. Aufwärmen) | 4,1 s |
|
||||||
|
| VRAM | 1965 MiB nach dem Laden und bei 10-s-Aufrufen, bis 2179 MiB nach 60-s-Audio, im Paralleltest kein weiterer Anstieg |
|
||||||
|
| RAM des Containers | 286 MiB |
|
||||||
|
| Leerlauf der Karte | P5, 17 W |
|
||||||
|
|
||||||
|
**Last mit 2 Erkennungen gleichzeitig (je 60 s):**
|
||||||
|
- Antworten nach 8,8 s und 17,5 s. whisper-server arbeitet sie nacheinander ab (globaler Mutex), die zweite wartet.
|
||||||
|
- GPU-Last stieg auf 100 %, Mittel 78 %. Der Leerlauf (0 %) wurde separat gemessen; der erste Wert im Log (85 %) war noch Nachlauf.
|
||||||
|
- **Der VRAM steigt nicht**, er bleibt bei 2179 MiB, weil der Kontext einmal geladen wird.
|
||||||
|
|
||||||
|
**Autostart nach Neustart getestet: ja.** Die VM wurde über Proxmox neu gestartet (`qm reboot 252`). 34 s nach dem Boot lieferten beide `/health` 200, die erste Erkennung danach war normal.
|
||||||
|
|
||||||
|
**Auffälligkeiten:**
|
||||||
|
1. **Gleichzeitige Anfragen laufen nacheinander** (siehe Last).
|
||||||
|
2. **Mindestdauer etwa 2,3 s pro Aufruf**, auch bei kurzen Clips. Der Encoder rechnet immer ein 30-s-Fenster.
|
||||||
|
3. **Format der `json`-Antwort:** Der Text beginnt mit einem Leerzeichen und hat Zeilenumbrüche zwischen den Segmenten. Für Hermes ggf. `strip()` bzw. Umbrüche durch Leerzeichen ersetzen.
|
||||||
|
4. **Content-Type bei `text`:** `text/html; charset=utf-8`.
|
||||||
|
5. **Qualität am synthetischen Testaudio:**
|
||||||
|
- Sauber: „Baubesprechung in Haslach ist am 4. November um 10 Uhr“.
|
||||||
|
- „NEX“ wurde meist als „NX“ erkannt.
|
||||||
|
- Im Teil mit der leisen Kerstin-Stimme gab es einzelne Fehler, z. B. „Baugüro“ und „Baulätung“.
|
||||||
|
- Echte Aufnahmen und die Dialekt-Testsätze stehen in Task 3.3 an.
|
||||||
|
6. **Log:** Es enthält nur den Upload-Dateinamen und die Audiolänge (z. B. `Received request: t10.wav`), **keinen Text**. Gegen den Testtext gegrepped: 0 Treffer.
|
||||||
|
- ffmpeg läuft mit `-loglevel error`.
|
||||||
|
- Log-Rotation: Treiber `local`, 2 × 5 MB.
|
||||||
|
7. **Temp-Dateien:**
|
||||||
|
- Nur im tmpfs, nach jeder Anfrage gelöscht; nach den Tests 0 Dateien.
|
||||||
|
- Bei ffmpeg-Fehlern bleibt eine Upload-Datei liegen. Ein Aufräumer löscht Dateien älter als 10 min.
|
||||||
|
8. **Endpunkt `POST /load`:** Er existiert (Modellwechsel). Erreichbar ist er nur von `.29`, und im Container liegt nur dieses eine Modell.
|
||||||
|
9. **Proxmox-Firewall:** Abweichend vom Abgleich oben ist sie **nicht** aktiviert (Vorgabe). Es gilt nur **ufw in der VM**:
|
||||||
|
- 8178/8179 nur von `192.168.100.29`
|
||||||
|
- SSH aus `192.168.100.0/24`
|
||||||
|
- sonst `deny incoming`
|
||||||
|
- Beide Dienste laufen im Host-Netz, weil veröffentlichte Docker-Ports ufw umgehen würden.
|
||||||
|
- Negativtest von ops (.173): ohne Regel Zeitüberschreitung auf beiden Ports.
|
||||||
|
|
||||||
|
### Ausgabe (`nex-tts`)
|
||||||
|
|
||||||
|
**Basis-URL:** `http://192.168.100.174:8179`
|
||||||
|
- Pfad: `POST /v1/audio/speech`
|
||||||
|
- Zustand: `GET /health` → `{"status":"ok","voices":[…],"default":"de_DE-thorsten-high"}`
|
||||||
|
- außerdem `GET /v1/models` und `GET /v1/audio/voices`
|
||||||
|
|
||||||
|
**Knoten und Hardware:** pve52, VM `gpu-pve52`, nur CPU. Die VM hat 4 Kerne (i7-7700); der Container ist auf 3 Kerne und 2 GB begrenzt.
|
||||||
|
|
||||||
|
**Software:**
|
||||||
|
- eigener schlanker Wrapper: FastAPI 0.141.1, uvicorn 0.53.0, piper-tts **1.8.0** (GPL-3.0, eigener Dienst), onnxruntime 1.30.0, ffmpeg 5.1.9
|
||||||
|
- Image `knaster/nex-tts-piper:1.8.0`, ID `sha256:d9e65ee77836…`
|
||||||
|
- Basis `python:3.12-slim-bookworm`, per Digest gepinnt
|
||||||
|
|
||||||
|
**Warum nicht speaches:** Der Wrapper ist klein und gut zu prüfen. Er streamt satzweise, setzt die PCM-Header nach Vorgabe und schreibt weder Text noch Audio auf die Platte. Der bisherige knaster-`tts_server` konnte nur wav.
|
||||||
|
|
||||||
|
**Modellname (`model`):** beliebig, z. B. `piper` oder `nex-tts`.
|
||||||
|
|
||||||
|
**Stimmen-IDs:**
|
||||||
|
- `de_DE-thorsten-high`: männlich, 22050 Hz, CC0, Standard
|
||||||
|
- `de_DE-kerstin-low`: weiblich, 16000 Hz, Datensatz CC0
|
||||||
|
- Dateien aus HF `rhasspy/piper-voices`, Revision `c10ece1a…`, sha256 geprüft.
|
||||||
|
- Kurzformen `thorsten` und `kerstin` gehen ebenfalls.
|
||||||
|
- Unbekannte Stimmen (z. B. `alloy`) fallen ohne Fehler auf die Standardstimme. Der Header `X-Voice` nennt die verwendete Stimme.
|
||||||
|
|
||||||
|
**Weitere weibliche Stimmen**, nicht genommen:
|
||||||
|
- `de_DE-ramona-low` und `de_DE-eva_k-x_low`: M-AILABS, Lizenz „See URL“, kommerzielle Nutzung nicht belegt
|
||||||
|
- `de_DE-mls-medium`: mehrere Sprecher
|
||||||
|
|
||||||
|
**`response_format`:**
|
||||||
|
- Standard ist `mp3` (`audio/mpeg`).
|
||||||
|
- `opus` (Ogg, 48 kHz, `audio/ogg`), `aac`, `flac` und `pcm` werden **gestreamt** (chunked). Die Header kommen erst mit dem ersten Audioblock, deshalb misst TTFB echtes Audio.
|
||||||
|
- `wav` kommt vollständig mit korrektem Header; das passt zu `knaster bench tts` und zum Canary.
|
||||||
|
- `speed` (0,25–4) wird auf `length_scale` abgebildet.
|
||||||
|
- Unbekannte Felder werden ignoriert.
|
||||||
|
- `input` ist auf 5000 Zeichen begrenzt.
|
||||||
|
|
||||||
|
**PCM-Sample-Rate:** **24000 Hz** für alle Stimmen, wie OpenAI.
|
||||||
|
- Format: int16 LE, mono, umgerechnet per ffmpeg.
|
||||||
|
- Header: `X-Audio-Sample-Rate: 24000` und `Content-Type: audio/pcm; rate=24000`.
|
||||||
|
- Mit `PCM_SAMPLE_RATE=0` käme die native Rate der Stimme.
|
||||||
|
|
||||||
|
**Messwerte (Auftragssatz, thorsten-high, je 3 Läufe):**
|
||||||
|
|
||||||
|
| Format | TTFB | gesamt | Audio |
|
||||||
|
|---|---|---|---|
|
||||||
|
| mp3 | 0,67–0,91 s | 1,42–1,68 s | 5,25 s |
|
||||||
|
| pcm | 0,72–0,87 s | 1,48–1,62 s | 5,31 s |
|
||||||
|
| opus | 0,64–0,72 s | 1,54–1,61 s | 5,28 s |
|
||||||
|
|
||||||
|
- Von ops über das Netz: mp3 TTFB 0,72 s.
|
||||||
|
- Erste Synthese nach einem Neustart (wav, 10 s Audio): 2,7 s gesamt. Die Stimmen werden beim Start aufgewärmt.
|
||||||
|
- RAM: 616 MiB. Kein VRAM.
|
||||||
|
|
||||||
|
**Autostart nach Neustart getestet: ja**, wie bei der Erkennung.
|
||||||
|
|
||||||
|
**Auffälligkeiten:**
|
||||||
|
1. **Log:** kein Access-Log, Level `warning`.
|
||||||
|
- Gegen den Testtext gegrepped: 0 Treffer.
|
||||||
|
- Piper meldet gelegentlich `Missing phoneme from id map` (ohne Text).
|
||||||
|
- onnxruntime meldet beim Start eine harmlose Telemetrie-Warnung.
|
||||||
|
2. **Gleichzeitige Synthesen:** höchstens 2 (`TTS_PARALLEL`). Die CPU teilt sich der Dienst mit whisper-server, das die CPU aber kaum nutzt.
|
||||||
|
3. **Ablageort:** Laut Registry (`tts-de`) gehört Piper in den Infra-LXC. Vorerst läuft es hier.
|
||||||
|
|
||||||
|
### Stufe 2 (nur geprüft, nichts installiert)
|
||||||
|
|
||||||
|
**PyTorch auf der M4000:**
|
||||||
|
- In einem Wegwerf-Container geprüft: `torch 2.9.1+cu126`, `get_arch_list()` = `sm_50, sm_60, sm_70, sm_75, sm_80, sm_86, sm_90`.
|
||||||
|
- Die M4000 (5, 2) rechnet eine Matrixmultiplikation korrekt.
|
||||||
|
- **2.9.1 ist die letzte Version mit cu126-Wheels.** Laut den Build-Skripten haben cu128/cu129/cu130 kein sm_5x/sm_6x mehr, also ist das die Obergrenze für Maxwell/Pascal.
|
||||||
|
|
||||||
|
**Chatterbox Multilingual (ResembleAI):**
|
||||||
|
- Lizenz **MIT** (HF-Karte), Deutsch enthalten.
|
||||||
|
- Läuft grundsätzlich mit PyTorch ≤ 2.9.1 auf sm_52, aber ohne Tensor-Cores in fp32.
|
||||||
|
- Langsam erwartet. Nicht gemessen.
|
||||||
|
|
||||||
|
**Kokoro-82M:** `VOICES.md` listet **keine deutsche Stimme**, nur en, ja, zh, es, fr, hi, it und pt-br.
|
||||||
|
|
||||||
|
**XTTS-v2 und Fish Speech:** nicht geprüft, laut NEX-Team nicht kommerziell.
|
||||||
|
|
||||||
|
**Empfehlung:** Stufe 2 mit `tts-de-hq` (Qwen3-TTS) auf der Ada (Task 6.4) statt auf der M4000.
|
||||||
|
|
||||||
|
### Notiz für Task 3.3 (Benchmark M4000, whisper)
|
||||||
|
|
||||||
|
**Ergebnis:**
|
||||||
|
- CC 5.2 **rechnet** mit dem CUDA-12.6-Build. `system_info` zeigt `CUDA : ARCHS = 520,610`.
|
||||||
|
- `large-v3-turbo` f16 mit `-bs 5`: 10 s Audio in 2,94 s (RTF 0,29), 60 s in 8,63 s (RTF 0,14).
|
||||||
|
- Mit `-nfa` (Flash-Attention aus) sind es 3,21 s für 10 s, also bleibt Flash-Attention an.
|
||||||
|
- Speicher: VRAM 2,2 GB, RAM 0,3 GB.
|
||||||
|
|
||||||
|
**Noch offen:**
|
||||||
|
- P2000 nicht gemessen
|
||||||
|
- Dialekt-Testsätze
|
||||||
|
- `primeline/whisper-large-v3-turbo-german`: bräuchte eine ggml-Umwandlung
|
||||||
|
- Strom unter Last
|
||||||
|
|
||||||
|
**Offener Punkt Registry:** Den Slot `stt-de` habe ich **nicht** angelegt.
|
||||||
|
- `SlotSpec.kind` (core `registry.py`) kennt kein `stt`, Placement (`placement.py`) und Canary ebenfalls nicht.
|
||||||
|
- Ohne Core-Änderung schlägt `knaster validate` fehl, und der Canary würde den Slot als Chat proben.
|
||||||
|
- Gehört zu 3.3 bzw. 5.2, zusammen mit dem Umzug von `tts-de`.
|
||||||
|
|
||||||
|
### Betrieb
|
||||||
|
|
||||||
|
**Dateien im Repo:**
|
||||||
|
- `deploy/compose/nex-speech/` (Compose, `.env.example`, Dockerfiles, `piper/app.py`)
|
||||||
|
- Rolle `platform/proxmox/ansible/roles/nex-speech`
|
||||||
|
- Playbook `platform/proxmox/ansible/playbooks/nex-speech.yml`, nur für `gpu-pve52`
|
||||||
|
|
||||||
|
**Ausrollen von ops:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /root/cluster-knaster && git pull
|
||||||
|
cd platform/proxmox/tofu && . /root/.config/knaster/tofu.env && bash ../scripts/refresh-vm-access.sh
|
||||||
|
cd ../ansible && ansible-playbook playbooks/nex-speech.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was die Rolle macht:**
|
||||||
|
- ufw-Regeln setzen und ufw einschalten
|
||||||
|
- `docker-buildx` installieren
|
||||||
|
- Modelle nach `/srv/models/{whisper,piper}` laden, mit sha256
|
||||||
|
- Compose nach `/opt/nex-speech` kopieren, `.env` aus den Rollen-Variablen schreiben
|
||||||
|
- bauen und starten, danach auf `/health` beider Dienste warten
|
||||||
|
- Ein zweiter Lauf ändert nichts (`changed=0`).
|
||||||
|
|
||||||
|
**Build:**
|
||||||
|
- Der erste whisper-Build dauert in der VM etwa 15 min.
|
||||||
|
- Das devel-Image (11,5 GB) bleibt als Cache liegen. Freier Platz auf `/` danach: 18 GB.
|
||||||
|
|
||||||
|
**Prüfen in der VM:**
|
||||||
|
- `curl -s localhost:8178/health; curl -s localhost:8179/health`
|
||||||
|
- `sudo docker compose -f /opt/nex-speech/docker-compose.yml ps`
|
||||||
|
- Logs mit `sudo docker logs nex-stt` bzw. `nex-tts`. Sie enthalten keine Texte.
|
||||||
|
|
||||||
|
**Firewall in der VM:**
|
||||||
|
- `sudo ufw status`
|
||||||
|
- Zum Testen von einem anderen Rechner eine Regel nur vorübergehend setzen: `sudo ufw insert 1 allow from <IP> to any port 8178:8179 proto tcp`, danach `sudo ufw delete allow from <IP> to any port 8178:8179 proto tcp`.
|
||||||
|
- Weitere Compose-Stacks in dieser VM mit veröffentlichten Ports (`ports:`) umgehen ufw. Für Sprachdienste deshalb im Host-Netz bleiben.
|
||||||
|
|
||||||
|
**Updates:**
|
||||||
|
- Ubuntu-Sicherheitsupdates laufen per `unattended-upgrades`. Der NVIDIA-Treiber ist gehalten.
|
||||||
|
- Images sind gepinnt. Ein Wechsel geht nur bewusst über die Rollenvariablen `nex_speech_env`:
|
||||||
|
- whisper: `WHISPER_TAG` und `WHISPER_COMMIT` gemeinsam
|
||||||
|
- Piper: `requirements.txt` und `PIPER_TTS_VERSION`
|
||||||
|
- Danach das Playbook erneut laufen lassen.
|
||||||
|
|
||||||
|
**Parameter ändern:** In `nex_speech_env`, z. B. `WHISPER_EXTRA_ARGS: "-nfa"` oder `PCM_SAMPLE_RATE: "0"`, dann das Playbook laufen lassen.
|
||||||
@@ -22,14 +22,16 @@ Dieser Plan **löst** die Abläufe in `docs/PLAN.md` §8 („Roadmap“) und `do
|
|||||||
|
|
||||||
| Knoten | IP | Hardware | Platten | GPUs | Besonderheit |
|
| Knoten | IP | Hardware | Platten | GPUs | Besonderheit |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| pve44 | 192.168.100.210 | ThinkStation P310, i7-6700, 32 GB | 2× 256 GB (sdb: altes Windows, NTFS) | M4000 + P2000 | – |
|
| pve44 | 192.168.100.210 | ThinkStation P310, i7-6700, 32 GB | 2× 256 GB (sdb: früher Windows, seit 12.10. `tank`) | M4000 + P2000 | – |
|
||||||
| pve46 | 192.168.100.209 | ThinkStation P310, i7-6700, 32 GB | 1× 256 GB | M4000 + P2000 | bootet Legacy statt UEFI |
|
| pve46 | 192.168.100.209 | ThinkStation P310, i7-6700, 32 GB | 1× 256 GB | M4000 + P2000 | bootet Legacy statt UEFI |
|
||||||
| pve48 | 192.168.100.212 | ThinkStation P310, i7-6700, 32 GB | 3 Platten (SanDisk): sda 256 GB Proxmox, sdb 256 GB **altes Windows, 220 GB belegt**, sdc 128 GB **Windows 10, 101 GB belegt** | M4000 + P2000 | bootet Legacy statt UEFI |
|
| pve48 | 192.168.100.212 | ThinkStation P310, i7-6700, 32 GB | 3 Platten (SanDisk): sda 256 GB Proxmox, sdb 256 GB (früher Windows, seit 12.10. `tank`), sdc 128 GB (früher Windows 10, seit 12.10. leer) | M4000 + P2000 | bootet Legacy statt UEFI |
|
||||||
| pve52 | 192.168.100.207 | ThinkStation P320, i7-7700, 32 GB | 2× 256 GB (sdb: altes Windows, **BitLocker „GODEL55“**) | nur M4000 (keine P2000 verbaut) | – |
|
| pve52 | 192.168.100.207 | ThinkStation P320, i7-7700, 32 GB | 2× 256 GB (sdb: früher Windows mit BitLocker „GODEL55“, seit 12.10. `tank`) | nur M4000 (keine P2000 verbaut) | – |
|
||||||
| pve61 | 192.168.100.214 | Surface Book, i5-8350U, 8 GB | 1× 256 GB NVMe | Intel UHD 620 | LAN nur über USB-NIC im Lenovo-Dock, 2 Akkus |
|
| pve61 | 192.168.100.214 | Surface Book, i5-8350U, 8 GB | 1× 256 GB NVMe | Intel UHD 620 | LAN nur über USB-NIC im Lenovo-Dock, 2 Akkus |
|
||||||
|
|
||||||
Alle Knoten: PVE 9.2.2, Kernel 7.0.2, **ext4 + LVM-thin auf einer SSD (kein ZFS)**, 1 GbE, kein Cluster.
|
Alle Knoten: PVE 9.2.2, Kernel 7.0.2, **ext4 + LVM-thin auf einer SSD (kein ZFS)**, 1 GbE, kein Cluster.
|
||||||
|
|
||||||
|
Stand 12.10. abends: PVE 9.2.21, Kernel 7.0.14 (pve61 noch 7.0.2 bis zum Neustart vor Ort), Cluster `knaster` mit 5 Stimmen, ZFS-Pool `tank` (238 GB) auf pve44, pve48 und pve52.
|
||||||
|
|
||||||
Aufteilung der Systemplatte (per SSH geprüft, auf allen fünf Knoten gleich, alles noch leer): 69 GB `local` (System, ISOs, Vorlagen, lokale Backups), 141 GB `local-lvm` (Platten der VMs und Container), 8 GB Swap, 16 GB frei in der Volume Group. SMART: alle Platten `PASSED`. Samsung-SSDs (pve44, pve52) zu 3–17 % verschlissen, Surface-NVMe (pve61) zu 46 %. Bei der SanDisk in pve46 steht der Rohwert für umgelagerte Sektoren auf 100; der Wert wird überwacht. VT-x und VT-d sind aktiv. IOMMU ist geprüft: Jede Karte liegt mit ihrer Audio-Funktion in einer eigenen Gruppe (M4000 in Gruppe 1, P2000 in Gruppe 8), Passthrough ist also möglich.
|
Aufteilung der Systemplatte (per SSH geprüft, auf allen fünf Knoten gleich, alles noch leer): 69 GB `local` (System, ISOs, Vorlagen, lokale Backups), 141 GB `local-lvm` (Platten der VMs und Container), 8 GB Swap, 16 GB frei in der Volume Group. SMART: alle Platten `PASSED`. Samsung-SSDs (pve44, pve52) zu 3–17 % verschlissen, Surface-NVMe (pve61) zu 46 %. Bei der SanDisk in pve46 steht der Rohwert für umgelagerte Sektoren auf 100; der Wert wird überwacht. VT-x und VT-d sind aktiv. IOMMU ist geprüft: Jede Karte liegt mit ihrer Audio-Funktion in einer eigenen Gruppe (M4000 in Gruppe 1, P2000 in Gruppe 8), Passthrough ist also möglich.
|
||||||
|
|
||||||
Außerhalb des Clusters:
|
Außerhalb des Clusters:
|
||||||
@@ -49,7 +51,7 @@ Außerhalb des Clusters:
|
|||||||
- **STT:** `whisper-server` auf Port 8178, nur von `192.168.100.29` erreichbar. `stt.openai.base_url` nur als IP-Literal (nex-STT-Plan, Annahmen).
|
- **STT:** `whisper-server` auf Port 8178, nur von `192.168.100.29` erreichbar. `stt.openai.base_url` nur als IP-Literal (nex-STT-Plan, Annahmen).
|
||||||
- **Ada-Rechner:** Windows bleibt, Mitarbeitende haben tagsüber Vorrang, kein Proxmox darauf.
|
- **Ada-Rechner:** Windows bleibt, Mitarbeitende haben tagsüber Vorrang, kein Proxmox darauf.
|
||||||
- **Datacenter-weite Einstellungen** (Benutzer, 2FA, Firewall, Benachrichtigungen, Pools, Metriken) erst, wenn alle Knoten im Cluster sind.
|
- **Datacenter-weite Einstellungen** (Benutzer, 2FA, Firewall, Benachrichtigungen, Pools, Metriken) erst, wenn alle Knoten im Cluster sind.
|
||||||
- **Alte Windows-SSDs** (pve44 sdb, pve52 sdb) nur nach ausdrücklicher Freigabe löschen (D1).
|
- **Platten:** Die alten Windows-SSDs sind seit 12.10. gelöscht (Freigabe D1). Systemplatten (`sda`, NVMe) nie anfassen; Platten nur über `/dev/disk/by-id/…` mit Seriennummer ansprechen (auf pve52 unterscheiden sich System- und `tank`-Platte nur in der letzten Ziffer).
|
||||||
- **Geheimnisse** nie ins Repo. Zugangsdaten liegen in `C:\Users\r.rapp\it\proxmox\.env`, der Schlüssel unter `…\proxmox\keys\`.
|
- **Geheimnisse** nie ins Repo. Zugangsdaten liegen in `C:\Users\r.rapp\it\proxmox\.env`, der Schlüssel unter `…\proxmox\keys\`.
|
||||||
- **Planner-Webhook `tdo-codex-update`:** pro Aufruf nur **eine** Aktualisierung, vorhandene Titel nie umbenennen (Abgleich läuft über den Titel, Schwelle 0.8).
|
- **Planner-Webhook `tdo-codex-update`:** pro Aufruf nur **eine** Aktualisierung, vorhandene Titel nie umbenennen (Abgleich läuft über den Titel, Schwelle 0.8).
|
||||||
|
|
||||||
@@ -71,8 +73,19 @@ Außerhalb des Clusters:
|
|||||||
| **D2** | GPU als VM oder LXC | **Pro GPU-Knoten eine Ubuntu-24.04-VM mit allen Karten des Knotens**, darin Docker Compose pro Rolle. | Der Host-Kernel (7.0) bleibt ohne NVIDIA, Proxmox-Updates gefährden den Treiber nicht. R580 kommt als Ubuntu-Paket und ist per Snapshot rückrollbar. IOMMU-Gruppen sind geprüft. Eine VM pro Knoten statt pro Rolle (ADR-0001) spart RAM. Das Argument „gemischte Generationen“ aus ADR-0001 gilt im Cluster nicht mehr, alle Karten sind Maxwell/Pascal. Kosten: 2–5 % CPU, fest reservierter VM-RAM (12–16 GB von 32 GB). | LXC mit Host-Treiber: braucht R580 auf Kernel 7.0 oder einen festgehaltenen älteren Kernel. |
|
| **D2** | GPU als VM oder LXC | **Pro GPU-Knoten eine Ubuntu-24.04-VM mit allen Karten des Knotens**, darin Docker Compose pro Rolle. | Der Host-Kernel (7.0) bleibt ohne NVIDIA, Proxmox-Updates gefährden den Treiber nicht. R580 kommt als Ubuntu-Paket und ist per Snapshot rückrollbar. IOMMU-Gruppen sind geprüft. Eine VM pro Knoten statt pro Rolle (ADR-0001) spart RAM. Das Argument „gemischte Generationen“ aus ADR-0001 gilt im Cluster nicht mehr, alle Karten sind Maxwell/Pascal. Kosten: 2–5 % CPU, fest reservierter VM-RAM (12–16 GB von 32 GB). | LXC mit Host-Treiber: braucht R580 auf Kernel 7.0 oder einen festgehaltenen älteren Kernel. |
|
||||||
| **D3** | Ada-Rechner | **Windows bleibt.** `LocalLLMWorker.ps1` mit llama-server (CUDA) meldet sich über die Worker-Registry v2. Die knaster-Slots `tier1-general`/`tier1-flex` lösen auf einen Worker auf, der online ist, sonst tier0. | Die Registry v2 ist fast fertig (44 von 55 Schritten). Mitarbeitende werden nicht gestört. vLLM läuft unter Windows nicht nativ, Multi-LoRA, QLoRA und Qwen3-TTS kommen deshalb erst mit einer späteren WSL2-Entscheidung (Phase 9). | Dual-Boot mit Proxmox: scheidet aus, die Rechner werden tagsüber genutzt. |
|
| **D3** | Ada-Rechner | **Windows bleibt.** `LocalLLMWorker.ps1` mit llama-server (CUDA) meldet sich über die Worker-Registry v2. Die knaster-Slots `tier1-general`/`tier1-flex` lösen auf einen Worker auf, der online ist, sonst tier0. | Die Registry v2 ist fast fertig (44 von 55 Schritten). Mitarbeitende werden nicht gestört. vLLM läuft unter Windows nicht nativ, Multi-LoRA, QLoRA und Qwen3-TTS kommen deshalb erst mit einer späteren WSL2-Entscheidung (Phase 9). | Dual-Boot mit Proxmox: scheidet aus, die Rechner werden tagsüber genutzt. |
|
||||||
| **D4** | LiteLLM | **Kein eigener LiteLLM im knaster-Infra.** Der Router nutzt den LiteLLM auf appsrv08 (mit PSE), die lokalen Slots werden dort als Modelle eingetragen. | Es läuft bereits ein dritter LiteLLM; noch einer würde PSE für tier0 umgehen. Ein Modellwechsel bleibt eine Datenänderung. | Eigener LiteLLM nur für lokale Slots, ohne OpenRouter-Zugang. |
|
| **D4** | LiteLLM | **Kein eigener LiteLLM im knaster-Infra.** Der Router nutzt den LiteLLM auf appsrv08 (mit PSE), die lokalen Slots werden dort als Modelle eingetragen. | Es läuft bereits ein dritter LiteLLM; noch einer würde PSE für tier0 umgehen. Ein Modellwechsel bleibt eine Datenänderung. | Eigener LiteLLM nur für lokale Slots, ohne OpenRouter-Zugang. |
|
||||||
|
| **D6** | Orchestrierung der Gäste-Dienste | **k3s** (Server auf Dienst-VM `k3s`, Agents = GPU-VMs, NVIDIA Device Plugin), Portainer auf appsrv08 als Oberfläche über den Portainer-Agent (NodePort 30778). Entschieden 15.10.2026. | Nutzer will die vier GPU-Hosts in Portainer als einen Cluster sehen. Portainer kennt dafür nur Swarm oder Kubernetes; Kubernetes plant GPUs sauber (Device-Plugin), Ökosystem (Helm) größer. GPU-Rechenleistung identisch, Grundlast k3s ~0,5–1 GB je Knoten. | Docker Swarm (kaum GPU-Planung, kein Helm), RKE2 (gleiches Haus, schwerer), kubeadm. |
|
||||||
| **D5** | IP-Plan | **Knoten behalten .207–.214.** Gäste bekommen einen festen Block außerhalb des DHCP-Bereichs. Vorschlag `.220–.239`, wird in Task 0.3 gegen den DHCP-Bereich geprüft. | Die STT-Konfiguration braucht IP-Literale. Die alten GODEL-DNS-Einträge zeigen, dass die Knoten-IPs aus DHCP stammen, deshalb Reservierungen anlegen. | VLAN `svc` (PLAN §1.4) später, wenn der Switch das zulässt. |
|
| **D5** | IP-Plan | **Knoten behalten .207–.214.** Gäste bekommen einen festen Block außerhalb des DHCP-Bereichs. Vorschlag `.220–.239`, wird in Task 0.3 gegen den DHCP-Bereich geprüft. | Die STT-Konfiguration braucht IP-Literale. Die alten GODEL-DNS-Einträge zeigen, dass die Knoten-IPs aus DHCP stammen, deshalb Reservierungen anlegen. | VLAN `svc` (PLAN §1.4) später, wenn der Switch das zulässt. |
|
||||||
|
|
||||||
|
## Was hängt woran (Stand 12.10.)
|
||||||
|
|
||||||
|
Die Speicherfreigabe (D1) liegt seit 12.10. vor, `tank` steht auf pve44, pve48 und pve52; die rechte Spalte ist damit freigeschaltet. D2 ist entschieden (12.10.). D5 ist zurückgestellt: Gäste starten mit DHCP; feste IPs müssen vor Phase 6 stehen, weil STT und die LiteLLM-Slots IP-Literale nutzen. 19.10.: Reservierungen für pve44, pve52, gpu-pve52 und den Laptop bestätigt, Rest siehe Task 0.2.
|
||||||
|
|
||||||
|
| Sofort möglich | Braucht D2 (entschieden) | Braucht die Speicherfreigabe |
|
||||||
|
|---|---|---|
|
||||||
|
| 1.3 pve61-Netz am Dock · 5.1 Backup-Ziel entscheiden · 6.4 Worker-Registry v2 (nex) · NEX P1–P8 | 0.4 Site-Dateien/ADRs · 1.1 Erst-Update aller Knoten · 2.1 Cluster · Phase 4 (Benutzer, 2FA, Benachrichtigungen, Firewall, Pools, Zertifikat, Update-Routine) · 3.1/3.2 ops-LXC und GPU-VMs auf `local-lvm` (vorerst DHCP) · 3.3 Benchmark | 1.2 `tank` anlegen · 2.2 Replikation/HA · 5.2 Infra-LXC mit HA (alternativ zuerst auf `local-lvm`, später verschieben) · 5.3 Kiosk-Anzeige · Phase 8 GRP-Test |
|
||||||
|
|
||||||
|
Task 1.2 läuft am besten **nach** dem Cluster (2.1): Dann wird `tank` einmal für alle drei Knoten angelegt (`pvesm add … --nodes pve44,pve48,pve52`), statt die Speicherdefinition beim Beitritt zu überschreiben.
|
||||||
|
|
||||||
## RAM-Budget je Knoten
|
## RAM-Budget je Knoten
|
||||||
|
|
||||||
Bei einem Knoten mit 32 GB sind 6 GB fest belegt: 2 GB für den Host und 4 GB ZFS-ARC. Eine GPU-VM reserviert ihre 12 GB vollständig, Passthrough erlaubt kein Ballooning. Damit bleiben auf einem GPU-Knoten **14 GB** für Container und als Reserve für den Failover.
|
Bei einem Knoten mit 32 GB sind 6 GB fest belegt: 2 GB für den Host und 4 GB ZFS-ARC. Eine GPU-VM reserviert ihre 12 GB vollständig, Passthrough erlaubt kein Ballooning. Damit bleiben auf einem GPU-Knoten **14 GB** für Container und als Reserve für den Failover.
|
||||||
@@ -83,7 +96,7 @@ pve48 hat laut Inventar vom 12.10. M4000 und P2000 und ist damit der vierte GPU-
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `gpu-pve44`, `gpu-pve46`, `gpu-pve48`, `gpu-pve52` | je 12 GB, fest | auf dem eigenen Knoten |
|
| `gpu-pve44`, `gpu-pve46`, `gpu-pve48`, `gpu-pve52` | je 12 GB, fest | auf dem eigenen Knoten |
|
||||||
| `infra` (LXC) | 8 GB | pve44 auf `tank`, Replikation nach pve52 und pve48 |
|
| `infra` (LXC) | 8 GB | pve44 auf `tank`, Replikation nach pve52 und pve48 |
|
||||||
| `ops` (LXC) | 1 GB | pve46 (kein HA nötig, per Ansible neu aufsetzbar) |
|
| `ops` (LXC) | 2 GB | pve46 (kein HA nötig, per Ansible neu aufsetzbar; 2 GB statt 1 GB, pve46 hat keinen ZFS-ARC) |
|
||||||
| `pbs` (VM) | 4 GB | pve52 |
|
| `pbs` (VM) | 4 GB | pve52 |
|
||||||
| `grp-test` (LXC) | 4 GB | pve48 auf `tank`, Replikation nach pve44 |
|
| `grp-test` (LXC) | 4 GB | pve48 auf `tank`, Replikation nach pve44 |
|
||||||
| pve61 | 8 GB gesamt | keine Gäste, nur Kiosk |
|
| pve61 | 8 GB gesamt | keine Gäste, nur Kiosk |
|
||||||
@@ -116,7 +129,13 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
|
|||||||
|
|
||||||
**Wo:** remote (DHCP-Server, vermutlich DC `.120` oder UniFi) · **Planner:** „PVE-Config: Netzwerk prüfen – Bridge, VLAN-Fähigkeit und NIC-Kompatibilität“
|
**Wo:** remote (DHCP-Server, vermutlich DC `.120` oder UniFi) · **Planner:** „PVE-Config: Netzwerk prüfen – Bridge, VLAN-Fähigkeit und NIC-Kompatibilität“
|
||||||
|
|
||||||
- [ ] DHCP-Bereich auslesen. Für .207, .209, .210, .212 und .214 Reservierungen auf die MACs der Knoten anlegen (MACs stehen im Healthcheck). Ebenso eine Reservierung für den Admin-Laptop (heute .151 per WLAN). Die Firewall in 4.4 lässt nur diese Adresse zu, ein Lease-Wechsel würde sonst aussperren.
|
- [x] (14.10.) ops und infra auf feste Adressen .173/.188 umgestellt (BETRIEB §10), damit hängt der Steuerrechner nicht mehr am Lease. Laptop kam am 14.10. per Dock mit .181, als zweite Adresse in `firewall.management` aufgenommen.
|
||||||
|
- [ ] Reservierungen beim DC-Support anfordern (15.10. angefragt; MAC-Liste vom 14.10.: Knoten, ops bc:24:11:c1:d9:c8, infra bc:24:11:6d:e5:c0, gpu-pve52 bc:24:11:92:4b:eb, **nachreichen: k3s-VM bc:24:11:a5:d5:52 → .124**, Laptop WLAN 34:fd:70:65:06:6c und Dock 84:ba:59:fb:e0:36) plus Ausschluss `.220–.239`. Nötig ist dafür nur die Gruppe „DHCP-Administratoren“ auf dem DC .120, kein Domänen-Admin.
|
||||||
|
- [x] (19.10., Bestätigung an den Nutzer) Feste Adressen per Reservierung: pve44 (.210), pve52 (.207), gpu-pve52 (.174, `node.json` auf infra) und der Laptop über WLAN (.151, gemessen) und LAN im Dock (.181, gemessen 19.10.). Damit ist die Laptop-Sperre aus BETRIEB §10 behoben.
|
||||||
|
- [ ] Laut DC derzeit **nicht konfigurierbar**: infra (.188), pve46 (.209), pve48 (.212). Alle drei sind im Gast bzw. in `/etc/network/interfaces` statisch eingestellt (19.10. geprüft), hängen also an keinem Lease. Restrisiko: Der DHCP-Server vergibt die Adresse an ein anderes Gerät, dann gibt es einen Konflikt. Ob das droht, zeigt erst „DHCP-Bereich auslesen“ (liegen die Adressen im Bereich?). Liegen sie darin, als Alternative einen Ausschluss der Einzeladressen erfragen.
|
||||||
|
- [ ] Ohne Rückmeldung: ops (.173, statisch), k3s-VM (.124), gpu-pve44/46/48, pve61 (.214), Ausschluss `.220–.239`. 19.10. vom Nutzer erneut beim DC angefragt.
|
||||||
|
- [ ] Später (Phase 9): eigenes Verwaltungs-/Dienstnetz als VLAN, Vorbedingung: Switch kann VLAN, Routing beim Netzverantwortlichen geklärt. Bis dahin bleibt die Adressliste in der Proxmox-Firewall der Schutz.
|
||||||
|
- [ ] DHCP-Bereich auslesen. **Seit 13.10. dringender:** Die Firewall (4.4) lässt nur .151 (Laptop), .173 (ops) und .188 (infra) an die Verwaltungsports; ein Lease-Wechsel sperrt die Quelle aus (BETRIEB §10). Für .207, .209, .210, .212 und .214 Reservierungen auf die MACs der Knoten anlegen (MACs stehen im Healthcheck). Ebenso eine Reservierung für den Admin-Laptop (heute .151 per WLAN). Die Firewall in 4.4 lässt nur diese Adresse zu, ein Lease-Wechsel würde sonst aussperren. 19.10.: Laptop, pve44 und pve52 reserviert; der Bereich selbst ist noch nicht ausgelesen und entscheidet über das Restrisiko für infra, pve46, pve48.
|
||||||
- [ ] Gäste-Block festlegen (Vorschlag `.220–.239`) und als Ausschluss im DHCP eintragen.
|
- [ ] Gäste-Block festlegen (Vorschlag `.220–.239`) und als Ausschluss im DHCP eintragen.
|
||||||
Prüfung: `ping` auf jede Adresse des Blocks → keine Antwort. DHCP-Ausschluss sichtbar.
|
Prüfung: `ping` auf jede Adresse des Blocks → keine Antwort. DHCP-Ausschluss sichtbar.
|
||||||
|
|
||||||
@@ -125,8 +144,10 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
|
|||||||
**Wo:** Gespräch mit dem Nutzer
|
**Wo:** Gespräch mit dem Nutzer
|
||||||
|
|
||||||
- [x] D1 entschieden (12.10.): **Variante B**, keine Neuinstallation. Alle alten Windows-Platten werden für den Cluster vollständig gelöscht.
|
- [x] D1 entschieden (12.10.): **Variante B**, keine Neuinstallation. Alle alten Windows-Platten werden für den Cluster vollständig gelöscht.
|
||||||
- [ ] D1-Freigabe zum Löschen: Der Nutzer prüft die alten Windows-Platten zuerst (pve44 sdb NTFS, pve52 sdb BitLocker „GODEL55“, pve48 sdb mit 220 GB und sdc mit 101 GB belegt). Gelöscht wird erst nach seinem ausdrücklichen „Freigabe“.
|
- [x] D1-Freigabe zum Löschen (12.10., „festplatten alle platt machen“): pve44 sdb NTFS, pve52 sdb BitLocker „GODEL55“, pve48 sdb und sdc. Alle vier gelöscht (Task 1.2).
|
||||||
- [ ] D2–D5 bestätigen. Ergebnis als Abschnitt „Entscheidungen 2026-10“ in `docs/PLAN.md` §9 eintragen.
|
- [x] D2 entschieden (12.10.): eine GPU-VM pro Knoten. Vorteil, der schon heute greift: Das anstehende Kernel-Update 7.0.2 → 7.0.14 berührt keinen NVIDIA-Treiber.
|
||||||
|
- [ ] D5 zurückgestellt (12.10., „Schritt zwei“): Gäste vorerst per DHCP. Für feste IPs braucht es keinen DNS-Eintrag, sondern einen Ausschluss bzw. Reservierungen im DHCP-Server. Den verwaltet nicht der Nutzer. Dabei auch die Knoten-IPs .207–.214 reservieren lassen (Corosync bindet an sie). Stand 19.10. in Task 0.2.
|
||||||
|
- [ ] D3 und D4 bestätigen. Ergebnis als Abschnitt „Entscheidungen 2026-10“ in `docs/PLAN.md` §9 eintragen.
|
||||||
|
|
||||||
### Task 0.4: Site-Dateien, Ansible-Inventar und ADRs an die echte Hardware anpassen
|
### Task 0.4: Site-Dateien, Ansible-Inventar und ADRs an die echte Hardware anpassen
|
||||||
|
|
||||||
@@ -138,15 +159,14 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
|
|||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Produces: Proxmox-Knoten `pve44`, `pve46`, `pve48`, `pve52`, `pve61`, dazu die Inventar-Knoten `gode-pc-05` und `gode-pc-06` (Ada, ohne Proxmox, nur im Inventar). PCI-Mappings `gpu-m4000-pve44`, `gpu-p2000-pve44`, `gpu-m4000-pve46`, `gpu-p2000-pve46`, `gpu-m4000-pve52`, `gpu-m4000-pve48`, `gpu-p2000-pve48`. GPU-VMs `gpu-pve44` (vmid 244), `gpu-pve46` (246), `gpu-pve48` (248), `gpu-pve52` (252). Infra-LXC `infra` (vmid 101). Ansible-Gruppen `pve_all`, `pve_gpu_hosts`, `pve_kiosk`.
|
- Produces: Proxmox-Knoten `pve44`, `pve46`, `pve48`, `pve52`, `pve61`, dazu die Inventar-Knoten `gode-pc-05` und `gode-pc-06` (Ada, ohne Proxmox, nur im Inventar). PCI-Mappings `gpu-m4000-pve44`, `gpu-p2000-pve44`, `gpu-m4000-pve46`, `gpu-p2000-pve46`, `gpu-m4000-pve52`, `gpu-m4000-pve48`, `gpu-p2000-pve48`. GPU-VMs `gpu-pve44` (vmid 244), `gpu-pve46` (246), `gpu-pve48` (248), `gpu-pve52` (252). Infra-LXC `infra` (vmid 101). Ansible-Gruppen `pve_all`, `pve_gpu_hosts`, `pve_kiosk`.
|
||||||
|
|
||||||
- [x] **Step 1: Failing test.** In `test_inventory_validate.py` den Test `test_site_knaster_matches_real_nodes` anlegen:
|
- [ ] **Step 1: Failing test.** In `test_inventory_validate.py` den Test `test_site_knaster_matches_real_nodes` anlegen:
|
||||||
- Die Inventar-Knoten sind genau `{pve44, pve46, pve48, pve52, pve61, gode-pc-05, gode-pc-06}`.
|
- Die Inventar-Knoten sind genau `{pve44, pve46, pve48, pve52, pve61, gode-pc-05, gode-pc-06}`.
|
||||||
- `proxmox.yaml` enthält keine IP aus `10.20.0.0/24`.
|
- `proxmox.yaml` enthält keine IP aus `10.20.0.0/24`.
|
||||||
- Jedes `pci_mappings[*].node` und jedes `vms[*].node` liegt in `{pve44, pve46, pve48, pve52}`.
|
- Jedes `pci_mappings[*].node` und jedes `vms[*].node` liegt in `{pve44, pve46, pve48, pve52}`.
|
||||||
- [x] **Step 2:** `make test` → FAIL (alte Annahmen `pve-ada`, `pve1`–`pve4`).
|
- [ ] **Step 2:** `make test` → FAIL (alte Annahmen `pve-ada`, `pve1`–`pve4`).
|
||||||
- [x] **Step 3:** Site-Dateien umschreiben. Werte für IDs und Pfade aus `lspci -nn` und dem Healthcheck: M4000 `10de:13f1` auf `0000:01:00`, P2000 `10de:1c30` auf `0000:02:00`. Gateway `192.168.100.1`, DNS `192.168.100.120`, keine `vlan_svc`, Gast-IPs aus D5. Die M4000 bleiben `disabled: true` bis zum Benchmark-Tag (Task 3.3). Die Ada-Slots (`tier1-general`, `tier1-flex`, `tts-de-hq`) behalten vorerst ihre Engine. In `registry.yaml` entfallen bei ihnen nur `pve_node` und `vmid`. Die Umstellung auf die Worker-Registry folgt in Task 6.4.
|
- [ ] **Step 3:** Site-Dateien umschreiben. Werte für IDs und Pfade aus `lspci -nn` und dem Healthcheck: M4000 `10de:13f1` auf `0000:01:00`, P2000 `10de:1c30` auf `0000:02:00`. Gateway `192.168.100.1`, DNS `192.168.100.120`, keine `vlan_svc`, Gast-IPs aus D5. Die M4000 bleiben `disabled: true` bis zum Benchmark-Tag (Task 3.3). Die Ada-Slots (`tier1-general`, `tier1-flex`, `tts-de-hq`) behalten vorerst ihre Engine. In `registry.yaml` entfallen bei ihnen nur `pve_node` und `vmid`. Die Umstellung auf die Worker-Registry folgt in Task 6.4.
|
||||||
- [x] **Step 4:** `make test && make validate` → PASS. `knaster placement plan --inventory sites/knaster/inventory.yaml` liefert eine Zuordnung ohne Fehler.
|
- [ ] **Step 4:** `make test && make validate` → PASS. `knaster placement plan --inventory sites/knaster/inventory.yaml` liefert eine Zuordnung ohne Fehler.
|
||||||
- [x] **Step 5:** Commit `sites: Inventar und Zielbild an echte Knoten (pve44–pve61) angepasst; ADR-0001/0002/0006 ergänzt`.
|
- [ ] **Step 5:** Commit `sites: Inventar und Zielbild an echte Knoten (pve44–pve61) angepasst; ADR-0001/0002/0006 ergänzt`.
|
||||||
- Ergebnis (13.10.): Zusätzlich angepasst wurden `platform/proxmox/tofu` (mehrere Karten pro VM, Speicher je Gast, `vlan_svc: null`), `playbooks/site.yml` (`pve-host` nur für `pve_gpu_hosts`), die Replikation in `pve-host` (mehrere Ziele) und `deploy/prometheus/prometheus.yml` (IP-Literale, keine Ada-Ziele). Neues Inventarfeld `cpu_roles: false` für die Ada-Rechner und pve61. Damit verplant das Placement dort keine Infra- und keine CPU-Rollen. Hinweis: Das Placement schlägt `infra` auf pve52 vor, weil dort keine Karte aktiv ist. Laut RAM-Budget steht `infra` auf pve44, so ist es in `proxmox.yaml` eingetragen. Nicht mit `tofu validate` und `ansible-lint` geprüft, beides ist lokal nicht installiert. Das wird im ops-LXC nachgeholt (Task 3.1).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -159,15 +179,16 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
|
|||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Produces: `bootstrap-node.sh <knoten>`, idempotent. Ausgabe endet mit `BOOTSTRAP OK <hostname>` oder Exit ≠ 0.
|
- Produces: `bootstrap-node.sh <knoten>`, idempotent. Ausgabe endet mit `BOOTSTRAP OK <hostname>` oder Exit ≠ 0.
|
||||||
|
|
||||||
- [ ] **Step 1:** Das Skript (läuft auf dem Laptop, Git-Bash) erledigt Folgendes:
|
- [x] **Step 1:** Das Skript (läuft auf dem Laptop, Git-Bash) erledigt Folgendes (12.10.: `ssh-keygen -R` nur mit `--reinstall`, Update abgekoppelt per `systemd-run`):
|
||||||
- alten Hostschlüssel mit `ssh-keygen -R <ip>` entfernen
|
- alten Hostschlüssel mit `ssh-keygen -R <ip>` entfernen
|
||||||
- Schlüssel per Passwort aus `.env` eintragen, mit dem SSH_ASKPASS-Verfahren vom 08.10. (nur anhängen, `grep -qxF`)
|
- Schlüssel per Passwort aus `.env` eintragen, mit dem SSH_ASKPASS-Verfahren vom 08.10. (nur anhängen, `grep -qxF`)
|
||||||
- Enterprise-Quellen auf `Enabled: no` setzen, `proxmox.sources` mit `pve-no-subscription` anlegen, vorher sichern
|
- Enterprise-Quellen auf `Enabled: no` setzen, `proxmox.sources` mit `pve-no-subscription` anlegen, vorher sichern
|
||||||
- `apt-get update && apt-get -y full-upgrade`
|
- `apt-get update && apt-get -y full-upgrade`
|
||||||
- Pakete `ethtool lm-sensors` installieren
|
- Pakete `ethtool lm-sensors` installieren
|
||||||
- Prüfen: `timedatectl show -p NTPSynchronized` = `yes`
|
- Prüfen: `timedatectl show -p NTPSynchronized` = `yes`
|
||||||
- [ ] **Step 2:** Zweiter Lauf gegen denselben Knoten → keine Änderung, wieder `BOOTSTRAP OK`.
|
- [x] **Step 2:** Zweiter Lauf gegen denselben Knoten → keine Änderung, wieder `BOOTSTRAP OK` (pve44, 12.10., 17 s).
|
||||||
- [ ] **Step 3:** Commit.
|
- [x] **Step 3:** Commit.
|
||||||
|
- [ ] **Step 4 (Ausführung 12.10.):** Alle Knoten auf PVE 9.2.21 / Kernel 7.0.14-23 gebracht (pve44, pve46, pve48, pve52 neu gestartet und geprüft; **pve61 Neustart offen**); nach jedem Neustart Kernel, `systemctl --failed`, vmbr0, NTP sowie GPU-Adressen und IOMMU-Gruppen geprüft. pve61 wird erst neu gestartet, wenn der Nutzer vor Ort ist (USB-NIC im Dock).
|
||||||
|
|
||||||
### Task 1.2: Speicher (D1)
|
### Task 1.2: Speicher (D1)
|
||||||
|
|
||||||
@@ -175,10 +196,20 @@ Ersetzt PLAN §8 „0 Fundament“ (Teil Inventar) und IMPLEMENTATION „Gemeins
|
|||||||
|
|
||||||
**Variante B (Empfehlung) – remote, ohne Neuinstallation**
|
**Variante B (Empfehlung) – remote, ohne Neuinstallation**
|
||||||
|
|
||||||
- [ ] Freigabe aus Task 0.3 liegt vor. Dann auf pve44, pve48 und pve52: Partitionen von `sdb` mit `wipefs -a /dev/disk/by-id/<ssd2>` entfernen, danach `zpool create -o ashift=12 -O compression=lz4 -O atime=off tank /dev/disk/by-id/<ssd2>` und `pvesm add zfspool tank --pool tank --content images,rootdir --nodes pve44,pve48,pve52`. Auf pve48 bleibt `sdc` unberührt.
|
- [x] Freigabe aus Task 0.3 liegt vor (12.10.). Auf pve44, pve48 und pve52 die zweite SSD gelöscht und `tank` angelegt. Die Platten wurden nur über ihre Seriennummer angesprochen; vor dem Löschen wurde geprüft, dass sie nicht die Systemplatte, nicht eingehängt und nicht in Benutzung sind:
|
||||||
Das Löschen ist nicht umkehrbar. Vorher `lsblk -o NAME,SIZE,FSTYPE,LABEL /dev/sdb` prüfen: Es muss die freigegebene Windows-Platte sein, nie `sda`.
|
|
||||||
- [ ] ZFS-ARC auf 4 GiB begrenzen (`/etc/modprobe.d/zfs.conf`, `update-initramfs -u`, wirksam beim nächsten Neustart).
|
| Knoten | `tank` auf | Systemplatte (unberührt) |
|
||||||
Prüfung: `zpool status tank` → `ONLINE`. `pvesm status` → `tank active` auf beiden Knoten.
|
|---|---|---|
|
||||||
|
| pve44 | `ata-SAMSUNG_MZ7LN256HAJQ-000L7_S3S7NY0K403015` | Samsung S20HNAAH271598 |
|
||||||
|
| pve48 | `ata-SanDisk_SD6SB1M256G1022I_140877401621` | SanDisk 164862429106 |
|
||||||
|
| pve52 | `ata-SAMSUNG_MZ7LN256HAJQ-000L7_S3S7NY0K403017` | Samsung S3S7NY0K403016 (gleiches Modell, nur die letzte Ziffer unterscheidet sich) |
|
||||||
|
|
||||||
|
Ablauf je Platte: `wipefs -a` (Partitionen, dann Platte), `sgdisk --zap-all`, `blkdiscard`. Danach `zpool create -o ashift=12 -O compression=lz4 -O atime=off tank /dev/disk/by-id/<ssd2>` und `systemctl enable zfs-import@tank.service`. Einmal im Cluster: `pvesm add zfspool tank --pool tank --content images,rootdir --sparse 1 --nodes pve44,pve48,pve52`.
|
||||||
|
pve48 `sdc` (SanDisk 128 GB, 140621401586) ist ebenfalls gelöscht und bleibt vorerst ungenutzt. Als Stripe in `tank` würde sie das Ausfallrisiko verdoppeln.
|
||||||
|
- [x] ZFS-ARC auf 4 GiB begrenzt: `/etc/modprobe.d/zfs.conf`, `update-initramfs -u -k all`, zusätzlich sofort über `/sys/module/zfs/parameters/zfs_arc_max`.
|
||||||
|
Prüfung 12.10.: `zpool status tank` → `ONLINE` auf allen drei Knoten. `pvesm status` → `tank active` auf pve44, pve48 und pve52 (auf pve46 und pve61 `disabled`, wie gewollt).
|
||||||
|
- [x] Neustart-Probe (12.10.): pve48 (Legacy-BIOS) und pve44 (UEFI) neu gestartet. Beide starten von `sda`, `tank` wird importiert (`ONLINE`, `pvesm` active), ARC = `4294967296`, keine fehlgeschlagenen Dienste, GPU-Stand unverändert, Quorum 5/5.
|
||||||
|
- [ ] Nebenbefund pve44: Die EFI-Partition `sda2` ist nicht unter `/boot/efi` eingehängt (kein Eintrag in `/etc/fstab`). Der Start klappt, aber GRUB-Updates erreichen die EFI-Partition nicht. Eintrag `UUID=BB96-13D2 /boot/efi vfat defaults 0 1` nachtragen, `grub-install` prüfen. Die alten Windows-Starteinträge im UEFI (pve44 `Boot000E`, pve52 `Boot000D`) zeigen ins Leere und können mit `efibootmgr -b <nr> -B` entfernt werden.
|
||||||
|
|
||||||
**Variante A – Neuinstallation mit ZFS**
|
**Variante A – Neuinstallation mit ZFS**
|
||||||
|
|
||||||
@@ -214,17 +245,19 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
|
|||||||
|
|
||||||
**Wo:** remote · **Planner:** „GPU-Knoten 3: Cluster mit QDevice (Container auf appsrv08), NVIDIA-Treiber, GPU-Container“ (Teil Cluster)
|
**Wo:** remote · **Planner:** „GPU-Knoten 3: Cluster mit QDevice (Container auf appsrv08), NVIDIA-Treiber, GPU-Container“ (Teil Cluster)
|
||||||
|
|
||||||
- [ ] Auf pve44: `pvecm create knaster --link0 192.168.100.210`.
|
- [x] Auf pve44: `pvecm create knaster --link0 192.168.100.210` (12.10.).
|
||||||
- [ ] Nacheinander auf pve46, pve48, pve52 und pve61: `pvecm add 192.168.100.210 --link0 <eigene IP>`. Nach **jedem** Beitritt den Laptop-Schlüssel in `/etc/pve/priv/authorized_keys` prüfen und gegebenenfalls einmal ergänzen.
|
- [x] Nacheinander auf pve46, pve48, pve52 und pve61 beigetreten (12.10.). Statt `pvecm add` (fragt das Passwort am Terminal ab) `pvesh create /cluster/config/join --hostname 192.168.100.210 --fingerprint <sha256 von pve44> --link0 <eigene IP> --password …`, Passwort per stdin, Windows-Zeilenende entfernen. Nach **jedem** Beitritt den Laptop-Schlüssel in `/etc/pve/priv/authorized_keys` prüfen und gegebenenfalls einmal ergänzen.
|
||||||
Prüfung: `pvecm status` → `Quorate: Yes`, `Total votes: 5`. `for n in pve44 pve46 pve48 pve52 pve61; do ssh -o BatchMode=yes $n hostname; done` → fünf Namen.
|
Prüfung: `pvecm status` → `Quorate: Yes`, `Total votes: 5`. `for n in pve44 pve46 pve48 pve52 pve61; do ssh -o BatchMode=yes $n hostname; done` → fünf Namen.
|
||||||
- [ ] Datacenter-Optionen: Migrationsnetz `192.168.100.0/24`, Typ `secure`.
|
- [x] Datacenter-Optionen: Migrationsnetz `192.168.100.0/24`, Typ `secure`.
|
||||||
|
Ergebnis 12.10.: `Nodes: 5`, `Quorate: Yes`, `Total votes: 5`, Schlüssel-Login auf allen fünf Knoten ok.
|
||||||
|
|
||||||
### Task 2.2: HA-Regeln und Replikation
|
### Task 2.2: HA-Regeln und Replikation
|
||||||
|
|
||||||
**Wo:** remote
|
**Wo:** remote
|
||||||
|
|
||||||
- [ ] Node-Affinity-Regeln: Infra-Dienste nur auf pve44, pve48, pve52. **pve61** bekommt keine HA-Gäste, GPU-VMs bleiben an ihrem Knoten.
|
- [x] Node-Affinity-Regeln (12.10., per Ansible `pve-host`, nicht tofu – der Token braucht so kein `Sys.Console`): Regel `knaster-infra`, `ct:101`, `pve44:2,pve52:1,pve48:1`, strikt. Infra-Dienste nur auf pve44, pve48, pve52. **pve61** bekommt keine HA-Gäste, GPU-VMs bleiben an ihrem Knoten.
|
||||||
- [ ] Replikationspartner festlegen: Infra-LXC von seinem Knoten auf einen zweiten, `*/15`.
|
- [x] Replikation (12.10.): Jobs `101-0` → pve52 und `101-1` → pve48, `*/15`, Erstlauf je 14 s.
|
||||||
|
**Test 12.10.:** Manuelles `migrate` nach pve52 lehnt PVE 9 ab (niedrigere Priorität, solange pve44 lebt – gewollt). Wartungsmodus `ha-manager crm-command node-maintenance enable pve44` → `infra` nach 36 s auf pve48 gestartet, Testdatei aktuell, Docker läuft, IP gleich; `disable` → nach 41 s zurück auf pve44. Echter Ausfall (Strom weg) noch nicht getestet – nur vor Ort.
|
||||||
Prüfung: `ha-manager rules config` bzw. `ha-manager groupconfig` zeigt die Regeln.
|
Prüfung: `ha-manager rules config` bzw. `ha-manager groupconfig` zeigt die Regeln.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -235,31 +268,41 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
|
|||||||
|
|
||||||
**Wo:** remote · **Files:** Modify `platform/proxmox/ansible/inventory.yml`, `platform/proxmox/ansible/playbooks/site.yml`, Create `platform/proxmox/README.md` (Abschnitt ops-LXC)
|
**Wo:** remote · **Files:** Modify `platform/proxmox/ansible/inventory.yml`, `platform/proxmox/ansible/playbooks/site.yml`, Create `platform/proxmox/README.md` (Abschnitt ops-LXC)
|
||||||
|
|
||||||
- [ ] LXC `ops` (Debian 13, vmid 100, auf pve46 laut RAM-Budget, IP aus D5) mit `ansible-core`, `opentofu`, `git` und dem Repo-Clone. Ansible läuft nicht auf dem Windows-Laptop.
|
- [x] LXC `ops` (12.10.): CT 100 auf pve46, Debian 13.6, unprivilegiert, 2 Kerne, 2 GB, 8 GB `local-lvm`, DHCP (heute `192.168.100.173`, Alias `ops` in der SSH-Konfiguration des Laptops). `ansible-core` 2.19.11, OpenTofu 1.13.1 (offizielles signiertes Repo), `git`, `make`, `python3-venv`. Eigener Schlüssel `root@ops` steht in `/etc/pve/priv/authorized_keys` (gilt für alle Knoten), `known_hosts` vom Laptop übernommen (geprüfte Einträge), `StrictHostKeyChecking yes`. Repo-Clone anonym über HTTPS (Branch `plan/aufbau-2026-10-12`); geändert wird auf dem Laptop, `ops` macht nur `git pull`.
|
||||||
- [ ] `site.yml`: `pve-host` nur für `pve_gpu_hosts` (pve44, pve46, pve48, pve52). pve61 steht in `pve_kiosk` ohne VFIO und Blacklist.
|
- [x] `site.yml`: `pve-host` nur für `pve_gpu_hosts` (pve44, pve46, pve48, pve52). pve61 steht in `pve_kiosk` ohne VFIO und Blacklist.
|
||||||
- [ ] Rolle `pve-host` ergänzen: `/etc/modprobe.d/vfio.conf` mit `options vfio-pci ids=<liste>` und `softdep nvidia pre: vfio-pci`. Die Liste enthält die Grafik- **und** die Audio-Funktionen der Karten. Die IDs aus `lspci -nn -s 01:00` und `lspci -nn -s 02:00` auf dem Knoten lesen, nicht aus dem Gedächtnis eintragen; bekannt sind bisher nur die VGA-IDs `10de:13f1` und `10de:1c30`.
|
- [x] Rolle `pve-host` ergänzen (12.10., IDs liest die Rolle per `lspci -n -d 10de:` auf dem Knoten; zusätzlich `softdep nouveau/snd_hda_intel pre: vfio-pci`, `initcall_blacklist=sysfb_init`, Handler `update-initramfs`; Handler-Fehler behoben: `proxmox-boot-tool refresh || update-grub` erreichte `update-grub` nie): `/etc/modprobe.d/vfio.conf` mit `options vfio-pci ids=<liste>` und `softdep nvidia pre: vfio-pci`. Die Liste enthält die Grafik- **und** die Audio-Funktionen der Karten. Die IDs aus `lspci -nn -s 01:00` und `lspci -nn -s 02:00` auf dem Knoten lesen, nicht aus dem Gedächtnis eintragen; auf allen GPU-Knoten gelesen (12.10.): M4000 `10de:13f1` + Audio `10de:0fbb`, P2000 `10de:1c30` + Audio `10de:10f1`.
|
||||||
- [ ] `ansible-playbook playbooks/site.yml --limit pve_gpu_hosts --check`, danach der echte Lauf. Knoten **einzeln** neu starten.
|
- [x] `ansible-playbook playbooks/site.yml --limit pve_gpu_hosts --check`, danach der echte Lauf. Knoten **einzeln** neu starten (12.10.: pve48 als Pilot inkl. Wegwerf-VM mit beiden Karten, dann pve44, pve52, pve46).
|
||||||
|
Ergebnis 12.10.: auf allen vier GPU-Knoten jede NVIDIA-Funktion an `vfio-pci`, keine fehlgeschlagenen Dienste. Die M4000 ist überall die Boot-Grafik und es gibt keine iGPU: **Der lokale Bildschirm bleibt nach dem frühen Start schwarz**, Zugang per SSH/Weboberfläche, GRUB-Menü bleibt sichtbar. Die P2000 kann kein FLR, der Bus-Reset klappt („reset done“).
|
||||||
Prüfung: `lspci -nnk -s 01:00` und `lspci -nnk -s 02:00` auf jedem GPU-Knoten → bei allen Funktionen `Kernel driver in use: vfio-pci`. Auf pve61: `lspci -k -s 00:02.0` → `i915`.
|
Prüfung: `lspci -nnk -s 01:00` und `lspci -nnk -s 02:00` auf jedem GPU-Knoten → bei allen Funktionen `Kernel driver in use: vfio-pci`. Auf pve61: `lspci -k -s 00:02.0` → `i915`.
|
||||||
|
|
||||||
### Task 3.2: Resource Mappings, Template und GPU-VMs
|
### Task 3.2: Resource Mappings, Template und GPU-VMs
|
||||||
|
|
||||||
**Wo:** remote (ops-LXC) · **Planner:** „GPU-Knoten 3 …“ (Teil NVIDIA, GPU-Container)
|
**Wo:** remote (ops-LXC) · **Planner:** „GPU-Knoten 3 …“ (Teil NVIDIA, GPU-Container)
|
||||||
|
|
||||||
- [ ] PCI-Resource-Mappings aus `sites/knaster/proxmox.yaml` anlegen (Namen aus Task 0.4).
|
- [x] PCI-Resource-Mappings aus `sites/knaster/proxmox.yaml` anlegen (Namen aus Task 0.4) – per tofu (`proxmox_hardware_mapping_pci`), 12.10.
|
||||||
- [ ] Ubuntu-24.04-Cloud-Template vmid 9000 auf jedem GPU-Knoten.
|
- [x] Ubuntu-24.04-Cloud-Template je Knoten (12.10.): **9044, 9046, 9048, 9052** statt 9000 überall, weil VMIDs clusterweit eindeutig sind. `platform/proxmox/scripts/make-template.sh <knoten> <vmid>` (Prüfsumme gegen SHA256SUMS, kein Secure Boot wegen DKMS, Gast-Agent an).
|
||||||
- [ ] `tofu plan` und dann `tofu apply` für `gpu-pve44`, `gpu-pve46`, `gpu-pve48` und `gpu-pve52`: alle Karten des Knotens, 12 GB RAM, 4 Kerne, Systemplatte 40 GB und Modellplatte 60 GB auf `local-lvm` (Variante B) bzw. `local-zfs` (Variante A), IPs aus D5.
|
- [x] API-Benutzer `tofu@pve` mit Token `tofu@pve!ops` (12.10.), Rechte nur: `/` PVEAuditor, `/vms` PVEVMAdmin, `/storage` PVEDatastoreUser, `/sdn/zones/localnetwork` PVESDNUser, `/mapping/pci` PVEMappingAdmin. Token nur auf `ops` in `/root/.config/knaster/tofu.env` (600). Für HA (5.2) kommt `Sys.Console` hinzu.
|
||||||
- [ ] `ansible-playbook … --limit gpu_vms` (Rollen `docker-host` und `gpu-node`, `driver_generation: legacy` = R580).
|
- [x] tofu-Code an D2/D5 angepasst (12.10.): `bpg/proxmox ~> 0.116.0`, `hostpci` dynamisch aus `vms.*.gpus`, DHCP mit Adresse über den `qemu-guest-agent` (Vendor-Snippet `local:snippets/knaster-vendor.yaml` aus `pve-host`), Speicher je Gast, Ausgabe `ansible_inventory`. `tofu plan`: 11 neue Objekte.
|
||||||
|
- [x] (12.10.) `tofu plan` und dann `tofu apply` für `gpu-pve44`, `gpu-pve46`, `gpu-pve48` und `gpu-pve52`: alle Karten des Knotens, 12 GB RAM, 4 Kerne, Systemplatte 40 GB und Modellplatte 60 GB auf `local-lvm` (Variante B) bzw. `local-zfs` (Variante A), IPs aus D5.
|
||||||
|
- [x] `ansible-playbook … --limit gpu_vms` (Rollen `docker-host` und `gpu-node`, `driver_generation: legacy` = R580 → `580.178.04`, DKMS), 12.10.
|
||||||
|
Ergebnis 12.10., auch nach Neustart aller VMs: `gpu-pve44/46/48` sehen M4000 + P2000, `gpu-pve52` die M4000, `docker run --gpus all …` sieht alle Karten, Modellplatte 60 GB unter `/srv/models`.
|
||||||
|
**Befund DHCP:** Nach dem ersten Neustart hatten zwei VMs ihre Adressen getauscht (DUID als Client-ID); die strenge Hostschlüssel-Prüfung hat gestoppt. Jetzt `dhcp-identifier: mac` (netplan), Zugang nach jedem Neustart mit `scripts/refresh-vm-access.sh` auffrischen. Dauerhaft löst das nur D5 (Reservierungen auf die MACs: gpu-pve44 `bc:24:11:6a:67:15`, gpu-pve46 `bc:24:11:75:ad:33`, gpu-pve48 `bc:24:11:f5:da:73`, gpu-pve52 `bc:24:11:92:4b:eb`). 19.10.: gpu-pve52 reserviert (.174), die übrigen drei offen.
|
||||||
Prüfung in jeder VM: `nvidia-smi -L` → beide Karten. `docker run --rm --gpus all nvidia/cuda:12.6.3-base-ubuntu24.04 nvidia-smi` → beide Karten.
|
Prüfung in jeder VM: `nvidia-smi -L` → beide Karten. `docker run --rm --gpus all nvidia/cuda:12.6.3-base-ubuntu24.04 nvidia-smi` → beide Karten.
|
||||||
|
|
||||||
### Task 3.3: Benchmark-Tag (fasst drei Pläne zusammen)
|
### Task 3.3: Benchmark-Tag (fasst drei Pläne zusammen)
|
||||||
|
|
||||||
**Wo:** remote, **Strommessung vor Ort** (Messgerät an der Steckdose) · **Planner:** „GPU-Knoten 4: Geschwindigkeitstests (Gate) auf M4000 und P2000“ · ersetzt außerdem den „Benchmark-Tag“ aus IMPLEMENTATION und den Gate-Task 1 des nex-STT-Plans
|
**Wo:** remote, **Strommessung vor Ort** (Messgerät an der Steckdose) · **Planner:** „GPU-Knoten 4: Geschwindigkeitstests (Gate) auf M4000 und P2000“ · ersetzt außerdem den „Benchmark-Tag“ aus IMPLEMENTATION und den Gate-Task 1 des nex-STT-Plans
|
||||||
|
|
||||||
- [ ] `llama-bench` je Kartentyp: Embedding-Modell, 3–4B Q4 (verifier/signature), 7B Q4. Tokens/s und VRAM messen.
|
- [x] `llama-bench` je Kartentyp: Embedding-Modell, 3–4B Q4 (verifier/signature), 7B Q4. Tokens/s und VRAM messen.
|
||||||
|
(12.10., `gpu-pve48`, llama.cpp b6000/CUDA 12.4, sm_52/61) pp512/tg128 in t/s: Embedding 0.6B Q8 M4000 929 / P2000 1009; Qwen3-4B Q4 M4000 197/15,1, P2000 271/14,5; Qwen3-8B Q4 M4000 115/10,0, P2000 152/9,0. VRAM beim Katalog-Kontext 2,8 / 4,1 / 7,7 GiB, das 8B mit 16k passt nur auf die M4000. Details in ADR-0006.
|
||||||
- [ ] whisper.cpp `large-v3-turbo`, Sprache `de`: Sekunden je 10 s Diktat auf M4000 und P2000. Klärt, ob CC 5.2 überhaupt rechnet. Zusätzlich die Dialekt-Testsätze.
|
- [ ] whisper.cpp `large-v3-turbo`, Sprache `de`: Sekunden je 10 s Diktat auf M4000 und P2000. Klärt, ob CC 5.2 überhaupt rechnet. Zusätzlich die Dialekt-Testsätze.
|
||||||
- [ ] `knaster bench tts` (Piper, CPU).
|
- [x] (12.10., `gpu-pve48`, v1.9.5, synthetisches Piper-Audio) P2000: 3,5 s je 10-s-Clip, 2,5 s je 10 s bei 33 s Audio; M4000 zum Vergleich 2,7 s bzw. 2,1 s. CC 5.2 rechnet, Flash-Attention eingeschlossen. M4000 im Dienst: `gpu-pve52` (Task 6.2).
|
||||||
|
- [ ] Offen: Dialekt-Testsätze (echte Sprecher nötig).
|
||||||
|
- [x] `knaster bench tts` (Piper, CPU). (12.10., Image `tts-piper`, 4 vCPU i7-6700) RTF 0,17 bei 241 Zeichen, 0,11 bei 596 Zeichen.
|
||||||
- [ ] Strom je Knoten: Leerlauf und Volllast beider Karten (400-W-Netzteil, erwartet etwa 300 W).
|
- [ ] Strom je Knoten: Leerlauf und Volllast beider Karten (400-W-Netzteil, erwartet etwa 300 W).
|
||||||
- [ ] Ergebnis eintragen in `catalog/models.yaml` und `sites/knaster/registry.yaml`, in ADR-0006 „M4000 wieder aufnehmen ja/nein“ und in die STT-Entscheidung D131/D133 (nex). Commit.
|
- [ ] Ergebnis eintragen in `catalog/models.yaml` und `sites/knaster/registry.yaml`, in ADR-0006 „M4000 wieder aufnehmen ja/nein“ und in die STT-Entscheidung D131/D133 (nex). Commit.
|
||||||
|
- [x] (12.10.) Katalog: `vram_gb` gemessen (Embedding 2,8, 4B 4,1, 8B 7,8 aufgerundet) und Messwerte als `note`. Die Registry hat keine Felder für Messwerte und bleibt unverändert. ADR-0006 Nachtrag „Benchmark“: M4000 **ja für STT auf pve52**, auf pve44/46/48 vorerst nein (P2000 gleich schnell bei halber Leistung). `knaster validate` und `pytest core` grün (außer dem bekannten Windows-Fehler in `test_tts_server`), Placement unverändert, alle aktiven Rollen auf gemessenen P2000.
|
||||||
|
- [ ] Offen: STT-Entscheidung D131/D133 im nex-Repo, `disabled` der M4000 auf pve52 erst nach der Strommessung (Nutzer). Rerank-VRAM neben Embedding: 19.10. gemessen, passt mit `-ub 4096` (Task 6.1). Für Task 6.1: Compose `count: 1` greift die M4000, `device_ids` setzen.
|
||||||
Prüfung: `make validate` → PASS, Placement ordnet jede aktive Rolle einer gemessenen Karte zu.
|
Prüfung: `make validate` → PASS, Placement ordnet jede aktive Rolle einer gemessenen Karte zu.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -268,13 +311,13 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
|
|||||||
|
|
||||||
**Wo:** remote. Jede Aufgabe hier entspricht einer Planner-Aufgabe.
|
**Wo:** remote. Jede Aufgabe hier entspricht einer Planner-Aufgabe.
|
||||||
|
|
||||||
- [ ] **4.1 Benutzer und Token** („PVE-Config: Persönlicher Admin-Benutzer, Rollen und API-Token anlegen“): Admin-Benutzer und Gruppe, root@pam nur als Notfallzugang. Token `monitoring@pve!prom` (PVEAuditor), `healer@pve!knaster` (nur `VM.PowerMgmt` auf GPU-VMs, PLAN §3), `tofu@pve!ops`.
|
- [x] **4.1 Benutzer und Token** (13.10. erledigt, Rolle `pve-cluster`/`playbooks/cluster.yml`: Gruppe `admins` (Administrator auf `/`), `r.rapp@pve` mit Passwort per `scripts/pve-admin-user.sh`, Rolle `KnasterHealer` + `healer@pve!knaster` nur auf `/pool/gpu`, Token auf ops `/root/.config/knaster/healer.env`; BETRIEB §8) (12.10. teilweise: `tofu@pve!ops` mit engen ACLs und `monitoring@pve!prom` (PVEAuditor, Geheimnis nur auf ops `/root/.config/knaster/pve-exporter.env`) angelegt; offen: persönlicher Admin-Benutzer (Name vom Nutzer), `healer@pve!knaster`) („PVE-Config: Persönlicher Admin-Benutzer, Rollen und API-Token anlegen“): Admin-Benutzer und Gruppe, root@pam nur als Notfallzugang. Token `monitoring@pve!prom` (PVEAuditor), `healer@pve!knaster` (nur `VM.PowerMgmt` auf GPU-VMs, PLAN §3), `tofu@pve!ops`.
|
||||||
- [ ] **4.2 2FA** („PVE-Config: Zwei-Faktor-Authentifizierung für Admin-Konten aktivieren“): TOTP für root@pam und Admin. Recovery-Keys offline ablegen.
|
- [x] **4.2 2FA** (13.10.: TOTP + Recovery-Keys für `r.rapp@pve`, Datei `it/proxmox/tfa-r.rapp.txt`; root@pam bewusst ohne 2FA als Notfallzugang, BETRIEB §8) („PVE-Config: Zwei-Faktor-Authentifizierung für Admin-Konten aktivieren“): TOTP für root@pam und Admin. Recovery-Keys offline ablegen.
|
||||||
- [ ] **4.3 Benachrichtigungen** („PVE-Config: Benachrichtigungen (Notification Center) einrichten“): SMTP-Ziel Büro-Postfach, Matcher für Backup, Fencing und Updates. Testnachricht kommt an.
|
- [x] **4.3 Benachrichtigungen** (13.10.: Ziel `knaster-mail` → ki@mygodel.de per Sendmail direkt an die MX, Matcher `knaster-alles`, `default-matcher` aus, Testmail zugestellt; BETRIEB §9) („PVE-Config: Benachrichtigungen (Notification Center) einrichten“): SMTP-Ziel Büro-Postfach, Matcher für Backup, Fencing und Updates. Testnachricht kommt an.
|
||||||
- [ ] **4.4 Firewall** („PVE-Config: Firewall auf Datacenter- und Knotenebene einrichten“): IP-Set `admin` (Laptop) und `appsrv08` (.29). Zuerst Allow-Regeln für 8006, 22 und ICMP, **dann** aktivieren. Konsole als Rückfall bereithalten. Prüfung: Weboberfläche vom Laptop ok, von einem anderen Rechner gesperrt.
|
- [x] **4.4 Firewall** (13.10.: aktiv auf allen Knoten, IPSET `admin` = Knoten + .151/.29/.173/.188, ACCEPT +admin dann DROP /24 auf 22/8006/3128/5900-5999/60000-60050 – `management` taugt nicht, Proxmox ergänzt es stets um das /24; Totmannschalter im Playbook; Negativtest aus GPU-VM bestanden; BETRIEB §10) („PVE-Config: Firewall auf Datacenter- und Knotenebene einrichten“): IP-Set `admin` (Laptop) und `appsrv08` (.29). Zuerst Allow-Regeln für 8006, 22 und ICMP, **dann** aktivieren. Konsole als Rückfall bereithalten. Prüfung: Weboberfläche vom Laptop ok, von einem anderen Rechner gesperrt.
|
||||||
- [ ] **4.5 Ordnung** („PVE-Config: Ressourcenpools und Tags festlegen“, „PVE-Config: Standard für neue LXC-Container festlegen“): Pools `infra`, `gpu`, `test`. Tags `gpu`, `infra`, `test`. LXC-Standard: unprivilegiert, Debian-13-Vorlage, Nesting nur für Docker.
|
- [x] **4.5 Ordnung** (12.10.: Pools `gpu` = 244/246/248/252 per tofu, `infra` = 100/101 per `pveum` – beim Container würde `pool_id` in tofu ein Ersetzen erzwingen; Tags `knaster`, `gpu`, `legacy`, `infra`, `ops` per tofu/pct. LXC-Standard so umgesetzt: unprivilegiert, Debian 13.6, Nesting nur bei Docker) („PVE-Config: Ressourcenpools und Tags festlegen“, „PVE-Config: Standard für neue LXC-Container festlegen“): Pools `infra`, `gpu`, `test`. Tags `gpu`, `infra`, `test`. LXC-Standard: unprivilegiert, Debian-13-Vorlage, Nesting nur für Docker.
|
||||||
- [ ] **4.6 Zertifikat** („PVE-Config: Web-GUI-Zertifikat festlegen (niedrige Priorität)“): `/etc/pve/pve-root-ca.pem` auf dem Laptop als vertrauenswürdig importieren.
|
- [x] **4.6 Zertifikat** (13.10.: CA nach `it/proxmox/pve-root-ca.pem` kopiert, Knotenzertifikate tragen die IP als SAN; Import am Laptop per `certutil -user -addstore Root …`, BETRIEB §11) („PVE-Config: Web-GUI-Zertifikat festlegen (niedrige Priorität)“): `/etc/pve/pve-root-ca.pem` auf dem Laptop als vertrauenswürdig importieren.
|
||||||
- [ ] **4.7 Update-Routine** („PVE-Config: Update- und Neustart-Routine festlegen (rollierend, Knoten für Knoten)“): Ablauf in `docs/BETRIEB.md` eintragen (Wartungsmodus, ein Knoten nach dem anderen, GPU-VMs vorher sauber stoppen).
|
- [x] **4.7 Update-Routine** (12.10.: `docs/BETRIEB.md` Abschnitt 6, Wartungsmodus getestet) („PVE-Config: Update- und Neustart-Routine festlegen (rollierend, Knoten für Knoten)“): Ablauf in `docs/BETRIEB.md` eintragen (Wartungsmodus, ein Knoten nach dem anderen, GPU-VMs vorher sauber stoppen).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -284,7 +327,8 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
|
|||||||
|
|
||||||
**Wo:** remote, Speicherort vor Ort · **Planner:** „Proxmox: Backup-System überlegen“ (beantwortet PLAN §9, offener Punkt 6)
|
**Wo:** remote, Speicherort vor Ort · **Planner:** „Proxmox: Backup-System überlegen“ (beantwortet PLAN §9, offener Punkt 6)
|
||||||
|
|
||||||
- [ ] Ziel-Speicher entscheiden. Empfehlung: externer Datenträger oder NAS, **nicht** die Toshiba-Platte „BACKUP“ des Nutzers. PBS als VM `pbs` (vmid 110), Datastore auf dem Ziel.
|
- [ ] Ziel-Speicher entscheiden. Empfehlung: externer Datenträger oder NAS, **nicht** die Toshiba-Platte „BACKUP“ des Nutzers.
|
||||||
|
Prüfung 12.10. (nur lesend): Die angeschlossene 4-TB-HDD **ist** die Toshiba „BACKUP“ (USB, exFAT, rund 126 GB belegt), Windows meldet „Full Repair Needed“ und Controller-/Paging-Fehler (Event 11/51). Für den Cluster ausgeschlossen. Vorschlag: eigene 4-TB-CMR-Platte intern per SATA in pve52 oder pve44 (auf allen vier P310/P320 melden ata2–ata4 „link down“, freie Schächte/Kabel vor Ort prüfen). Surface/pve61 scheidet aus (Platte und einzige Netzkarte hängen an derselben USB-Kette). PBS als VM `pbs` (vmid 110), Datastore auf dem Ziel.
|
||||||
- [ ] Jobs: nächtlich Infra-LXC, ops-LXC und GPU-VMs (ohne Modellplatte), Aufbewahrung 7 Tage, 4 Wochen und 6 Monate. Dazu ein wöchentlicher Verify-Job.
|
- [ ] Jobs: nächtlich Infra-LXC, ops-LXC und GPU-VMs (ohne Modellplatte), Aufbewahrung 7 Tage, 4 Wochen und 6 Monate. Dazu ein wöchentlicher Verify-Job.
|
||||||
- [ ] **Restore-Test:** Infra-LXC als Kopie zurückspielen.
|
- [ ] **Restore-Test:** Infra-LXC als Kopie zurückspielen.
|
||||||
Prüfung: Die Kopie startet, `docker ps` zeigt die Dienste.
|
Prüfung: Die Kopie startet, `docker ps` zeigt die Dienste.
|
||||||
@@ -293,12 +337,13 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
|
|||||||
|
|
||||||
**Wo:** remote · **Planner:** „Proxmox: Grafana-Dashboard für den Cluster“, „PVE-Config: Verfügbarkeits-Monitoring und Log-Kontrolle ergänzen“ · **Files:** Modify `deploy/compose/infra/docker-compose.yml`, `deploy/prometheus/prometheus.yml`
|
**Wo:** remote · **Planner:** „Proxmox: Grafana-Dashboard für den Cluster“, „PVE-Config: Verfügbarkeits-Monitoring und Log-Kontrolle ergänzen“ · **Files:** Modify `deploy/compose/infra/docker-compose.yml`, `deploy/prometheus/prometheus.yml`
|
||||||
|
|
||||||
- [ ] `deploy/compose/infra` ohne den Dienst `litellm` (D4). Router zeigt auf `http://192.168.100.29:<LiteLLM-Port>`. Image-Tags pinnen (offener Punkt aus IMPLEMENTATION).
|
- [x] `deploy/compose/infra` ohne den Dienst `litellm` (D4). Router zeigt auf `http://192.168.100.29:<LiteLLM-Port>`. Image-Tags pinnen (offener Punkt aus IMPLEMENTATION). 12.10.: erledigt, alle Images gepinnt; **Port offen** (LiteLLM auf appsrv08 nicht im LAN veröffentlicht, vorläufig `:4000` in `roles/infra-stack/defaults`, Task 6.3).
|
||||||
- [ ] LXC `infra` (vmid 101, Nesting, 4 Kerne, 8 GB, Knoten laut RAM-Budget) mit HA und Replikation (Task 2.2). Compose mit `KNASTER_DRY_RUN=true`.
|
- [x] LXC `infra` (vmid 101, Nesting, 4 Kerne, 8 GB, Knoten laut RAM-Budget) mit HA und Replikation (Task 2.2), 12.10.: tofu, Debian 13 auf `tank` (60 GB), DHCP `.188`, Docker + Compose 2.26 (Rolle `docker-host`).
|
||||||
- [ ] Prometheus-Ziele: `pve-exporter` (Token aus 4.1), `node-exporter` und `nvidia_gpu_exporter` in den GPU-VMs, Router.
|
- [x] Compose-Stack mit `KNASTER_DRY_RUN=true`. 12.10.: Rolle `infra-stack`, `playbooks/monitoring.yml`, idempotent; Geheimnisse nur in `/opt/knaster/infra/.env` (BETRIEB §7).
|
||||||
- [ ] Grafana: Dashboards für Proxmox, Knoten, GPU (Community-JSON) und knaster. Konto `kiosk` mit Rolle Viewer.
|
- [x] Prometheus-Ziele: `pve-exporter` (Token aus 4.1), `node-exporter` und `nvidia_gpu_exporter` in den GPU-VMs, Router. 12.10.: GPU-VMs per file_sd (`monitoring-targets.yml`), Rolle `gpu-exporters`; alle 5 Knoten und 7 GPUs sichtbar.
|
||||||
- [ ] Alertmanager an den Router (Healer, Dry-Run) und an n8n `knaster-notify`. Der Workflow wird inaktiv importiert, dann aktiviert.
|
- [x] Grafana: Dashboards für Proxmox, Knoten, GPU (Community-JSON) und knaster. Konto `kiosk` mit Rolle Viewer. 12.10.: grafana.com 10347, 1860, 14574 und „knaster – Übersicht“; Kiosk-Passwort in `/opt/knaster/infra/.env` (für 5.3).
|
||||||
Prüfung: Grafana zeigt alle 5 Knoten und alle GPUs. Ein Test-Alert (`amtool alert add test`) erscheint im Kanal.
|
- [ ] Alertmanager an den Router (Healer, Dry-Run) und an n8n `knaster-notify`. Der Workflow wird inaktiv importiert, dann aktiviert. 12.10.: **Router erledigt** (Test-Alert kommt mit 200 an), n8n-Receiver nur vorbereitet (auskommentiert, Format passt nicht direkt, braucht den Nutzer).
|
||||||
|
Prüfung: Grafana zeigt alle 5 Knoten und alle GPUs. Ein Test-Alert (`amtool alert add test`) erscheint im Kanal. 12.10.: Knoten/GPUs ja, Test-Alert im Alertmanager und beim Router ja, Kanal (n8n) offen. HA-Probe (Wartungsmodus pve44, zweimal hin und zurück) bestanden, nachdem ein ZFS-Cache-Fehler gefunden und im LXC abgefangen wurde (BETRIEB §7).
|
||||||
|
|
||||||
### Task 5.3: Kiosk auf pve61
|
### Task 5.3: Kiosk auf pve61
|
||||||
|
|
||||||
@@ -314,8 +359,24 @@ Ersetzt GPU-Knoten 3 (Teil Cluster). Mit 5 Stimmen ist **kein QDevice** nötig.
|
|||||||
|
|
||||||
Ersetzt PLAN §8 „1 Data Plane“.
|
Ersetzt PLAN §8 „1 Data Plane“.
|
||||||
|
|
||||||
- [ ] **6.1 GPU-Rollen:** `deploy/compose/gpu-llamacpp` in den GPU-VMs nach der Zuordnung aus Task 3.3, also Embedding und Rerank redundant auf zwei Knoten, Chat für verifier und signature. Prüfung: Canary-Checks grün (`knaster` Canary-Prober).
|
- [x] (15.10.) **6.0 k3s (D6):** Dienst-VM `k3s` (tofu `service_vms`, HA + Replikation über Rolle pve-host), Rollen `k3s-server`, `k3s-agent`, `k3s-apps`, Manifeste `deploy/k8s/`, `playbooks/k3s.yml`. Fünf Knoten Ready, `nvidia.com/gpu` 2/2/2/1, Portainer-Agent läuft (NodePort 30778 von Laptop erreichbar). Umzugstest: NEX-Sprachdienste als Deployments (`playbooks/nex-speech-k3s.yml`), Rundlauf TTS→STT korrekt. BETRIEB §14.
|
||||||
- [ ] **6.2 STT** (nex-STT-Plan Tasks 2–4): `whisper-server` als zusätzlicher Dienst in der GPU-VM des in Task 3.3 gewählten Knotens. Neuer Slot `stt-de` im Katalog. Port 8178 nur von `192.168.100.29` (Firewall der VM bzw. des Gasts). Prüfung: Von appsrv08 liefert `curl -F file=@probe.wav http://<IP>:8178/v1/audio/transcriptions` deutschen Text, von einem anderen Rechner wird der Zugriff abgelehnt.
|
- [x] (15.10.) Portainer CE 2.45.1 im Cluster (Dienst-VM k3s) mit der Datenbank von appsrv08: Rollback BE→CE geprüft, API-Schlüssel gültig (`playbooks/portainer-k3s.yml`, BETRIEB §14). Grund: Business-Lizenz zählt die fünf k3s-Knoten (Angebot ~1000 €), CE ist frei. Offen (Nutzer, Admin-Login nötig): Umgebung „appsrv08“ (Agent `192.168.100.29:9001`, läuft) anlegen und `godel-deployer` freigeben; dann nginx `portainer.mygodel.de` auf `:30779` umstellen, EE stoppen, nex-Admin-Endpoint/URL anpassen (mit SearXNG-Variable im nex-Repo).
|
||||||
|
- [x] (15.10.) Zuteilung angehoben, weil der Cluster nur die Hälfte der Hosts zeigte: GPU-VMs 4→8 vCPU, 12→20 GB; infra 8→4 GB; ZFS-ARC 4→2 GB (neu in Rolle pve-host, `cluster.zfs_arc_max_mb`); tofu `reboot_after_update=false`, VMs einzeln per `qm reboot` (je ~30 s bis Ready). k3s: 34 vCPU, 83 GB. Nutzer-Idee pve61 als Cache: geht nicht, ARC ist Host-lokal; pve61 (8 GB, kein tank) bleibt Quorum/Kiosk, könnte später CPU-Agent werden.
|
||||||
|
- [x] (15.10.) Umzug 3: RustDesk-Server (Stack `rustdesk` auf appsrv08) als Deployment + Service LoadBalancer in k3s (`playbooks/rustdesk-k3s.yml`), gleiches Schlüsselpaar, Ports auf allen Knoten erreichbar. Clients zeigen weiter auf .29; Umstellung erst mit fester Adresse/DNS-Name (Nutzer), dann Installer-Name im Stack `rustdesk-updater` und Stack `rustdesk` auf appsrv08 abschalten.
|
||||||
|
- [x] (15.10.) Umzugstest 2 aus Portainer-Umgebung appsrv08: SearXNG (`nex-searxng`) als Deployment + NodePort 30880 in k3s, Suche liefert Treffer. Cutover: Nutzer hat 15.10. Weg 2 gewählt (SEARXNG_URL im nex-Admin als Stack-Variable konfigurierbar machen, nex-Repo), Rollout nach DHCP-Reservierung der k3s-VM.
|
||||||
|
- [x] (19.10.) Agenten im Cluster (nex-Spec 2026-10-19 D256–D267): Namespace nex mit Brücke zu appsrv08 (Services ohne Selector: router 4100, know 14200, access 4300, grp-mcp-intern 8790), ServiceAccount nex-admin (Role nur Namespace, Token auf ops), Playbook `playbooks/nex-k3s.yml`; nex-admin 0.2.1 auf appsrv08 legt neue Agenten als Deployment/PVC/Secret/NodePort-Service an, Umzug per UI/API, Traefik auf appsrv08 leitet Port-Adressen an NodePort <port>+20000. Offen: Pull-Secret `gitea-registry` (Gitea-Token read:package in /root/.config/knaster/gitea-registry.env, dann Playbook), Umzugsprobe nextest3, feste Adresse der k3s-VM (NEX_K3S_HOST).
|
||||||
|
- [ ] Offen: Umgebung in Portainer (appsrv08) anlegen (Nutzer, 15.10. laut Nutzer erledigt), DHCP-Reservierung für VM 110 (`bc:24:11:a5:d5:52`), llama.cpp (6.1, seit 19.10. in k3s), Monitoring der Pods (Task 6.5), Exporter-Stacks umziehen, Docker danach aus den VMs entfernen.
|
||||||
|
- [ ] **6.1 GPU-Rollen:** ab 15.10. als k3s-Manifeste (D6) statt Compose: `deploy/compose/gpu-llamacpp` in den GPU-VMs nach der Zuordnung aus Task 3.3, also Embedding und Rerank redundant auf zwei Knoten, Chat für verifier und signature. Prüfung: Canary-Checks grün (`knaster` Canary-Prober).
|
||||||
|
- [x] (19.10.) llama.cpp läuft in k3s (`playbooks/gpu-llamacpp-k3s.yml`, Rolle gpu-llamacpp `tasks/k3s.yml`, Vorlage `deploy/k8s/llamacpp/llamacpp.yaml.j2`, Namespace `knaster`): ein Pod je VM, Host-Netz mit den alten Ports (8081/8082 auf gpu-pve44/46, 8080 auf gpu-pve48), Image aus Docker in containerd importiert. Compose-Stack auf gpu-pve48 angehalten (Rückweg: Deployment löschen, `docker compose --project-directory /opt/knaster/gpu-llamacpp start`); `playbooks/gpu-llamacpp.yml` bricht ohne `-e llamacpp_compose=true` ab.
|
||||||
|
- [x] (19.10.) Kartenwahl: Das Device Plugin vergibt nur noch Index 0 (M4000, per Playbook auf allen GPU-VMs geprüft), allocatable `nvidia.com/gpu` 1/1/1/1 statt 2/2/2/1. Die P2000 bekommt der llama.cpp-Pod fest per UUID (`NVIDIA_VISIBLE_DEVICES`); im Pod ist nur sie sichtbar, `nex-stt` lief durch.
|
||||||
|
- [x] (19.10.) Befund VRAM: Embedding mit `-ub 8192` braucht allein 5 GB Rechenpuffer (wächst mit ub × Vokabular 151k), Flash-Attention ändert daran nichts. Mit `-ub 4096`: Embedding + Rerank zusammen 4,4–4,6 von 5 GB auf der P2000, Chat 4,1 GB. Auch im Compose (Rückweg) angepasst.
|
||||||
|
- [x] (19.10.) Prüfung statt Canary (die Canaries fragen über LiteLLM, das kommt mit 6.3): Prometheus `llamacpp` 5/5 `up`; vom LAN (ops) liefern beide Embedding-Instanzen 1024-dim Vektoren (0,06–0,08 s), beide Rerank-Instanzen sortieren richtig, Chat antwortet „OK.“ (0,46 s); 5.467 Tokens in einem Embedding-Aufruf (11,6 s).
|
||||||
|
- [ ] Offen: Canary-Checks grün nach 6.3. Registry und Healer-Regeln führen noch `docker_host`/`container` (Aktion `restart_container` träfe einen Docker-Container, den es nicht mehr gibt); heute folgenlos (Dry-Run, `enabled_actions: []`), vor dem Scharfschalten auf Pod-Neustart umstellen.
|
||||||
|
- [ ] **6.2 STT und TTS für NEX** – Auftrag vom 12.10. mit Abgleich: `docs/auftraege/2026-10-12-sprachmodelle-nex.md` (Erkennung whisper.cpp auf einer M4000, naheliegend `gpu-pve52`; Ausgabe Piper auf CPU, Port 8179; Abnahme und Rückmeldeformat dort). Läuft nach Task 3.2, gern als Subagent. **Blockiert für die Abnahme:** feste IP (D5). Bisheriger Text (nex-STT-Plan Tasks 2–4): `whisper-server` als zusätzlicher Dienst in der GPU-VM des in Task 3.3 gewählten Knotens. Neuer Slot `stt-de` im Katalog. Port 8178 nur von `192.168.100.29` (Firewall der VM bzw. des Gasts). Prüfung: Von appsrv08 liefert `curl -F file=@probe.wav http://<IP>:8178/v1/audio/transcriptions` deutschen Text, von einem anderen Rechner wird der Zugriff abgelehnt.
|
||||||
|
- [x] (12.10.) Umgesetzt in `gpu-pve52`: `nex-stt` (whisper.cpp v1.9.4, CUDA 12.6, sm_52/61, `large-v3-turbo`, Port 8178) und `nex-tts` (Piper 1.8.0, `de_DE-thorsten-high` + `de_DE-kerstin-low`, Port 8179) als `deploy/compose/nex-speech`, Rolle/Playbook `nex-speech`. ufw in der VM (8178/8179 nur von `.29`, SSH aus dem LAN), Proxmox-Firewall bewusst aus.
|
||||||
|
- [x] (12.10.) Tests in der VM und von ops (temporäre Regel, danach entfernt): 10 s → 2,94 s, 60 s → 8,63 s (Median), TTS-TTFB 0,64–0,91 s (mp3/pcm/opus), Autostart nach `qm reboot 252` geprüft, keine Texte in Logs. Rückmeldung im Auftrag.
|
||||||
|
- [x] (19.10.) Feste IP: `192.168.100.174` per DHCP-Reservierung bestätigt (D5, Task 0.2).
|
||||||
|
- [ ] Offen: Abnahme von appsrv08 durch das NEX-Team, Slot `stt-de` (Core kennt kein `kind: stt`), `tts-de` später nach `infra`.
|
||||||
- [ ] **6.3 Lokale Slots in LiteLLM (D4):** `embed-default`, `rerank-default`, `verifier-small` und `signature-extractor` als Modelle im LiteLLM auf appsrv08 eintragen (IP-Literale). Prüfung: `curl http://192.168.100.29:<port>/v1/models` listet die Slots, ein Aufruf je Slot antwortet.
|
- [ ] **6.3 Lokale Slots in LiteLLM (D4):** `embed-default`, `rerank-default`, `verifier-small` und `signature-extractor` als Modelle im LiteLLM auf appsrv08 eintragen (IP-Literale). Prüfung: `curl http://192.168.100.29:<port>/v1/models` listet die Slots, ein Aufruf je Slot antwortet.
|
||||||
- [ ] **6.4 Ada-Worker (D3)** · **Planner:** „LLM-Worker: direkte Inferenz-Schnittstelle für LiteLLM (zusätzlich zum Job-Poll), Tunnel, Registry, Fallback-Kette“
|
- [ ] **6.4 Ada-Worker (D3)** · **Planner:** „LLM-Worker: direkte Inferenz-Schnittstelle für LiteLLM (zusätzlich zum Job-Poll), Tunnel, Registry, Fallback-Kette“
|
||||||
- Offene Schritte der Worker-Registry v2 im Repo `mygodel/nex` abschließen.
|
- Offene Schritte der Worker-Registry v2 im Repo `mygodel/nex` abschließen.
|
||||||
@@ -326,6 +387,24 @@ Ersetzt PLAN §8 „1 Data Plane“.
|
|||||||
- `test_worker_registry_slot_falls_back_to_tier0_when_list_empty`
|
- `test_worker_registry_slot_falls_back_to_tier0_when_list_empty`
|
||||||
- `test_worker_registry_slot_falls_back_when_list_unreachable`
|
- `test_worker_registry_slot_falls_back_when_list_unreachable`
|
||||||
- Prüfung: `make test` PASS. Live tagsüber geht eine tier1-Anfrage an tier0, nachts an GODE-PC-05.
|
- Prüfung: `make test` PASS. Live tagsüber geht eine tier1-Anfrage an tier0, nachts an GODE-PC-05.
|
||||||
|
- [ ] **6.5 Pod-Monitoring (k3s in Grafana)** · **Planner:** „Proxmox: Grafana-Dashboard für den Cluster“ · **Files:** Create `deploy/k8s/monitoring/`, `platform/proxmox/ansible/playbooks/monitoring-k3s.yml`, `deploy/grafana/dashboards/k3s-pods.json`; Modify `deploy/prometheus/prometheus.yml`, `deploy/prometheus/alerts.yml`, `config/healer-rules.yaml`, `platform/proxmox/scripts/monitoring-targets.py`, Rolle `infra-stack` (Token, Ziele), `deploy/compose/infra/docker-compose.yml`, `core/tests/test_artifacts.py`, BETRIEB §7/§14
|
||||||
|
Ausgangslage 19.10.: Grafana 12.4 und Prometheus laufen auf infra mit Dashboards für Proxmox, Knoten, GPU und knaster; für k3s gibt es kein Ziel. Zustand und Speicher der Pods zeigt bisher nur nex-admin über die Metrics-API (metrics-server, nur Momentwerte, keine Grafana-Datenquelle).
|
||||||
|
- [x] (19.10.) kube-state-metrics v2.19.1 im Namespace `kube-system` (`deploy/k8s/monitoring/kube-state-metrics.yaml`, nur die nötigen Ressourcen), Service NodePort 30808.
|
||||||
|
- [x] (19.10.) ServiceAccount `prometheus-infra`, ClusterRole nur `get nodes/metrics`, langlebiges Token-Secret. Token und CA legt `playbooks/monitoring-k3s.yml` auf ops ab (`/root/.config/knaster/k3s-prometheus.token`, `k3s-ca.crt`), die Rolle `infra-stack` kopiert sie nach `/opt/knaster/infra/secrets/k3s/` (nur dieser Ordner in Prometheus eingehängt). Die kubelet-Zertifikate sind von `k3s-server-ca` signiert und tragen die Knoten-IP (geprüft auf gpu-pve52), daher TLS ohne `insecure_skip_verify`.
|
||||||
|
- [x] (19.10.) Ziele per file_sd aus `kubectl get nodes` (statt tofu, so ist die k3s-VM dabei): `monitoring-targets.py --k3s-nodes` schreibt `kubelet.json` (alle Knoten, 10250) und `kube-state-metrics.json` (ein Ziel, NodePort auf der Dienst-VM k3s, sonst erster bereiter Knoten; `instance` fest, damit ein Wechsel keine neuen Reihen erzeugt).
|
||||||
|
- [x] (19.10.) Prometheus-Jobs `kubelet`, `cadvisor`, `kube-state-metrics`. cAdvisor behält nur CPU, Arbeitssatz, RSS, OOM und Netz und verwirft Reihen ohne Pod sowie Pod-Summen/Pause-Container.
|
||||||
|
- [ ] Speicher von Prometheus eine Woche beobachten (infra hat 4 GB).
|
||||||
|
- [x] ufw: Die Rolle `k3s-agent` erlaubt 10250 und 30000–32767 schon seit 15.10. aus dem LAN, keine Änderung nötig.
|
||||||
|
- [x] (19.10.) Dashboard „k3s – Pods“ (`deploy/grafana/dashboards/k3s-pods.json`, eigenes statt Community, passend zu den behaltenen Metriken): Namespace-Auswahl mit Vorgabe `nex`, Kennzahlen, Speicher/CPU je Pod, Tabelle mit Knoten, Neustarts, Speicher gegen Request/Limit, Pods und Speicher je Knoten, PVC-Belegung.
|
||||||
|
- [ ] Dashboard in die Kiosk-Playlist (5.3) aufnehmen, sobald es die Playlist gibt.
|
||||||
|
- [x] (19.10.) Alerts `K3sNodeNotReady`, `K3sPodCrashLooping` (> 10 min), `K3sDeploymentUnavailable` (> 15 min), `K3sContainerMemoryNearLimit` (> 90 %, 10 min); Healer-Regeln nur `notify`.
|
||||||
|
- Prüfung: `make test` PASS (Struktur von prometheus.yml/alerts.yml). Auf infra zeigt `api/v1/targets` die Jobs `kubelet`, `cadvisor`, `kube-state-metrics` mit allen fünf Knoten `up`. Grafana zeigt für `nex` dieselben Pods und ungefähr denselben Speicher wie `kubectl top pods -n nex`. Ein absichtlich kaputtes Test-Deployment löst den CrashLoop-Alert aus.
|
||||||
|
- [x] (19.10.) Prüfung bestanden: alle 11 k3s-Ziele `up`; Speicher je Pod in `nex` gleich `kubectl top` (344/354/122 MiB); ~2000 neue Reihen; Dashboard im Ordner `knaster`. Test-Deployment `knaster-crashtest` (busybox, `exit 1`): `K3sDeploymentUnavailable` feuert nach 15 min; `K3sPodCrashLooping` flackerte zwischen den Neustarts und blieb pending, seit `max_over_time(…[5m])` feuert er nach ~14 min. Test-Deployment gelöscht.
|
||||||
|
- [ ] **6.6 Namespace nex absichern (Pod Security)** · vom Nutzer am 19.10. gewünscht · **Files:** `deploy/k8s/nex-speech/` (Namespace `nex-speech`), `deploy/k8s/nex/` (Label), `playbooks/nex-speech-k3s.yml`, Rolle `nex-speech-k3s`, BETRIEB §14
|
||||||
|
Anlass: nex-admin darf in `nex` Pods anlegen, und ohne Pod-Security-Regel auch mit `privileged`, `hostPath`, `hostNetwork`; ein abgeflossenes Token hieße damit Root auf einer GPU-VM. „baseline“ für `nex` geht erst, wenn die Sprachdienste (Host-Netz, hostPath) dort nicht mehr laufen.
|
||||||
|
- [ ] `nex-stt` und `nex-tts` in einen eigenen Namespace `nex-speech` umziehen (gleiche Host-Ports 8178/8179 auf gpu-pve52, kurze Unterbrechung, vorher mit dem NEX-Team abstimmen).
|
||||||
|
- [ ] Namespace `nex`: `pod-security.kubernetes.io/enforce: baseline` (zuerst `warn`/`audit` und prüfen, ob nex-admin-Agenten und SearXNG passen).
|
||||||
|
- [ ] Prüfung: Als nex-admin wird ein Pod mit `hostPath` oder `privileged` in `nex` abgelehnt; Agenten, SearXNG und die Sprachdienste laufen; Rundlauf TTS→STT von appsrv08.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -353,6 +432,20 @@ Die Software-Phasen aus PLAN §8 bleiben inhaltlich gültig und bekommen jeweils
|
|||||||
- „5 Lifecycle“: QLoRA-Training. Voraussetzung ist die Entscheidung über WSL2 bzw. vLLM auf den Ada-Rechnern nachts (D3, Folgeentscheidung).
|
- „5 Lifecycle“: QLoRA-Training. Voraussetzung ist die Entscheidung über WSL2 bzw. vLLM auf den Ada-Rechnern nachts (D3, Folgeentscheidung).
|
||||||
- „6 Portabilität“
|
- „6 Portabilität“
|
||||||
|
|
||||||
|
### Verwaltung durch mehrere Personen (Nutzerwunsch 19.10.)
|
||||||
|
|
||||||
|
- [x] **Weg 1 (19.10.):** Verwaltung an Personen statt an den Admin-Laptop gebunden. Einstieg ist ops (Bastion):
|
||||||
|
Schlüssel je Person in `sites/knaster/proxmox.yaml` (`access.ops_ssh_keys`, `ops_ssh_keys_revoked`), Playbook
|
||||||
|
`playbooks/admins.yml` (nur hinzufügen bzw. ausdrücklich entziehen), Test `test_ops_admin_keys_in_site_file`.
|
||||||
|
Aufnahme und Entzug in BETRIEB §15. Einstieg für Agenten: `AGENTS.md`, `project-map.md`, `known-issues.md`.
|
||||||
|
- [ ] **Weg 2 (später, Entwurf):** Verwaltung über NEX statt persönlicher Shell. nex-admin bekommt einen Bereich
|
||||||
|
„Infrastruktur“ (Anmeldung, Rollen und Protokoll von NEX). Dahinter auf ops ein kleiner Dienst, der nur freigegebene
|
||||||
|
Aktionen ausführt (bestimmtes Playbook, Dienst neu starten, Zustand zeigen), keine freie Shell. Neue Dinge entstehen
|
||||||
|
weiter im Repo: Ein Agent in NEX schreibt die Änderung als Branch in Gitea, ein Mensch gibt per Merge frei, der Dienst
|
||||||
|
rollt danach aus. Agenten schlagen nur vor (Grundsatz wie beim Healer). Anknüpfen an das Vorschlag-/Freigabe-Verfahren
|
||||||
|
von knaster (Policy „approve“). Offen vor dem Start: Wer verwaltet künftig (interne IT, Dienstleister, mehrere
|
||||||
|
Rollen)? Eigener Plan.
|
||||||
|
|
||||||
Am Ende von Phase 9 wird das Betriebshandbuch abgeschlossen („PVE-Config: Betriebshandbuch und Testvorgehen anlegen“). Das geschieht als Erweiterung von `docs/BETRIEB.md`, nicht als neues Dokument.
|
Am Ende von Phase 9 wird das Betriebshandbuch abgeschlossen („PVE-Config: Betriebshandbuch und Testvorgehen anlegen“). Das geschieht als Erweiterung von `docs/BETRIEB.md`, nicht als neues Dokument.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -377,7 +470,7 @@ Am Ende von Phase 9 wird das Betriebshandbuch abgeschlossen („PVE-Config: Betr
|
|||||||
| Planner-Aufgabe (exakter Titel) | Im Plan | Planner-Aktion nach Freigabe |
|
| Planner-Aufgabe (exakter Titel) | Im Plan | Planner-Aktion nach Freigabe |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Proxmox: pve48 root-Passwort klären und SSH-Schlüssel nachtragen | Task 0.1 | bleibt |
|
| Proxmox: pve48 root-Passwort klären und SSH-Schlüssel nachtragen | Task 0.1 | bleibt |
|
||||||
| PVE-Config: Netzwerk prüfen – Bridge, VLAN-Fähigkeit und NIC-Kompatibilität | Task 0.2 (D5), VLAN später | bleibt, Beschreibung ergänzen |
|
| PVE-Config: Netzwerk prüfen – Bridge, VLAN-Fähigkeit und NIC-Kompatibilität | Task 0.2 (D5), VLAN später | 13.10. geprüft (BETRIEB §12: e1000e/r8152, 1 GBit, kein VLAN-aware, keine Hänger), erledigt; Reservierungen bleiben in 0.2 |
|
||||||
| Proxmox: Konfiguration aller Knoten durcharbeiten | aufgeteilt auf 0.1, 1.1–1.3 | schließen mit Verweis auf diesen Plan |
|
| Proxmox: Konfiguration aller Knoten durcharbeiten | aufgeteilt auf 0.1, 1.1–1.3 | schließen mit Verweis auf diesen Plan |
|
||||||
| PVE-Config: Erst-Update aller Knoten (apt full-upgrade) | Task 1.1 | bleibt |
|
| PVE-Config: Erst-Update aller Knoten (apt full-upgrade) | Task 1.1 | bleibt |
|
||||||
| Proxmox: Speicher aller Knoten richtig konfigurieren | D1, Task 1.2 | bleibt |
|
| Proxmox: Speicher aller Knoten richtig konfigurieren | D1, Task 1.2 | bleibt |
|
||||||
@@ -398,7 +491,7 @@ Am Ende von Phase 9 wird das Betriebshandbuch abgeschlossen („PVE-Config: Betr
|
|||||||
| Proxmox: pve61 (Surface) zeigt das Grafana-Dashboard als Kiosk | Tasks 1.3, 5.3 | bleibt |
|
| Proxmox: pve61 (Surface) zeigt das Grafana-Dashboard als Kiosk | Tasks 1.3, 5.3 | bleibt |
|
||||||
| LLM-Worker: direkte Inferenz-Schnittstelle für LiteLLM (zusätzlich zum Job-Poll), Tunnel, Registry, Fallback-Kette | Task 6.4 | bleibt, Beschreibung: Registry v2 + knaster-Brücke |
|
| LLM-Worker: direkte Inferenz-Schnittstelle für LiteLLM (zusätzlich zum Job-Poll), Tunnel, Registry, Fallback-Kette | Task 6.4 | bleibt, Beschreibung: Registry v2 + knaster-Brücke |
|
||||||
| Proxmox: GRP als Testservice in einen LXC-Container umziehen | Phase 8 | bleibt |
|
| Proxmox: GRP als Testservice in einen LXC-Container umziehen | Phase 8 | bleibt |
|
||||||
| PVE-Config: Betriebshandbuch und Testvorgehen anlegen | Phase 9 (`docs/BETRIEB.md`) | bleibt |
|
| PVE-Config: Betriebshandbuch und Testvorgehen anlegen | Phase 9 (`docs/BETRIEB.md`) | 13.10. erledigt: §8–§13 und `scripts/healthcheck.sh` |
|
||||||
| P1–P8 (NEX-Aufgaben) | Parallelspur | bleiben |
|
| P1–P8 (NEX-Aufgaben) | Parallelspur | bleiben |
|
||||||
|
|
||||||
**Neu angelegt** (nur Schritte ohne bestehende Aufgabe, keine Duplikate):
|
**Neu angelegt** (nur Schritte ohne bestehende Aufgabe, keine Duplikate):
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Bekannte Probleme und Fallen
|
||||||
|
|
||||||
|
Stand 19.10.2026. Je Eintrag: was passiert, Auswirkung, Umgang, wo es weitergeht. Behobene Einträge nicht löschen,
|
||||||
|
sondern unter „Erledigt“ verschieben (mit Datum), damit die Lehre erhalten bleibt. Siehe auch [AGENTS.md](AGENTS.md),
|
||||||
|
[project-map.md](project-map.md).
|
||||||
|
|
||||||
|
## Offen
|
||||||
|
|
||||||
|
### Netz und Zugang
|
||||||
|
|
||||||
|
- **DHCP-Reservierungen unvollständig.** Reserviert sind pve44, pve52, gpu-pve52 und der Laptop. Für infra, pve46 und
|
||||||
|
pve48 geht laut DC keine Reservierung (alle drei sind statisch konfiguriert, Risiko: doppelt vergebene Adresse).
|
||||||
|
Ohne Rückmeldung: ops, k3s-VM (.124), gpu-pve44/46/48, pve61, Gäste-Block `.220–.239` (am 19.10. erneut angefragt).
|
||||||
|
Auswirkung: Ändert sich die Adresse der k3s-VM, brechen Portainer CE (nginx-Ziel), `NEX_K3S_HOST` und die geplante
|
||||||
|
RustDesk-Umstellung. Ändern sich GPU-VM-Adressen: `refresh-vm-access.sh`, dann `playbooks/monitoring-targets.yml`.
|
||||||
|
→ Aufbauplan Task 0.2.
|
||||||
|
- **Firewall sperrt bei Adresswechsel aus.** Die Proxmox-Firewall lässt nur Adressen aus `firewall.management` an
|
||||||
|
22/8006. Rückweg über ops oder Konsole (BETRIEB §10).
|
||||||
|
- **Namespace `nex` ohne Pod Security.** nex-admin darf dort Pods mit `privileged`/`hostPath`/`hostNetwork` anlegen;
|
||||||
|
ein abgeflossenes Token wäre damit Root auf einer GPU-VM. → Task 6.6 (Sprachdienste nach `nex-speech`, dann
|
||||||
|
„baseline“).
|
||||||
|
|
||||||
|
### Monitoring und Healer
|
||||||
|
|
||||||
|
- **`SlotEndpointDown` feuert seit 15.10.** für tier1-flex, tier1-general (Ada-Rechner noch nicht angebunden, 6.4),
|
||||||
|
tts-de, tts-de-hq, embed-default, verifier-small, signature-extractor (Canaries laufen über LiteLLM, das kommt mit 6.3).
|
||||||
|
Erwartet, kein Handlungsbedarf bis 6.3/6.4.
|
||||||
|
- **vLLM-Ziele `gpu-main`/`gpu-flex` down** (DNS-Namen ohne Auflösung, Ada-Rechner nicht angebunden). → Task 6.4.
|
||||||
|
- **Healer kennt nur Docker-Container.** Registry und Healer-Regeln führen `docker_host`/`container`; llama.cpp und
|
||||||
|
die Sprachdienste laufen inzwischen in k3s. `restart_container` würde ins Leere greifen. Heute folgenlos
|
||||||
|
(Dry-Run, `enabled_actions: []`); vor dem Scharfschalten auf Pod-Neustart umstellen. → Task 6.1 (offen), Phase 9.
|
||||||
|
- **n8n-Receiver im Alertmanager nur vorbereitet** (auskommentiert, Format passt nicht). Alerts gehen nur an den
|
||||||
|
Router. → Task 5.2.
|
||||||
|
- **Prometheus-Neustart setzt Alert-Zustände zurück** (alles wieder „pending“). Nach einem Neustart des Stacks die
|
||||||
|
`for:`-Dauer abwarten, bevor man auf fehlende Alerts schließt.
|
||||||
|
- **Speicher von Prometheus auf infra beobachten** (4 GB RAM, seit 19.10. ~2000 Reihen mehr durch k3s). → Task 6.5.
|
||||||
|
- **`K3sPodCrashLooping` bleibt bis zu 5 min länger stehen,** nachdem ein Pod behoben oder gelöscht wurde
|
||||||
|
(`max_over_time[5m]`, nötig gegen Flackern).
|
||||||
|
|
||||||
|
### GPU und Modelle
|
||||||
|
|
||||||
|
- **Embedding auf der P2000 nur mit `-ub 4096`.** Mit 8192 braucht llama.cpp (Stand b6000) allein 5 GB Rechenpuffer
|
||||||
|
(ub × Vokabular), Flash-Attention hilft nicht. Belegung mit Rerank: 4,4–4,6 von 5 GB, wenig Reserve.
|
||||||
|
- **`nvidia.com/gpu` vergibt nur die M4000** (Device Plugin sieht nur Index 0). Wer einen GPU-Pod anlegt, bekommt
|
||||||
|
eine M4000; die P2000 gehört llama.cpp. Annahme: Index 0 ist in jeder GPU-VM die M4000 – das Playbook
|
||||||
|
`gpu-llamacpp-k3s.yml` prüft das vor jeder Änderung.
|
||||||
|
- **LiteLLM-Port auf appsrv08 nicht im LAN veröffentlicht.** Blockiert Task 6.3 (lokale Slots in LiteLLM) und damit
|
||||||
|
grüne Canaries. → Nutzer, Task 6.3.
|
||||||
|
|
||||||
|
### Knoten und Speicher
|
||||||
|
|
||||||
|
- **ZFS-Seiten-Cache nach HA-Umzug.** Nach `zfs receive` liefert ein Knoten für früher gemappte Dateien veralteten
|
||||||
|
Inhalt; Docker brach am 12.10. mit `invalid freelist page` ab. Abgefangen im LXC infra durch
|
||||||
|
`knaster-drop-stale-cache.service`; jeder neue replizierte LXC mit mmap-Nutzern braucht denselben Schutz (BETRIEB §7).
|
||||||
|
- **pve44: EFI-Partition nicht eingehängt** (`sda2` fehlt in `/etc/fstab`); GRUB-Updates erreichen sie nicht.
|
||||||
|
→ Aufbauplan Task 1.1, Nebenbefund.
|
||||||
|
- **pve61: Neustart nach dem Kernel-Update offen.** → Aufbauplan Task 1.1, Step 4.
|
||||||
|
- **Kein Backup.** Proxmox Backup Server und Ziel-Speicher stehen aus. → Task 5.1 (Entscheidung Nutzer).
|
||||||
|
|
||||||
|
### Übergänge (alt und neu laufen parallel)
|
||||||
|
|
||||||
|
- **Portainer:** EE auf appsrv08 läuft weiter, `portainer.mygodel.de` zeigt noch dorthin; CE im Cluster ist bereit.
|
||||||
|
Umstellung braucht Admin-Login und feste Adresse der k3s-VM (BETRIEB §14).
|
||||||
|
- **RustDesk:** Server läuft zusätzlich in k3s, Clients zeigen weiter auf `.29`.
|
||||||
|
- **SearXNG:** läuft in k3s und auf appsrv08; Umstellung der Agenten über `SEARXNG_URL` im nex-Admin.
|
||||||
|
|
||||||
|
### Arbeitsumgebung
|
||||||
|
|
||||||
|
- **Windows-Arbeitskopie mit CRLF:** `yamllint` meldet „wrong new line character“ für geänderte Dateien. Den Stand aus
|
||||||
|
Git prüfen (`git show HEAD:<datei> | yamllint -`), siehe AGENTS.md.
|
||||||
|
- **`test_tts_server` scheitert unter Windows** (kein Piper-Programm). Unter Linux grün.
|
||||||
|
- **`test_ontology.py::test_cli_check_with_snapshot` scheitert unter Windows** (liest ohne UTF-8, cp1252). Mit
|
||||||
|
`PYTHONUTF8=1` grün; unter Linux grün.
|
||||||
|
- **`main` und `plan/aufbau-2026-10-12` laufen auseinander,** wenn auf `main` direkt gearbeitet wird (13.10. passiert,
|
||||||
|
19.10. zusammengeführt). Gitea zeigt `main`, ops arbeitet mit dem Plan-Branch. Nur auf dem Plan-Branch arbeiten
|
||||||
|
oder sofort zusammenführen.
|
||||||
|
- **Kein Python-venv auf ops:** `make test`/`make lint` laufen auf dem Laptop; auf ops nur Ansible-Syntaxprüfung.
|
||||||
|
- **Ansible-Ad-hoc mit Go-Templates** (`docker ps --format '{{.Names}}'`) scheitert, weil Ansible `{{ }}` auswertet.
|
||||||
|
Skript per `-m script` verwenden.
|
||||||
|
- **Mehrere Agenten-Sitzungen im selben Repo:** Eine Sitzung kann fremde, noch nicht committete Änderungen mit
|
||||||
|
committen. Nur eigene Dateien stagen, vor dem Push `git pull --rebase`.
|
||||||
|
|
||||||
|
## Erledigt
|
||||||
|
|
||||||
|
- 19.10.: Embedding/Rerank auf gpu-pve44/46 liefen nicht (Prometheus-Ziele 8081/8082 down) – seit Task 6.1 in k3s.
|
||||||
|
- 19.10.: Laptop wurde bei Lease-Wechsel von der Firewall ausgesperrt (14.10. passiert) – WLAN und Dock reserviert.
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
[defaults]
|
[defaults]
|
||||||
inventory = inventory.yml
|
inventory = inventory.yml,inventory.generated.yml
|
||||||
roles_path = roles
|
roles_path = roles
|
||||||
host_key_checking = True
|
host_key_checking = True
|
||||||
stdout_callback = yaml
|
callback_result_format = yaml
|
||||||
|
|
||||||
|
[inventory]
|
||||||
|
unparsed_is_failed = False
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Hosts und IPs aus sites/knaster/proxmox.yaml (Aufbauplan Task 0.4). Ansible läuft im ops-LXC (Task 3.1).
|
# Knoten und Gäste von knaster (Stand 12.10.2026). Läuft auf dem Steuerrechner ops.
|
||||||
# Gast-IPs: ANNAHME bis Task 0.2 (DHCP-Ausschluss .220–.239).
|
# Gäste bekommen ihre IP per DHCP (D5 zurückgestellt): ansible_host der GPU-VMs und von infra
|
||||||
|
# kommt aus inventory.generated.yml (`tofu output -raw ansible_inventory`, nicht im Repo).
|
||||||
all:
|
all:
|
||||||
vars:
|
vars:
|
||||||
knaster_site: knaster
|
knaster_site: knaster
|
||||||
@@ -12,29 +13,43 @@ all:
|
|||||||
pve46: {ansible_host: 192.168.100.209}
|
pve46: {ansible_host: 192.168.100.209}
|
||||||
pve48: {ansible_host: 192.168.100.212}
|
pve48: {ansible_host: 192.168.100.212}
|
||||||
pve52: {ansible_host: 192.168.100.207}
|
pve52: {ansible_host: 192.168.100.207}
|
||||||
pve_kiosk: # keine VFIO-/NVIDIA-Sperre, iGPU bleibt beim Host (Kiosk)
|
pve_kiosk:
|
||||||
hosts:
|
hosts:
|
||||||
pve61: {ansible_host: 192.168.100.214}
|
pve61: {ansible_host: 192.168.100.214}
|
||||||
vars:
|
vars:
|
||||||
ansible_user: root
|
ansible_user: root
|
||||||
gpu_vms:
|
gpu_vms:
|
||||||
hosts:
|
hosts: # k3s_node_labels: Knoten-Labels für die Platzierung von GPU-Pods (D6)
|
||||||
gpu-pve44: {ansible_host: 192.168.100.224}
|
gpu-pve44: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1, knaster.gpu.p2000=1]}
|
||||||
gpu-pve46: {ansible_host: 192.168.100.226}
|
gpu-pve46: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1, knaster.gpu.p2000=1]}
|
||||||
gpu-pve48: {ansible_host: 192.168.100.228}
|
gpu-pve48: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1, knaster.gpu.p2000=1]}
|
||||||
gpu-pve52: {ansible_host: 192.168.100.232}
|
gpu-pve52: {driver_generation: legacy, k3s_node_labels: [knaster.gpu=true, knaster.gpu.m4000=1]}
|
||||||
vars:
|
vars:
|
||||||
ansible_user: knaster
|
ansible_user: knaster
|
||||||
ansible_become: true
|
ansible_become: true
|
||||||
driver_generation: legacy # R580, Maxwell/Pascal
|
ansible_python_interpreter: /usr/bin/python3
|
||||||
knaster_role: gpu-llamacpp
|
# llama.cpp-Profile je GPU-VM (Task 6.1, Rolle gpu-llamacpp). Liefert auch die Prometheus-Ziele (file_sd).
|
||||||
|
gpu_llamacpp:
|
||||||
|
hosts:
|
||||||
|
gpu-pve44: {llamacpp_profiles: [embed]}
|
||||||
|
gpu-pve46: {llamacpp_profiles: [embed]}
|
||||||
|
gpu-pve48: {llamacpp_profiles: [chat]}
|
||||||
infra:
|
infra:
|
||||||
hosts:
|
hosts:
|
||||||
infra: {ansible_host: 192.168.100.221}
|
infra: {}
|
||||||
vars:
|
vars:
|
||||||
ansible_user: root
|
ansible_user: root
|
||||||
ops:
|
# Dienst-VMs ohne GPU (D6): k3s-Steuerung. Adresse aus inventory.generated.yml (Gruppe service_vms).
|
||||||
|
service_vms:
|
||||||
hosts:
|
hosts:
|
||||||
ops: {ansible_host: 192.168.100.220}
|
k3s: {}
|
||||||
vars:
|
vars:
|
||||||
ansible_user: root
|
ansible_user: knaster
|
||||||
|
ansible_become: true
|
||||||
|
ansible_python_interpreter: /usr/bin/python3
|
||||||
|
k3s_server:
|
||||||
|
hosts:
|
||||||
|
k3s: {}
|
||||||
|
k3s_agents:
|
||||||
|
children:
|
||||||
|
gpu_vms: {}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Verwaltung durch Personen statt durch ein Gerät („Weg 1“, 19.10.): SSH-Schlüssel der Admins auf ops (Bastion).
|
||||||
|
# Quelle: sites/<site>/proxmox.yaml, access.ops_ssh_keys und access.ops_ssh_keys_revoked. Läuft auf ops:
|
||||||
|
# ansible-playbook playbooks/admins.yml
|
||||||
|
# Fügt nur hinzu bzw. entfernt die ausdrücklich entzogenen Schlüssel; fremde Einträge (z. B. Notfallschlüssel) bleiben.
|
||||||
|
# Proxmox-Weboberfläche: access.admin_users + playbooks/cluster.yml + scripts/pve-admin-user.sh, Gerät in
|
||||||
|
# firewall.management (BETRIEB §8, §10, §15).
|
||||||
|
- name: ops – SSH-Schlüssel der Admins
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
site_file: "{{ playbook_dir }}/../../../../sites/{{ knaster_site | default('knaster') }}/proxmox.yaml"
|
||||||
|
access: "{{ (lookup('file', site_file) | from_yaml).access }}"
|
||||||
|
admin_keys: "{{ access.ops_ssh_keys | default([]) }}"
|
||||||
|
revoked_keys: "{{ access.ops_ssh_keys_revoked | default([]) }}"
|
||||||
|
tasks:
|
||||||
|
- name: Liste prüfen (mindestens ein Schlüssel, Namen eindeutig, kein Schlüssel zugleich entzogen)
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- admin_keys | length > 0
|
||||||
|
- admin_keys | map(attribute='name') | unique | length == admin_keys | length
|
||||||
|
- admin_keys | map(attribute='key') | select('match', '^ssh-(ed25519|rsa) ') | list | length == admin_keys | length
|
||||||
|
- (admin_keys | map(attribute='key') | map('split', ' ') | map(attribute=1) | list)
|
||||||
|
| intersect(revoked_keys | map(attribute='key') | map('split', ' ') | map(attribute=1) | list) | length == 0
|
||||||
|
fail_msg: "access.ops_ssh_keys in {{ site_file }} prüfen"
|
||||||
|
|
||||||
|
- name: Schlüssel eintragen
|
||||||
|
ansible.posix.authorized_key:
|
||||||
|
user: root
|
||||||
|
key: "{{ item.key }}"
|
||||||
|
state: present
|
||||||
|
loop: "{{ admin_keys }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}"
|
||||||
|
|
||||||
|
- name: Entzogene Schlüssel entfernen
|
||||||
|
ansible.posix.authorized_key:
|
||||||
|
user: root
|
||||||
|
key: "{{ item.key }}"
|
||||||
|
state: absent
|
||||||
|
loop: "{{ revoked_keys }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}"
|
||||||
|
|
||||||
|
- name: Schlüssel auf ops (Fingerabdrücke)
|
||||||
|
ansible.builtin.command: ssh-keygen -lf /root/.ssh/authorized_keys
|
||||||
|
register: fingerprints
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Ergebnis
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ fingerprints.stdout_lines }}"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Clusterweite Grundkonfiguration (Phase 4): Admin-Gruppe und -Konten, Healer-Rolle/-Token,
|
||||||
|
# Benachrichtigungen, Firewall mit IPSET management. Läuft auf einem Knoten (Einstellungen in /etc/pve).
|
||||||
|
# Vorher: Laptop-, ops- und infra-Adresse in sites/knaster/proxmox.yaml (firewall.management) prüfen.
|
||||||
|
- name: Cluster-Grundkonfiguration
|
||||||
|
hosts: "{{ pve_cluster_node | default('pve44') }}"
|
||||||
|
roles: [pve-cluster]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# llama.cpp-Rollen nach k3s (Task 6.1, D6). Läuft auf ops (Verzeichnis platform/proxmox/ansible):
|
||||||
|
# ansible-playbook playbooks/gpu-llamacpp-k3s.yml # alle VMs der Gruppe gpu_llamacpp
|
||||||
|
# ansible-playbook playbooks/gpu-llamacpp-k3s.yml -e llamacpp_k3s_hosts=gpu-pve44 # nur eine VM (Pilot)
|
||||||
|
# Danach: ansible-playbook playbooks/monitoring-targets.yml (Ziele bleiben gleich, Ports wie im Compose).
|
||||||
|
#
|
||||||
|
# Kartenwahl: Das Device Plugin verwaltet nur Index 0 (M4000, in allen GPU-VMs); die P2000 bekommen die
|
||||||
|
# llama.cpp-Pods fest per UUID. Erst prüfen, dass Index 0 überall die M4000 ist, dann das Device Plugin anwenden.
|
||||||
|
- name: GPU-VMs – Index 0 ist die M4000
|
||||||
|
hosts: gpu_vms
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Karte auf Index 0 lesen
|
||||||
|
ansible.builtin.command: nvidia-smi -i 0 --query-gpu=name --format=csv,noheader
|
||||||
|
register: gpu0
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Index 0 muss die M4000 sein (sonst gibt das Device Plugin die falsche Karte frei)
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: gpu0.stdout == "Quadro M4000"
|
||||||
|
fail_msg: "{{ inventory_hostname }}: Index 0 ist {{ gpu0.stdout }}"
|
||||||
|
|
||||||
|
- name: k3s – Device Plugin nur für die M4000
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Device Plugin anwenden
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ playbook_dir }}/../../../../deploy/k8s/nvidia-device-plugin.yaml
|
||||||
|
register: dp
|
||||||
|
changed_when: "'configured' in dp.stdout"
|
||||||
|
|
||||||
|
- name: Warten, bis das Device Plugin auf allen Knoten läuft
|
||||||
|
ansible.builtin.command: kubectl -n kube-system rollout status daemonset/nvidia-device-plugin-daemonset --timeout=180s
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Jede GPU-VM meldet genau eine Karte (M4000)
|
||||||
|
ansible.builtin.command: kubectl get node {{ item }} -o jsonpath='{.status.allocatable.nvidia\.com/gpu}'
|
||||||
|
loop: "{{ groups['gpu_vms'] }}"
|
||||||
|
register: alloc
|
||||||
|
until: alloc.stdout == "1"
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: llama.cpp-Server in k3s
|
||||||
|
hosts: "{{ llamacpp_k3s_hosts | default('gpu_llamacpp') }}"
|
||||||
|
serial: 1
|
||||||
|
tasks:
|
||||||
|
- name: Rolle gpu-llamacpp (k3s)
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: gpu-llamacpp
|
||||||
|
tasks_from: k3s
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# llama.cpp-Rollen (Task 6.1): Embedding + Rerank in gpu-pve44 und gpu-pve46, Chat (verifier-small,
|
||||||
|
# signature-extractor) in gpu-pve48. Zuordnung: Inventar, Gruppe gpu_llamacpp (gpu-pve52 gehört nicht dazu).
|
||||||
|
# Aufruf auf ops (Verzeichnis platform/proxmox/ansible), vorher Zugang auffrischen (refresh-vm-access.sh):
|
||||||
|
# ansible-playbook playbooks/gpu-llamacpp.yml
|
||||||
|
# ansible-playbook playbooks/monitoring-targets.yml # Prometheus-Ziele (file_sd) nachziehen
|
||||||
|
#
|
||||||
|
# Seit 19.10. laufen die Server in k3s (playbooks/gpu-llamacpp-k3s.yml). Dieses Playbook würde den Compose-Stack
|
||||||
|
# wieder starten und Host-Ports und VRAM doppelt belegen. Nur mit -e llamacpp_compose=true (Rückweg).
|
||||||
|
- name: llama.cpp-Server in den GPU-VMs
|
||||||
|
hosts: gpu_llamacpp
|
||||||
|
pre_tasks:
|
||||||
|
- name: Abbruch, wenn llama.cpp in k3s läuft
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: "llama.cpp läuft in k3s (playbooks/gpu-llamacpp-k3s.yml). Compose nur mit -e llamacpp_compose=true."
|
||||||
|
when: not (llamacpp_compose | default(false) | bool)
|
||||||
|
roles: [gpu-llamacpp]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# k3s-Cluster (D6): Server auf der Dienst-VM "k3s", Agents in den GPU-VMs, danach Runtime, Device-Plugin,
|
||||||
|
# Portainer-Agent. Aufruf auf ops (vorher bash ../scripts/refresh-vm-access.sh, Adressen per DHCP):
|
||||||
|
# ansible-playbook playbooks/k3s.yml
|
||||||
|
- name: k3s-Server
|
||||||
|
hosts: k3s_server
|
||||||
|
pre_tasks:
|
||||||
|
- name: DHCP-Client-ID auf MAC umstellen (Reservierung am DC, wie bei den GPU-VMs)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/netplan/99-knaster-dhcp-id.yaml
|
||||||
|
content: |
|
||||||
|
network:
|
||||||
|
version: 2
|
||||||
|
ethernets:
|
||||||
|
eth0:
|
||||||
|
dhcp-identifier: mac
|
||||||
|
mode: "0600"
|
||||||
|
roles: [k3s-server]
|
||||||
|
|
||||||
|
- name: k3s-Agents (GPU-VMs)
|
||||||
|
hosts: k3s_agents
|
||||||
|
roles: [k3s-agent]
|
||||||
|
|
||||||
|
- name: Grundausstattung im Cluster (von ops)
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
roles: [k3s-apps]
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Pod-Monitoring (Task 6.5): kube-state-metrics und Lesezugang für Prometheus in k3s, danach der Infra-Stack
|
||||||
|
# (Prometheus-Jobs kubelet/cadvisor/kube-state-metrics, file_sd-Ziele, Dashboard „k3s – Pods“). Läuft auf ops:
|
||||||
|
# ansible-playbook playbooks/monitoring-k3s.yml
|
||||||
|
# Token und CA landen auf ops unter /root/.config/knaster (0600), die Rolle infra-stack kopiert sie nach infra.
|
||||||
|
- name: k3s – kube-state-metrics und Zugang für Prometheus
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
manifests: "{{ playbook_dir }}/../../../../deploy/k8s/monitoring"
|
||||||
|
token_file: /root/.config/knaster/k3s-prometheus.token
|
||||||
|
ca_file: /root/.config/knaster/k3s-ca.crt
|
||||||
|
tasks:
|
||||||
|
- name: Manifeste anwenden
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ manifests }}/{{ item }}
|
||||||
|
loop: [prometheus-access.yaml, kube-state-metrics.yaml]
|
||||||
|
register: apply
|
||||||
|
changed_when: "'configured' in apply.stdout or 'created' in apply.stdout"
|
||||||
|
|
||||||
|
- name: Warten, bis kube-state-metrics bereit ist
|
||||||
|
ansible.builtin.command: kubectl -n kube-system rollout status deployment/kube-state-metrics --timeout=180s
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Token des ServiceAccounts prometheus-infra lesen (Secret wird vom Controller befüllt)
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
set -o pipefail;
|
||||||
|
kubectl -n kube-system get secret prometheus-infra-token -o go-template='{{ "{{" }}.data.token{{ "}}" }}' | base64 -d
|
||||||
|
args: {executable: /bin/bash}
|
||||||
|
register: token
|
||||||
|
until: token.stdout | length > 0
|
||||||
|
retries: 10
|
||||||
|
delay: 2
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Token als Datei auf ops ablegen (600, nie ausgeben)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ token.stdout }}"
|
||||||
|
dest: "{{ token_file }}"
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
# Die kubelet-Zertifikate sind von k3s-server-ca signiert und tragen die Knoten-IP (19.10. geprüft).
|
||||||
|
- name: CA des Clusters lesen
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
set -o pipefail;
|
||||||
|
kubectl -n kube-system get secret prometheus-infra-token -o go-template='{{ "{{" }}index .data "ca.crt"{{ "}}" }}' | base64 -d
|
||||||
|
args: {executable: /bin/bash}
|
||||||
|
register: ca
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: CA als Datei auf ops ablegen
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ ca.stdout }}\n"
|
||||||
|
dest: "{{ ca_file }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Zugang prüfen (cAdvisor eines Knotens mit Token und CA)
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
set -o pipefail;
|
||||||
|
ip=$(kubectl get node k3s -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}');
|
||||||
|
curl -fsS -m 10 --cacert {{ ca_file }} -H "Authorization: Bearer $(cat {{ token_file }})"
|
||||||
|
https://$ip:10250/metrics/cadvisor | grep -c '^container_memory_working_set_bytes'
|
||||||
|
args: {executable: /bin/bash}
|
||||||
|
register: probe
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Ergebnis
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "cAdvisor auf k3s erreichbar, {{ probe.stdout }} Reihen container_memory_working_set_bytes"
|
||||||
|
|
||||||
|
- name: Infra-LXC – Prometheus, Ziele und Dashboard
|
||||||
|
hosts: infra
|
||||||
|
roles: [infra-stack]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# file_sd-Ziele der GPU-VMs auffrischen, wenn sich DHCP-Adressen geändert haben. Auf ops:
|
||||||
|
# cd /root/cluster-knaster/platform/proxmox/tofu && . /root/.config/knaster/tofu.env && bash ../scripts/refresh-vm-access.sh
|
||||||
|
# cd ../ansible && ansible-playbook playbooks/monitoring-targets.yml
|
||||||
|
- name: Infra-LXC – file_sd-Ziele
|
||||||
|
hosts: infra
|
||||||
|
tasks:
|
||||||
|
- name: Ziele erzeugen und kopieren
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: infra-stack
|
||||||
|
tasks_from: targets
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Nur Monitoring (Task 5.2), ohne Treiber-/Docker-Rollen der GPU-VMs (kein Docker-Neustart, kein Reboot).
|
||||||
|
# Aufruf auf ops (Verzeichnis platform/proxmox/ansible), vorher Adressen auffrischen (refresh-vm-access.sh):
|
||||||
|
# ansible-playbook playbooks/monitoring.yml
|
||||||
|
- name: GPU-VMs – Exporter (node_exporter, nvidia_gpu_exporter)
|
||||||
|
hosts: gpu_vms
|
||||||
|
roles: [gpu-exporters]
|
||||||
|
|
||||||
|
- name: Infra-LXC – Compose-Stack mit Monitoring
|
||||||
|
hosts: infra
|
||||||
|
roles: [infra-stack]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Namespace nex für Agenten im Cluster (nex-Spec 2026-10-19 Cluster-Agenten): Brücke zu appsrv08 (D259), Zugang für
|
||||||
|
# nex-admin (D265), Pull-Secret für die Gitea-Registry (D263). Läuft auf ops: ansible-playbook playbooks/nex-k3s.yml
|
||||||
|
#
|
||||||
|
# Voraussetzung für das Pull-Secret: /root/.config/knaster/gitea-registry.env mit REGISTRY_USER=… und
|
||||||
|
# REGISTRY_TOKEN=… (Gitea-Token mit read:package). Fehlt die Datei, wird dieser Schritt übersprungen.
|
||||||
|
# Ausgabe: Token-Datei für nex-admin (nur Pfad), CA des Clusters als base64 (für NEX_ADMIN_K8S_CA_B64) und API-Adresse.
|
||||||
|
- name: Namespace nex für Cluster-Agenten
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
manifests: "{{ playbook_dir }}/../../../../deploy/k8s/nex"
|
||||||
|
registry_env: /root/.config/knaster/gitea-registry.env
|
||||||
|
token_file: /root/.config/knaster/nex-admin-k8s.token
|
||||||
|
api_url: "https://{{ hostvars['k3s'].ansible_host }}:6443"
|
||||||
|
tasks:
|
||||||
|
- name: Namespace nex
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ playbook_dir }}/../../../../deploy/k8s/nex-speech/namespace.yaml
|
||||||
|
register: ns
|
||||||
|
changed_when: "'created' in ns.stdout"
|
||||||
|
|
||||||
|
- name: Brücke zu appsrv08 (Services ohne Selector + Endpoints)
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ manifests }}/bridge.yaml
|
||||||
|
register: bridge
|
||||||
|
changed_when: "'configured' in bridge.stdout or 'created' in bridge.stdout"
|
||||||
|
|
||||||
|
- name: Zugang für nex-admin (ServiceAccount, Role, RoleBinding, Token-Secret)
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ manifests }}/admin-access.yaml
|
||||||
|
register: access
|
||||||
|
changed_when: "'configured' in access.stdout or 'created' in access.stdout"
|
||||||
|
|
||||||
|
- name: Token des ServiceAccounts lesen (Secret wird vom Controller befüllt)
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
set -o pipefail;
|
||||||
|
kubectl -n nex get secret nex-admin-token -o go-template='{{ "{{" }}.data.token{{ "}}" }}' | base64 -d
|
||||||
|
args: {executable: /bin/bash}
|
||||||
|
register: token
|
||||||
|
until: token.stdout | length > 0
|
||||||
|
retries: 10
|
||||||
|
delay: 2
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Token als Datei auf ops ablegen (600, nie ausgeben)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ token.stdout }}"
|
||||||
|
dest: "{{ token_file }}"
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Registry-Zugang vorhanden?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ registry_env }}"
|
||||||
|
register: reg
|
||||||
|
|
||||||
|
- name: Pull-Secret gitea-registry (aus {{ registry_env }})
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
set -o pipefail; set -a; . {{ registry_env }}; set +a;
|
||||||
|
kubectl -n nex create secret docker-registry gitea-registry --docker-server=gitea.mindboost.team
|
||||||
|
--docker-username="$REGISTRY_USER" --docker-password="$REGISTRY_TOKEN" --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
args: {executable: /bin/bash}
|
||||||
|
register: pull
|
||||||
|
changed_when: "'configured' in pull.stdout or 'created' in pull.stdout"
|
||||||
|
when: reg.stat.exists
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: CA des Clusters (base64, eine Zeile)
|
||||||
|
ansible.builtin.command: kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}'
|
||||||
|
register: ca
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Brücke prüfen (Pod im Namespace nex löst die Namen auf)
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
kubectl -n nex run bridge-check --rm -i --restart=Never --image=alpine:3.20 --
|
||||||
|
sh -c 'for h in nex-router nex-know nex-access grp-mcp-intern; do getent hosts $h >/dev/null && echo "$h dns ok" || echo "$h dns FEHLT"; done' 2>/dev/null
|
||||||
|
register: check
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Ergebnis
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg:
|
||||||
|
- "Token für nex-admin: {{ token_file }} (NEX_ADMIN_K8S_TOKEN)"
|
||||||
|
- "NEX_ADMIN_K8S_URL={{ api_url }}"
|
||||||
|
- "NEX_ADMIN_K8S_CA_B64={{ ca.stdout }}"
|
||||||
|
- "Pull-Secret gitea-registry: {{ 'angelegt/aktuell' if reg.stat.exists else 'übersprungen (' + registry_env + ' fehlt)' }}"
|
||||||
|
- "{{ check.stdout_lines }}"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# NEX-Websuche (SearXNG) in k3s (D6-Umzugstest). Läuft auf ops: ansible-playbook playbooks/nex-searxng-k3s.yml
|
||||||
|
# Secret SEARXNG_SECRET wird einmal erzeugt und bleibt im Cluster (nicht im Repo, nicht auf ops).
|
||||||
|
- name: SearXNG nach k3s
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
manifests: "{{ playbook_dir }}/../../../../deploy/k8s/nex-searxng"
|
||||||
|
tasks:
|
||||||
|
- name: Namespace nex
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ playbook_dir }}/../../../../deploy/k8s/nex-speech/namespace.yaml
|
||||||
|
register: ns
|
||||||
|
changed_when: "'created' in ns.stdout"
|
||||||
|
|
||||||
|
- name: Secret vorhanden?
|
||||||
|
ansible.builtin.command: kubectl -n nex get secret nex-searxng
|
||||||
|
register: secret
|
||||||
|
failed_when: false
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Secret anlegen (zufällig, 64 Hex-Zeichen)
|
||||||
|
ansible.builtin.shell: kubectl -n nex create secret generic nex-searxng --from-literal=SEARXNG_SECRET=$(openssl rand -hex 32)
|
||||||
|
when: secret.rc != 0
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: ConfigMap aus settings.yml
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
kubectl -n nex create configmap nex-searxng-settings --from-file=settings.yml={{ manifests }}/settings.yml
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
register: cm
|
||||||
|
changed_when: "'configured' in cm.stdout or 'created' in cm.stdout"
|
||||||
|
|
||||||
|
- name: Deployment und Service
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ manifests }}/deployment.yaml
|
||||||
|
register: dep
|
||||||
|
changed_when: "'configured' in dep.stdout or 'created' in dep.stdout"
|
||||||
|
|
||||||
|
- name: Neustart, wenn sich die Einstellungen geändert haben
|
||||||
|
ansible.builtin.command: kubectl -n nex rollout restart deployment/nex-searxng
|
||||||
|
when: cm.changed and not dep.changed
|
||||||
|
|
||||||
|
- name: Warten, bis bereit
|
||||||
|
ansible.builtin.command: kubectl -n nex rollout status deployment/nex-searxng --timeout=300s
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Suchtest über NodePort (Knoten k3s)
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://{{ hostvars['k3s'].ansible_host }}:30880/search?q=Proxmox&format=json"
|
||||||
|
return_content: true
|
||||||
|
register: probe
|
||||||
|
until: probe.status == 200 and (probe.json.results | length) > 0
|
||||||
|
retries: 6
|
||||||
|
delay: 5
|
||||||
|
|
||||||
|
- name: Ergebnis
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ probe.json.results | length }} Treffer, erster: {{ probe.json.results[0].url }}"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# NEX-Sprachdienste von Docker Compose nach k3s umziehen (Test für D6). Rückweg: Deployments löschen
|
||||||
|
# (kubectl -n nex delete deploy nex-stt nex-tts) und auf gpu-pve52 `docker compose -f /opt/nex-speech start`.
|
||||||
|
- name: NEX-Sprachdienste nach k3s
|
||||||
|
hosts: "{{ nex_speech_hosts | default('gpu-pve52') }}"
|
||||||
|
roles: [nex-speech-k3s]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# NEX-Sprachdienste (Task 6.2): Erkennung (whisper.cpp, M4000) und Ausgabe (Piper, CPU) in gpu-pve52.
|
||||||
|
# Aufruf auf ops: ansible-playbook playbooks/nex-speech.yml
|
||||||
|
# Vorher Zugang auffrischen (DHCP): bash ../scripts/refresh-vm-access.sh
|
||||||
|
# Seit 15.10.2026 laufen die Dienste in k3s (playbooks/nex-speech-k3s.yml, D6). Dieses Playbook würde
|
||||||
|
# den Compose-Stack wieder starten und die Host-Ports 8178/8179 belegen. Nur mit -e nex_speech_compose=true.
|
||||||
|
- name: NEX-Sprachdienste (Docker Compose, abgelöst)
|
||||||
|
hosts: "{{ nex_speech_hosts | default('gpu-pve52') }}"
|
||||||
|
pre_tasks:
|
||||||
|
- name: Abgelöst durch k3s
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: "Die NEX-Sprachdienste laufen in k3s (playbooks/nex-speech-k3s.yml). Compose nur mit -e nex_speech_compose=true."
|
||||||
|
when: not (nex_speech_compose | default(false) | bool)
|
||||||
|
roles: [nex-speech]
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Portainer CE in k3s mit der Datenbank von appsrv08 (Portainer EE 2.45.1). Läuft auf ops:
|
||||||
|
# ansible-playbook playbooks/portainer-k3s.yml
|
||||||
|
#
|
||||||
|
# Voraussetzung: Sicherung des Docker-Volumes portainer_data von appsrv08 als /root/.config/knaster/portainer_data.tgz
|
||||||
|
# (Inhalt: data/portainer.db, data/portainer.key, …; erzeugt mit
|
||||||
|
# docker run --rm -v portainer_data:/data:ro alpine tar -C / -czf - data > portainer_data.tgz ).
|
||||||
|
# Beim ersten Lauf wird das Volume befüllt und die Datenbank mit dem offiziellen Werkzeug der EE
|
||||||
|
# (`--rollback-to-ce`) auf Community Edition zurückgesetzt; danach bleibt der Datenstand im Cluster.
|
||||||
|
# Benutzer, API-Schlüssel und Umgebungen bleiben erhalten (getestet 15.10.2026). Die Umgebung 4 (Docker-Socket
|
||||||
|
# von appsrv08) funktioniert im Cluster nicht mehr; appsrv08 wird als Agent-Umgebung neu angebunden.
|
||||||
|
- name: Portainer CE nach k3s
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
manifest: "{{ playbook_dir }}/../../../../deploy/k8s/portainer/portainer.yaml"
|
||||||
|
backup: /root/.config/knaster/portainer_data.tgz
|
||||||
|
ns: portainer-ce
|
||||||
|
version: 2.45.1
|
||||||
|
tasks:
|
||||||
|
- name: Namespace, PVC, RBAC (noch ohne Deployment, damit das Volume zuerst befüllt wird)
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
set -o pipefail;
|
||||||
|
python3 -c "import sys,yaml; docs=[d for d in yaml.safe_load_all(open('{{ manifest }}')) if d and d['kind']!='Deployment' and d['kind']!='Service']; yaml.safe_dump_all(docs, sys.stdout)"
|
||||||
|
| kubectl apply -f -
|
||||||
|
args: {executable: /bin/bash}
|
||||||
|
register: base
|
||||||
|
changed_when: "'created' in base.stdout"
|
||||||
|
|
||||||
|
- name: Ist das Volume schon befüllt?
|
||||||
|
ansible.builtin.command: kubectl -n {{ ns }} get configmap portainer-seeded
|
||||||
|
register: seeded
|
||||||
|
failed_when: false
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Sicherung vorhanden?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ backup }}"
|
||||||
|
register: bk
|
||||||
|
when: seeded.rc != 0
|
||||||
|
failed_when: not bk.stat.exists
|
||||||
|
|
||||||
|
- name: Volume aus der Sicherung befüllen (Hilfs-Pod auf dem Knoten k3s)
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -e
|
||||||
|
kubectl -n {{ ns }} delete pod seed --ignore-not-found >/dev/null
|
||||||
|
cat <<EOF | kubectl apply -f - >/dev/null
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata: {name: seed, namespace: {{ ns }}}
|
||||||
|
spec:
|
||||||
|
nodeSelector: {knaster.role: control}
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: seed
|
||||||
|
image: alpine:3.20
|
||||||
|
command: ["sleep", "600"]
|
||||||
|
volumeMounts: [{name: data, mountPath: /data}]
|
||||||
|
volumes: [{name: data, persistentVolumeClaim: {claimName: portainer-data}}]
|
||||||
|
EOF
|
||||||
|
kubectl -n {{ ns }} wait --for=condition=Ready pod/seed --timeout=180s >/dev/null
|
||||||
|
kubectl -n {{ ns }} cp {{ backup }} seed:/tmp/pd.tgz
|
||||||
|
kubectl -n {{ ns }} exec seed -- sh -c "cd /tmp && tar xzf pd.tgz && cp -a data/. /data/ && test -s /data/portainer.db"
|
||||||
|
kubectl -n {{ ns }} delete pod seed --wait=true >/dev/null
|
||||||
|
when: seeded.rc != 0
|
||||||
|
|
||||||
|
- name: Datenbank auf Community Edition zurücksetzen (offizielles Werkzeug der EE, fragt per stdin)
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -e
|
||||||
|
kubectl -n {{ ns }} delete pod rollback --ignore-not-found >/dev/null
|
||||||
|
echo y | kubectl -n {{ ns }} run rollback -i --restart=Never --image=portainer/portainer-ee:{{ version }} \
|
||||||
|
--overrides='{"spec":{"nodeSelector":{"knaster.role":"control"},"containers":[{"name":"rollback","image":"portainer/portainer-ee:{{ version }}","args":["--rollback-to-ce"],"stdin":true,"volumeMounts":[{"name":"data","mountPath":"/data"}]}],"volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"portainer-data"}}]}}' >/dev/null 2>&1 || true
|
||||||
|
sleep 5
|
||||||
|
kubectl -n {{ ns }} logs rollback | grep -q "rolled back to CE Edition"
|
||||||
|
kubectl -n {{ ns }} delete pod rollback --wait=false >/dev/null
|
||||||
|
kubectl -n {{ ns }} create configmap portainer-seeded --from-literal=source={{ backup | basename }} --from-literal=date="$(date -Is)"
|
||||||
|
when: seeded.rc != 0
|
||||||
|
|
||||||
|
- name: Deployment und Service
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ manifest }}
|
||||||
|
register: dep
|
||||||
|
changed_when: "'configured' in dep.stdout or 'created' in dep.stdout"
|
||||||
|
|
||||||
|
- name: Warten, bis bereit
|
||||||
|
ansible.builtin.command: kubectl -n {{ ns }} rollout status deployment/portainer --timeout=300s
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Version über NodePort (Knoten k3s, ohne Anmeldung)
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "https://{{ hostvars['k3s'].ansible_host }}:30779/api/system/status"
|
||||||
|
validate_certs: false
|
||||||
|
return_content: true
|
||||||
|
register: ver
|
||||||
|
until: ver.status == 200
|
||||||
|
retries: 6
|
||||||
|
delay: 5
|
||||||
|
|
||||||
|
- name: Edition aus dem Startprotokoll (CE-Binary meldet sich als portainer/portainer, EE als portainer-ee)
|
||||||
|
ansible.builtin.shell: kubectl -n {{ ns }} logs deployment/portainer | grep -o 'image_tag=[^ ]*' | tail -1
|
||||||
|
register: tag
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Ergebnis
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- ver.json.Version == version
|
||||||
|
- "'ee' not in tag.stdout"
|
||||||
|
success_msg: "Portainer CE {{ ver.json.Version }} ({{ tag.stdout }}) unter https://{{ hostvars['k3s'].ansible_host }}:30779"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# RustDesk-Server (hbbs/hbbr) in k3s, Umzug vom Portainer-Stack „rustdesk“ auf appsrv08 (15.10.2026).
|
||||||
|
# Läuft auf ops: ansible-playbook playbooks/rustdesk-k3s.yml [-e rustdesk_public_host=<Adresse für Clients>]
|
||||||
|
#
|
||||||
|
# Voraussetzung: Schlüsselpaar des bisherigen Servers unter /root/.config/knaster/rustdesk/{id_ed25519,id_ed25519.pub}
|
||||||
|
# auf ops (aus dem Docker-Volume rustdesk_rustdesk-data von appsrv08, Pfad /root). Damit bleiben die in den
|
||||||
|
# Clients hinterlegten Public Keys gültig. Die Clients selbst zeigen weiter auf appsrv08, bis ihre
|
||||||
|
# Server-Adresse umgestellt wird (Installer-Name im Stack rustdesk-updater und bestehende Installationen).
|
||||||
|
- name: RustDesk nach k3s
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
manifest: "{{ playbook_dir }}/../../../../deploy/k8s/rustdesk/rustdesk.yaml"
|
||||||
|
key_dir: /root/.config/knaster/rustdesk
|
||||||
|
# Adresse, die hbbs den Clients als Relay nennt (Port 21117). Standard: die k3s-VM; ServiceLB stellt die Ports
|
||||||
|
# auf jeder Knoten-Adresse bereit. Nach DHCP-Reservierung/DNS-Name hier bzw. per -e anpassen.
|
||||||
|
rustdesk_public_host: "{{ hostvars['k3s'].ansible_host }}"
|
||||||
|
tasks:
|
||||||
|
- name: Schlüsseldateien vorhanden?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ key_dir }}/{{ item }}"
|
||||||
|
loop: [id_ed25519, id_ed25519.pub]
|
||||||
|
register: keys
|
||||||
|
failed_when: not keys.stat.exists
|
||||||
|
|
||||||
|
- name: Namespace
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
kubectl create namespace rustdesk --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
register: ns
|
||||||
|
changed_when: "'created' in ns.stdout"
|
||||||
|
|
||||||
|
- name: Secret rustdesk-keys aus den Schlüsseldateien
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
kubectl -n rustdesk create secret generic rustdesk-keys
|
||||||
|
--from-file=id_ed25519={{ key_dir }}/id_ed25519 --from-file=id_ed25519.pub={{ key_dir }}/id_ed25519.pub
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
register: secret
|
||||||
|
changed_when: "'configured' in secret.stdout or 'created' in secret.stdout"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Manifest mit Relay-Adresse anwenden
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
sed 's/__RELAY_HOST__/{{ rustdesk_public_host }}/' {{ manifest }} | kubectl apply -f -
|
||||||
|
register: dep
|
||||||
|
changed_when: "'configured' in dep.stdout or 'created' in dep.stdout"
|
||||||
|
|
||||||
|
- name: Neustart, wenn sich nur das Secret geändert hat
|
||||||
|
ansible.builtin.command: kubectl -n rustdesk rollout restart deployment/rustdesk
|
||||||
|
when: secret.changed and not dep.changed
|
||||||
|
|
||||||
|
- name: Warten, bis bereit
|
||||||
|
ansible.builtin.command: kubectl -n rustdesk rollout status deployment/rustdesk --timeout=300s
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Public Key im Cluster entspricht der Datei (Image hat keine Shell, daher über das Secret)
|
||||||
|
ansible.builtin.shell: >
|
||||||
|
set -o pipefail;
|
||||||
|
kubectl -n rustdesk get secret rustdesk-keys -o go-template='{{ "{{" }}index .data "id_ed25519.pub"{{ "}}" }}' | base64 -d | cmp - {{ key_dir }}/id_ed25519.pub
|
||||||
|
args: {executable: /bin/bash}
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Ports von außen erreichbar (ID-Server 21116/tcp, Relay 21117/tcp auf {{ rustdesk_public_host }})
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
host: "{{ rustdesk_public_host }}"
|
||||||
|
port: "{{ item }}"
|
||||||
|
timeout: 30
|
||||||
|
loop: [21115, 21116, 21117, 21118, 21119]
|
||||||
|
|
||||||
|
- name: Ergebnis
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "RustDesk in k3s bereit: ID-Server/Relay {{ rustdesk_public_host }}, Public Key {{ lookup('file', key_dir + '/id_ed25519.pub') }}"
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
# Reihenfolge: GPU-Knoten vorbereiten → (tofu apply) → GPU-VMs → Infra
|
# Reihenfolge: GPU-Knoten vorbereiten → Knoten einzeln neu starten → (tofu apply) → GPU-VMs → Infra
|
||||||
# pve61 (pve_kiosk) bekommt bewusst keine Host-Rolle: keine VFIO-Bindung, keine NVIDIA-Sperre (Review Focus 5).
|
# pve61 (pve_kiosk) bekommt kein VFIO und keine NVIDIA-Sperre, seine iGPU bleibt für den Kiosk.
|
||||||
- name: Proxmox-GPU-Knoten (IOMMU, VFIO, ZFS-Replikation)
|
- name: GPU-Knoten (IOMMU, VFIO, NVIDIA auf dem Host gesperrt, Replikation)
|
||||||
hosts: pve_gpu_hosts
|
hosts: pve_gpu_hosts
|
||||||
roles: [pve-host]
|
roles: [pve-host]
|
||||||
|
|
||||||
- name: GPU-VMs (Treiber gepinnt, Container-Toolkit, Docker, Xid-Exporter)
|
- name: GPU-VMs (Treiber gepinnt, Container-Toolkit, Docker, Xid-Exporter)
|
||||||
hosts: gpu_vms
|
hosts: gpu_vms
|
||||||
roles: [docker-host, gpu-node]
|
pre_tasks:
|
||||||
|
# DHCP-Client-ID = MAC statt DUID: sonst greifen Reservierungen über die MAC nicht und die
|
||||||
|
# Adressen wechseln beim Neustart (12.10. beobachtet). Wirkt ab dem nächsten Neustart der VM.
|
||||||
|
- name: DHCP-Client-ID auf MAC umstellen
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/netplan/99-knaster-dhcp-id.yaml
|
||||||
|
content: |
|
||||||
|
network:
|
||||||
|
version: 2
|
||||||
|
ethernets:
|
||||||
|
eth0:
|
||||||
|
dhcp-identifier: mac
|
||||||
|
mode: "0600"
|
||||||
|
roles: [docker-host, gpu-node, gpu-exporters]
|
||||||
|
|
||||||
- name: Infra-LXC (Docker)
|
# Nur Monitoring ohne Treiber/Neustart der GPU-VMs: playbooks/monitoring.yml
|
||||||
|
- name: Infra-LXC (Docker, Compose-Stack mit Monitoring)
|
||||||
hosts: infra
|
hosts: infra
|
||||||
roles: [docker-host]
|
roles: [docker-host, infra-stack]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
collections:
|
collections:
|
||||||
- name: ansible.posix
|
- name: ansible.posix
|
||||||
- name: community.general
|
- name: community.general
|
||||||
|
- name: community.docker
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
- name: Docker aus Distribution installieren
|
- name: Docker aus Distribution installieren
|
||||||
ansible.builtin.apt:
|
ansible.builtin.apt:
|
||||||
name: [docker.io, docker-compose-v2]
|
# Compose v2 heißt unter Ubuntu docker-compose-v2, unter Debian 13 docker-compose
|
||||||
|
name: [docker.io, "{{ 'docker-compose-v2' if ansible_distribution == 'Ubuntu' else 'docker-compose' }}"]
|
||||||
state: present
|
state: present
|
||||||
update_cache: true
|
update_cache: true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Monitoring-Exporter in den GPU-VMs (Task 5.2). Ziele erzeugt monitoring-targets.py als file_sd.
|
||||||
|
# node_exporter aus Ubuntu (24.04: 1.7.x), liest die Xid-Zähler aus dem Textfile-Verzeichnis (Rolle gpu-node)
|
||||||
|
gpu_exporters_textfile_dir: /var/lib/node_exporter/textfile
|
||||||
|
# nvidia_gpu_exporter (nvidia-smi statt DCGM/NVML: läuft auch mit Maxwell/Pascal), Port 9835
|
||||||
|
nvidia_gpu_exporter_version: "1.15.1"
|
||||||
|
# sha256 aus checksums.txt des Releases v1.15.1 (am 12.10.2026 gegen die Datei geprüft)
|
||||||
|
nvidia_gpu_exporter_sha256: 0dc8c2756c6853ec7e6a7c404b229d8f764443bef89f33e13b3c4278b309acd4
|
||||||
|
# ufw: nur eigene allow-Regeln hinzufügen (Kommentar knaster-monitoring), ufw nie ein- oder ausschalten
|
||||||
|
gpu_exporters_allow_from: 192.168.100.0/24
|
||||||
|
gpu_exporters_ports: [9100, 9835]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- name: restart node-exporter
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: prometheus-node-exporter
|
||||||
|
state: restarted
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Bewusst getrennt von gpu-node: kein Treiber, kein Docker-Neustart, kein Reboot.
|
||||||
|
- name: node_exporter installieren (ohne Zusatz-Collectors)
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: prometheus-node-exporter
|
||||||
|
state: present
|
||||||
|
install_recommends: false
|
||||||
|
update_cache: true
|
||||||
|
cache_valid_time: 3600
|
||||||
|
|
||||||
|
- name: Textfile-Verzeichnis (Xid-Zähler aus gpu-xid-textfile.sh)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ gpu_exporters_textfile_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: node_exporter liest das Textfile-Verzeichnis
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/default/prometheus-node-exporter
|
||||||
|
regexp: '^ARGS='
|
||||||
|
line: 'ARGS="--collector.textfile.directory={{ gpu_exporters_textfile_dir }}"'
|
||||||
|
notify: restart node-exporter
|
||||||
|
|
||||||
|
- name: node_exporter aktiv
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: prometheus-node-exporter
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: nvidia_gpu_exporter herunterladen (Prüfsumme fest)
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "https://github.com/utkuozdemir/nvidia_gpu_exporter/releases/download/v{{ nvidia_gpu_exporter_version }}/nvidia-gpu-exporter_{{ nvidia_gpu_exporter_version }}_linux_amd64.deb"
|
||||||
|
dest: "/var/cache/apt/archives/nvidia-gpu-exporter_{{ nvidia_gpu_exporter_version }}_linux_amd64.deb"
|
||||||
|
checksum: "sha256:{{ nvidia_gpu_exporter_sha256 }}"
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: nvidia_gpu_exporter installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
deb: "/var/cache/apt/archives/nvidia-gpu-exporter_{{ nvidia_gpu_exporter_version }}_linux_amd64.deb"
|
||||||
|
|
||||||
|
- name: nvidia_gpu_exporter aktiv
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: nvidia_gpu_exporter
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: Ist ufw vorhanden?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /usr/sbin/ufw
|
||||||
|
register: ufw_bin
|
||||||
|
|
||||||
|
# Regeln wirken auch bei inaktivem ufw (werden vorgemerkt). Fremde Regeln bleiben unberührt.
|
||||||
|
- name: ufw – Exporter-Ports aus dem LAN erlauben
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
src: "{{ gpu_exporters_allow_from }}"
|
||||||
|
port: "{{ item | string }}"
|
||||||
|
proto: tcp
|
||||||
|
comment: knaster-monitoring
|
||||||
|
loop: "{{ gpu_exporters_ports }}"
|
||||||
|
when: ufw_bin.stat.exists
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# llama.cpp-Rollen in den GPU-VMs (Task 6.1): Compose aus deploy/compose/gpu-llamacpp.
|
||||||
|
# Profile je VM kommen aus dem Inventar (Gruppe gpu_llamacpp, Variable llamacpp_profiles).
|
||||||
|
llamacpp_dir: /opt/knaster/gpu-llamacpp
|
||||||
|
llamacpp_models_dir: /srv/models
|
||||||
|
llamacpp_profiles: []
|
||||||
|
|
||||||
|
# Karte je Dienst, gewählt über den Namen aus `nvidia-smi` (ADR-0006: alles auf die P2000, M4000 bleibt frei).
|
||||||
|
# Passt Rerank nicht neben Embedding auf die P2000, hier "Quadro M4000" eintragen.
|
||||||
|
llamacpp_cards:
|
||||||
|
EMBED_GPU: Quadro P2000
|
||||||
|
RERANK_GPU: Quadro P2000
|
||||||
|
CHAT_GPU: Quadro P2000
|
||||||
|
|
||||||
|
# Dienste und Ports je Profil (feste Ports im Compose, Host-Netz)
|
||||||
|
llamacpp_services:
|
||||||
|
embed:
|
||||||
|
- {name: llamacpp-embed, port: 8081}
|
||||||
|
- {name: llamacpp-rerank, port: 8082}
|
||||||
|
chat:
|
||||||
|
- {name: llamacpp-chat, port: 8080}
|
||||||
|
|
||||||
|
# ufw: nur Regeln vormerken (Kommentar knaster-llamacpp), ufw nie ein- oder ausschalten (Firewall: Task 4.4)
|
||||||
|
llamacpp_allow_from: 192.168.100.0/24
|
||||||
|
|
||||||
|
# Werte für .env (keine Geheimnisse); Build-Stand wie im Benchmark (ADR-0006)
|
||||||
|
llamacpp_env:
|
||||||
|
LLAMACPP_REF: b6000
|
||||||
|
LLAMACPP_COMMIT: 4762ad7316dcdec20016ab5985fb46a27902204d
|
||||||
|
CUDA_VERSION: 12.4.0
|
||||||
|
CUDA_DOCKER_ARCH: "52;61"
|
||||||
|
MODELS_DIR: "{{ llamacpp_models_dir }}"
|
||||||
|
EMBED_GGUF: Qwen3-Embedding-0.6B-Q8_0.gguf
|
||||||
|
EMBED_POOLING: last
|
||||||
|
RERANK_GGUF: bge-reranker-v2-m3-Q8_0.gguf
|
||||||
|
CHAT_GGUF: Qwen3-4B-Q4_K_M.gguf
|
||||||
|
|
||||||
|
# Modelle je Profil mit fester Revision und Prüfsumme (sha256 = LFS-OID laut Hugging Face, ADR-0006)
|
||||||
|
_hf: https://huggingface.co
|
||||||
|
llamacpp_models:
|
||||||
|
embed:
|
||||||
|
- dest: Qwen3-Embedding-0.6B-Q8_0.gguf
|
||||||
|
url: "{{ _hf }}/Qwen/Qwen3-Embedding-0.6B-GGUF/resolve/370f27d7550e0def9b39c1f16d3fbaa13aa67728/Qwen3-Embedding-0.6B-Q8_0.gguf"
|
||||||
|
checksum: sha256:06507c7b42688469c4e7298b0a1e16deff06caf291cf0a5b278c308249c3e439
|
||||||
|
- dest: bge-reranker-v2-m3-Q8_0.gguf
|
||||||
|
url: "{{ _hf }}/gpustack/bge-reranker-v2-m3-GGUF/resolve/3093af03b1a635e67b084b1d8c03c5f5e020fd05/bge-reranker-v2-m3-Q8_0.gguf"
|
||||||
|
checksum: sha256:a43c7c9b11a4c1517e5bf95151960e1621d1b72f7a493364b01e386cf1aaa1d3
|
||||||
|
chat:
|
||||||
|
- dest: Qwen3-4B-Q4_K_M.gguf
|
||||||
|
url: "{{ _hf }}/Qwen/Qwen3-4B-GGUF/resolve/bc640142c66e1fdd12af0bd68f40445458f3869b/Qwen3-4B-Q4_K_M.gguf"
|
||||||
|
checksum: sha256:7485fe6f11af29433bc51cab58009521f205840f5b4ae3a32fa7f92e8534fdf5
|
||||||
|
|
||||||
|
# --- k3s (Task 6.1, D6): tasks/k3s.yml, Playbook playbooks/gpu-llamacpp-k3s.yml ---
|
||||||
|
llamacpp_image: "knaster/llamacpp-server:{{ llamacpp_env.LLAMACPP_REF }}-cu{{ llamacpp_env.CUDA_VERSION }}"
|
||||||
|
llamacpp_k3s_template: "{{ playbook_dir }}/../../../../deploy/k8s/llamacpp/llamacpp.yaml.j2"
|
||||||
|
llamacpp_k3s_namespace: "{{ playbook_dir }}/../../../../deploy/k8s/llamacpp/namespace.yaml"
|
||||||
|
# Argumente wie im Compose (deploy/compose/gpu-llamacpp); gpu_var wählt die Karte aus llamacpp_cards
|
||||||
|
llamacpp_k3s_services:
|
||||||
|
embed:
|
||||||
|
- name: llamacpp-embed
|
||||||
|
port: 8081
|
||||||
|
gpu_var: EMBED_GPU
|
||||||
|
memory_limit: 3Gi
|
||||||
|
# -ub 4096 statt 8192: Der Rechenpuffer wächst mit ub × Vokabular (151k) und bräuchte bei 8192 allein 5 GB
|
||||||
|
# (19.10. gemessen). Mit 4096: 2,5 GB Puffer, P2000 mit Rerank 4,4–4,6 von 5 GB, 5.467 Tokens in einem Aufruf ok.
|
||||||
|
args: ["-m", "/models/{{ llamacpp_env.EMBED_GGUF }}", "--embedding", "--pooling", "{{ llamacpp_env.EMBED_POOLING }}",
|
||||||
|
"-c", "8192", "-b", "4096", "-ub", "4096", "-np", "1", "-ngl", "99", "--alias", "embed-default",
|
||||||
|
"--metrics", "--no-slots", "--no-webui", "--host", "0.0.0.0", "--port", "8081"]
|
||||||
|
- name: llamacpp-rerank
|
||||||
|
port: 8082
|
||||||
|
gpu_var: RERANK_GPU
|
||||||
|
memory_limit: 3Gi
|
||||||
|
args: ["-m", "/models/{{ llamacpp_env.RERANK_GGUF }}", "--reranking",
|
||||||
|
"-c", "8192", "-b", "8192", "-ub", "8192", "-np", "1", "-ngl", "99", "--alias", "rerank-default",
|
||||||
|
"--metrics", "--no-slots", "--no-webui", "--host", "0.0.0.0", "--port", "8082"]
|
||||||
|
chat:
|
||||||
|
- name: llamacpp-chat
|
||||||
|
port: 8080
|
||||||
|
gpu_var: CHAT_GPU
|
||||||
|
memory_limit: 6Gi
|
||||||
|
args: ["-m", "/models/{{ llamacpp_env.CHAT_GGUF }}", "-c", "8192", "-np", "1", "-ngl", "99",
|
||||||
|
"--jinja", "--chat-template-kwargs", "{\"enable_thinking\": false}",
|
||||||
|
"--alias", "verifier-small", "--metrics", "--no-slots", "--no-webui",
|
||||||
|
"--host", "0.0.0.0", "--port", "8080"]
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# llama.cpp in k3s statt Compose (Task 6.1, D6). Aufruf über playbooks/gpu-llamacpp-k3s.yml (je VM nacheinander).
|
||||||
|
# Rückweg je VM: kubectl -n knaster delete deploy llamacpp-<vm>, dann in der VM
|
||||||
|
# docker compose --project-directory /opt/knaster/gpu-llamacpp start
|
||||||
|
- name: Profile, Karten, Modelle
|
||||||
|
ansible.builtin.include_tasks: prepare.yml
|
||||||
|
|
||||||
|
# 1. Image aus Docker in das containerd von k3s übernehmen (kein Neubau, kein Registry-Umweg)
|
||||||
|
- name: Image in k3s vorhanden?
|
||||||
|
ansible.builtin.command: /usr/local/bin/k3s ctr images ls -q
|
||||||
|
register: llamacpp_k3s_images
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Image aus Docker importieren
|
||||||
|
ansible.builtin.shell: set -o pipefail; docker save {{ llamacpp_image }} | /usr/local/bin/k3s ctr images import -
|
||||||
|
args:
|
||||||
|
executable: /bin/bash
|
||||||
|
when: ("docker.io/" ~ llamacpp_image) not in llamacpp_k3s_images.stdout
|
||||||
|
async: 1800
|
||||||
|
poll: 15
|
||||||
|
|
||||||
|
# 2. Compose-Stack anhalten (Host-Ports und VRAM werden frei), Dateien bleiben für den Rückweg
|
||||||
|
- name: Compose-Projekt vorhanden?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ llamacpp_dir }}/docker-compose.yml"
|
||||||
|
register: llamacpp_compose_file
|
||||||
|
|
||||||
|
- name: Compose-Stack anhalten
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ llamacpp_dir }}"
|
||||||
|
state: stopped
|
||||||
|
when: llamacpp_compose_file.stat.exists
|
||||||
|
|
||||||
|
# 3. Manifest je VM rendern (UUID der P2000) und anwenden
|
||||||
|
- name: Namespace knaster
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ llamacpp_k3s_namespace }}
|
||||||
|
register: llamacpp_ns
|
||||||
|
changed_when: "'created' in llamacpp_ns.stdout"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Deployment anwenden
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: kubectl apply -f -
|
||||||
|
stdin: "{{ lookup('template', llamacpp_k3s_template) }}"
|
||||||
|
vars:
|
||||||
|
llamacpp_k3s_containers: "{{ llamacpp_profiles | map('extract', llamacpp_k3s_services) | flatten }}"
|
||||||
|
register: llamacpp_apply
|
||||||
|
changed_when: "'configured' in llamacpp_apply.stdout or 'created' in llamacpp_apply.stdout"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Warten, bis der Pod bereit ist
|
||||||
|
ansible.builtin.command: kubectl -n knaster rollout status deployment/llamacpp-{{ inventory_hostname }} --timeout=600s
|
||||||
|
changed_when: false
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
# 4. Prüfen in der VM: Dienste gesund, Prozesse nur auf der P2000
|
||||||
|
- name: Gesundheit in der VM prüfen
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ item.port }}/health"
|
||||||
|
status_code: 200
|
||||||
|
loop: "{{ llamacpp_profiles | map('extract', llamacpp_k3s_services) | flatten }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}:{{ item.port }}"
|
||||||
|
|
||||||
|
- name: Belegung der Karten
|
||||||
|
ansible.builtin.command: nvidia-smi --query-gpu=index,name,memory.used,memory.total --format=csv,noheader
|
||||||
|
register: llamacpp_vram
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Ergebnis
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ llamacpp_vram.stdout_lines }}"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# llama.cpp-Server je Profil (Task 6.1). Idempotent: Modelle per Prüfsumme, Image nur bauen, wenn es fehlt
|
||||||
|
# (Image-Tag enthält Stand und CUDA-Version), compose ändert nur, was sich geändert hat.
|
||||||
|
- name: Profile, Karten, Modelle
|
||||||
|
ansible.builtin.include_tasks: prepare.yml
|
||||||
|
|
||||||
|
# Compose baut nur mit buildx (BuildKit, auch für den Git-Kontext mit Commit-Pin)
|
||||||
|
- name: docker-buildx installieren
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: docker-buildx
|
||||||
|
state: present
|
||||||
|
|
||||||
|
# --- Compose-Projekt ---
|
||||||
|
- name: Projektverzeichnis
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ llamacpp_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Compose-Datei kopieren
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ playbook_dir }}/../../../../deploy/compose/gpu-llamacpp/docker-compose.yml"
|
||||||
|
dest: "{{ llamacpp_dir }}/docker-compose.yml"
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: .env schreiben (keine Geheimnisse)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ llamacpp_dir }}/.env"
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
# Verwaltet von Ansible (Rolle gpu-llamacpp)
|
||||||
|
COMPOSE_PROFILES={{ llamacpp_profiles | join(',') }}
|
||||||
|
{% for k, v in (llamacpp_env | combine(llamacpp_gpu_env)).items() %}
|
||||||
|
{{ k }}={{ v }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
# ufw-Regeln wirken auch bei inaktivem ufw (werden vorgemerkt). Fremde Regeln bleiben unberührt.
|
||||||
|
- name: Ist ufw vorhanden?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /usr/sbin/ufw
|
||||||
|
register: llamacpp_ufw
|
||||||
|
|
||||||
|
- name: ufw – llama.cpp-Ports aus dem LAN vormerken
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
src: "{{ llamacpp_allow_from }}"
|
||||||
|
port: "{{ item.port | string }}"
|
||||||
|
proto: tcp
|
||||||
|
comment: knaster-llamacpp
|
||||||
|
loop: "{{ llamacpp_profiles | map('extract', llamacpp_services) | flatten }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}:{{ item.port }}"
|
||||||
|
when: llamacpp_ufw.stat.exists
|
||||||
|
|
||||||
|
# Der erste Build dauert lange (llama.cpp mit CUDA, sm_52/61, alle CPU-Varianten auf 4 vCPU)
|
||||||
|
- name: Image bauen (falls es fehlt) und Dienste starten
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ llamacpp_dir }}"
|
||||||
|
profiles: "{{ llamacpp_profiles }}"
|
||||||
|
build: policy
|
||||||
|
state: present
|
||||||
|
remove_orphans: true
|
||||||
|
wait: false
|
||||||
|
async: 7200
|
||||||
|
poll: 30
|
||||||
|
|
||||||
|
- name: Warten, bis alle Dienste gesund sind
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ item.port }}/health"
|
||||||
|
status_code: 200
|
||||||
|
register: llamacpp_health
|
||||||
|
until: llamacpp_health.status == 200
|
||||||
|
retries: 60
|
||||||
|
delay: 10
|
||||||
|
loop: "{{ llamacpp_profiles | map('extract', llamacpp_services) | flatten }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}:{{ item.port }}"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Gemeinsam für Compose (main.yml) und k3s (k3s.yml): Profile prüfen, P2000 per Name finden, Modelle laden.
|
||||||
|
- name: Profile prüfen
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- llamacpp_profiles | length > 0
|
||||||
|
- llamacpp_profiles | difference(llamacpp_services.keys() | list) | length == 0
|
||||||
|
fail_msg: "llamacpp_profiles muss aus {{ llamacpp_services.keys() | list }} bestehen (Inventar, Gruppe gpu_llamacpp)"
|
||||||
|
|
||||||
|
# --- Kartenwahl: UUID statt Index, geprüft über den Kartennamen ---
|
||||||
|
- name: Karten in der VM lesen
|
||||||
|
ansible.builtin.command: nvidia-smi --query-gpu=index,uuid,name --format=csv,noheader
|
||||||
|
register: llamacpp_smi
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: UUID je Dienst aus dem Kartennamen bestimmen
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
llamacpp_gpu_env: >-
|
||||||
|
{{ llamacpp_gpu_env | default({}) | combine({item.key: (llamacpp_smi.stdout_lines
|
||||||
|
| map('split', ', ') | selectattr(2, 'equalto', item.value) | map(attribute=1) | first | default(''))}) }}
|
||||||
|
loop: "{{ llamacpp_cards | dict2items }}"
|
||||||
|
|
||||||
|
- name: Jede gewünschte Karte ist vorhanden
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: llamacpp_gpu_env[item.key] is match('GPU-')
|
||||||
|
fail_msg: "{{ item.value }} für {{ item.key }} nicht gefunden: {{ llamacpp_smi.stdout_lines }}"
|
||||||
|
loop: "{{ llamacpp_cards | dict2items }}"
|
||||||
|
|
||||||
|
# --- Modelle ---
|
||||||
|
- name: Modelle laden (Prüfsumme gegen Hugging Face)
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "{{ item.url }}"
|
||||||
|
dest: "{{ llamacpp_models_dir }}/{{ item.dest }}"
|
||||||
|
checksum: "{{ item.checksum }}"
|
||||||
|
mode: "0644"
|
||||||
|
timeout: 120
|
||||||
|
loop: "{{ llamacpp_profiles | map('extract', llamacpp_models) | flatten }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.dest }}"
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
- name: configure nvidia runtime
|
|
||||||
ansible.builtin.command: nvidia-ctk runtime configure --runtime=docker
|
|
||||||
notify: restart docker
|
|
||||||
|
|
||||||
- name: restart docker
|
- name: restart docker
|
||||||
ansible.builtin.service:
|
ansible.builtin.service:
|
||||||
name: docker
|
name: docker
|
||||||
|
|||||||
@@ -30,12 +30,20 @@
|
|||||||
creates: /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
creates: /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||||
executable: /bin/bash
|
executable: /bin/bash
|
||||||
|
|
||||||
- name: Container-Toolkit installieren und für Docker konfigurieren
|
- name: Container-Toolkit installieren
|
||||||
ansible.builtin.apt:
|
ansible.builtin.apt:
|
||||||
name: nvidia-container-toolkit
|
name: nvidia-container-toolkit
|
||||||
state: present
|
state: present
|
||||||
update_cache: true
|
update_cache: true
|
||||||
notify: configure nvidia runtime
|
|
||||||
|
# Als Aufgabe statt Handler: Ein Handler ging verloren, wenn der Lauf beim Neustart abbrach
|
||||||
|
- name: NVIDIA-Runtime in Docker eintragen
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
grep -q '"nvidia"' /etc/docker/daemon.json 2>/dev/null && exit 0
|
||||||
|
nvidia-ctk runtime configure --runtime=docker && echo geändert
|
||||||
|
register: nvidia_runtime
|
||||||
|
changed_when: "'geändert' in nvidia_runtime.stdout"
|
||||||
|
notify: restart docker
|
||||||
|
|
||||||
- name: Modell-Disk (scsi1) formatieren, falls leer (formatiert nie eine vorhandene Partition um)
|
- name: Modell-Disk (scsi1) formatieren, falls leer (formatiert nie eine vorhandene Partition um)
|
||||||
community.general.filesystem:
|
community.general.filesystem:
|
||||||
@@ -79,6 +87,9 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: Handler vor dem Neustart ausführen
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
- name: Neustart nach Treiberinstallation
|
- name: Neustart nach Treiberinstallation
|
||||||
ansible.builtin.reboot:
|
ansible.builtin.reboot:
|
||||||
when: driver.changed
|
when: driver.changed
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Compose-Stack im LXC infra (Task 5.2). Repo-Auszug und Zustand liegen getrennt:
|
||||||
|
# infra_repo_dir Kopie der nötigen Repo-Teile von ops (wird bei jedem Lauf abgeglichen, --delete)
|
||||||
|
# infra_state_dir .env (0600, Geheimnisse), secrets/, file_sd/ – nie im Repo, nie überschrieben
|
||||||
|
infra_repo_src: "{{ playbook_dir }}/../../../.."
|
||||||
|
infra_repo_dir: /opt/knaster/repo
|
||||||
|
infra_state_dir: /opt/knaster/infra
|
||||||
|
infra_compose_project: knaster-infra
|
||||||
|
infra_compose_file: "{{ infra_repo_dir }}/deploy/compose/infra/docker-compose.yml"
|
||||||
|
|
||||||
|
# Einstellungen in .env (keine Geheimnisse). Änderungen hier, nicht im Container.
|
||||||
|
infra_settings:
|
||||||
|
KNASTER_VERSION: dev
|
||||||
|
KNASTER_SITE: "{{ knaster_site }}"
|
||||||
|
KNASTER_STATE_DIR: "{{ infra_state_dir }}"
|
||||||
|
KNASTER_DRY_RUN: "true" # Global Constraint: erst nach einer Woche Beobachtung einzeln freischalten
|
||||||
|
KNASTER_CANARY_INTERVAL: "60"
|
||||||
|
# D4: LiteLLM mit PSE auf appsrv08. TODO (Nutzer, Task 6.3): Port ist dort noch nicht im LAN veröffentlicht.
|
||||||
|
LITELLM_BASE_URL: http://192.168.100.29:4000
|
||||||
|
|
||||||
|
# Geheimnisse: werden im Container einmalig mit `openssl rand -hex 24` erzeugt und danach nie geändert
|
||||||
|
# (Postgres- und Grafana-Admin-Passwort wirken nur bei der Erstinitialisierung).
|
||||||
|
infra_secrets: [POSTGRES_PASSWORD, KNASTER_API_TOKEN, GRAFANA_ADMIN_PASSWORD, GRAFANA_KIOSK_PASSWORD]
|
||||||
|
|
||||||
|
# Lese-Token monitoring@pve!prom (PVEAuditor) auf ops
|
||||||
|
infra_pve_exporter_env: /root/.config/knaster/pve-exporter.env
|
||||||
|
|
||||||
|
# Lesezugang zum kubelet für Prometheus (Task 6.5), auf ops von playbooks/monitoring-k3s.yml abgelegt
|
||||||
|
infra_k3s_token: /root/.config/knaster/k3s-prometheus.token
|
||||||
|
infra_k3s_ca: /root/.config/knaster/k3s-ca.crt
|
||||||
|
|
||||||
|
# file_sd: auf ops aus `tofu output -json gpu_vms` erzeugt (nicht im Repo)
|
||||||
|
infra_tofu_dir: "{{ playbook_dir }}/../../tofu"
|
||||||
|
infra_targets_dir: "{{ playbook_dir }}/../monitoring-targets.generated"
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Veralteten Seiten-Cache des Wirts für alle Dateien dieses Containers verwerfen (vor Docker-Start).
|
||||||
|
|
||||||
|
Hintergrund (12.10.2026, Task 5.2, HA-Probe): Nach `zfs receive` (Proxmox-Replikation) liefert der Wirt
|
||||||
|
für Dateien, die auf ihm früher per mmap geöffnet waren, weiter den ALTEN Inhalt aus dem Seiten-Cache
|
||||||
|
(pve44: `zfs diff` leer, `written` 0, Datei-md5 trotzdem ungleich Snapshot; ZFS 2.4.4, Kernel 7.0.14).
|
||||||
|
Zieht der Container per HA auf so einen Knoten, liest dockerd veraltete bbolt-Datenbanken
|
||||||
|
(Panic „invalid freelist page“, Netzwerke/Volumes „not found“); Prometheus-TSDB wäre ebenso betroffen.
|
||||||
|
|
||||||
|
POSIX_FADV_DONTNEED wirft saubere, nicht gemappte Seiten aus dem Cache; danach liest der Kernel wieder
|
||||||
|
von ZFS. Läuft vor containerd/docker, scheitert nie (Exit 0), protokolliert geänderte *.db-Prüfsummen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SKIP = {"/proc", "/sys", "/dev", "/run", "/tmp"}
|
||||||
|
WATCH = ("/var/lib/docker/", "/var/lib/containerd/")
|
||||||
|
|
||||||
|
|
||||||
|
def md5(path: str) -> str:
|
||||||
|
h = hashlib.md5()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
root_dev = os.stat("/").st_dev
|
||||||
|
files = dropped = errors = 0
|
||||||
|
changed = []
|
||||||
|
for dirpath, dirnames, filenames in os.walk("/"):
|
||||||
|
dirnames[:] = [d for d in dirnames if os.path.join(dirpath, d) not in SKIP]
|
||||||
|
try:
|
||||||
|
if os.lstat(dirpath).st_dev != root_dev:
|
||||||
|
dirnames[:] = []
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for name in filenames:
|
||||||
|
path = os.path.join(dirpath, name)
|
||||||
|
try:
|
||||||
|
st = os.lstat(path)
|
||||||
|
if not stat.S_ISREG(st.st_mode) or st.st_dev != root_dev or st.st_size == 0:
|
||||||
|
continue
|
||||||
|
files += 1
|
||||||
|
watch = path.endswith(".db") and path.startswith(WATCH)
|
||||||
|
before = md5(path) if watch else None
|
||||||
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||||
|
try:
|
||||||
|
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
||||||
|
dropped += 1
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
if watch:
|
||||||
|
after = md5(path)
|
||||||
|
if after != before:
|
||||||
|
changed.append(f"{path}: {before[:8]} -> {after[:8]}")
|
||||||
|
except OSError:
|
||||||
|
errors += 1
|
||||||
|
for line in changed:
|
||||||
|
print(f"veralteter Cache verworfen: {line}")
|
||||||
|
print(f"{files} Dateien, Cache verworfen für {dropped}, Fehler {errors}, geänderte Datenbanken {len(changed)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except Exception as exc: # nie den Docker-Start blockieren
|
||||||
|
print(f"Fehler: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Veralteten Wirts-Seiten-Cache nach ZFS-Replikation verwerfen (vor Docker)
|
||||||
|
Documentation=file:///usr/local/sbin/knaster-drop-stale-cache
|
||||||
|
DefaultDependencies=no
|
||||||
|
After=local-fs.target
|
||||||
|
Before=containerd.service docker.socket docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/sbin/knaster-drop-stale-cache
|
||||||
|
TimeoutStartSec=300
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=containerd.service docker.service
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Compose-Stack infra: Router, TTS, Postgres, Prometheus, Alertmanager, Grafana, pve-exporter, node-exporter.
|
||||||
|
# Idempotent: Geheimnisse nur anlegen, wenn sie fehlen; compose up ändert nur, was sich geändert hat.
|
||||||
|
- name: Werkzeuge
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: [rsync, openssl, curl]
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
cache_valid_time: 3600
|
||||||
|
|
||||||
|
- name: Verzeichnisse
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: directory
|
||||||
|
mode: "{{ item.mode }}"
|
||||||
|
loop:
|
||||||
|
- {path: /opt/knaster, mode: "0755"}
|
||||||
|
- {path: "{{ infra_repo_dir }}", mode: "0755"}
|
||||||
|
- {path: "{{ infra_state_dir }}", mode: "0700"}
|
||||||
|
- {path: "{{ infra_state_dir }}/secrets", mode: "0700"}
|
||||||
|
- {path: "{{ infra_state_dir }}/secrets/k3s", mode: "0755"}
|
||||||
|
|
||||||
|
# Nur die Teile, die Build und Konfiguration brauchen. Nie .git, .venv, tofu-State oder .env-Dateien.
|
||||||
|
- name: Repo-Auszug von ops abgleichen
|
||||||
|
ansible.posix.synchronize:
|
||||||
|
src: "{{ infra_repo_src }}/"
|
||||||
|
dest: "{{ infra_repo_dir }}/"
|
||||||
|
delete: true
|
||||||
|
rsync_opts:
|
||||||
|
# Der Clone auf ops ist teils 0600/0700; Container-Benutzer (nobody, grafana, knaster) müssen lesen können
|
||||||
|
- "--chmod=go+rX,go-w"
|
||||||
|
- "--exclude=__pycache__"
|
||||||
|
- "--exclude=.pytest_cache"
|
||||||
|
- "--exclude=*.egg-info"
|
||||||
|
- "--exclude=.env"
|
||||||
|
- "--exclude=secrets/"
|
||||||
|
- "--include=/core/***"
|
||||||
|
- "--include=/config/***"
|
||||||
|
- "--include=/catalog/***"
|
||||||
|
- "--include=/skills/***"
|
||||||
|
- "--include=/ontology/***" # Router-Image kopiert ontology/ (KNASTER_ONTOLOGY)
|
||||||
|
- "--include=/sites/***"
|
||||||
|
- "--include=/deploy/***"
|
||||||
|
- "--exclude=*"
|
||||||
|
|
||||||
|
- name: Einstellungen in .env
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "{{ infra_state_dir }}/.env"
|
||||||
|
regexp: "^{{ item.key }}="
|
||||||
|
line: "{{ item.key }}={{ item.value }}"
|
||||||
|
create: true
|
||||||
|
mode: "0600"
|
||||||
|
loop: "{{ infra_settings | dict2items }}"
|
||||||
|
|
||||||
|
# Gibt nur Namen aus, nie Werte
|
||||||
|
- name: Fehlende Geheimnisse im Container erzeugen
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
f={{ infra_state_dir }}/.env
|
||||||
|
for k in {{ infra_secrets | join(' ') }}; do
|
||||||
|
grep -q "^$k=" "$f" || { echo "$k=$(openssl rand -hex 24)" >> "$f"; echo "neu: $k"; }
|
||||||
|
done
|
||||||
|
chmod 0600 "$f"
|
||||||
|
args:
|
||||||
|
executable: /bin/bash
|
||||||
|
register: secrets_gen
|
||||||
|
changed_when: "'neu:' in secrets_gen.stdout"
|
||||||
|
|
||||||
|
- name: Router-Token für Alertmanager (= KNASTER_API_TOKEN, lesbar für nobody im Container)
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
cd {{ infra_state_dir }}
|
||||||
|
v=$(sed -n 's/^KNASTER_API_TOKEN=//p' .env)
|
||||||
|
t=secrets/router_token
|
||||||
|
if [ ! -f "$t" ] || [ "$(cat "$t")" != "$v" ]; then printf '%s' "$v" > "$t"; echo geändert; fi
|
||||||
|
chown 65534:65534 "$t"
|
||||||
|
chmod 0400 "$t"
|
||||||
|
args:
|
||||||
|
executable: /bin/bash
|
||||||
|
register: router_token
|
||||||
|
changed_when: "'geändert' in router_token.stdout"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Proxmox-Lese-Token für den pve-exporter
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ infra_pve_exporter_env }}"
|
||||||
|
dest: "{{ infra_state_dir }}/secrets/pve-exporter.env"
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
# Task 6.5: Token des ServiceAccounts prometheus-infra und CA des Clusters (von playbooks/monitoring-k3s.yml
|
||||||
|
# auf ops abgelegt). Lesbar für nobody (Prometheus im Container). Fehlen sie, bleiben die k3s-Jobs down.
|
||||||
|
- name: k3s-Zugang für Prometheus vorhanden? (auf ops)
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ item }}"
|
||||||
|
loop: ["{{ infra_k3s_token }}", "{{ infra_k3s_ca }}"]
|
||||||
|
delegate_to: localhost
|
||||||
|
register: k3s_access
|
||||||
|
|
||||||
|
- name: k3s-Zugang für Prometheus kopieren
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ item.src }}"
|
||||||
|
dest: "{{ infra_state_dir }}/secrets/k3s/{{ item.dest }}"
|
||||||
|
owner: "65534"
|
||||||
|
group: "65534"
|
||||||
|
mode: "0400"
|
||||||
|
loop:
|
||||||
|
- {src: "{{ infra_k3s_token }}", dest: token}
|
||||||
|
- {src: "{{ infra_k3s_ca }}", dest: ca.crt}
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.dest }}"
|
||||||
|
when: k3s_access.results | map(attribute='stat.exists') | min
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: file_sd-Ziele
|
||||||
|
ansible.builtin.include_tasks: targets.yml
|
||||||
|
|
||||||
|
# HA-Umzug: Nach zfs receive liefert der Zielknoten für früher gemappte Dateien veralteten Seiten-Cache
|
||||||
|
# (12.10.2026 belegt). Vor jedem Docker-Start den Cache dieses Containers verwerfen. BETRIEB.md Abschnitt 7.
|
||||||
|
- name: Schutz gegen veralteten Seiten-Cache – Skript
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: knaster-drop-stale-cache.py
|
||||||
|
dest: /usr/local/sbin/knaster-drop-stale-cache
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Schutz gegen veralteten Seiten-Cache – systemd-Dienst vor containerd/docker
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: knaster-drop-stale-cache.service
|
||||||
|
dest: /etc/systemd/system/knaster-drop-stale-cache.service
|
||||||
|
mode: "0644"
|
||||||
|
register: dropcache_unit
|
||||||
|
|
||||||
|
- name: Schutz gegen veralteten Seiten-Cache – aktivieren
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: knaster-drop-stale-cache.service
|
||||||
|
enabled: true
|
||||||
|
daemon_reload: "{{ dropcache_unit.changed }}"
|
||||||
|
|
||||||
|
- name: Stack bauen und starten
|
||||||
|
ansible.builtin.command: >
|
||||||
|
docker compose -p {{ infra_compose_project }} --env-file {{ infra_state_dir }}/.env
|
||||||
|
-f {{ infra_compose_file }} up -d --build --remove-orphans
|
||||||
|
register: compose_up
|
||||||
|
changed_when: compose_up.stderr is search('Container \S+\s+(Created|Recreated|Started)')
|
||||||
|
|
||||||
|
- name: Prometheus und Alertmanager laden geänderte Konfiguration (bind-mount, kein Neustart nötig)
|
||||||
|
ansible.builtin.command: "curl -fsS -m 10 -X POST http://127.0.0.1:{{ item }}/-/reload"
|
||||||
|
loop: [9090, 9093]
|
||||||
|
register: reload
|
||||||
|
retries: 15
|
||||||
|
delay: 4
|
||||||
|
until: reload.rc == 0
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Grafana-Konto kiosk (Viewer) anlegen, Passwort aus .env
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -eu
|
||||||
|
cd {{ infra_state_dir }}
|
||||||
|
A=$(sed -n 's/^GRAFANA_ADMIN_PASSWORD=//p' .env)
|
||||||
|
K=$(sed -n 's/^GRAFANA_KIOSK_PASSWORD=//p' .env)
|
||||||
|
umask 077
|
||||||
|
cfg=$(mktemp)
|
||||||
|
trap 'rm -f "$cfg"' EXIT
|
||||||
|
printf 'user = "admin:%s"\n' "$A" > "$cfg" # Passwort nicht in der Prozessliste
|
||||||
|
g() { curl -sS -K "$cfg" -H 'Content-Type: application/json' "$@"; }
|
||||||
|
for i in $(seq 60); do curl -fsS -m 3 http://127.0.0.1:3000/api/health >/dev/null 2>&1 && break; sleep 2; done
|
||||||
|
code=$(g -o /dev/null -w '%{http_code}' 'http://127.0.0.1:3000/api/users/lookup?loginOrEmail=kiosk')
|
||||||
|
if [ "$code" = 404 ]; then
|
||||||
|
printf '{"name":"Kiosk","login":"kiosk","password":"%s"}' "$K" \
|
||||||
|
| g -f -X POST --data @- http://127.0.0.1:3000/api/admin/users >/dev/null
|
||||||
|
echo angelegt
|
||||||
|
elif [ "$code" != 200 ]; then
|
||||||
|
echo "Grafana-API antwortet $code" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
id=$(g -f 'http://127.0.0.1:3000/api/users/lookup?loginOrEmail=kiosk' | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
g -f -X PATCH --data '{"role":"Viewer"}' "http://127.0.0.1:3000/api/org/users/$id" >/dev/null
|
||||||
|
args:
|
||||||
|
executable: /bin/bash
|
||||||
|
register: kiosk
|
||||||
|
changed_when: "'angelegt' in kiosk.stdout"
|
||||||
|
no_log: true
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# file_sd-Ziele der GPU-VMs (node, gpu, llamacpp). Auch einzeln aufrufbar: playbooks/monitoring-targets.yml
|
||||||
|
# (nach refresh-vm-access.sh, wenn sich DHCP-Adressen geändert haben). Prometheus liest die Dateien selbst neu.
|
||||||
|
# k3s (Task 6.5): kubelet und kube-state-metrics aus `kubectl get nodes` auf ops. Ist der Cluster nicht
|
||||||
|
# erreichbar, bleiben diese Listen leer und der Rest läuft weiter.
|
||||||
|
- name: k3s-Knoten lesen (auf ops)
|
||||||
|
ansible.builtin.shell: mkdir -p {{ infra_targets_dir }} && kubectl get nodes -o json > {{ infra_targets_dir }}/k3s-nodes.json
|
||||||
|
args:
|
||||||
|
executable: /bin/bash
|
||||||
|
delegate_to: localhost
|
||||||
|
run_once: true
|
||||||
|
register: k3s_nodes
|
||||||
|
failed_when: false
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: file_sd-Ziele aus tofu output erzeugen (auf ops)
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -euo pipefail
|
||||||
|
tofu output -json gpu_vms | python3 {{ infra_repo_src }}/platform/proxmox/scripts/monitoring-targets.py \
|
||||||
|
--registry {{ infra_repo_src }}/sites/{{ knaster_site }}/registry.yaml --out {{ infra_targets_dir }} \
|
||||||
|
--llamacpp '{{ dict(_llamacpp_hosts | zip(_llamacpp_hosts | map('extract', hostvars, 'llamacpp_profiles'))) | to_json }}' \
|
||||||
|
{{ ('--k3s-nodes ' ~ infra_targets_dir ~ '/k3s-nodes.json') if k3s_nodes.rc == 0 else '' }}
|
||||||
|
args:
|
||||||
|
chdir: "{{ infra_tofu_dir }}"
|
||||||
|
executable: /bin/bash
|
||||||
|
delegate_to: localhost
|
||||||
|
run_once: true
|
||||||
|
vars:
|
||||||
|
# Profile je VM aus dem Inventar (Gruppe gpu_llamacpp, Task 6.1)
|
||||||
|
_llamacpp_hosts: "{{ groups['gpu_llamacpp'] | default([]) }}"
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: file_sd-Verzeichnis
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ infra_state_dir }}/file_sd"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: file_sd-Ziele in den Container kopieren
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ infra_targets_dir }}/{{ item }}.json"
|
||||||
|
dest: "{{ infra_state_dir }}/file_sd/{{ item }}.json"
|
||||||
|
mode: "0644"
|
||||||
|
loop: [node, gpu, llamacpp, kubelet, kube-state-metrics]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# k3s-Agent in den GPU-VMs (D6). Der Server heißt k3s.knaster (/etc/hosts), Token aus der Datei auf ops.
|
||||||
|
k3s_version: v1.36.5+k3s1
|
||||||
|
k3s_server_name: k3s.knaster
|
||||||
|
k3s_server_host: "{{ hostvars['k3s'].ansible_host }}"
|
||||||
|
k3s_env_file: /root/.config/knaster/k3s.env
|
||||||
|
k3s_token: "{{ lookup('ini', 'K3S_TOKEN', type='properties', file=k3s_env_file) }}"
|
||||||
|
k3s_install_script: /usr/local/src/k3s-install.sh
|
||||||
|
k3s_node_labels: []
|
||||||
|
k3s_lan: 192.168.100.0/24
|
||||||
|
k3s_pod_cidr: 10.42.0.0/16
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# k3s-Agent neben Docker: k3s bringt sein eigenes containerd mit und erkennt nvidia-container-runtime
|
||||||
|
# von selbst (Runtime "nvidia"). Docker bleibt für die bestehenden Compose-Stacks, bis sie umgezogen sind.
|
||||||
|
- name: Server-Name in /etc/hosts (Adresse per DHCP, D5)
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/hosts
|
||||||
|
regexp: '\s{{ k3s_server_name | regex_escape }}$'
|
||||||
|
line: "{{ k3s_server_host }} {{ k3s_server_name }}"
|
||||||
|
|
||||||
|
- name: curl vorhanden
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: curl
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Installationsskript laden
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: https://get.k3s.io
|
||||||
|
dest: "{{ k3s_install_script }}"
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Verzeichnis /etc/rancher/k3s
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/rancher/k3s
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Konfiguration /etc/rancher/k3s/config.yaml
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/rancher/k3s/config.yaml
|
||||||
|
mode: "0600"
|
||||||
|
content: |
|
||||||
|
# Verwaltet von Ansible (Rolle k3s-agent)
|
||||||
|
server: https://{{ k3s_server_name }}:6443
|
||||||
|
token: {{ k3s_token }}
|
||||||
|
node-label: {{ k3s_node_labels | to_json }}
|
||||||
|
register: k3s_agent_config
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
# ufw (nur in VMs mit aktiver Firewall): Kubelet und NodePorts aus dem LAN, Flannel-VXLAN zwischen den
|
||||||
|
# Knoten, alles aus dem Pod-Netz (CoreDNS, Device-Plugin, Portainer-Agent sprechen mit dem Knoten).
|
||||||
|
- name: Ist ufw vorhanden?
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /usr/sbin/ufw
|
||||||
|
register: k3s_ufw
|
||||||
|
|
||||||
|
- name: ufw – k3s-Ports
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
from_ip: "{{ item.from }}"
|
||||||
|
port: "{{ item.port | default(omit) }}"
|
||||||
|
proto: "{{ item.proto | default(omit) }}"
|
||||||
|
comment: "{{ item.comment }}"
|
||||||
|
loop:
|
||||||
|
- {from: "{{ k3s_lan }}", port: "10250", proto: tcp, comment: k3s-kubelet}
|
||||||
|
- {from: "{{ k3s_lan }}", port: "8472", proto: udp, comment: k3s-flannel}
|
||||||
|
- {from: "{{ k3s_lan }}", port: "30000:32767", proto: tcp, comment: k3s-nodeport}
|
||||||
|
- {from: "{{ k3s_pod_cidr }}", comment: k3s-pods}
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.comment }}"
|
||||||
|
when: k3s_ufw.stat.exists
|
||||||
|
|
||||||
|
- name: Installierte Version lesen
|
||||||
|
ansible.builtin.command: /usr/local/bin/k3s --version
|
||||||
|
register: k3s_installed
|
||||||
|
failed_when: false
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: k3s-Agent installieren oder auf die gepinnte Version bringen
|
||||||
|
ansible.builtin.command: "{{ k3s_install_script }} agent"
|
||||||
|
environment:
|
||||||
|
INSTALL_K3S_VERSION: "{{ k3s_version }}"
|
||||||
|
when: k3s_version not in (k3s_installed.stdout | default(''))
|
||||||
|
|
||||||
|
- name: Agent neu starten, wenn sich die Konfiguration geändert hat
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: k3s-agent
|
||||||
|
state: restarted
|
||||||
|
when: k3s_agent_config.changed and k3s_version in (k3s_installed.stdout | default(''))
|
||||||
|
|
||||||
|
- name: Warten, bis containerd von k3s läuft
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
path: /var/lib/rancher/k3s/agent/etc/containerd/config.toml
|
||||||
|
timeout: 120
|
||||||
|
|
||||||
|
- name: NVIDIA-Runtime von k3s erkannt?
|
||||||
|
ansible.builtin.command: grep -c nvidia /var/lib/rancher/k3s/agent/etc/containerd/config.toml
|
||||||
|
register: k3s_nvidia
|
||||||
|
changed_when: false
|
||||||
|
failed_when: k3s_nvidia.stdout | int == 0
|
||||||
|
|
||||||
|
- name: Warten, bis der Knoten im Cluster bereit ist (von ops aus)
|
||||||
|
ansible.builtin.command: kubectl get node {{ inventory_hostname }} -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
|
||||||
|
register: k3s_node_ready
|
||||||
|
until: k3s_node_ready.stdout == "True"
|
||||||
|
retries: 36
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Grundausstattung im k3s-Cluster (von ops per kubectl): NVIDIA-Runtime und Device-Plugin, Portainer-Agent.
|
||||||
|
k3s_manifest_dir: "{{ playbook_dir }}/../../../../deploy/k8s"
|
||||||
|
k3s_base_manifests:
|
||||||
|
- runtimeclass-nvidia.yaml
|
||||||
|
- nvidia-device-plugin.yaml
|
||||||
|
- portainer-agent.yaml
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
- name: Grund-Manifeste anwenden
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ k3s_manifest_dir }}/{{ item }}
|
||||||
|
loop: "{{ k3s_base_manifests }}"
|
||||||
|
register: k3s_apply
|
||||||
|
changed_when: "'configured' in k3s_apply.stdout or 'created' in k3s_apply.stdout"
|
||||||
|
|
||||||
|
- name: Warten, bis jede GPU-VM ihre GPUs meldet (nvidia.com/gpu)
|
||||||
|
ansible.builtin.command: >
|
||||||
|
kubectl get node {{ item }} -o jsonpath='{.status.allocatable.nvidia\.com/gpu}'
|
||||||
|
loop: "{{ groups['k3s_agents'] }}"
|
||||||
|
register: k3s_gpus
|
||||||
|
until: (k3s_gpus.stdout | default('0') | int) > 0
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Warten, bis der Portainer-Agent läuft
|
||||||
|
ansible.builtin.command: kubectl -n portainer rollout status deployment/portainer-agent --timeout=180s
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Übersicht
|
||||||
|
ansible.builtin.command: kubectl get nodes -o custom-columns=NAME:.metadata.name,STATUS:.status.conditions[-1].type,GPU:.status.allocatable.nvidia\.com/gpu,VERSION:.status.nodeInfo.kubeletVersion
|
||||||
|
register: k3s_nodes
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Knoten
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ k3s_nodes.stdout_lines }}"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# k3s-Steuerung (D6) auf der Dienst-VM "k3s". Version gepinnt (Kanal stable, 15.10.2026).
|
||||||
|
k3s_version: v1.36.5+k3s1
|
||||||
|
k3s_server_name: k3s.knaster # Name, unter dem die Agents den Server finden (/etc/hosts, Rolle k3s-agent)
|
||||||
|
k3s_install_script: /usr/local/src/k3s-install.sh
|
||||||
|
# Geheimnisse und Zugang landen auf ops, nicht im Repo
|
||||||
|
k3s_env_file: /root/.config/knaster/k3s.env # K3S_URL und K3S_TOKEN für die Agents
|
||||||
|
k3s_kubeconfig: /root/.kube/config # kubectl auf ops
|
||||||
|
k3s_kubectl_version: "{{ k3s_version.split('+') | first }}"
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# k3s-Server (Steuerung, Datenbank, API) auf der Dienst-VM. Ohne traefik (kein Ingress nötig),
|
||||||
|
# servicelb bleibt für Dienste vom Typ LoadBalancer. Die Agents (GPU-VMs) kommen über die Rolle k3s-agent.
|
||||||
|
- name: curl vorhanden
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: curl
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Installationsskript laden
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: https://get.k3s.io
|
||||||
|
dest: "{{ k3s_install_script }}"
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Verzeichnis /etc/rancher/k3s
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/rancher/k3s
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Konfiguration /etc/rancher/k3s/config.yaml
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/rancher/k3s/config.yaml
|
||||||
|
mode: "0600"
|
||||||
|
content: |
|
||||||
|
# Verwaltet von Ansible (Rolle k3s-server)
|
||||||
|
disable: [traefik]
|
||||||
|
write-kubeconfig-mode: "0600"
|
||||||
|
tls-san: [{{ k3s_server_name }}, {{ ansible_host }}]
|
||||||
|
node-label: [knaster.role=control]
|
||||||
|
register: k3s_server_config
|
||||||
|
|
||||||
|
- name: Installierte Version lesen
|
||||||
|
ansible.builtin.command: /usr/local/bin/k3s --version
|
||||||
|
register: k3s_installed
|
||||||
|
failed_when: false
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: k3s-Server installieren oder auf die gepinnte Version bringen
|
||||||
|
ansible.builtin.command: "{{ k3s_install_script }} server"
|
||||||
|
environment:
|
||||||
|
INSTALL_K3S_VERSION: "{{ k3s_version }}"
|
||||||
|
when: k3s_version not in (k3s_installed.stdout | default(''))
|
||||||
|
|
||||||
|
- name: k3s neu starten, wenn sich die Konfiguration geändert hat
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: k3s
|
||||||
|
state: restarted
|
||||||
|
when: k3s_server_config.changed and k3s_version in (k3s_installed.stdout | default(''))
|
||||||
|
|
||||||
|
- name: Warten, bis der Knoten bereit ist
|
||||||
|
ansible.builtin.command: /usr/local/bin/k3s kubectl get node {{ inventory_hostname }} -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
|
||||||
|
register: k3s_ready
|
||||||
|
until: k3s_ready.stdout == "True"
|
||||||
|
retries: 30
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Token und kubeconfig lesen
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ item }}"
|
||||||
|
loop: [/var/lib/rancher/k3s/server/node-token, /etc/rancher/k3s/k3s.yaml]
|
||||||
|
register: k3s_secrets
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Zugang nach ops schreiben
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0700"
|
||||||
|
loop: ["{{ k3s_env_file | dirname }}", "{{ k3s_kubeconfig | dirname }}"]
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: K3S_URL und K3S_TOKEN auf ops ({{ k3s_env_file }})
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ k3s_env_file }}"
|
||||||
|
mode: "0600"
|
||||||
|
content: |
|
||||||
|
K3S_URL=https://{{ k3s_server_name }}:6443
|
||||||
|
K3S_TOKEN={{ k3s_secrets.results[0].content | b64decode | trim }}
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: kubeconfig auf ops ({{ k3s_kubeconfig }}, Server = VM-Adresse)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ k3s_kubeconfig }}"
|
||||||
|
mode: "0600"
|
||||||
|
content: "{{ k3s_secrets.results[1].content | b64decode | replace('https://127.0.0.1:6443', 'https://' ~ ansible_host ~ ':6443') }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: kubectl auf ops (Version passend zum Server)
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "https://dl.k8s.io/release/{{ k3s_kubectl_version }}/bin/linux/amd64/kubectl"
|
||||||
|
checksum: "sha256:https://dl.k8s.io/release/{{ k3s_kubectl_version }}/bin/linux/amd64/kubectl.sha256"
|
||||||
|
dest: /usr/local/bin/kubectl
|
||||||
|
mode: "0755"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Umzug der NEX-Sprachdienste von Docker Compose nach k3s (gleiche VM, gleiche Ports, gleiche Images).
|
||||||
|
nex_speech_dir: /opt/nex-speech
|
||||||
|
nex_speech_images:
|
||||||
|
- knaster/nex-whisper:v1.9.4-cu12.6
|
||||||
|
- knaster/nex-tts-piper:1.8.0
|
||||||
|
nex_speech_k3s_manifests: "{{ playbook_dir }}/../../../../deploy/k8s/nex-speech"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 1. Images aus Docker in das containerd von k3s übernehmen (kein Neubau, kein Registry-Umweg)
|
||||||
|
- name: Images in k3s vorhanden?
|
||||||
|
ansible.builtin.command: /usr/local/bin/k3s ctr images ls -q
|
||||||
|
register: k3s_images
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Images aus Docker importieren
|
||||||
|
ansible.builtin.shell: docker save {{ item }} | /usr/local/bin/k3s ctr images import -
|
||||||
|
loop: "{{ nex_speech_images }}"
|
||||||
|
when: ("docker.io/" ~ item) not in k3s_images.stdout
|
||||||
|
async: 1800
|
||||||
|
poll: 15
|
||||||
|
|
||||||
|
# 2. Compose-Stack anhalten (Host-Ports 8178/8179 werden frei), Dateien bleiben für den Rückweg
|
||||||
|
- name: Compose-Stack nex-speech anhalten
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ nex_speech_dir }}"
|
||||||
|
state: stopped
|
||||||
|
when: nex_speech_compose_stop | default(true)
|
||||||
|
|
||||||
|
# 3. Manifeste anwenden und auf Bereitschaft warten (Probes prüfen /health in der VM)
|
||||||
|
- name: Manifeste anwenden
|
||||||
|
ansible.builtin.command: kubectl apply -f {{ nex_speech_k3s_manifests }}/
|
||||||
|
register: nex_apply
|
||||||
|
changed_when: "'configured' in nex_apply.stdout or 'created' in nex_apply.stdout"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Warten, bis beide Deployments bereit sind
|
||||||
|
ansible.builtin.command: kubectl -n nex rollout status deployment/{{ item }} --timeout=600s
|
||||||
|
loop: [nex-stt, nex-tts]
|
||||||
|
changed_when: false
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Gesundheit in der VM prüfen
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ item }}/health"
|
||||||
|
status_code: 200
|
||||||
|
loop: [8178, 8179]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user