Overview

Scheduled tasks are a foundational component of operations automation — log rotation, data backups, certificate renewal, health checks, report generation — nearly every ops scenario relies on scheduled execution. Most people’s understanding of scheduled tasks stops at crontab -e plus a line like 0 2 * * * /path/to/script.sh, but this is far from sufficient in production: who gets notified when a task fails? Who handles execution timeouts? How do you coordinate tasks across multiple machines? This article systematically covers scheduled task management practices from cron to systemd timer to distributed scheduling.

References: cron Wikipedia, systemd.timer Official Documentation

I. cron Syntax and Limitations

1.1 cron Expressions

A cron expression consists of 5 fields:

┌──────── Minute (0-59)
│ ┌────── Hour (0-23)
│ │ ┌──── Day (1-31)
│ │ │ ┌── Month (1-12)
│ │ │ │ ┌ Day of week (0-7, both 0 and 7 are Sunday)
│ │ │ │ │
* * * * * command
ExpressionMeaning
0 2 * * *Every day at 2:00 AM
*/15 * * * *Every 15 minutes
0 */6 * * *Every 6 hours
0 0 * * 0Every Sunday at 0:00
0 0 1 * *1st of every month at 0:00
30 3-5 * * *3:30, 4:30, 5:30
0 0 1 1,4,7,10 *1st of January, April, July, October
@rebootAt system startup
@daily / @midnightEvery day at 0:00
@weeklyEvery Sunday at 0:00
@monthly1st of every month at 0:00
@yearly / @annuallyJanuary 1st at 0:00

1.2 crontab Management

# Edit current user's crontab
crontab -e

# View current user's crontab
crontab -l

# Delete all scheduled tasks for current user
crontab -r

# Import crontab from file
crontab cron_jobs.txt

# Edit a specific user's crontab (requires root)
sudo crontab -u deploy -e

# System-level scheduled tasks (visible to all users)
sudo vim /etc/crontab
# Format includes an extra user field:
# m h dom mon dow user command
0 2 * * * root /opt/scripts/backup.sh

# Directory-based scheduled tasks (execute all scripts in the directory at preset frequency)
/etc/cron.hourly/    # Hourly
/etc/cron.daily/     # Daily
/etc/cron.weekly/    # Weekly
/etc/cron.monthly/   # Monthly

1.3 Limitations of cron

LimitationDescriptionImpact
No error retryTasks don’t automatically retry after failureTransient failures cause missed executions
No execution timeoutTasks can run indefinitelySlow tasks consume resources, block subsequent tasks
No concurrency controlSame task may run concurrentlyData races, resource exhaustion
Unfriendly loggingOutput sent via email or lostDifficult troubleshooting
No dependency managementCannot define dependencies between tasksManual execution order enforcement needed
Minute-only precisionNo sub-minute schedulingCannot meet high-frequency task needs
Restricted environmentDefault PATH is short, no user env varsScripts can’t find commands
Single-machine onlyCan only run on one machineNo cross-node coordination
# Typical cron problem scenarios

# Problem 1: Missing environment variables
0 2 * * * /opt/scripts/backup.sh
# backup.sh uses python3, but cron's PATH doesn't include /usr/local/bin
# Solution: Explicitly set PATH at the top of the script, or define it in crontab

PATH=/usr/local/bin:/usr/bin:/bin
0 2 * * * /opt/scripts/backup.sh

# Problem 2: Concurrent execution
*/1 * * * * /opt/scripts/process_data.sh
# If process_data.sh takes more than 1 minute, the next run starts concurrently
# Solution: Use flock for file locking
*/1 * * * * /usr/bin/flock -n /tmp/process_data.lock /opt/scripts/process_data.sh

# Problem 3: Lost output
0 2 * * * /opt/scripts/backup.sh
# If there's stderr output, cron tries to send email (lost if no mail configured)
# Solution: Redirect to a log file
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

# Problem 4: No awareness of failures
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# Even if backup.sh exits non-zero, cron doesn't care
# Solution: Implement alerting within the script, or use systemd timer

II. systemd timer

2.1 Basic Concepts

A systemd timer consists of two files:

  • Service file: Defines the task to execute (same as a regular systemd service)
  • Timer file: Defines the trigger schedule
/etc/systemd/system/
├── backup.service      # Task definition
└── backup.timer        # Scheduled trigger

2.2 Creating a Timer

# /etc/systemd/system/backup.service
[Unit]
Description=Database Backup Service
Documentation=file:///opt/scripts/backup.sh
After=network.target postgresql.service

[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
ExecStartPost=/opt/scripts/notify-backup.sh
User=backup
Group=backup
# Resource limits
MemoryMax=512M
CPUQuota=50%
# Timeout
TimeoutStartSec=3600
# Working directory
WorkingDirectory=/opt/scripts
# Environment variables
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
Environment="BACKUP_DIR=/data/backups"
EnvironmentFile=-/etc/sysconfig/backup

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/backup.timer
[Unit]
Description=Run Database Backup Daily
Requires=backup.service

[Timer]
# Trigger method 1: Calendar time (similar to cron)
OnCalendar=*-*-* 02:00:00

# Trigger method 2: Relative time
# OnBootSec=5min          # 5 minutes after boot
# OnUnitActiveSec=1h      # 1 hour after last execution
# OnUnitInactiveSec=30min # 30 minutes after last completion

# Random delay (prevent all tasks from starting simultaneously)
RandomizedDelaySec=300

# Persistent (missed tasks are run after startup)
Persistent=true

# Accuracy (reduce wakeup frequency, save power)
AccuracySec=1min

[Install]
WantedBy=timers.target
# Enable the timer
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer

# View timer status
systemctl status backup.timer
systemctl list-timers backup.timer

# View all timers
systemctl list-timers --all

# Manual trigger
sudo systemctl start backup.service

# View execution logs
journalctl -u backup.service
journalctl -u backup.service --since today
journalctl -u backup.service -f

# View next trigger time
systemctl list-timers backup.timer

2.3 OnCalendar Syntax

systemd’s calendar time expressions are more powerful than cron:

# Basic format: DayOfWeek Year-Month-Day Hour:Minute:Second

OnCalendar=*-*-* 02:00:00              # Every day at 2:00
OnCalendar=*-*-* 02,14:00:00          # Every day at 2:00 and 14:00
OnCalendar=Mon..Fri 09:00:00          # Weekdays at 9:00
OnCalendar=Sat,Sun 02:00:00           # Weekends at 2:00
OnCalendar=*-*-01 00:00:00            # 1st of every month
OnCalendar=*-01,04,07,10-01 00:00:00  # 1st of quarter-start months
OnCalendar=Mon *-*-* 09:00:00         # Every Monday at 9:00
OnCalendar=*:0/15                     # Every 15 minutes (second-level precision)
OnCalendar=hourly                     # Hourly
OnCalendar=daily                      # Daily at 00:00
OnCalendar=weekly                     # Every Monday at 00:00
OnCalendar=monthly                    # 1st of every month at 00:00
OnCalendar=yearly                     # January 1st at 00:00

# Validate calendar expressions
systemd-analyze calendar "*-*-* 02:00:00"
systemd-analyze calendar "Mon..Fri 09:00:00"

2.4 cron vs systemd timer Comparison

Dimensioncronsystemd timer
PrecisionMinuteSecond
Error retryNoneVia OnFailure=
Execution timeoutNoneTimeoutStartSec=
Concurrency controlRequires flockRefuseManualStart= + ConditionPathExists=
LoggingEmail or manual redirectNative journalctl integration
Dependency managementNoneAfter=, Requires=
Environment variablesLimitedEnvironment=, EnvironmentFile=
Resource limitsNoneMemoryMax=, CPUQuota=
User isolationcrontab -usystemctl --user
Missed tasksLostPersistent=true catches up
Random delayNoneRandomizedDelaySec=
Status querycrontab -lsystemctl list-timers
Learning curveLowMedium

Conclusion: Prefer systemd timer for new projects. Existing cron setups can be migrated gradually. systemd timer’s advantages in error handling, logging, and resource control represent a qualitative improvement.

III. Task Design Principles

3.1 Idempotency

Scheduled tasks must be idempotent — running them any number of times produces the same result:

#!/usr/bin/env bash
# ❌ Non-idempotent: appends data every time
echo "backup data" >> /data/backup.log

# ✅ Idempotent: use a fixed filename or date-based filename
echo "backup data" > "/data/backup-$(date +%Y%m%d).log"

# ✅ Idempotent: check before executing
if [ -f /data/backup-done.flag ]; then
    echo "Already backed up today, skipping"
    exit 0
fi
# Execute backup...
touch /data/backup-done.flag

3.2 Concurrency Control

#!/usr/bin/env bash
set -euo pipefail

# Method 1: flock file lock
LOCK_FILE="/var/lock/$(basename "$0").lock"
exec 200>"$LOCK_FILE"
flock -n 200 || {
    echo "Another instance is running, exiting"
    exit 0
}

# Method 2: PID file lock
PID_FILE="/var/run/$(basename "$0" .sh).pid"
if [ -f "$PID_FILE" ]; then
    PID=$(cat "$PID_FILE")
    if kill -0 "$PID" 2>/dev/null; then
        echo "Process $PID is running, exiting"
        exit 0
    fi
fi
echo $$ > "$PID_FILE"
trap "rm -f $PID_FILE" EXIT
# systemd approach: more elegant concurrency control
# /etc/systemd/system/data-sync.service
[Service]
Type=oneshot
ExecStart=/opt/scripts/sync-data.sh
# Prevent manual duplicate starts
RefuseManualStart=no
# If already running, no new instance is started (default behavior)

3.3 Timeout Control

#!/usr/bin/env bash
set -euo pipefail

TIMEOUT=300  # 5 minutes

# Method 1: timeout command
timeout "$TIMEOUT" /opt/scripts/long_task.sh
exit_code=$?

if [ $exit_code -eq 124 ]; then
    echo "Task timed out (${TIMEOUT}s)"
    # Send alert
    curl -sf -X POST "$WEBHOOK_URL" \
        -d "{\"text\":\"Task timed out: $(basename $0)\"}"
    exit 1
fi

exit $exit_code

# Method 2: Background execution + timeout check
run_with_timeout() {
    local timeout=$1; shift
    local cmd="$*"

    $cmd &
    local pid=$!

    local start=$(date +%s)
    while kill -0 $pid 2>/dev/null; do
        local elapsed=$(( $(date +%s) - start ))
        if [ $elapsed -gt $timeout ]; then
            echo "Timed out, killing process $pid"
            kill -TERM $pid
            sleep 2
            kill -9 $pid 2>/dev/null || true
            return 124
        fi
        sleep 5
    done

    wait $pid
    return $?
}
# systemd approach
[Service]
TimeoutStartSec=300
# After timeout, systemd sends SIGTERM, then SIGKILL

3.4 Environment Isolation

#!/usr/bin/env bash
# Set up a complete environment at the top of the script

set -euo pipefail

# Explicitly set PATH
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

# Load environment variables
if [ -f /etc/profile.d/app.sh ]; then
    source /etc/profile.d/app.sh
fi

# Set working directory
cd /opt/app || { echo "Cannot switch to working directory"; exit 1; }

# Set umask
umask 022

# Locale settings
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8

IV. Failure Retry and Alerting

4.1 systemd Failure Retry

# /etc/systemd/system/critical-task.service
[Unit]
Description=Critical Task
OnFailure=critical-task-failed@%n.service

[Service]
Type=oneshot
ExecStart=/opt/scripts/critical-task.sh

# Auto restart (not applicable to oneshot, but can be paired with timer)
# Restart=on-failure
# RestartSec=30

# Resource limits
MemoryMax=1G
CPUQuota=100%

# Timeout
TimeoutStartSec=600
# /etc/systemd/system/critical-task-failed@.service
# Template service: triggered when a task fails
[Unit]
Description=Handle Failure of %i

[Service]
Type=oneshot
ExecStart=/opt/scripts/alert-failure.sh %i
#!/usr/bin/env bash
# /opt/scripts/alert-failure.sh
# Task failure alerting script

FAILED_UNIT="$1"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
HOSTNAME=$(hostname)

# Get failure logs
LOGS=$(journalctl -u "$FAILED_UNIT" --since "10 min ago" --no-pager -n 20 2>/dev/null)

# Send alert
curl -sf -X POST "${WEBHOOK_URL}" \
    -H 'Content-Type: application/json' \
    -d "{
        \"text\": \"Scheduled task failure alert\nService: ${FAILED_UNIT}\nHost: ${HOSTNAME}\nTime: ${TIMESTAMP}\nLogs:\n${LOGS}\"
    }"

# Attempt retry
RETRY_FILE="/var/lib/task-retry/${FAILED_UNIT}.count"
mkdir -p /var/lib/task-retry
RETRY_COUNT=0
if [ -f "$RETRY_FILE" ]; then
    RETRY_COUNT=$(cat "$RETRY_FILE")
fi

if [ "$RETRY_COUNT" -lt 3 ]; then
    RETRY_COUNT=$((RETRY_COUNT + 1))
    echo "$RETRY_COUNT" > "$RETRY_FILE"
    echo "Retry attempt ${RETRY_COUNT}..."
    sleep $((RETRY_COUNT * 30))  # Incremental delay
    systemctl start "$FAILED_UNIT"
else
    echo "Max retries reached (3), manual intervention required"
    # Escalate alert
    curl -sf -X POST "${ESCALATION_URL}" \
        -d "{\"text\":\"Task ${FAILED_UNIT} failed after 3 retries, manual intervention required\"}"
fi

4.2 cron Task Alerting Wrapper

#!/usr/bin/env bash
# /opt/scripts/cron-wrapper.sh
# cron task wrapper: unified handling of logging, timeout, and alerting

set -euo pipefail

# Argument parsing
TASK_NAME="$1"; shift
TIMEOUT="${TIMEOUT:-300}"
WEBHOOK_URL="${WEBHOOK_URL:-}"
LOG_DIR="/var/log/cron"

mkdir -p "$LOG_DIR"

LOG_FILE="${LOG_DIR}/${TASK_NAME}-$(date +%Y%m%d).log"
START_TIME=$(date +%s)

echo "========================================" | tee -a "$LOG_FILE"
echo "Task: $TASK_NAME" | tee -a "$LOG_FILE"
echo "Time: $(date '+%Y-%m-%d %H:%M:%S')" | tee -a "$LOG_FILE"
echo "Command: $*" | tee -a "$LOG_FILE"
echo "========================================" | tee -a "$LOG_FILE"

# Execute task (with timeout)
set +e
timeout "$TIMEOUT" "$@" 2>&1 | tee -a "$LOG_FILE"
EXIT_CODE=${PIPESTATUS[0]}
set -e

END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))

echo "========================================" | tee -a "$LOG_FILE"
echo "Exit code: $EXIT_CODE" | tee -a "$LOG_FILE"
echo "Duration: ${DURATION}s" | tee -a "$LOG_FILE"
echo "========================================" | tee -a "$LOG_FILE"

# Handle result
if [ $EXIT_CODE -eq 0 ]; then
    echo "✓ Task succeeded" | tee -a "$LOG_FILE"
    # Update success marker
    touch "/var/lib/cron-status/${TASK_NAME}.success"
    rm -f "/var/lib/cron-status/${TASK_NAME}.failed"
else
    echo "✗ Task failed" | tee -a "$LOG_FILE"
    # Record failure
    mkdir -p /var/lib/cron-status
    echo "$(date +%s)" > "/var/lib/cron-status/${TASK_NAME}.failed"

    # Send alert
    if [ -n "$WEBHOOK_URL" ]; then
        FAILURE_LOG=$(tail -20 "$LOG_FILE")
        curl -sf -X POST "$WEBHOOK_URL" \
            -H 'Content-Type: application/json' \
            -d "{
                \"text\": \"⚠ Cron task failed\nTask: ${TASK_NAME}\nHost: $(hostname)\nExit code: ${EXIT_CODE}\nDuration: ${DURATION}s\nLogs:\n${FAILURE_LOG}\"
            }" 2>/dev/null || true
    fi
fi

# Log rotation
find "$LOG_DIR" -name "${TASK_NAME}-*.log" -mtime +30 -delete

exit $EXIT_CODE
# Use the wrapper in crontab
0 2 * * * /opt/scripts/cron-wrapper.sh backup 300 /opt/scripts/backup.sh
*/15 * * * * /opt/scripts/cron-wrapper.sh health-check 60 /opt/scripts/check.sh
0 * * * * /opt/scripts/cron-wrapper.sh sync-data 600 /opt/scripts/sync.sh

V. Log Integration

5.1 systemd Journal Logging

# View timer execution history
journalctl -u backup.service --since today
journalctl -u backup.service --since "2026-07-10" --until "2026-07-10 12:00"

# Errors only
journalctl -u backup.service -p err

# Follow live logs
journalctl -u backup.service -f

# View execution duration
journalctl -u backup.service -o json | jq '. | select(.MESSAGE | contains("Duration"))'

# View execution records for all timers
journalctl -u '*.timer' --since today

# Export logs
journalctl -u backup.service --since "2026-07-01" > backup-logs.txt

5.2 Structured Logging

#!/usr/bin/env bash
# Output structured logs from within scripts

# Method 1: logger command (writes to syslog/journal)
logger -t backup "Backup started: target=${BACKUP_TARGET}"
# ... perform backup ...
logger -t backup "Backup completed: size=${BACKUP_SIZE}, duration=${DURATION}s"

# Method 2: systemd-cat (supports priority levels)
echo "Backup started" | systemd-cat -t backup -p info
echo "Backup warning: low disk space" | systemd-cat -t backup -p warning
echo "Backup failed" | systemd-cat -t backup -p err

# Method 3: JSON format logging
log_json() {
    local level="$1"; shift
    local message="$*"
    echo "{\"timestamp\":\"$(date -u +%FT%TZ)\",\"level\":\"${level}\",\"task\":\"backup\",\"message\":\"${message}\"}" | systemd-cat -t backup
}

log_json info "Backup started"
log_json info "Backup completed: size=${BACKUP_SIZE}"
log_json error "Backup failed: ${ERROR_MSG}"

5.3 Log Aggregation and Querying

# Query execution status of all scheduled tasks
systemctl list-timers --all --output=json | \
    jq '.[] | {unit: .unit, next: .next, last: .last, result: .result}'

# Count today's task success rate
SUCCESS=$(journalctl --since today -u '*.service' --grep '✓ Task succeeded' | wc -l)
FAILED=$(journalctl --since today -u '*.service' --grep '✗ Task failed' | wc -l)
echo "Today's scheduled tasks: succeeded=${SUCCESS} failed=${FAILED}"

# Generate scheduled task daily report
generate_timer_report() {
    echo "=== Scheduled Task Daily Report $(date +%Y-%m-%d) ==="
    echo ""
    systemctl list-timers --all | head -n -1 | while read -r line; do
        unit=$(echo "$line" | awk '{print $1}')
        if [[ "$unit" == *.timer ]]; then
            service="${unit%.timer}.service"
            last_run=$(journalctl -u "$service" --since today -o short-iso | tail -1)
            result="N/A"
            if journalctl -u "$service" --since today | grep -q "Succeeded"; then
                result="✓"
            elif journalctl -u "$service" --since today | grep -q "Failed"; then
                result="✗"
            fi
            echo "  $result $unit"
        fi
    done
}

VI. Distributed Scheduled Tasks

6.1 Distributed Challenges with Single-Machine cron

When tasks need to be coordinated across multiple machines, single-machine cron falls short:

  • The same task should only run on one machine (requires distributed locking)
  • Cross-machine task dependencies (machine A’s task must complete before triggering machine B’s task)
  • Task orchestration needed (DAG dependencies)
  • Visual execution history and manual triggering needed

6.2 Solution Comparison

SolutionUse CaseAdvantagesDisadvantages
cron + distributed lockSimple scenariosLow migration costComplex lock failure handling
systemd timerSingle-machine/per-machine independentNative integration, feature-richNo cross-machine coordination
XXL-JobSmall-to-medium distributedLightweight, good UIJava ecosystem, limited features
AirflowData pipelines / complex DAGsPowerful DAG orchestration, rich OperatorsHeavyweight, steep learning curve
K8s CronJobK8s environmentsNative integration, declarativeSimple features, no DAG
TemporalLong-running tasks/workflowsCode-as-workflow, strong consistencyNewer, learning curve

6.3 K8s CronJob

# k8s-cronjob-backup.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: database-backup
  namespace: production
spec:
  schedule: "0 2 * * *"
  # Number of successful jobs to retain
  successfulJobsHistoryLimit: 3
  # Number of failed jobs to retain
  failedJobsHistoryLimit: 5
  # Concurrency policy
  # Allow: Allow concurrency (default)
  # Forbid: Forbid concurrency
  # Replace: Replace the running old job
  concurrencyPolicy: Forbid
  # Job start deadline
  startingDeadlineSeconds: 200
  # Suspend
  # suspend: false
  jobTemplate:
    spec:
      # Retry count
      backoffLimit: 3
      # Job timeout
      activeDeadlineSeconds: 3600
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: backup
            image: backup-tool:1.0
            command:
            - /bin/bash
            - -c
            - |
              set -euo pipefail
              echo "Starting backup..."
              pg_dump "$DATABASE_URL" > /backup/db-$(date +%Y%m%d).sql
              echo "Backup completed"              
            env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: url
            resources:
              requests:
                memory: 256Mi
                cpu: 200m
              limits:
                memory: 512Mi
                cpu: 500m
            volumeMounts:
            - name: backup-storage
              mountPath: /backup
          volumes:
          - name: backup-storage
            persistentVolumeClaim:
              claimName: backup-pvc
# Manage K8s CronJob
kubectl create -f k8s-cronjob-backup.yaml

# View CronJob
kubectl get cronjob -n production

# View execution history
kubectl get jobs -n production

# View Pod logs
kubectl logs -n production job/database-backup-XXXXX

# Manual trigger
kubectl create job --from=cronjob/database-backup manual-backup -n production

# Suspend
kubectl patch cronjob database-backup -n production -p '{"spec":{"suspend":true}}'

# Resume
kubectl patch cronjob database-backup -n production -p '{"spec":{"suspend":false}}'

6.4 XXL-Job

// XXL-Job task example (Java)
@XxlJob("databaseBackupHandler")
public void databaseBackup() {
    String param = XxlJobHelper.getJobParam();
    XxlJobHelper.log("Starting database backup, param: {}", param);

    try {
        // Execute backup logic
        String result = backupService.backup(param);
        XxlJobHelper.log("Backup completed: {}", result);
        XxlJobHelper.handleSuccess(result);
    } catch (Exception e) {
        XxlJobHelper.log("Backup failed: {}", e.getMessage());
        XxlJobHelper.handleFail(e.getMessage());
    }
}

Core advantages of XXL-Job:

  • Visual scheduling center: Web UI to manage all tasks, supporting manual triggering, pausing, and viewing execution logs
  • Failure alerting: Automatic email/DingTalk alerts on task failure
  • Routing strategies: First, round-robin, random, sharded broadcast, etc.
  • Task sharding: Large-data tasks sharded across multiple executors for parallel processing
  • Dynamic parameters: Support modifying task parameters dynamically in the UI

6.5 Airflow DAG

# airflow_dags/etl_pipeline.py
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator
from airflow.providers.ssh.operators.ssh import SSHOperator
from datetime import datetime, timedelta

default_args = {
    'owner': 'data-team',
    'depends_on_past': False,
    'email': ['data-alerts@example.com'],
    'email_on_failure': True,
    'email_on_retry': False,
    'retries': 3,
    'retry_delay': timedelta(minutes=5),
    'retry_exponential_backoff': True,
    'max_retry_delay': timedelta(minutes=30),
}

dag = DAG(
    'etl_pipeline',
    default_args=default_args,
    description='Daily ETL data pipeline',
    schedule_interval='0 2 * * *',
    start_date=datetime(2026, 1, 1),
    catchup=False,  # Don't backfill historical tasks
    max_active_runs=1,  # No concurrency
    tags=['etl', 'production'],
)

# Task 1: Check data source availability
check_source = BashOperator(
    task_id='check_source',
    bash_command='curl -sf http://source-api/health || exit 1',
    dag=dag,
)

# Task 2: Extract data
extract_data = SSHOperator(
    task_id='extract_data',
    ssh_conn_id='etl_server',
    command='cd /opt/etl && python3 extract.py --date {{ ds }}',
    dag=dag,
)

# Task 3: Transform data
transform_data = SSHOperator(
    task_id='transform_data',
    ssh_conn_id='etl_server',
    command='cd /opt/etl && python3 transform.py --date {{ ds }}',
    dag=dag,
)

# Task 4: Load data
load_data = SSHOperator(
    task_id='load_data',
    ssh_conn_id='etl_server',
    command='cd /opt/etl && python3 load.py --date {{ ds }}',
    dag=dag,
)

# Task 5: Data quality check
def check_quality(**context):
    import requests
    ds = context['ds']
    resp = requests.get(f'http://quality-service/check?date={ds}')
    if resp.status_code != 200 or resp.json()['score'] < 0.95:
        raise ValueError(f"Data quality check failed: {resp.json()}")
    return resp.json()

quality_check = PythonOperator(
    task_id='quality_check',
    python_callable=check_quality,
    dag=dag,
)

# Task 6: Send notification
notify = BashOperator(
    task_id='notify',
    bash_command='curl -sf -X POST $WEBHOOK_URL -d "ETL pipeline completed: {{ ds }}"',
    dag=dag,
)

# Define dependencies (DAG)
check_source >> extract_data >> transform_data >> load_data >> quality_check >> notify

Core advantages of Airflow DAG:

  • DAG orchestration: Define task dependencies in Python code, supporting complex workflows
  • Rich Operators: BashOperator, PythonOperator, SSHOperator, KubernetesPodOperator, etc.
  • Jinja2 templates: {{ ds }}, {{ prev_ds }} and other template variables auto-inject execution dates
  • Backfill: Can re-run tasks for historical dates
  • UI visualization: DAG graph display, execution history, manual triggering, log viewing

VII. Production Practices

7.1 Scheduled Task Inventory Management

# /opt/scheduler/tasks.yaml - Scheduled task inventory (version-controlled)
tasks:
  - name: database-backup
    description: "Daily database backup"
    schedule: "0 2 * * *"
    command: /opt/scripts/backup.sh
    timeout: 3600
    retries: 3
    alert_on_failure: true
    owner: dba-team
    enabled: true

  - name: log-cleanup
    description: "Log cleanup (retain 30 days)"
    schedule: "0 4 * * *"
    command: /opt/scripts/clean-logs.sh
    timeout: 600
    retries: 1
    alert_on_failure: false
    owner: ops-team
    enabled: true

  - name: ssl-check
    description: "SSL certificate expiry check"
    schedule: "0 9 * * *"
    command: /opt/scripts/check-ssl.py
    timeout: 120
    retries: 2
    alert_on_failure: true
    owner: ops-team
    enabled: true

  - name: metrics-report
    description: "Generate daily monitoring report"
    schedule: "0 8 * * *"
    command: /opt/scripts/generate-report.py
    timeout: 300
    retries: 2
    alert_on_failure: true
    owner: sre-team
    enabled: true

7.2 Generating systemd timers from Inventory

#!/usr/bin/env python3
"""
Generate systemd timer and service files from a YAML inventory
"""

import yaml
import os
from pathlib import Path

SYSTEMD_DIR = "/etc/systemd/system"

def generate_service(task: dict) -> str:
    """Generate service file"""
    return f"""[Unit]
Description={task['description']}
After=network.target

[Service]
Type=oneshot
ExecStart={task['command']}
User=root
TimeoutStartSec={task.get('timeout', 300)}
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
"""

def generate_timer(task: dict) -> str:
    """Generate timer file"""
    persistent = "true" if task.get('persistent', True) else "false"
    return f"""[Unit]
Description=Timer for {task['name']}
Requires={task['name']}.service

[Timer]
OnCalendar={cron_to_systemd(task['schedule'])}
Persistent={persistent}
RandomizedDelaySec=60

[Install]
WantedBy=timers.target
"""

def cron_to_systemd(cron_expr: str) -> str:
    """Convert a cron expression to systemd OnCalendar"""
    parts = cron_expr.split()
    minute, hour, day, month, weekday = parts

    # Simplified conversion (handles common patterns)
    if cron_expr == "@daily":
        return "daily"
    elif cron_expr == "@weekly":
        return "weekly"
    elif cron_expr == "@monthly":
        return "monthly"

    result = []
    if weekday != "*":
        # Convert 0-7 to Mon-Sun
        days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
        if "/" in weekday:
            result.append(days[int(weekday.split("/")[0]) - 1])
        else:
            result.append(days[int(weekday) % 7])

    date_parts = []
    if month != "*":
        date_parts.append(month)
    else:
        date_parts.append("*")
    if day != "*":
        date_parts.append(day)
    else:
        date_parts.append("*")
    date_parts.append("*")

    result.append("-".join(date_parts))

    time_parts = []
    if hour != "*":
        time_parts.append(hour)
    else:
        time_parts.append("*")
    if minute != "*":
        time_parts.append(f"{minute}:00")
    else:
        time_parts.append("*:00")

    result.append(":".join(time_parts))
    return " ".join(result)

def main():
    with open("/opt/scheduler/tasks.yaml") as f:
        config = yaml.safe_load(f)

    for task in config['tasks']:
        if not task.get('enabled', True):
            continue

        name = task['name']
        service_content = generate_service(task)
        timer_content = generate_timer(task)

        service_path = Path(SYSTEMD_DIR) / f"{name}.service"
        timer_path = Path(SYSTEMD_DIR) / f"{name}.timer"

        with open(service_path, 'w') as f:
            f.write(service_content)
        with open(timer_path, 'w') as f:
            f.write(timer_content)

        print(f"Generated: {service_path} + {timer_path}")

    print("\nRun the following commands to enable:")
    print("  systemctl daemon-reload")
    for task in config['tasks']:
        if task.get('enabled', True):
            print(f"  systemctl enable --now {task['name']}.timer")

if __name__ == '__main__':
    main()

7.3 Scheduled Task Inspection

#!/usr/bin/env bash
#
# check_timers.sh - Scheduled task inspection script
#
set -euo pipefail

echo "============================================"
echo "Scheduled Task Inspection Report"
echo "Time: $(date '+%Y-%m-%d %H:%M:%S')"
echo "============================================"

# 1. All timer statuses
echo -e "\n=== Timer List ==="
systemctl list-timers --all --no-pager | head -30

# 2. Failed tasks
echo -e "\n=== Failed Tasks ==="
FAILED=$(systemctl list-units --type=service --state=failed --no-pager | grep -E '\.service' || true)
if [ -n "$FAILED" ]; then
    echo "$FAILED"
else
    echo "No failed tasks"
fi

# 3. Today's execution summary
echo -e "\n=== Today's Execution Summary ==="
TOTAL=0
SUCCESS=0
FAILED_COUNT=0

for timer in $(systemctl list-timers --all --no-pager | grep '\.timer' | awk '{print $1}'); do
    service="${timer%.timer}"
    TOTAL=$((TOTAL + 1))

    if journalctl -u "$service" --since today --no-pager 2>/dev/null | grep -q "Succeeded\|Deactivated successfully"; then
        SUCCESS=$((SUCCESS + 1))
    elif journalctl -u "$service" --since today --no-pager 2>/dev/null | grep -q "Failed\|failed"; then
        FAILED_COUNT=$((FAILED_COUNT + 1))
        echo "  ✗ $service"
    fi
done

echo "  Total: $TOTAL  Succeeded: $SUCCESS  Failed: $FAILED_COUNT"

# 4. Check cron tasks
echo -e "\n=== Cron Tasks ==="
if command -v crontab &>/dev/null; then
    for user in root $(cut -d: -f1 /etc/passwd); do
        CRON_JOBS=$(crontab -u "$user" -l 2>/dev/null | grep -v '^#' | grep -v '^$' || true)
        if [ -n "$CRON_JOBS" ]; then
            echo "  User $user:"
            echo "$CRON_JOBS" | while read -r line; do
                echo "    $line"
            done
        fi
    done
fi

# 5. Check for missed executions
echo -e "\n=== Potentially Missed Tasks ==="
for timer in $(systemctl list-timers --all --no-pager | grep '\.timer' | awk '{print $1}'); do
    service="${timer%.timer}"
    LAST_RUN=$(systemctl show "$service" -p ActiveEnterTimestamp --value 2>/dev/null || echo "")
    if [ -z "$LAST_RUN" ] || [ "$LAST_RUN" = "n/a" ]; then
        echo "  ⚠ $service has never executed"
    fi
done

echo -e "\n============================================"

7.4 Migrating cron to systemd timer

#!/usr/bin/env bash
#
# migrate_cron_to_systemd.sh - Migrate cron tasks to systemd timer
#
set -euo pipefail

SYSTEMD_DIR="/etc/systemd/system"

# Read crontab
CRON_FILE="${1:-/etc/crontab}"

if [ ! -f "$CRON_FILE" ]; then
    echo "File not found: $CRON_FILE"
    exit 1
fi

# Parse cron lines (skip comments and empty lines)
while IFS= read -r line; do
    # Skip comments and empty lines
    [[ "$line" =~ ^[[:space:]]*# ]] && continue
    [[ -z "${line// }" ]] && continue

    # Parse fields: m h dom mon dow user command
    read -r minute hour day month weekday user command <<< "$line"

    # Extract task name from command
    task_name=$(echo "$command" | awk '{print $1}' | xargs basename | sed 's/\.sh$//')

    echo "Migrating: $task_name ($minute $hour $day $month $weekday)"

    # Generate service file
    cat > "${SYSTEMD_DIR}/${task_name}.service" << EOF
[Unit]
Description=Migrated from cron: ${task_name}
After=network.target

[Service]
Type=oneshot
ExecStart=${command}
User=${user}
TimeoutStartSec=3600
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
EOF

    # Generate timer file
    cat > "${SYSTEMD_DIR}/${task_name}.timer" << EOF
[Unit]
Description=Timer for ${task_name}
Requires=${task_name}.service

[Timer]
OnCalendar=${minute}:${hour} *-*-${day:-*}-${month:-*} ${weekday:-*}
Persistent=true
RandomizedDelaySec=60

[Install]
WantedBy=timers.target
EOF

    echo "  -> ${SYSTEMD_DIR}/${task_name}.service"
    echo "  -> ${SYSTEMD_DIR}/${task_name}.timer"

done < "$CRON_FILE"

echo ""
echo "Migration complete. Run the following commands to enable:"
echo "  systemctl daemon-reload"
echo "  # Enable one by one (recommended to test before enabling)"
echo "  systemctl enable --now <task-name>.timer"

Summary

Scheduled tasks may seem simple — just running a script on a timer, right? — but managing them in production is a systems engineering challenge. From cron to systemd timer to distributed scheduling, each layer addresses the limitations of the previous one. Key takeaways:

  1. cron works but isn’t good enough: cron’s syntax is simple and ubiquitous, but it lacks error retry, timeout control, concurrency protection, and log integration. cron is fine for ad-hoc tasks, but don’t rely on it alone for critical tasks
  2. systemd timer is the better single-machine solution: journal log integration, resource limits, persistent catch-up, random delay for avalanche prevention — these features transform scheduled tasks from “runs” to “reliable.” Choose systemd timer for new projects
  3. Four task design principles: Idempotency (safe re-runs), concurrency control (flock or systemd), timeout protection (timeout or TimeoutStartSec), environment isolation (explicit PATH and env vars)
  4. Failures must trigger alerts: cron task failures are silent by default — this is the biggest hidden danger. Use a wrapper script for unified logging and alerting, or use systemd’s OnFailure= mechanism
  5. Distributed scenarios need specialized tools: K8s CronJob for K8s-native scenarios, XXL-Job for small-to-medium Java teams, Airflow for complex data pipeline DAGs. The choice depends on your team’s tech stack and task complexity
  6. Task inventory should be version-controlled: Maintain all scheduled task configurations (name, schedule, command, timeout, owner) in a version-controlled YAML file, with scripts to auto-generate systemd configs. This way task changes have reviewable diffs, and anyone on the team can see “what scheduled tasks are running on the system”
  7. Regular inspection is essential: Scheduled tasks need monitoring too. Inspection scripts check “which tasks have never executed,” “which tasks failed today,” “which tasks have abnormally long execution times” — ensuring the scheduled task system itself is healthy

Scheduled tasks are the infrastructure of automated operations. Their reliability directly impacts business stability. When you bring every scheduled task under version control, equip it with alerting, and inspect it regularly, the “woken up at 3 AM because some cron script died” scenario becomes increasingly rare.

References & Acknowledgments

This article referenced the following materials during writing. We thank the original authors for their contributions:

  1. cron Wikipedia — En, referenced for cron Wikipedia
  2. systemd.timer Official Documentation — freedesktop.org, referenced for systemd.timer Official Documentation