30. Juli 2026 · 11 Min Lesezeit cronsystemd-timermonitoring

Cron vs. systemd-Timer: Was wann?

Mein Watch-&-Alert-Setup lief ein Jahr lang mit klassischen Cronjobs. Drei Einträge in der Crontab, fertig. Bis ein Montagmorgen kam, an dem der Heartbeat zwei Stunden lang fehlte und ich es erst durch die fehlende Telegram-Nachricht gegen Mittag bemerkt habe. Das war der Punkt, an dem ich mich ernsthaft mit systemd-Timern beschäftigt habe. Heute, sechs Monate später, läuft das gesamte Scheduling über systemd. Aber nicht jeder Job ist dort besser aufgehoben. In diesem Artikel zeige ich dir, wo die Grenzen zwischen Cron und systemd-Timer in einem selbstgebauten Monitoring-System verlaufen — und wie ich konkret von Crontab auf systemd migriert habe, ohne dass der Aufwand den Nutzen übersteigt.

Ausgangslage

Mein Watch-&-Alert-System läuft auf einem kleinen VPS bei Hetzner, Debian 12, 4 GB RAM, Ollama lokal für die LLM-Klassifikation. Gemonitored werden rund 30 RSS- und Atom-Feeds aus den Bereichen IT-Security, Python-Ökosystem und Open-Source-Tools. Dazu kommen drei Webhook-Quellen für GitHub-Releases und CVE-Feeds. Pro Tag gehen im Schnitt fünf bis fünfzehn Alerts raus, abends oft mehr, weil dann die Mailinglisten-Feeds reinkommen.

Die Aufgaben, die das System regelmäßig erledigen muss, sind klar umrissen: Erstens ein Feed-Poll alle 15 Minuten, der neue Einträge aus RSS/Atom/Webhook holt, in SQLite speichert, LLM klassifiziert und bei Treffer einen Alert über Telegram oder ntfy.sh rausjagt. Zweitens ein Heartbeat alle 5 Minuten — eine stille "Ich lebe noch"-Nachricht, die signalisiert, dass der Service noch hängt; falls sie ausbleibt, schickt ntfy.sh eine Warnung an mich. Drittens ein Nightly Digest um 23:00 Uhr, der die Tageszusammenfassung als E-Mail verschickt, damit ich morgens direkt sehe, was passiert ist.

Ein Jahr lang lief das Ganze mit einer Crontab, die ungefähr so aussah:

*/15 * * * * /opt/watch-alert/.venv/bin/python /opt/watch-alert/poll.py >> /var/log/watch-alert/poll.log 2>&1
*/5 * * * * /opt/watch-alert/.venv/bin/python /opt/watch-alert/heartbeat.py >> /var/log/watch-alert/heartbeat.log 2>&1
0 23 * * * /opt/watch-alert/.venv/bin/python /opt/watch-alert/digest.py >> /var/log/watch-alert/digest.log 2>&1

Funktioniert. Sieht aufgeräumt aus. Hat aber über die Monate drei Probleme angehäuft, die mir auf die Nerven gegangen sind.

Problem 1: Stille Ausfälle. Cron verschickt von sich aus keine Benachrichtigung, wenn ein Job nicht startet oder crasht. Wenn /usr/sbin/cron hängt, der Disk voll ist oder der Python-Prozess segfaultet, merkt das niemand. Mein Heartbeat vom 5-Minuten-Intervall war einmal drei Tage lang ausgefallen, ohne dass ich es bemerkt habe — weil der Heartbeat selbst der Job war, der hätte meckern müssen. Klassischer Single Point of Failure.

Problem 2: Keine Abhängigkeiten. Watch & Alert braucht für den Poll eine Netzwerkverbindung UND einen laufenden Ollama-Daemon. Wenn der Ollama-Service nach einem Update noch nicht wieder hochgefahren ist und der Cronjob trotzdem pollt, hagelt es 500er-Fehler, die ich erst im Logfile entdecke. Bei cron musst du das im Skript selbst prüfen — und dann hoffst du, dass deine Reihenfolge stimmt.

Problem 3: Logging war ein Flickwerk. >> /var/log/watch-alert/poll.log 2>&1 rotiert nicht von alleine, wächst ewig und ist nicht durchsuchbar. Ich hatte am Ende drei Textdateien, in denen ich mit grep und tail nach Fehlern suchen musste, und keine Ahnung, wann der letzte Lauf erfolgreich war.

Anforderungen

Bevor ich migriere, musste ich klären, was ich vom neuen Setup erwarte. Hier meine Liste, niedrigschwellig genug für ein Ein-Personen-Projekt, aber konkret genug, dass ich danach messen kann, ob die Migration etwas gebracht hat.

  • Beobachtbarkeit: Ich will sehen, wann ein Job gelaufen ist, wann er fehlgeschlagen ist und warum. Ein einzelnes journalctl -u watch-alert-poll soll reichen, ohne durch Textdateien zu greppen.
  • Abhängigkeiten: Wenn Ollama nicht läuft, soll der Poll-Job gar nicht erst starten — oder zumindest klar markiert sein, dass er auf eine Abhängigkeit wartet.
  • Stille Fehler abfangen: Wenn der Job mehrfach hintereinander fehlschlägt, will ich eine Benachrichtigung über ntfy.sh. Nicht erst nach drei Tagen, wenn zufällig jemand das fehlende Telegram-Update bemerkt.
  • Backoff bei Misserfolg: Wenn der RSS-Feed-Server kurz down ist, soll der nächste Versuch nicht sofort wieder donnern, sondern etwas warten.
  • Migration darf nicht komplexer werden. Wenn das Setup danach mehr Aufwand ist als vorher, ist es die Migration nicht wert.

Diese Anforderungen sind bewusst keine Enterprise-Vorgaben. Ich will keine zentrale Monitoring-Plattform, keine Prometheus-Exporter, keine sechstelligen Schadenssummen, die ein Ausfall verursacht hätte. Nur ein solides Scheduling, das mir nicht im Hintergrund wegstirbt.

Drei Optionen im Vergleich

Ich habe drei Wege ernsthaft erwogen: Cron beibehalten, systemd-Timer einsetzen, oder eine Mischung aus beidem. Hier mein ehrlicher Vergleich.

Option 1: Cron beibehalten, aber härten

Man könnte die Cronjobs mit Wrapper-Skripten panzern: Healthcheck vor dem Lauf, strukturierte Logs nach /var/log/syslog, Deadman-Switch via zweitem Cron, der den ersten überwacht, Lock-Dateien gegen Parallelität. Das funktioniert technisch, hat aber den Charakter einer Flickwerk-Lösung. Du baust dir dein eigenes Mini-Scheduling-System, das die gleichen Eigenschaften wie systemd-Timer nachbildet — nur schlechter, weil es nicht in den Init-Prozess integriert ist und keine eingebauten Werkzeuge für Restart-Logik oder Abhängigkeiten mitbringt. Für ein Indie-Projekt mit zwei bis drei Jobs ist das schlicht zu viel Aufwand.

Option 2: Komplett auf systemd-Timer umsteigen

systemd-Timer ersetzen Cronjobs durch .service- und .timer-Units. Der Timer triggert den Service, der Service führt den Job aus. Vorteile: native Integration in journald, Abhängigkeiten über Requires=, After=, eigene Restart-Logik mit Restart=on-failure, Exponential Backoff via RestartSec=, und die Möglichkeit, einen Job manuell mit systemctl start zu testen, ohne den Timer zu manipulieren. Nachteile: Die Syntax der Unit-Dateien ist am Anfang gewöhnungsbedürftig, und du brauchst pro Job zwei Dateien. Bei drei Jobs also sechs Dateien, plus das Verzeichnis-Layout unter /etc/systemd/system/.

Option 3: Mischbetrieb

Klassische, einfache Jobs — etwa ein Skript, das einmal am Tag ein Verzeichnis aufräumt oder einen Cache leert — bleiben in Cron. Komplexe, beobachtungspflichtige Jobs wandern zu systemd. Das ist pragmatisch und reduziert Migrationsaufwand. Genau diesen Weg bin ich gegangen.

Gegenüberstellung

Kriterium Cron systemd-Timer Mischbetrieb
Lernkurve minimal mittel mittel
Logging manuell journald eingebaut beides
Abhängigkeiten im Skript deklarativ gemischt
Fehlerbenachrichtigung manuell via OnFailure= gemischt
Aufwand bei 3 Jobs gering mittel gering bis mittel
Beobachtbarkeit schlecht gut gut für kritische Jobs
Backoff bei Fehler selbst bauen eingebaut gemischt
Persistent nach Reboot nein ja (Persistent=true) gemischt

Für mein Watch-&-Alert-Setup war am Ende die Mischung aus Aufwand und Nutzen der entscheidende Punkt. systemd-Timer sind nicht per se besser — sie sind besser für Jobs, die ich aktiv beobachten will. Bei einem reinen Aufräum-Skript ist dieser Mehrwert nicht vorhanden.

Die Lösung: Migration zu systemd

Die Migration lief in drei Schritten: Heartbeat zuerst, weil er am kritischsten ist. Dann der Poll-Job, weil er die meisten Abhängigkeiten hat. Den Nightly Digest habe ich vorerst in Cron gelassen, weil er niedrigfrequent läuft und kein Echtzeit-Monitoring braucht.

Schritt 1: Heartbeat als systemd-Unit

Zuerst die Service-Unit. Sie ruft das Python-Skript auf, das einen Timestamp in SQLite schreibt und bei Erfolg eine stille ntfy.sh-Nachricht an einen separaten Topic sendet. Letzteres ist nicht zwingend nötig, hilft aber beim Debuggen.

# /opt/watch-alert/heartbeat.py
from __future__ import annotations

import logging
import sqlite3
from datetime import datetime, timezone
from pathlib import Path

import httpx

DB_PATH = Path("/opt/watch-alert/data/watch.db")
NTFY_TOPIC = "watch-alert-heartbeat"

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger(__name__)


def record_heartbeat(conn: sqlite3.Connection) -> None:
    conn.execute(
        "INSERT INTO heartbeats (ts) VALUES (?)",
        (datetime.now(timezone.utc).isoformat(),),
    )
    conn.commit()


def notify_alive() -> None:
    try:
        httpx.post(
            f"https://ntfy.sh/{NTFY_TOPIC}",
            data="heartbeat ok",
            timeout=5.0,
        )
    except httpx.HTTPError as exc:
        log.warning("ntfy notify failed: %s", exc)


def main() -> int:
    with sqlite3.connect(DB_PATH) as conn:
        record_heartbeat(conn)
    notify_alive()
    log.info("heartbeat written")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Dazu die Service-Unit unter /etc/systemd/system/watch-alert-heartbeat.service:

[Unit]
Description=Watch & Alert Heartbeat
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
WorkingDirectory=/opt/watch-alert
ExecStart=/opt/watch-alert/.venv/bin/python /opt/watch-alert/heartbeat.py
User=watchalert
Restart=on-failure
RestartSec=30

[Install]
WantedBy=multi-user.target

Und der Timer unter /etc/systemd/system/watch-alert-heartbeat.timer:

[Unit]
Description=Watch & Alert Heartbeat alle 5 Minuten

[Timer]
OnCalendar=*:0/5
Persistent=true
AccuracySec=10s

[Install]
WantedBy=timers.target

OnCalendar=*:0/5 heißt: jede volle Stunde zur 0., 5., 10., … Minute. Persistent=true sorgt dafür, dass verpasste Läufe nachgeholt werden, falls der Server zum Trigger-Zeitpunkt aus war. AccuracySec=10s definiert das Zeitfenster, in dem systemd den Timer auslösen darf — wichtig, weil systemd sonst versucht, den Job millisekundengenau zu starten, was bei OnCalendar-Triggern gar nicht nötig ist und nur Akku bzw. CPU verschwendet.

Aktivieren und starten:

sudo systemctl daemon-reload
sudo systemctl enable --now watch-alert-heartbeat.timer

Status prüfen geht jetzt mit zwei Befehlen, und das ist der Punkt, an dem systemd für mich gewonnen hat:

systemctl list-timers --all | grep watch-alert
journalctl -u watch-alert-heartbeat.service -n 50 --no-pager

Der erste listet alle Timer mit dem nächsten Trigger-Zeitpunkt und dem letzten Lauf. Der zweite zeigt die Logs des konkreten Services mit Timestamp, ohne dass ich in Textdateien suchen muss. Diese zwei Befehle haben bei mir die durchschnittliche Debug-Zeit pro Vorfall halbiert.

Schritt 2: Poll-Job mit Abhängigkeit zu Ollama

Der Poll-Job ist komplexer. Er braucht eine Netzwerkverbindung und einen laufenden Ollama-Daemon. In der Service-Unit lässt sich das deklarativ abbilden:

[Unit]
Description=Watch & Alert Feed-Poll
After=network-online.target ollama.service
Requires=ollama.service
OnFailure=watch-alert-failure-notify.service

[Service]
Type=oneshot
WorkingDirectory=/opt/watch-alert
ExecStart=/opt/watch-alert/.venv/bin/python /opt/watch-alert/poll.py
User=watchalert
Restart=on-failure
RestartSec=60
StartLimitBurst=3
StartLimitIntervalSec=600

[Install]
WantedBy=multi-user.target

StartLimitBurst=3 in Verbindung mit StartLimitIntervalSec=600 heißt: Wenn der Job innerhalb von 10 Minuten dreimal hintereinander crasht, wird er nicht weiter automatisch neu gestartet. Das verhindert, dass ein kaputter Ollama-Container das System mit Fehlversuchen flutet und gleichzeitig das Journal mit identischen Stacktraces zumüllt.

OnFailure=watch-alert-failure-notify.service ist der eigentliche Game-Changer. Diese Direktive sorgt dafür, dass beim Erreichen des Start-Limits ein zweiter Service getriggert wird, der eine priorisierte ntfy.sh-Nachricht mit hoher Priorität verschickt. Das ist die Deadman-Switch, die ich vorher bei Cron von Hand bauen musste, und sie funktioniert ohne zusätzliches Skript.

Der Timer dazu ist analog zum Heartbeat, nur mit anderem Intervall:

[Unit]
Description=Watch & Alert Poll alle 15 Minuten

[Timer]
OnCalendar=*:0/15
Persistent=true
AccuracySec=30s

[Install]
WantedBy=timers.target

Schritt 3: Nightly Digest bleibt in Cron

Der Digest-Job ist niedrigfrequent, läuft nur einmal am Tag, und ein verpasster Lauf um 23:00 Uhr lässt sich am nächsten Morgen manuell nachholen. Hier ist der Mehraufwand von zwei Unit-Dateien nicht gerechtfertigt. Bleibt in Cron, ein Wrapper-Skript prüft nur, ob die SQLite-DB lesbar ist und schreibt den Lauf in eine kleine Logdatei.

0 23 * * * /opt/watch-alert/.venv/bin/python /opt/watch-alert/digest.py >> /var/log/watch-alert/digest.log 2>&1

Schritt 4: Monitoring der Timer selbst

Cron-Ausfälle waren mein Hauptproblem. Also überwache ich jetzt die Timer selbst: Ein zweiter systemd-Timer läuft alle 30 Minuten und prüft via systemctl is-active, ob die kritischen Units in den letzten 30 Minuten gelaufen sind. Das klingt paranoid, ist aber effektiv. Dieser eine Timer hat mich schon zweimal gerettet, wenn ein Update den Ollama-Service zerschossen hat und der Heartbeat nicht mehr durchkam.

# /opt/watch-alert/timer_monitor.py
from __future__ import annotations

import logging
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path

import httpx

NTFY_TOPIC = "watch-alert-meta"
MAX_AGE_MINUTES = 30

log = logging.getLogger("watch-alert.meta")


def last_run_age(unit: str) -> timedelta | None:
    result = subprocess.run(
        [
            "systemctl",
            "show",
            unit,
            "--property=ActiveEnterTimestamp",
            "--value",
        ],
        check=True,
        capture_output=True,
        text=True,
    )
    raw = result.stdout.strip()
    if not raw:
        return None
    last = datetime.fromisoformat(raw)
    return datetime.now(timezone.utc) - last


def alert(text: str) -> None:
    httpx.post(
        f"https://ntfy.sh/{NTFY_TOPIC}",
        data=text,
        headers={"Priority": "high", "Tags": "warning"},
        timeout=5.0,
    )


def main() -> int:
    critical = [
        "watch-alert-heartbeat.service",
        "watch-alert-poll.service",
    ]
    for unit in critical:
        age = last_run_age(unit)
        if age is None or age > timedelta(minutes=MAX_AGE_MINUTES):
            alert(f"{unit} last run {age} ago")
            log.warning("%s stale: %s", unit, age)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Was wir gelernt haben

Erkenntnis 1: systemd-Timer sind kein Allheilmittel. Für triviale Jobs mit niedriger Frequenz ist die Crontab weiterhin schneller aufgesetzt und einfacher zu lesen. Die Migration lohnt sich nur dort, wo Beobachtbarkeit und Abhängigkeiten wichtig sind. Wer seinen Nightly Digest in einen systemd-Timer umzieht, nur weil es "moderner" klingt, hat Aufwand ohne Nutzen.

Erkenntnis 2: OnFailure= ist der eigentliche Killer-Feature. Damit wird aus einer geplanten Aufgabe ein selbstüberwachender Service. In meinem Watch-&-Alert-Setup hat das die mittlere Reaktionszeit von "am nächsten Werktag" auf "wenige Minuten" reduziert, weil ich jetzt eine Push-Nachricht bekomme, sobald das Start-Limit gerissen ist.

Erkenntnis 3: Persistent=true ist auf Servern mit unregelmäßiger Laufzeit Gold wert. Mein kleiner VPS wird hin und wieder für Wartungsarbeiten heruntergefahren. Ohne Persistent=true wären die 15-Minuten-Poll-Slots während der Downtime verloren. Mit der Direktive werden sie beim nächsten Start nachgeholt — allerdings nur ein einzelner Lauf pro Timer, nicht eine ganze Serie.

Erkenntnis 4: journald-Logging ist nicht perfekt, aber gut genug. Die Standardansicht ist auf 1000 Zeilen begrenzt und du brauchst --since, um alte Einträge zu sehen. Für mehr Komfort habe ich auf meinem VPS journald-Logs dauerhaft aktiviert (Storage=persistent in /etc/systemd/journald.conf). Kostet ein paar hundert MB Plattenplatz, lohnt sich aber, weil ich nicht mehr raten muss, ob ein Logeintrag von gestern oder von vor drei Wochen ist.

Erkenntnis 5: Die Mischung aus beiden Welten ist legitim und kein Zeichen von Faulheit. Wenn ein Setup seine Aufgabe erfüllt und nicht ständig Aufmerksamkeit braucht, ist es gut aufgesetzt. Bei mir laufen zwei Timer über systemd, ein Job bleibt in Cron, und alle drei funktionieren seit sechs Monaten ohne Eingriff. Die Versuchung, alles in ein einziges System zu pressen, ist oft größer als der tatsächliche Nutzen.

Bezug zum Buch

Kapitel 14 ("Automatisieren") im Watch-&-Alert-Buch behandelt genau diesen Trade-off zwischen Cron und systemd. Es zeigt am konkreten Setup des Buchprojekts, wie das Poll-Skript, der Heartbeat und der Digest zusammen als System agieren — und welche Designentscheidungen beim Wechsel von Cron zu systemd eine Rolle spielen. Wenn du die Unit-Dateien aus diesem Artikel im Buch nachschlagen willst, findest du sie im Repository unter deploy/systemd/. Das Buch geht außerdem ausführlich auf das Heartbeat-Pattern ein, das hier nur am Rand erwähnt wurde: ein kleines Skript, das den eigenen Timer überwacht und bei Ausfall eine Eskalation auslöst — quasi ein Meta-Timer. Das gleiche Pattern verwende ich in Schritt 4 dieses Artikels, nur kompakter. Wer das Buchprojekt 1:1 nachbauen will, sollte mit dem Heartbeat starten, weil dort der meiste Lerneffekt steckt.

Wann du das nachbauen solltest — und wann nicht

Du solltest auf systemd-Timer umsteigen, wenn du mehrere kritische Jobs hast, die du aktiv beobachten willst, und wenn du bereits mit systemd arbeitest — also Debian, Ubuntu, Fedora, Arch, quasi jedem modernen Linux-Server. Wenn du merkst, dass du in deinen Cronjob-Wrappern langsam eine eigene Mini-Scheduling-Schicht nachbaust, ist der Punkt erreicht, an dem systemd die bessere Wahl ist.

Wenn du hingegen einen Raspberry-Pi-Cluster hast, auf dem ein einzelner Cronjob läuft und ein Ausfall dich nicht interessiert, bleib bei Cron. Der Aufwand, sechs Unit-Dateien zu schreiben, zu aktivieren und zu testen, zahlt sich nur aus, wenn du die Beobachtbarkeit auch tatsächlich nutzt.

Für mein Watch-&-Alert-Projekt war der Wechsel sinnvoll, weil er genau die Punkte adressiert hat, die mir vorher Sorgen gemacht haben: stille Ausfälle, fehlende Abhängigkeiten, fragmentiertes Logging. Drei Jobs laufen jetzt sauberer, einer läuft weiterhin in Cron, und ich habe keine schlaflosen Nächte mehr wegen eines Heartbeats, der leise verschwunden ist. Ob das auch für dich gilt, hängt davon ab, wie viel Aufwand du in Beobachtung investieren willst — und wie viele Stunden Schlaf du verlierst, wenn nachts mal ein Job ausfällt.