Logging, Locking, Monitoring und Exit-Codes in der Praxis
Ein Cronjob-Eintrag in der Crontab ist nur der Anfang. Ohne Locking laufen Jobs parallel, ohne Logging verschwinden Fehler, ohne Monitoring läuft ein ausgefallener Cronjob wochenlang unbemerkt. flock, strukturiertes Logging, Exit-Code-Checks und systemd-Timer verwandeln fragile Cron-Aufrufe in zuverlässige, beobachtbare Automatisierungseinheiten.
Inhaltsverzeichnis
- 1. Die häufigsten Cronjob-Probleme in der Praxis
- 2. Locking mit flock — parallele Ausführung verhindern
- 3. Strukturiertes Logging für Cronjobs
- 4. Exit-Codes richtig setzen und auswerten
- 5. Mailbenachrichtigung und Alerting
- 6. Crontab-Hygiene und Umgebungsvariablen
- 7. systemd-Timer als moderne Cron-Alternative
- 8. cron vs. systemd-Timer im Vergleich
- 9. Monitoring: Cronjob-Ausführung extern überwachen
- 10. Zusammenfassung
- 11. FAQ
1. Die häufigsten Cronjob-Probleme in der Praxis
Der typische Cronjob in der Produktion sieht so aus: Ein einzeiliger Crontab-Eintrag ruft ein Shell-Skript auf, das Skript liest keine Ausgabe, der Exit-Code interessiert niemanden, und ob der Job tatsächlich regelmäßig läuft, merkt man erst, wenn die Backup-Wiederherstellung fehlschlägt. Dieses Szenario ist kein Extremfall – es ist die Norm in vielen Infrastrukturen. Cronjobs werden einmal eingerichtet und danach nicht mehr aktiv überwacht, weil scheinbar alles läuft.
Das erste klassische Problem sind parallele Ausführungen. Wenn ein Cronjob länger läuft als sein Intervall – etwa weil die Datenmenge gewachsen ist oder ein Netzwerkproblem für Verzögerungen sorgt – startet cron eine zweite Instanz, während die erste noch läuft. Zwei parallele Backup-Jobs schreiben in dieselbe Datei. Zwei parallele Datenbank-Migrationsjobs erzeugen Race Conditions. Das Ergebnis ist Datenverlust oder Datenverfälschung, und niemand weiß, warum. Das zweite Problem ist das Verschwinden von Fehlern: Cron sendet die Ausgabe von Cronjobs per Mail an den lokalen System-User, was auf Produktionsservern meist niemand liest oder was wegen fehlender Mailkonfiguration still verworfen wird.
Das dritte Problem betrifft die Ausführungsumgebung. Der Cron-Daemon startet Cronjobs mit einer minimalen Umgebung: kein PATH jenseits der System-Standardverzeichnisse, keine SSH-Umgebungsvariablen, kein User-.bashrc. Ein Skript, das im interaktiven Terminal mit dem vollen User-Umfeld läuft, kann im Cron-Kontext still scheitern, weil mysql, php oder ein anderes Werkzeug nicht im minimalen PATH gefunden wird. Diese Klasse von Fehlern ist besonders heimtückisch, weil sie sich im manuellen Test nicht reproduzieren lässt.
2. Locking mit flock — parallele Ausführung verhindern
Das zuverlässigste Werkzeug gegen parallele Cronjob-Ausführungen ist flock, ein POSIX-konformes Advisory-Lock-Tool. Das Prinzip: Das Skript öffnet eine Lock-Datei als Dateideskriptor und ruft flock -n darauf auf. Wenn keine andere Instanz die Sperre hält, wird sie erworben und der Job läuft. Wenn eine andere Instanz läuft, gibt flock -n sofort mit Exit-Code 1 zurück, und der neue Aufruf beendet sich sauber. Der entscheidende Vorteil gegenüber PID-File-Mustern: Das Betriebssystem gibt die Sperre automatisch frei, wenn der Prozess endet – egal ob durch normales Beenden, durch ein Signal oder durch einen Crash. Veraltete Lock-Dateien, die manuelle Bereinigung erfordern, gibt es nicht.
Eine elegante Variante ist der direkte flock-Aufruf in der Crontab, ohne den Code des Skripts zu verändern: flock -n /var/lock/backup.lock /usr/local/bin/backup.sh. So lässt sich jeder bestehende Cronjob mit einem einzelnen Wort sichern. Wenn das Skript selbst eine Fehlermeldung ausgeben soll, wenn es die Sperre nicht bekommt, empfiehlt sich die interne Variante mit einem dedizierten Dateideskriptor. Mit dem optionalen Timeout-Parameter flock -w 10 wartet flock bis zu zehn Sekunden auf die Freigabe der Sperre, bevor es mit Fehler abbricht – nützlich, wenn kurze Überlappungen tolerierbar sind, lange Parallelläufe aber nicht.
#!/usr/bin/env bash
# backup.sh — Cronjob with flock locking and structured logging
set -euo pipefail
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
readonly LOCK_FILE="/var/lock/${SCRIPT_NAME%.sh}.lock"
readonly LOG_DIR="/var/log/cronjobs"
readonly LOG_FILE="${LOG_DIR}/${SCRIPT_NAME%.sh}-$(date +%Y%m%d).log"
# Logging function: timestamp + level + message to log file and stderr
log() {
local level="$1"; shift
local msg="$*"
local ts; ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '%s [%s] [%s] %s\n' "$ts" "$level" "$SCRIPT_NAME" "$msg" \
| tee -a "$LOG_FILE" >&2
}
# Ensure log directory exists
mkdir -p "$LOG_DIR"
# Acquire exclusive lock — exit silently if already running
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
log WARN "Another instance is already running — skipping this run"
exit 0
fi
log INFO "Job started (PID $$)"
trap 'log INFO "Job finished (exit $?)"' EXIT
# --- Main job logic here ---
log INFO "Starting database backup"
# pg_dump ... | gzip > /backups/db-$(date +%Y%m%d).sql.gz
log INFO "Database backup complete"
3. Strukturiertes Logging für Cronjobs
Die minimalste Form des Cronjob-Loggings ist eine Umleitung der Ausgabe in eine Datei: 0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1. Das ist besser als nichts, reicht aber für produktionsreife Cronjobs nicht aus. Ohne Zeitstempel in der Ausgabe ist es im Fehlerfall schwer zu sagen, wann genau etwas passiert ist. Ohne Log-Rotation füllt die Logdatei über Wochen den Speicher. Ohne strukturierte Log-Level ist es schwer, mit grep oder einem Log-Aggregator schnell zwischen normalen Informationsmeldungen und Fehlereinträgen zu unterscheiden.
Die professionelle Lösung ist eine dedizierte Logging-Funktion im Skript, die Timestamp, Level und Script-Name in jede Zeile einbaut, kombiniert mit täglicher Log-Rotation über logrotate. Das Muster tee -a "$LOG_FILE" >&2 schreibt jede Logzeile gleichzeitig in die Logdatei und auf stderr. Cron leitet stderr dann per Mail weiter – so gehen Fehlermeldungen nicht verloren. Für Cronjobs, die in systemd-Timer-Einheiten umgewandelt wurden, übernimmt das Journal automatisch alle Ausgaben und macht logrotate überflüssig.
4. Exit-Codes richtig setzen und auswerten
Exit-Codes sind die primäre Kommunikationsschnittstelle zwischen einem Cronjob und dem Monitoring-System. Der Exit-Code 0 bedeutet Erfolg, jeder Wert von 1–255 signalisiert einen Fehler. Cron selbst macht nichts mit Exit-Codes, aber Monitoring-Tools wie Nagios, Icinga, Prometheus-Alertmanager und Healthcheck-Dienste werten sie aus. Damit das funktioniert, muss jeder Cronjob seinen Exit-Code explizit setzen: exit 0 am Ende eines erfolgreichen Laufs, exit 1 bei allgemeinen Fehlern, und spezifische Codes für verschiedene Fehlerkategorien, wenn das Monitoring differenziert reagieren soll.
Das größte Risiko beim Setzen von Exit-Codes ist, dass set -e das Skript bei jedem Fehler abbricht, aber ohne trap den Exit-Code nicht sauber nach außen kommuniziert. Das korrekte Muster: eine Cleanup-Funktion per trap cleanup EXIT registrieren, die den Exit-Code in einer Variablen speichert, vor dem Bereinigen loggt und Monitoring-Webhooks aufruft. So enthält das Monitoring immer den tatsächlichen Exit-Code, auch wenn das Skript durch set -e abgebrochen wurde.
#!/usr/bin/env bash
# cron_wrapper.sh — Generic cronjob wrapper with exit code monitoring
set -euo pipefail
readonly JOB_NAME="${1:?Usage: $0 <job-name> <command...>}"
shift
readonly JOB_CMD=("$@")
readonly HEALTHCHECK_URL="${HEALTHCHECK_URL:-}" # Optional: healthchecks.io URL
readonly SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"
START_TS=$(date +%s)
EXIT_CODE=0
cleanup() {
EXIT_CODE=$?
local duration=$(( $(date +%s) - START_TS ))
if [[ $EXIT_CODE -eq 0 ]]; then
echo "[OK] ${JOB_NAME} finished in ${duration}s"
# Signal success to healthchecks.io
[[ -n "$HEALTHCHECK_URL" ]] && \
curl -fsS -m 5 "${HEALTHCHECK_URL}" -d "OK: ${JOB_NAME} (${duration}s)" || true
else
echo "[FAIL] ${JOB_NAME} failed with exit code ${EXIT_CODE} after ${duration}s" >&2
# Notify Slack on failure
if [[ -n "$SLACK_WEBHOOK" ]]; then
curl -fsS -m 10 -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d "{\"text\":\"Cronjob FAILED: ${JOB_NAME} (exit ${EXIT_CODE}, ${duration}s)\"}" || true
fi
# Signal failure to healthchecks.io
[[ -n "$HEALTHCHECK_URL" ]] && \
curl -fsS -m 5 "${HEALTHCHECK_URL}/fail" -d "FAIL: exit ${EXIT_CODE}" || true
fi
}
trap cleanup EXIT
# Run the actual job
"${JOB_CMD[@]}"
5. Mailbenachrichtigung und Alerting
Cron sendet die Ausgabe eines Jobs per Mail an den User, unter dem er läuft – sofern ein lokaler MTA konfiguriert ist. Das ist auf vielen modernen Servern nicht mehr gegeben, und selbst wenn es funktioniert, landen die Mails in einem Postfach, das niemand regelmäßig liest. Professionelles Cronjob-Alerting bedeutet, Fehler aktiv an die zuständigen Personen zu senden: per E-Mail über einen konfigurierten SMTP-Relay, per Slack-Webhook, per PagerDuty-Alert oder per Healthcheck-Dienst wie healthchecks.io.
Das Muster für zuverlässige Benachrichtigung: Die Cronjob-Ausgabe komplett unterdrücken (MAILTO="" in der Crontab), im Skript selbst aber bei Fehlern einen strukturierten Alert absetzen. So gehen keine Benachrichtigungen an nicht-existente Mailboxen verloren, und die Alerts enthalten genau die Information, die zur Diagnose nötig ist: Jobname, Exit-Code, letzte Log-Zeilen, Timestamp. Healthcheck-Dienste ergänzen das aktive Alerting um Dead-Man's-Switch-Monitoring: Wenn ein Cronjob nicht innerhalb des erwarteten Fensters seinen Ping sendet, löst der Dienst automatisch einen Alert aus – auch dann, wenn der Cronjob überhaupt nicht gestartet wurde.
6. Crontab-Hygiene und Umgebungsvariablen
Eine gut gepflegte Crontab ist so selbstdokumentierend wie möglich. Jeder Eintrag enthält einen Kommentar, der erklärt, warum dieser Cronjob existiert und was er tut. Die Umgebung wird explizit gesetzt: PATH, SHELL, MAILTO und alle anwendungsspezifischen Variablen stehen am Anfang der Crontab und gelten für alle nachfolgenden Einträge. Alternativ werden Umgebungsvariablen im Skript selbst gesetzt, was das Skript unabhängig von der aufrufenden Umgebung macht.
Eine typische Falle: Im Terminal ist der volle PATH durch .bashrc oder .profile gesetzt. Im Cron-Kontext gibt es nur /usr/bin:/bin. Befehle wie php, composer, mysql, node oder selbst kompilierte Tools liegen oft in /usr/local/bin oder einem anderen Verzeichnis, das im minimalen Cron-PATH fehlt. Jedes professionelle Cronjob-Skript beginnt deshalb mit einer expliziten PATH-Deklaration. Der Trick zum Debuggen: env -i /bin/sh -c 'set' zeigt die Minimalumgebung, mit der Cron Skripte startet.
7. systemd-Timer als moderne Cron-Alternative
Auf allen modernen Linux-Systemen mit systemd bieten systemd-Timer eine deutlich mächtigere Alternative zu klassischen Cronjobs. Jeder Timer besteht aus zwei Einheiten: einer .timer-Datei, die den Zeitplan definiert, und einer .service-Datei, die den auszuführenden Befehl beschreibt. Das Journal von systemd protokolliert automatisch Start, Ende, Ausgabe und Exit-Code jedes Timer-Runs, ohne dass eine manuelle Logging-Konfiguration nötig ist. journalctl -u backup.timer zeigt die komplette Ausführungshistorie.
Systemd-Timer unterstützen Abhängigkeiten über die After=-Direktive: Ein Cronjob, der die Datenbank braucht, wartet, bis postgresql.service bereit ist. Das ist mit klassischen Cronjobs nicht möglich. OnCalendar=daily entspricht 0 0 * * *, ist aber lesbarer und unterstützt auch komplexere Ausdrücke wie Mon,Wed,Fri *-*-* 08:00:00. Mit Persistent=true in der Timer-Einheit wird ein verpasster Lauf (wegen Neustart oder ausgeschaltetem Server) beim nächsten System-Start nachgeholt – eine Funktionalität, die klassische Cronjobs nicht bieten.
# /etc/systemd/system/db-backup.service
[Unit]
Description=Database backup job
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=oneshot
User=backup
Group=backup
# Explicit PATH — not inherited from user environment
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/local/bin/backup.sh --type database
# Capture output to journal (automatic — no logging config needed)
StandardOutput=journal
StandardError=journal
SyslogIdentifier=db-backup
# Fail the job if script exits non-zero
SuccessExitStatus=0
# /etc/systemd/system/db-backup.timer
[Unit]
Description=Run database backup daily at 02:00
Requires=db-backup.service
[Timer]
# Run every day at 02:00 local time
OnCalendar=*-*-* 02:00:00
# Catch up missed runs after reboot
Persistent=true
# Random delay up to 5 min to spread load on multiple servers
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
8. cron vs. systemd-Timer im Vergleich
Die Entscheidung zwischen klassischem cron und systemd-Timern hängt von der Infrastruktur und den Anforderungen ab. Beide Ansätze haben ihre Stärken.
| Merkmal | Klassischer Cron | systemd-Timer | Empfehlung |
|---|---|---|---|
| Logging | Manuell konfigurieren oder Mail | Automatisch im Journal | systemd für neue Setups |
| Verpasste Runs | Werden still übersprungen | Persistent=true holt nach | systemd für kritische Jobs |
| Abhängigkeiten | Nicht unterstützt | After=, Requires= | systemd bei Service-Deps |
| Portabilität | Alle Unix-Systeme | Nur systemd-Systeme | Cron für macOS/BSD |
| Statusabfrage | Nur Logdateien | systemctl list-timers | systemd für Monitoring |
Auf modernen Linux-Servern mit systemd sind systemd-Timer die bessere Wahl für neue Cronjobs. Die Frage ist nicht ob, sondern wann man migriert. Bestehende Cronjobs zu migrieren lohnt sich immer dann, wenn Logging, Nachholung verpasster Runs oder Service-Abhängigkeiten Probleme bereiten. Für portable Skripte, die auch auf macOS oder BSD-Systemen laufen müssen, bleibt cron die einzige Option.
9. Monitoring: Cronjob-Ausführung extern überwachen
Die zuverlässigste Form des Cronjob-Monitorings ist das Dead-Man's-Switch-Prinzip: Der Cronjob sendet bei erfolgreichem Abschluss einen HTTP-Request an einen Healthcheck-Dienst. Wenn der Ping innerhalb des erwarteten Zeitfensters ausbleibt, sendet der Dienst automatisch einen Alert. Damit wird nicht nur ein fehlgeschlagener Cronjob erkannt, sondern auch ein Cronjob, der gar nicht erst gestartet wurde – etwa weil der Server ausgefallen ist oder weil ein Konfigurationsfehler die Crontab beschädigt hat. Dienste wie healthchecks.io, Cronitor und Dead Man's Snitch bieten genau dieses Muster als gehosteten Service.
Für Teams, die Prometheus bereits nutzen, ist der prometheus-pushgateway eine elegante Lösung: Der Cronjob pusht nach dem Lauf Metriken (Laufzeit, Exit-Code, Anzahl verarbeiteter Datensätze) an das Pushgateway, von wo Prometheus sie scrapt. Alertmanager kann dann auf Exit-Code != 0 oder auf fehlende Metriken (kein Push in erwartetem Fenster) reagieren. Das Muster skaliert von wenigen Cronjobs auf Hunderte und ermöglicht dashboardbasierte Übersicht über alle geplanten Jobs in einer Infrastruktur.
#!/usr/bin/env bash
# monitor_wrapper.sh — Healthcheck.io + Prometheus pushgateway integration
set -euo pipefail
readonly HC_URL="${HC_URL:-}" # healthchecks.io ping URL
readonly PUSHGW="${PUSHGW:-}" # Prometheus pushgateway URL
readonly JOB_NAME="${1:?job name required}"
shift
START=$(date +%s%N) # nanoseconds for precision timing
# Signal job start to healthchecks.io
[[ -n "$HC_URL" ]] && curl -fsS -m 5 "${HC_URL}/start" || true
run_status=0
"$@" || run_status=$?
DURATION_MS=$(( ($(date +%s%N) - START) / 1000000 ))
# Push metrics to Prometheus pushgateway
if [[ -n "$PUSHGW" ]]; then
cat <<METRICS | curl -fsS -m 10 --data-binary @- "${PUSHGW}/metrics/job/${JOB_NAME}"
# HELP cronjob_last_success_timestamp Unix timestamp of last successful run
# TYPE cronjob_last_success_timestamp gauge
cronjob_last_success_timestamp{job="${JOB_NAME}"} $(date +%s)
# HELP cronjob_duration_milliseconds Duration of last run in milliseconds
# TYPE cronjob_duration_milliseconds gauge
cronjob_duration_milliseconds{job="${JOB_NAME}"} ${DURATION_MS}
# HELP cronjob_exit_code Exit code of last run
# TYPE cronjob_exit_code gauge
cronjob_exit_code{job="${JOB_NAME}"} ${run_status}
METRICS
fi
# Signal result to healthchecks.io
if [[ $run_status -eq 0 ]]; then
[[ -n "$HC_URL" ]] && curl -fsS -m 5 "$HC_URL" || true
else
[[ -n "$HC_URL" ]] && curl -fsS -m 5 "${HC_URL}/fail" || true
exit "$run_status"
fi
Mironsoft
Cron-Infrastruktur, Monitoring und systemd-Migration
Cronjobs, die zuverlässig laufen und Fehler melden?
Wir analysieren eure Cron-Infrastruktur, ergänzen Locking, Logging und Monitoring, und migrieren kritische Jobs zu systemd-Timern – damit keine Backup- oder Deployment-Job-Fehler mehr unbemerkt bleiben.
Cron-Audit
Bestehende Cronjobs auf Locking, Logging und Exit-Code-Behandlung prüfen
systemd-Migration
Kritische Cronjobs zu systemd-Timern migrieren und Abhängigkeiten konfigurieren
Monitoring-Setup
Healthcheck-Dienste und Prometheus-Integration für vollständige Job-Übersicht einrichten
10. Zusammenfassung
Professionelle Cronjobs brauchen mehr als einen Crontab-Eintrag. flock verhindert parallele Ausführungen zuverlässig und ohne manuelle Cleanup-Logik. Strukturiertes Logging mit Zeitstempel und Level macht Fehler sichtbar, ohne Logdateien ohne Bezug anzuhäufen. Exit-Codes müssen explizit gesetzt und von einem Monitoring-System ausgewertet werden – Dead-Man's-Switch-Dienste erkennen sogar Cronjobs, die gar nicht erst starten. Systemd-Timer lösen viele strukturelle Schwächen von cron: automatisches Journal, Nachholung verpasster Runs und Service-Abhängigkeiten sind eingebaut.
Der wichtigste Schritt ist, Cronjobs nicht als selbstverständlich laufende Hintergrundprozesse zu behandeln, sondern als kritische Systemkomponenten, die dieselbe Sorgfalt wie Produktionsdienste verdienen. Locking, Logging, Fehlerbenachrichtigung und Monitoring sind kein Luxus – sie sind die Voraussetzung dafür, dass automatisierte Jobs tatsächlich zuverlässig sind und nicht nur zuverlässig aussehen, bis die Backup-Wiederherstellung zeigt, dass sie das nie waren.
Cronjobs richtig bauen — Das Wichtigste auf einen Blick
Locking
flock -n /var/lock/job.lock — verhindert parallele Ausführung. OS gibt Sperre automatisch frei, keine veralteten Lock-Dateien.
Logging
Timestamp + Level + Skriptname in jede Zeile. tee -a "$LOG_FILE" schreibt gleichzeitig in Datei und stderr. systemd-Timer loggen automatisch.
Exit-Codes & Monitoring
Exit-Code explizit setzen. trap cleanup EXIT für Alerts. Healthcheck-Dienst erkennt Jobs, die gar nicht erst starten.
systemd-Timer
Persistent=true holt verpasste Runs nach. After= für Abhängigkeiten. journalctl -u job.timer für vollständige Ausführungshistorie.