cronaute

cronaute

A cron daemon for containers: one static binary, one user, no spool, and a read-only dashboard.

It is a reimplementation of webdevops/go-crond in Crystal, cut down to what a container actually needs. The crontab is the only source of truth — it is edited out of band, in a mounted volume or by gitops, and reloaded on change. Nothing in the HTTP surface writes to it.

What it does not do

Stated first, because the omissions are the design:

  • No multi-user. No user column in the crontab, no per-user spool, no fork/setuid, no privilege drop. Every job runs under the account the daemon runs as.
  • No reaping. The daemon is expected to run under an init that is PID 1 — tini, typically. Orphans a job leaves behind are reparented there and reaped there. Process#wait on a direct child is all the child handling this does, and that is where the exit code and the duration come from.
  • No editing from the UI. No POST, no form, no database. Every HTTP method other than GET is answered 405.
  • No persistence. Execution state lives in memory. On restart, occurrences are recomputed from the crontab; the full history is in the JSON log.

Running it

cronaute /etc/crontab --include=/etc/cron.d --run-parts-daily=/etc/cron.daily

In an image, as a child of an init process:

ENTRYPOINT ["tini", "-w", "--", "entrypoint"]

where entrypoint ends with exec cronaute "$@".

Exit codes: 0 on a clean shutdown, 2 on a usage mistake, 1 on anything else — so a wrapper can tell "I invoked it wrong" from "it broke".

Crontab format

Standard five fields, single-user, so no user column:

SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin

*/15 * * * *  /usr/bin/backup --incremental
0 3 * * 0     /usr/bin/backup --full

Blank lines and # comments are skipped. KEY=VALUE assignments apply to every job declared after them and are passed to the command's environment; one layer of matching quotes is stripped.

Seconds

A sixth field may be given, in which case the leading one is seconds:

*/30 * * * * *  /usr/bin/tick

The split between schedule and command is resolved by what actually parses as a cron field, so 0 0 * * * echo hi reads as five fields and echo hi.

Shorthands

@yearly, @annually, @monthly, @weekly, @daily, @midnight and @hourly behave as they do in Vixie cron. @reboot is rejected: its next run cannot be predicted.

@every takes a Go-style duration and is anchored on when the crontab was loaded, which is what it means in go-crond:

@every 5m     /usr/bin/poll
@every 1h30m  /usr/bin/sweep

Durations are a run of <value><unit> with units ms, s, m and h. Anything not consumed in full is rejected rather than truncated: 1h30 and 5ns raise instead of quietly meaning something else.

Day of month and day of week

When both are restricted, a day matches if it satisfies either — the standard cron union, not an intersection. A field counts as unrestricted when it begins with *, so 0 0 */2 * 5 fires on Fridays only.

run-parts

Runs every executable of a directory, in alphabetical order, skipping names that are not run-parts names — alphanumerics, underscores and hyphens, and notably no dot, which is what keeps backup.dpkg-old from running beside backup.

Flag Schedule
--run-parts-1min=DIR * * * * *
--run-parts-15min=DIR */15 * * * *
--run-parts-hourly=DIR 0 * * * *
--run-parts-daily=DIR 0 0 * * *
--run-parts-weekly=DIR 0 0 * * 0
--run-parts-monthly=DIR 0 0 1 * *

The named periods map to cron lines rather than to durations, because "weekly" and "monthly" are calendar notions: seven days after the last run drifts off the day of the week, and a month has no fixed length.

For anything else, --run-parts=TIMESPEC:PATH sweeps a directory on a fixed interval aligned on the wall clock, counted from local midnight — 1h fires on the hour, 15m on the quarters:

cronaute --run-parts=10s:/etc/periodic/fast --run-parts=1h30m:/etc/periodic/slow

The argument splits on the first colon, so a path may contain more. An interval longer than a day is rejected: alignment is counted from midnight, so it would have no grid to sit on and would silently become daily. Use --run-parts-weekly, --run-parts-monthly or a cron line instead.

Note the difference from @every, which reads alike and does not behave alike: @every 1h fires one hour after the last run, wherever that falls; --run-parts=1h:DIR fires on the hour.

Options

--include=DIR                Load every file of DIR as a crontab (repeatable)
--run-parts-<period>=DIR     See above (repeatable)
--run-parts=TIMESPEC:DIR     Sweep DIR every TIMESPEC (repeatable)
--overlap=allow|skip         What to do when a run is still going (default allow)
--host=ADDR                  HTTP bind address (default 0.0.0.0)
--port=PORT                  HTTP port (default 8080)
--max-concurrent-jobs=N      How many jobs may run at once (default 16)
--healthcheck                Probe a running daemon and exit 0 or 1
--healthcheck-path=PATH      What --healthcheck asks for (default /live)
--verbose                    Log scheduling decisions, not only executions
--licenses                   Print the bundled third-party notices
--version                    Print the version banner
--dumpversion                Print the bare version number
--help                       Print the usage

Crontabs are positional arguments. --include applies the same naming rule as Debian's /etc/cron.d — no dot — but does not require the executable bit: those are crontabs, not scripts.

Overlap

--overlap=allow is the default because it is what Vixie cron and go-crond do, and a drop-in replacement that quietly skipped executions would be a trap. Use --overlap=skip for a job that can outrun its own period, where the default piles up processes until something gives; skipped occurrences are logged.

Not carried over from go-crond

--allow-unprivileged has no meaning here — there is nothing to drop. --log.json is not a flag because the log is always JSON. --server.bind=ADDR:PORT is split into --host and --port.

Time zones

Schedules are computed in the zone TZ names, on the wall clock, the way a system cron does. TZ absent, empty or unknown falls back to UTC — not the host zone, so that the same image schedules identically wherever it runs.

Daylight saving is handled by the pinned cron_parser fork:

  • An occurrence whose wall clock a zone skipped runs at the instant the clock jumped to, where Vixie runs it — a 02:30 job runs at 03:00 on the spring-forward day in Europe/Paris.
  • An occurrence inside a repeated hour runs once, not twice.
  • #next is always strictly after the time it is given, in every zone, so the scheduler cannot loop on a stationary occurrence.

The scheduler carries its own guard for that last point anyway, and logs an error if a schedule ever stops advancing.

HTTP

Five GET routes, on port 8080 by default. Anything else is 404; any other method is 405.

Route What it serves
/ Read-only dashboard: schedule, command, source, last run with its exit code and duration, next run, counters
/api/jobs The same as JSON
/metrics Prometheus text format
/health Readiness — is the configuration good?
/live Liveness — is the tick loop turning?

Readiness and liveness are not the same question

They trigger opposite reactions, which is the whole reason there are two:

  • /health answers 200 once a crontab has been loaded without error and 503 while the last attempt failed, with a body carrying jobs_loaded, last_reload_ok, last_reload_at and last_reload_error. A failing readiness probe takes an instance out of service. That is the right reaction to a crontab pushed broken into the volume: restarting would not fix the file, and it would destroy the previously loaded, working schedule that is still running jobs — turning a configuration mistake into an outage.

  • /live answers 200 while the scheduler has completed a pass within the last 30 seconds, 503 otherwise, with last_tick_at and grace_seconds. A failing liveness probe restarts the container, and that is the right reaction to the failure it catches: a wedged or dead tick fiber leaves a process that answers every request, a crontab that parses, a dashboard that renders — and not one job ever starting again. From outside, that is indistinguishable from a daemon with nothing to do.

    It says nothing about the crontab. A daemon whose configuration is broken is not stuck.

In Kubernetes, where the two are separate settings:

readinessProbe:
  httpGet: { path: /health, port: 8080 }
livenessProbe:
  httpGet: { path: /live, port: 8080 }
  periodSeconds: 30
  failureThreshold: 3

Docker HEALTHCHECK

Docker has a single health state, so it cannot express both. The image already carries one:

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD ["cronaute", "--healthcheck"]

Two things about that line:

  • The binary probes itself. The released image is gcr.io/distroless/static-debian12 — no shell, no curl, no wget. A HEALTHCHECK CMD curl … cannot work there, so --healthcheck performs the request and exits 0 or 1. It reads --host and --port, substituting the loopback when the daemon is bound to 0.0.0.0.
  • It asks /live, not /health. Docker's health state is commonly wired to something that restarts the container: Swarm reschedules an unhealthy task, and the various autoheal sidecars restart it. Restarting on a broken crontab would destroy the working schedule still in force. /live only goes red on a stopped tick loop, which a restart does fix.

If nothing in your chain restarts on unhealthy — plain docker compose, where the state is a flag and a depends_on: service_healthy gate — then /health is the more informative one:

CMD ["cronaute", "--healthcheck", "--healthcheck-path=/health"]

Either way, the crontab's state is best watched through cronaute_last_reload_ok in the metrics, which is where a broken configuration belongs: it is an alert, not a restart.

Metrics exposed: cronaute_build_info, cronaute_last_reload_ok, cronaute_jobs_loaded, cronaute_last_reload_timestamp_seconds, and per job cronaute_job_next_run_timestamp_seconds, cronaute_job_last_run_timestamp_seconds, cronaute_job_last_duration_seconds, cronaute_job_last_exit_code, cronaute_job_running, cronaute_job_runs_total and cronaute_job_failures_total.

Logging

One JSON object per line on stdout — the container is the rotation, the shipper and the retention policy. Every execution leaves a record, and nothing else persists it:

{"ts":"2026-08-09T14:38:39.015Z","level":"info","source":"cronaute.executor",
 "message":"job finished","job":"/etc/crontab#0\t*/15 * * * *\t/usr/bin/backup",
 "label":"/usr/bin/backup","schedule":"*/15 * * * *","origin":"/etc/crontab:4",
 "start":"2026-08-09T14:38:39.004Z","end":"2026-08-09T14:38:39.015Z",
 "exit_code":0,"duration_ms":10.9}

source is the component that emitted the line and origin the crontab line the job came from — deliberately two names, since a key written twice in one JSON object is not an error and a parser simply keeps the last one.

A job's stdout and stderr are logged too, bounded so that a command writing without end cannot take the daemon with it. A process killed by a signal reports 128 + signal; a command that could not be launched reports 127.

Reloading

The crontab files and --include directories are watched, and a change reloads them. Reloading is atomic: everything is read and parsed before the current set is replaced, so a broken crontab leaves the daemon running exactly what it was running, and only flips /health to 503.

Execution history survives a reload for every job whose identity is unchanged — identity being its file, schedule and command, deliberately not its line number, so that inserting a line at the top of a crontab does not blank the dashboard for every job below it.

Watching is done by polling rather than inotify. inotify is Linux-only, and it handles the container case worst: a bind mount replaced wholesale, or a file swapped by rename, produces events a watch on the old inode never sees.

Building

mise dev:deps           # install dependencies
mise dev:build          # build bin/cronaute
mise dev:spec           # run the suite
mise dev:spec-mt        # run it multi-threaded
mise dev:format-check   # formatting
mise dev:ameba          # static analysis
mise dev:docs           # crystal doc
mise release:static     # static Linux binaries, amd64 and arm64, via Docker

mise pins the compiler; the CI runs these same tasks.

The binary embeds the third-party notices it owes, assembled at build time from licenses.manifest and licenses-spdx/cronaute --licenses prints them. Every task that compiles depends on that assembly, crystal doc included, since doc runs the macros too.

Repository

cronaute

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 5
  • about 3 hours ago
  • August 9, 2026
License

MIT License

Links
Synced at

Sun, 09 Aug 2026 15:15:55 GMT

Languages