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.
This commit is contained in:
1 parent
235cbc68dc
commit
6b169c77e6
4 files changed
+376
No files matched your search
Executable
+210
@@ -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())
|
||||
Executable
+90
@@ -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://<collector>:9090/api/v1/query"
|
||||
say "curl -s --data-urlencode 'query=count by (node)(archivebox_runner_up)' http://<collector>:9090/api/v1/query"
|
||||
say "OK -- Knoten $NODE eingerichtet."
|
||||
@@ -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
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user