Гравюра Яна Питерса Санредама «Пещера Платона»; изображение часов (Wikimedia, лицензия CC BY 3.0); логотип systemd (проект systemd, лицензия CC-BY-SA 4.0)
Гравюра Яна Питерса Санредама «Пещера Платона»; изображение часов (Wikimedia, лицензия CC BY 3.0); логотип systemd (проект systemd, лицензия CC-BY-SA 4.0)

Как же я люблю метонимичный технологический термин «задача cron». Несмотря на то, что реальным демоном, выполняющим задачи по расписанию, может являться совсем не cron, мы называем этим именем всё, что «ходит» как cron и «крякает» как cron. Как говорит Патрик Маккензи, задачи cron — это одни из самых полезных вычислительных примитивов. Их ценность легко демонстрируется множеством контекстов применения, с которыми сталкиваются почти все, кому требуется регулярно выполнять задачи, например, на ежедневной или ежемесячной основе.

И всё же использовать для планировки задач реальный cron вам, пожалуй, не следует. В 2026 году уже есть более продуманные варианты, и моим любимчиком является скромный таймер systemd. Я люблю таймеры systemd. И если вы о себе пока так сказать не можете, то я приведу аргументы, которые заставят вас пересмотреть свой взгляд.

Неужели с cron покончено?

Таймер systemd — это вид юнита, который планирует запуск других юнитов (обычно сервисов) по определённому расписанию. (Механизм работы сервисного юнита systemd заслуживает отдельной статьи, но для простоты понимания можете считать его обычным скриптом). По своей сути, таймеры представляют функциональную замену традиционному демону cron (хотя их вполне можно использовать параллельно), а формат настроек календаря в них похож на привычные cron-выражения, что позволяет смягчить переход между этими инструментами.

И на этом моменте обычно возникают нелюбители systemd, готовые наброситься на таймеры просто потому, что те являются частью проекта systemd и заменяют собой зрелую (пусть и топорную) технологию. Я предпочёл бы не тратить время на споры о cron, поэтому просто приведу краткие доводы, почему новые решения вроде таймеров, которые созданы на основе многолетнего опыта, лучше:

  • Неоднозначные настройки $PATH делают выполнение скрипта cron менее предсказуемым.

  • Вывод stdout и stderr часто отправляется в «чёрную дыру» (либо автоматом сливается в почтовую систему хоста, что обычно нам совсем не нужно).

  • Историю выполнения сложно проследить и проанализировать.

  • Вы можете чувствовать себя уверенно, зная синтаксис cron наизусть, но читать что-то вроде 01,31 04,05 1-15 1,6 * непросто, и уж точно не интуитивно.

И таймеры как раз решают все эти и другие проблемы.

Основы таймеров

Для знакомства с таймерами никаких особых церемоний не требуется. Для начала вам нужно что-то, что таймер будет запускать. Если на хосте с Linux запущен systemd, то размещение следующего содержимого в файле /etc/systemd/system/roulette.service даст вам готовый к запуску сервис, который с вероятностью 10% освободит вас от цифровых оков (то есть просто выключит ваш ПК):

[Unit]
Description=1 in 10 chance to break your chains

[Service]
ExecStart=/usr/bin/env bash -c '[[ $(($RANDOM % 10)) == 0 ]] && systemctl poweroff || echo LIVE ANOTHER DAY'
Обновление от 05.05.2026

Мой знакомый из Twitter* под ником @HSVSphere подсказал, что директива ExecCondition= предлагает нативный подход к реализации условного выполнения. Это более интегрированный способ ответить на вопрос «стоит ли продолжать выполнение?», и я согласен, что он позволяет нагляднее выразить намерения на уровне самого юнита (здесь я использую абсолютные пути для системы NixOS):

[Unit] Description=1 in 10 chance to break your chains
[Service] ExecCondition=/run/current-system/sw/bin/bash -c ‘[[ RANDOM % 10)) == 0 ]]’ ExecStart=/run/current-system/sw/bin/systemctl poweroff

Результат получится такой же, что и в прошлом условии с bash, но в журнале будет уже другая формулировка. На мой взгляд, она лучше отражает ситуацию, когда это условие срабатывает:

May 05 11:05:32 diesel systemd[3117]: Condition check resulted in 1 in 10 chance to break your chains being skipped.

Как правило, применение предлагаемых systemd вариантов лучше, чем прописывание в скриптах собственной логики. (Ещё одним примером будет использование OnFailure= на случай сбоя сервисных скриптов или Restart= для попытки перезапустить процесс в случае временного отказа).

Теперь связываем этот сервис с таймером, размещая файл с тем же базовым именем (roulette) по пути /etc/systemd/system/roulette.timer:

[Unit]
Description=impending destruction

[Timer]
OnCalendar=10:00

[Install]
WantedBy=timers.target

Под связыванием я имею в виду, что по умолчанию через директиву таймера Unit= выбирается юнит с таким же базовым именем и расширением .service — в нашем случае roulette.service. Если вы захотите запустить сервис с другим именем юнита, эту настройку всегда можно изменить.

Сразу проговорю пару моментов:

  • Согласно стандартной семантике сервисных юнитов, директива ExecStart= по умолчанию не выполняется как команда оболочки. Вам следует рассматривать целевой файл как скрипт или, как в нашем случае, интерпретатор, ожидающий получить скрипт в виде строкового аргумента. Например, ExecStart=/usr/bin/echo Hello | /usr/bin/awk попросту не сработает; в этом контексте использование символа конвейера не имеет смысла.

  • Аргумент ExecStart= по умолчанию не наследует никакие переменные среды (за исключением некоторых предустановок системного менеджера), поэтому начинаем мы с практически пустой $PATH. Запуск через /usr/bin/env — это ловкий приём, обеспечивающий доступность таких утилит, как systemctl, ведь базово вы получаете абсолютно чистую среду. Если бы мы запустили ExecStart=/usr/bin/bash, то в $PATH был бы необходимый минимум, но использование env здесь выступает дополнительной подстраховкой.

Вы можете крутануть рулетку и без помощи таймера:

systemctl start roulette

И хотя без секции [install] вы не сможете включить этот сервис через enable, в этом случае стандартным способом его согласованного запуска является таймер. Также имейте ввиду, что systemctl будет работать с roulette.service по умолчанию без явного указания его расширения.

При применении к юниту .timer подкоманда systemctl start как бы заводит будильник, но фактически целевой Unit= не выполняет.

systemctl start roulette.timer

Теперь таймер активен, но сам сервис — нет.

В зависимости от конкретного момента времени команда status покажет, когда для таймера наступит следующий момент решать вашу судьбу:

systemctl status roulette.timer

На странице статуса хранится много информации о таймере, включая момент его следующего срабатывания:

Trigger: Sat 2026-04-18 10:00:00 MDT; 35min left

Простейший механизм настройки таймера выглядит так: вы создаёте целевой сервис, добавляете к нему файл таймера с расписанием и запускаете этот таймер, а не сервис. Поскольку в секции [install] файла .timer определена директива WantedBy=, можно сделать так, чтобы таймер стартовал и при загрузке системы, а не только при запуске вручную.

systemctl enable roulette.timer

На этом основы закончены. Идём далее.

Повелитель времени

Пожалуй, наиболее важным для понимания таймеров является то, как правильно задавать расписание. Это может быть как регулярный запуск через определённый интервал времени (в руководстве называемый time span), так и запуск в конкретное календарное время (timestamp). К счастью, раздел мануала systemd.time(7) описывает всё это очень подробно с большим количеством примеров. При составлении своих первых таймеров рекомендую использовать его в качестве основного источника информации. Всё это надёжнее и информативнее, чем доверяться поверхностным статьям в блогах.

В systemd также есть утилита systemd-analyze, которая умеет прямо в командной строке интерактивно проверять и толковать выражения установки расписания, упрощая их понимание. С её помощью вы можете даже разгадать классическое выражение cron со звёздочками, которое systemd-analyze спарсит и пояснит, в том числе показав точное время будущих запусков:

systemd-analyze calendar '*-*-* *:*:*'
Normalized form: *-*-* *:*:*
    Next elapse: Sat 2026-04-18 16:44:26 MDT
       (in UTC): Sat 2026-04-18 22:44:26 UTC
       From now: 431ms left

В этой статье ни к чему пересказывать весь раздел systemd.time(7), поэтому я рекомендую вам самим прочесть мануал. Если кратко, то вы можете легко настроить запуск либо по календарному времени, либо — в противоположность старому-доброму cron — через определённый период времени относительно конкретного прошлого события.

Первую категорию выражений установки расписания представить легко. К примеру, в развёрнутой форме daily означает:

*-*-* 00:00:00

│ │ │ │  │  ╰── в 00 секунд

│ │ │ │  ╰───── в 00 минут

│ │ │ ╰──────── в 00 часов

│ │ ╰────────── каждый день

│ ╰──────────── каждый месяц

╰────────────── каждый год

Вы можете использовать сокращения вроде daily, расписывать их развёрнутые формы или использовать любое другое поддерживаемое значение, указанное в systemd.time(7), и затем проверять свои догадки через systemd-analyze.

Вторая категория выражений характеризуется как «запуск юнита по времени относительно другого указанного события». Часто бывает так, что именно это вам и нужно. Представьте себе задачу, которая очищает временный каталог: если выражение cron по совпадению сработает сразу после загрузки системы, то в /tmp наверняка ещё будет нечего чистить. Если же вы проинструктируете планировщик «выполнять очистку через час после запуска компьютера и каждый час после», такая логика будет уже реально соответствовать задаче этого сервиса.

И реализовать это с помощью таймера легко:

[Timer]
OnBootSec=1h
OnUnitActiveSec=1h

Эти инструкции означают: «выполни через час после запуска машины» (разово) и «выполняй через час после запуска Unit=» (что неявно заставляет таймер срабатывать неограниченное число раз).

Подобные периодические запуски с установленным интервалом оказываются на удивление актуальней строгих схем вроде выполнения в конкретную минуту каждого часа и аналогичных. Ещё один хороший пример — это таймер, который я использую каждый декабрь для опроса API Advent of Code. Этот опрос я произвожу для обновления информации в Slack-боте, которого написал для друзей. Cron-выражение */15 полностью соответствует правилу выполнения запроса «не чаще одного раза в 15 минут», установленному этим API. Но поскольку на языке cron это самый простой способ задать такой интервал, его используют практически все, опрашивая API в одно и то же время, создавая резкие всплески трафика. Для меня же главное — запустить таймер сразу после внесения правок в код, чтобы отсчёт каждых 15 минут пошёл с этого момента. Такое решение уменьшает риски периодического столпотворения.

Переход от календарного расписания к отсчёту от установленных системных событий, пожалуй, стал самым значительным прорывом в сравнении с традиционным механизмом cron, но таймеры способны на большее.

Таймеры как на ладони

Представление об установленных таймерах я предпочитаю получать с помощью подкоманды list-timers. Вот её вывод:

systemctl list-timers
NEXT                                 LEFT LAST                                  PASSED UNIT                         ACTIVATES
Mon 2026-04-20 15:15:00 MDT      1min 40s Mon 2026-04-20 15:00:05 MDT        13min ago zfs-snapshot-frequent.timer  zfs-snapshot-frequent.service
Mon 2026-04-20 15:32:16 MDT         18min Mon 2026-04-20 14:22:15 MDT        51min ago fwupd-refresh.timer          fwupd-refresh.service
Mon 2026-04-20 16:00:00 MDT         46min Mon 2026-04-20 15:00:05 MDT        13min ago logrotate.timer              logrotate.service
Mon 2026-04-20 16:00:00 MDT         46min Mon 2026-04-20 15:00:05 MDT        13min ago zfs-snapshot-hourly.timer    zfs-snapshot-hourly.service
Tue 2026-04-21 00:00:00 MDT            8h Mon 2026-04-20 09:43:22 MDT     5h 29min ago zfs-snapshot-daily.timer     zfs-snapshot-daily.service
Tue 2026-04-21 07:31:28 MDT           16h Sun 2026-04-19 20:15:47 MDT           7h ago systemd-tmpfiles-clean.timer systemd-tmpfiles-clean.service
Mon 2026-04-27 00:00:00 MDT        6 days Mon 2026-04-20 09:43:22 MDT     5h 29min ago zfs-snapshot-weekly.timer    zfs-snapshot-weekly.service
Mon 2026-04-27 01:09:27 MDT        6 days Mon 2026-04-20 09:43:22 MDT     5h 29min ago fstrim.timer                 fstrim.service
Mon 2026-04-27 04:28:38 MDT        6 days Mon 2026-04-20 09:43:22 MDT     5h 29min ago zpool-trim.timer             zpool-trim.service
Fri 2026-05-01 00:00:00 MDT 1 week 3 days Wed 2026-04-01 10:07:51 MDT 1 week 1 day ago zfs-snapshot-monthly.timer   zfs-snapshot-monthly.service
Fri 2026-05-01 03:17:17 MDT 1 week 3 days Wed 2026-04-01 10:07:51 MDT 1 week 1 day ago zfs-scrub.timer              zfs-scrub.service

Всего 11 таймеров.

Чтобы вывести все, включая неактивные, дополнительно укажите --all.

Буквально одной командой вы получаете полную картину расписания таймеров. Очень полезно.

list-timers относится к семейству подкоманд systemd, которыми я пользуюсь постоянно. Среди наиболее актуальных list-units и list-paths (последняя была добавлена в systemctl недавно).

Пробуждение спящего

Пробуждение спящей системы для выполнения важного скрипта, даже когда вас нет рядом, чтобы, например, поднять крышку ноутбука, может показаться утомительным занятием. Но для этого у нас есть WakeSystem=:

WakeSystem=

Принимает логический аргумент – в случае true истекание таймера
заставит систему выйти из сна, если она такую возможность поддерживает.
...

Только представьте себе потенциал этой функции. В дистрибутиве, который поддерживает предварительное скачивание обновлений пакетов (например, в Arch или NixOS), вы можете подгрузить эти обновления ночью, чтобы применить их утром, когда будете у ПК. Естественно, эта фича может пригодиться и во многих других сценариях. Только в руководстве указан один важный нюанс: если вы хотите, чтобы после выполнения вашего сервиса система снова ушла в сон, это придётся делать вручную.

Распределение нагрузки

Выше я уже упоминал о возможном столпотворении, которое является системной проблемой, когда куча процессов пробуждаются в одно и то же время. Если бы apt update каждой системы Debian в мире было жёстко привязано к 00:00:00, то полночь становилась бы жутко перегруженным периодом для всех.

Избежать подобного нам помогают две опции таймера: FixedRandomDelay= и RandomizedOffsetSec=.

FixedRandomDelay=

Получает логический аргумент. Когда эта опция включена,
случайное значение задержки, определяемое RandomizedDelaySec=,
выбирается детерминированно. После этого оно остаётся неизменным
для всех запусков одного и того же таймера, даже после перезапуска менеджера.
...

RandomizedOffsetSec=

Сдвигает таймер на одинаковое, случайно выбранное количество времени
между 0 и указанным значением.
...

Я использовал эти приёмы в реальных системах, которые связываются с сервером для обновления ПО. И они не только помогают решить проблемы столпотворения, но и обеспечивают предсказуемость поведения, избегая критических сценариев вроде перезапуска демонов, координирующих распределённые сервисы.

Настройки тайминга имеют очень широкие возможности, с которыми можно познакомиться в том же руководстве.

Упрямый Persistent

Эта опция особенно подходит для выполняющихся по расписанию скриптов, которые нельзя пропустить из-за того же уснувшего ноутбука, но ради которых будить систему через WakeSystem= уже перебор.

Persistent=

Получает логический аргумент. Если он true, время последнего запуска
сервисного юнита записывается на диск. Впоследствии при активации таймера
юнит запустится сразу же, если он должен был запуститься хотя бы раз в период,
пока таймер был неактивен. 
...

Если вы настраиваете расписание для синхронизации с удалённым сервером, то на случай возможного периода отключения вашего хоста добавьте в файл .timer опцию Persistent=. Это обеспечит получение настроек сразу после возобновления работы системы, а не по очередному срабатыванию таймера, которое может случиться очень нескоро. Есть и другие хорошие примеры активации сервисов, когда вы не хотите ждать дежурного срабатывания таймера в случае пропущенного запуска: обновление системы, проверка пакетных задач и прочее.

Подытожим

Если вы всерьёз займётесь написанием таймеров, то имейте в виду:

  • Таймеры, которые выполняются в контексте диспетчера пользователей (с которым вы взаимодействуете через systemctl --user) абсолютно допустимы — главное выбирать правильный таргет в секции [install]. Иногда в зависимости от дистрибутива подходящим для этого юнитом будет default.target.

  • Типичные нюансы вроде обеспечения корректности системных часов здесь актуальны точно также, как в случае cron. Просмотреть статус синхронизации пользователи systemd могут с помощью команды timedatectl timesync-status.

  • Многие редакторы нативно поддерживают формат юнит-файлов systemd, что упрощает работу, когда эти файлы большие. Лично я использую пакет emacs systemd.

  *Деятельность социальной сети Twitter (X) запрещена на территории Российской Федерации

Комментарии (5)


  1. Kenya-West
    07.06.2026 14:47

    Давно уже на них переехать хочу, но вот вопрос: в скриптах Bash или PowerShell, запускаемых таймерами, доступен ли полновесный PTY с окружением или нет? Просто, например, broot без PTY не отработает аргумент --install, и приходится на cron прям извращаться. И, опять же, в случае с cron надо прям внутри каждого скрипта прокидывать путь до интерпретатора, все эти $PATH с бинарниками пробрасывать и т. д. Какая-то дичь из 90-х. Как будто у меня дел нет, кроме как с cron няньчиться через Ansible... а приходится. Если systemd эту задачу решает, то ускорю переход на него.


    1. andreymal
      07.06.2026 14:47

      А зачем запускать broot в кроме/таймере, тем более с --install?


  1. alexkuzko
    07.06.2026 14:47

    Как красиво звучала претензия про $PATH... А потом, раз, и оказалось что в юните он с аналогичным неожиданным поведением.

    Юниты хороши, где есть связка с сервисами, оно даже логично. А просто так перетаскивать рабочий функционал, потому что немодно делать как раньше... Это признак инженерной незрелости скорее.


    1. sergrych
      07.06.2026 14:47

      плюс поставить не могу, но на все 100 согласен )


  1. ilnarildarovuch_ruria
    07.06.2026 14:47

    Может и оценил бы, если бы не вырезал SystemD при первой возможности из ОС