diff --git a/README.md b/README.md index 483ba93..2a6fa0f 100644 --- a/README.md +++ b/README.md @@ -545,6 +545,48 @@ Wer kein sudo hat, aber in der Gruppe `docker` ist: Container-Variante aus [netbird-observability/stacks/telegraf-node](https://gitea.m-m.bio/mm/netbird-observability) (privates Repo). Dort ist `network_mode: host` gesetzt — ohne das sieht Telegraf nur `lo`+`eth0` statt der echten Interfaces. +## llama-swap-Telemetrie ohne sudo (Nutzer-Instanz) + +Für Knoten, die eine lokale Inferenz-Landschaft über **llama-swap** anbieten und +**kein sudo** haben (ROG). Die System-Instanz dieses Installers kann dort nicht +greifen — und sie würde llama-swap auch nicht sichtbar machen: llama-swap läuft +als **User**-Unit, seine INFO-Zeilen liegen im Nutzer-Journal, und +`journal-tail.sh` liest das Systemjournal mit `priority >= warning`. Ergebnis +vorher: null llama-swap-Zeilen in Loki. + +```sh +# auf dem Zielknoten, als der Nutzer, dem llama-swap gehört +sh scripts/install-llamaswap-user.sh +# Optionen: --raw (Dateien vom Repo laden) --dry-run +``` + +Was die Nutzer-Instanz holt: + +| Quelle | Weg | Inhalt | +|---|---|---| +| `llama-swap:9300/metrics` | `inputs.prometheus` | Host (CPU je Kern, RAM, Netz), GPU (Auslastung, VRAM, Temperatur, Leistung) | +| `/api/metrics/stats?model=` | `inputs.exec` → `llamaswap-telemetrie.py` | Token/Anfragen/Cache **je Modell** — auch für Modelle ohne `/metrics` (strata-Engine, sd-server) | +| `http:///metrics` | dito | Token je **laufendem** Profil aus llama.cpp (nur mit `--metrics`) | +| `journalctl --user-unit …` | `inputs.exec` → `llamaswap-logs.py` | Logs von llama-swap und kalina-backends für Loki | + +Voraussetzungen, die llama-swap selbst erfüllen muss: + +* **`--metrics` in den Startbefehlen** — llama.cpp liefert `/metrics` nur mit + diesem Schalter (sonst HTTP 501). Für `model-manager.sh`, die Inline-`cmd`s in + `llama-swap.yaml` und eigene Startskripte. +* **`store.path` in `llama-swap.yaml`** — ohne Datei-Store hält llama-swap die + Aktivität nur im RAM und kürzt sie nach `metricsMaxInMemory`; Token-Summen + wären dann ein rollendes Fenster. Mit Store sind sie dauerhaft (SQLite). + +Zugangsdaten bleiben dort, wo der Knoten-Installer sie hingelegt hat: +`EnvironmentFile=/etc/default/telegraf` für die Unit, `${PROM_URL}` / +`${LOKI_URL}` / `${TELEGRAF_TOKEN}` in der Konfiguration. Kein Kopieren von +Token. Ohne `linger` endet die Unit mit der Sitzung — `loginctl enable-linger $USER`. + +Grafana: Dashboard **llama-swap (ROG)** (`uid: llama-swap-rog`) in +`netbird-observability/stacks/central-imac/dashboards/llama-swap.json`. + + ## Error-Logs (seit 1.3.0) Apache schreibt **zwei** Logarten mit **verschiedenen** Formaten: diff --git a/scripts/install-llamaswap-user.sh b/scripts/install-llamaswap-user.sh new file mode 100755 index 0000000..0107a0c --- /dev/null +++ b/scripts/install-llamaswap-user.sh @@ -0,0 +1,155 @@ +#!/bin/sh +# Installiert die llama-swap-Telemetrie als NUTZER-Instanz von Telegraf. +# +# WOFUER: Knoten, auf denen llama-swap laeuft, aber kein sudo existiert (ROG). +# Die System-Instanz (install.sh) kann hier nicht greifen: sie schreibt nach +# /etc/telegraf/telegraf.d und laeuft als root-Dienst. Ausserdem liest sie das +# Systemjournal mit priority>=warning — llama-swaps INFO-Zeilen (Modellstart, +# Ports) liegen im NUTZER-Journal und wurden deshalb nie nach Loki geschickt. +# Diese Instanz darf `journalctl --user-unit` lesen und braucht kein root. +# +# ZUGANGSDATEN: kommen NICHT in die Konfiguration. Das Systemd-Unit setzt +# EnvironmentFile=/etc/default/telegraf; Telegraf expandiert ${PROM_URL}, +# ${LOKI_URL} und ${TELEGRAF_TOKEN} beim Laden. Damit bleibt +# /etc/default/telegraf (das der System-Installer geschrieben hat) die einzige +# Quelle pro Knoten. +# +# BENUTZUNG (auf dem Zielknoten, als der Nutzer, dem llama-swap gehoert): +# sh install-llamaswap-user.sh # Dateien liegen neben dem Skript +# sh install-llamaswap-user.sh --raw # sonst von dort laden +# sh install-llamaswap-user.sh --dry-run # nur zeigen, nichts schreiben +# +# IDEMPOTENT: mehrfaches Ausfuehren ist erlaubt; Unit wird neu geladen und +# neu gestartet. Der Journal-Cursor bleibt erhalten (kein doppelter Versand). + +set -eu + +RAW_BASE="" +DRY=0 +HIER="$(cd "$(dirname "$0")" && pwd)" + +while [ $# -gt 0 ]; do + case "$1" in + --raw) RAW_BASE="$2"; shift 2 ;; + --dry-run) DRY=1; shift ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) printf 'Unbekannte Option: %s\n' "$1" >&2; exit 2 ;; + esac +done + +BIN_DIR="$HOME/bin" +CONF_DIR="$HOME/.config/telegraf-llamaswap" +UNIT_DIR="$HOME/.config/systemd/user" +STATE_DIR="$HOME/.local/state/llamaswap-logs" + +sag() { printf ' %s\n' "$*"; } +schritt() { printf '\n== %s\n' "$*"; } + +# ---------------------------------------------------------------- Vorbedingungen +schritt "Vorbedingungen" +if [ ! -r /etc/default/telegraf ]; then + printf 'FEHLER: /etc/default/telegraf fehlt oder ist nicht lesbar.\n' >&2 + printf ' Ohne PROM_URL/LOKI_URL/TELEGRAF_TOKEN gibt es kein Ziel.\n' >&2 + printf ' Zuerst den Knoten-Installer laufen lassen (install.sh).\n' >&2 + exit 1 +fi +for var in NODE_NAME PROM_URL LOKI_URL TELEGRAF_TOKEN; do + if ! grep -q "^${var}=" /etc/default/telegraf; then + printf 'FEHLER: %s fehlt in /etc/default/telegraf.\n' "$var" >&2 + exit 1 + fi +done +sag "/etc/default/telegraf vorhanden (NODE_NAME, PROM_URL, LOKI_URL, TELEGRAF_TOKEN)" + +TGBIN="" +for kandidat in /usr/bin/telegraf /usr/local/bin/telegraf; do + [ -x "$kandidat" ] && TGBIN="$kandidat" && break +done +[ -n "$TGBIN" ] || { printf 'FEHLER: telegraf-Binary nicht gefunden.\n' >&2; exit 1; } +sag "Telegraf: $TGBIN ($("$TGBIN" --version | head -1))" + +# ------------------------------------------------------------------- Dateien holen +hole() { # hole + _rel="$1"; _dst="$2" + if [ -f "$HIER/../$_rel" ]; then + cp "$HIER/../$_rel" "$_dst"; sag "aus Repo: $_rel" + elif [ -n "$RAW_BASE" ]; then + if command -v curl >/dev/null 2>&1; then + curl -fsSL -m 60 "$RAW_BASE/$_rel" -o "$_dst"; sag "geladen: $_rel" + else + printf 'FEHLER: curl fehlt und $_rel nicht lokal vorhanden.\n' >&2; exit 1 + fi + else + printf 'FEHLER: %s weder lokal noch --raw gesetzt.\n' "$_rel" >&2; exit 1 + fi + [ -s "$_dst" ] || { printf 'FEHLER: %s ist leer.\n' "$_dst" >&2; exit 1; } +} + +if [ "$DRY" -eq 1 ]; then + schritt "Trockenlauf — es wird nichts geschrieben" + sag "wuerde anlegen: $BIN_DIR/llamaswap-telemetrie.py, $BIN_DIR/llamaswap-logs.py" + sag "wuerde anlegen: $CONF_DIR/telegraf.conf (0600)" + sag "wuerde anlegen: $UNIT_DIR/telegraf-llamaswap.service" + sag "wuerde starten: systemctl --user enable --now telegraf-llamaswap.service" + exit 0 +fi + +schritt "Programme nach $BIN_DIR" +mkdir -p "$BIN_DIR" "$CONF_DIR" "$UNIT_DIR" "$STATE_DIR" +hole scripts/llamaswap-telemetrie.py "$BIN_DIR/llamaswap-telemetrie.py" +hole scripts/llamaswap-logs.py "$BIN_DIR/llamaswap-logs.py" +chmod 755 "$BIN_DIR/llamaswap-telemetrie.py" "$BIN_DIR/llamaswap-logs.py" + +schritt "Konfiguration nach $CONF_DIR" +hole telegraf/llamaswap-user.conf "$CONF_DIR/telegraf.conf" +chmod 600 "$CONF_DIR/telegraf.conf" # 0600: hier stehen keine Geheimnisse, + # aber die Datei ist auch nicht oeffentlich + +schritt "Unit nach $UNIT_DIR" +hole telegraf/llamaswap-user.service "$UNIT_DIR/telegraf-llamaswap.service" + +schritt "Probelauf der beiden Programme (vor dem Dienst)" +set -a; . /etc/default/telegraf; set +a +metriken="$(python3 "$BIN_DIR/llamaswap-telemetrie.py" | wc -l)" +logs="$(python3 "$BIN_DIR/llamaswap-logs.py" | wc -l)" +sag "llamaswap-telemetrie.py: $metriken Zeilen" +sag "llamaswap-logs.py: $logs Zeilen (0 ist normal, wenn gerade nichts passiert)" +if [ "$metriken" -eq 0 ]; then + sag "WARNUNG: keine Metriken — laeuft llama-swap auf diesem Knoten?" +fi + +schritt "Telegraf-Konfiguration pruefen (--test, keine Ausgabe an die Ziele)" +if ! "$TGBIN" --config "$CONF_DIR/telegraf.conf" --test --test-wait 5 >/dev/null 2>&1; then + printf 'FEHLER: Telegraf meldet einen Konfigurationsfehler.\n' >&2 + "$TGBIN" --config "$CONF_DIR/telegraf.conf" --test --test-wait 5 2>&1 | tail -5 >&2 + exit 1 +fi +sag "Konfiguration in Ordnung" + +schritt "Dienst starten" +systemctl --user daemon-reload +systemctl --user enable --now telegraf-llamaswap.service +sleep 6 +zustand="$(systemctl --user is-active telegraf-llamaswap.service)" +sag "telegraf-llamaswap.service: $zustand" +[ "$zustand" = "active" ] || { + printf 'FEHLER: Dienst laeuft nicht. Log:\n' >&2 + journalctl --user -u telegraf-llamaswap.service -n 15 --no-pager >&2 + exit 1 +} + +cat <<'HINWEIS' + +== Fertig. Was jetzt laeuft + * llama-swap /metrics -> Prometheus (Host-, GPU-Werte) + * llamaswap_activity_* -> Token/Anfragen je Modell + * llamacpp_* -> Token je laufendem Profil (nur mit --metrics) + * Nutzer-Journal der Units -> Loki (unit="llama-swap.service") + + Ohne `linger` endet der Dienst mit der Sitzung des Nutzers: + loginctl enable-linger $USER (braucht meist root, einmalig) + + Pruefen: + systemctl --user status telegraf-llamaswap.service + journalctl --user -u telegraf-llamaswap.service -n 20 +HINWEIS diff --git a/scripts/llamaswap-logs.py b/scripts/llamaswap-logs.py new file mode 100755 index 0000000..0e53e01 --- /dev/null +++ b/scripts/llamaswap-logs.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Logs der llama-swap-Nutzer-Units -> Influx Line Protocol fuer Telegraf. + +Warum eigene Quelle: + llama-swap und kalina-backends laufen als *User*-Units. Der Knoten-Telegraf + liest das Systemjournal ueber journal-tail.sh und filtert dort zusaetzlich + auf priority >= warning. Die INFO-Zeilen von llama-swap (Modellstart, + Port-Vergabe, Weckpfad) und alles von kalina-backends kommen deshalb dort + nie an - Loki hatte fuer den ROG keine einzige llama-swap-Zeile. + Ein Nutzer-Prozess darf --user-unit lesen; damit ist kein sudo noetig. + +Ausgabe: dieselbe Zeilenform wie journal-tail.sh (measurement `journal`, Tags +unit/ident/priority/jhost, Feld message), damit die bestehenden Log-Panels im +Grafana ohne Sonderfall greifen. + +Zustand: __CURSOR der letzten Zeile in $STATE/cursor - keine Duplikate, kein +Verlust bei Neustart oder Rotation. +""" + +import json +import os +import subprocess +import sys + +UNITS = os.environ.get( + "LLAMASWAP_LOG_UNITS", "llama-swap.service,kalina-backends.service" +).split(",") +PRIORITY = os.environ.get("LLAMASWAP_LOG_PRIORITY", "info") +MAX_LINES = os.environ.get("LLAMASWAP_LOG_MAX_LINES", "800") +BUDGET = float(os.environ.get("LLAMASWAP_LOG_BUDGET", "12")) +SINCE = os.environ.get("LLAMASWAP_LOG_SINCE", "-5min") +STATE = os.path.expanduser( + os.environ.get("LLAMASWAP_LOG_STATE", "~/.local/state/llamaswap-logs") +) +CURSOR = os.path.join(STATE, "cursor") + + +def etag(wert): + """Tag-Werte: Komma, Leerzeichen, Gleichheitszeichen escapen.""" + for zeichen in ("\\", " ", ",", "="): + wert = wert.replace(zeichen, "\\" + zeichen) + return wert + + +def estr(wert): + """String-Feld: Backslash und Anfuehrungszeichen escapen.""" + return wert.replace("\\", "\\\\").replace('"', '\\"') + + +def journal_zeilen(): + args = ["journalctl", "-o", "json", "--no-pager", "--no-hostname", + "-p", PRIORITY, "-n", MAX_LINES] + for unit in UNITS: + unit = unit.strip() + if unit: + args += ["--user-unit", unit] + + try: + os.makedirs(STATE, exist_ok=True) + except OSError: + return [] + + if os.path.exists(CURSOR): + try: + with open(CURSOR, "r", encoding="utf-8") as fh: + letzter = fh.read().strip() + except OSError: + letzter = "" + args += ["--after-cursor", letzter] if letzter else ["--since", SINCE] + else: + args += ["--since", SINCE] + + try: + ergebnis = subprocess.run( + args, capture_output=True, text=True, timeout=BUDGET + ) + except (subprocess.TimeoutExpired, OSError): + return [] + return [z for z in ergebnis.stdout.splitlines() if z.startswith("{")] + + +def main(): + ausgabe, neuer_cursor = [], "" + for zeile in journal_zeilen(): + try: + eintrag = json.loads(zeile) + except ValueError: + continue + + neuer_cursor = eintrag.get("__CURSOR", neuer_cursor) + nachricht = eintrag.get("MESSAGE", "") + if not isinstance(nachricht, str) or not nachricht: + continue + + unit = eintrag.get("_SYSTEMD_USER_UNIT") or eintrag.get("_SYSTEMD_UNIT") \ + or eintrag.get("SYSLOG_IDENTIFIER") or "unknown" + ident = eintrag.get("SYSLOG_IDENTIFIER") or "unknown" + prio = str(eintrag.get("PRIORITY", "0")) + jhost = eintrag.get("_HOSTNAME") or "unknown" + ts = str(eintrag.get("__REALTIME_TIMESTAMP", "")) + + ns = ts + "000" if ts.isdigit() else "" + if not ns: + continue + + ausgabe.append( + 'journal,unit=%s,ident=%s,priority=%s,jhost=%s message="%s" %s' + % (etag(unit), etag(ident), etag(prio), etag(jhost), + estr(nachricht), ns) + ) + + if neuer_cursor: + try: + with open(CURSOR + ".tmp", "w", encoding="utf-8") as fh: + fh.write(neuer_cursor) + os.replace(CURSOR + ".tmp", CURSOR) + except OSError: + pass + + if ausgabe: + sys.stdout.write("\n".join(ausgabe) + "\n") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as fehler: # fail-open: nie den exec brechen + sys.stderr.write("llamaswap-logs: %s\n" % fehler) + sys.exit(0) diff --git a/scripts/llamaswap-telemetrie.py b/scripts/llamaswap-telemetrie.py new file mode 100755 index 0000000..043b6d8 --- /dev/null +++ b/scripts/llamaswap-telemetrie.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""llama-swap + llama.cpp -> Prometheus-Text fuer Telegraf (inputs.exec). + +Warum dieses Skript ueberhaupt: + llama-swap vergibt die Upstream-Ports DYNAMISCH (`${PORT}`, startPort 9340, + stabile Modell-ID-Reihenfolge) - ein statischer Scrape kann sie nicht + treffen. Ausserdem liegen die Token-Zahlen in zwei Quellen: llama.cpp + (/metrics, nur mit --metrics, kein Modell-Label) und llama-swaps eigenem + Statistik-Store (/api/metrics/stats, das die Upstream-Antworten selbst + auswertet und damit auch strata und sd-server abdeckt). + Ein exec-Input, das llama-swap selbst fragt, ist deshalb der einzige Weg + ohne Raten. + +Quellen (alle nur lesend, jede mit eigenem Timeout, Fehler brechen nichts ab): + * GET /running laufende Modelle + Upstream-Adresse + * GET /v1/models alle Modell-IDs + * GET /api/metrics/stats?model= Token-Summen je Modell + * GET http:///metrics llama.cpp-Zaehler je Profil + +Ausgabe: Prometheus-Textformat auf stdout. + * Keine erreichbare Quelle = keine Zeile fuer diese Quelle. + * Es wird nie ein Wert erfunden und nie ein Teil-Fehler als 0 gemeldet. + * Rueckgabewert ist immer 0, damit Telegraf den Lauf nicht als Fehler sieht. + +Aufruf durch Telegraf (Nutzer-Instanz auf dem ROG): + [[inputs.exec]] + commands = ["/home/mm/bin/llamaswap-telemetrie.py"] + data_format = "prometheus" + timeout = "25s" + interval = "60s" +""" + +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request + +BASE = os.environ.get("LLAMASWAP_BASE", "http://192.168.188.184:9300") +TIMEOUT = float(os.environ.get("LLAMASWAP_TIMEOUT", "4")) + +# Profile, die model-manager.sh auf festen Ports startet (Dauerlaeufer und +# Handstarts). Sie erscheinen NICHT in /running, wenn llama-swap sie nicht +# verwaltet - deshalb fest hinterlegt. Nicht erreichbare Ports kosten nur einen +# sofortigen ECONNREFUSED, keine Wartezeit. +FESTE_PORTS = { + 8101: "chat", + 8102: "embedding", + 8103: "vision", + 8104: "ocr", + 8105: "rerank", + 8107: "fast", + 8108: "agent", + 8109: "desktop", +} + +METRIK_RE = re.compile(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{[^}]*\})?\s+(\S+)\s*$") + + +def hole(pfad, timeout=None): + """GET gegen llama-swap. Gibt (status, bytes) zurueck - status 0 bei Fehler.""" + url = pfad if pfad.startswith("http") else BASE + pfad + try: + with urllib.request.urlopen(url, timeout=timeout or TIMEOUT) as antwort: + return antwort.status, antwort.read() + except urllib.error.HTTPError as e: + return e.code, b"" + except Exception: + return 0, b"" + + +def hole_json(pfad): + status, rohdaten = hole(pfad) + if status != 200 or not rohdaten: + return None + try: + return json.loads(rohdaten) + except ValueError: + return None + + +def name_saeubern(name): + """llama.cpp liefert `llamacpp:prompt_tokens_total` (Doppelpunkt erlaubt, + aber Prometheus reserviert ihn fuer Recording-Regeln) -> Unterstrich.""" + return name.replace(":", "_") + + +def doppelpunkt_label_frei(labels): + """Label-String normalisieren: fuehrende/abschliessende Klammern entfernen.""" + if not labels: + return "" + labels = labels.strip() + if labels.startswith("{") and labels.endswith("}"): + return labels[1:-1] + return "" + + +def upstream_kandidaten(): + """{port: modell} aus /running plus die festen model-manager-Ports.""" + kandidaten = dict(FESTE_PORTS) + running = hole_json("/running") + if isinstance(running, dict): + for eintrag in running.get("running", []) or []: + modell = eintrag.get("model") or "" + proxy = eintrag.get("proxy") or "" + m = re.search(r":(\d+)\s*$", proxy) + if m and modell: + # /running gewinnt: llama-swap kennt den echten Modellnamen. + kandidaten[int(m.group(1))] = modell + return kandidaten + + +def llama_cpp_zeilen(port, modell): + """Prometheus-Zeilen eines llama.cpp-Upstreams, mit model-Label ergaenzt.""" + status, rohdaten = hole("http://192.168.188.184:%d/metrics" % port, timeout=2) + if status != 200 or not rohdaten: + return [], [] + text = rohdaten.decode("utf-8", "replace") + if "llamacpp" not in text: # kein llama.cpp (strata, sd-server) + return [], [] + + typen, zeilen = {}, [] + for zeile in text.splitlines(): + zeile = zeile.strip() + if not zeile: + continue + if zeile.startswith("# TYPE"): + teile = zeile.split() + if len(teile) >= 4: + typen[name_saeubern(teile[2])] = teile[3] + continue + if zeile.startswith("#"): + continue + m = METRIK_RE.match(zeile) + if not m: + continue + name = name_saeubern(m.group(1)) + labels = doppelpunkt_label_frei(m.group(2)) + wert = m.group(3) + if wert in ("NaN", "+Inf", "-Inf"): + continue + try: + float(wert) + except ValueError: + continue + neue_labels = ('model="%s"' % modell) + ("," + labels if labels else "") + zeilen.append((name, neue_labels, wert)) + return typen, zeilen + + +def aktivitaets_zeilen(): + """Token-Summen je Modell aus llama-swaps Statistik-Store.""" + modelle = hole_json("/v1/models") + if not isinstance(modelle, dict): + return [], [] + ids = [m.get("id") for m in modelle.get("data", []) or [] if m.get("id")] + felder = [ + ("total_requests", "llamaswap_activity_requests_total"), + ("total_input_tokens", "llamaswap_activity_input_tokens_total"), + ("total_output_tokens", "llamaswap_activity_output_tokens_total"), + ("total_cache_tokens", "llamaswap_activity_cache_tokens_total"), + ] + zeilen = [] + for modell in ids: + stats = hole_json("/api/metrics/stats?model=%s" % urllib.parse.quote(modell)) + if not isinstance(stats, dict): + continue + for schluessel, metrik in felder: + wert = stats.get(schluessel) + if isinstance(wert, (int, float)) and not isinstance(wert, bool): + zeilen.append((metrik, 'model="%s"' % modell, "%d" % wert)) + return zeilen + + +def main(): + typen = {} + zeilen = [] + + kandidaten = upstream_kandidaten() + for port, modell in sorted(kandidaten.items()): + t, z = llama_cpp_zeilen(port, modell) + typen.update(t) + zeilen.extend(z) + + aktivitaet = aktivitaets_zeilen() + for metrik, _, _ in aktivitaet: + typen[metrik] = "counter" + zeilen.extend(aktivitaet) + + if not zeilen: + # Nichts erreichbar: lieber gar nichts ausgeben als Nullen. + return 0 + + ausgabe = [] + for name in sorted({n for n, _, _ in zeilen}): + typ = typen.get(name, "gauge") + ausgabe.append("# TYPE %s %s" % (name, typ)) + for n, labels, wert in zeilen: + if n != name: + continue + ausgabe.append("%s{%s} %s" % (n, labels, wert)) + sys.stdout.write("\n".join(ausgabe) + "\n") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as fehler: # fail-open: nie den exec brechen + sys.stderr.write("llamaswap-telemetrie: %s\n" % fehler) + sys.exit(0) diff --git a/telegraf/llamaswap-user.conf b/telegraf/llamaswap-user.conf new file mode 100644 index 0000000..92f66a0 --- /dev/null +++ b/telegraf/llamaswap-user.conf @@ -0,0 +1,82 @@ +# llama-swap-Telemetrie des ROG — Nutzer-Instanz von Telegraf. +# +# Warum eine zweite Instanz und nicht /etc/telegraf/telegraf.d: +# Auf dem ROG gibt es kein sudo. llama-swap und kalina-backends laufen als +# Nutzer-Units; ihre Logs stehen im Nutzer-Journal und werden von der +# System-Instanz (journal-tail.sh, priority>=warning) verworfen. Eine +# Nutzer-Instanz darf --user-unit lesen und braucht keinen root. +# +# Zugangsdaten NICHT hier: das Systemd-Unit setzt +# EnvironmentFile=/etc/default/telegraf, Telegraf expandiert ${PROM_URL}, +# ${LOKI_URL}, ${TELEGRAF_TOKEN} beim Laden. Eine Quelle fuer alle Knoten. +# +# Aufruf: telegraf --config /home/mm/.config/telegraf-llamaswap/telegraf.conf + +[global_tags] + node = "ROG" + +[agent] + interval = "15s" + flush_interval = "15s" + metric_batch_size = 1000 + metric_buffer_limit = 20000 + hostname = "ROG" + omit_hostname = false + round_interval = true + skip_processors_after_aggregators = true + +# ---- Metriken → Prometheus Remote Write (imac) ---- +[[outputs.http]] + url = "${PROM_URL}" + data_format = "prometheusremotewrite" + timeout = "10s" + namedrop = ["journal"] + [outputs.http.headers] + Content-Type = "application/x-protobuf" + Content-Encoding = "snappy" + X-Prometheus-Remote-Write-Version = "0.1.0" + Authorization = "Bearer ${TELEGRAF_TOKEN}" + +# ---- Logs → Loki (imac) ---- +[[outputs.loki]] + domain = "${LOKI_URL}" + endpoint = "/loki/api/v1/push" + timeout = "10s" + namepass = ["journal"] + sanitize_label_names = true + metric_name_label = "" + http_headers = { "Authorization" = "Bearer ${TELEGRAF_TOKEN}" } + +# ---- 1. llama-swap selbst: System- und GPU-Werte ---- +# /metrics liefert llama-swap nativ (CPU, RAM, Netz, GPU-Auslastung, VRAM, +# Temperatur, Leistungsaufnahme). Namen sind schon llamaswap_*. +[[inputs.prometheus]] + urls = ["http://192.168.188.184:9300/metrics"] + metric_version = 2 + interval = "30s" + timeout = "10s" + [inputs.prometheus.tags] + app = "llama-swap" + +# ---- 2. Token-/Request-Zaehler je Modell ---- +# Ein exec statt fester Ports: llama-swap vergibt die Upstream-Ports dynamisch +# (${PORT}, startPort 9340) und nur laufende Modelle haben ueberhaupt eine +# /metrics. Das Skript fragt llama-swap selbst und ergaenzt das model-Label. +[[inputs.exec]] + commands = [["/home/mm/bin/llamaswap-telemetrie.py"]] + data_format = "prometheus" + interval = "60s" + timeout = "25s" + [inputs.exec.tags] + app = "llama-swap" + +# ---- 3. Logs der Nutzer-Units (llama-swap, kalina-backends) ---- +# Gleiche Zeilenform wie journal-tail.sh (measurement journal), damit die +# bestehenden Log-Panels ohne Sonderfall greifen. +[[inputs.exec]] + commands = [["/home/mm/bin/llamaswap-logs.py"]] + data_format = "influx" + interval = "30s" + timeout = "20s" + [inputs.exec.tags] + app = "llama-swap" diff --git a/telegraf/llamaswap-user.service b/telegraf/llamaswap-user.service new file mode 100644 index 0000000..7e7c078 --- /dev/null +++ b/telegraf/llamaswap-user.service @@ -0,0 +1,22 @@ +[Unit] +Description=Telegraf — llama-swap-Telemetrie (ROG, Nutzer-Instanz) +Documentation=https://github.com/influxdata/telegraf +# Ohne llama-swap gibt es nichts zu holen; die Unit startet trotzdem, damit +# ein spaeterer llama-swap-Start nicht am Logger scheitert (After statt Requires). +After=network-online.target llama-swap.service +Wants=llama-swap.service + +[Service] +Type=notify +NotifyAccess=all +# Dieselben Zugangsdaten wie die System-Instanz: eine Quelle pro Knoten, +# keine Kopie des Tokens in dieser Unit oder im Conf. +EnvironmentFile=/etc/default/telegraf +ExecStart=/usr/bin/telegraf --config /home/mm/.config/telegraf-llamaswap/telegraf.conf +Restart=on-failure +RestartForceExitStatus=SIGPIPE +KillMode=mixed +LimitMEMLOCK=8M:8M + +[Install] +WantedBy=default.target