Nach dem Umstieg auf Alloy wirkte ein Domaincontroller im Logging tot: drei Minuten keine neue Zeile, kein Ausschlag in der Rate. Auf dem Host lief alles: Samba, DNS, Anmeldungen. Fast wäre ein gesunder Server zurückgerollt worden. Auf einem anderen Host startete der Agent gar nicht erst.

Promtail ist am 2. März 2026 End-of-Life gegangen. Bei zwölf gewachsenen Hosts ist der Wechsel kein Abendprojekt. Zwei Fehler daraus stehen in keinem Migrationsleitfaden; der zweite sieht nach einem Rechteproblem aus, ist aber eines an der falschen Stelle.

Die Ausgangslage

Zwölf Hosts schicken Journal- und Container-Logs an einen Loki 3.4.2. Auf elf davon lief Promtail 3.4.2, der zwölfte hatte nie einen Agenten. Darunter sind zwei Domaincontroller, der Logserver selbst läuft als Container.

Die Migration lief Host für Host: Promtail stoppen, Alloy 1.20.0-1 (damals aktuell, heute 1.21.1) installieren, konvertieren, starten, prüfen. Das Skript hat einen --rollback-Pfad. Er lief einmal und funktionierte, ausgelöst von einer fehlenden Messgröße, nicht von einem Agentenfehler. Die Promtail-Positionsdatei läuft währenddessen nicht weiter, deshalb liest Promtail danach ab dem alten Cursor: kein Datenverlust, aber doppelter Ingest.

Ein Domaincontroller schreibt stoßweise, minutenlange Ruhe ist normal; kurze Messfenster sind wertlos.

Der Umstieg

alloy convert \
  --source-format=promtail \
  --output=/etc/alloy/config.alloy \
  /etc/promtail/config.yml

convert ist Public Preview und laut Doku auf Promtail 2.8.x bezogen; für 3.x war das ein Anfang, kein Ersatz. Positionsdateien übernimmt der Konverter nicht. Für Journal braucht es legacy_position:

loki.source.journal "journal" {
  max_age    = "2h"   # vor dem Vorfall: 12h, 1:1 aus Promtail
  labels     = { host = "dc-01", job = "systemd-journal" }
  forward_to = [loki.write.default.receiver]

  legacy_position {
    file = "/var/lib/promtail/positions.yaml"
    name = "journal"   # Promtail-Schlüssel: cursor-journal
  }
}

Ein Doku-Hinweis, der im Betrieb als „Agent tot“ ankommt: loki.source.journal startet ohne Fehler und liefert null Zeilen, wenn der Benutzer nicht in adm und systemd-journal ist.

Dazu kamen Drop-ins, die den Agenten im Zaum halten:

# /etc/systemd/system/alloy.service.d/10-limits.conf
[Service]
MemoryHigh=384M
MemoryMax=512M
Environment=GOMEMLIMIT=320MiB
systemctl daemon-reload
systemctl restart alloy

GOMEMLIMIT begrenzt den Speicher, den die Go-Runtime verwaltet, nicht den RSS des Prozesses. Zwischen Limit und MemoryMax bleibt deshalb Luft, hier rund 20 Prozent Reserve: 320 MiB gegen 512 MB.

Fehler 1: Der Cursor landet unter dem falschen Label

Während des Rollouts meldete Loki entry too far behind: Der Agent las, Loki nahm einen Teil nicht an. Kurz nach dem Start zeigte er 3570 Zeilen in 43 Sekunden, einen Nachlauf über das ganze Journal.

Die Ursache steckt in der Positionsdatei. Alloy speichert den Journal-Fortschritt unter einem Schlüssel aus Pfad und Label-Satz. Der Konverter schreibt ihn mit einem leeren Label-Objekt:

positions:
  ? path: cursor-loki.source.journal.journal
    labels: "{}"
  : "s=96c0...;i=25557;..."

Der Tailer liest mit einem leeren String:

positions:
  ? path: cursor-loki.source.journal.journal
    labels: ""
  : "s=96c0...;i=25557;..."

Für eine YAML-Map sind das zwei verschiedene Schlüssel. Der Cursor ist da, wird aber nicht gefunden; der Reader startet bei max_age erneut. Im Quellcode: Die Konvertierung setzt Labels: "{}", der Tailer ruft pos.GetString(key, "").

Warum Loki einen Teil davon wegwirft

entry too far behind klingt nach reject_old_samples_max_age, dem klassischen Alterslimit. Das war es nicht. GET /config zeigt alle drei relevanten Werte unverändert auf Default: max_chunk_age: 2h0m0s, reject_old_samples_max_age: 1w, unordered_writes: true.

Abgelehnt wurde nach der Reihenfolge im Stream, nicht nach der Uhr. Bei unordered_writes prüft der Ingester gegen den höchsten angenommenen Zeitstempel:

cutoff = höchster Zeitstempel im Stream - max_chunk_age / 2

Mit max_chunk_age: 2h ist das Fenster eine Stunde. Im Log stand der konkrete Wert:

entry too far behind, entry timestamp is: 2026-10-22T12:01:42Z,
oldest acceptable timestamp is: 2026-10-22T15:29:12Z

Der Stream stand bei 16:29, die Grenze also bei 15:29. Sechs Minuten später lag sie bei 15:35: Sie wandert mit dem Stream. Die 3,5 Stunden zwischen abgelehntem Eintrag und Grenze sind die Länge des Nachlaufs, keine Frist. reject_old_samples_max_age war bedeutungslos, die Einträge waren viereinhalb Stunden alt, nicht eine Woche.

Der Regler wäre ingester.max_chunk_age: Er bestimmt die Chunk-Größe und damit die Hälfte des Akzeptanzfensters. Höher heißt mehr Toleranz und größere Chunks. unordered_writes: false würde jeden verspäteten Eintrag verwerfen und das Problem verschärfen. Sauber bleibt nur, den Nachlauf zu verhindern oder ihn über max_age kurz zu halten.

Warum der Nachlauf bei 12:01 stand

Der erste Lauf hatte max_age = "12h", 1:1 aus der Promtail-Konfiguration übernommen. Der Reader begann zwölf Stunden vor dem Start und war um 16:29 bis 12:01 vorgerückt. Danach wurde max_age auf 2h gesenkt. Die zwei Stunden in der heutigen Konfiguration sind die Reaktion auf den Fehler, nicht seine Ursache.

Der Seed

Die Positionsdatei muss vor dem ersten Start mit dem richtigen Schlüssel existieren. Der Cursor steht in der alten Promtail-Datei:

positions:
  /var/log/syslog: "0"
  cursor-journal: s=21df095c726644258ae3603769bea91c;i=157556;b=589ed943;m=10c06b;t=65e705;x=89a29

Daraus wird die neue Datei gebaut:

CURSOR=$(sed -n 's/^ *cursor-journal: *//p' /var/lib/promtail/positions.yaml | tr -d '"')
[ -n "$CURSOR" ] || { echo "Cursor nicht gefunden"; exit 1; }

install -d -m 0770 -o alloy -g alloy /var/lib/alloy/data/loki.source.journal.journal
cat > /var/lib/alloy/data/loki.source.journal.journal/positions.yml <<EOF
positions:
  ? path: cursor-loki.source.journal.journal
    labels: ""
  : "$CURSOR"
EOF
chown alloy:alloy /var/lib/alloy/data/loki.source.journal.journal/positions.yml
chmod 0600 /var/lib/alloy/data/loki.source.journal.journal/positions.yml

Danach liest Alloy nur, was seit dem Promtail-Stopp dazukam. Wichtig ist die Reihenfolge: erst die Datei, dann den Dienst starten.

Zwei Warnungen. Erstens setzt der Befehl alloy als Dienstbenutzer voraus (systemctl show alloy -p User). Läuft Alloy per Drop-in als root, entsteht eine Datei root:root 0600 in einem Verzeichnis alloy:alloy 0770, also genau die Konstellation, die ohne CAP_DAC_OVERRIDE scheitert; ein als alloy laufender Agent könnte sie umgekehrt nicht lesen. Eigentümer und Gruppe gehören zum Dienstbenutzer. Zweitens: Existiert die Datei bereits, vorher sichern (cp -p … positions.yml.bak-$(date -u +%Y%m%dT%H%M%SZ)) und nach dem ersten Start nicht mehr überschreiben. Der Seed ist ein Startwert, kein Reparaturwerkzeug; ein zweites Schreiben setzt den Cursor zurück.

Fehler 2: CapabilityBoundingSet= sperrt das eigene Datenverzeichnis

Auf einem anderen Host war die Konfigurationsprüfung grün, und Alloy ging in den Restart-Loop:

mkdir /var/lib/alloy/data/remotecfg: permission denied

Die Paket-Unit bringt User=alloy mit. In dieser Installation läuft Alloy trotzdem als root, über ein eigenes Drop-in (20-run-as-root.conf), weil Promtail vorher als root lief und mehrere Log-Pfade für den alloy-Benutzer nicht lesbar sind, etwa /var/log/samba mit 0750 root:root.

/var/lib/alloy/data gehört alloy:alloy mit Modus 0770. root überspringt solche Prüfungen nur mit CAP_DAC_OVERRIDE. Ein Hardening-Drop-in mit CapabilityBoundingSet= nimmt genau diese Capability weg, dann gelten für root dieselben Prüfungen wie für jeden anderen. root ist weder Eigentümer noch in der Gruppe, also greifen die Other-Bits: null.

alloy validate merkt davon nichts: Es prüft Syntax und Graph, öffnet das Datenverzeichnis aber nicht. Der Absturz kommt beim ersten Schreibzugriff.

Der Ausweg ist eine Entscheidung. Entweder User=alloy mit Mitgliedschaft in adm und systemd-journal plus Leserechten auf jedem Log-Pfad, oder root ohne Capability-Beschneidung. Der erste Weg hat den kleineren Rechteumfang und ist der übliche; er kostet Gruppenpflege. Der zweite ist ein 1:1-Abbild des Promtail-Verhaltens und war hier die pragmatische Wahl. Was nicht geht: root laufen lassen und ihm die Capability nehmen, die es zum Schreiben in ein fremdes Verzeichnis braucht.

Die Messfalle: drei Minuten sagen nichts

Ob der DC liefert, sollte eine Zeilenrate der letzten Minuten zeigen:

sum(count_over_time({host="dc-01", job="systemd-journal"}[3m]))

Das Ergebnis lag nahe bei null; ein gesunder DC wäre fast zurückgerollt worden. Der Wert war wertlos: Samba schreibt stoßweise; drei Minuten ohne Anmeldung sind normal. Gemessen wird die Arbeitslast, nicht der Agent.

Belastbar waren drei Prüfungen:

  1. Baseline je Job über 30 Minuten. Nicht die Summe über alle Jobs, denn ein lauter Job verdeckt einen stillen.
sum by (job) (count_over_time({host="dc-01"}[30m]))
  1. Cursor-Vergleich. Der Cursor in /var/lib/alloy/data/loki.source.journal.journal/positions.yml muss sich bewegen und hinter dem alten Promtail-Cursor liegen. Ein stehender Cursor bei laufendem Dienst ist ein klarer Fehler, unabhängig von der Rate.

  2. 10-Minuten-Prüfung nach dem Start. Kein zweiter Nachlese-Schub, keine neuen entry too far behind-Einträge, loki_discarded_samples_total{reason="too_far_behind"} bleibt flach. Die Metrik entsteht serverseitig in Loki, nicht im Agenten; nach diesem Nachlauf stand sie bei 3483. Im getrennten Betrieb fragt man den Prozess ab, der prüft.

Was nach dem EOL wirklich bricht

Am 2. März 2026 fiel erst einmal nichts aus. Promtail ist ein statisches Binary und läuft weiter, bis eine Distribution es aus dem Repo nimmt oder eine Änderung an libc oder systemd es bricht. Neue Loki-Features erreichen es nicht mehr, Sicherheitsfixes auch nicht. Alloy bringt Pipeline-Komponenten mit echten Ausdrücken, OTEL-Empfang, Metriken und Traces in einer Pipeline und eine Positionsverwaltung, die auch Docker-Cursor je Container hält. Wer nur Logs schickt und stabile Hosts hat, kann warten, solange das Paket noch da ist. Wer ohnehin neu baut, macht es in einem Zug.

Lessons Learned

  • Positionsdateien sind Teil der Migration. Der Konverter übersetzt Pipelines, aber nicht den Zustand. Ohne Cursor fängt der Agent bei max_age wieder an, und Loki wirft weg, was älter ist als der halbe max_chunk_age unter dem Stream-Kopf. Messbar: 3570 Zeilen in 43 Sekunden, davon 3483 verworfen.
  • Ein leerer String und ein leeres Objekt sind in YAML nicht dasselbe. Der Cursor war da, nur unter labels: "{}" statt labels: "". Der Blick in den Alloy-Quellcode half mehr als jede Log-Zeile.
  • Capability-Hardening und Dateirechte gehören zusammen geplant. CapabilityBoundingSet= nimmt root die Fähigkeit, Rechte zu überspringen; ein Datenverzeichnis eines anderen Benutzers wird unschreibbar. Sichtbar erst beim ersten Schreibzugriff, nicht bei alloy validate.
  • Kurze Zeilenraten sind bei stoßweisen Logs kein Gesundheitsindikator. Ein stiller DC sieht aus wie ein toter Agent.
  • Der Rollback war getestet und lief sauber. Er holt trotzdem keine Zeit zurück: Promtail liest ab dem alten Cursor, Loki nimmt die Duplikate an.

Wer die Pipeline neu aufbaut, findet Loki, Alloy, Retention und Memory-Limits im Leitfaden für zentrales Logging. Wie ein einzelner Host das Ingest-Budget auffrisst, steht im Artikel über die Nextcloud-Logschleife.

Checkliste

  • Promtail-Positionsdatei vor dem Umbau gesichert
  • Cursor-Schlüssel verglichen: labels: "" gegen labels: "{}"
  • max_age bewusst gesetzt, nicht die 12h aus Promtail übernommen
  • Positionsdatei vor dem ersten Start geseedet, Eigentümer passend zu systemctl show alloy -p User
  • Hardening-Drop-ins gegen das Datenverzeichnis geprüft: Wer schreibt, wem gehört es, welche Capability braucht er?
  • Bei User=alloy: Mitgliedschaft in adm und systemd-journal
  • GOMEMLIMIT mit rund 20 Prozent Reserve unter MemoryMax
  • Baseline je Job über 30 Minuten vor dem Wechsel notiert
  • Nach 10 Minuten: Cursor wandert, kein zweiter Nachlese-Schub, loki_discarded_samples_total{reason="too_far_behind"} flach