Warum zentrales Logging?
Auf jedem deiner Hosts liegt ein journal, unter /var/log stapeln sich Dateien, und jeder Docker-Container schreibt sein eigenes JSON-Log. Solange alles läuft, ist das kein Problem. Sobald aber etwas kaputt ist, beginnt die Suche: Du SSHst dich durch fünf Maschinen, durchsuchst mit grep Logdateien, von denen du nicht weißt, wie weit sie rotiert sind, und am Ende fehlt genau das Zeitfenster, in dem der Fehler passiert ist.
Zentrales Logging dreht das um. Alle Hosts schicken ihre Logs an einen Speicher, du suchst an einer Stelle, und du behältst sie so lange du willst — unabhängig davon, ob der Quell-Host seine lokalen Dateien schon rotiert hat. Dazu kommt der zweite, oft unterschätzte Nutzen: Du kannst über Hosts hinweg korrelieren. Ein fehlgeschlagener Login im Reverse-Proxy, der gleichzeitige Neustart eines Containers und ein Auth-Fehler im SSO landen im selben Zeitstrahl, statt in drei getrennten Silos.
Dieser Guide baut so eine Pipeline auf. Der Stack:
- Grafana Loki als Log-Speicher — leichtgewichtig, indexiert nur Labels, nicht den Volltext.
- Grafana als Oberfläche zum Suchen (Explore) und für Dashboards.
- Promtail zum Einsammeln von Journal- und Datei-Logs.
- Grafana Alloy als Nachfolger von Promtail — hier für Docker-Logs.
Wichtig gleich zu Beginn: Promtail ist am 2. März 2026 End-of-Life gegangen. Der Neubau läuft deshalb mit Alloy. In bestehenden Setups läuft Promtail oft noch neben Alloy, bis der letzte Host migriert ist. Beides ist in diesem Guide drin — Promtail, weil du es auf einem gewachsenen Homelab noch findest, Alloy, weil es der Weg nach vorne ist.
Architektur: Wer schickt was an wen
Die Pipeline hat drei Rollen: Quellen, Sammler und Speicher plus Auswertung.
QUELLEN SAMMLER SPEICHER AUSWERTUNG
─────── ─────── ──────── ──────────
journald ──────────┐
/var/log/*.log ────┼──► Promtail (datei-basiert) ─┐
syslog UDP/TCP ────┘ │
├──► Loki 3.4.2 ──► Grafana 11.6.0
Docker-Logs ──────────► Grafana Alloy (read-only) ───┘ (14 Tage) │
/var/lib/docker/... │ └──► Alertmanager
│ │
└────────────────────────┘
n8n → Telegram
Die drei Datenwege im Detail:
| Quelle | Sammler | Weg |
|---|---|---|
| systemd-Journal | Promtail (oder Alloy) | Liest das lokale Journal über die Journal-API. |
Datei-Logs (/var/log/...) | Promtail | Tailt konfigurierte Pfade. |
| Syslog (Router, Switches, NAS) | rsyslog auf dem Host → Promtail | Geräte senden per UDP/TCP an den Sammelhost, rsyslog schreibt pro Host in Dateien, Promtail liest sie als job=syslog. |
| Docker-Container-Logs | Grafana Alloy | Liest die JSON-Logs der Container read-only. |
Eine Design-Entscheidung vorweg, die du bewusst treffen solltest: Docker-Logs liest Alloy datei-basiert aus /var/lib/docker/containers/*/*-json.log, read-only — nicht über einen breiten Docker-Socket-Mount im Agent. Braucht der Agent zusätzlich Metadaten aus der Docker-API (Labels, Container-Namen), kommt davor ein Socket-Proxy, der ausschließlich Lesezugriff erlaubt. Warum das wichtig ist, steht unter Das Perfide.
Retention und Speicher-Planung: erst rechnen, dann rollen
Der häufigste Fehler beim zentralen Logging ist: Pipeline läuft, drei Wochen später ist die Platte voll und Loki wirft Samples weg, weil die Retention nicht ins System passt. Deshalb steht am Anfang die Rechnung, nicht der Container.
Retention: 14 Tage ist für ein Homelab ein guter Startwert — lang genug, um „das war letzte Woche auch schon so" nachzuschauen, kurz genug, um den Speicher zu beherrschen.
Budget: Rechne mit einem Tagesbudget und leite daraus den nötigen Platz ab. Wenn dein Log-Volume bei 1,07 GB/Tag liegt und du 14 Tage behalten willst, brauchst du rund 15 GB freien Platz nur für Logs — plus Puffer für Kompression, Index und die Zeit, in der du die Retention noch nicht aufgeräumt hast.
In unserem Aufbau lief Loki auf einem LXC mit 4 GB RAM. Der Host hatte zu Beginn 14,7 GB frei. Nach dem ersten vollen Tag lagen 255 MB Logs im Speicher — klingt harmlos, aber 92 % davon kamen von einem einzigen Host. Genau deshalb gehört die Speicher-Planung vor den Vollausbau: Erst wenn du das Volumen kennst, weißt du, ob 20 GB reichen oder du auf 60 GB erweitern musst.
Merksatz: Die Retention ist nur so gut wie die Platte, auf der sie liegt. Plane den Speicher, bevölkere dann die Pipeline.
Zwei Stellschrauben im Loki-Betrieb, die du dabei im Kopf haben solltest:
- Schema-Start: Das Startdatum der
schema_config(from: 2020-01-01) wählt das Index-Schema. Es verhindert nicht, dass alte Samples verworfen werden — das regeltreject_old_samples_max_age(Loki-Seite) bzw. der Agent-seitigemax_age/drop older_than(siehe Backfill-Schutz). - Ingest-Limits: Die Loki-Defaults hochzudrehen ist selten die richtige Antwort. In diesem Aufbau blieb das Limit bewusst niedrig (Gate: < 2 MB/s bzw. < 1,5 GB/Tag bei 15 GB für 14 Tage). Ein hoher Ingest ist fast immer eine überlaute Quelle — die gehört an der Wurzel gefixt, nicht mit mehr Limit zugedeckt.
Das Label-Schema: der wichtigste Entwurf
Bevor du irgendeinen Agenten installierst, legst du das Schema fest. Loki ist keine Volltext-Suchmaschine wie Elasticsearch — es indexiert nur die Labels. Eine schlechte Label-Wahl macht jede Query langsam und bläht den Index auf.
Das hier ist das Schema, mit dem dieser Aufbau fährt:
Stream-Labels (das sind die wenigen, hoch-kardinalitäts-armen Felder):
| Label | Werte | Bedeutung |
|---|---|---|
host | Hostname | Wer hat geloggt. |
job | journal, syslog, docker, file | Woher die Zeile stammt. |
service_name | Compose-Service | Welcher Dienst (bei Docker). |
stream | stdout, stderr | Ausgabekanal. |
Structured Metadata (alles, was viele verschiedene Werte hat):
- Containername
- Log-Level
Der Unterschied ist entscheidend. Der Containername und das Log-Level haben pro Host Dutzende bis Hunderte mögliche Werte. Würdest du sie zu Stream-Labels machen, erzeugst du für jede Kombination aus Host × Job × Service × Container × Level einen eigenen Stream — die Stream-Anzahl explodiert, der Index wächst, und Loki wird langsam. Als Structured Metadata bleiben sie durchsuchbar, ohne einen eigenen Stream aufzuspannen.
Verwechsle die beiden nicht — und teste es. Wie du beweist, dass ein Feld tatsächlich Metadata und kein Stream-Label ist, steht in der Verifikation.
Schritt 1: Syslog-Hosts einsammeln (rsyslog)
Fang mit den Geräten an, die von sich aus per Syslog sprechen: Router, Switches, NAS. Sie brauchen keinen Agenten, nur ein Ziel.
Auf jedem Host, der Syslog weiterleiten soll, steht die Konfiguration in /etc/rsyslog.d/. Der Kern ist eine Zeile — hier leitet sie alles an den Log-Host log-hansolo auf Port 514:
*.* @log-hansolo:514
Das @ steht für UDP, @@ für TCP. Für den ersten Empfang reicht UDP; TCP (@@) ist sinnvoll, wenn du bei Last keine Pakete verlieren willst.
Vor dem Bearbeiten die ganze Flotte nach Alt-Zielen absuchen. Wenn du eine bestehende Umgebung auf einen neuen Log-Host umziehst, zeigt irgendwo noch ein Host auf einen dekommissionierten Sammler. Dieser Grep über alle Konfigurationspfade hat in unserem Aufbau genau einen vergessenen Host zutage gefördert:
grep -rE '10\.99\.99\.48|ALTER-LOGHOST' /etc/rsyslog* /etc/promtail* /etc/alloy*
Dann pro Host, sauber und mit Rückweg:
cp /etc/rsyslog.d/99-forward.conf /etc/rsyslog.d/99-forward.conf.bak-20260928
# Ziel anpassen ...
rsyslogd -N1
systemctl restart rsyslog
rsyslogd -N1 prüft die Syntax, bevor du neu startest. Erst wenn der Check sauber durchläuft, restartest du.
Verifikation: Innerhalb von fünf Minuten muss der Host in Loki auftauchen:
{host="srv-r2d2"}
Das Perfide: Netzwerkgeräte lügen im Syslog-Header
Wenn du Switches, Firewalls oder NAS direkt anbindest, lauern drei Fallen, die nichts mit Loki zu tun haben:
- Manche Geräte akzeptieren im Loghost-Feld nur eine IP, keinen Port. Wer
10.99.99.20:1514einträgt, bekommt entweder einen Parse-Fehler oder das Gerät strippt den Port wortlos. Trag im Gerät nur die IP ein und bediene den Port serverseitig. - Nicht jeder Syslog-Header ist RFC3164-konform. Manche Switches schieben ein zusätzliches Jahresfeld zwischen Zeitstempel und Hostname — der Parser hält dann das Jahr für den Hostnamen. Workaround ist ein source-IP-basiertes Dispatching statt Verlass auf den im Paket stehenden Namen.
- Reverse-DNS mit mehreren PTR-Einträgen für dieselbe IP führt dazu, dass Logs mal unter dem einen, mal unter dem anderen Namen landen. Prüfe mit
dig +short -x <ip>, dass genau ein PTR existiert.
Schritt 2: Journal & Datei-Logs mit Promtail
Promtail liest das systemd-Journal und beliebige Datei-Logs und schickt sie an Loki. Die Konfiguration liegt in /etc/promtail/config.yml.
Der Journal-Reader in Promtail kann das Journal direkt lesen — kein journalctl-Umweg. Für die Datei-Logs tailst du die Pfade, die dich interessieren.
Zwei Schutzmechanismen, die du von Anfang an einbauen solltest:
Ein Obergrenze für das Alter (Backfill-Schutz). Wenn Promtail eine Positionsdatei verliert oder verschoben bekommt, liest es alte Einträge erneut ein. Setz im Journal-Reader ein
max_age(in unserem Aufbau zunächst 1 h), damit es nicht beliebig weit zurückspringt. Für das Docker-Target kann zusätzlich ein Drop älterer Einträge greifen, mit einem sichtbaren Grund im Counter (drop_counter_reason=backfill_too_old).Transiente systemd-Units zusammenfassen. Jeder
systemd-run-Aufruf erzeugt einen Prozess mit Namen wierun-r1a2b3c.service. Ohne Filter wird daraus jeder ein eigener Stream — und deine Stream-Anzahl explodiert. Fasse sie per Relabel auf einen festen Wert zusammen:
# Auszug aus der Promtail-Relabel-Phase (Journal-Scrape-Config)
# Alle transienten systemd-run-Prozesse landen in EINEM Stream.
# source_labels ist Pflicht — ohne den Bezug matcht die Regel nie.
- source_labels: ['__journal__systemd_unit']
target_label: unit
regex: '^run-r[0-9a-f]+\.service$'
replacement: 'transient'
action: replace
Vor dem Neustart immer:
promtail -check-syntax -config.file=/etc/promtail/config.yml
systemctl restart promtail
Verifikation:
# Ein transienter Prozess muss als unit="transient" landen
systemd-run /bin/echo "relabel-test"
{host="srv-r2d2"} | unit="transient"
Danach sollte die Zahl der run-r*-Streams null sein und die Gesamt-Stream-Zahl pro Host deutlich unter deiner Zielgrenze liegen (in unserem Aufbau: 36 statt vorher unkontrolliert wachsend, Ziel < 100).
Das Perfide: eine verlorene Positionsdatei flutet Loki rückwärts
Promtail merkt sich in einer Positionsdatei, bis wohin es jede Datei und das Journal gelesen hat. Geht diese Datei verloren oder wird sie verschoben, fängt Promtail von vorne an und schickt alte Einträge erneut. Loki lehnt dann Einträge ab, die zu alt sind — und du siehst in den Metriken:
loki_discarded_samples_total{reason="greater_than_max_sample_age"}
loki_discarded_samples_total{reason="too_far_behind"}
In unserem Aufbau waren das 541.466 plus 119.248 = 660.714 verworfene Samples, alle einem einzigen Zeitfenster zuzuordnen, mit Samples, die teils mehrere Wochen alt waren. Das sah auf den ersten Blick nach „Loki spinnt" aus, war aber ein Positions-/Backfill-Schwall einer einzelnen Promtail-Instanz — ausgelöst, weil ihre Konfiguration im selben Zeitfenster geändert wurde.
Der Fix an der Quelle: max_age begrenzen und einen Drop für zu alte Einträge einbauen — plus sicherstellen, dass die Positionsdatei persistent liegt. Danach stieg der Counter nicht mehr.
Aber Vorsicht bei einem pauschalen Drop: Ein unbedingter drop older_than kann legitim verspätete Logs verwerfen und einen echten Positionsverlust verschleiern. Rolle so einen Drop erst aus, nachdem du geprüft hast, dass die Positionsdatei wirklich persistent ist — nicht, um das Symptom zuzukleistern.
Schritt 3: Docker-Logs mit Grafana Alloy
Jetzt der Docker-Teil. Grafana Alloy löst Promtail ab und sammelt die JSON-Logs der Container aus /var/lib/docker/containers/*/*-json.log, read-only.
Wichtig: Alloy liest die Logdateien direkt. Es braucht keinen Zugriff auf den Docker-Socket, nur um Logs zu lesen. Brauchst du zusätzlich Metadaten aus der Docker-API, kommt ein Socket-Proxy davor, der ausschließlich Lesezugriffe erlaubt. Der relevante Teil der Compose-Datei:
# Auszug: die sicherheitsrelevanten Zeilen des Socket-Proxys
services:
docker-socket-proxy:
image: tecnativa/docker-socket-proxy:v0.5.0
environment:
POST: 0 # Schreiboperationen verboten -> Antwort 403
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
Der Proxy hört auf einem eigenen Port. Ein Schreibversuch gegen ihn muss mit 403 beantwortet werden, ein Lesezugriff auf die Netzwerk-Liste mit 200. Das ist dein Sicherheitsnachweis, dass der Agent nicht versehentlich Container steuern kann.
Installation und Start. Alloy kommt aus dem Grafana-APT-Repo als Paket (in diesem Aufbau Version 1.20.0-1). Danach der Teil, der viele überrascht:
systemctl enable alloy
systemctl start alloy
Das Paket wird je nach Distribution disabled und inactive installiert — es startet also nicht von selbst. Ohne manuelles enable + start läuft der Agent nie und du suchst am falschen Ende.
Vor dem Start die Konfiguration prüfen:
alloy validate /etc/alloy/config.alloy
systemctl restart alloy
Verifikation der ersten Sekunden:
journalctl -u alloy --no-pager | tail
Und die Metriken, die zeigen, dass wirklich Logs fließen:
loki_source_docker_target_entries_total # gelesene Einträge
loki_write_sent_entries_total # an Loki gesendet
loki_source_docker_target_parsing_errors_total # muss 0 sein
Nach dem ersten Start siehst du unter /var/lib/alloy/data/loki.source.docker.containers/ eine Positionsdatei entstehen — der Beweis, dass sich Alloy merkt, wie weit es gelesen hat.
Das Perfide: der Grafana-APT-Key wird doppelt entschlüsselt
Ein Klassiker, der dich eine halbe Stunde kostet. apt-get update scheitert mit:
NO_PUBKEY 963FA27710458545
Obwohl du den Key „doch importiert hast". Die Ursache ist subtil: apt-keys interne Funktion erkennt .asc-Dateien und dearmort sie nochmals. Wenn du den Key vorher schon per gpg --dearmor -o /etc/apt/keyrings/grafana.asc in binäres Format gebracht hast, wird durch die zweite Dearmor-Stufe ein leeres Keyring daraus.
Fix: Den Key als reines ASCII-Armored-Format ablegen, ohne eigenen gpg --dearmor-Schritt:
curl -fsSL https://apt.grafana.com/gpg.key -o /etc/apt/keyrings/grafana.asc
Danach läuft apt-get update fehlerfrei.
Schritt 4: Secret-Schwärzung — bevor die Logs rausgehen
Das ist kein „Nice-to-have", sondern der Teil, der vor dem Rollout stehen muss. Sobald Logs von vielen Hosts an einer Stelle zusammenlaufen, landen dort auch Dinge, die nie in ein zentrales Log gehören: API-Tokens, Passwörter aus Config-Ausgaben, Bearer-Header.
Die Schwärzung passiert im Agenten, auf dem Weg nach Loki — nicht erst in der Suche, nicht „später mal". Verankert sind Regeln für typische Muster:
Bearer …(Header-Werte)token=…password=…apikey=…secret=…
In unserem Aufbau wurden die Regeln mit echten Testzeilen geprüft, inklusive des JSON-Formats und des Vaultwarden-access_token-Formats:
| Eingabe (Test) | Ergebnis |
|---|---|
Bearer abc… | Bearer ***** |
token=abc… | token=***** |
access_token=eyJ… | geschwärzt |
"password":"hunter2" | "password":"*****" |
Ergebnis: NO_LEAK — alle Testzeilen kamen geschwärzt in Loki an. Nebenbei normalisierte der Agent das Log-Level (WARN → warn), sodass Queries über Level hinweg konsistent matchen.
Das Perfide: Schwärzung ist kein Ersatz für Datenminimierung
Zwei Dinge, die zusammengehören:
- Schwärzung fängt Muster, nicht Bedeutung. Eine Regel für
password=erwischt kein Passwort, das als Freitext in einer Log-Zeile steht. Schwärzung ist eine Sicherheitsnetz-Schicht, nicht die Garantie. - Manche Dienste sammelt man gar nicht ein. Dienste, die Kundendaten verarbeiten, gehören nicht ins zentrale Log — auch nicht mit Schwärzung. In unserem Rollout wurde ein solcher Dienst (ein Helpdesk mit personenbezogenen Tickets) bewusst vom Log-Rollout ausgenommen. Das ist Datenminimierung nach „so viel wie nötig, so wenig wie möglich" und keine technische Einschränkung.
Schritt 5: Memory-Limits — dem Agenten Grenzen setzen
Ein Log-Agent, der unbegrenzt Speicher fressen darf, ist ein Risiko: Bei einem plötzlichen Backfill-Schwall kann er den ganzen Host in den OOM-Kill ziehen. Systemd-Drop-ins sind der richtige Hebel — sie gelten unabhängig davon, wie der Agent gestartet wurde.
# /etc/systemd/system/alloy.service.d/memory.conf
[Service]
MemoryHigh=384M
MemoryMax=512M
Environment=GOMEMLIMIT=320MiB
Was die drei Werte tun:
| Setting | Wirkung |
|---|---|
MemoryHigh | Weiche Grenze: Ab hier bremst das System, drosselt (Throttling), killt aber nicht. |
MemoryMax | Harte Grenze: Wird sie erreicht, greift der OOM-Killer. |
GOMEMLIMIT | Die Go-Runtime-Grenze — muss unter MemoryMax liegen. |
Auf einem RAM-knappen Host fährst du die Werte strenger:
[Service]
MemoryHigh=256M
MemoryMax=384M
Environment=GOMEMLIMIT=256MiB
Nach dem Anlegen:
systemctl daemon-reload
systemctl restart alloy
systemctl show alloy -p MemoryCurrent
Verifikation: Der tatsächliche Verbrauch lag in unserem Aufbau bei 76–85 MiB — deutlich unter dem Limit von 384 MiB. Die Limits sind also eine Sicherheitsleine, kein Dauerzustand.
Das Perfide: GOMEMLIMIT über MemoryMax setzen
Setzt du GOMEMLIMIT höher als MemoryMax, kann die Go-Runtime bis zur harten Systemd-Grenze alloziieren — und wird dann vom OOM-Killer erwischt, statt sauber Speicher freizugeben. Die Reihenfolge muss immer GOMEMLIMIT < MemoryMax sein.
Verifikation (Expected Output)
Eine Pipeline läuft erst dann, wenn du es an den Metriken beweisen kannst. Die wichtigsten Checks auf einen Blick.
1. Volumen pro Host (letzte 24 h):
curl -s -G http://log-hansolo:3100/loki/api/v1/query \
--data-urlencode 'query=sum(bytes_over_time({host="srv-r2d2"}[24h]))' \
--data-urlencode "time=$(date +%s)"
Erwartung: ein plausibler Tageswert. In unserem Pilot war das eine Hochrechnung von ~39,3 MB/Tag — bei einem Budget von 1,07 GB/Tag also viel Puffer. (Vorsicht: Aus einem kurzen Messfenster hochgerechnete Werte sind keine stabilen 24-h-Schnitte. Miss die volle Periode ab.)
2. Volumen pro Docker-Service:
curl -s -G http://log-hansolo:3100/loki/api/v1/query \
--data-urlencode 'query=sum by (service_name)(bytes_over_time({host="srv-r2d2",job="docker"}[24h]))'
Erwartung: eine Rangliste der gesprächigsten Container. Genau diese Query ist dein Werkzeug gegen Budget-Fresser (siehe Praxisbeispiel).
3. Stream-Anzahl pro Host:
curl -s -G http://log-hansolo:3100/loki/api/v1/series \
--data-urlencode 'match[]={host="srv-r2d2"}' \
--data-urlencode "start=$(( $(date +%s) - 86400 ))" \
--data-urlencode "end=$(date +%s)" \
| python3 -c 'import sys,json; print(len(json.load(sys.stdin)["data"]))'
Erwartung: eine überschaubare Zahl. Im 15-Minuten-Fenster unseres Pilots waren es 21.
4. Keine Parse-Fehler, keine Drops:
curl -s http://127.0.0.1:12345/metrics \
| grep -E 'loki_process_dropped_lines_total|loki_source_docker_target_parsing_errors_total|loki_write_dropped_entries_total|loki_write_sent_entries_total'
Erwartung: parsing_errors = 0, write_dropped = 0, sent_entries > 0 und steigend.
5. Structured Metadata ist wirklich Metadata. Das ist der wichtigste Schema-Test. Eine Query mit dem Container als Filter matcht:
count_over_time({host="srv-r2d2"} | container=~".+" [1h])
Aber die Series-API mit demselben Filter liefert 0 Streams — denn container ist kein Stream-Label:
curl -s -G http://log-hansolo:3100/loki/api/v1/series \
--data-urlencode 'match[]={container=~".+"}'
Wenn hier 0 herauskommt: Schema korrekt. Käme eine hohe Zahl heraus, wäre der Containername versehentlich ein Stream-Label — und dein Index würde wachsen.
6. Loki verwirft nichts:
curl -s http://log-hansolo:3100/metrics \
| grep -E 'loki_discarded_samples_total|loki_distributor_bytes_received_total'
Erwartung: loki_discarded_samples_total flach über die Zeit. (Ein Counter, der bei nicht vorhanden startet, ist kein Fehler — er wurde beim letzten Loki-Neustart zurückgesetzt.)
7. Ingest-Rate im Rahmen: Der Gate-Wert unseres Rollouts war < 2 MB/s bzw. < 1,5 GB/Tag. Zusammen mit dem Tagesbudget von 1,07 GB/Tag ist das die Leine, an der du einen entgleisenden Host sofort erkennst.
Praxisbeispiel: den lauten Verursacher finden
Das ist die Situation, für die du das Ganze überhaupt baust — und der wichtigste Abschnitt dieses Guides. Nach dem ersten Tag zentraler Logs fällt auf: Ein Host zieht fast das gesamte Budget. 92 % eines Tagesvolumens kamen aus einer einzigen Quelle.
Schritt 1: Welcher Stream frisst das Budget?
Nicht raten — messen. Die LogQL-Query aggregiert die Byte-Summe pro Unit über ein Zeitfenster:
sum(bytes_over_time({host="cloud-leia"}[1h])) by (unit)
Das zeigt sofort, wer verantwortlich ist: In unserem Fall dominierte eine User-Unit mit 86,19 MB von insgesamt 87,53 MB in einer Stunde.
Schritt 2: Welcher Prozess steckt dahinter?
Der Unit-Name allein reicht nicht. Die Journal-Felder des Streams geben die konkrete Quelle preis:
_SYSTEMD_UNIT=user@1000.service
_SYSTEMD_USER_UNIT=nextcloud-sync@deathstar.service
SYSLOG_IDENTIFIER=nextcloudcmd
Jetzt ist klar, welcher Prozess dahintersteckt — ein Nextcloud-Sync-Client, der per Timer alle fünf Minuten läuft.
Schritt 3: Ist es ein Fehler oder nur Geschwätzigkeit?
Der naheliegende Reflex bei einem dominanten Log-Stream ist „Crash-Loop". Hier war es nichts davon: Der Sync lief fehlerfrei, die Sync-Datenbank war valid=true, es gab keinen Endlos-Retry. Der Client protokollierte schlicht jede einzelne angefasste Datei auf INFO-Level:
13.503 Zeilen / 8,51 MB pro Lauf — bei einem 5-Minuten-Timer.
Schritt 4: Die Quelle fixen, nicht das Limit
Hier kommt der Kern der Lektion. Man könnte das Ingest-Limit erhöhen und die Flut einfach durchlaufen lassen. Das ist die falsche Antwort:
- Es verschiebt das Problem nur stromabwärts — auf Disk, Retention und Index.
- Es verdeckt die Ursache: ein Dienst, der konfigurativ viel zu viel redet.
- Es kostet dich das Budget, das andere, echte Logs brauchen.
Der Fix gehört an die Quelle. Beim Sync-Client war das ein Silent-Flag plus eine feste Locale, direkt in der systemd-Unit. Vorher:
[Service]
Type=oneshot
ExecStart=/usr/bin/nextcloudcmd -n --non-interactive ... https://nextcloud.deathstar.lan
Nachher:
[Service]
Type=oneshot
Environment=LANG=C.UTF-8
ExecStart=/usr/bin/nextcloudcmd -s -n --non-interactive ... https://nextcloud.deathstar.lan
Ergebnis:
| Lauf | Zeilen | Größe |
|---|---|---|
ohne -s (Default) | 13.503 | 8,51 MB |
mit -s | 0–4 | 265 B |
Hochgerechnet: von 87,53 MB/h auf rund 1,4 MB/h (gemessen als 0,34 MB in einem 15-Minuten-Fenster). Die Unit fällt aus dem Top-Ranking.
Der entscheidende Negativ-Test: -s darf niemals echte Fehler verschlucken. Ein Testlauf gegen eine absichtlich falsche URL lieferte weiterhin exit=1 plus Fehlermeldung. Erst dieser Beweis macht den Fix sicher — ein Silent-Flag, das Fehler verbirgt, wäre schlimmer als das ursprüngliche Problem.
cp ~/.config/systemd/user/nextcloud-sync@.service \
~/.config/systemd/user/nextcloud-sync@.service.bak-20260928
# Unit anpassen ...
systemctl --user daemon-reload
Dieselbe Logik beim Ingest-Rate-Limit
Dasselbe Muster gilt eine Ebene tiefer, beim Rate-Limit des Agenten. In unserem Docker-Rollout tauchte ein einmaliger Schwall im ratelimit_drop_stage auf: 5.033 verworfene Zeilen, restlos verteilt auf drei gesprächige Container.
Der Reflex wäre: Limit hochsetzen. Die Analyse sagte das Gegenteil. Beim ersten Anhängen liest der Docker-Target den vorhandenen Log-Abschluss ein — die drei Container reißen dabei das Limit (Rate 200/s, Burst 400) für unter einer Sekunde einmalig. Danach war der Counter flach.
Die tatsächliche Dauerlast war harmlos:
docker logs --since 10m nextcloud-app 2>&1 | wc -l # 471 Zeilen in 10 min
unter 2 Zeilen/s — weit unter dem Limit. Aktion: keine. Das Limit wurde bewusst nicht erhöht, weil kein Container es im Dauerbetrieb überhaupt erreicht. Wer hier reflexhaft aufdreht, verschiebt die Last nur nach hinten und verpasst die echte Frage: Warum ist dieser Container so gesprächig?
Das ist die eigentliche Disziplin beim zentralen Logging: Erst die Quelle verstehen, dann Limits anfassen. Ein Ingest-Limit ist ein Sicherheitsnetz gegen Unfälle, kein Ventil für schlecht konfigurierte Dienste.
Lessons Learned
- „Hoher Ingest" heißt nicht „Fehler". Erst die Journal-Felder (
SYSLOG_IDENTIFIER,_SYSTEMD_USER_UNIT) zeigen, welcher Prozess wirklich dahintersteckt. Der erste Verdacht — Crash-Loop — war falsch. - Struktur schlägt Rate. Das Label-Schema (
host,job,service_name,stream+ Structured Metadata für Container/Level) entscheidet über Index-Größe und Query-Geschwindigkeit, nicht die Ingest-Rate. - Schwärzung vor dem ersten Container-Log. Secrets werden im Agenten geschwärzt, nicht in der Suche. Und getestet wird mit realistischen Formaten (JSON, Query-String,
access_token=eyJ…) — nicht nur mit dem Muster, das man selbst erfunden hat. - Datenminimierung ist Teil der Pipeline. Dienste mit Kundendaten sammelt man nicht ein, auch nicht mit Schwärzung.
- Ein verlorener Positions-Stand flutet rückwärts.
max_ageund ein begründeter Drop schützen — aber ein pauschaler Drop kann legitime verspätete Logs verwerfen und echte Positionsverluste maskieren. GOMEMLIMIT<MemoryMax. Sonst killt der OOM-Killer die Go-Runtime an der harten Grenze, statt dass sie sauber Speicher freigibt.- Limits sind nicht dein Ventil. Ein gestiegenes Ingest-Limit verschiebt das Problem auf die Platte und verdeckt die Ursache. Fixe den Verursacher an der Quelle.
- Zwei scheinbar verwandte Symptome, zwei Ursachen. Die 660.714 verworfenen Samples und die Nextcloud-Log-Flut sahen nach demselben Thema aus, waren aber ein unabhängiger Positions-/Backfill-Vorfall. Getrennte Root-Cause-Analysen sparen falsche Korrelationen.
- Planung vor Rollout. Erst Retention und Speicherbudget rechnen, dann Host für Host bevölkern. Der Agent selbst ist der billige Teil.
- Promtail läuft aus, Alloy ist der Weg. Auf einem gewachsenen Homelab koexistieren beide, bis der letzte Host migriert ist.
Checkliste
- Retention festgelegt (z. B. 14 Tage) und Speicherbudget gerechnet, bevor der erste Host loggt
- Label-Schema definiert: wenige Stream-Labels (
host,job,service_name,stream), hoch-kardinale Felder als Structured Metadata - Schema-Startdatum gesetzt, sodass historische Logs beim Backfill nicht verworfen werden
- Flotte nach Alt-Log-Hosts in
/etc/rsyslog*,/etc/promtail*,/etc/alloy*durchsucht - Syslog-Hosts umgestellt, Syntax mit
rsyslogd -N1geprüft, Backup vor Änderung - Promtail: transiente Units zusammengefasst,
max_age/Backfill-Drop gesetzt, Positionsdatei persistent - Alloy: Paket manuell
enable+start, Konfiguration mitalloy validategeprüft - Docker-Zugriff read-only (Dateipfade bzw. Socket-Proxy mit
POST=0, 403 verifiziert) - Secret-Schwärzung vor dem Container-Rollout aktiv und mit realistischen Testzeilen geprüft (NO_LEAK)
- Dienste mit Kundendaten bewusst ausgeschlossen
- Memory-Limits als systemd-Drop-in,
GOMEMLIMIT<MemoryMax - Verifikation per Metrik-Export:
parsing_errors=0,write_dropped=0,discardedflach, Stream-Anzahl im Rahmen - Vor dem ersten Vollausbau: Volumen pro Host/Service messen (
sum(bytes_over_time(...)) by (...)) - Limits erst nach der Verursacher-Analyse anfassen — nicht als Reflex
Hardware für den Log-Host
Loki und die Sammler sind genügsam, aber der Log-Host läuft bei dir 24/7 und die Log-Daten wollen auf flotten Speicher:
- 🖥️ Mini-PC N100 Beelink S12 Pro — sparsamer Mini-PC, reicht als LXC-/Docker-Host für Loki und Grafana
- 💾 Samsung SSD 990 NVMe (2 TB) — NVMe für die Loki-Daten, wenn du beim Suchen keine Lust auf Wartezeit hast
Einige Links auf dieser Seite sind Affiliate-Links. Wenn du über diese Links einkaufst, erhalte ich eine kleine Provision — für dich ändert sich am Preis nichts. So unterstützte ich diesen Blog und ermögliche weitere kostenlose Tutorials. Danke!