A Docker-native cronjob scheduler that monitors containers and executes scheduled tasks based on RRULE labels.
- RRULE-based scheduling: Uses RFC 5545 recurrence rules instead of cron syntax
- Docker-native: Monitors Docker containers via labels
- Event-driven: Automatically detects container changes
- Concurrent execution: Runs multiple jobs in parallel
- No persistent storage: Fully in-memory, stateless design
- OpenTelemetry-ready: Optional, vendor-neutral metrics and traces, including trace-context propagation into executed jobs (details)
version: '3.8'
services:
scheduler:
image: qodev/cronjobs:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- LOG_LEVEL=INFO # Optional: DEBUG, INFO, WARNING, ERROR
restart: unless-stopped
# Example worker container with scheduled job
worker:
image: python:3.12-slim
labels:
ai.qodev.cronjobs: |
FREQ=MINUTELY;INTERVAL=5 => echo "Running every 5 minutes"
FREQ=HOURLY => python /app/hourly_task.py
command: sleep infinityversion: '3.8'
services:
scheduler:
build: https://github.com/qodevai/cronjobs.git
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stoppedThen start with:
docker-compose up -dAdd jobs to containers using the ai.qodev.cronjobs label:
ai.qodev.cronjobs: '<RRULE> => <command>'
RRULE can be uppercase, lowercase, or mixed case - all are supported.
Examples:
# Every 5 minutes (uppercase)
ai.qodev.cronjobs: 'FREQ=MINUTELY;INTERVAL=5 => python sync.py'
# Every 5 minutes (lowercase works too!)
ai.qodev.cronjobs: 'freq=minutely;interval=5 => python sync.py'
# Daily at 2 AM
ai.qodev.cronjobs: 'FREQ=DAILY;BYHOUR=2;BYMINUTE=0 => python cleanup.py'
# Weekdays at 9 AM
ai.qodev.cronjobs: 'FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9 => python report.py'
# Multiple jobs (newline-separated)
ai.qodev.cronjobs: |
FREQ=HOURLY => python hourly.py
FREQ=DAILY;BYHOUR=3 => python daily.py
Common patterns:
FREQ=MINUTELY;INTERVAL=5- Every 5 minutesFREQ=HOURLY- Every hour (on the hour)FREQ=DAILY;BYHOUR=14;BYMINUTE=30- Daily at 2:30 PMFREQ=WEEKLY;BYDAY=MO- Every MondayFREQ=WEEKLY;BYDAY=MO,WE,FR;BYHOUR=9- Mon/Wed/Fri at 9 AMFREQ=MONTHLY;BYMONTHDAY=1- First day of each month
See RFC 5545 for full RRULE specification.
# Install dependencies
uv sync --all-extras
# Run tests
uv run pytest
# Run specific test file
uv run pytest tests/test_scheduler.py -vsrc/cronjob_scheduler/
models.py # Job model and label parsing
scheduler.py # Job scheduling logic
executor.py # Job execution in containers
docker_watcher.py # Container monitoring
main.py # Main orchestrator
# Build and run with docker-compose
docker-compose up --build
# Check health
curl http://localhost:8080/health- Docker Watcher: Monitors container events (start/stop/die) and syncs jobs
- Scheduler: Event-driven scheduler that sleeps until next job is due
- Executor: Runs commands in target containers via Docker exec
- Jobs are anchored: All schedules align to
2025-01-01T00:00:00Zfor consistent timing
- Scheduler scans containers for
ai.qodev.cronjobslabels on startup - Parses RRULE and calculates next run time for each job
- Sleeps until next job is due (event-driven, no polling)
- When job is due, fires it via
docker exec(non-blocking) - Immediately calculates next run time and continues
- Container changes trigger immediate re-sync
Configure logging verbosity with the LOG_LEVEL environment variable:
services:
scheduler:
image: qodev/cronjobs:latest
environment:
- LOG_LEVEL=DEBUG # DEBUG, INFO (default), WARNING, ERRORLog levels:
DEBUG: Detailed information for diagnosing problems (sleep timings, job registration, etc.)INFO: General informational messages (jobs found, jobs executed, container events)WARNING: Warning messages (invalid labels, failed jobs)ERROR: Error messages only
The scheduler can export OpenTelemetry metrics and traces over
OTLP/HTTP. It is opt-in and vendor-neutral — configured entirely through the standard
OTEL_* environment variables, with no specific backend assumed. When no OTLP endpoint is
configured, telemetry stays a no-op and adds no overhead.
Set an OTLP endpoint (and, for most backends, auth headers and resource attributes):
services:
scheduler:
image: qodev/cronjobs:latest
environment:
# Required to enable telemetry — base OTLP/HTTP endpoint (paths are appended automatically)
- OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com
# Optional: headers for authenticated collectors (e.g. HTTP Basic / API key)
- OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <base64>
# Optional: identify this service in your backend
- OTEL_RESOURCE_ATTRIBUTES=service.namespace=myproject,service.name=schedulerRecognised environment variables (all standard OpenTelemetry SDK variables):
| Variable | Purpose |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
Base OTLP/HTTP endpoint. Telemetry is disabled unless this (or a signal-specific endpoint) is set. The exporter appends /v1/metrics and /v1/traces. |
OTEL_EXPORTER_OTLP_HEADERS |
Comma-separated headers, e.g. Authorization=Basic abc123. |
OTEL_EXPORTER_OTLP_PROTOCOL |
Defaults to http/protobuf (the only protocol bundled). |
OTEL_RESOURCE_ATTRIBUTES |
Resource attributes, e.g. service.namespace=...,service.name=.... |
OTEL_SERVICE_NAME |
Shorthand for service.name (defaults to cronjob-scheduler). |
OTEL_SDK_DISABLED |
Set to true to force telemetry off even when an endpoint is configured. |
Metrics
| Metric | Type | Description |
|---|---|---|
cronjob.executions |
Counter | One increment per job run. |
cronjob.duration |
Histogram (seconds) | Wall-clock duration of each run. |
cronjob.last_run_failed |
Gauge | 1 if a job's most recent run did not succeed, else 0. |
cronjob.executions and cronjob.duration carry the attributes cronjob.job_id,
cronjob.container_name, and cronjob.status (success | failure | timeout |
error); cronjob.last_run_failed carries cronjob.job_id and cronjob.container_name.
For alerting, prefer cronjob.last_run_failed: it holds each job's most recent outcome
until that job runs again, so an alert on latest(cronjob.last_run_failed) == 1 (grouped
by cronjob.job_id) stays firing until the same job succeeds, rather than resolving
once the failure ages out of a window. Alerting on cronjob.executions where
cronjob.status != "success" instead is windowed, so a brief periodic failure flaps
(fires, then auto-resolves when the window clears) rather than latching.
That latching only works if a job keeps the same identity, so cronjob.job_id is built
from the container name and the command — dashboard-1:python-sync_emails.py — not
from the container id or the job's position in the label. A redeploy, a reordered label,
or a rescheduled job therefore continues the same series instead of starting a new one,
and the id names the job in the alert. Changing a job's command is a new identity, by
design.
Traces
Each run produces one cronjob.execute span (service = the scheduler) carrying cronjob.job_id,
cronjob.container_name, cronjob.command, cronjob.exit_code, and cronjob.status. The span
is marked as an error when the job exits non-zero, times out, or fails to start.
Before running a command, the scheduler injects the active span's
W3C trace context into the command's environment as
TRACEPARENT (and TRACESTATE when present). An OpenTelemetry-instrumented job can extract it
and continue the same trace, so the job's own spans appear as children of cronjob.execute —
giving you one end-to-end trace per run, even across services.
The scheduler only exposes the standard variables; how (and whether) a job uses them is up to that job. In Python, for example:
import os
from opentelemetry.propagate import extract
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
parent = extract({
"traceparent": os.environ.get("TRACEPARENT", ""),
"tracestate": os.environ.get("TRACESTATE", ""),
})
with tracer.start_as_current_span("my-job", context=parent):
... # this span (and its children) join the scheduler's traceNote: language SDKs do not auto-read
TRACEPARENTfrom the environment, so each job that wants to be linked needs a few lines like the above. Jobs that don't opt in still get the standalonecronjob.executespan (duration + status); linking simply lights up per job.
- Docker socket access (
/var/run/docker.sock) - Python 3.11+
-
Check scheduler logs:
docker logs cronjob-scheduler
or if using compose:
docker-compose logs scheduler
-
Verify RRULE syntax:
- Use uppercase:
FREQ=HOURLY(lowercase also works but uppercase is recommended) - Check RFC 5545 specification for valid patterns
- Test your RRULE at rrule.dev
- Use uppercase:
-
Verify container is running:
docker ps
-
Check if scheduler detected your container:
- With
LOG_LEVEL=INFO, you'll see:Found X job(s) in container ... - If not shown, verify the
ai.qodev.cronjobslabel is set correctly
- With
If you see "Invalid cronjob label" warnings:
- Check that you're using
=>separator (not=or:) - Ensure both RRULE and command are present
- For multi-line labels, use YAML's
|syntax:labels: ai.qodev.cronjobs: | FREQ=HOURLY => command1 FREQ=DAILY => command2
-
Test manually:
docker exec <container-name> sh -c "your-command"
-
Check if binaries exist in container
-
Review command output in logs (visible with
LOG_LEVEL=DEBUG)
See CONTRIBUTING.md for development setup and guidelines.
See SECURITY.md for security considerations and reporting vulnerabilities.
MIT