
🌐 Auch auf: English · Français · Español
Deep Dive aus der Reihe rund um die Ansible-Tour. Niveau: fortgeschrittene Linux-Administration. Die Tour erklärt die Rollen; die Deep Dives erklären die Technik darunter – wie sie funktioniert, warum sie hier ist, was genau dieses Setup damit macht, wie man sie im Alltag betreibt und was es sonst noch gegeben hätte.
Metriken sagen, dass etwas nicht stimmt: Der Speicher steht bei 90 Prozent, ein Container ist dreimal neu gestartet, die Platte läuft voll. Sie sagen selten, warum. Das Warum steht in den Logs – und auf einem K3s-Host sind die verteilt auf das systemd-Journal, ein Verzeichnis pro Pod unter /var/log/pods, das Audit-Log und eine Handvoll Anwendungsdateien. Bisher hieß etwas finden: SSH, journalctl, kubectl logs und grep, eine Quelle nach der anderen. Dieser Beitrag erklärt, wie Loki und Grafana Alloy daraus einen durchsuchbaren Ort neben den Dashboards machen, was genau seit dem 19. Oktober 2026 auf dem Blog läuft – und was die neuen Log-Alarme in ihrer ersten Stunde fanden, bevor irgendjemand danach gesucht hatte.
🧭 Wie Loki und Alloy funktionieren
Die Idee: die Labels indexieren, nicht die Wörter
Klassische Log-Systeme wie Elasticsearch bauen einen Volltextindex: Jedes Wort jeder Zeile wird durchsuchbar, und der Index ist oft größer als die Logs selbst. Loki macht es umgekehrt. Es indexiert pro Stream nur eine kleine Menge Labels – etwa job="journal", unit="sshd.service", namespace="wordpress" – und legt die Zeilen selbst komprimiert in Chunks ab. Eine Abfrage wählt zuerst Streams über ihre Labels aus (günstig, über den Index) und durchsucht dann nur die Chunks dieser Streams nach dem Text (rohe Gewalt, aber auf wenig Daten). Das ist dasselbe Modell, das Prometheus für Metriken nutzt – deshalb passen die beiden so gut zusammen.
Daraus folgt eine Regel, die entscheidet, ob Loki schnell bleibt: Labels dürfen nur wenige Werte haben. Host, Job, systemd-Unit, Namespace, Container – je ein paar Dutzend Werte – sind gute Labels. Ein Benutzername, eine IP-Adresse, eine Request-ID sind es nicht: Jeder neue Wert erzeugt einen neuen Stream, und Tausende winzige Streams machen den Index groß und die Chunks nutzlos. Solche Werte bleiben in der Zeile und werden zur Abfragezeit gefiltert.
Streams, Chunks, Index, Compactor
- Stream: alle Zeilen mit genau demselben Label-Satz.
{job="journal", unit="k3s.service", level="info"}ist ein Stream. - Chunk: ein komprimierter Block Zeilen eines Streams, der ins Dateisystem geschrieben wird, wenn er voll oder alt genug ist.
- Index (TSDB, Schema v13): ordnet Label-Sätze den Chunks zu, eine Indexdatei pro Tag.
- Compactor: führt die täglichen Indexdateien zusammen und ist – wichtig – die Komponente, die Daten nach Ablauf der Aufbewahrung tatsächlich löscht. Ohne
retention_enabledblendet die Aufbewahrungseinstellung alte Daten nur in Abfragen aus.
LogQL in zwei Schritten
Eine LogQL-Abfrage beginnt immer mit einem Stream-Selektor in geschweiften Klammern, gefolgt von optionalen Filtern. Mit einem Zeitfenster und einer Funktion darum wird dieselbe Abfrage zu einer Metrik – das nutzen die Alarme.
# alle Zeilen der k3s-Unit, die "error" enthalten (ohne Groß/Klein)
{job="journal", unit="k3s.service"} |~ "(?i)error"
# als Zahl: wie viele solche Zeilen in den letzten 15 Minuten?
sum(count_over_time({job="journal", unit="k3s.service"} |~ "(?i)error" [15m]))|= heißt „enthält“, != „enthält nicht“, |~ und !~ sind die Regex-Varianten. Parser wie | json oder | logfmt machen Teile einer Zeile zu vorübergehenden Labels, die nur während der Abfrage existieren – der günstige Weg, nach einem Wert zu filtern, der niemals ein echtes Label werden darf.
Grafana Alloy: der Sammler
Alloy ist Grafanas Agent, der Nachfolger von Promtail und dem Grafana Agent. Seine Konfiguration ist eine Pipeline aus Komponenten: Quellen lesen Logs (Journal, Dateien), Prozessoren verändern sie (parsen, umbenennen, Zeitstempel setzen, verwerfen), und Writer schicken sie an Loki. Jede Komponente gibt an, wohin ihre Ausgabe geht (forward_to), daher liest sich die Konfiguration wie ein Datenflussdiagramm. Alloy merkt sich in seinem eigenen Datenverzeichnis, wie weit es jede Datei und das Journal gelesen hat – ein Neustart verliert also keine Zeilen und liest keine doppelt.
🛡️ Warum es hier ist
- Ein Ort für alle Logs. Journal, jeder Pod, das Audit-Log und fail2ban, durchsuchbar in Grafana neben den Metriken, für die letzten 14 Tage – auch für Pods, die es nicht mehr gibt und deren Logs
kubectl logsnicht mehr zeigen kann. - Alarme auf Dinge, die Metriken nicht sehen. Eine SELinux-Verweigerung, ein fehlgeschlagener fail2ban-Ban oder ein OOM-Kill des Kernels ist eine Logzeile, keine Zahl. Bisher fiel so etwas nur auf, wenn jemand zufällig hinsah.
- Günstig genug für einen kleinen Server. Loki und Alloy brauchen zusammen etwa ein halbes Gigabyte RAM und auf dem Blog ein paar Megabyte Platte pro Stunde – ein Volltext-Stack bräuchte ein Mehrfaches.
🔧 Was dieses Repository macht
Die Rolle common_loki läuft nach common_grafana (deren Paketquelle und API sie nutzt) und vor common_monit (das danach beide Dienste überwacht). Seit dem 19. Oktober 2026 lief sie auf dem Blog; die beiden Nextcloud-Server folgten am 20. Oktober nach einem Tag Beobachtung.
Pakete, gepinnt
Loki und Alloy kommen als RPMs aus dem offiziellen Grafana-Repository, demselben, aus dem auch Grafana installiert wird. Die Versionen sind wie jede andere Komponente in versions.yml gepinnt (loki_version, alloy_version); der Rauchtest am Ende des Laufs liest ihre build_info-Metrik und meldet, wenn die laufende Version nicht die gepinnte ist.
Loki: ein Binary, lokale Platte, 14 Tage
Das Paket bringt eine Demo-Konfiguration mit, die alles unter /tmp/loki speichert – nach jedem Neustart weg – und mit Debug-Logging. Die Rolle ersetzt sie vollständig. Die wichtigen Teile:
server:
http_listen_address: 127.0.0.1 # nur Grafana auf demselben Host liest
grpc_listen_address: 127.0.0.1
common:
path_prefix: /var/lib/loki
replication_factor: 1
ring:
kvstore:
store: inmemory # ein Prozess, kein Cluster
schema_config:
configs:
- from: 2026-10-01
store: tsdb
object_store: filesystem
schema: v13
limits_config:
retention_period: 336h # 14 Tage
compactor:
retention_enabled: true # der Teil, der wirklich löscht
analytics:
reporting_enabled: falseDas Template wird geprüft, bevor es die echte Datei ersetzt: Ansibles validate lässt loki -verify-config über den Kandidaten laufen. Ein Tippfehler lässt also den Playbook-Lauf scheitern statt eines Dienstes, der nicht mehr startet.
Alloy: vier Quellen, eine Handvoll Labels
- Journal mit den Labels
unit,levelundtransport.transport="kernel"trennt die eigenen Meldungen des Kernels ab – dort schreibt der OOM-Killer hin. - Pod-Logs aus
/var/log/pods/<namespace>_<pod>_<uid>/<container>/N.log. Eine Relabel-Regel schneidet Namespace, Pod und Container aus dem Pfad;stage.crientfernt das Präfix von containerd (Zeit, Stream, Teilzeilen-Flag) und fügt geteilte Zeilen zusammen. - audit.log. SELinux-Verweigerungen stehen auf diesen Hosts nicht im Journal – auditd besitzt den Audit-Socket des Kernels –, also liest Alloy die Datei.
- fail2ban.log, weil ein fehlgeschlagener Ban nur dort protokolliert wird.
Alle Streams bekommen das Label host; das automatische Label filename wird verworfen, weil es nur Informationen wiederholen würde, die schon in den anderen Labels stecken.
Die Logs von root lesen, ohne root zu sein
Das Paket startet Alloy als Benutzer alloy in den Gruppen adm und systemd-journal. Das reicht für das Journal, aber nicht für audit.log (root, Modus 0600), fail2ban.log und die Pod-Logs (root, 0640). Die bequeme Lösung wäre, Alloy als root laufen zu lassen. Die Rolle gibt ihm stattdessen eine einzige Linux-Capability, in einem systemd-Drop-in:
[Service]
AmbientCapabilities=CAP_DAC_READ_SEARCH
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/alloy
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
...CAP_DAC_READ_SEARCH umgeht die Leserechte-Prüfung auf Dateien und Verzeichnissen – und sonst nichts. Alloy kann jedes Log lesen, aber außerhalb seines Datenverzeichnisses nicht schreiben, keine Besitzer ändern, keine Module laden. Loki bekommt ein ähnliches Drop-in ganz ohne Capability. systemd-analyze security bewertet die beiden Units mit 4.0 und 3.8 – „OK“ auf seiner Skala, auf der ein ungehärteter Dienst bei etwa 9 liegt.
Grafana: Datenquelle, Alarme und ein Selbsttest
Die Rolle legt die Datenquelle „Loki“ über Grafanas API an (erst prüfen, nur bei Fehlen anlegen) und vier Alarmregeln in dem Ordner, in dem schon die Metrik-Alarme liegen:
| Alarm | LogQL (15-Minuten-Fenster) | Warum |
|---|---|---|
| SELinux hat etwas blockiert | {job="audit"} |= "type=AVC" |= "permissive=0" | Eine echte Verweigerung. permissive=1-Zeilen von Domains, die noch in der Lernphase sind, zählen nicht. |
| fail2ban meldet Fehler | {job="fail2ban"} |= "]: ERROR" != "KeyError('/var/log/pods/" | Eine Ban-Aktion oder ein Filter ist kaputt. Der ausgenommene KeyError ist harmlos (siehe unten). |
| OOM-Killer des Kernels | {job="journal", transport="kernel"} |~ "(?i)out of memory: killed process" | Erfasst auch Host-Prozesse, nicht nur Container. |
| Keine Logs mehr | sum(count_over_time({job="journal"}[15m])) < 1 | Die Pipeline selbst ist kaputt. „Keine Daten“ zählt hier als Alarm. |
Zum Schluss testet die Rolle die ganze Kette: Sie schreibt mit logger eine eindeutige Markierung ins Journal und fragt Loki ab, bis die Markierung auftaucht. Ist Alloy oder Loki kaputt, scheitert der Playbook-Lauf genau dort.
🔍 Die erste Stunde: vier Befunde, nach denen niemand gesucht hatte
Innerhalb einer Stunde nach dem ersten Ausrollen hatten die neuen Alarme Mails verschickt. Keine davon war Rauschen – jede zeigte auf etwas Echtes, zwei davon auf eigene Fehler.
1. Alte Zeilen, neue Zeitstempel
Die ersten beiden Alarme feuerten sofort: ein AVC und ein fail2ban-Fehler. Die fail2ban-Zeile stammte von 11:13 Uhr am selben Morgen. Was passiert war: Beim ersten Start liest Alloy jede Datei von Anfang an, und ohne weitere Anweisung versieht es jede Zeile mit dem Zeitpunkt, zu dem es sie gelesen hat. Der ganze Rückstand aus audit.log und fail2ban.log landete als „jetzt“ in Loki – und die Alarme, die Zeilen der letzten 15 Minuten zählen, sahen auf einen Schlag Stunden alter Fehler.
Die Lösung: den Zeitstempel aus der Zeile selbst nehmen. Das Audit-Log trägt die Epoch-Zeit in msg=audit(1792426019.999:37103), fail2ban schreibt Ortszeit ohne Zone:
loki.process "files" {
stage.match {
selector = "{job=\"audit\"}"
stage.regex { expression = "msg=audit\\((?P<ts>[0-9]+[.][0-9]+):" }
stage.timestamp { source = "ts" format = "Unix" }
}
stage.match {
selector = "{job=\"fail2ban\"}"
stage.regex { expression = "^(?P<ts>[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9:]{8},[0-9]{3})" }
stage.timestamp { source = "ts" format = "2006-01-02 15:04:05,000" location = "UTC" }
}
...
}Die Zeitzone ist in der Rolle nicht fest eingetragen: Sie liest die Zone des Hosts mit timedatectl und reicht sie ans Template weiter, damit dieselbe Rolle auch auf einem Host stimmt, der in Ortszeit läuft.
2. Das erste echte AVC: ein Paket-Installer
Das AVC selbst war echt und ganz neu: Bei der Installation des Alloy-RPMs wollte useradd -m die Datei .bash_logout im neuen Home-Verzeichnis /var/lib/alloy anlegen. Dieses Verzeichnis hat den Typ var_lib_t, und die Policy erlaubt useradd_t nicht, dort Benutzerdateien anzulegen. Harmlos – Alloy braucht kein Shell-Profil –, aber genau die Art Ereignis, die früher in einem 6 MB großen Audit-Log verschwand.
3. fail2ban-Fehler, die schon seit Tagen da waren
Der Blick auf die fail2ban-Fehler in Loki zeigte zwei Sorten. Die eine war harmlos: KeyError('/var/log/pods/…'), das Polling-Backend verliert ein Pod-Log, das bei einem Pod-Neustart entfernt wurde; der Jail läuft weiter. Die andere nicht: am 13. und 14. Oktober Failed to execute ban jail 'nginx-k3s-bad-paths' action 'nftables…' – Bans, die nicht stattfanden, in den Tagen, in denen das Neuladen der Firewall überarbeitet wurde. Niemand hatte sie gesehen. Der Alarm nimmt jetzt nur das bekannte harmlose Muster aus.
4. Ein AVC vom nächsten neuen Werkzeug
Eine Stunde später wurde Trivy installiert (sein eigener Deep Dive folgt). Seine Scan-Unit hatte NoNewPrivileges=yes, und das Skript ruft k3s kubectl auf. Das k3s-Binary wechselt in die Domain container_runtime_t, und unter NoNewPrivileges verweigert SELinux einen solchen Wechsel, wenn die Policy ihn nicht ausdrücklich erlaubt: avc: denied { nnp_transition }. Der Loki-Alarm feuerte wenige Minuten nach dem ersten Scan. Nicht das erste Mal, dass dieser Mechanismus hier Ärger machte – MariaDB und Grafana waren schon darauf gestoßen –, aber das erste Mal, dass ihn eine Maschine fand statt eines ausgefallenen Dienstes.
🧰 Betrieb im Alltag
LogQL-Rezepte
# alles, was SELinux heute verweigert hat, ohne die Domains in der Lernphase
{job="audit"} |= "type=AVC" |= "permissive=0"
# was in der letzten Stunde gebannt wurde, und warum
{job="fail2ban"} |= "Ban "
# Logs eines Pods, den es nicht mehr gibt (Namespace + Container sind Labels)
{namespace="wordpress", container="wordpress-fpm"} |= "Fatal"
# Kernel-Meldungen: OOM, Segfaults, Hardware
{job="journal", transport="kernel"}
# SSH-Logins mit Schlüssel, nach Benutzer (zur Abfragezeit geparst, kein Label)
{job="journal", unit="sshd.service"} |= "Accepted publickey" | regexp "for (?P<user>\\S+) from"
# welche Units loggen am meisten? (findet geschwätzige Dienste)
topk(10, sum by (unit) (count_over_time({job="journal"}[1h])))Kommt alles an?
# Loki bereit, Alloy bereit (beide nur auf 127.0.0.1)
curl -s 127.0.0.1:3100/ready
curl -s 127.0.0.1:12345/-/ready
# Zeilen pro Quelle in den letzten 10 Minuten
for j in journal pods audit fail2ban; do
curl -sG 127.0.0.1:3100/loki/api/v1/query \
--data-urlencode "query=sum(count_over_time({job=\"$j\"}[10m]))"
done
# welche Namespaces Loki kennt
curl -s 127.0.0.1:3100/loki/api/v1/label/namespace/values
# Alloys eigene Sicht: Warnungen der letzten Stunde
journalctl -u alloy -p warning --since -1hPlatte und Speicher
Nach den ersten zwei Stunden auf dem Blog: Loki etwa 240 MB RSS, Alloy etwa 275 MB, zusammen rund zwei Minuten CPU-Zeit, und 13 MB in /var/lib/loki – etwa 150 MB am Tag, also rund 2 GB bei vollen 14 Tagen. Die Nextcloud-Server haben mehr Pods und mehr Verkehr; deshalb blieb die Rolle zuerst einen Tag auf dem Blog, und die Zahlen wurden verglichen, bevor sie weiterzog. Wächst die Platte schneller als erwartet, ist die erste Frage, welcher Stream groß ist – topk nach unit oder namespace beantwortet das –, und die zweite, ob dieser Stream überhaupt aufbewahrt werden muss; Alloy kann Zeilen mit stage.drop verwerfen, bevor sie Loki erreichen.
Gut zu wissen
- Zeitzone im Browser. Grafana zeigt Zeiten in der Zone des Browsers, die Server laufen in UTC. Eine Zeile „von 11:13“ in einer Datei steht im Sommer in Grafana bei 13:13.
- Alte Zeilen werden abgelehnt.
reject_old_samples_max_age: 168h– eine Datei mit Zeilen, die älter als eine Woche sind (etwa ein zurückgespieltes Backup), wird nicht importiert. Das ist gewollt. - Neustart ist sicher. Alloy hält seine Lesepositionen in
/var/lib/alloy/data. Löscht man dieses Verzeichnis, liest es alles noch einmal – jetzt mit richtigen Zeitstempeln, aber doppelt.
🆘 Spickzettel: Symptom → Befehl
Die Kurzfassung dieses Artikels für den Moment, in dem etwas nicht stimmt. Auf dem Host als root ausführen; <…> sind Platzhalter.
| Symptom | Befehl |
|---|---|
| Ist Loki bereit, ist Alloy bereit? | curl -s 127.0.0.1:3100/ready |
| Logs einer Quelle fehlen | curl -sG 127.0.0.1:3100/loki/api/v1/query \ |
| Was hat SELinux verweigert? | {job="audit"} |= "type=AVC" |= "permissive=0" |
| Logs eines Pods, den es nicht mehr gibt | {namespace="<ns>", container="<container>"} |= "Fatal" |
| Kernel: OOM, Segfaults, Hardware | {job="journal", transport="kernel"} |
| Platte wächst schneller als erwartet | du -sh /var/lib/loki |
| Zeiten in Grafana um zwei Stunden verschoben | # browser time zone vs. UTC on the servers - not a bug |
🔭 Was als Nächstes kommt
- Eine SELinux-Domain für Loki und Alloy, wie sie der Monitoring-Stack bekommen hat: Heute laufen sie als
unconfined_service_t, nur durch systemd eingeschränkt. - Logs und Metriken in einer Ansicht. Grafana kann unter einem Metrik-Panel die passenden Logzeilen für denselben Zeitraum zeigen – nützlich für das Container-Dashboard.
- Weniger, bessere Labels für die Pods, falls die Zahl der Streams wächst: Der Pod-Name ändert sich bei jedem Neustart und könnte strukturierte Metadaten statt eines Labels werden.
⚖️ Alternativen – und warum Loki
| Option | Stärke | Warum nicht hier |
|---|---|---|
| journald + grep | Kostenlos, immer da | Ein Host, eine Quelle nach der anderen, keine Alarme, Pod-Logs verschwinden mit dem Pod. |
| ELK / OpenSearch | Volltextsuche über alles, sehr mächtig | Java, mehrere GB RAM für einen einzigen Knoten – mehr, als der ganze Blog-Stack braucht. |
| Graylog | Gute Oberfläche, Streams und Alarme eingebaut | Früher im Labor getestet: braucht MongoDB plus OpenSearch – wieder schwer für einen kleinen Server. |
| Promtail + Loki | Die klassische Kombination | Promtail ist im Wartungsmodus; Alloy ist sein Nachfolger und spricht auch Metriken und Traces. |
| Loki + Alloy | Label-Index wie Prometheus, klein, nativ in Grafana | Gewählt: passt zum vorhandenen Prometheus/Grafana-Setup und zum RAM-Budget. |
Den Ausschlag gaben nicht Funktionen, sondern die Passform. Prometheus und Grafana waren schon da, die Alarm-Mails funktionierten schon, und das Label-Modell ist dasselbe wie bei den Metriken. Loki ergänzte die fehlende Hälfte – das Warum hinter den Zahlen – für etwa ein halbes Gigabyte RAM.


