9 июня 2026 г.
macOSlaunchdAutomationDevOps

Перестаньте использовать cron на Mac: используйте launchd

cron опрашивает систему по расписанию и пропускает задачи, пока ноутбук спит. WatchPaths в launchd вместо этого реагирует на события файловой системы: и именно эта автоматизация навсегда заменила мне cron.

7 мин. чтения

Мне была нужна машина, реагирующая на изменения, а не опрашивающая систему на предмет них. Ответом стала двадцатилетняя функция macOS, которой почти никто не пользуется, и она обошла cron по всем статьям, которые имели значение.

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

Когда я начинал, моя моторная память подсказывала то же, что подсказывает моторная память любого разработчика:

Просто закинь в cron.

Я не стал. И причины этого превратились в самый полезный урок по macOS за долгие годы.

⏰ Проблема с опросом

Вот как выглядела бы cron-версия моей автоматизации:

*/5 * * * * /Users/me/sync.sh

cron опрашивает каждые пять минут против launchd, срабатывающего на события файловой системы

Каждые пять минут, бесконечно, моя машина будит процесс, чтобы спросить: что-нибудь изменилось?

Почти каждый раз ответ, нет.

Это и есть фундаментальная природа cron: опрос. Он ничего не знает о событиях. Он знает только о времени. Поэтому вы вынуждены выбирать из двух плохих вариантов:

  • Опрашивать часто → сотни бессмысленных запусков в день, на всякий случай
  • Опрашивать редко → изменения час висят незакоммиченными, что убивает весь смысл

А у cron на ноутбуке есть вторая, более коварная проблема. cron исходит из того, что машина включена. Закройте крышку в 11:58, и полуденная задача просто не случится. Ни повтора, ни навёрстывания, ничего. Серверы всегда включены; ноутбуки спят. cron проектировался для серверов.

То, чего я на самом деле хотел, формулировалось просто:

Когда файловая система меняется, запусти мой скрипт. Иначе не делай вообще ничего.

Это не расписание. Это событие.

⚙️ Демон, который у вас уже есть

macOS поставляется с launchd, это буквально процесс 1, та штука, что запускает каждую службу в системе. Apple заменила им cron внутри системы много лет назад (задачи cron на вашем Mac сами запускаются через launchd).

Большинство разработчиков знают, что launchd существует. Почти никто не пользуется его лучшей возможностью:

WatchPaths, launchd будет следить за путём файловой системы на уровне ОС и запустит вашу задачу в момент, когда путь изменится.

<key>WatchPaths</key>
<array>
    <string>/opt/homebrew/Cellar</string>
    <string>/opt/homebrew/Caskroom</string>
</array>

Вот и весь трюк. Homebrew устанавливает каждый пакет в /opt/homebrew/Cellar, так что любая установка или удаление, из любой оболочки, любого скрипта, любого инструмента, меняет эту директорию. ОС замечает. Мой скрипт запускается.

Никакого опроса. Никаких бесполезных запусков. Ноль нагрузки на CPU в простое. Задача существует как зарегистрированное намерение, а слежку ведёт ядро.

Полное определение задачи умещается в один plist-файл:

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.paurushrai.dotfiles-sync</string>
 
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/paurushrai/Developer/infra/dotfiles/sync.sh</string>
    </array>
 
    <key>WatchPaths</key>
    <array>
        <string>/opt/homebrew/Cellar</string>
    </array>
 
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key><integer>12</integer>
        <key>Minute</key><integer>0</integer>
    </dict>
 
    <key>ThrottleInterval</key>
    <integer>60</integer>
 
    <key>StandardOutPath</key>
    <string>/Users/paurushrai/Library/Logs/dotfiles-sync.log</string>
</dict>
</plist>

Положите его в ~/Library/LaunchAgents/, зарегистрируйте один раз:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.paurushrai.dotfiles-sync.plist

Он переживает перезагрузки. Работает независимо от того, открыт ли терминал. Теперь это часть ОС.

Я протестировал это честно, установил пакет и не тронул больше ничего:

$ brew install jq
... 8 seconds later, zero shell involvement:
67ba27e chore: auto-sync .Brewfile  → pushed to GitHub ✅

brew install jq запускает авто-коммит, запушенный на GitHub за восемь секунд

🧩 Три ключа, которые делают всю тяжёлую работу

В plist выше зашиты три архитектурных решения, и каждое заменило код, который иначе пришлось бы писать самому.

три ключа launchd: ThrottleInterval, StartCalendarInterval и StandardOutPath

ThrottleInterval: 60, это бесплатный дебаунсер. Установите пять пакетов подряд, файловая система выдаст пять событий, но launchd не запустит задачу чаще раза в 60 секунд, так что я получаю один коммит, покрывающий все пять. В мире cron такая пакетная обработка, это логика, которую пишешь и тестируешь сам. Здесь это одно целое число.

StartCalendarInterval, это расписание cron, но без его проблемы с ноутбуками. Оно даёт мне ежедневный полуденный запуск как подстраховку на случай, если слежка что-то пропустит. И, в отличие от cron, если машина спит в полдень, launchd запускает задачу, когда она просыпается. Пропущенные расписания выполняются с опозданием, а не никогда. Для ноутбука одно это поведение закрывает вопрос сравнения.

StandardOutPath, это observability бесплатно. Вывод каждого запуска дописывается в выбранный мной лог-файл. Когда я задавался вопросом, почему что-то не сработало, ответ уже был записан.

🔁 Ловушка, которая кусает каждого

Теперь признание. Мой скрипт коммитит в git-репозиторий, и первым инстинктом было следить за самой директорией репозитория. Одна запись слежения покрывает каждый файл. Изящно.

Это ещё и бесконечный цикл.

цикл обратной связи: коммит меняет .git, что запускает агента, который коммитит снова

Каждый git-коммит изменяет .git/. Так что: изменение запускает агента → агент коммитит → коммит меняет .git/ → который находится внутри отслеживаемой директории → которая снова запускает агента. Бесконечно.

Решение, правило, которое стоит запомнить наизусть:

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

Вместо этого я слежу за отдельными файлами, каждый отслеживаемый dotfile получает собственную запись WatchPaths, а корень репозитория намеренно отсутствует. Компромисс: совершенно новый файл в глубокой поддиректории замечается не мгновенно; его подхватывает ежедневный полуденный прогон. Честная цена за систему, которая не способна гоняться за собственным хвостом.

Ещё две ловушки из того же вечера, чтобы вам не пришлось платить за них своей шишкой:

  • Не симлинкуйте plist. Я храню plist в репозитории dotfiles, но копирую его в ~/Library/LaunchAgents/, launchd ненадёжно работает с симлинкнутыми определениями задач на современных версиях macOS. Репозиторий хранит источник истины; инсталлятор разворачивает копию.
  • Задачи launchd получают минимальное окружение. Никакого вашего PATH из оболочки, никаких алиасов, ничего из .zshrc. Мой скрипт сам выставляет PATH первой строкой. Если ваша задача «работает в терминале, но не в launchd», почти всегда дело именно в этом.

🔔 Научите её говорить, потом научите ждать

Фоновая задача, которую не видно, это фоновая задача, которой перестаёшь доверять.

Мой скрипт объявляет о каждом коммите одной строкой нативного macOS:

osascript -e 'display notification "Committed & pushed: .Brewfile" with title "dotfiles sync"'

Первый тест: я его не увидел вообще. Баннерные уведомления угасают за пять секунд.

Решение оказалось не в коде, а в настройке, которую почти никто не меняет: System Settings → Notifications → Script Editor → Alerts. Уведомления в стиле «Alert» остаются на экране, пока их не закроешь. Теперь каждая синхронизация ждёт в углу, пока я её не подтвержу, с изменёнными именами файлов в сообщении.

Важная деталь дизайна: скрипт уведомляет, только когда что-то реально закоммичено. Прогон без изменений проходит молча. Уведомление, которое срабатывает постоянно, это уведомление, которое уже научились игнорировать; усталость от алертов убивает observability быстрее, чем полное их отсутствие.

🛠 Шпаргалка

Всё, что нужно, чтобы запустить свою:

# register (once, or after editing the plist)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.myjob.plist
# unregister
launchctl bootout gui/$(id -u)/com.example.myjob
# is it loaded?
launchctl list | grep myjob
# what happened?
tail -f ~/Library/Logs/myjob.log

И таблица решений, которую мне хотелось бы иметь с самого начала:

  • Задача должна реагировать на изменение файла или директории → WatchPaths
  • Задача должна выполняться по расписанию, безопасному для ноутбука → StartCalendarInterval
  • Задача срабатывает пачками, которые стоит объединять → ThrottleInterval
  • Задаче нужно выполниться в момент регистрации → RunAtLoad
  • Вы вот-вот наберёте crontab -e на Mac → остановитесь, напишите plist

🎯 Урок под уроком

Автоматизация, которую я построил, невелика. Принцип, нет.

Я чуть было не потянулся к cron, потому что cron, это то, что я знал, и чуть не потянулся к npm-пакету для слежки за файлами, потому что это подсказывает поисковая выдача. Оба варианта сработали бы, но плохо: один, опрашивая по таймеру, другой, добавляя зависимость ради того, что ОС уже делает нативно.

Скучный, встроенный, двадцатилетний инструмент оказался лучше обоих. Управляемый событиями вместо опроса. Осведомлённый о ноутбуках вместо предположения, что перед вами сервер. Ноль зависимостей вместо ещё одной вещи для установки на следующей машине.

Прежде чем добавлять инструмент, спросите, что уже поставляет ОС.

Ответ чаще, чем кажется: всё, что вам было нужно, сидело в процессе 1, дожидаясь своего часа с 2005 года.

Что работает в вашей папке LaunchAgents?

Мне любопытно, что другие автоматизировали на уровне ОС, и в какие ловушки они попали при этом. Цикл обратной связи с WatchPaths точно не единственная из них.