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

+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())