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 0000000..86dd24c Binary files /dev/null and b/scripts/__pycache__/apache-log-archive.cpython-314.pyc differ diff --git a/scripts/__pycache__/apache-log-survey.cpython-314.pyc b/scripts/__pycache__/apache-log-survey.cpython-314.pyc new file mode 100644 index 0000000..620698a Binary files /dev/null and b/scripts/__pycache__/apache-log-survey.cpython-314.pyc differ 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) # --------------------------------------------------------------------------