From 6b169c77e6a3e183391ce9a9b64d47351526c073 Mon Sep 17 00:00:00 2001 From: kalina Date: Sat, 10 Oct 2026 23:26:19 +0200 Subject: [PATCH] ArchiveBox-Telemetrie: Exporteur + Telegraf-Container fuer Knoten ohne sudo archibald hat kein sudo und kein linger, aber Docker-Gruppe. Muster wie bei der llama-swap-Telemetrie auf dem ROG: ein Nutzer-Cron fuehrt den Exporteur aus, Telegraf laeuft als Container daneben und pusht per Remote Write zum Collector im Mesh. - scripts/archivebox-telemetrie.py: liest die ArchiveBox-Logs und die SQLite-DB IM Container (Host-User mm darf index.sqlite3 nicht oeffnen, Container-User 911 schon) und schreibt eine Prometheus-Exposition. Kein node-Label -- das setzt Telegraf per global_tags. - scripts/install-archivebox-telemetrie.sh: installiert Exporteur, Cron-Eintrag (jede Minute) und den Container, mit --dry-run. - stacks/telegraf-container/: telegraf 1.39, network_mode host, inputs.file (data_format prometheus), outputs.prometheus_remote_write. --- scripts/archivebox-telemetrie.py | 210 +++++++++++++++++++ scripts/install-archivebox-telemetrie.sh | 90 ++++++++ stacks/telegraf-container/docker-compose.yml | 32 +++ stacks/telegraf-container/telegraf.conf | 44 ++++ 4 files changed, 376 insertions(+) create mode 100755 scripts/archivebox-telemetrie.py create mode 100755 scripts/install-archivebox-telemetrie.sh create mode 100644 stacks/telegraf-container/docker-compose.yml create mode 100644 stacks/telegraf-container/telegraf.conf diff --git a/scripts/archivebox-telemetrie.py b/scripts/archivebox-telemetrie.py new file mode 100755 index 0000000..7594836 --- /dev/null +++ b/scripts/archivebox-telemetrie.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""ArchiveBox-Runner-Telemetrie fuer den Knoten archibald. + +Laeuft als Nutzer `mm` per cron (jede Minute) und schreibt Prometheus-Exposition +in eine .prom-Datei, die eine Telegraf-Container-Instanz liest und per +Remote Write an den Collector (imac) schickt. + +Warum ueberhaupt ein Exporteur: ArchiveBox' eigener Healthcheck prueft nur den +Webserver, nicht den Runner. Am 03.10.2026 blieb der Runner sieben Tage in einer +Neustart-Schleife (uid-Mismatch im Archivbaum, PermissionError beim mkdir des +Crawl-Verzeichnisses), waehrend Healthcheck und API gruen waren und jeder neue +Snapshot dauerhaft `queued` blieb. + +Warum die Werte aus dem Container gelesen werden: der Host-Nutzer `mm` darf +index.sqlite3 (uid 911) nicht oeffnen; `docker exec` laeuft als Container-User. +Die Logdateien dagegen liegen im Host-Dateisystem und sind direkt lesbar. + +Bewusst KEIN `node`-Label in der Ausgabe -- das setzt Telegraf per +[global_tags] (sonst entstehen zwei Serien derselben Identitaet). +""" +import os +import re +import subprocess +import sys +import time + +ARCHIVEBOX_DIR = "/home/mm/archivebox" +OUT_DIR = "/home/mm/abx-telemetrie" +OUT_FILE = os.path.join(OUT_DIR, "abx.prom") +LOG_FILE = os.path.join(OUT_DIR, "exporteur.log") +CONTAINER = "archivebox-archivebox-1" +MAX_LOG_BYTES = 512 * 1024 + +LOGS = os.path.join(ARCHIVEBOX_DIR, "data", "logs") +SUPERVISORD_LOG = os.path.join(LOGS, "supervisord.log") +ERRORS_LOG = os.path.join(LOGS, "errors.log") + +# Im Container: Status-Verteilung + Alter des aeltesten offenen Snapshots/Crawls. +DB_QUERY = r""" +import json, sqlite3, time +from datetime import datetime, timezone +c = sqlite3.connect("file:/data/index.sqlite3?mode=ro", uri=True) +now = time.time() + +def age_of(value): + # ArchiveBox (Django, USE_TZ) speichert created_at als TEXT in UTC. + if value is None: + return None + if isinstance(value, str): + try: + dt = datetime.fromisoformat(value.replace(" ", "T")) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = value + return now - dt.timestamp() + +out = {} +out["snapshots"] = {str(s): int(n) for s, n in c.execute( + "select status, count(*) from core_snapshot group by 1")} +out["snapshots_total"] = int(c.execute( + "select count(*) from core_snapshot").fetchone()[0]) +row = c.execute("select min(created_at) from core_snapshot where status!='sealed'").fetchone() +out["oldest_open_snapshot_age"] = age_of(row[0] if row else None) +# Alarmsignal: ein Snapshot im Zustand `queued` ist NICHT abgeholt. Ein +# `started`-Snapshot darf legitim lange laufen (Video-Crawl, gemessen > 1 h) -- +# deshalb getrennt fuehren und `queued` explizit als 0 ausgeben, wenn nichts +# wartet: eine fehlende Serie waere von "keine Warteschlange" nicht zu +# unterscheiden. +row = c.execute("select min(created_at) from core_snapshot where status='queued'").fetchone() +out["oldest_queued_snapshot_age"] = age_of(row[0] if row else None) or 0 +out["crawls"] = {str(s): int(n) for s, n in c.execute( + "select status, count(*) from crawls_crawl group by 1")} +row = c.execute("select min(created_at) from crawls_crawl where status!='sealed'").fetchone() +out["oldest_open_crawl_age"] = age_of(row[0] if row else None) +print("JSON:" + json.dumps(out)) +""" + + +def sh(args, timeout=20): + try: + p = subprocess.run(args, capture_output=True, text=True, timeout=timeout) + return p.returncode, p.stdout, p.stderr + except subprocess.TimeoutExpired: + return 124, "", "timeout" + except Exception as exc: # noqa: BLE001 + return 1, "", str(exc) + + +def line_count(path): + try: + with open(path, "rb") as fh: + return sum(1 for _ in fh) + except OSError: + return None + + +def stat_or_none(path): + try: + st = os.stat(path) + return st.st_size, st.st_mtime + except OSError: + return None, None + + +def main(): + now = time.time() + metrics = [] # (name, wert) ohne Labels + labeled = [] # (name, labels-dict, wert) + notes = [] + + # --- Logdateien (Host-Sicht) --- + size, mtime = stat_or_none(ERRORS_LOG) + if size is not None: + metrics.append(("archivebox_runner_errors_log_bytes", size)) + lines = line_count(ERRORS_LOG) + if lines is not None: + metrics.append(("archivebox_runner_errors_log_lines", lines)) + metrics.append(("archivebox_runner_errors_log_age_seconds", int(now - mtime))) + + spawns = None + try: + with open(SUPERVISORD_LOG, "r", errors="replace") as fh: + spawns = fh.read().count("spawned: 'worker_runner'") + except OSError: + notes.append("supervisord.log nicht lesbar") + if spawns is not None: + metrics.append(("archivebox_runner_spawns_total", spawns)) + + # --- Container: laeuft er, laeuft der Runner, was haengt in der Queue --- + rc, out, err = sh(["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], timeout=15) + container_up = 1 if (rc == 0 and out.strip() == "true") else 0 + metrics.append(("archivebox_container_up", container_up)) + + runner_up = 0 + if container_up: + rc, out, _ = sh(["docker", "exec", CONTAINER, "sh", "-c", + "pgrep -f 'archivebox run' >/dev/null && echo 1 || echo 0"], timeout=20) + runner_up = 1 if out.strip().endswith("1") else 0 + metrics.append(("archivebox_runner_up", runner_up)) + + if container_up: + rc, out, err = sh(["docker", "exec", CONTAINER, "python3", "-c", DB_QUERY], timeout=30) + payload = None + for line in out.splitlines(): + if line.startswith("JSON:"): + import json + payload = json.loads(line[5:]) + if payload is None: + notes.append("DB-Abfrage fehlgeschlagen: %s" % (err.strip()[:120] or rc)) + else: + metrics.append(("archivebox_snapshots_total", payload["snapshots_total"])) + for status, n in sorted(payload["snapshots"].items()): + labeled.append(("archivebox_snapshots", {"status": status}, n)) + for status, n in sorted(payload["crawls"].items()): + labeled.append(("archivebox_crawls", {"status": status}, n)) + age = payload.get("oldest_open_snapshot_age") + if age is not None: + metrics.append(("archivebox_oldest_open_snapshot_age_seconds", int(age))) + metrics.append(("archivebox_oldest_queued_snapshot_age_seconds", + int(payload.get("oldest_queued_snapshot_age") or 0))) + age = payload.get("oldest_open_crawl_age") + if age is not None: + metrics.append(("archivebox_oldest_open_crawl_age_seconds", int(age))) + else: + notes.append("Container laeuft nicht") + + metrics.append(("archivebox_telemetrie_timestamp_seconds", int(now))) + + # --- Ausgabe (atomar) --- + os.makedirs(OUT_DIR, mode=0o755, exist_ok=True) + buf = ["# HELP archivebox_telemetrie_timestamp_seconds Zeitpunkt der Erhebung.", + "# TYPE archivebox_telemetrie_timestamp_seconds gauge", + "# TYPE archivebox_container_up gauge", + "# TYPE archivebox_runner_up gauge", + "# TYPE archivebox_runner_spawns_total counter", + "# TYPE archivebox_snapshots gauge", + "# TYPE archivebox_crawls gauge"] + for name, val in metrics: + buf.append("%s %s" % (name, val)) + for name, labels, val in labeled: + lbl = ",".join('%s="%s"' % (k, v) for k, v in sorted(labels.items())) + buf.append("%s{%s} %s" % (name, lbl, val)) + tmp = OUT_FILE + ".tmp" + with open(tmp, "w") as fh: + fh.write("\n".join(buf) + "\n") + os.chmod(tmp, 0o644) + os.replace(tmp, OUT_FILE) + + # --- Laufprotokoll (gedeckelt) --- + try: + if os.path.exists(LOG_FILE) and os.path.getsize(LOG_FILE) > MAX_LOG_BYTES: + os.replace(LOG_FILE, LOG_FILE + ".1") + with open(LOG_FILE, "a") as fh: + fh.write("%s ok container=%d runner=%d spawns=%s %s\n" % ( + time.strftime("%Y-%m-%dT%H:%M:%S"), container_up, runner_up, + spawns, ("; ".join(notes) if notes else ""))) + except OSError: + pass + + if notes: + print("Hinweise: " + "; ".join(notes), file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/install-archivebox-telemetrie.sh b/scripts/install-archivebox-telemetrie.sh new file mode 100755 index 0000000..355719b --- /dev/null +++ b/scripts/install-archivebox-telemetrie.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# ============================================================================= +# ArchiveBox-Runner-Telemetrie auf einem Knoten OHNE sudo einrichten. +# +# Drei Teile, alle ohne Root: +# 1. Exporteur-Skript nach ~/bin (liest Logs + DB im Container, schreibt .prom) +# 2. Cron-Eintrag (jede Minute) -- der Cron-Daemon des Hosts, kein Nutzer-Timer +# (linger=no wuerde Nutzer-Units beim Abmelden abraeumen) +# 3. Telegraf-Container (Docker-Gruppe) mit Remote Write an den Collector +# +# Aufruf: sh install-archivebox-telemetrie.sh [--dry-run] +# Vorbedingungen: python3, docker (Docker-Gruppe), cron laufen. +# ============================================================================= +set -eu + +DRY="${1:-}" +NODE="${NODE_NAME:-archibald}" +HOME_DIR="${HOME:-/home/mm}" +BIN="$HOME_DIR/bin/archivebox-telemetrie.py" +STACK_DIR="$HOME_DIR/stacks/telegraf-archibald" +PROM_URL="${PROM_URL:-http://100.83.134.46:9090/api/v1/write}" + +say() { printf '%s\n' "$*"; } +run() { + if [ "$DRY" = "--dry-run" ]; then say "DRY: $*"; else eval "$@"; fi +} + +say "== Vorbedingungen ==" +for c in python3 docker crontab; do + command -v "$c" >/dev/null 2>&1 || { say "FEHLER: $c fehlt"; exit 1; } + say "ok: $c -> $(command -v "$c")" +done +docker ps >/dev/null 2>&1 || { say "FEHLER: Docker nicht nutzbar (Gruppe?)"; exit 1; } +say "ok: Docker nutzbar" + +say "== 1. Exporteur ==" +run "mkdir -p '$HOME_DIR/bin' '$HOME_DIR/abx-telemetrie'" +run "chmod 755 '$HOME_DIR/abx-telemetrie'" +if [ "$DRY" != "--dry-run" ]; then + here=$(cd "$(dirname "$0")" && pwd) + run "cp '$here/archivebox-telemetrie.py' '$BIN'" + run "chmod 755 '$BIN'" +fi + +say "== 2. Cron (jede Minute, idempotent) ==" +LINE="* * * * * LC_ALL=C /usr/bin/python3 $BIN >/dev/null 2>&1" +if [ "$DRY" = "--dry-run" ]; then + say "DRY: crontab-Zeile -> $LINE" +else + tmp=$(mktemp) + crontab -l 2>/dev/null | grep -v 'archivebox-telemetrie.py' > "$tmp" || true + printf '%s\n' "$LINE" >> "$tmp" + crontab "$tmp" + rm -f "$tmp" + say "crontab jetzt:" + crontab -l | sed 's/^/ /' +fi + +say "== 3. Telegraf-Container ==" +run "mkdir -p '$STACK_DIR'" +here=$(cd "$(dirname "$0")" && pwd) +if [ "$DRY" != "--dry-run" ]; then + run "cp '$here/../stacks/telegraf-container/docker-compose.yml' '$STACK_DIR/'" + run "cp '$here/../stacks/telegraf-container/telegraf.conf' '$STACK_DIR/'" + # NODE_NAME/PROM_URL aus der Datei gegen die hier gesetzten Werte stellen. + run "sed -i \"s|NODE_NAME: .*|NODE_NAME: $NODE|\" '$STACK_DIR/docker-compose.yml'" + run "sed -i \"s|PROM_URL: .*|PROM_URL: $PROM_URL|\" '$STACK_DIR/docker-compose.yml'" +fi + +say "== Probelauf des Exporteurs ==" +if [ "$DRY" != "--dry-run" ]; then + run "/usr/bin/python3 '$BIN'" + say "--- $HOME_DIR/abx-telemetrie/abx.prom (Kopf) ---" + head -20 "$HOME_DIR/abx-telemetrie/abx.prom" +fi + +say "== Container starten ==" +if [ "$DRY" != "--dry-run" ]; then + ( cd "$STACK_DIR" && docker compose up -d ) + sleep 20 + docker logs --since 60s telegraf-archibald 2>&1 | grep -c 'E!' | sed 's/^/ E!-Zeilen: /' + docker exec telegraf-archibald sh -c 'cat /var/lib/abx-prom/abx.prom | head -3' \ + | sed 's/^/ Container sieht: /' +fi + +say "" +say "== Abnahme (auf dem Collector ausfuehren) ==" +say "curl -s --data-urlencode 'query=archivebox_telemetrie_timestamp_seconds' http://:9090/api/v1/query" +say "curl -s --data-urlencode 'query=count by (node)(archivebox_runner_up)' http://:9090/api/v1/query" +say "OK -- Knoten $NODE eingerichtet." diff --git a/stacks/telegraf-container/docker-compose.yml b/stacks/telegraf-container/docker-compose.yml new file mode 100644 index 0000000..c06cb3d --- /dev/null +++ b/stacks/telegraf-container/docker-compose.yml @@ -0,0 +1,32 @@ +# ============================================================================= +# Telegraf als CONTAINER auf einem Knoten OHNE sudo. +# +# Fuer Knoten mit System-Telegraf ist install.sh der richtige Weg; hier genuegt +# die Docker-Gruppe, weil nur die fertige .prom-Datei gelesen und per Remote +# Write gepusht wird. Kein /hostfs, kein docker.sock, kein privileged -- nichts +# davon braucht eine dateibasierte App-Metrik. +# +# Werte kommen aus der Umgebung (Telegraf expandiert ${...} beim Laden), damit +# kein Geheimnis und keine IP doppelt im Repo steht. +# ============================================================================= +services: + telegraf: + image: telegraf:1.39 # gleiche Version wie der Collector (imac) + container_name: telegraf-archibald + restart: unless-stopped + # host-Netz: derselbe Weg, den ein curl auf dem Host zum Collector nimmt. + # Ueber eine Bridge kaeme ein zusaetzlicher NAT/forward-Hop hinzu, der in + # NetBird-Firewall-Regeln haengenbleiben kann. + network_mode: host + environment: + NODE_NAME: archibald + # Direkter Mesh-Weg, kein Reverse-Proxy: der Collector laeuft mit + # --web.enable-remote-write-receiver und OHNE web.config.file, also ohne + # Auth. Ueber den Proxy-Weg waere ein Bearer-Token noetig. + PROM_URL: http://100.83.134.46:9090/api/v1/write + volumes: + - ./telegraf.conf:/etc/telegraf/telegraf.conf:ro + # VERZEICHNIS-Mount, bewusst kein Datei-Mount: der Cron-Exporteur ersetzt + # die .prom-Datei atomar per mv -- ein Datei-Mount haette danach weiter + # den alten Inode und lieferte still veraltete Werte. + - /home/mm/abx-telemetrie:/var/lib/abx-prom:ro diff --git a/stacks/telegraf-container/telegraf.conf b/stacks/telegraf-container/telegraf.conf new file mode 100644 index 0000000..2df87b2 --- /dev/null +++ b/stacks/telegraf-container/telegraf.conf @@ -0,0 +1,44 @@ +# ============================================================================= +# Telegraf-Konfiguration fuer einen CONTAINER-Knoten ohne sudo. +# +# Variablen kommen aus der Container-Umgebung (docker-compose `environment`), +# NICHT aus /etc/default/telegraf -- das gibt es auf solchen Knoten nicht. +# ============================================================================= + +[global_tags] + node = "${NODE_NAME}" + +[agent] + # Der Exporteur schreibt einmal pro Minute; 60 s Abtastung ist das Maximum, + # das keine Werte doppelt sieht. Jitter, damit Cron-Schreiben und Abtastung + # nicht im Gleichtakt kollidieren. + interval = "60s" + flush_interval = "60s" + collection_jitter = "5s" + hostname = "${NODE_NAME}" + omit_hostname = false + # Reserve fuer Ausfaelle: Telegraf puffert nur, solange der Push scheitert. + metric_buffer_limit = 200000 + +# Der Cron-Exporteur schreibt die .prom-Datei atomar (mv). +# data_format = "prometheus" ist PFLICHT: das Skript besitzt seine Metriknamen +# schon. Mit "influx" bekaeme jede Serie ein _value-Suffix (oder der Parser +# scheiterte ganz) und jedes Panel bliebe leer, ohne Fehlermeldung. +[[inputs.file]] + files = ["/var/lib/abx-prom/abx.prom"] + data_format = "prometheus" + +# ---- Metriken -> Prometheus Remote Write (Collector imac) ---- +[[outputs.http]] + url = "${PROM_URL}" + data_format = "prometheusremotewrite" + timeout = "60s" + # Ein 400 ist PERMANENT: dieselbe Nutzlast liefert dieselbe Antwort. Ohne + # diese Liste bleibt der abgelehnte Batch als FIFO-Kopf stehen, wird endlos + # wiederholt und blockiert jede dahinter gesammelte Serie. + non_retryable_statuscodes = [400, 409, 413, 422] + [outputs.http.headers] + Content-Type = "application/x-protobuf" + Content-Encoding = "snappy" + X-Prometheus-Remote-Write-Version = "0.1.0" + # Kein Authorization-Header: direkter Mesh-Weg ohne Auth (siehe compose).