k3s: Pod-Monitoring in Grafana (Task 6.5)
ci / test (push) Failing after 23s

kube-state-metrics (NodePort 30808) und ServiceAccount prometheus-infra (nur
nodes/metrics) im Cluster; Prometheus auf infra liest kubelet/cAdvisor per TLS
gegen die k3s-CA. Ziele per file_sd aus kubectl get nodes, cAdvisor auf die
nötigen Reihen begrenzt. Dashboard "k3s – Pods" (Vorgabe nex), Alerts K3s*
nur mit notify. Playbook monitoring-k3s.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Robert Rapp
2026-10-19 16:31:03 +02:00
co-authored by Claude Opus 5.5
parent abf4a75063
commit 4ac251715e
17 changed files with 1271 additions and 13 deletions
@@ -18,6 +18,11 @@ Registry (die kennt je Slot nur eine VM, die zweite Embedding-Instanz fehlt dort
`--llamacpp '{"gpu-pve44": ["embed"], ...}'`. Die Slots eines Dienstes kommen aus der Registry
(engine llamacpp, gleicher Container). Labels zusätzlich docker_host (= VM) und container, damit der
Healer die tatsächliche VM trifft. Diese Slots zählen auch für node/gpu der VM.
k3s (Task 6.5): Mit `--k3s-nodes DATEI` (Ausgabe von `kubectl get nodes -o json`) entstehen zusätzlich
DIR/kubelet.json (jeder Knoten, Port 10250, für die Jobs kubelet und cadvisor) und
DIR/kube-state-metrics.json (ein Ziel: NodePort auf der Dienst-VM k3s, sonst auf dem ersten bereiten Knoten).
Ohne die Option bleiben beide Listen leer.
"""
from __future__ import annotations
@@ -35,6 +40,9 @@ LLAMACPP_PROFILES = {
"embed": [("llamacpp-embed", 8081), ("llamacpp-rerank", 8082)],
"chat": [("llamacpp-chat", 8080)],
}
KUBELET_PORT = 10250
KSM_NODEPORT = 30808 # deploy/k8s/monitoring/kube-state-metrics.yaml
K3S_SERVER = "k3s" # Dienst-VM mit der Steuerung (HA), bevorzugtes Ziel für kube-state-metrics
def slots_by_vmid(registry: dict) -> dict[str, list[str]]:
@@ -89,11 +97,41 @@ def build_targets(gpu_vms: dict, registry: dict,
return result
def _internal_ip(node: dict) -> str | None:
return next((a["address"] for a in node.get("status", {}).get("addresses", [])
if a.get("type") == "InternalIP"), None)
def _ready(node: dict) -> bool:
return any(c.get("type") == "Ready" and c.get("status") == "True"
for c in node.get("status", {}).get("conditions", []))
def build_k3s_targets(nodes: dict) -> dict[str, list[dict]]:
"""Ziele aus `kubectl get nodes -o json`. instance ist der Knotenname (bleibt bei IP-Wechsel gleich)."""
kubelet: list[dict] = []
ready: list[tuple[str, str]] = []
for node in sorted(nodes.get("items", []), key=lambda n: n["metadata"]["name"]):
name, ip = node["metadata"]["name"], _internal_ip(node)
if not ip:
print(f"Warnung: k3s-Knoten {name} ohne InternalIP, übersprungen", file=sys.stderr)
continue
kubelet.append({"targets": [f"{ip}:{KUBELET_PORT}"], "labels": {"instance": name, "node": name}})
if _ready(node):
ready.append((name, ip))
ksm: list[dict] = []
if ready:
_, ip = next((r for r in ready if r[0] == K3S_SERVER), ready[0])
ksm.append({"targets": [f"{ip}:{KSM_NODEPORT}"], "labels": {"instance": "kube-state-metrics"}})
return {"kubelet": kubelet, "kube-state-metrics": ksm}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--registry", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--llamacpp", default="{}", help='JSON {"<vm>": ["embed"|"chat", ...]} aus dem Inventar')
ap.add_argument("--k3s-nodes", type=Path, help="Ausgabe von `kubectl get nodes -o json` (Task 6.5)")
args = ap.parse_args()
gpu_vms = json.load(sys.stdin)
@@ -103,6 +141,8 @@ def main() -> int:
if unknown:
ap.error(f"unbekannte llama.cpp-Profile: {sorted(unknown)}")
targets = build_targets(gpu_vms, registry, llamacpp)
k3s_nodes = json.loads(args.k3s_nodes.read_text(encoding="utf-8")) if args.k3s_nodes else {}
targets.update(build_k3s_targets(k3s_nodes))
args.out.mkdir(parents=True, exist_ok=True)
for job, entries in targets.items():
path = args.out / f"{job}.json"