Зачем альтернатива cron
cron — proven, простой, надёжный. Но у него есть фундаментальные ограничения:
- Пропущенные запуски не догоняются. Если VM была off в 06:00, daily job не запустится. Cron не имеет понятия «последний запуск».
- Логи разбросаны. cron сам пишет в syslog, скрипт пишет куда сам redirect-ит, status невозможно получить.
- Нет встроенного supervisor. cron запустил — забыл. Если процесс висит — никто не убьёт.
- Нет dependencies. Нельзя «запусти ETL после того как Postgres готов».
- Нет встроенной jitter. 100 VM с одинаковым
0 6 * * *ударят по DB одновременно. - Минимальное окружение. См. урок
02-cron-gotchas— там целый список граблей.
systemd timer — это альтернатива, решающая эти проблемы. Идея: вместо одного запутанного crontab, у тебя пара .service + .timer:
.serviceописывает, что выполнить (как обычный сервис из урока 04 модуля 15)..timerописывает, когда запускать.
Анатомия: пара .service + .timer
Минимальный пример. Файл orders-etl.service:
[Unit]
Description=Daily orders ETL job
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=oneshot
User=etl
WorkingDirectory=/opt/orders-etl
ExecStart=/opt/orders-etl/venv/bin/python -m orders_etl
EnvironmentFile=/etc/orders-etl/env
StandardOutput=journal
StandardError=journal
Type=oneshot — выполнился и вышел. Это batch-job, не daemon. Нет Restart=on-failure — это не сервис, который должен работать постоянно.
Файл orders-etl.timer:
[Unit]
Description=Daily orders ETL timer
[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
RandomizedDelaySec=5min
Unit=orders-etl.service
[Install]
WantedBy=timers.target
OnCalendar= — расписание. *-*-* 06:00:00 = «любой год, любой месяц, любое число, 06:00:00 локального времени». Эквивалент cron 0 6 * * *.
Запуск:
# Положить оба файла в /etc/systemd/system/
$ sudo cp orders-etl.service orders-etl.timer /etc/systemd/system/
# Перечитать
$ sudo systemctl daemon-reload
# Enable + start TIMER (не service)
$ sudo systemctl enable --now orders-etl.timer
enable --now для timer-а: установит autostart при boot и запустит таймер сейчас (чтобы он начал тикать). Сервис сам запустится в 06:00.
OnCalendar=: формат расписания
OnCalendar= — это более выразительный, чем cron, формат:
DAYS YYYY-MM-DD HH:MM:SS
Все части опциональны:
Гораздо более выразительно и читаемо, чем cron.
Проверить, во сколько следующее срабатывание:
$ systemd-analyze calendar "Mon-Fri *-*-* 09:00:00"
Original form: Mon-Fri *-*-* 09:00:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Mon 2026-05-18 09:00:00 UTC
From now: 4 days left
Это аналог cronitor.io или calendar-parser для cron-выражений, но встроенный.
Persistent=true — главный killer feature
Это самое важное преимущество timers над cron:
[Timer]
OnCalendar=daily
Persistent=true
Persistent=true означает: если timer должен был сработать, пока система была off, при следующем boot он сработает сразу.
Сценарий: laptop с DE-задачами, выключаешь на ночь. cron-job в 0 6 * * * пропустится — VM не работала в 06:00, cron его не запомнит. systemd timer с Persistent=true сработает сразу, как только laptop включится в 09:00.
Для production server это тоже важно: при maintenance window (планируемая перезагрузка) или unexpected reboot timer догонит пропущенные запуски.
Под капотом: systemd хранит timestamp последнего запуска в /var/lib/systemd/timers/. При boot сравнивает с OnCalendar= — если время уже прошло, запускает.
RandomizedDelaySec=: jitter для thundering herd
[Timer]
OnCalendar=*-*-* 06:00:00
RandomizedDelaySec=10min
Без jitter все 100 VM с одинаковым расписанием стрелят в один и тот же момент -> DB перегружается, S3 throttles, kubernetes API rate-limits.
RandomizedDelaySec=10min — добавляет случайную задержку от 0 до 10 минут на каждый запуск. На 100 VM с этим — нагрузка равномерно размазана между 06:00 и 06:10.
В cron это сделать сложнее — обычно через sleep $((RANDOM % 600)) в начале скрипта.
OnBootSec, OnUnitActiveSec — relative timing
Кроме absolute OnCalendar=, есть relative:
[Timer]
# Запустить через 5 минут после boot
OnBootSec=5min
# Запустить через 1 час после ПРОШЛОГО запуска связанного service
OnUnitActiveSec=1h
OnUnitActiveSec=1h — это «через 1 час после прошлой активации сервиса». Если сервис long-running и upgraded — recalculation. Полезно для health-checks или monitoring.
Можно комбинировать: timer срабатывает на первое наступившее условие.
Запуск, статус, debugging
# Активировать
$ sudo systemctl enable --now orders-etl.timer
# Статус timer (НЕ service)
$ systemctl status orders-etl.timer
● orders-etl.timer - Daily orders ETL timer
Loaded: loaded (/etc/systemd/system/orders-etl.timer; enabled)
Active: active (waiting) since Mon 2026-05-13 08:42:11 UTC; 4h 23min ago
Trigger: Wed 2026-05-14 06:00:00 UTC; 13h left
Triggers: ● orders-etl.service
# Что сейчас запланировано:
$ systemctl list-timers
NEXT LEFT LAST PASSED UNIT
Wed 2026-05-14 06:00:00 UTC 13h left Tue 2026-05-13 06:00:00 UTC 6h 23min orders-etl.timer
Wed 2026-05-14 09:00:00 UTC 16h left Tue 2026-05-13 09:00:00 UTC 3h 23min weekly-report.timer
...
# Логи последнего запуска сервиса:
$ journalctl -u orders-etl.service --since today
# Логи самого таймера (когда срабатывает):
$ journalctl -u orders-etl.timer
# Запустить вручную (для теста):
$ sudo systemctl start orders-etl.service
systemctl status orders-etl.timer показывает: when next, when last, какой service triggered. Это намного больше информации, чем cron даёт.
systemctl list-timers — обзор всех timer-ов на системе. Очень полезная команда для DE-аудита: «что у нас запланировано?».
Сравнение: cron vs systemd timer
Каждый имеет свою нишу.
Когда выбирать timer:
- Production DE-задачи (Airflow scheduler health, daily reports).
- Нужны dependencies («ETL стартует после Postgres ready»).
- Важен Persistent (нет thundering herd при batch reboot).
- Хочешь structured logs через journal.
Когда выбирать cron:
- Простой однострочник, нет dependencies.
- Personal/dev машина.
- Стандартный legacy паттерн, который все знают.
- Embedded system без systemd.
В production-DE команде обычно используют оба: cron для simple sysadmin задач (log rotation, simple cleanup), timer для serious DE pipeline шагов.
Kubernetes CronJob — те же идеи в кластерном окруженииDE-сценарий: hourly aggregation с dependencies
Требования: каждый час запускать Python-скрипт, который агрегирует данные из Postgres в S3. Зависит от того, что Postgres готов. Не должен запускаться, если предыдущий ещё работает (lock).
hourly-agg.service:
[Unit]
Description=Hourly aggregation to S3
After=network-online.target postgresql.service
Wants=network-online.target postgresql.service
ConditionPathExists=/etc/hourly-agg/env
[Service]
Type=oneshot
User=etl
WorkingDirectory=/opt/hourly-agg
EnvironmentFile=/etc/hourly-agg/env
ExecStart=/opt/hourly-agg/venv/bin/python -m hourly_agg
# Resource limits
MemoryMax=4G
CPUQuota=200%
TasksMax=128
# Защита от concurrent — systemd сам не запустит второй экземпляр oneshot,
# пока первый работает. Это эквивалент flock без flock.
# Логи в journal
StandardOutput=journal
StandardError=journal
# Sandbox
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/log/hourly-agg /tmp/hourly-agg-cache
hourly-agg.timer:
[Unit]
Description=Hourly aggregation timer
[Timer]
# Каждый час в :05 (5 минут после начала часа, чтобы данные за прошлый час уж точно были в DB):
OnCalendar=*-*-* *:05:00 UTC
Persistent=true
RandomizedDelaySec=2min
Unit=hourly-agg.service
[Install]
WantedBy=timers.target
Persistent=true гарантирует, что если VM была off час, при boot догонит. RandomizedDelaySec=2min — jitter (хорошо если VM много).
After=postgresql.service в .service — гарантирует, что postgres готов. ConditionPathExists=/etc/hourly-agg/env — не запускать без конфига.
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now hourly-agg.timer
$ systemctl list-timers --no-legend | grep hourly-agg
Wed 2026-05-13 13:05:00 UTC 22min left ... hourly-agg.timer hourly-agg.service
В 13:05 (с jitter до 13:07) запустится. Логи: journalctl -u hourly-agg --since today.
Гибридный паттерн: cron триггерит systemd unit
Иногда удобно: расписание в cron (старый, знакомый), а сама задача — systemd-юнит:
# crontab
0 6 * * * sudo systemctl start orders-etl.service
Это даёт: cron-простоту расписания + systemd resource limits, logging, dependencies. Sudoers нужно настроить, чтобы cron-user мог systemctl start нужного сервиса без пароля.
Это редкий паттерн — обычно лучше или чистый cron, или чистый timer.
migration: переписать cron-job в timer
Был cron:
0 6 * * * etl /opt/etl/run.sh >> /var/log/etl.log 2>&1
Стало пара unit-ов. Шаги:
-
Создать
etl.service:[Unit] Description=Daily ETL [Service] Type=oneshot User=etl ExecStart=/opt/etl/run.sh StandardOutput=journal StandardError=journal -
Создать
etl.timer:[Unit] Description=Daily ETL timer [Timer] OnCalendar=*-*-* 06:00:00 Persistent=true Unit=etl.service [Install] WantedBy=timers.target -
Деплой:
$ sudo cp etl.service etl.timer /etc/systemd/system/ $ sudo systemctl daemon-reload $ sudo systemctl enable --now etl.timer $ sudo crontab -u etl -e # удалить старую cron строчку
Готово. Логи теперь через journalctl -u etl, статус через systemctl status etl.timer.
Попробуй сам
- Список всех timer на системе:
systemctl list-timers - Когда следующий запуск apt-daily?
systemctl list-timers apt-daily.timer - Проверить calendar-выражение:
systemd-analyze calendar "Mon-Fri *-*-* 09:00:00" - Создай тестовый timer (требует root):
# /etc/systemd/system/test-echo.service # [Service] # ExecStart=/bin/echo "Hello from timer" # /etc/systemd/system/test-echo.timer # [Timer] # OnUnitActiveSec=1min # OnBootSec=1min # [Install] # WantedBy=timers.target sudo systemctl daemon-reload sudo systemctl enable --now test-echo.timer # Через 1 мин: journalctl -u test-echo.service
macOS-различия
На macOS нет systemd -> нет systemd timer. Аналог — launchd с StartCalendarInterval:
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>6</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
Persistent runs (missed) тоже есть: RunAtLoad=true + StartCalendarInterval примерно эквивалентны systemd Persistent=true. Синтаксис заметно другой.
Главное
- systemd timer = пара
.service(что) +.timer(когда). OnCalendar=— расписание. Более выразительно cron:*-*-* 06:00:00,Mon-Fri 09:00,*-*-01 02:30.Persistent=true— killer feature: при boot догоняет пропущенные запуски.RandomizedDelaySec=— jitter против thundering herd. В cron нужно вручную черезsleep $RANDOM.- Dependencies через
After=/Requires=в.service— «запусти после Postgres». - Логи в journal автоматически.
journalctl -u SVC --since today— структурированно. systemctl list-timers— обзор всех timer на системе. Когда last, когда next.systemd-analyze calendar EXPR— проверить расписание, когда следующее срабатывание.- Когда timer: production DE-jobs, dependencies, Persistent, structured logs.
- Когда cron: одностроки, dev/personal, legacy, embedded без systemd.
- Гибрид (cron -> systemctl start SVC) возможен, но редко лучшее решение.