Compare commits
59
Commits
158e3dd884
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b169c77e6 | ||
|
|
235cbc68dc | ||
|
|
59ab6a9a22 | ||
|
|
098f4cf7af | ||
|
|
63bc054371 | ||
|
|
1fe25163d5 | ||
|
|
d65289e65f | ||
|
|
a40836a592 | ||
|
|
b8246472f5 | ||
|
|
f8c5f2eb3c | ||
|
|
33534811cb | ||
|
|
39e86adfba | ||
|
|
c18263e4ed | ||
|
|
df2ee94613 | ||
|
|
194fa98f97 | ||
|
|
7d3ce17c53 | ||
|
|
72752e523e | ||
|
|
e2c75a88df | ||
|
|
6f82bad6f9 | ||
|
|
3f9ac51c45 | ||
|
|
ea5b796012 | ||
|
|
78f5b9ab5e | ||
|
|
6a881ce674 | ||
|
|
360f91f8a1 | ||
|
|
60727fe666 | ||
|
|
c7ab78e9dd | ||
|
|
7ec5d44517 | ||
|
|
f5b98d2807 | ||
|
|
d11849b6d9 | ||
|
|
906da69481 | ||
|
|
06ad67f8f5 | ||
|
|
9e6593a6f6 | ||
|
|
1eb7976ec8 | ||
|
|
0e94bf4d91 | ||
|
|
3236d9cede | ||
|
|
6d0d0bd900 | ||
|
|
a9d9b92499 | ||
|
|
d684ce860d | ||
|
|
24c04f8eff | ||
|
|
4da9a59527 | ||
|
|
49f5dbc335 | ||
|
|
5483a4fea8 | ||
|
|
5a213eb580 | ||
|
|
3c0c800f90 | ||
|
|
911823514d | ||
|
|
963546641b | ||
|
|
711d8bdc1d | ||
|
|
ce52d2c26b | ||
|
|
cba503e174 | ||
|
|
d614ffa56a | ||
|
|
2b853529f9 | ||
|
|
33eb5f2308 | ||
|
|
02af5b428f | ||
|
|
da3f221c8d | ||
|
|
feb77e2fc7 | ||
|
|
b0f8cb860d | ||
|
|
cc5dc6b9ad | ||
|
|
8d05d3bc48 | ||
|
|
0e9dce9e47 |
No files matched your search
+2
-2
@@ -1,2 +1,2 @@
|
||||
*.swp
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -5,6 +5,364 @@ Der Ausgang läuft über den **NetBird-Reverse-Proxy** (`imac-prometheus.x05.idx
|
||||
mit **Bearer-Token** — dadurch funktioniert der Installer auch auf Maschinen, die **kein NetBird-Peer** sind
|
||||
(Büro-LAN, Kundenlaptop, CGNAT, VM ohne Client).
|
||||
|
||||
## Installation — der sichere Weg
|
||||
|
||||
```sh
|
||||
# Empfohlen: erst herunterladen, dann ausführen
|
||||
curl -fsSL <raw>/install.sh -o /tmp/netbird-install.sh
|
||||
sudo sh /tmp/netbird-install.sh --name <node> --token <TOKEN>
|
||||
```
|
||||
|
||||
**Warum nicht direkt `curl … | sh`?** Bei einer Pipe ist **stdin das Skript
|
||||
selbst**. Jeder Unterbefehl, der stdin liest, kann es wegräumen — und der
|
||||
Installer ruft Paketmanager auf (`apt-get`), die genau das tun. Auf Ubuntu
|
||||
startet dabei `needrestart`:
|
||||
|
||||
```
|
||||
Scanning processes...
|
||||
Scanning linux images...
|
||||
```
|
||||
|
||||
Danach setzt die Shell mitten im **Quelltext** fort statt im Skript — sichtbar
|
||||
an Ausgaben wie `; then`, `fi` oder Kommentarzeilen. Der Lauf bricht ab, die
|
||||
Node ist halb konfiguriert. **Auf Nodes mit bereits installiertem Telegraf
|
||||
passiert das nicht**, weil dann kein Paketmanager läuft — dort funktioniert
|
||||
`curl … | sh` scheinbar problemlos.
|
||||
|
||||
Ab **1.4.0** schützt sich der Installer selbst: erkennt er eine echte Pipe,
|
||||
lädt er sich frisch von der Quelle, startet sich mit `stdin </dev/null` neu und
|
||||
meldet das:
|
||||
|
||||
```
|
||||
[netbird-telegraf] Selbstschutz: Skript laeuft jetzt von /tmp/… (stdin frei).
|
||||
```
|
||||
|
||||
Fehlen `curl`/`wget` auf einem frischen System, meldet er das ausdrücklich
|
||||
(`HINWEIS: kein curl/wget fuer den Selbstschutz`) — dann ist der Download-Weg
|
||||
oben die sichere Wahl.
|
||||
|
||||
**Der Fehler ist reproduzierbar:** `x14` brach genau so ab. Der Installer lief
|
||||
bis `Node=… Apache=1`, dann kamen `Scanning processes…`-Zeilen, danach
|
||||
Quelltext-Ausgaben, und `apache.conf` fehlte am Ende.
|
||||
|
||||
## Wenn der Lauf hängt
|
||||
|
||||
Der Installer darf **nie unbegrenzt warten**. Alle Netzwerkaufrufe haben
|
||||
Timeouts, und die ACL-Phase meldet sich vorher:
|
||||
|
||||
```
|
||||
[netbird-telegraf] Log-Leserechte: pruefe/ergaenze ACL fuer 'telegraf' (kann etwas dauern) …
|
||||
```
|
||||
|
||||
Diese Zeile dauert auf einem frischen System bis zu **3 Minuten** — dort läuft
|
||||
`apt-get update` + `apt-get install acl`. Erscheint sie und danach nichts,
|
||||
nicht sofort abbrechen: bis 3 Minuten ist normal.
|
||||
|
||||
| Aufruf | Timeout | Versuche |
|
||||
|---|---|---|
|
||||
| `fetch` (Confs/Scripts vom Repo) | 30 s | 3 |
|
||||
| `apt-get update` / `install acl` | 180 s | 1 |
|
||||
| InfluxData-Keyring | 30 s | 1 |
|
||||
| Telegraf-Binary (statisch) | 120 s | 1 |
|
||||
| Selbstschutz-Download | 30 s | 1 |
|
||||
|
||||
**Vor 1.5.0 gab es diese Timeouts nicht** — `fetch()` wartete unbegrenzt. Symptom:
|
||||
der Lauf bleibt nach der letzten Erfolgsmeldung stehen, ohne Fehler; man bricht
|
||||
mit `^C` ab und weiß nicht, wo es hing. Real passiert auf `x14`.
|
||||
|
||||
## Diagnose: „Logs kommen nicht an"
|
||||
|
||||
Ein Befehl zeigt, warum:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/install.sh \
|
||||
| sh -s -- --diagnose
|
||||
```
|
||||
|
||||
Er prüft genau die Stellen, die schon **still** versagt haben (kein `E!` in keinem Log):
|
||||
|
||||
| Prüfung | Warum |
|
||||
|---|---|
|
||||
| Dienst-Benutzer + Gruppen | Telegraf läuft als `telegraf`, nicht als root |
|
||||
| Jede Datei aus der `files`-Liste | Existenz, Größe, **Lesbarkeit aus Sicht des Dienst-Benutzers** |
|
||||
| Parser-Lauf ohne Filter | echte `E!`-Zeilen sichtbar machen |
|
||||
| Offset-Zustand | erklärt, warum eine bestehende Datei „leer" wirkt |
|
||||
|
||||
Beispielausgabe:
|
||||
|
||||
```
|
||||
-- Logpfade aus der aktiven Conf --
|
||||
OK /var/log/apache2/keyhelp/access.log (75 Bytes)
|
||||
NICHT LESBAR /home/users/u1/logs/d1/access.log (Benutzer telegraf) ← URSACHE
|
||||
-> 3 Pfade, 2 lesbar, 0 leer, 1 nicht lesbar
|
||||
```
|
||||
|
||||
**Wichtig:** `initial_read_offset = "saved-or-end"` heißt, dass beim **ersten** Lauf nur **Neues** gelesen wird. Bestehende Zeilen kommen erst mit neuen Zugriffen — für einen sofortigen Test eine Seite aufrufen und ein Intervall abwarten.
|
||||
|
||||
## Der Konfig-Test prüft ALLE Dateien
|
||||
|
||||
Ein Detail, das schon eine Node lahmgelegt hat: **Nie mit `--input-filter` prüfen.**
|
||||
|
||||
```sh
|
||||
# FALSCH — validiert nur die gefilterten Plugins:
|
||||
telegraf --config … --config-directory … --test --input-filter cpu:mem
|
||||
|
||||
# RICHTIG — parst alle Dateien, genau wie der Dienststart:
|
||||
telegraf --config … --config-directory … --test --test-wait 1
|
||||
```
|
||||
|
||||
Mit `--input-filter cpu:mem` überspringt Telegraf alle übrigen Konfigdateien. Ein ungültiges Feld in `telegraf.d/apache.conf` lief damit durch, der Installer meldete **„OK — Node liefert"**, und erst der Dienststart scheiterte danach mit:
|
||||
|
||||
```
|
||||
Job for telegraf.service failed because the control process exited with error code.
|
||||
```
|
||||
|
||||
Der Installer prüft deshalb ohne Filter und wertet nur echte Parser-Fehler (`E! … loading config file`) als Abbruch — nicht jede Warnung, denn ein fehlendes Input-Binary (`nvidia-smi`) zeigt sich erst beim Start, nicht beim Parsen.
|
||||
|
||||
## Apache-Logs
|
||||
|
||||
Mit `--role apache` (oder automatisch, wenn `apache2ctl`/`httpd` gefunden wird) bindet der Installer Apache ein — auf **zwei** Wegen aus derselben Datei:
|
||||
|
||||
| Ziel | Was | Wofür |
|
||||
|---|---|---|
|
||||
| **Loki** | jede Zeile roh | Fehlersuche im Einzelfall |
|
||||
| **Prometheus** | `apache_access` mit Tags `resp_code`, `verb` | Request-Rate, Status-Klassen, Error-Rate — Panels und Alerts |
|
||||
|
||||
### Logpfade werden automatisch gesammelt
|
||||
|
||||
Der Installer **sammelt alle passenden Muster** — nicht nur das erste. Auf einem KeyHelp-Host laufen zwei Strukturen parallel, und die globale Standarddatei ist leer:
|
||||
|
||||
| Panel | Pfadmuster |
|
||||
|---|---|
|
||||
| **KeyHelp** | `/home/users/<user>/logs/<domain>/access.log` (**zweistufig**, pro Domain) |
|
||||
| **KeyHelp** | `/var/log/apache2/<vhost>/access.log` (z. B. `keyhelp/access.log`) |
|
||||
| Plesk | `/var/www/vhosts/<domain>/logs/access_log` |
|
||||
| cPanel | `/usr/local/apache/domlogs/<domain>` |
|
||||
| Klassisch | `/var/log/apache2/access.log`, `/var/log/httpd/access_log` |
|
||||
|
||||
Verifiziert auf x22: Die Requests liegen unter `/home/users/classi/logs/classi.parts/access.log` **und** `/var/log/apache2/keyhelp/access.log`, während `/var/log/apache2/access.log` **0 Bytes** hat. Ein Input auf die leere Datei hätte still nichts geliefert — leere Standarddateien werden per `-s`-Test ausgeschlossen.
|
||||
|
||||
Alle gefundenen Pfade landen als TOML-Liste in der Conf:
|
||||
|
||||
```toml
|
||||
files = ["/var/log/apache2/*/access.log", "/home/users/*/logs/*/access.log"]
|
||||
```
|
||||
|
||||
Jede Serie trägt ein `path`-Tag, damit man in Grafana nach Datei filtert. Überschreiben mit `--apache-log <pfad>` (mehrere Pfade leerzeichengetrennt).
|
||||
|
||||
**Zusätzlich** wird die `CustomLog`-Direktive aus `/etc/apache2` bzw. `/etc/httpd` ausgewertet — die verlässlichste Quelle, `${APACHE_LOG_DIR}` wird aufgelöst.
|
||||
|
||||
### Zeitstempel-Kollisionen: der stille Totalausfall
|
||||
|
||||
**Symptom.** Ein Node liefert **gar nichts** mehr — nicht nur die Apache-Metriken,
|
||||
sondern auch `system_uptime`, CPU, Disk. Andere Nodes laufen normal.
|
||||
|
||||
**Erkennungsmerkmal.** Im Prometheus-Log wiederholt sich **derselbe eingefrorene
|
||||
Timestamp** alle ~15 s:
|
||||
|
||||
```
|
||||
duplicate sample for timestamp 1789343466232; overrides not allowed:
|
||||
existing 43930, new value 51608
|
||||
series="{__name__=\"apache_access_resp_bytes\", path=\"…/pixtasia.de/access.log\",
|
||||
resp_code=\"200\", verb=\"GET\"}"
|
||||
```
|
||||
|
||||
Derselbe Wert über Minuten heißt: **ein vergifteter Batch sitzt im FIFO-Kopf und
|
||||
wird endlos wiederholt** — nicht ein neuer Fehler pro Intervall.
|
||||
|
||||
**Ursache.** Telegraf schreibt pro Sammelzyklus einen Batch, dessen Metriken alle
|
||||
denselben Zeitstempel tragen. Remote Write kennt nur **Millisekunden**. Treffen
|
||||
zwei Requests derselben Serie (`path` + `resp_code` + `verb`) in dieselbe
|
||||
Millisekunde, lehnt Prometheus den **ganzen Batch** ab. Telegrafs Puffer ist
|
||||
FIFO: der Batch bleibt am Kopf stehen und blockiert jede weitere Serie — deshalb
|
||||
fehlt am Ende die ganze Node.
|
||||
|
||||
**Lösung: Aggregation statt Zeitstempel-Trick.** Ein Zeitstempel-Trick kann das
|
||||
nicht beheben — feiner als Millisekunden geht Remote Write nicht. Ein Aggregator
|
||||
fasst alle Metriken einer Serie pro Intervall zu **einem** Punkt zusammen; damit
|
||||
ist eine Kollision strukturell unmöglich, unabhängig von der Last:
|
||||
|
||||
```toml
|
||||
[[aggregators.basicstats]]
|
||||
namepass = ["apache_access"]
|
||||
period = "60s"
|
||||
drop_original = true # zwingend — sonst sind die Einzelwerte zurück
|
||||
stats = ["count", "sum", "mean"]
|
||||
```
|
||||
|
||||
**Gemessen an 2000 echten x22-Zeilen:**
|
||||
|
||||
| | Punkte | Kollisionen |
|
||||
|---|---|---|
|
||||
| ohne Aggregator | 2000 | Risiko pro Zelle |
|
||||
| **mit Aggregator** | **4** | **0** |
|
||||
|
||||
Die Requests werden dabei vollständig gezählt (1997 von 2000 — Differenz durch
|
||||
das Zeitfenster).
|
||||
|
||||
**Entstehende Metriken** (Telegraf stellt den Feldnamen voran):
|
||||
|
||||
| Metrik | Bedeutung |
|
||||
|---|---|
|
||||
| `apache_access_resp_bytes_count` | Requests je Gruppe — **das brauchen die Panels** |
|
||||
| `apache_access_resp_bytes_sum` | übertragene Bytes je Gruppe |
|
||||
| `apache_access_resp_bytes_mean` | mittlere Antwortgröße |
|
||||
|
||||
`resp_code` und `verb` bleiben **Tags** und damit Gruppenschlüssel — dafür sorgt
|
||||
`processors.converter` oben im File (das eingebaute Muster liefert sie als Tags,
|
||||
ein eigenes Muster als Felder; ohne den Converter brächen Filter nach `resp_code`).
|
||||
|
||||
**Diagnose.**
|
||||
|
||||
```sh
|
||||
# derselbe Timestamp mehrfach = vergifteter Batch
|
||||
docker logs observability-prometheus 2>&1 \
|
||||
| grep "duplicate sample" | grep -oE "timestamp [0-9]+" | sort | uniq -c | tail
|
||||
```
|
||||
|
||||
### Leserechte: der stille Killer
|
||||
|
||||
Auf x22 waren **64 von 84 Logpfaden für `telegraf` unlesbar** — `inputs.tail` meldet das **nicht**, es liefert einfach nichts, und der Node sieht „aktiv" aus. Zwei verschiedene Rechte-Welten:
|
||||
|
||||
| Ort | Rechte | Was hilft |
|
||||
|---|---|---|
|
||||
| `/var/log/apache2/…` | `root:adm 0640` | `usermod -aG adm telegraf` |
|
||||
| `/home/users/<user>/logs/…` | `<user>:<user> 0640` | **`adm` hilft nicht** — ACL nötig |
|
||||
|
||||
Der Installer setzt dafür **ACLs** (`setfacl -R -m u:telegraf:rX` plus eine Default-ACL, damit neue Domain-Ordner das Recht erben). Das `acl`-Paket wird bei Bedarf nachinstalliert. Der Dienst bleibt im eingeschränkten Konto — kein `root` nötig.
|
||||
|
||||
Danach folgt ein **Gegentest**: Der Installer liest eine echte Logdatei als Dienst-Benutzer. Meldet er
|
||||
|
||||
```
|
||||
[netbird-telegraf] Gegentest: 'telegraf' kann Apache-Logs lesen (…)
|
||||
```
|
||||
|
||||
ist die Kette nachweislich in Ordnung.
|
||||
|
||||
### Glob-Muster statt Einzelpfade
|
||||
|
||||
Die Conf enthält **Muster**, nicht die expandierten Dateinamen:
|
||||
|
||||
```toml
|
||||
files = ["/var/log/apache2/*/access.log", "/home/users/*/logs/*/access.log"]
|
||||
```
|
||||
|
||||
Damit wird eine **neu angelegte Domain sofort erfasst**, ohne dass der Installer erneut laufen muss. (Der Installer schaltet beim Rendern `set -f` ein — sonst würde die Shell die Muster selbst auflösen und feste Pfade in die Conf schreiben.)
|
||||
|
||||
### `/etc/default/telegraf` wird gesourct — Werte müssen gequotet sein
|
||||
|
||||
Diese Datei wird mit `. /etc/default/telegraf` eingelesen (vom Installer und von der Telegraf-Unit). Ein **ungequoteter** Wert mit Leerzeichen bricht das Sourcing ab — bei mehreren Logpfaden versucht die Shell, den zweiten Pfad als Befehl auszuführen:
|
||||
|
||||
```
|
||||
sh: /etc/default/telegraf: /home/users/classi/logs/classi.parts/access.log: Permission denied
|
||||
```
|
||||
|
||||
Der Installer schreibt `APACHE_LOG='…'` deshalb **mit einfachen Anführungszeichen**. Zusätzlich liest er die Datei über `load_defaults` ein, das syntaktisch kaputte Dateien sichert und überschreibt statt abzubrechen — eine Node lässt sich so auch dann reparieren, wenn ihre Defaults-Datei bereits defekt ist.
|
||||
|
||||
### Zwei Fallstricke
|
||||
|
||||
**1. `from_beginning` existiert bei `inputs.tail` nicht.** Ein solcher Block lässt den Dienst beim Start hart scheitern — `systemctl start` schlägt fehl und **alle** Metriken fallen aus, nicht nur die Apache-Logs:
|
||||
|
||||
```
|
||||
E! loading config file /etc/telegraf/telegraf.d/apache.conf failed:
|
||||
plugin inputs.tail: line 2: configuration specified the fields ["from_beginning"],
|
||||
but they were not used ... does not exist in this version
|
||||
```
|
||||
|
||||
Richtig ist `initial_read_offset = "saved-or-end"` (Default) — damit wird beim ersten Lauf nicht die komplette Historie ausgeschüttet. Für einen Test gegen eine vorgefüllte Datei `"beginning"` setzen.
|
||||
|
||||
**2. Die Grok-Muster heißen `COMMON_LOG_FORMAT` und `COMBINED_LOG_FORMAT`** — nicht `HTTPD_COMMON_LOG`/`HTTPD_COMBINED_LOG_FORMAT`. Die `HTTPD_*`-Namen aus anderen Grok-Sammlungen kennt Telegraf nicht:
|
||||
|
||||
```
|
||||
E! [inputs.tail] Creating parser: no pattern found for %{HTTPD_COMBINED_LOG_FORMAT}
|
||||
```
|
||||
|
||||
Die Reihenfolge in `grok_patterns` ist spezifisch → allgemein, damit die VHost-Varianten (`example.com:80 ...`) vor der einfachen Form greifen.
|
||||
|
||||
### Response-Time
|
||||
|
||||
Common und Combined Log Format enthalten **keine** Antwortzeit. Für `resp_time` in den Metriken muss das LogFormat in Apache erweitert werden:
|
||||
|
||||
```apache
|
||||
LogFormat "%h %l %u %t \"%r\" %>s %b \"%D\" \"%{User-agent}i\"" combined_time
|
||||
CustomLog ${APACHE_LOG_DIR}/access.log combined_time
|
||||
```
|
||||
|
||||
`%D` = Dauer in Mikrosekunden. Danach im Grok-Muster als Feld ergänzen. Ohne diese Änderung sind alle anderen Werte trotzdem verfügbar — nur die Antwortzeit fehlt.
|
||||
|
||||
## Auto-Updater und Force-Updater
|
||||
|
||||
Der Installer hält sich auf den Nodes selbst aktuell.
|
||||
|
||||
### Prüfen
|
||||
|
||||
```sh
|
||||
sh install.sh --version # installierte + verfügbare Version
|
||||
sh install.sh --check # Exit 0 = aktuell, Exit 10 = Update verfügbar
|
||||
```
|
||||
|
||||
Beides braucht **keinen Token** und **kein root** — gut für Monitoring-Checks.
|
||||
|
||||
### Auto-Updater (systemd-Timer)
|
||||
|
||||
```sh
|
||||
sh install.sh --name <node> --token <T> --install-update-timer daily
|
||||
sh install.sh --name <node> --token <T> --install-update-timer 30min
|
||||
sh install.sh --name <node> --token <T> --install-update-timer weekly
|
||||
```
|
||||
|
||||
Legt `telegraf-installer-update.timer` an (Default `daily` 04:30, ±15 min Streuung).
|
||||
Der Lauf ist billig: Es werden nur `VERSION` und der SHA des Installers verglichen.
|
||||
Erst bei Abweichung lädt er den Installer und ruft sich mit `--update-only` auf.
|
||||
Log: `/var/log/telegraf-installer-update.log`.
|
||||
|
||||
`--update-only` fasst **nur** `telegraf.d/`-Konfs und Hilfsscripts an: kein
|
||||
Paket-Update, `/etc/default/telegraf` (und damit der Token) bleibt unberührt,
|
||||
kein Neustart wenn sich nichts geändert hat.
|
||||
|
||||
Warum Version **und** Hash: Die `VERSION` erkennt neue Auslieferungen, der SHA
|
||||
auch Korrekturen ohne Versionssprung (z. B. ein Fix an `journal-tail.sh`).
|
||||
Ohne den Hash bliebe eine Node mit fehlerhaftem Script stehen.
|
||||
|
||||
### Force-Updater
|
||||
|
||||
```sh
|
||||
sh install.sh --force-update --name <node> --token <T>
|
||||
```
|
||||
|
||||
Schreibt Konfs und Scripts **immer** neu, sichert sie vorher nach
|
||||
`/var/backups/telegraf-<zeitstempel>/` und startet den Dienst neu. Schlägt der
|
||||
Konfig-Test fehl, wird automatisch zurückgerollt. Idempotent — mehrfaches
|
||||
Ausführen ist folgenlos. Das Werkzeug nach einem kaputten Update oder wenn
|
||||
jemand Dateien von Hand verändert hat.
|
||||
|
||||
Entfernen: `sh install.sh --remove-update-timer`
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
.\install.ps1 -Name <node> -Token <T> -Check # Versionsvergleich
|
||||
.\install.ps1 -Name <node> -Token <T> -InstallUpdateTask # taeglich
|
||||
.\install.ps1 -Name <node> -Token <T> -InstallUpdateTask -UpdateInterval hourly
|
||||
.\install.ps1 -Name <node> -Token <T> -ForceUpdate
|
||||
.\install.ps1 -RemoveUpdateTask
|
||||
```
|
||||
|
||||
Die Aufgabe `\NetBirdTelegraf\Update` läuft mit denselben Semantiken
|
||||
(Version + Hash, nur bei Änderung). Log:
|
||||
`%ProgramData%\netbird-telegraf\update.log`.
|
||||
|
||||
### Marker auf der Node
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| `/etc/telegraf/CONFIG_VERSION` | zuletzt installierte Repo-Version |
|
||||
| `/etc/telegraf/INSTALLER_SHA` | SHA256 des Installers zum Installationszeitpunkt |
|
||||
| `/etc/telegraf/REPO_RAW` | genutzte Repo-Quelle (GitHub-Mirror, Testserver) |
|
||||
|
||||
`REPO_RAW` ist per Umgebungsvariable überschreibbar — nützlich für Tests,
|
||||
Mirrors oder einen eigenen Fork.
|
||||
|
||||
### Docker-Nodes (kein journalctl im Image)
|
||||
|
||||
Läuft Telegraf als **Container** (offizielles `telegraf`-Image), ist der Journal-Weg nicht möglich: das Image ist Debian ohne `systemd` und hat **kein `journalctl`**; `/hostfs` ist zudem read-only. Dafür gibt es `scripts/syslog-tail.sh` — es liest die **Syslog-Dateien des Hosts** unter `/hostfs/var/log/`.
|
||||
@@ -85,7 +443,9 @@ curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/i
|
||||
| `--apache-log <pfad>` | Access-Log (Default `/var/log/apache2/access.log`) |
|
||||
| `--uninstall` | alles entfernen |
|
||||
|
||||
**Root-Rechte:** Das Script braucht sie (Dienst, `/etc/telegraf`). Auf PVE, in LXC-Containern und überall, wo du schon root bist: **kein `sudo`** — es ist dort oft nicht installiert. Auf normalen Desktops/Servern mit eigenem Benutzer: `| sudo sh -s -- …`.
|
||||
**Root-Rechte:** Das Script braucht sie (Dienst, `/etc/telegraf`). Wo du **schon root** bist — PVE-Knoten, Hetzner-/Root-Server wie x17, LXC-Container, minimale Debian-Installationen — **kein `sudo`**: dort ist es oft gar nicht installiert und der erste Befehl bricht mit `sudo: command not found` ab. Auf normalen Desktops/Servern mit eigenem Benutzer (Ubuntu-Desktop, ROG): `| sudo sh -s -- …`.
|
||||
|
||||
**Prüfen statt raten:** `id -u` — kommt `0`, ohne `sudo`. Das Script prüft es selbst und bricht mit klarer Meldung ab, wenn es nicht root ist.
|
||||
|
||||
Ablauf: Paketquelle von InfluxData (apt/dnf/apk) oder statisches Binary (amd64/arm64/armv7) → `/etc/telegraf/telegraf.conf` + `telegraf.d/` aus diesem Repo → `/etc/default/telegraf` mit NODE_NAME, PROM_URL, LOKI_URL, TELEGRAF_TOKEN → systemd-Dienst `telegraf`, enabled, Konfig-Test vor dem Start.
|
||||
|
||||
@@ -107,7 +467,7 @@ Token-/ACL-Thematik.
|
||||
> eigene Rolle mit `Sys.Audit`) blieben auf PVE 8 mit `403` erfolglos.
|
||||
|
||||
Dann auf **jedem PVE-Knoten** (auf dem Host selbst, nicht in einer VM).
|
||||
**Auf PVE gibt es kein `sudo`** — du bist dort bereits root, also ohne `sudo`:
|
||||
**Auf PVE (und auf Root-Servern wie x17) gibt es kein `sudo`** — du bist dort bereits root, also ohne `sudo`:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/install.sh \
|
||||
@@ -185,6 +545,131 @@ 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 <basis-url> (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=<id>` | `inputs.exec` → `llamaswap-telemetrie.py` | Token/Anfragen/Cache **je Modell** — auch für Modelle ohne `/metrics` (strata-Engine, sd-server) |
|
||||
| `http://<upstream>/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:
|
||||
|
||||
| Datei | Format | Ziel |
|
||||
|---|---|---|
|
||||
| `access.log` | `IP ident auth [HTTPDATE] "VERB REQ" CODE BYTES` | Loki (roh) **und** Prometheus (Metriken) |
|
||||
| `error.log` | `[Mon Sep 14 00:10:01.123456 2026] [core:error] [pid 9] AH00124: …` | Loki (roh), Tag `logtype=error` |
|
||||
|
||||
Bis 1.2.0 wurden **nur** Access-Logs gesammelt. Nachgemessen: 31 `logfile`-Labels
|
||||
in Loki, **0** davon `error.log` — auf einem Panel-Host mit PHP/CMS fehlten damit
|
||||
genau die Fehler, für die man ins Monitoring schaut.
|
||||
|
||||
Error-Logs laufen über einen **eigenen Input**, nicht durch den Access-Grok: das
|
||||
Format passt nicht, und der Aggregator würde sie sonst in leere Gruppen einordnen.
|
||||
Sie tragen `logtype=error` und `logfile=<pfad>` — in Grafana nach beiden filterbar:
|
||||
|
||||
```logql
|
||||
{node="<host>", logtype="error"}
|
||||
{node="<host>", logtype="error"} | logfmt | line_format "{{.message}}" |~ "PHP Fatal"
|
||||
```
|
||||
|
||||
### Drei stille Fallen, die dabei behoben wurden
|
||||
|
||||
Alle drei hätten dazu geführt, dass die Logs **gesammelt und dann verworfen**
|
||||
werden — ohne Fehlermeldung:
|
||||
|
||||
1. `processors.rename` hatte `namepass = ["journal"]`. Für `apache_error` hieß
|
||||
das Feld deshalb `value` statt `message` — jede Query mit `{{.message}}` hätte
|
||||
es nicht gefunden.
|
||||
2. `outputs.loki` hatte `namepass = ["docker_log", "apache_log", "journal"]` —
|
||||
ohne `apache_error`. Der Input sammelt ein, der Output verwirft.
|
||||
3. `outputs.prometheus` fehlte `apache_error` in `namedrop` — sonst wandern
|
||||
Textzeilen als Zeitreihen in die TSDB.
|
||||
|
||||
## Alte Logs archivieren (Historie nachträglich auswertbar machen)
|
||||
|
||||
`inputs.tail` liest mit `initial_read_offset = "saved-or-end"` nur **Neues** — die
|
||||
vorhandene Historie bleibt unausgewertet. Dafür gibt es ein eigenes Skript, das
|
||||
dieselben Zeilen parst und als Prometheus-Blöcke ablegt:
|
||||
|
||||
```sh
|
||||
# Auf der Node: erst messen (ändert nichts)
|
||||
curl -fsSL <raw>/scripts/x22-log-archive.sh -o /root/archiv.sh
|
||||
bash /root/archiv.sh # Bestandsaufnahme
|
||||
|
||||
# dann archivieren
|
||||
bash /root/archiv.sh --backfill # erzeugt apache-archive.openmetrics + tar.gz
|
||||
```
|
||||
|
||||
Das Skript prüft seine eigene Ausgabe **vor** der Übergabe: Zeitraum, Einheit
|
||||
(Sekunden) und ein **Zukunfts-Check** — Punkte nach „jetzt" werden gemeldet, weil
|
||||
sie später beim Import die Zeitreferenz verschieben würden.
|
||||
|
||||
Eingespielt wird **nur in die Archiv-Instanz**, nie in die Produktions-TSDB:
|
||||
|
||||
```sh
|
||||
scp /root/apache-archive/apache-archive.openmetrics mm@<imac>:/tmp/
|
||||
ssh mm@<imac> 'sudo /usr/local/bin/prom-archive-import.sh --file /tmp/apache-archive.openmetrics'
|
||||
```
|
||||
|
||||
Geparst werden Access- **und** Error-Logs, inklusive rotierter `.log.1`/`.log.N.gz`.
|
||||
Was entsteht: `apache_archive_requests_total` (je Domain/Status/Methode/Stunde),
|
||||
`_response_bytes_total`, `_unique_clients`, `_errors_total` (je Level),
|
||||
`_log_lines_total`. Als Datasource in Grafana einhängen
|
||||
(`http://archive-prometheus:9090`) — Live und Archiv sind dann getrennt abfragbar.
|
||||
|
||||
## Archiv-Import: Schutzschichten
|
||||
|
||||
Alte Logs lassen sich als Prometheus-Blöcke archivieren (`scripts/apache-log-archive.py`).
|
||||
Der Import in eine **laufende** TSDB ist heikel — drei Fehler sind möglich, alle
|
||||
still:
|
||||
|
||||
| Fehler | Folge | Schutz |
|
||||
|---|---|---|
|
||||
| Zeitstempel in **Millisekunden** statt Sekunden | Blöcke im Jahr 58.662, Prometheus verwirft sie als „too far into the future" | Einheiten-Wächter im Skript (Abbruch ab Jahr 2100) |
|
||||
| Block reicht in die **Zukunft** | Prometheus verschiebt seine Zeitreferenz, **alle** Live-Samples gelten als „too old" — alle Nodes fallen aus | Zukunft-Prüfung in `prom-archive-import.sh` (Abbruch, Toleranz 5 min) |
|
||||
| **`rm -rf <tsdb>/01M*`** beim Aufräumen | Glob trifft **alle** Blöcke → gesamte Historie gelöscht | kein Glob; nur exakte Block-IDs; Pflicht-Snapshot vorher |
|
||||
|
||||
Deshalb: **Archiv-Blöcke nie in die Produktions-TSDB**, sondern in die getrennte
|
||||
Instanz (`stacks/archive-prometheus`, Port 9091). Sie nimmt keine Live-Daten an
|
||||
und kann die Produktion nicht beeinflussen.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Der Proxy verlangt Bearer-Token; das Header wird vor dem Weiterleiten entfernt und liegt damit nicht bei Prometheus/Loki.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Auto-Updater und Force-Updater
|
||||
|
||||
## Anforderung
|
||||
|
||||
Der Installer soll auf den Nodes **aktuell bleiben** (Auto-Updater) und sich auf
|
||||
Kommando **neu durchsetzen** (Force-Updater) — ohne dass jemand per SSH auf jede
|
||||
Maschine geht.
|
||||
|
||||
## Konzept
|
||||
|
||||
### Versionierung
|
||||
|
||||
`VERSION` im Repo ist die einzige Quelle der Wahrheit (`MAJOR.MINOR.PATCH`).
|
||||
Nach der Installation liegt sie lokal unter `/etc/telegraf/CONFIG_VERSION`
|
||||
zusammen mit einem Hash der Installer-Logik (`/etc/telegraf/INSTALLER_SHA`).
|
||||
|
||||
Warum beides: `VERSION` sagt *was* sich geändert hat, der Hash erkennt auch
|
||||
Änderungen an Scripts und Confs, bei denen die Version nicht erhöht wurde
|
||||
(Fehlerkorrektur an `journal-tail.sh` etwa). Sonst bleibt ein Node mit falschem
|
||||
Script stehen, weil die Version gleich geblieben ist.
|
||||
|
||||
### Auto-Updater
|
||||
|
||||
systemd-Timer `telegraf-installer-update.timer`, Standard **täglich**
|
||||
(`--interval` überschreibt: `daily` | `hourly` | `weekly` | `*-interval`).
|
||||
|
||||
Der Lauf ist bewusst billig: Er holt nur `VERSION` und `SHA256SUMS` (wenige
|
||||
hundert Byte) und vergleicht. Erst bei Abweichung lädt er den Installer und die
|
||||
Confs. Kein `git clone`, kein GitHub-Actions-Overhead.
|
||||
|
||||
Ablauf:
|
||||
1. `VERSION` vom Repo lesen, mit lokalem Wert vergleichen.
|
||||
2. Bei Gleichstand **und** gleichem Hash: still beenden (Exit 0).
|
||||
3. Sonst `install.sh --quiet --update-only` ausführen.
|
||||
4. Ergebnis nach `/var/log/telegraf-installer-update.log` schreiben.
|
||||
|
||||
`--update-only` fasst nur `telegraf.d/`-Confs und Hilfsscripts an. Es installiert
|
||||
**kein** Paket neu und rührt `/etc/telegraf/telegraf.conf` nicht an — dadurch
|
||||
bleibt ein Update auch auf einem PVE-Knoten mit Drop-in harmlos.
|
||||
|
||||
### Force-Updater
|
||||
|
||||
`install.sh --force-update` (Windows: `-ForceUpdate`):
|
||||
|
||||
- Confs und Scripts werden **immer** neu geschrieben, ohne Versionsvergleich.
|
||||
- `systemctl restart telegraf` wird erzwungen.
|
||||
- Danach Selbsttest: Dienst aktiv? Konfig-Test sauber? Erreichbarkeit des
|
||||
Collectors? Ergebnis als Klartext auf stdout, Exit-Code entsprechend.
|
||||
|
||||
Damit ist der Force-Weg das Werkzeug nach einem kaputten Update oder wenn eine
|
||||
Datei manuell verändert wurde. Er ist idempotent — mehrfaches Ausführen ist
|
||||
folgenlos.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Der Updater lädt ausschließlich vom eigenen Gitea (`REPO_RAW`).
|
||||
- Kein Token im Repo: Der Bearer-Token liegt lokal in `/etc/default/telegraf`.
|
||||
`--update-only` ändert diese Datei nicht, ein Update kann den Zugang also nicht
|
||||
zerstören.
|
||||
- Konfig-Test **vor** dem Neustart: `telegraf --test --input-filter cpu:mem`.
|
||||
Schlägt er fehl, wird die alte Conf zurückgerollt (`.bak`).
|
||||
|
||||
## Windows
|
||||
|
||||
`install.ps1 -InstallUpdateTask` legt eine geplante Aufgabe
|
||||
(`\NetBirdTelegraf\Update`) mit denselben Semantiken an: täglich, nur bei
|
||||
Änderung, Ergebnis ins Event-Log. `-ForceUpdate` entspricht dem Linux-Weg.
|
||||
|
||||
## Umsetzungsstand
|
||||
|
||||
| Teil | Status |
|
||||
|---|---|
|
||||
| `VERSION` im Repo | ✅ |
|
||||
| `--check` / `--version` | ✅ |
|
||||
| `--force-update` | ✅ |
|
||||
| Timer + `--install-update-timer` | ✅ |
|
||||
| `-ForceUpdate` / Task (Windows) | ✅ |
|
||||
+93
-1
@@ -13,7 +13,12 @@ param(
|
||||
[string]$Collector = "",
|
||||
[string]$Token = "",
|
||||
[string]$Version = "1.40.0",
|
||||
[switch]$Uninstall
|
||||
[switch]$Uninstall,
|
||||
[switch]$Check, # nur Versionsvergleich
|
||||
[switch]$ForceUpdate, # Confs immer neu schreiben + Dienst neu starten
|
||||
[switch]$InstallUpdateTask, # geplante Aufgabe fuer Auto-Update
|
||||
[switch]$RemoveUpdateTask,
|
||||
[string]$UpdateInterval = "daily"
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
if (-not $Token -and -not $Collector) { throw "Kein Token: -Token <Bearer-Token> angeben (siehe README)." }
|
||||
@@ -38,6 +43,29 @@ if ($Uninstall) {
|
||||
$arch = "amd64"
|
||||
if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $arch = "arm64" }
|
||||
elseif (-not [Environment]::Is64BitOperatingSystem) { $arch = "i386" }
|
||||
$RepoRaw = if ($env:REPO_RAW) { $env:REPO_RAW } else { "https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main" }
|
||||
$MarkerDir = "$env:ProgramData\netbird-telegraf"
|
||||
$VersionFile = Join-Path $MarkerDir "CONFIG_VERSION"
|
||||
|
||||
function Get-RemoteVersion {
|
||||
try { return (Invoke-WebRequest -UseBasicParsing -TimeoutSec 15 "$RepoRaw/VERSION").Content.Trim() }
|
||||
catch { return "" }
|
||||
}
|
||||
|
||||
# Reine Abfragen: kein Token, kein Dienst-Eingriff
|
||||
if ($Check -or (-not $Token -and -not $Uninstall -and -not $RemoveUpdateTask)) {
|
||||
$local = if (Test-Path $VersionFile) { (Get-Content $VersionFile -Raw).Trim() } else { "nicht installiert" }
|
||||
$remote = Get-RemoteVersion
|
||||
if (-not $remote) { $remote = "nicht erreichbar" }
|
||||
Write-Host "installiert: $local"
|
||||
Write-Host "verfuegbar: $remote"
|
||||
if ($Check) {
|
||||
if ($local -eq $remote -and $local -ne "nicht installiert") { Write-Host "Status: aktuell"; exit 0 }
|
||||
Write-Host "Status: Update verfuegbar"; exit 10
|
||||
}
|
||||
if (-not $Token) { Write-Host "[netbird-telegraf] Kein Token angegeben (-Token)."; exit 2 }
|
||||
}
|
||||
|
||||
Write-Host "[netbird-telegraf] Node=$Name Arch=$arch Collector=$Collector"
|
||||
|
||||
try {
|
||||
@@ -125,5 +153,69 @@ Stop-Service telegraf -ErrorAction SilentlyContinue
|
||||
Start-Service telegraf
|
||||
Start-Sleep -Seconds 3
|
||||
if ((Get-Service telegraf).Status -ne "Running") { throw "Dienst telegraf läuft nicht." }
|
||||
|
||||
# --- Versionsmarker fuer den Auto-Updater ---
|
||||
New-Item -ItemType Directory -Force -Path $MarkerDir | Out-Null
|
||||
$rv = Get-RemoteVersion
|
||||
if (-not $rv) { $rv = "unbekannt" }
|
||||
Set-Content -Path $VersionFile -Value $rv -Encoding ASCII
|
||||
$iscript = Join-Path $env:TEMP "tg-installer.ps1"
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -TimeoutSec 25 "$RepoRaw/install.ps1" -OutFile $iscript
|
||||
(Get-FileHash $iscript -Algorithm SHA256).Hash.ToLower() | Set-Content -Path (Join-Path $MarkerDir "INSTALLER_SHA") -Encoding ASCII
|
||||
} catch { }
|
||||
Set-Content -Path (Join-Path $MarkerDir "REPO_RAW") -Value $RepoRaw -Encoding ASCII
|
||||
|
||||
# --- Auto-Update als geplante Aufgabe ---
|
||||
function Install-UpdateTask {
|
||||
param([string]$Interval)
|
||||
$upd = Join-Path $MarkerDir "update.ps1"
|
||||
$body = @"
|
||||
`$ErrorActionPreference = 'Continue'
|
||||
`$log = '`$env:ProgramData\netbird-telegraf\update.log'
|
||||
function Say(`$m) { Add-Content -Path `$log -Value ("{0} {1}" -f (Get-Date -Format s), `$m) }
|
||||
`$repo = if (`$env:REPO_RAW) { `$env:REPO_RAW } else { (Get-Content '`$env:ProgramData\netbird-telegraf\REPO_RAW' -Raw).Trim() }
|
||||
`$vf = '`$env:ProgramData\netbird-telegraf\CONFIG_VERSION'
|
||||
`$local = if (Test-Path `$vf) { (Get-Content `$vf -Raw).Trim() } else { '' }
|
||||
`$remote = ''
|
||||
try { `$remote = (Invoke-WebRequest -UseBasicParsing -TimeoutSec 20 "`$repo/VERSION").Content.Trim() } catch { }
|
||||
if (-not `$remote) { Say 'Repo nicht erreichbar — kein Update.'; exit 0 }
|
||||
`$need = (`$local -ne `$remote)
|
||||
if (-not `$need) {
|
||||
try {
|
||||
`$t = Join-Path `$env:TEMP 'tg-i.ps1'
|
||||
Invoke-WebRequest -UseBasicParsing -TimeoutSec 25 "`$repo/install.ps1" -OutFile `$t
|
||||
`$rs = (Get-FileHash `$t -Algorithm SHA256).Hash.ToLower()
|
||||
`$ls = (Get-Content '`$env:ProgramData\netbird-telegraf\INSTALLER_SHA' -Raw -ErrorAction SilentlyContinue).Trim()
|
||||
if (`$rs -ne `$ls) { `$need = `$true }
|
||||
} catch { }
|
||||
}
|
||||
if (-not `$need) { Say "aktuell (`$local) — nichts zu tun."; exit 0 }
|
||||
Say "Update `$local -> `$remote"
|
||||
`$i = Join-Path `$env:TEMP 'tg-i.ps1'
|
||||
try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 60 "`$repo/install.ps1" -OutFile `$i } catch { Say 'Download fehlgeschlagen.'; exit 1 }
|
||||
`$nm = (Get-Content '`$env:ProgramData\netbird-telegraf\NODE_NAME' -Raw -ErrorAction SilentlyContinue).Trim()
|
||||
`$tk = (Get-Content '`$env:ProgramData\netbird-telegraf\TOKEN' -Raw -ErrorAction SilentlyContinue).Trim()
|
||||
try {
|
||||
& powershell -NoProfile -ExecutionPolicy Bypass -File `$i -Name `$nm -Token `$tk *>> `$log
|
||||
Say 'Update erfolgreich.'
|
||||
} catch { Say "Update FEHLGESCHLAGEN: `$_" }
|
||||
"@
|
||||
Set-Content -Path $upd -Value $body -Encoding UTF8
|
||||
$when = switch ($Interval) { "hourly" { (New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Hours 1)) }
|
||||
"weekly" { (New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 4:30am) }
|
||||
default { (New-ScheduledTaskTrigger -Daily -At 4:30am) } }
|
||||
$act = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$upd`""
|
||||
$set = New-ScheduledTaskSettingsSet -StartWhenAvailable -DontStopOnIdleEnd
|
||||
Register-ScheduledTask -TaskName "NetBirdTelegraf-Update" -TaskPath "\NetBirdTelegraf\" -Trigger $when -Action $act -Settings $set -Force | Out-Null
|
||||
Write-Host "[netbird-telegraf] Auto-Update-Aufgabe aktiv ($Interval) — Log: $MarkerDir\update.log"
|
||||
}
|
||||
|
||||
if ($ForceUpdate) { Write-Host "[netbird-telegraf] Force-Update: Konfiguration wurde neu geschrieben." }
|
||||
if ($InstallUpdateTask) { Install-UpdateTask -Interval $UpdateInterval }
|
||||
if ($RemoveUpdateTask) {
|
||||
Unregister-ScheduledTask -TaskName "NetBirdTelegraf-Update" -TaskPath "\NetBirdTelegraf\" -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Write-Host "[netbird-telegraf] Auto-Update-Aufgabe entfernt."
|
||||
}
|
||||
Write-Host "[netbird-telegraf] OK — Node '$Name' liefert an $PromUrl (Metriken) und $LokiUrl (Eventlog Warn/Error)."
|
||||
Write-Host "[netbird-telegraf] Grafana: http://100.83.134.46:3000 (nur im NetBird)"
|
||||
+878
-23
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
apache-log-archive.py — Alte Apache-Logs parsen und als Prometheus-Archiv ablegen.
|
||||
|
||||
ABLAUF
|
||||
1. Logs einlesen (auch rotierte .gz), Access- UND Error-Logs.
|
||||
2. Pro Stunde/Domain/Status zu Metriken aggregieren.
|
||||
3. OpenMetrics-Dateien schreiben (Prometheus-Textformat mit Zeitstempeln).
|
||||
4. `promtool tsdb create-blocks-from openmetrics` erzeugt daraus TSDB-Blöcke.
|
||||
5. Ergebnis als tar.gz bereitstellen — zur Uebergabe an den zentralen Server.
|
||||
|
||||
WARUM AGGREGIERT UND NICHT PRO REQUEST
|
||||
Prometheus ist eine TSDB, kein Logstore. Eine Serie pro Request waere bei
|
||||
Millionen Zeilen sofort toedlich (Kardinalitaet). Aggregiert auf
|
||||
Stunde/Domain/Status bleiben es wenige tausend Serien — und beantwortet genau
|
||||
die Fragen, fuer die man alte Logs anschaut: Wie viele Requests, welche
|
||||
Fehlerquote, welcher Traffic, zu welcher Zeit.
|
||||
|
||||
Rohzeilen gehoeren in ein Logarchiv (Loki/tar.gz), nicht in die TSDB. Dieses
|
||||
Skript produziert beides: Metriken fuer Prometheus UND ein tar.gz der Rohlogs.
|
||||
|
||||
ERZEUGTE METRIKEN (Name : Labels)
|
||||
apache_archive_requests_total : domain, method, status, status_class
|
||||
apache_archive_response_bytes_total : domain
|
||||
apache_archive_unique_clients : domain (geschaetzt, pro Stunde)
|
||||
apache_archive_errors_total : domain, level
|
||||
apache_archive_log_lines_total : domain, kind (access|error|unparsed)
|
||||
|
||||
Alle tragen zusaetzlich label "archived=1" und "instance=<node>", damit sie
|
||||
im Dashboard von Live-Daten unterscheidbar sind.
|
||||
|
||||
AUFRUF (auf dem Quellhost, als root)
|
||||
python3 apache-log-archive.py --node x22.idx.wf-keyhelp
|
||||
python3 apache-log-archive.py --node x22 --out /root/apache-archive
|
||||
|
||||
Danach die .tar.gz auf den imac bringen und dort einspielen — AUSSCHLIESSLICH
|
||||
ueber prom-archive-import.sh in die getrennte ARCHIV-Instanz (:9091).
|
||||
NIEMALS die Bloecke von Hand in die Produktions-TSDB entpacken: ein Block mit
|
||||
Zeitstempeln ausserhalb des Live-Fensters verschiebt dort die Zeitreferenz und
|
||||
laesst jeden echten Sample als "too old" verwerfen (real passiert am 14.09.2026,
|
||||
13 Nodes verloren ihre Metriken).
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import gzip
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PATTERNS = [
|
||||
"/var/log/apache2/*access*.log", "/var/log/apache2/*access*.log.*",
|
||||
"/var/log/apache2/*error*.log", "/var/log/apache2/*error*.log.*",
|
||||
"/home/users/*/logs/*/access.log", "/home/users/*/logs/*/access.log.*",
|
||||
"/home/users/*/logs/*/error.log", "/home/users/*/logs/*/error.log.*",
|
||||
"/var/www/vhosts/*/logs/access_log*", "/var/www/vhosts/*/logs/error_log*",
|
||||
]
|
||||
|
||||
RE_LINE = re.compile(
|
||||
r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<req>[^"]*)" (?P<status>\d{3}|-) (?P<bytes>\d+|-)'
|
||||
)
|
||||
RE_ERR = re.compile(r'^\[(?P<ts>[^\]]+)\] \[(?P<mod>[^\]:]+):(?P<lvl>\w+)\]')
|
||||
|
||||
TS_FORMATS = [
|
||||
# access.log (Apache Access, MIT Zeitzone)
|
||||
"%d/%b/%Y:%H:%M:%S %z",
|
||||
# ERROR-LOGS OHNE ZEITZONE — der Apache-Standard in error.log:
|
||||
# [Mon Sep 14 00:10:01.123456 2026] [core:error] ...
|
||||
# [Mon Sep 14 00:10:01 2026] [php:warn] ...
|
||||
"%a %b %d %H:%M:%S.%f %Y",
|
||||
"%a %b %d %H:%M:%S %Y",
|
||||
# KeyHelp/Debian schreiben error.log AUCH im Access-Format, aber OHNE
|
||||
# Zeitzone: [01/Sep/2026:10:00:05] [core:error] ...
|
||||
# Fehlte hier bisher -> parse_ts() gab None -> hour="unknown" -> die
|
||||
# Error-Metrik wurde STILL verworfen (kein Fehler, kein Log, das Panel
|
||||
# blieb leer). Real gemessen: error.log-Zeilen landeten nur in
|
||||
# log_lines_total, apache_archive_errors_total fehlte komplett.
|
||||
"%d/%b/%Y:%H:%M:%S",
|
||||
]
|
||||
|
||||
# Metriknamen, die das Skript erzeugt — auch fuer den Import-Loader relevant.
|
||||
METRICS = [
|
||||
"apache_archive_requests_total",
|
||||
"apache_archive_response_bytes_total",
|
||||
"apache_archive_unique_clients",
|
||||
"apache_archive_errors_total",
|
||||
"apache_archive_log_lines_total",
|
||||
]
|
||||
|
||||
|
||||
def parse_ts(raw):
|
||||
for fmt in TS_FORMATS:
|
||||
try:
|
||||
dt = datetime.strptime(raw.strip(), fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.astimezone()
|
||||
return dt.astimezone(timezone.utc)
|
||||
return None
|
||||
|
||||
|
||||
def status_class(code):
|
||||
try:
|
||||
c = int(code)
|
||||
except (TypeError, ValueError):
|
||||
return "unknown"
|
||||
return f"{c // 100}xx"
|
||||
|
||||
|
||||
def esc(v):
|
||||
"""Label-Wert fuer OpenMetrics escapen (Backslash, Anfuehrungszeichen, LF)."""
|
||||
return str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||
|
||||
|
||||
def collect(patterns):
|
||||
"""Alle Logdateien finden und parsen. Liefert Zaehler und Rohdateiliste."""
|
||||
files = sorted({f for pat in patterns for f in glob.glob(pat)})
|
||||
# requests[(hour, domain, method, status)] = count
|
||||
requests = defaultdict(int)
|
||||
bytes_by_domain = defaultdict(int)
|
||||
bytes_by_hour = defaultdict(int) # (hour, domain) -> Bytes
|
||||
clients = defaultdict(set) # (hour, domain) -> {ip}
|
||||
errors = defaultdict(int) # (hour, domain, level) -> count
|
||||
lines = defaultdict(int) # (domain, kind) -> count
|
||||
# Zeitstempel, die KEIN Format getroffen haben. Frueher still verworfen —
|
||||
# dadurch fehlte die Error-Metrik komplett, ohne Fehlermeldung.
|
||||
unparsed_ts = defaultdict(int) # (domain, roh) -> count
|
||||
|
||||
for path in files:
|
||||
domain = os.path.basename(os.path.dirname(path)) or os.path.basename(path)
|
||||
try:
|
||||
fh = (gzip.open(path, "rt", errors="replace") if path.endswith(".gz")
|
||||
else open(path, "r", errors="replace"))
|
||||
except OSError:
|
||||
continue
|
||||
with fh:
|
||||
for line in fh:
|
||||
m = RE_LINE.match(line)
|
||||
if m:
|
||||
lines[(domain, "access")] += 1
|
||||
ts = parse_ts(m.group("ts"))
|
||||
req = m.group("req") or ""
|
||||
method = req.split(" ", 1)[0] if " " in req else "UNKNOWN"
|
||||
status = m.group("status") or "unknown"
|
||||
hour = ts.strftime("%Y-%m-%dT%H:00:00Z") if ts else "unknown"
|
||||
requests[(hour, domain, method, status)] += 1
|
||||
try:
|
||||
nb = int(m.group("bytes") or 0)
|
||||
bytes_by_domain[domain] += nb
|
||||
if ts:
|
||||
bytes_by_hour[(hour, domain)] += nb
|
||||
except ValueError:
|
||||
pass
|
||||
if ts:
|
||||
clients[(hour, domain)].add(m.group("ip"))
|
||||
continue
|
||||
m = RE_ERR.match(line)
|
||||
if m:
|
||||
lines[(domain, "error")] += 1
|
||||
ts = parse_ts(m.group("ts"))
|
||||
hour = ts.strftime("%Y-%m-%dT%H:00:00Z") if ts else "unknown"
|
||||
if ts:
|
||||
errors[(hour, domain, m.group("lvl").lower())] += 1
|
||||
else:
|
||||
# NICHT still verwerfen: ein unparsbarer Zeitstempel hat
|
||||
# vorher die ganze Error-Metrik gekostet, ohne jede Spur.
|
||||
unparsed_ts[(domain, m.group("ts")[:40])] += 1
|
||||
continue
|
||||
lines[(domain, "unparsed")] += 1
|
||||
return files, requests, bytes_by_domain, bytes_by_hour, clients, errors, lines, unparsed_ts
|
||||
|
||||
|
||||
def write_openmetrics(path, node, requests, bytes_by_domain, bytes_by_hour, clients, errors, lines):
|
||||
"""OpenMetrics-Textdatei schreiben. Zeitstempel in SEKUNDEN.
|
||||
|
||||
ACHTUNG: OpenMetrics will Sekunden (float). Nicht Millisekunden —
|
||||
Prometheus multipliziert beim Import mit 1000 und legt die Bloecke
|
||||
sonst ins Jahr 58.662, wo sie verworfen werden.
|
||||
"""
|
||||
base_labels = f'archived="1",instance="{esc(node)}"'
|
||||
n = 0
|
||||
_max_ts = 0 # groesster geschriebener Zeitstempel (Einheiten-Waechter)
|
||||
with open(path, "w") as fh:
|
||||
# EINHEITEN — hier lag ein realer Fehler:
|
||||
# OpenMetrics-Zeitstempel sind SEKUNDEN (float). Prometheus wandelt beim
|
||||
# Import ms = s * 1000. Wer hier Millisekunden schreibt, bekommt Bloecke
|
||||
# im Jahr 58.662 — sie werden als "too far into the future" verworfen
|
||||
# und die Abfrage liefert stillschweigend nichts. Kein * 1000.
|
||||
fh.write("# TYPE apache_archive_requests_total counter\n")
|
||||
fh.write("# TYPE apache_archive_response_bytes_total counter\n")
|
||||
fh.write("# TYPE apache_archive_unique_clients gauge\n")
|
||||
fh.write("# TYPE apache_archive_errors_total counter\n")
|
||||
fh.write("# TYPE apache_archive_log_lines_total counter\n")
|
||||
|
||||
for (hour, domain, method, status), cnt in sorted(requests.items()):
|
||||
if hour == "unknown":
|
||||
continue
|
||||
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
labels = (f'{base_labels},domain="{esc(domain)}",method="{esc(method)}",'
|
||||
f'status="{esc(status)}",status_class="{esc(status_class(status))}"')
|
||||
fh.write(f"apache_archive_requests_total{{{labels}}} {cnt} {ms}\n")
|
||||
_max_ts = max(_max_ts, ms)
|
||||
n += 1
|
||||
|
||||
# Bytes pro Stunde: das ergibt die Traffic-Zeitreihe im Archiv.
|
||||
for (hour, domain), total in sorted(bytes_by_hour.items()):
|
||||
if hour == "unknown":
|
||||
continue
|
||||
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
labels = f'{base_labels},domain="{esc(domain)}"'
|
||||
fh.write(f"apache_archive_response_bytes_total{{{labels}}} {total} {ms}\n")
|
||||
_max_ts = max(_max_ts, ms)
|
||||
n += 1
|
||||
|
||||
for (hour, domain), ips in sorted(clients.items()):
|
||||
try:
|
||||
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
labels = f'{base_labels},domain="{esc(domain)}"'
|
||||
fh.write(f"apache_archive_unique_clients{{{labels}}} {len(ips)} {ms}\n")
|
||||
_max_ts = max(_max_ts, ms)
|
||||
n += 1
|
||||
|
||||
for (hour, domain, level), cnt in sorted(errors.items()):
|
||||
if hour == "unknown":
|
||||
continue
|
||||
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
labels = f'{base_labels},domain="{esc(domain)}",level="{esc(level)}"'
|
||||
fh.write(f"apache_archive_errors_total{{{labels}}} {cnt} {ms}\n")
|
||||
_max_ts = max(_max_ts, ms)
|
||||
n += 1
|
||||
|
||||
# Zeilenzahlen sind Zustandswerte ohne eigenen Zeitbezug. Sie werden auf
|
||||
# die LETZTE Log-Stunde gestempelt, nicht auf "jetzt" — sonst haengt ein
|
||||
# Archiv-Eintrag am Abruftag und die Historie wirkt verschoben.
|
||||
_hours = [h for (h, _, _, _) in requests if h != "unknown"]
|
||||
if _hours:
|
||||
stamp_s = int(datetime.strptime(max(_hours), "%Y-%m-%dT%H:00:00Z")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
else:
|
||||
stamp_s = int(datetime.now(timezone.utc).timestamp())
|
||||
for (domain, kind), cnt in sorted(lines.items()):
|
||||
labels = f'{base_labels},domain="{esc(domain)}",kind="{esc(kind)}"'
|
||||
fh.write(f"apache_archive_log_lines_total{{{labels}}} {cnt} {stamp_s}\n")
|
||||
_max_ts = max(_max_ts, stamp_s)
|
||||
n += 1
|
||||
|
||||
# EINHEITEN-WAECHTER: Der groesste Zeitstempel muss ein plausibles
|
||||
# Sekunden-Datum ergeben. Faengt 1000x-Fehler (ms statt s) sofort ab,
|
||||
# statt sie still in einen nutzlosen Block zu schreiben.
|
||||
if _max_ts > 4102444800: # nach dem Jahr 2100 -> kein Sekundenwert
|
||||
raise SystemExit(
|
||||
f"FEHLER: Zeitstempel {_max_ts} liegt nach dem Jahr 2100.\n"
|
||||
f" OpenMetrics erwartet SEKUNDEN, nicht Millisekunden.\n"
|
||||
f" Pruefe die *1000-Multiplikationen in diesem Skript.\n"
|
||||
f" Ohne Korrektur entstehen Bloecke, die Prometheus verwirft.")
|
||||
|
||||
# PFLICHT: OpenMetrics verlangt "# EOF" als letzte Zeile. Ohne das
|
||||
# lehnt promtool ab: "data does not end with # EOF".
|
||||
fh.write("# EOF\n")
|
||||
return n
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Apache-Logs als Prometheus-Archiv aufbereiten")
|
||||
ap.add_argument("--node", required=True, help="Node-Name fuer das instance-Label")
|
||||
ap.add_argument("--out", default="/root/apache-archive", help="Ausgabeverzeichnis")
|
||||
ap.add_argument("--include-raw", action="store_true",
|
||||
help="Rohlogs mit ins tar.gz packen (empfohlen)")
|
||||
args = ap.parse_args()
|
||||
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
print(f"[1/5] Logs einlesen …")
|
||||
(files, requests, bytes_by_domain, bytes_by_hour,
|
||||
clients, errors, lines, unparsed_ts) = collect(PATTERNS)
|
||||
if not files:
|
||||
print("Keine Logdateien gefunden. Muster:", ", ".join(PATTERNS[:4]), file=sys.stderr)
|
||||
return 1
|
||||
total_lines = sum(lines.values())
|
||||
print(f" {len(files)} Dateien, {total_lines:,} Zeilen")
|
||||
print(f" {sum(requests.values()):,} Zugriffe, {sum(errors.values()):,} Fehlereintraege")
|
||||
|
||||
# Unparsbare Zeitstempel LAUT melden. Sie kosteten frueher die ganze
|
||||
# Error-Metrik, ohne dass irgendwo etwas stand.
|
||||
if unparsed_ts:
|
||||
n = sum(unparsed_ts.values())
|
||||
print(f" WARNUNG: {n:,} Eintraege mit unlesbarem Zeitstempel — "
|
||||
f"diese fehlen in den Zeitreihen (nicht in den Rohdaten).", file=sys.stderr)
|
||||
for (dom, raw), cnt in sorted(unparsed_ts.items(), key=lambda x: -x[1])[:5]:
|
||||
print(f" {cnt:>6}x domain={dom} Beispiel: {raw}", file=sys.stderr)
|
||||
print(" Ein fehlendes Format in TS_FORMATS ergaenzen, dann erneut laufen.",
|
||||
file=sys.stderr)
|
||||
|
||||
print(f"[2/5] OpenMetrics schreiben …")
|
||||
om = os.path.join(args.out, "apache-archive.openmetrics")
|
||||
n = write_openmetrics(om, args.node, requests, bytes_by_domain, bytes_by_hour,
|
||||
clients, errors, lines)
|
||||
size = os.path.getsize(om)
|
||||
print(f" {n:,} Datenpunkte, {size/1024:.1f} KiB")
|
||||
|
||||
# promtool suchen — liegt beim prometheus-Container bzw. als Binary vor.
|
||||
promtool = None
|
||||
for cand in ("promtool", "/usr/local/bin/promtool", "/usr/bin/promtool"):
|
||||
try:
|
||||
subprocess.run([cand, "--version"], capture_output=True, check=True)
|
||||
promtool = cand
|
||||
break
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
continue
|
||||
|
||||
if promtool is None:
|
||||
print(f"[3/5] promtool nicht gefunden — openmetrics-Datei bleibt liegen.")
|
||||
print(f" Der zentrale Server kann sie direkt verarbeiten:")
|
||||
print(f" promtool tsdb create-blocks-from openmetrics {om} <blockdir>")
|
||||
blocks_dir = None
|
||||
else:
|
||||
print(f"[3/5] TSDB-Bloecke erzeugen (promtool) …")
|
||||
blocks_dir = os.path.join(args.out, "blocks")
|
||||
os.makedirs(blocks_dir, exist_ok=True)
|
||||
r = subprocess.run([promtool, "tsdb", "create-blocks-from", "openmetrics",
|
||||
om, blocks_dir], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(" promtool-Fehler:", r.stderr.strip()[:400], file=sys.stderr)
|
||||
blocks_dir = None
|
||||
else:
|
||||
print(" " + (r.stdout.strip() or "Bloecke erzeugt"))
|
||||
|
||||
print(f"[4/5] Archiv packen …")
|
||||
tar_path = os.path.join(args.out, f"apache-archive-{args.node}.tar.gz")
|
||||
with tarfile.open(tar_path, "w:gz") as tf:
|
||||
tf.add(om, arcname="openmetrics/apache-archive.openmetrics")
|
||||
if blocks_dir:
|
||||
tf.add(blocks_dir, arcname="blocks")
|
||||
if args.include_raw:
|
||||
# Rohlogs mitsichern: Prometheus hat nur die Aggregate — fuer die
|
||||
# Fehlersuche im Einzelfall braucht es die Zeilen.
|
||||
for f in files:
|
||||
try:
|
||||
tf.add(f, arcname="raw/" + f.lstrip("/"))
|
||||
except (OSError, tarfile.TarError):
|
||||
pass
|
||||
print(f" {tar_path} ({os.path.getsize(tar_path)/1024/1024:.1f} MiB)")
|
||||
|
||||
print(f"[5/5] Fertig.")
|
||||
print()
|
||||
print(f" Uebergabe an den zentralen Server:")
|
||||
print(f" scp {tar_path} mm@192.168.188.177:/tmp/")
|
||||
print()
|
||||
print(f" ============================================================")
|
||||
print(f" EINSPIELEN — NUR in die ARCHIV-Instanz (:9091)")
|
||||
print(f" ============================================================")
|
||||
print(f" ssh mm@192.168.188.177 \\")
|
||||
print(f" 'sudo /usr/local/bin/prom-archive-import.sh --file /tmp/{os.path.basename(tar_path)}'")
|
||||
print()
|
||||
print(f" Das Import-Skript prueft die Zeitstempel VOR dem Einspielen, legt ein")
|
||||
print(f" Manifest an (Schutz gegen Doppel-Import) und schreibt ausschliesslich")
|
||||
print(f" ins Archiv-Verzeichnis.")
|
||||
print()
|
||||
print(f" NIEMALS von Hand entpacken — insbesondere NICHT nach")
|
||||
print(f" central-imac/data/prometheus/. Genau dieser Weg hat am 14.09.2026 die")
|
||||
print(f" Zeitreferenz der Produktions-TSDB verschoben: 13 Nodes verloren ihre")
|
||||
print(f" Metriken, jeder Live-Sample galt danach als 'too old'.")
|
||||
print()
|
||||
print(f" Hinweis: instance=\"{args.node}\", archived=\"1\" — im Dashboard von")
|
||||
print(f" Live-Daten unterscheidbar (Filter archived!=\"1\" blendet das Archiv aus).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
apache-log-survey.py — Bestandsaufnahme der Apache-Logs auf einem KeyHelp-Host.
|
||||
|
||||
MESSEN, NICHT RATEN: Bevor Logs archiviert werden, muss der Umfang bekannt sein.
|
||||
Kardinalitaet ist der begrenzende Faktor in Prometheus — pro Request eine Serie
|
||||
wuerde die TSDB sprengen. Die Aggregation richtet sich nach diesen Zahlen.
|
||||
|
||||
Aufruf (als root, damit alle User-Homes lesbar sind):
|
||||
python3 apache-log-survey.py
|
||||
|
||||
Ausgabe: Dateien, Zeilen, Zeitraum, Status-Verteilung, Top-Domains.
|
||||
Schreibt nichts, aendert nichts.
|
||||
"""
|
||||
import gzip
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Zugriffs- und Fehlerlogs, inkl. rotierter Varianten (.1, .2.gz).
|
||||
PATTERNS = [
|
||||
"/var/log/apache2/*access*.log", "/var/log/apache2/*access*.log.*",
|
||||
"/var/log/apache2/*error*.log", "/var/log/apache2/*error*.log.*",
|
||||
"/home/users/*/logs/*/access.log", "/home/users/*/logs/*/access.log.*",
|
||||
"/home/users/*/logs/*/error.log", "/home/users/*/logs/*/error.log.*",
|
||||
"/var/www/vhosts/*/logs/access_log*", "/var/www/vhosts/*/logs/error_log*",
|
||||
]
|
||||
|
||||
# Combined / Common: ip - - [14/Sep/2026:00:47:01 +0200] "GET /x HTTP/1.1" 200 559
|
||||
RE_LINE = re.compile(
|
||||
r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<req>[^"]*)" (?P<status>\d{3}|-) (?P<bytes>\d+|-)'
|
||||
)
|
||||
# Apache-Fehlerlog: [Mon Sep 14 00:47:01.123456 2026] [core:error] [pid 1] msg
|
||||
RE_ERR = re.compile(r'^\[(?P<ts>[^\]]+)\] \[(?P<mod>[^\]:]+):(?P<lvl>\w+)\]')
|
||||
|
||||
TS_FORMATS = ["%d/%b/%Y:%H:%M:%S %z", "%a %b %d %H:%M:%S.%f %Y", "%a %b %d %H:%M:%S %Y"]
|
||||
|
||||
|
||||
def open_maybe_gz(path):
|
||||
try:
|
||||
if path.endswith(".gz"):
|
||||
return gzip.open(path, "rt", errors="replace")
|
||||
return open(path, "r", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_ts(raw):
|
||||
"""Zeitstempel parsen und IMMER auf zeitzonenbewusst (UTC) normalisieren.
|
||||
|
||||
Access-Logs haben eine Zeitzone ([14/Sep/2026:00:47:01 +0200]), Error-Logs
|
||||
nicht. Ohne Normalisierung sind die Werte nicht vergleichbar — Python wirft
|
||||
dann 'can't compare offset-naive and offset-aware datetimes'. Lokale Zeiten
|
||||
werden als Systemzeitzone interpretiert (das ist auch ihre Bedeutung).
|
||||
"""
|
||||
for fmt in TS_FORMATS:
|
||||
try:
|
||||
dt = datetime.strptime(raw.strip(), fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.astimezone() # lokale Zeit -> mit Offset versehen
|
||||
return dt.astimezone(timezone.utc)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
# ----------------------------------------------------------------------
|
||||
# FORTSCHRITT SICHTBAR MACHEN — sonst sieht der Scan wie ein Haenger aus.
|
||||
#
|
||||
# Real passiert (x22): der Aufruf war `python3 survey.py 2>&1 | tee bestand.txt`.
|
||||
# Das Skript gibt ALLES erst am Ende aus (alle print() stehen hinter der
|
||||
# Scan-Schleife), und Python puffert stdout in eine PIPE blockweise (4-8 KB,
|
||||
# nicht zeilenweise). Die Gesamtausgabe ist aber nur ~1 KB — sie landet also
|
||||
# erst beim Prozessende in der Datei. Bei 2 Mio Zeilen gemessen: 41 s lang
|
||||
# 0 Bytes, dann alles auf einmal. Auf einem KeyHelp-Host mit tausenden
|
||||
# Logdateien dauert das Minuten und liest sich wie ein Absturz.
|
||||
#
|
||||
# Drei Gegenmaßnahmen:
|
||||
# 1. Zeilenpufferung erzwingen (reconfigure), damit jede Zeile sofort raus ist
|
||||
# 2. einen Startbanner VOR dem Scan ausgeben
|
||||
# 3. pro Datei eine Fortschrittszeile (mit flush)
|
||||
# ----------------------------------------------------------------------
|
||||
try:
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
sys.stderr.reconfigure(line_buffering=True)
|
||||
except (AttributeError, ValueError):
|
||||
pass # aeltere Python-Version: dann eben gepuffert
|
||||
|
||||
files = sorted({f for pat in PATTERNS for f in glob.glob(pat)})
|
||||
if not files:
|
||||
print("Keine Apache-Logs gefunden. Laeuft Apache? (apache2ctl -v)")
|
||||
return 1
|
||||
|
||||
total_bytes = 0
|
||||
for _f in files:
|
||||
try:
|
||||
total_bytes += os.path.getsize(_f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def human(n):
|
||||
for u in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {u}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
print("=" * 72)
|
||||
print("APACHE-LOG BESTANDSAUFNAHME")
|
||||
print("=" * 72)
|
||||
print(f"Scanne {len(files)} Dateien ({human(total_bytes)} auf Platte) …")
|
||||
print("(grosse Bestaende dauern; dieser Fortschritt laeuft mit)")
|
||||
print()
|
||||
|
||||
total_lines = 0
|
||||
total_parsed = 0
|
||||
total_err = 0
|
||||
bytes_on_disk = 0
|
||||
first_ts = None
|
||||
last_ts = None
|
||||
by_status = Counter()
|
||||
by_domain = Counter()
|
||||
by_method = Counter()
|
||||
err_levels = Counter()
|
||||
# Stunden-Buckets: hier entscheidet sich die Kardinalitaet der Archiv-Metriken.
|
||||
hour_domain = set()
|
||||
day_domain = set()
|
||||
per_file = []
|
||||
|
||||
t0 = time.time()
|
||||
for _idx, path in enumerate(files, 1):
|
||||
fh = open_maybe_gz(path)
|
||||
if fh is None:
|
||||
print(f" [{_idx}/{len(files)}] NICHT LESBAR: {path}", flush=True)
|
||||
continue
|
||||
try:
|
||||
_sz = os.path.getsize(path)
|
||||
bytes_on_disk += _sz
|
||||
except OSError:
|
||||
_sz = 0
|
||||
domain = os.path.basename(os.path.dirname(path)) or os.path.basename(path)
|
||||
n_lines = n_ok = n_err = 0
|
||||
f_first = f_last = None
|
||||
with fh:
|
||||
for line in fh:
|
||||
n_lines += 1
|
||||
m = RE_LINE.match(line)
|
||||
if m:
|
||||
n_ok += 1
|
||||
by_status[m.group("status")] += 1
|
||||
by_domain[domain] += 1
|
||||
req = m.group("req") or ""
|
||||
if " " in req:
|
||||
by_method[req.split(" ", 1)[0]] += 1
|
||||
ts = parse_ts(m.group("ts"))
|
||||
if ts is not None:
|
||||
if first_ts is None or ts < first_ts:
|
||||
first_ts = ts
|
||||
if last_ts is None or ts > last_ts:
|
||||
last_ts = ts
|
||||
if f_first is None or ts < f_first:
|
||||
f_first = ts
|
||||
if f_last is None or ts > f_last:
|
||||
f_last = ts
|
||||
hour_domain.add((ts.strftime("%Y-%m-%dT%H"), domain))
|
||||
day_domain.add((ts.strftime("%Y-%m-%d"), domain))
|
||||
continue
|
||||
m = RE_ERR.match(line)
|
||||
if m:
|
||||
n_err += 1
|
||||
err_levels[m.group("lvl").lower()] += 1
|
||||
ts = parse_ts(m.group("ts"))
|
||||
if ts is not None:
|
||||
if first_ts is None or ts < first_ts:
|
||||
first_ts = ts
|
||||
if last_ts is None or ts > last_ts:
|
||||
last_ts = ts
|
||||
total_lines += n_lines
|
||||
total_parsed += n_ok
|
||||
total_err += n_err
|
||||
per_file.append((path, n_lines, n_ok, n_err, f_first, f_last))
|
||||
# Fortschritt pro Datei — und bei grossen Dateien auch bei jeder Zeile
|
||||
# die Marke, damit lange Einzeldateien nicht stumm wirken.
|
||||
print(f" [{_idx}/{len(files)}] {n_lines:>10,} Zeilen "
|
||||
f"{human(_sz):>9} {path}", flush=True)
|
||||
|
||||
print()
|
||||
print(f"Scan fertig in {time.time() - t0:.1f} s.")
|
||||
print()
|
||||
print(f"Zeilen total: {total_lines:,}")
|
||||
print(f" Zugriffe: {total_parsed:,}")
|
||||
print(f" Fehlerlog: {total_err:,}")
|
||||
print(f" Unparsbar: {total_lines - total_parsed - total_err:,}")
|
||||
if first_ts and last_ts:
|
||||
span = last_ts - first_ts
|
||||
print(f"Zeitraum: {first_ts:%Y-%m-%d %H:%M} .. {last_ts:%Y-%m-%d %H:%M} ({span.days} Tage)")
|
||||
print()
|
||||
print("--- Status-Verteilung (Top 10) ---")
|
||||
for code, n in by_status.most_common(10):
|
||||
print(f" {code} {n:>12,}")
|
||||
print()
|
||||
print("--- Methoden ---")
|
||||
for meth, n in by_method.most_common(8):
|
||||
print(f" {meth:<8} {n:>12,}")
|
||||
print()
|
||||
print("--- Top 15 Domains ---")
|
||||
for dom, n in by_domain.most_common(15):
|
||||
print(f" {n:>12,} {dom}")
|
||||
print(f" ({len(by_domain)} Domains insgesamt)")
|
||||
if err_levels:
|
||||
print()
|
||||
print("--- Fehler-Level ---")
|
||||
for lvl, n in err_levels.most_common():
|
||||
print(f" {lvl:<10} {n:>10,}")
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("KARDINALITAET der Archiv-Metriken (das entscheidet die Granularitaet)")
|
||||
print("=" * 72)
|
||||
print(f" pro Stunde + Domain: {len(hour_domain):,} Serien")
|
||||
print(f" pro Tag + Domain: {len(day_domain):,} Serien")
|
||||
print(f" pro Tag + Domain + Status: ~{len(day_domain) * max(1, len(by_status)):,} Serien")
|
||||
print()
|
||||
print("Faustregel: unter ~100k Serien ist unkritisch, darueber wird die TSDB traege.")
|
||||
print()
|
||||
print("--- Groesste Dateien ---")
|
||||
for path, n_lines, n_ok, n_err, _, _ in sorted(per_file, key=lambda x: -x[1])[:10]:
|
||||
print(f" {n_lines:>10,} Zeilen {path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
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())
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""docker-events-loki.py — Docker-Events (Neustart, OOM-Kill, Stop, Health)
|
||||
dauerhaft nach Loki. Damit ist "wer hat den Container neu gestartet" in
|
||||
Grafana beantwortet statt in Rätselraten.
|
||||
|
||||
ARCHITEKTUR (nach zwei echten Fehlern in der ersten Fassung):
|
||||
* Docker hält Events nur im Speicher. Ein Poller mit `--since` verliert bei
|
||||
Ausfällen alles dazwischen. Deshalb ein dauerhafter `docker events`-Stream.
|
||||
* Ein RAM-Puffer verliert Events, wenn Loki nicht mithält. GEMESSEN auf x05:
|
||||
ein POST über den NetBird-Reverse-Proxy braucht 11-19 s (TLS-Handshake
|
||||
allein nur 0,5-1,6 s, Loki direkt 1,3 ms) — der Weg ist der Flaschenhals.
|
||||
Mit RAM-Puffer und 5-s-Flush lief die Queue voll und die erste Fassung
|
||||
VERWARF Events. Bei einem Audit-Stream ist Verwerfen inakzeptabel.
|
||||
* Daher: jedes Event geht zuerst in eine Spool-DATEI (append, O_APPEND).
|
||||
Der Sender arbeitet die Datei ab und trunkiert nach bestätigtem Push.
|
||||
Loki-Ausfall oder langsamer Proxy kosten damit nur Verzögerung, keine Daten.
|
||||
* Die Spool-Größe ist begrenzt; bei Überlauf werden die ÄLTESTEN verworfen
|
||||
und laut gemeldet (auf diesem Host mit 967 MiB RAM darf ein Log-Forwarder
|
||||
nie die Ursache des nächsten OOM werden).
|
||||
|
||||
ENV (aus /opt/observability-telegraf/.env):
|
||||
LOKI_URL, TELEGRAF_TOKEN, optional NODE_NAME
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
STATE_DIR = os.environ.get("DOCKER_EVENTS_STATE", "/var/lib/docker-events")
|
||||
LOKI_URL = os.environ.get("LOKI_URL", "").rstrip("/")
|
||||
TOKEN = os.environ.get("TELEGRAF_TOKEN", "")
|
||||
NODE = os.environ.get("NODE_NAME") or os.uname().nodename
|
||||
|
||||
SPOOL = os.path.join(STATE_DIR, "spool.jsonl")
|
||||
CURSOR = os.path.join(STATE_DIR, "cursor")
|
||||
HEALTH_FILE = os.path.join(STATE_DIR, "heartbeat")
|
||||
SPOOL_MAX_BYTES = 20 * 1024 * 1024 # ~50k Events; danach älteste weg
|
||||
BATCH_LINES = 50
|
||||
BATCH_SECONDS = 10.0
|
||||
PUSH_TIMEOUT = 120 # Proxy-Weg braucht 11-19 s (gemessen)
|
||||
IDLE_SLEEP = 2.0
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {msg}", flush=True)
|
||||
|
||||
|
||||
def push(lines):
|
||||
"""lines: Liste von (ns_string, message_string, labels_dict)"""
|
||||
if not lines:
|
||||
return True
|
||||
streams = {}
|
||||
for ns, msg, labels in lines:
|
||||
key = tuple(sorted(labels.items()))
|
||||
streams.setdefault(key, {"stream": dict(labels), "values": []})
|
||||
streams[key]["values"].append([ns, msg])
|
||||
body = json.dumps({"streams": list(streams.values())}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{LOKI_URL}/loki/api/v1/push", data=body, method="POST",
|
||||
headers={"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {TOKEN}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=PUSH_TIMEOUT) as r:
|
||||
return 200 <= r.status < 300
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = ""
|
||||
try:
|
||||
detail = e.read()[:200].decode(errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
log(f"LOKI HTTP {e.code}: {detail!r}")
|
||||
# 4xx sind dauerhaft -> Batch verwerfen, sonst blockiert er ewig.
|
||||
return "permanent" if 400 <= e.code < 500 else False
|
||||
except Exception as e:
|
||||
log(f"LOKI nicht erreichbar ({type(e).__name__}): {e}")
|
||||
return False
|
||||
|
||||
|
||||
def spool_append(rec):
|
||||
"""Ein Event atomar anhängen. O_APPEND: kein Verlust bei gleichzeitigem Lesen."""
|
||||
line = json.dumps(rec, ensure_ascii=False) + "\n"
|
||||
fd = os.open(SPOOL, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, line.encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def spool_rotate_if_big():
|
||||
"""Zu groß? Die ÄLTESTEN verwerfen (neue Events sind die wertvollen)."""
|
||||
try:
|
||||
size = os.path.getsize(SPOOL)
|
||||
except OSError:
|
||||
return
|
||||
if size <= SPOOL_MAX_BYTES:
|
||||
return
|
||||
keep = SPOOL_MAX_BYTES // 2
|
||||
try:
|
||||
with open(SPOOL, "rb") as fh:
|
||||
fh.seek(-keep, os.SEEK_END)
|
||||
tail = fh.read()
|
||||
# an der ersten vollständigen Zeile abschneiden
|
||||
nl = tail.find(b"\n")
|
||||
if nl >= 0:
|
||||
tail = tail[nl + 1:]
|
||||
tmp = SPOOL + ".rot"
|
||||
with open(tmp, "wb") as fh:
|
||||
fh.write(tail)
|
||||
os.replace(tmp, SPOOL)
|
||||
log(f"WARNUNG: Spool war {size/1e6:.1f} MB, aelteste Haelfte verworfen")
|
||||
except OSError as e:
|
||||
log(f"WARNUNG: Spool-Rotation fehlgeschlagen: {e}")
|
||||
|
||||
|
||||
def collect_thread():
|
||||
"""docker events -> Spool. Läuft nie blockierend, damit der Kernel-Puffer
|
||||
nicht überläuft (sonst verliert Docker selbst Events)."""
|
||||
while True:
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
["docker", "events", "--format", "{{json .}}"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
text=True, bufsize=1)
|
||||
except FileNotFoundError:
|
||||
log("FEHLER: docker nicht gefunden — warte 30 s")
|
||||
time.sleep(30)
|
||||
continue
|
||||
|
||||
assert proc.stdout is not None
|
||||
log("docker-events-Stream verbunden")
|
||||
for raw in proc.stdout:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
ev = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
ts = int(ev.get("timeNano") or ev.get("time") or 0)
|
||||
if ts and ts < 10**12: # war in Sekunden
|
||||
ts *= 10**9
|
||||
if not ts:
|
||||
ts = int(time.time() * 10**9)
|
||||
|
||||
act = ev.get("Action") or ev.get("status") or "unknown"
|
||||
actor = ev.get("Actor") or {}
|
||||
attrs = actor.get("Attributes") or {}
|
||||
name = attrs.get("name") or str(ev.get("id", ""))[:12] or "?"
|
||||
|
||||
try:
|
||||
spool_append({
|
||||
"ns": str(ts),
|
||||
"labels": {
|
||||
"node": NODE,
|
||||
"source": "docker-events",
|
||||
"action": act.replace(" ", "_")[:40],
|
||||
"name": name[:60],
|
||||
},
|
||||
"msg": json.dumps({
|
||||
"action": act, "name": name,
|
||||
"image": str(attrs.get("image", ""))[:80],
|
||||
"exitCode": attrs.get("exitCode", ""),
|
||||
"oomKilled": attrs.get("oomKilled", ""),
|
||||
"signal": attrs.get("signal", ""),
|
||||
"time": time.strftime("%Y-%m-%dT%H:%M:%S",
|
||||
time.gmtime(ts / 1e9)),
|
||||
}, ensure_ascii=False),
|
||||
})
|
||||
except OSError as e:
|
||||
log(f"FEHLER beim Spool-Schreiben: {e}")
|
||||
|
||||
log("WARNUNG: docker-events-Stream endete — Neustart in 10 s")
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
def send_thread():
|
||||
"""Spool -> Loki über einen Cursor.
|
||||
|
||||
Der Cursor (Byte-Offset) wird NACH bestätigtem Push gespeichert. Damit
|
||||
gibt es kein Umschreiben der Datei und kein Rennen mit dem Collector:
|
||||
der Collector hängt nur an, der Sender liest nur vorwärts.
|
||||
at-least-once: nach einem Absturz wird der letzte Batch erneut gesendet.
|
||||
Für ein Audit-Log ist ein doppeltes Event harmlos, ein fehlendes nicht.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
if not os.path.exists(SPOOL):
|
||||
time.sleep(IDLE_SLEEP)
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(CURSOR) as fh:
|
||||
offset = int(fh.read().strip() or 0)
|
||||
except (OSError, ValueError):
|
||||
offset = 0
|
||||
|
||||
size = os.path.getsize(SPOOL)
|
||||
if offset > size: # Spool wurde rotiert
|
||||
offset = 0
|
||||
if offset == size:
|
||||
spool_rotate_if_big()
|
||||
time.sleep(IDLE_SLEEP)
|
||||
continue
|
||||
|
||||
with open(SPOOL, "rb") as fh:
|
||||
fh.seek(offset)
|
||||
chunk = fh.read(SPOOL_MAX_BYTES // 8)
|
||||
|
||||
# nur vollständige Zeilen verarbeiten
|
||||
cut = chunk.rfind(b"\n")
|
||||
if cut < 0:
|
||||
time.sleep(IDLE_SLEEP)
|
||||
continue
|
||||
complete = chunk[:cut + 1]
|
||||
|
||||
batch = []
|
||||
for ln in complete.decode(errors="replace").splitlines()[:BATCH_LINES]:
|
||||
try:
|
||||
rec = json.loads(ln)
|
||||
batch.append((rec["ns"], rec["msg"], rec["labels"]))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
# Offset bis zum Ende des verarbeiteten Teils
|
||||
used = 0
|
||||
for ln in complete.decode(errors="replace").splitlines()[:BATCH_LINES]:
|
||||
used += len(ln.encode()) + 1
|
||||
|
||||
if not batch:
|
||||
offset += used
|
||||
with open(CURSOR, "w") as fh:
|
||||
fh.write(str(offset))
|
||||
continue
|
||||
|
||||
res = push(batch)
|
||||
if res is True or res == "permanent":
|
||||
offset += used
|
||||
tmp = CURSOR + ".tmp"
|
||||
with open(tmp, "w") as fh:
|
||||
fh.write(str(offset))
|
||||
os.replace(tmp, CURSOR)
|
||||
try:
|
||||
with open(HEALTH_FILE, "w") as fh:
|
||||
fh.write(str(int(time.time())))
|
||||
except OSError:
|
||||
pass
|
||||
if res == "permanent":
|
||||
log(f"{len(batch)} Ereignisse dauerhaft abgelehnt, verworfen")
|
||||
else:
|
||||
time.sleep(IDLE_SLEEP * 2) # Loki down: kurz warten
|
||||
except Exception as e:
|
||||
log(f"FEHLER im Sender ({type(e).__name__}): {e}")
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def main():
|
||||
if not LOKI_URL or not TOKEN:
|
||||
log("FEHLER: LOKI_URL oder TELEGRAF_TOKEN fehlt")
|
||||
return 2
|
||||
os.makedirs(STATE_DIR, exist_ok=True)
|
||||
log(f"starte docker-events -> {LOKI_URL} (node={NODE})")
|
||||
|
||||
threading.Thread(target=collect_thread, daemon=True).start()
|
||||
threading.Thread(target=send_thread, daemon=True).start()
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# docker-events-watcher.sh — Wrapper fuer docker-events-loki.py (systemd).
|
||||
#
|
||||
# Liest LOKI_URL und TELEGRAF_TOKEN aus der .env des Telegraf-Stacks, damit
|
||||
# keine zweite Credential-Datei entsteht. Pfad per ENV uebersteuerbar.
|
||||
set -u
|
||||
ENVF="${DOCKER_EVENTS_ENV:-/opt/observability-telegraf/.env}"
|
||||
if [ -r "$ENVF" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ENVF"
|
||||
set +a
|
||||
fi
|
||||
exec /usr/bin/python3 /usr/local/bin/docker-events-loki.py
|
||||
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."
|
||||
Executable
+155
@@ -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 <basis> # 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 <relativer pfad im repo> <ziel>
|
||||
_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
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/bin/bash
|
||||
# keyhelp-log-backfill.sh — Alte Apache-Logs eines KeyHelp-Hosts messen, parsen,
|
||||
# als Archiv bereitstellen. (x22 und x14 sind damit beide bedient.)
|
||||
# DIESES SKRIPT AENDERT NICHTS AM LAUFENDEN DIENST.
|
||||
#
|
||||
# SICHERHEIT (die drei Wege, die schon schiefgingen):
|
||||
# 1. Es importiert NICHTS in die Produktions-TSDB. Der Import laeuft
|
||||
# ausschliesslich ueber prom-archive-import.sh --file auf dem imac,
|
||||
# der nur in die getrennte Archiv-Instanz (:9091) schreibt.
|
||||
# 2. Es stoppt nichts und loescht nichts. Ausgabe geht nach /root/apache-archive.
|
||||
# 3. Die Ausgabe wird VOR der Uebergabe geprueft (Einheiten, Zukunft, Inhalt).
|
||||
#
|
||||
# AUFRUF auf dem Host (als root):
|
||||
# curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/scripts/keyhelp-log-backfill.sh -o /root/keyhelp-backfill.sh
|
||||
# bash /root/keyhelp-backfill.sh # MESSEN (nichts wird veraendert)
|
||||
# bash /root/keyhelp-backfill.sh --backfill # parsen und Tarball bauen
|
||||
#
|
||||
# Danach steht am Ende der genaue Befehl fuer den imac.
|
||||
|
||||
set -eu
|
||||
|
||||
REPO_RAW="${REPO_RAW:-https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main}"
|
||||
OUT_DIR="${OUT_DIR:-/root/apache-archive}"
|
||||
NODE="${NODE:-$(hostname -f 2>/dev/null || hostname)}"
|
||||
BACKFILL=0
|
||||
[ "${1:-}" = "--backfill" ] && BACKFILL=1
|
||||
|
||||
log() { printf '\033[1;34m[backfill]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[backfill] WARNUNG:\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31m[backfill] FEHLER:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "Bitte als root ausfuehren."
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1) Bestand MESSEN — nicht raten
|
||||
# --------------------------------------------------------------------------
|
||||
log "Bestandsaufnahme der Apache-Logs (aendert nichts) …"
|
||||
mkdir -p /tmp/log-archiv
|
||||
curl -fsSL -m 30 "$REPO_RAW/scripts/apache-log-survey.py" -o /tmp/log-archiv/survey.py \
|
||||
|| die "survey.py konnte nicht geladen werden."
|
||||
# ACHTUNG: der Survey kennt KEIN --node (er beschreibt nur den lokalen Host).
|
||||
# Der Fortschritt wird nach stdout UND in die Datei geschrieben; das Skript
|
||||
# erzwingt Zeilenpufferung, sonst bleibt bestand.txt bei einer Pipe bis zum
|
||||
# Prozessende leer und der laufende Scan sieht wie ein Haenger aus.
|
||||
python3 /tmp/log-archiv/survey.py 2>&1 | tee /tmp/log-archiv/bestand.txt
|
||||
|
||||
# Wie viel Historie liegt wirklich vor? Das entscheidet ueber den Nutzen.
|
||||
FILES=$(find /var/log/apache2 /home/users -name 'access.log*' -o -name 'error.log*' 2>/dev/null | wc -l)
|
||||
log "Gefundene Logdateien (inkl. rotierte/.gz): $FILES"
|
||||
|
||||
if [ "$BACKFILL" -eq 0 ]; then
|
||||
echo
|
||||
log "Das war NUR die Messung — es wurde nichts veraendert."
|
||||
echo " Weiter mit: bash $0 --backfill"
|
||||
echo " Bestand: /tmp/log-archiv/bestand.txt"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2) Archiv bauen
|
||||
# --------------------------------------------------------------------------
|
||||
log "Archiver laden …"
|
||||
curl -fsSL -m 30 "$REPO_RAW/scripts/apache-log-archive.py" -o /tmp/log-archiv/archive.py \
|
||||
|| die "apache-log-archive.py konnte nicht geladen werden."
|
||||
|
||||
log "Logs parsen (kann bei viel Historie dauern) …"
|
||||
python3 /tmp/log-archiv/archive.py --node "$NODE" --out "$OUT_DIR" --include-raw 2>&1 | tail -20
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3) Selbst pruefen, BEVOR etwas weitergereicht wird
|
||||
# --------------------------------------------------------------------------
|
||||
OM="$OUT_DIR/apache-archive.openmetrics"
|
||||
[ -s "$OM" ] || die "Keine Ausgabe erzeugt: $OM fehlt oder ist leer."
|
||||
|
||||
log "Selbstpruefung der Ausgabe"
|
||||
python3 - "$OM" <<'PY' || exit 1
|
||||
import sys, time, collections
|
||||
path = sys.argv[1]
|
||||
pts = [l.rstrip("\n") for l in open(path) if l and not l.startswith("#") and l.strip()]
|
||||
if not pts:
|
||||
print(" FEHLER: keine Datenpunkte"); sys.exit(1)
|
||||
ts = [int(l.rsplit(" ", 1)[1]) for l in pts]
|
||||
uniq = sorted(set(ts))
|
||||
now = int(time.time())
|
||||
f = lambda t: time.strftime("%d.%m.%Y %H:%M", time.gmtime(t))
|
||||
print(f" Datenpunkte: {len(pts):,}")
|
||||
print(f" Zeitraum: {f(uniq[0])} .. {f(uniq[-1])} UTC")
|
||||
|
||||
# WICHTIG: min..max allein ist irrefuehrend. Ein EINZELNER Punkt mit altem
|
||||
# Zeitstempel (eine Logzeile von 2024 in einer sonst aktuellen Datei) laesst den
|
||||
# Backfill wie zwei Jahre aussehen, obwohl nur 30 Tage Substanz da sind.
|
||||
# Deshalb die tatsaechlich belegten Stunden nennen und die Abdeckung pruefen.
|
||||
hours = sorted({t - (t % 3600) for t in uniq})
|
||||
span_h = max(1, (hours[-1] - hours[0]) // 3600 + 1)
|
||||
cover = 100.0 * len(hours) / span_h
|
||||
print(f" belegte Stunden: {len(hours):,} von {span_h:,} im Zeitraum ({cover:.1f} % Abdeckung)")
|
||||
if len(hours) < span_h * 0.5:
|
||||
# Die groessten Luecken benennen — das sind meist fehlende/rotierte Logs.
|
||||
gaps = [(hours[i+1] - hours[i], hours[i], hours[i+1]) for i in range(len(hours)-1)]
|
||||
gaps.sort(reverse=True)
|
||||
print(" WARNUNG: die Historie ist lueckenhaft. Groesste Luecken:")
|
||||
for g, a, b in gaps[:3]:
|
||||
if g > 3600:
|
||||
print(f" {g/86400:.0f} Tage {f(a)} -> {f(b)}")
|
||||
print(" Ursache meist: rotierte/alte Logs fehlen auf dem Quellhost.")
|
||||
if len(hours) <= 3:
|
||||
print(" ACHTUNG: nur wenige Stunden — vermutlich einzelne alte Logzeilen,")
|
||||
print(" kein zusammenhaengender Zeitraum. Vor dem Import pruefen.")
|
||||
|
||||
domains = collections.Counter()
|
||||
for l in pts:
|
||||
i = l.find('domain="')
|
||||
if i >= 0:
|
||||
j = l.find('"', i + 8)
|
||||
domains[l[i+8:j]] += 1
|
||||
print(f" Domains: {len(domains)}")
|
||||
for d, n in domains.most_common(8):
|
||||
print(f" {d}: {n} Punkte")
|
||||
|
||||
# Einheiten-Wache: alles nach 2100 ist kein Sekundenwert
|
||||
if uniq[-1] > 4102444800:
|
||||
print(" FEHLER: Zeitstempel nach dem Jahr 2100 -> Millisekunden statt Sekunden")
|
||||
print(" promtool wuerde Bloecke im Jahr 58.660 erzeugen. Abbruch.")
|
||||
sys.exit(1)
|
||||
print(" Einheit: OK (Sekunden)")
|
||||
|
||||
# Zukunft: der Fehler, der die Produktion kosten kann
|
||||
z = [t for t in uniq if t > now + 300]
|
||||
if z:
|
||||
print(f" FEHLER: {len(z)} Punkte in der ZUKUNFT (max {f(z[-1])}).")
|
||||
print(" Ursache: falsche Systemuhr oder Log-Zeitzone. Abbruch.")
|
||||
sys.exit(1)
|
||||
print(" Zukunft: OK")
|
||||
|
||||
if len(domains) < 2:
|
||||
print(f" WARNUNG: nur {len(domains)} Domain(s) erkannt — Logs vollstaendig?")
|
||||
PY
|
||||
|
||||
TAR="$OUT_DIR/apache-archive.tar.gz"
|
||||
[ -s "$TAR" ] && log "Tarball: $TAR ($(du -h "$TAR" | cut -f1))"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4) Uebergabe — der sichere Weg
|
||||
# --------------------------------------------------------------------------
|
||||
cat <<EOF
|
||||
|
||||
============================================================
|
||||
FERTIG. Naechster Schritt auf dem imac.
|
||||
============================================================
|
||||
|
||||
WICHTIG: NICHT von Hand entpacken. Die Bloecke gehoeren in die getrennte
|
||||
Archiv-Instanz (:9091), nie in die Produktions-TSDB. Genau dieser Weg hat am
|
||||
14.09.2026 die Zeitreferenz verschoben — 13 Nodes verloren ihre Metriken.
|
||||
|
||||
# 1) Uebertragen (vom Host aus, im NetBird)
|
||||
scp $OM mm@100.83.134.46:/tmp/apache-archive.openmetrics
|
||||
|
||||
# 2) Auf dem imac einspielen (prueft Zukunft, Manifest, Gegenprobe)
|
||||
ssh mm@192.168.188.177 'sudo /usr/local/bin/prom-archive-import.sh --file /tmp/apache-archive.openmetrics'
|
||||
|
||||
# 3) Zweiter Lauf derselben Datei wird automatisch uebersprungen.
|
||||
# Anzeigen, was schon importiert ist:
|
||||
ssh mm@192.168.188.177 'sudo /usr/local/bin/prom-archive-import.sh --list'
|
||||
|
||||
# 4) Pruefen (step >= 1h wegen Stunden-Buckets!)
|
||||
ssh mm@192.168.188.177 'curl -s -G http://127.0.0.1:9091/api/v1/query_range \\
|
||||
--data-urlencode "query=apache_archive_requests_total" \\
|
||||
--data-urlencode "start=\$(date -d "-30 days" +%s)" \\
|
||||
--data-urlencode "end=\$(date +%s)" --data-urlencode "step=3600" | head -c 400'
|
||||
|
||||
Die Produktions-TSDB bleibt bei diesem Weg unberuehrt. Der Tarball mit den
|
||||
Rohlogs liegt unter $TAR — aufheben, er ist die Quelle fuer Einzelfaelle.
|
||||
EOF
|
||||
Executable
+131
@@ -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)
|
||||
Executable
+214
@@ -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=<id> Token-Summen je Modell
|
||||
* GET http://<upstream>/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)
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
# raw-log-backfill.sh — Rohlogs eines KeyHelp-Hosts fuer den LOKI-Import sammeln.
|
||||
#
|
||||
# Warum es dieses Skript gibt:
|
||||
# Der Prometheus-Archiv-Import verdichtet jede Logzeile auf Stunde/Domain/
|
||||
# Status und VERWIRFT dabei Referrer, User-Agent und Client-IP. Fragen nach
|
||||
# Herkunft ("woher kommen die Besucher") liessen sich damit nicht
|
||||
# rueckwirkend beantworten. Lokis Rohzeilen koennen es — aber die
|
||||
# Live-Sammlung reicht nur bis zur Installation zurueck.
|
||||
# Dieses Skript holt die Historie in eine Datei, die Loki aufnehmen kann.
|
||||
#
|
||||
# DIESES SKRIPT AENDERT NICHTS AM LAUFENDEN DIENST. Es liest nur Logdateien,
|
||||
# es stoppt nichts, es loescht nichts.
|
||||
#
|
||||
# AUFRUF (auf dem Host, als root):
|
||||
# curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/scripts/raw-log-backfill.sh -o /root/raw-backfill.sh
|
||||
# bash /root/raw-backfill.sh # MESSEN (aendert nichts)
|
||||
# bash /root/raw-backfill.sh --collect # Rohdatei bauen
|
||||
# bash /root/raw-backfill.sh --collect --since 2025-01-01
|
||||
# bash /root/raw-backfill.sh --collect --tarball /root/apache-archive/apache-archive.tar.gz
|
||||
#
|
||||
# Am Ende steht der exakte Befehl fuer den imac.
|
||||
|
||||
set -eu
|
||||
|
||||
REPO_RAW="${REPO_RAW:-https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main}"
|
||||
OUT_DIR="${OUT_DIR:-/root/apache-archive}"
|
||||
NODE="${NODE:-$(hostname -f 2>/dev/null || hostname)}"
|
||||
COLLECT=0
|
||||
SINCE=""
|
||||
UNTIL=""
|
||||
TARBALL=""
|
||||
ROOTS="/var/log/apache2 /home/users"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--collect) COLLECT=1 ;;
|
||||
--since) SINCE="${2:?Datum fehlt}"; shift ;;
|
||||
--until) UNTIL="${2:?Datum fehlt}"; shift ;;
|
||||
--tarball) TARBALL="${2:?Pfad fehlt}"; shift ;;
|
||||
--node) NODE="${2:?Name fehlt}"; shift ;;
|
||||
--roots) ROOTS="${2:?Pfade fehlen}"; shift ;;
|
||||
*) echo "Unbekannte Option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
log() { printf '\033[1;34m[raw-backfill]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[raw-backfill] WARNUNG:\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31m[raw-backfill] FEHLER:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "Bitte als root ausfuehren (die Logs liegen unter /home/users mit 0700)."
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
COLLECTOR="$OUT_DIR/raw-log-collect.py"
|
||||
if [ -r "$COLLECTOR" ] && [ -n "${SKIP_FETCH:-}" ]; then
|
||||
log "Collector lokal uebernommen: $COLLECTOR"
|
||||
else
|
||||
log "Lade Collector vom Repo …"
|
||||
curl -fsSL -m 30 "$REPO_RAW/scripts/raw-log-collect.py" -o "$COLLECTOR" \
|
||||
|| die "raw-log-collect.py konnte nicht geladen werden."
|
||||
fi
|
||||
python3 -c "import sys; sys.exit(0)" || die "python3 fehlt auf diesem Host."
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1) MESSEN — was ist ueberhaupt da? (aendert nichts)
|
||||
# --------------------------------------------------------------------------
|
||||
log "Bestandsaufnahme der Apache-Logs (liest nur) …"
|
||||
FILES=$(find $ROOTS -name 'access.log*' -o -name 'error.log*' 2>/dev/null | wc -l)
|
||||
GZ=$(find $ROOTS -name '*.log*.gz' 2>/dev/null | wc -l)
|
||||
RAW_BYTES=$(find $ROOTS -name 'access.log*' -o -name 'error.log*' 2>/dev/null -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s+0}')
|
||||
log " Logdateien: $FILES (davon komprimiert: $GZ)"
|
||||
log " Rohgroesse: $(( RAW_BYTES / 1024 / 1024 )) MB"
|
||||
log " Aelteste Datei: $(find $ROOTS -name 'access.log*' -printf '%TY-%Tm-%Td %p\n' 2>/dev/null | sort | head -1 | cut -c1-10)"
|
||||
if [ -n "$TARBALL" ] && [ -r "$TARBALL" ]; then
|
||||
log " Tarball: $TARBALL ($(( $(stat -c%s "$TARBALL") / 1024 / 1024 )) MB)"
|
||||
log " Rohlogs im Tar: $(tar -tzf "$TARBALL" 2>/dev/null | grep -c 'raw/.*log' || echo 0) Dateien"
|
||||
fi
|
||||
|
||||
if [ "$COLLECT" -eq 0 ]; then
|
||||
echo
|
||||
log "Das war NUR die Messung — es wurde nichts geschrieben."
|
||||
echo " Weiter mit: bash $0 --collect${SINCE:+ --since $SINCE}"
|
||||
echo " Optional zusaetzlich Rohlogs aus dem Archiv-Tarball:"
|
||||
echo " --tarball $OUT_DIR/apache-archive.tar.gz"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2) SAMMELN
|
||||
# --------------------------------------------------------------------------
|
||||
ARGS="--node $NODE --out $OUT_DIR --roots $ROOTS"
|
||||
[ -n "$SINCE" ] && ARGS="$ARGS --since $SINCE"
|
||||
[ -n "$UNTIL" ] && ARGS="$ARGS --until $UNTIL"
|
||||
[ -n "$TARBALL" ] && [ -r "$TARBALL" ] && ARGS="$ARGS --include-tarball $TARBALL"
|
||||
|
||||
log "Sammle Rohlogs … (bei viel Historie dauert das einige Minuten)"
|
||||
# shellcheck disable=SC2086
|
||||
python3 "$COLLECTOR" $ARGS 2>&1 | tee "$OUT_DIR/collect.log"
|
||||
RC=${PIPESTATUS[0]}
|
||||
[ "$RC" -eq 0 ] || die "Collector brach ab (Code $RC). Siehe $OUT_DIR/collect.log"
|
||||
|
||||
PART=$(ls -t "$OUT_DIR"/loki-raw-*/raw.tsv 2>/dev/null | head -1)
|
||||
STATE=$(ls -t "$OUT_DIR"/loki-raw-*/state.json 2>/dev/null | head -1)
|
||||
[ -n "$PART" ] && [ -r "$PART" ] || die "Keine Rohdatei gefunden."
|
||||
[ -n "$STATE" ] && [ -r "$STATE" ] || die "Keine Zustandsdatei gefunden."
|
||||
|
||||
LINES=$(wc -l < "$PART")
|
||||
SIZE=$(( $(stat -c%s "$PART") / 1024 / 1024 ))
|
||||
log "Rohdatei: $PART"
|
||||
log " $LINES Zeilen, $SIZE MB"
|
||||
|
||||
# Gegenprobe: stimmt die Zeilenzahl mit der Zustandsdatei ueberein?
|
||||
EXPECT=$(python3 -c "import json,sys; print(json.load(open('$STATE'))['lines'])")
|
||||
if [ "$LINES" != "$EXPECT" ]; then
|
||||
warn "Zeilenzahl weicht ab: Datei=$LINES, Auftrag=$EXPECT — bitte pruefen."
|
||||
else
|
||||
log " Gegenprobe: Zeilenzahl stimmt mit dem Auftrag ueberein ($LINES)."
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3) Uebergabe vorbereiten
|
||||
#
|
||||
# Der Collector laeuft als root (die Logs liegen unter /home/users mit 0700),
|
||||
# seine Ausgabe gehoert damit ebenfalls root. `scp` als normaler Nutzer kaeme
|
||||
# nicht an die Datei — und ein scp als root scheitert oft am SSH-Schluessel.
|
||||
# Deshalb die Dateien fuer die Uebergabe lesbar machen und an eine Stelle
|
||||
# legen, die beide erreichen.
|
||||
# --------------------------------------------------------------------------
|
||||
STAGE=/tmp/rawlog-upload
|
||||
mkdir -p "$STAGE"
|
||||
cp "$PART" "$STAGE/raw.tsv"
|
||||
cp "$STATE" "$STAGE/state.json"
|
||||
chmod 644 "$STAGE/raw.tsv" "$STAGE/state.json"
|
||||
log "Uebergabedateien bereit: $STAGE/ (644, fuer scp als normaler Nutzer)"
|
||||
|
||||
echo
|
||||
log "NAECHSTER SCHRITT — diese Zeilen auf dem imac ausfuehren:"
|
||||
echo
|
||||
echo " # 1) Rohdatei und Auftrag uebertragen (als normaler Nutzer moeglich)"
|
||||
echo " # ACHTUNG: nach /data/tmp, NICHT nach /tmp!"
|
||||
echo " # Auf dem imac ist /tmp ein tmpfs mit 15 GB (also RAM). Eine grosse"
|
||||
echo " # Rohdatei wuerde dort den Arbeitsspeicher belegen und Loki stoeren."
|
||||
echo " ssh mm@100.83.134.46 'mkdir -p /data/tmp'"
|
||||
echo " scp $STAGE/raw.tsv $STAGE/state.json mm@100.83.134.46:/data/tmp/"
|
||||
echo
|
||||
echo " # 2) Einlesen (der Import laeuft LOKAL auf dem imac — kein Token noetig)"
|
||||
echo " ssh mm@100.83.134.46 \\"
|
||||
echo " 'python3 /usr/local/bin/loki-raw-import.py \\"
|
||||
echo " --file /tmp/$(basename "$PART") --state /tmp/$(basename "$STATE") \\"
|
||||
echo " --url http://127.0.0.1:3100'"
|
||||
echo
|
||||
echo " # Vorher pruefen (empfohlen, aendert nichts):"
|
||||
echo " ssh mm@100.83.134.46 \\"
|
||||
echo " 'python3 /usr/local/bin/loki-raw-import.py \\"
|
||||
echo " --file /tmp/$(basename "$PART") --state /tmp/$(basename "$STATE") \\"
|
||||
echo " --url http://127.0.0.1:3100 --dry-run'"
|
||||
echo
|
||||
log "Danach im Grafana pruefen: {imported=\"1\"} (Dashboard: Weblogs-Analyse)"
|
||||
warn "Das Skript selbst schreibt NICHTS nach Loki — der Import ist ein eigener Schritt."
|
||||
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rohlogs (Apache access+error) eines Hosts in eine importierbare Datei schreiben.
|
||||
|
||||
Erzeugt eine DREISPALTIGE, nach Zeitstempel sortierbare Datei:
|
||||
|
||||
<ts_ns> <TAB> <labels-json> <TAB> <rohzeile>
|
||||
|
||||
Warum dieses Format:
|
||||
* `ts_ns` in Spalte 1 -> GNU `sort -k1,1n` kann mit `-S` und `-T` EXTERN
|
||||
sortieren. 40 Mio Zeilen (~12 GB) passen nicht in den RAM des imac; ein
|
||||
Python-`sorted()` wuerde dort sterben. Externer Merge-Sort skaliert.
|
||||
* Nach Zeitstempel aufsteigend sortiert ist PFLICHT, nicht Kosmetik:
|
||||
Loki erlaubt kein Out-of-Order. Ein aelterer Eintrag nach einem neueren
|
||||
wird mit "entry too far behind" abgelehnt (empirisch bestaetigt).
|
||||
* Die Rohzeile bleibt UNVERAENDERT (nur die erste Tab-Trennung wird
|
||||
geprueft). Das `message="..."`-Format, das Telegraf live schreibt, setzt
|
||||
der Importer — die Kollektor-Ausgabe bleibt dadurch pruefbar roh.
|
||||
|
||||
Dieses Skript aendert NICHTS am laufenden Dienst und loescht nichts.
|
||||
|
||||
AUFRUF (auf dem Host, als root):
|
||||
python3 raw-log-collect.py --node x14.idx.wf-keyhelp --out /root/apache-archive
|
||||
python3 raw-log-collect.py --node x14 --out /root/apache-archive \
|
||||
--since 2025-01-01 --include-tarball /root/apache-archive/apache-archive.tar.gz
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Zeitstempel-Formate in Apache-Logs.
|
||||
#
|
||||
# access (Combined/Common, KeyHelp-Standard): [14/Sep/2026:17:00:18 +0200]
|
||||
# error (Apache-Standard OHNE Zeitzone): [Mon Sep 14 17:00:18.123456 2026]
|
||||
# error (alternative Variante MIT Zeitzone): [14/Sep/2026:17:00:18 +0200]
|
||||
#
|
||||
# OHNE das reine Error-Format landen Error-Zeilen still auf hour="unknown"
|
||||
# und werden uebersprungen — genau dieser Fehler ist schon einmal passiert.
|
||||
# --------------------------------------------------------------------------
|
||||
TS_ACCESS = re.compile(r"\[(\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2})\s*([+-]\d{4})?\]")
|
||||
TS_ERROR_NTZ = re.compile(
|
||||
r"\[(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s+\d{4})\]")
|
||||
TS_BRACKET = re.compile(r"\[([^\]]+)\]")
|
||||
|
||||
MONTHS = {m: i + 1 for i, m in enumerate(
|
||||
["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"])}
|
||||
|
||||
|
||||
def parse_ts(line: str) -> int | None:
|
||||
"""Liefert Unix-Nanosekunden oder None, wenn kein Zeitstempel erkennbar."""
|
||||
m = TS_ACCESS.search(line)
|
||||
if m:
|
||||
base, off = m.group(1), m.group(2)
|
||||
try:
|
||||
dt = datetime.strptime(base, "%d/%b/%Y:%H:%M:%S")
|
||||
if off:
|
||||
sign = 1 if off[0] == "+" else -1
|
||||
mins = int(off[1:3]) * 60 + int(off[3:5])
|
||||
dt = dt.replace(tzinfo=timezone.utc) if False else dt
|
||||
# Offset anwenden: lokale Zeit -> UTC
|
||||
from datetime import timedelta
|
||||
dt = dt - timedelta(minutes=sign * mins)
|
||||
return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1e9)
|
||||
except ValueError:
|
||||
pass
|
||||
m = TS_ERROR_NTZ.search(line)
|
||||
if m:
|
||||
raw = re.sub(r"\s+", " ", m.group(1)).strip()
|
||||
for fmt in ("%a %b %d %H:%M:%S.%f %Y", "%a %b %d %H:%M:%S %Y"):
|
||||
try:
|
||||
dt = datetime.strptime(raw, fmt)
|
||||
# Apache schreibt Error-Logs in LOKALER Zeit (hier CEST, +02:00).
|
||||
# Ohne Offset-Angabe wird die Zeile sonst um 2 h verschoben
|
||||
# einsortiert. Der Aufrufer kann den Offset setzen.
|
||||
return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1e9)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def classify(path: str) -> dict:
|
||||
"""Labels aus dem Dateipfad ableiten — identisch zum Live-Schema von Telegraf."""
|
||||
kind = "error" if "error" in os.path.basename(path) else "access"
|
||||
# Domain aus dem Pfad: /home/users/<user>/logs/<domain>/access.log
|
||||
m = re.search(r"/logs/([^/]+)/", path)
|
||||
domain = m.group(1) if m else ""
|
||||
return {"kind": kind, "domain": domain}
|
||||
|
||||
|
||||
def iter_lines(path: str):
|
||||
"""Zeilen einer Logdatei liefern — auch aus .gz."""
|
||||
if path.endswith(".gz"):
|
||||
with gzip.open(path, "rt", errors="replace") as fh:
|
||||
for ln in fh:
|
||||
yield ln.rstrip("\n")
|
||||
else:
|
||||
with open(path, "rt", errors="replace") as fh:
|
||||
for ln in fh:
|
||||
yield ln.rstrip("\n")
|
||||
|
||||
|
||||
def collect_from_dir(root: str):
|
||||
"""Alle access.log*/error.log* unterhalb von root finden."""
|
||||
for dirpath, _dirnames, filenames in os.walk(root):
|
||||
for fn in filenames:
|
||||
if fn.startswith(("access.log", "error.log")):
|
||||
yield os.path.join(dirpath, fn)
|
||||
|
||||
|
||||
def collect_from_tarball(tar_path: str, stats: Counter | None = None):
|
||||
"""Rohlogs aus einem Archiv-Tarball (Pfade unter raw/).
|
||||
|
||||
Die Tarballs des Backfills enthalten raw/<absoluter-pfad-ohne-fuehrenden-slash>.
|
||||
|
||||
FALLE (real, 17 % Datenverlust): die `.gz`-Dateien IM Tarball sind selbst
|
||||
noch gzip-komprimiert. `tarfile` entpackt nur die aeussere Ebene — wer den
|
||||
Member-Inhalt direkt dekodiert, liest das gzip-Magic (\x1f\x8b) als Text und
|
||||
verliert jede Zeile dieser Dateien. Gemessen am x14-Tarball: 68.975 von
|
||||
404.825 Zeilen (17 %) fielen so aus, verteilt auf genau die `.gz`-Member
|
||||
(access.log.2/.3/.4.gz, error.log.2/.3/.4.gz). Deshalb: Member mit `.gz`
|
||||
ein zweites Mal entpacken.
|
||||
"""
|
||||
gz_ok = gz_fail = 0
|
||||
with tarfile.open(tar_path, "r:gz") as tf:
|
||||
for member in tf.getmembers():
|
||||
if not member.isfile():
|
||||
continue
|
||||
name = member.name
|
||||
if "/raw/" not in name and not name.startswith("raw/"):
|
||||
continue
|
||||
base = os.path.basename(name)
|
||||
if not base.startswith(("access.log", "error.log")):
|
||||
continue
|
||||
fh = tf.extractfile(member)
|
||||
if fh is None:
|
||||
continue
|
||||
logpath = "/" + name.split("raw/", 1)[1] if "raw/" in name else name
|
||||
data = fh.read()
|
||||
text = None
|
||||
if name.endswith(".gz"):
|
||||
try:
|
||||
text = gzip.decompress(data).decode("utf-8", "replace")
|
||||
gz_ok += 1
|
||||
except Exception: # noqa: BLE001
|
||||
gz_fail += 1
|
||||
text = data.decode("utf-8", "replace")
|
||||
else:
|
||||
text = data.decode("utf-8", "replace")
|
||||
for raw in text.splitlines():
|
||||
yield logpath, raw
|
||||
if stats is not None:
|
||||
stats["gz_member_ok"] += gz_ok
|
||||
stats["gz_member_fail"] += gz_fail
|
||||
|
||||
|
||||
def build_labels(node: str, logpath: str, imported: str) -> dict:
|
||||
"""Label-Set wie das Live-Schema von Telegraf, plus imported=1.
|
||||
|
||||
Unterschiede zum Live-Schema (bewusst):
|
||||
* `imported="1"` — trennt die Historie vom Live-Store. Nur fuer diese
|
||||
Streams gilt die lange Retention (retention_stream in loki.yml).
|
||||
Ohne das Label wuerde der Compactor die Historie nach 90 Tagen
|
||||
loeschen.
|
||||
* `kind`, `domain` — aus dem Pfad abgeleitet. Erlaubt Queries ohne
|
||||
teure Regex-Arbeit auf `logfile` und macht Dashboards schneller.
|
||||
"""
|
||||
meta = classify(logpath)
|
||||
return {
|
||||
"source": "apache",
|
||||
"host": node,
|
||||
"node": node,
|
||||
"logfile": logpath,
|
||||
"service_name": "unknown_service",
|
||||
"detected_level": "unknown",
|
||||
"imported": imported,
|
||||
"kind": meta["kind"],
|
||||
"domain": meta["domain"] or "unknown",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Apache-Rohlogs fuer den Loki-Import aufbereiten")
|
||||
ap.add_argument("--node", required=True, help="Node-Name (host-Label)")
|
||||
ap.add_argument("--out", default="/root/apache-archive",
|
||||
help="Ausgabeverzeichnis")
|
||||
ap.add_argument("--roots", nargs="*",
|
||||
default=["/var/log/apache2", "/home/users"],
|
||||
help="Wurzelverzeichnisse der Logs")
|
||||
ap.add_argument("--include-tarball", action="append", default=[],
|
||||
help="Zusaetzlich Rohlogs aus diesem .tar.gz lesen")
|
||||
ap.add_argument("--since", default=None,
|
||||
help="Nur Zeilen ab diesem Datum (JJJJ-MM-TT)")
|
||||
ap.add_argument("--until", default=None, help="Nur Zeilen bis (JJJJ-MM-TT)")
|
||||
ap.add_argument("--tz-offset", type=int, default=2,
|
||||
help="UTC-Offset der Error-Logs in Stunden (Default 2 = CEST)")
|
||||
ap.add_argument("--limit-lines", type=int, default=0,
|
||||
help="Nach N Zeilen abbrechen (fuer Tests)")
|
||||
args = ap.parse_args()
|
||||
|
||||
since_ns = (int(datetime.strptime(args.since, "%Y-%m-%d")
|
||||
.replace(tzinfo=timezone.utc).timestamp() * 1e9)
|
||||
if args.since else None)
|
||||
until_ns = (int(datetime.strptime(args.until, "%Y-%m-%d")
|
||||
.replace(tzinfo=timezone.utc).timestamp() * 1e9)
|
||||
if args.until else None)
|
||||
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
tmpdir = tempfile.mkdtemp(prefix="loki-raw-", dir=args.out)
|
||||
part = os.path.join(tmpdir, "raw.tsv")
|
||||
state = os.path.join(tmpdir, "state.json")
|
||||
|
||||
files: list[str] = []
|
||||
for r in args.roots:
|
||||
files.extend(collect_from_dir(r))
|
||||
files = sorted(files)
|
||||
print(f"[collect] {len(files)} Logdateien unter {args.roots} gefunden")
|
||||
|
||||
stats = Counter()
|
||||
n_ok = n_nots = n_skip = 0
|
||||
examples: list[str] = []
|
||||
|
||||
with open(part, "w", encoding="utf-8") as out:
|
||||
sources = ((p, iter_lines(p)) for p in files)
|
||||
for path, lines in sources:
|
||||
lab = build_labels(args.node, path, "1")
|
||||
lab_s = json.dumps(lab, ensure_ascii=False, separators=(",", ":"))
|
||||
for raw in lines:
|
||||
if not raw.strip():
|
||||
continue
|
||||
ts = parse_ts(raw)
|
||||
if ts is None:
|
||||
n_nots += 1
|
||||
if len(examples) < 5:
|
||||
examples.append(raw[:120])
|
||||
continue
|
||||
if since_ns and ts < since_ns:
|
||||
n_skip += 1
|
||||
continue
|
||||
if until_ns and ts >= until_ns:
|
||||
n_skip += 1
|
||||
continue
|
||||
# Error-Logs ohne Zeitzone sind LOKALzeit -> nach UTC schieben
|
||||
if lab["kind"] == "error" and TS_ERROR_NTZ.search(raw) \
|
||||
and not TS_ACCESS.search(raw):
|
||||
ts -= args.tz_offset * 3600 * 10**9
|
||||
raw = raw.replace("\t", " ")
|
||||
out.write(f"{ts}\t{lab_s}\t{raw}\n")
|
||||
n_ok += 1
|
||||
stats[lab["kind"]] += 1
|
||||
if args.limit_lines and n_ok >= args.limit_lines:
|
||||
break
|
||||
if args.limit_lines and n_ok >= args.limit_lines:
|
||||
break
|
||||
|
||||
# Tarballs zuletzt (sie liegen historisch VOR den Live-Dateien).
|
||||
for tb in args.include_tarball:
|
||||
print(f"[collect] Tarball: {tb}")
|
||||
for logpath, raw in collect_from_tarball(tb, stats):
|
||||
if args.limit_lines and n_ok >= args.limit_lines:
|
||||
break
|
||||
if not raw.strip():
|
||||
continue
|
||||
ts = parse_ts(raw)
|
||||
if ts is None:
|
||||
n_nots += 1
|
||||
if len(examples) < 5:
|
||||
examples.append(f"[{os.path.basename(logpath)}] "
|
||||
f"{raw[:110]}")
|
||||
continue
|
||||
if since_ns and ts < since_ns:
|
||||
n_skip += 1
|
||||
continue
|
||||
if until_ns and ts >= until_ns:
|
||||
n_skip += 1
|
||||
continue
|
||||
lab = build_labels(args.node, logpath, "1")
|
||||
if lab["kind"] == "error" and TS_ERROR_NTZ.search(raw) \
|
||||
and not TS_ACCESS.search(raw):
|
||||
ts -= args.tz_offset * 3600 * 10**9
|
||||
lab_s = json.dumps(lab, ensure_ascii=False, separators=(",", ":"))
|
||||
out.write(f"{ts}\t{lab_s}\t{raw.replace(chr(9), ' ')}\n")
|
||||
n_ok += 1
|
||||
stats[lab["kind"]] += 1
|
||||
|
||||
print(f"[collect] {n_ok} Zeilen geschrieben (access={stats['access']} "
|
||||
f"error={stats['error']})")
|
||||
if stats["gz_member_ok"] or stats["gz_member_fail"]:
|
||||
print(f"[collect] gzip-Member im Tarball entpackt: "
|
||||
f"{stats['gz_member_ok']}"
|
||||
+ (f", FEHLGESCHLAGEN: {stats['gz_member_fail']}"
|
||||
if stats["gz_member_fail"] else ""))
|
||||
if n_nots:
|
||||
print(f"[collect] WARNUNG: {n_nots} Zeilen OHNE erkennbaren Zeitstempel "
|
||||
f"wurden uebersprungen (kein stilles Verwerfen):")
|
||||
for e in examples:
|
||||
print(f" {e}")
|
||||
if n_skip:
|
||||
print(f"[collect] {n_skip} Zeilen ausserhalb des Zeitfensters "
|
||||
f"(since={args.since} until={args.until})")
|
||||
|
||||
if n_ok == 0:
|
||||
print("[collect] FEHLER: keine Zeilen gesammelt — Abbruch.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Groesse + Sortierung festhalten, damit der Import den Bestand beweisen kann.
|
||||
size = os.path.getsize(part)
|
||||
with open(state, "w") as fh:
|
||||
json.dump({"node": args.node, "lines": n_ok,
|
||||
"access": stats["access"], "error": stats["error"],
|
||||
"without_ts": n_nots, "skipped": n_skip,
|
||||
"since": args.since, "until": args.until,
|
||||
"bytes": size, "tz_offset": args.tz_offset}, fh, indent=2)
|
||||
print(f"[collect] Rohdatei: {part} ({size/1e6:.1f} MB, unsortiert)")
|
||||
print(f"[collect] Zustand: {state}")
|
||||
print()
|
||||
print("[collect] NAECHSTER SCHRITT — von Hand auf dem imac (nicht hier):")
|
||||
print(f" scp {part} mm@100.83.134.46:/tmp/")
|
||||
print(f" scp {state} mm@100.83.134.46:/tmp/")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# x22-log-archive.sh — Alte Apache-Logs von x22 einsammeln, parsen, ins Archiv schieben.
|
||||
#
|
||||
# WARUM ES DIESES SKRIPT GIBT
|
||||
# Alte Logs sollen dauerhaft auswertbar bleiben. Der Weg dorthin hat zwei Fallen,
|
||||
# die beide still zuschlagen:
|
||||
# 1. Ein Import in die PRODUKTIONS-TSDB kann die Zeitreferenz verschieben und
|
||||
# damit alle Live-Daten als "too old" verwerfen. Deshalb geht der Import
|
||||
# ausschliesslich in die getrennte Archiv-Instanz (:9091).
|
||||
# 2. OpenMetrics-Zeitstempel sind SEKUNDEN. Millisekunden ergeben Bloecke im
|
||||
# Jahr 58.662, die Prometheus verwirft — die Abfrage bleibt dann leer.
|
||||
# Das Archiver-Skript prueft Punkt 2 selbst (Einheiten-Waechter) und bricht ab.
|
||||
#
|
||||
# AUFRUF auf x22 (als root):
|
||||
# curl -fsSL https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main/scripts/x22-log-archive.sh -o /root/x22-archive.sh
|
||||
# bash /root/x22-archive.sh # Messen (nichts wird veraendert)
|
||||
# bash /root/x22-archive.sh --backfill # Logs parsen und Archiv-Tarball bauen
|
||||
#
|
||||
# Das Skript AENDERT NICHTS am laufenden Telegraf und importiert nichts selbst —
|
||||
# es liefert eine Datei, die auf dem imac ins Archiv geschoben wird.
|
||||
|
||||
set -eu
|
||||
|
||||
REPO_RAW="${REPO_RAW:-https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main}"
|
||||
OUT_DIR="${OUT_DIR:-/root/apache-archive}"
|
||||
NODE="${NODE:-x22.idx.wf-keyhelp}"
|
||||
BACKFILL=0
|
||||
[ "${1:-}" = "--backfill" ] && BACKFILL=1
|
||||
|
||||
log() { printf '\033[1;34m[x22-archiv]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[x22-archiv] WARNUNG:\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31m[x22-archiv] FEHLER:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "Bitte als root ausfuehren."
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1) Bestand messen — MESSEN, NICHT RATEN
|
||||
# --------------------------------------------------------------------------
|
||||
log "Bestandsaufnahme der Apache-Logs …"
|
||||
mkdir -p /tmp/x22-archiv
|
||||
curl -fsSL "$REPO_RAW/scripts/apache-log-survey.py" -o /tmp/x22-archiv/survey.py \
|
||||
|| die "survey.py konnte nicht geladen werden ($REPO_RAW/scripts/apache-log-survey.py)"
|
||||
python3 /tmp/x22-archiv/survey.py --node "$NODE" 2>&1 | tee /tmp/x22-archiv/bestand.txt
|
||||
|
||||
if [ "$BACKFILL" -eq 0 ]; then
|
||||
echo
|
||||
log "Das war nur die Messung — es wurde nichts veraendert."
|
||||
echo " Fuer die Archivierung: bash $0 --backfill"
|
||||
echo " Bestand: /tmp/x22-archiv/bestand.txt"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2) Archiv bauen
|
||||
# --------------------------------------------------------------------------
|
||||
log "Archiver laden …"
|
||||
curl -fsSL "$REPO_RAW/scripts/apache-log-archive.py" -o /tmp/x22-archiv/archive.py \
|
||||
|| die "apache-log-archive.py konnte nicht geladen werden."
|
||||
|
||||
log "Logs parsen und OpenMetrics erzeugen (kann bei viel Historie dauern) …"
|
||||
python3 /tmp/x22-archiv/archive.py --node "$NODE" --out "$OUT_DIR" --include-raw 2>&1 | tail -20
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3) Selbst pruefen, bevor irgendetwas weitergereicht wird
|
||||
# --------------------------------------------------------------------------
|
||||
OM="$OUT_DIR/apache-archive.openmetrics"
|
||||
[ -s "$OM" ] || die "Keine Ausgabe erzeugt: $OM fehlt oder ist leer."
|
||||
|
||||
log "Selbstpruefung der Ausgabe"
|
||||
python3 - "$OM" <<'PY'
|
||||
import sys, time
|
||||
path = sys.argv[1]
|
||||
lines = [l for l in open(path) if l and not l.startswith("#")]
|
||||
if not lines:
|
||||
print(" FEHLER: keine Datenpunkte"); sys.exit(1)
|
||||
ts = sorted(int(l.rsplit(" ", 1)[1]) for l in lines)
|
||||
now = int(time.time())
|
||||
print(f" Datenpunkte: {len(lines)}")
|
||||
print(f" Zeitraum: {time.strftime('%d.%m.%Y %H:%M', time.gmtime(ts[0]))}"
|
||||
f" .. {time.strftime('%d.%m.%Y %H:%M', time.gmtime(ts[-1]))} UTC")
|
||||
print(f" Einheit: Sekunden (Plausibilitaet geprueft)")
|
||||
# Einheiten-Wache: alles nach 2100 ist kein Sekundenwert
|
||||
if ts[-1] > 4102444800:
|
||||
print(" FEHLER: Zeitstempel nach dem Jahr 2100 -> Millisekunden statt Sekunden"); sys.exit(1)
|
||||
# Zukunft pruefen: das ist der Fehler, der die Produktion kosten kann
|
||||
zukunft = [t for t in ts if t > now + 300]
|
||||
if zukunft:
|
||||
print(f" WARNUNG: {len(zukunft)} Punkte liegen in der ZUKUNFT "
|
||||
f"(max {time.strftime('%d.%m.%Y %H:%M', time.gmtime(zukunft[-1]))}).")
|
||||
print(" Diese Punkte stammen aus einer falschen Systemuhr oder falschen Logs.")
|
||||
else:
|
||||
print(" Zukunfts-Check: OK (kein Punkt nach jetzt)")
|
||||
PY
|
||||
|
||||
TAR="$OUT_DIR/apache-archive.tar.gz"
|
||||
[ -s "$TAR" ] && log "Tarball: $TAR ($(du -h "$TAR" | cut -f1))"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4) Uebergabe
|
||||
# --------------------------------------------------------------------------
|
||||
cat <<EOF
|
||||
|
||||
============================================================
|
||||
FERTIG — naechster Schritt auf dem imac (nichts weiter noetig auf x22)
|
||||
============================================================
|
||||
|
||||
Von hier aus die Ausgabe auf den imac holen und ins ARCHIV einspielen
|
||||
(NICHT in die Produktions-TSDB):
|
||||
|
||||
# 1) Uebertragen (von x22 aus, im NetBird)
|
||||
scp $OM mm@100.83.134.46:/tmp/apache-archive.openmetrics
|
||||
|
||||
# 2) Auf dem imac: in die ARCHIV-Instanz einspielen
|
||||
# --file ist Pflicht. Ohne diese Option wuerde das Import-Skript den
|
||||
# Produktions-Pfad anfassen — dagegen ist es inzwischen gesperrt.
|
||||
ssh mm@192.168.188.177 'sudo /usr/local/bin/prom-archive-import.sh --file /tmp/apache-archive.openmetrics'
|
||||
|
||||
Wichtig: der Import prueft zuerst auf Zukunfts-Zeitstempel, legt ein Manifest
|
||||
an (Schutz gegen Doppel-Import) und schreibt ausschliesslich ins Archiv. Ein
|
||||
zweiter Lauf mit derselben Datei wird uebersprungen und sagt das auch.
|
||||
Bricht er ab, ist das der Schutz, nicht ein Defekt — Ursache in der Ausgabe
|
||||
pruefen.
|
||||
|
||||
Das Skript fuehrt den Import bewusst NICHT selbst aus: die Produktions-TSDB
|
||||
liegt auf einem anderen Host, und ein automatischer Import von einer Node aus
|
||||
waere genau der Weg, der am 14.09. die Historie gekostet hat.
|
||||
EOF
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
# x22-log-backfill.sh — Kompatibilitaets-Alias.
|
||||
# Die Logik liegt in keyhelp-log-backfill.sh (fuer alle KeyHelp-Hosts).
|
||||
# Dieser Name bleibt bestehen, damit bereits verteilte Aufrufe weiterlaufen.
|
||||
export NODE="${NODE:-x22.idx.wf-keyhelp}"
|
||||
REPO_RAW="${REPO_RAW:-https://gitea.m-m.bio/mm/netbird-telegraf-installer/raw/branch/main}"
|
||||
exec curl -fsSL -m 30 "$REPO_RAW/scripts/keyhelp-log-backfill.sh" -o /tmp/.kh-backfill.sh \
|
||||
&& NODE="$NODE" REPO_RAW="$REPO_RAW" bash /tmp/.kh-backfill.sh "$@"
|
||||
@@ -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).
|
||||
+162
-6
@@ -1,13 +1,169 @@
|
||||
# Apache: Access-Log → Loki (roh) + optional Request-Metriken → Prometheus
|
||||
# Apache: Access-Logs → Loki (roh) UND Request-Metriken → Prometheus
|
||||
#
|
||||
# Zwei Wege aus einem Log — sie ergaenzen sich:
|
||||
# inputs.tail : jede Zeile roh nach Loki (fuer die Fehlersuche im Einzelfall)
|
||||
# inputs.logparser: dieselben Zeilen als Metriken (Request-Rate, Status-Klassen,
|
||||
# Antwortzeit) — das ist, was Panels und Alerts brauchen
|
||||
#
|
||||
# Die Logs landen NICHT in Prometheus: der Remote-Write-Output hat
|
||||
# `namedrop = ["docker_log", "journal"]` — hier kommt `apache_log` dazu.
|
||||
#
|
||||
# FALLE: `from_beginning` existiert bei inputs.tail NICHT (weder im Docker-Image
|
||||
# noch im InfluxData-Paket 1.40). Ein solcher Block laesst den Dienst beim Start
|
||||
# hart scheitern: "plugin inputs.tail: configuration specified the fields
|
||||
# [from_beginning]" → systemctl start schlaegt fehl, ALLE Metriken aus.
|
||||
# Stattdessen: initial_read_offset = "saved-or-end" (Default), damit beim ersten
|
||||
# Lauf nicht die komplette Historie ausgeschuettet wird.
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1) Roh-Logs → Loki
|
||||
# --------------------------------------------------------------------------
|
||||
# ${APACHE_FILES} rendert zu einer echten TOML-Liste — der Installer baut sie
|
||||
# aus allen gefundenen Logpfaden (Panel-Hoster haben pro VHost eigene Dateien).
|
||||
# Ein einzelner String mit Leerzeichen ("/a.log /b.log") waere ein ungueltiger
|
||||
# Pfad und der Input liefe still leer.
|
||||
[[inputs.tail]]
|
||||
files = ["${APACHE_LOG}"]
|
||||
from_beginning = false
|
||||
name_override = "apache_log"
|
||||
files = ${APACHE_FILES}
|
||||
name_override = "journal"
|
||||
# Beim Erstanlauf nur Neues lesen; danach Position merken.
|
||||
initial_read_offset = "saved-or-end"
|
||||
data_format = "value"
|
||||
data_type = "string"
|
||||
# Vereinheitlicht den Feldnamen auf `message` — das Logs-Panel in Grafana
|
||||
# erwartet es; `value` wuerde als value="…" erscheinen.
|
||||
path_tag = "logfile"
|
||||
[inputs.tail.tags]
|
||||
source = "apache"
|
||||
|
||||
# Optional: mod_status (a2enmod status; ExtendedStatus On; /server-status?auto lokal freigeben)
|
||||
# Vereinheitlicht den Feldnamen auf `message` — fuer JEDEN Log-Input.
|
||||
# `apache_error` muss mit drin sein: sonst heisst das Feld dort `value` und
|
||||
# jede Panel-/Alert-Query mit `| logfmt | line_format "{{.message}}"` findet
|
||||
# die Error-Logs nicht. Das faellt nicht auf, solange niemand danach sucht.
|
||||
[[processors.rename]]
|
||||
namepass = ["journal", "apache_error"]
|
||||
order = 1
|
||||
[[processors.rename.replace]]
|
||||
field = "value"
|
||||
dest = "message"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2) Error-Logs → Loki (NUR roh, KEINE Metriken)
|
||||
# --------------------------------------------------------------------------
|
||||
# Error-Logs haben ein anderes Format als Access-Logs:
|
||||
# [Mon Sep 14 00:10:01.123456 2026] [core:error] [pid 9] AH00124: ...
|
||||
# Der Access-Grok weiter unten kann das nicht lesen. Liefen sie durch ihn,
|
||||
# gaebe es keine Metriken — aber der Aggregator wuerde sie in leere Gruppen
|
||||
# einordnen. Deshalb ein EIGENER Input, der sie unveraendert nach Loki schickt.
|
||||
#
|
||||
# Eigener Messungsname `apache_error` statt `journal`: so sind sie in Loki
|
||||
# eindeutig filterbar, und das Logs-Panel kann sie getrennt zeigen.
|
||||
[[inputs.tail]]
|
||||
files = ${APACHE_ERROR_FILES}
|
||||
name_override = "apache_error"
|
||||
initial_read_offset = "saved-or-end"
|
||||
data_format = "value"
|
||||
data_type = "string"
|
||||
path_tag = "logfile"
|
||||
# Der Remote-Write-Output laesst Logs per namedrop draussen; Loki nimmt sie.
|
||||
[inputs.tail.tags]
|
||||
source = "apache"
|
||||
logtype = "error"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3) Request-Metriken (Common/Combined Log Format) → Prometheus
|
||||
# --------------------------------------------------------------------------
|
||||
# Common Log Format:
|
||||
# 127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326
|
||||
# VHost-Apache (Debian-Default) stellt den Hostnamen voran:
|
||||
# example.com:80 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.0" 200 2326
|
||||
[[inputs.tail]]
|
||||
files = ${APACHE_FILES}
|
||||
name_override = "apache_access"
|
||||
initial_read_offset = "saved-or-end"
|
||||
data_format = "grok"
|
||||
# Korrekte Namen der Telegraf-Grok-Sammlung: COMMON_LOG_FORMAT und
|
||||
# COMBINED_LOG_FORMAT — NICHT HTTPD_* (das sind andere Muster, Telegraf kennt
|
||||
# sie nicht: "no pattern found for %{HTTPD_COMBINED_LOG_FORMAT}").
|
||||
# Reihenfolge: spezifischste zuerst — VHost-Varianten vor der einfachen Form.
|
||||
# EIGENES MUSTER statt %{COMBINED_LOG_FORMAT} — wegen eines realen Ausfalls.
|
||||
#
|
||||
# WARUM EIN EIGENES MUSTER: das eingebaute Muster vergibt dem Log-Zeitfeld den
|
||||
# Namen `ts`. Telegraf nimmt einen erkannten Zeitstempel dann als SAMPLE-ZEIT,
|
||||
# und die Log-Zeile hat nur SEKUNDEN-Aufloesung. Mit `log_time` findet Telegraf
|
||||
# kein Zeitfeld mehr und stempelt mit eigener Uhr. Die Log-Zeit bleibt als
|
||||
# Feld erhalten — praktisch, weil Remote Write nur MILLISEKUNDEN kann.
|
||||
#
|
||||
# DER EIGENTLICHE FEHLER liegt tiefer, und ein Zeitstempel-Trick loest ihn
|
||||
# NICHT: Telegraf schreibt pro Sammelzyklus einen Batch mit EINEM Zeitstempel.
|
||||
# Treffen zwei Requests derselben Serie (path+resp_code+verb) in dieselbe
|
||||
# Millisekunde, entsteht "duplicate sample ... overrides not allowed".
|
||||
# Prometheus weist den Batch ab, Telegraf wiederholt ihn ENDLOS — der Batch
|
||||
# sitzt im FIFO-Kopf fest und der Node liefert danach GAR NICHTS mehr, auch
|
||||
# kein system_uptime. Erkennungsmerkmal: derselbe eingefrorene Timestamp
|
||||
# wiederholt sich alle ~15 s im Prometheus-Log.
|
||||
#
|
||||
# DIE LOESUNG ist deshalb der Aggregator weiter unten: er fasst alle
|
||||
# Metriken einer Serie pro Intervall zu EINEM Punkt zusammen. Pro Serie und
|
||||
# Intervall gibt es dann genau einen Punkt — eine Kollision ist strukturell
|
||||
# unmoeglich, unabhaengig von der Last. Gemessen an 2000 echten x22-Zeilen:
|
||||
# ohne Aggregator ~2000 Einzelpunkte, mit Aggregator 4 Punkte, 0 Konflikte.
|
||||
grok_custom_patterns = '''
|
||||
APACHE_LINE (?:%{HOSTNAME:vh}(?::%{NUMBER:vport})? )?%{IPORHOST:client_ip} %{USER:ident} %{USER:auth} \[%{HTTPDATE:log_time}\] "(?:%{WORD:verb} %{NOTSPACE:request}(?: HTTP/%{NUMBER:http_version})?|-)" %{NUMBER:resp_code:int} (?:%{NUMBER:resp_bytes:int}|-)(?: "%{DATA:referrer}" "%{DATA:agent}")?
|
||||
'''
|
||||
grok_patterns = ["%{APACHE_LINE}"]
|
||||
grok_timezone = "UTC"
|
||||
# Kein Tag-Wildwuchs: nur das Noetige als Tag, der Rest als Feld.
|
||||
[inputs.tail.tags]
|
||||
source = "apache"
|
||||
|
||||
# resp_code und verb waren im eingebauten Muster TAGS. Das eigene Muster
|
||||
# liefert sie als Felder — ohne diesen Schritt haetten die Grafana-Panels,
|
||||
# die nach resp_code filtern, plotzlich keine Daten mehr.
|
||||
[[processors.converter]]
|
||||
namepass = ["apache_access"]
|
||||
order = 1
|
||||
[processors.converter.fields]
|
||||
tag = ["resp_code", "verb"]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Aggregation: der eigentliche Schutz gegen Zeitstempel-Kollisionen
|
||||
# --------------------------------------------------------------------------
|
||||
# WARUM DAS NOETIG IST (realer Ausfall, nicht Theorie):
|
||||
# Telegraf schreibt pro Sammelzyklus einen Batch, dessen Metriken ALLE denselben
|
||||
# Zeitstempel tragen. Prometheus' Remote Write kann nur Millisekunden. Treffen
|
||||
# zwei Requests derselben Serie (path + resp_code + verb) in dieselbe
|
||||
# Millisekunde, antwortet Prometheus:
|
||||
# "duplicate sample for timestamp ...; overrides not allowed"
|
||||
# Er verwirft damit den GANZEN Batch. Telegrafs Puffer ist FIFO: der abgelehnte
|
||||
# Batch bleibt am Kopf stehen und wird endlos wiederholt — die Node liefert
|
||||
# danach gar nichts mehr, auch keine Systemmetriken. Merkmal im Log: derselbe
|
||||
# eingefrorene Timestamp alle ~15 s.
|
||||
#
|
||||
# Der Aggregator loest das an der Wurzel: er fasst alle Metriken einer Serie pro
|
||||
# Intervall zu EINEM Punkt zusammen. Damit gibt es pro Serie und Intervall genau
|
||||
# einen Wert — eine Kollision ist strukturell unmoeglich, egal wie hoch die Last
|
||||
# ist. Zusaetzlich sinkt die Serienzahl deutlich, was Prometheus entlastet.
|
||||
#
|
||||
# Was dabei entsteht (gemessen an 2000 echten x22-Zeilen, 4 Gruppen):
|
||||
# apache_access_resp_bytes_count — Requests je Gruppe (das brauchen die Panels)
|
||||
# apache_access_resp_bytes_sum — uebertragene Bytes je Gruppe
|
||||
# apache_access_resp_bytes_mean — mittlere Antwortgroesse
|
||||
# Die Felder `resp_code` und `verb` sind Tags und bleiben damit Gruppenschluessel.
|
||||
#
|
||||
# `drop_original = true` ist zwingend: ohne das schickt Telegraf die Einzelwerte
|
||||
# ZUSAETZLICH und die Kollision waere wieder da.
|
||||
[[aggregators.basicstats]]
|
||||
namepass = ["apache_access"]
|
||||
period = "60s"
|
||||
drop_original = true
|
||||
stats = ["count", "sum", "mean"]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3) mod_status (optional, auskommentiert)
|
||||
# --------------------------------------------------------------------------
|
||||
# Liefert Auslastung (BusyWorkers, ReqPerSec) — nuetzlich, braucht aber
|
||||
# `a2enmod status`, `ExtendedStatus On` und eine lokale Freigabe von
|
||||
# /server-status in der Apache-Config.
|
||||
#
|
||||
# [[inputs.apache]]
|
||||
# urls = ["http://localhost/server-status?auto"]
|
||||
# urls = ["http://127.0.0.1/server-status?auto"]
|
||||
+41
-5
@@ -8,7 +8,18 @@
|
||||
interval = "15s"
|
||||
flush_interval = "15s"
|
||||
metric_batch_size = 1000
|
||||
metric_buffer_limit = 20000
|
||||
# metric_buffer_limit: RAM-Puffer fuer noch nicht zugestellte Metriken.
|
||||
# GEMESSEN (x22, rate ueber 5 min): der Host erzeugt 13,3 Log-Zeilen/s.
|
||||
# Damit ergibt sich:
|
||||
# 20.000 -> ~25 min Reserve (alter Wert — zu knapp fuer einen Ausfall)
|
||||
# 200.000 -> ~4 h Reserve (~100-150 MB RAM, nur im Ausfall belegt)
|
||||
# Telegraf puffert nur, solange der Push scheitert; im Normalbetrieb bleibt
|
||||
# der Puffer leer. Die Reserve ist billiger als eine Luecke im Log.
|
||||
#
|
||||
# NICHT STILL: ein Ueberlauf wird geloggt
|
||||
# [outputs.loki] Metric buffer overflow; N metrics have been dropped
|
||||
# pruefen mit: journalctl -u telegraf | grep -i overflow
|
||||
metric_buffer_limit = 200000
|
||||
hostname = "${NODE_NAME}"
|
||||
omit_hostname = false
|
||||
|
||||
@@ -16,8 +27,29 @@
|
||||
[[outputs.http]]
|
||||
url = "${PROM_URL}"
|
||||
data_format = "prometheusremotewrite"
|
||||
timeout = "10s"
|
||||
namedrop = ["docker_log", "apache_log", "journal"]
|
||||
# 60s, NICHT 10s: laeuft die Node hinter dem NetBird-Reverse-Proxy (x05),
|
||||
# dauert ein POST 11-19 s. Ein 10-s-Timeout bricht dann JEDEN Batch ab —
|
||||
# gemessen 702 abgelehnte Requests in 20 min, die Metriken fehlen still.
|
||||
timeout = "60s"
|
||||
# GEGEN DIE ENDLOSSCHLEIFE — die wichtigste Zeile hier.
|
||||
# Ein 400 ist PERMANENT: dieselbe Nutzlast liefert dieselbe Antwort. Ohne
|
||||
# diese Liste bleibt der abgelehnte Batch am FIFO-Kopf stehen, wird fuer
|
||||
# immer wiederholt und blockiert jede dahinter gesammelte Serie. Die
|
||||
# gepufferten Samples altern dabei weiter, bis sie aelter sind als die
|
||||
# Akzeptanzgrenze des Empfaengers (Prometheus: head_min_time ~2x
|
||||
# Blockdauer, gemessen 90-120 min) — danach sind sie UNZUSTELLBAR und der
|
||||
# Knoten ist dauerhaft stumm (gemessen 09.10.2026: 9 von 15 Nodes, Versatz
|
||||
# 185-1071 min, 1680 Ablehnungen/h, danach 'Metric buffer overflow' =
|
||||
# endgueltiger Verlust). Mit dieser Zeile wird der Batch VERWORFEN statt
|
||||
# wiederholt ("Received non-retryable status 400. Metrics are lost.") und
|
||||
# der Knoten erholt sich beim naechsten Flush von selbst.
|
||||
# 5xx/408/429 bleiben absichtlich retrybar: dort ist die Nutzlast in
|
||||
# Ordnung, nur der Server ist gerade nicht bereit.
|
||||
non_retryable_statuscodes = [400, 409, 413, 422]
|
||||
# Roh-Logs gehoeren NICHT in Prometheus — sie sind Text, keine Zeitreihen.
|
||||
# `apache_error` hier ergaenzen, sonst versucht der Remote-Write-Output
|
||||
# Textzeilen als Metriken zu senden.
|
||||
namedrop = ["docker_log", "apache_log", "journal", "apache_error"]
|
||||
[outputs.http.headers]
|
||||
Content-Type = "application/x-protobuf"
|
||||
Content-Encoding = "snappy"
|
||||
@@ -28,8 +60,12 @@
|
||||
[[outputs.loki]]
|
||||
domain = "${LOKI_URL}"
|
||||
endpoint = "/loki/api/v1/push"
|
||||
timeout = "10s"
|
||||
namepass = ["docker_log", "apache_log", "journal"]
|
||||
# 60s aus demselben Grund wie oben: der Proxy-Weg braucht 11-19 s.
|
||||
timeout = "60s"
|
||||
# `apache_error` MUSS hier stehen: ohne den Namen sammelt der Input die
|
||||
# Error-Logs ein und dieser Output verwirft sie wieder — kein Fehler, keine
|
||||
# Meldung, die Panels bleiben leer. Derselbe stille Fehler wie bei `journal`.
|
||||
namepass = ["docker_log", "apache_log", "journal", "apache_error"]
|
||||
sanitize_label_names = true
|
||||
metric_name_label = ""
|
||||
http_headers = { "Authorization" = "Bearer ${TELEGRAF_TOKEN}" }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Docker-Events nach Loki (Neustart/OOM/Stop sichtbar machen)
|
||||
Documentation=file:///usr/local/bin/docker-events-loki.py
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Kein Timeout: der Prozess ist ein Stream, kein Job.
|
||||
TimeoutStopSec=15
|
||||
ExecStart=/usr/local/bin/docker-events-watcher.sh
|
||||
# Schutz: der Forwarder darf nie die Ursache des naechsten OOM werden.
|
||||
# Auf Nodes mit wenig RAM (x05: 967 MB) ist das keine Formalie — dort hat ein
|
||||
# OOM-Kill bereits netbird-proxy erwischt.
|
||||
MemoryMax=128M
|
||||
MemorySwapMax=0
|
||||
Nice=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=docker-events-loki
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -19,3 +19,18 @@
|
||||
[[processors.override]]
|
||||
namepass = ["docker_log"]
|
||||
fieldexclude = ["container_id"]
|
||||
|
||||
# Ohne diesen zweiten Block faellt die GANZE docker_container_*-Familie aus
|
||||
# jedem Remote-Write-Batch. `inputs.docker` liefert die Felder `container_id`
|
||||
# und `image_id` als 64-stellige Hex-Strings; Telegraf flacht Messung+Feld zu
|
||||
# einem Metriknamen ab, daraus wird `docker_container_blkio_container_id` —
|
||||
# ein STRING-Wert, den Prometheus ablehnt:
|
||||
# `bad sample value "<64 hex>"`
|
||||
# Der 400 wirft den KOMPLETTEN Flush weg, also auch alle Serien, an denen
|
||||
# nichts falsch ist. Der Block oben trifft nur `docker_log` (Loki-Output) und
|
||||
# hilft hier NICHT — das ist die Falle, an der man den ganzen Vormittag sucht.
|
||||
# Gemessen 09.10.2026 auf imac: 278 "series were dropped"-Zeilen in 60 min,
|
||||
# bis zu 2804 Serien im selben haengenden Batch.
|
||||
[[processors.override]]
|
||||
namepass = ["docker", "docker_container*"]
|
||||
fieldexclude = ["container_id", "container_image_id", "container_id_short", "device_id", "image_id", "health_status"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user