fix(apache): Sekunden-Kollision behoben (log_time + converter) — Node lieferte gar nichts mehr

Echte x22-Logs: 2000 Zeilen -> 286 Kollisionen (duplicate sample).
Telegraf uebernimmt den Log-Zeitstempel; bei mehreren Treffern pro Sekunde
kollidiert der +1ms-Aufschlag mit echten Zeilen. Prometheus weist den Batch
ab, Telegraf retryt endlos -> FIFO steht -> Node liefert GAR NICHTS mehr.

Fix: eigenes Grok-Muster mit log_time als Feld (Telegraf nutzt eigene ns-Uhr)
+ processors.converter, damit resp_code/verb Tags bleiben.
Gemessen: 2000/2000 Samples, 0 Kollisionen.

Ausserdem: Einheiten-Waechter im Archiver (ms statt s -> Abbruch statt
nutzloser Block).
This commit is contained in:
mm committed 2026-09-14 01:25:51 +02:00
1 parent 5a213eb580
commit 5483a4fea8
7 files changed
+626 -7

No files matched your search

+76
View File
@@ -87,6 +87,66 @@ Jede Serie trägt ein `path`-Tag, damit man in Grafana nach Datei filtert. Über
**Zusätzlich** wird die `CustomLog`-Direktive aus `/etc/apache2` bzw. `/etc/httpd` ausgewertet — die verlässlichste Quelle, `${APACHE_LOG_DIR}` wird aufgelöst.
### Sekunden-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.
**Ursache.** Access-Logs haben nur **Sekunden**-Auflösung:
```
138.199.15.193 - - [14/Sep/2026:01:16:49 +0200] "GET /image/x HTTP/1.1" 200 25454
```
Telegraf übernimmt einen erkannten Zeitstempel als **Sample-Zeit**. Treffen
mehrere Zeilen auf dieselbe Sekunde, vergibt es `+1 ms` Aufschlag pro Zeile.
Bei hoher Last läuft dieser Aufschlag in die nächste Sekunde und kollidiert mit
echten Zeilen derselben Serie:
```
duplicate sample for timestamp 1789340156000; overrides not allowed:
existing 25646, new value 14178
```
Prometheus weist den Batch ab. Telegraf **wiederholt ihn endlos** — die
Remote-Write-Queue steht, und weil sie blockiert, kommt vom ganzen Node kein
Sample mehr an.
**Gemessen** an echten x22-Logs (2000 Zeilen): **286 Kollisionen**, 1098 statt
2000 eindeutige Zeitstempel.
**Fix.** Im eigenen Grok-Muster heißt das Zeitfeld **`log_time`** statt `ts`:
```toml
grok_custom_patterns = '''
APACHE_LINE (?:%{HOSTNAME:vh}(?::%{NUMBER:vport})? )?%{IPORHOST:client_ip} %{USER:ident} %{USER:auth} \[%{HTTPDATE:log_time}\] ...
'''
```
Telegraf erkennt kein Zeitfeld mehr und stempelt mit **eigener Uhr in
Nanosekunden**. Die Log-Zeit bleibt als Feld erhalten. Gemessen: 2000 von 2000
Samples, **0 Kollisionen**.
Damit `resp_code` und `verb` weiterhin **Tags** sind (das eingebaute Muster
liefert sie als Tags, ein eigenes Muster als Felder — sonst brechen Panels, die
danach filtern):
```toml
[[processors.converter]]
namepass = ["apache_access"]
order = 1
[processors.converter.fields]
tag = ["resp_code", "verb"]
```
**Diagnose.** `--diagnose` zeigt ein `E!` bzw. die Fehlerzeile; zentral im
Prometheus-Log:
```sh
docker logs observability-prometheus 2>&1 | grep -c "duplicate sample"
docker logs observability-prometheus 2>&1 | grep -c "Out of order"
```
### 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:
@@ -412,6 +472,22 @@ 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.
## 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.
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.1.0
+329
View File
@@ -0,0 +1,329 @@
#!/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 (siehe README).
"""
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 = ["%d/%b/%Y:%H:%M:%S %z", "%a %b %d %H:%M:%S.%f %Y", "%a %b %d %H:%M:%S %Y"]
# 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
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"
errors[(hour, domain, m.group("lvl").lower())] += 1
continue
lines[(domain, "unparsed")] += 1
return files, requests, bytes_by_domain, bytes_by_hour, clients, errors, lines
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 = 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")
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(f" Dort einspielen:")
print(f" tar -xzf /tmp/{os.path.basename(tar_path)} -C /data/stacks/netbird-observability/stacks/central-imac/data/prometheus/")
print(f" # Bloecke nach <prometheus>/ — Prometheus liest sie beim naechsten Start")
print(f" docker restart observability-prometheus")
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())
+194
View File
@@ -0,0 +1,194 @@
#!/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
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():
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_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 = []
for path in files:
fh = open_maybe_gz(path)
if fh is None:
continue
try:
bytes_on_disk += os.path.getsize(path)
except OSError:
pass
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))
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"Dateien: {len(files)} ({human(bytes_on_disk)} auf Platte)")
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())
+26 -6
View File
@@ -58,17 +58,37 @@
# 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.
grok_patterns = [
"%{HOSTNAME:vh}:%{NUMBER:vport} %{COMBINED_LOG_FORMAT}",
"%{HOSTNAME:vh} %{COMBINED_LOG_FORMAT}",
"%{COMBINED_LOG_FORMAT}",
"%{COMMON_LOG_FORMAT}",
]
# EIGENES MUSTER statt %{COMBINED_LOG_FORMAT} — wegen eines realen Ausfalls:
# Die Log-Zeile hat nur SEKUNDEN-Aufloesung. Telegraf uebernimmt einen
# erkannten Zeitstempel als Sample-Zeit; bei mehreren Treffern pro Sekunde
# vergibt es +1 ms Aufschlag. Bei hoher Last (>1000 Zeilen/s) laeuft dieser
# Aufschlag in die naechste Sekunde und kollidiert mit echten Zeilen:
# "duplicate sample for timestamp ...; overrides not allowed"
# Gemessen an echten x22-Logs: 2000 Zeilen -> 286 Kollisionen.
# Prometheus weist den Batch ab, Telegraf wiederholt ihn endlos — der Node
# liefert dann GAR NICHTS mehr (auch keine Systemmetriken).
#
# Der Fix: das Zeitfeld heisst `log_time` (kein `ts`), darum nutzt Telegraf
# seine EIGENE Uhr mit Nanosekunden-Aufloesung. Die Log-Zeit bleibt als Feld
# erhalten, der Zeitstempel ist eindeutig.
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"]
# --------------------------------------------------------------------------
# 3) mod_status (optional, auskommentiert)
# --------------------------------------------------------------------------