From 5483a4fea8780cfc5a7a1d7581bf91b3ee653599 Mon Sep 17 00:00:00 2001 From: mm Date: Mon, 14 Sep 2026 01:25:51 +0200 Subject: [PATCH] =?UTF-8?q?fix(apache):=20Sekunden-Kollision=20behoben=20(?= =?UTF-8?q?log=5Ftime=20+=20converter)=20=E2=80=94=20Node=20lieferte=20gar?= =?UTF-8?q?=20nichts=20mehr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- README.md | 76 ++++ VERSION | 2 +- .../apache-log-archive.cpython-314.pyc | Bin 0 -> 18518 bytes .../apache-log-survey.cpython-314.pyc | Bin 0 -> 9814 bytes scripts/apache-log-archive.py | 329 ++++++++++++++++++ scripts/apache-log-survey.py | 194 +++++++++++ telegraf/apache.conf | 32 +- 7 files changed, 626 insertions(+), 7 deletions(-) create mode 100644 scripts/__pycache__/apache-log-archive.cpython-314.pyc create mode 100644 scripts/__pycache__/apache-log-survey.cpython-314.pyc create mode 100644 scripts/apache-log-archive.py create mode 100644 scripts/apache-log-survey.py diff --git a/README.md b/README.md index 92cb013..fe078be 100644 --- a/README.md +++ b/README.md @@ -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 /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. diff --git a/VERSION b/VERSION index 3eefcb9..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/scripts/__pycache__/apache-log-archive.cpython-314.pyc b/scripts/__pycache__/apache-log-archive.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86dd24cacd8cde4c2e6076cc265ba019658cb088 GIT binary patch literal 18518 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 literal 0 HcmV?d00001 diff --git a/scripts/apache-log-archive.py b/scripts/apache-log-archive.py new file mode 100644 index 0000000..04d24ea --- /dev/null +++ b/scripts/apache-log-archive.py @@ -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=", 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\S+) \S+ \S+ \[(?P[^\]]+)\] "(?P[^"]*)" (?P\d{3}|-) (?P\d+|-)' +) +RE_ERR = re.compile(r'^\[(?P[^\]]+)\] \[(?P[^\]:]+):(?P\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} ") + 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 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()) diff --git a/scripts/apache-log-survey.py b/scripts/apache-log-survey.py new file mode 100644 index 0000000..15056de --- /dev/null +++ b/scripts/apache-log-survey.py @@ -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\S+) \S+ \S+ \[(?P[^\]]+)\] "(?P[^"]*)" (?P\d{3}|-) (?P\d+|-)' +) +# Apache-Fehlerlog: [Mon Sep 14 00:47:01.123456 2026] [core:error] [pid 1] msg +RE_ERR = re.compile(r'^\[(?P[^\]]+)\] \[(?P[^\]:]+):(?P\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()) diff --git a/telegraf/apache.conf b/telegraf/apache.conf index 97d72bb..10136e3 100644 --- a/telegraf/apache.conf +++ b/telegraf/apache.conf @@ -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) # --------------------------------------------------------------------------