LinuxXXXVI · Scheduled OperationsLogging
Job logging and failure detection - knowing what happened
What you'll learn
- Capture job output
- Detect failures
- Alert on missed runs
- Distinguish job failure from infrastructure failure
- Choose is-failed over is-active for oneshot jobs
- Wire OnFailure= as the native systemd failure notification
Prerequisites
Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-09
A scheduled job that runs silently is a job whose failures are invisible. Logging and alerting make failures visible.
Capture output
cron
cron emails the output if MAILTO is set:
MAILTO=ops@example.com
0 2 * * * /usr/local/bin/backup.sh
Without MAILTO, output is lost. Alternative: log to a file:
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
systemd
systemd captures stdout and stderr to journald:
[Service]
ExecStart=/usr/local/bin/backup.sh
StandardOutput=journal
StandardError=journal
View:
sudo journalctl -u backup.service
sudo journalctl -u backup.service --since today
Exit codes
- 0: success.
- non-zero: failure.
cron: cron does not interpret exit codes but does email the output if there is any.
systemd: Type=oneshot with Restart=no (default). If
the service exits non-zero, it is recorded as failed in
journald.
is-active is the wrong predicate for a oneshot job
A Type=oneshot unit runs, finishes, and goes back to
inactive (dead). That is what success looks like. So this
pattern alerts on every successful run:
# WRONG - fires on success as well as failure
sudo systemctl is-active backup.service || \
logger -t backup "backup failed"
Check it against a real oneshot unit that completed successfully:
$ systemctl is-active systemd-tmpfiles-clean.service; echo exit=$?; systemctl show -p Type -p Result -p ActiveState systemd-tmpfiles-clean.serviceinactive
exit=3
Type=oneshot
Result=success
ActiveState=inactiveis-active returned “inactive” and exit 3 on a run that
succeeded. Result=success is the field that carries the
truth.
Use is-failed instead. It is true only when the unit
actually entered the failed state:
# CORRECT
systemctl is-failed --quiet backup.service && \
logger -t backup "backup FAILED"
# Or read the result directly
systemctl show -p Result --value backup.service # success | exit-code | timeout | signal
is-active remains the right check for a long-running
daemon and for a .timer unit, both of which are supposed
to stay active. It is only wrong for the oneshot job that
is meant to end.
Better: let systemd tell you
Polling asks a question at a moment you chose.
OnFailure= fires the instant the unit fails, with no
scheduler of its own:
# /etc/systemd/system/backup.service
[Unit]
OnFailure=job-failure-notify@%n.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# /etc/systemd/system/job-failure-notify@.service
[Unit]
Description=Notify that %i failed
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-failure %i
%n expands to the failing unit’s full name, %i to the
instance string, so one template unit serves every job on
the host. Verify a unit file before installing it with
systemd-analyze verify /path/to/unit.
Detect missed runs
cron: a missed run is silent. No output, no email.
systemd: OnCalendar= with Persistent=true runs missed
runs on next boot. Missed runs without Persistent=true
are dropped.
Detect with monitoring:
# Check when the timer last fired
sudo systemctl list-timers --all | grep backup
# Alert if the last run was more than 25 hours ago
LAST_RUN=$(stat -c %Y /var/lib/my-job)
NOW=$(date +%s)
if (( (NOW - LAST_RUN) > 25 * 3600 )); then
logger -t my-job "Last run > 25h ago"
fi
Alerting patterns
Job failed
# Systemd - is-failed, not is-active (see above)
systemctl is-failed --quiet backup.service && {
curl -X POST https://alerts.example.com/webhook \
-d '{"text":"backup service failed"}'
}
Wiring a webhook to is-active is how a team ends up
muting the backup alert in week one: it pages on every
successful run, so the genuine failure - which looks
identical - arrives into a filter.
Job didn’t run
For scheduled jobs, monitor that they actually ran:
# Prometheus
- alert: BackupMissedRun
expr: time() - backup_last_success_timestamp > 90000
for: 1h
Job too slow
A job that takes longer than usual is a sign of trouble:
- alert: BackupSlow
expr: backup_last_run_duration > 3600
Distinguish causes
A job failure can be:
- The job script crashed: application bug, missing input.
- The infrastructure failed: disk full, network down, service unavailable.
- The job did not run: timer not enabled, machine off.
For each cause, a different response. Monitoring that distinguishes these:
# Is the timer armed? is-active IS correct here - a timer stays active
systemctl is-active backup.timer
# Did the last run fail? is-failed, because a healthy oneshot is inactive
systemctl is-failed backup.service
# How did it end, and when?
systemctl show -p Result -p ExecMainStatus -p ExecMainExitTimestamp backup.service
# Check last journal entries
sudo journalctl -u backup.service -n 20
# Check resources
df -h /var/lib/backup
The two systemctl predicates answer different questions.
is-active backup.timer tells you whether the job is still
scheduled. is-failed backup.service tells you whether the
last execution went wrong. A timer that is inactive and a
service that never failed is the “job did not run” case -
the one cron cannot report at all.
Knowledge check
Knowledge check · 5 questions
Q1. How do you view the output of a systemd service in the last hour?
Q2. cron always logs job output.
Q3. Which of the following are reasons a scheduled job may not run? Select all that apply.
Q4. backup.service is Type=oneshot, driven by backup.timer. It completed normally at 02:00. You run: systemctl is-active backup.service; echo $? and get "inactive" and exit 3. Your alert rule is `is-active backup.service || page`. What does this mean operationally?
Q5. Which check correctly distinguishes "the job failed" from "the job is no longer scheduled"?
Passing score: 75%. Answers are checked in this browser.