Свои службы и периодические задачи на launchd

Рейтинг: 68.5% · 14 голосов
Подробный курс по macOS для разработчиков и админов: архитектура и Apple Silicon, терминал и zsh, Homebrew и пакеты, launchd и автоматизация, APFS и Time Machine, безопасность (SIP, Gatekeeper, TCC, FileVault), сеть, логи и диагностика, MDM и DDM, контейнеры и виртуализация, восстановление. По официальной документации Apple, актуально на 2026 (macOS 26 Tahoe).
Ответить
Аватара пользователя
Artem_MacAdmin
Сообщения: 47
Зарегистрирован: 11 май 2026, 05:31

Свои службы и периодические задачи на launchd

Сообщение Artem_MacAdmin »

Оглавление курса (47)
  1. Что такое macOS: Unix под капотом и место в 2026
  2. Архитектура macOS: XNU, тома и Signed System Volume
  3. Apple Silicon: ARM, Rosetta 2 и отличия от Intel
  4. Установка, обновление и восстановление macOS
  5. Finder, рабочий стол и продуктивность
  6. Системные настройки и их управление из CLI (defaults)
  7. Пользователи и группы: учётные записи и управление
  8. Терминал и zsh: командная строка macOS
  9. CLI на macOS: BSD-команды и отличия от Linux
  10. Настройка zsh: .zshrc, prompt, плагины
  11. Файловая система и права: POSIX, ACL, атрибуты, флаги
  12. Homebrew: пакетный менеджер для macOS
  13. Homebrew глубже: tap, Brewfile, services, обслуживание
  14. Другие пакеты: MacPorts, mas, Nix и App Store
  15. Установка приложений: .app, .dmg, .pkg и Gatekeeper
  16. Управление приложениями и их данные
  17. launchd и launchctl: система запуска macOS
  18. Свои службы и периодические задачи на launchd (вы здесь)
  19. Автоматизация: Shortcuts, AppleScript и osascript
  20. Конфигурация как код: defaults, plist и профили
  21. APFS: снапшоты, клоны, контейнеры и шифрование
  22. Управление дисками: diskutil, образы и форматирование
  23. Time Machine и резервное копирование
  24. Внешние диски, форматы и сетевые тома
  25. Модель безопасности macOS: SIP и целостность системы
  26. Gatekeeper, нотаризация и защита от вредоносов
  27. Приватность и TCC: разрешения на доступ
  28. FileVault: полнодисковое шифрование
  29. Связка ключей и управление секретами
  30. Сетевой экран и защита сети
  31. Сеть в macOS: настройка и инструменты
  32. Диагностика сети на Mac
  33. Общий доступ: SSH, экран, файлы, удалённое управление
  34. Процессы и ресурсы: мониторинг производительности
  35. Логи macOS: unified logging и диагностика
  36. Производительность и энергия: powermetrics, диагностика
  37. Энергия, сон и обслуживание системы
  38. Профили конфигурации и основы MDM
  39. Управление парком Mac: MDM, DDM и zero-touch (2026)
  40. Среда разработчика на Mac: Xcode CLT, git, версии
  41. Контейнеры на Mac: Docker Desktop, OrbStack, Apple container
  42. Виртуализация: Virtualization.framework, UTM, Linux и Windows
  43. Режимы загрузки и восстановление (Apple Silicon)
  44. Сброс, NVRAM, Safe Mode и сброс настроек
  45. Траблшутинг macOS: дерево решений по симптомам
  46. Место на диске и обслуживание системы
  47. Сквозной проект: настройка Mac как у профи + куда расти
Зачем тебе launchd, а не crontab

Ты пришел с Linux и по привычке набираешь crontab -e. Оно даже сработает - cron в macOS формально еще жив. Но это ловушка. Реализация cron в macOS унаследована из BSD, помечена как устаревшая, не интегрирована с системой управления питанием и - главное - почти гарантированно упрется в TCC (Transparency, Consent and Control). Твоя задача по cron не сможет прочитать файлы на Рабочем столе или в Документах, потому что у демона cron нет ни Full Disk Access, ни понятного способа его получить. Скрипт молча отвалится с Operation not permitted, а ты будешь час искать причину.

Правильный способ запуска своих служб и периодических задач на mac - это launchd. Это PID 1, инит-система macOS, аналог systemd по роли, но со своей логикой. launchd умеет: запускать задачу по расписанию (cron-подобно), держать процесс живым (как supervisor), стартовать по событию - появился файл, изменилась папка, поднялась сеть. Все это описывается одним декларативным файлом - property list, или plist. Дальше разберем структуру launchd plist по косточкам, соберем рабочий launchagent (пример - бэкап по расписанию) и пройдемся по граблям, на которых спотыкаются все.

Изображение

Agents и Daemons: кто, где и от чьего имени

launchd оперирует двумя типами задач, и путать их нельзя.
  • LaunchAgent - работает в сессии конкретного пользователя, имеет доступ к графике (Aqua), запускается после логина. Это твой выбор для пользовательских задач: бэкап домашней папки, синхронизация, нотификации.
  • LaunchDaemon - работает в системном контексте, до логина, от root (или указанного UserName). Нет доступа к GUI. Для системных служб: сетевой сервис, мониторинг железа.
Где лежат plist-файлы - определяет, кто и когда их грузит:

Код: Выделить всё

~/Library/LaunchAgents/      агенты текущего юзера (грузятся при его логине)
/Library/LaunchAgents/       агенты для ВСЕХ юзеров (грузятся при логине любого)
/Library/LaunchDaemons/      демоны сторонних разработчиков (от root, при загрузке)
/System/Library/...          ТОЛЬКО Apple. Защищено SIP. Не трогаем.
Запомни: /System/Library под защитой System Integrity Protection, туда писать нельзя и не нужно. Твои файлы - в ~/Library/LaunchAgents для личного и в /Library/LaunchDaemons для системного. Имя файла по конвенции - reverse-DNS плюс .plist, например ru.cyberlake.backup.plist.

Анатомия plist: разбираем каждый ключ

plist - это XML (или бинарь, но руками пишем XML). Минимально жизнеспособная служба требует двух ключей: Label и одного из ProgramArguments/Program. Вот рабочий launchagent пример - ночной бэкап домашней папки в внешний раздел:

Код: Выделить всё

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>ru.cyberlake.backup</string>

    <key>ProgramArguments</key>
    <array>
        <string>/Users/khovanskiy/bin/backup.sh</string>
        <string>--dest</string>
        <string>/Volumes/Backup</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>3</integer>
        <key>Minute</key>
        <integer>30</integer>
    </dict>

    <key>WorkingDirectory</key>
    <string>/Users/khovanskiy</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
        <key>BACKUP_KEEP</key>
        <string>7</string>
    </dict>

    <key>StandardOutPath</key>
    <string>/Users/khovanskiy/Library/Logs/backup.out.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/khovanskiy/Library/Logs/backup.err.log</string>

    <key>RunAtLoad</key>
    <false/>

    <key>ProcessType</key>
    <string>Background</string>
</dict>
</plist>
Что значит каждый ключ и где грабли:
  • Label - уникальный идентификатор службы в домене launchd. По нему ты будешь обращаться к задаче через launchctl. Должен совпадать с базовым именем файла (без .plist) - это не строгое требование, но сильно упрощает жизнь.
  • ProgramArguments - массив argv. ПЕРВЫЙ элемент - абсолютный путь к исполняемому файлу, остальные - его аргументы. launchd НЕ зовет шелл, не выполняет ~, $HOME, не делает glob по *. Если напишешь относительный путь или тильду - служба не стартует. Это грабля номер один.
  • Program - альтернатива, путь к одному бинарю без аргументов. Если есть ProgramArguments, можно Program не указывать.
  • WorkingDirectory - chdir перед запуском. По умолчанию рабочая папка - /, не домашняя. Скрипт, ожидающий относительные пути от $HOME, без этого ключа сломается.
  • EnvironmentVariables - окружение процесса. launchd-задача стартует с почти ПУСТЫМ окружением: твой ~/.zshrc, PATH из профиля, exports - ничего этого нет. Поэтому brew, ffmpeg, node из /opt/homebrew/bin будут not found, пока ты явно не пропишешь PATH здесь. Грабля номер два.
  • StandardOutPath / StandardErrorPath - куда писать stdout/stderr. Без них вывод уходит в никуда, и ты слеп при отладке. Всегда задавай хотя бы StandardErrorPath. Папка должна существовать и быть доступна на запись от имени задачи.
  • RunAtLoad - запустить сразу при загрузке plist, не дожидаясь триггера. Для периодической задачи обычно false (иначе бэкап стартанет прямо при логине). Для постоянной службы - true.
  • ProcessType - Background/Standard/Adaptive/Interactive. Background понижает приоритет и дает системе придерживать задачу - корректно для фоновых бэкапов.
Расписание: StartInterval против StartCalendarInterval

Это сердце темы periodic mac и расписание задач mac. Два ключа, разная логика.

StartInterval - запуск каждые N СЕКУНД. Просто и грубо:

Код: Выделить всё

<key>StartInterval</key>
<integer>1800</integer>   <!-- каждые 30 минут -->
StartCalendarInterval - cron-подобное расписание по календарю. Ключи внутри словаря: Minute, Hour, Day (день месяца), Weekday (0 или 7 - воскресенье), Month. Пропущенный ключ = wildcard (любое значение), как звездочка в cron. "Каждый день в 3:30" - указываем только Hour и Minute, Day/Weekday опускаем.

Несколько расписаний? Передай МАССИВ словарей:

Код: Выделить всё

<key>StartCalendarInterval</key>
<array>
    <dict>
        <key>Weekday</key><integer>1</integer>
        <key>Hour</key><integer>9</integer>
        <key>Minute</key><integer>0</integer>
    </dict>
    <dict>
        <key>Weekday</key><integer>5</integer>
        <key>Hour</key><integer>18</integer>
        <key>Minute</key><integer>0</integer>
    </dict>
</array>
Это "по понедельникам в 9:00 и по пятницам в 18:00". Ключевое отличие launchd от cron mac: если в момент срабатывания Mac СПАЛ, cron пропустил бы запуск молча, а launchd запустит задачу при следующем пробуждении. Если за время сна прошло несколько интервалов, они склеиваются (coalesce) в одно срабатывание - бэкап не запустится 8 раз подряд за пропущенную ночь. Время берется по локальному часовому поясу системы.

Запуск по событию: WatchPaths и QueueDirectories

launchd умеет триггерить задачу не по времени, а по файловой системе - cron так не может в принципе.
  • WatchPaths - массив путей. Как только содержимое любого из них меняется (изменен файл, права, mtime) - задача запускается. Удобно для "пересобери конфиг, когда я отредактировал исходник".
  • QueueDirectories - массив папок. Задача запускается, ПОКА в папке есть файлы, и не запускается, когда папка пуста. Классика - папка-инбокс: кинул файл, обработчик его подхватил и обработал.

Код: Выделить всё

<key>WatchPaths</key>
<array>
    <string>/Users/khovanskiy/.config/app/config.yml</string>
</array>
Тонкость: WatchPaths реагирует на ЛЮБОЕ изменение, в том числе на то, которое делает твой же скрипт. Если задача пишет в отслеживаемый путь - получишь бесконечный цикл. Разводи входные и выходные пути.

KeepAlive: служба-демон, которую нельзя убить

До сих пор был разовый запуск. А если нужна постоянно живая служба (свой веб-хук, локальный прокси)? Ключ KeepAlive:

Код: Выделить всё

<key>KeepAlive</key>
<true/>
При true launchd перезапускает процесс всякий раз, когда тот завершается - намеренно или с крэшем. Важно: KeepAlive неявно включает RunAtLoad. Можно сделать умнее - словарь с условиями:

Код: Выделить всё

<key>KeepAlive</key>
<dict>
    <key>SuccessfulExit</key>
    <false/>
</dict>
Это значит "перезапускай, только если процесс вышел с НЕнулевым кодом" (упал), а при штатном exit 0 - оставь в покое. Грабля: если служба падает мгновенно, launchd включает троттлинг - не даст рестартовать чаще раза в 10 секунд, чтобы не сжечь систему в цикле. В логах увидишь throttling respawn. Чини причину краша, а не борись с троттлингом.

Загрузка и управление: launchctl bootstrap, kickstart, bootout

Старые команды launchctl load/unload объявлены устаревшими. В macOS 2026 (Tahoe и далее) используем современный синтаксис с явным указанием домена.

Домен для агента текущего юзера - gui/$UID, где $UID - твой числовой идентификатор (обычно 501 для первого пользователя). Полный цикл:

Код: Выделить всё

# узнать свой UID (пригодится для домена)
id -u
# 501

# загрузить агент в GUI-домен
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ru.cyberlake.backup.plist

# проверить, что служба видна (вторая колонка - последний код возврата)
launchctl list | grep cyberlake
# -	0	ru.cyberlake.backup

# принудительно запустить ПРЯМО СЕЙЧАС, не дожидаясь расписания
launchctl kickstart -k gui/$UID/ru.cyberlake.backup

# выгрузить службу
launchctl bootout gui/$UID/ru.cyberlake.backup
Разбор вывода launchctl list. Три колонки: PID, последний exit-код, Label. Прочерк в PID - служба не запущена прямо сейчас (норма для периодической задачи между запусками). Во второй колонке 0 - последний запуск прошел успешно. Если там НЕ ноль - например 78 или 127 - последний запуск упал, и это твой первый диагностический сигнал.

Для системного демона домен другой - system, и команды от root:

Код: Выделить всё

sudo launchctl bootstrap system /Library/LaunchDaemons/ru.cyberlake.daemon.plist
sudo launchctl kickstart -k system/ru.cyberlake.daemon
Флаг -k у kickstart означает "сначала прибей, если запущена, потом запусти заново" - удобно при отладке после правки скрипта.

Отладка: читаем логи и расшифровываем коды

Когда служба не работает, идешь по цепочке.

Шаг 1 - твои собственные логи из StandardErrorPath:

Код: Выделить всё

log stream --predicate 'process == "backup.sh"' --info
# или просто
cat ~/Library/Logs/backup.err.log
Шаг 2 - что говорит сам launchd про задачу:

Код: Выделить всё

launchctl print gui/$UID/ru.cyberlake.backup
Команда launchctl print выдает развернутое состояние: текущий PID, последний exit, путь к программе, окружение, причину последнего завершения. Ищи строки last exit code и runs.

Шаг 3 - системный лог launchd через unified logging:

Код: Выделить всё

log show --predicate 'subsystem == "com.apple.xpc.launchd"' --last 1h --info
Типичные коды и что они значат на практике:
  • exit 127 - command not found. Почти всегда пустой PATH в EnvironmentVariables: brew/node/python не найдены. Пропиши полный PATH.
  • exit 126 - найден, но не исполняемый. Забыл chmod +x на скрипт.
  • exit 78 (EX_CONFIG) - проблема конфигурации скрипта.
  • Bootstrap failed: 5: Input/output error - частая ошибка bootstrap. Причины: невалидный XML (проверь plutil), служба уже загружена, кривые права на plist.
  • Operation not permitted при чтении файлов - это TCC, не баг скрипта. О нем отдельно ниже.
Перед загрузкой ВСЕГДА валидируй plist - это сэкономит часы:

Код: Выделить всё

plutil -lint ~/Library/LaunchAgents/ru.cyberlake.backup.plist
# ~/Library/LaunchAgents/ru.cyberlake.backup.plist: OK
Главная грабля 2026: TCC и Full Disk Access

Вот почему cron на mac мертв, а launchd тоже требует внимания. TCC - подсистема приватности - блокирует доступ к Рабочему столу, Документам, Загрузкам, Фото, внешним томам для процессов без явного разрешения. Твой бэкап-скрипт через launchd упрется в Operation not permitted на ~/Desktop, даже если в Finder ты туда ходишь свободно.

Механика: TCC привязывает разрешение к ПОДПИСАННОМУ исполняемому файлу или к "ответственному процессу". Шелл-скрипт сам по себе подписать нельзя - реальный процесс это /bin/zsh или /bin/bash, и именно интерпретатор просит доступ. Поэтому в Системных настройках -> Конфиденциальность и безопасность -> Полный доступ к диску нужно добавить НЕ скрипт, а бинарь-интерпретатор (или, что чище, обернуть логику в подписанный .app/бинарь).

Практический рецепт для launchagent с доступом к защищенным папкам:
  • Дай Full Disk Access на /bin/zsh (или /opt/homebrew/bin/... если зовешь конкретный бинарь). Через перетаскивание в список Полного доступа к диску (Cmd+Shift+G -> /bin/zsh).
  • Либо собери задачу как подписанный исполняемый файл и выдай доступ ему - так правильнее для парка машин.
  • Бэкапь в путь, не покрытый TCC (свой каталог вне Desktop/Documents), если защищенные данные не нужны - тогда разрешения вообще не потребуются.
И вторая частая засада - внешний том. Если StartCalendarInterval сработал в 3:30, а /Volumes/Backup в этот момент не примонтирован (диск отключен, не проснулся) - скрипт запишет бэкап в несуществующую точку или упадет. Всегда проверяй в скрипте mount перед записью:

Код: Выделить всё

if ! mount | grep -q "/Volumes/Backup"; then
  echo "backup volume not mounted, skip" >&2
  exit 0
fi
Мини-лаба: бэкап-агент по расписанию своими руками

Повтори вживую, по шагам.
  • 1. Создай скрипт ~/bin/backup.sh: rsync домашней папки в /Volumes/Backup или в ~/Backups. Внутри - проверка mount и echo с датой в stderr. Сделай chmod +x ~/bin/backup.sh.
  • 2. Напиши ~/Library/LaunchAgents/ru.cyberlake.backup.plist по образцу выше. Для теста поставь StartInterval 120 (каждые 2 минуты), чтобы не ждать ночи.
  • 3. Проверь синтаксис: plutil -lint на файл. Должно быть OK.
  • 4. Загрузи: launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ru.cyberlake.backup.plist
  • 5. Дерни вручную: launchctl kickstart -k gui/$UID/ru.cyberlake.backup
  • 6. Смотри логи: cat ~/Library/Logs/backup.err.log и launchctl list | grep cyberlake. Поймай exit-код во второй колонке.
  • 7. Сломай специально: убери PATH из EnvironmentVariables, зови brew из скрипта. Получи exit 127, найди в логе not found, почини. Это закрепляет грабли с окружением.
  • 8. Прибери за собой: launchctl bootout gui/$UID/ru.cyberlake.backup, верни StartInterval на боевое значение, перезагрузи.
Контрольные вопросы
  • 1. Почему launchd-задача не видит твоего brew/node, хотя в обычном терминале они работают? Какой ключ это лечит?
  • 2. В чем разница поведения StartCalendarInterval и cron, когда Mac спал в момент запланированного запуска?
  • 3. launchctl list показывает во второй колонке 127. Что это значит и куда смотреть в первую очередь?
  • 4. Почему добавлять в Full Disk Access нужно /bin/zsh, а не сам твой backup.sh?
Итог

cron на macOS - наследие, которое спотыкается о TCC и не дружит со сном. launchd - родной механизм: один декларативный plist описывает службу, расписание (StartInterval/StartCalendarInterval) или событийный триггер (WatchPaths/QueueDirectories), окружение и логи. Грузишь через launchctl bootstrap gui/$UID, гоняешь kickstart, отлаживаешь через launchctl print и unified logging. Три вещи, которые сэкономят тебе вечер: абсолютные пути в ProgramArguments, явный PATH в EnvironmentVariables и понимание, что Full Disk Access выдается интерпретатору, а не скрипту.
👍3 ❤️1 🔥2 😄 🤔
Аватара пользователя
martinaitis
Сообщения: 2
Зарегистрирован: 14 май 2026, 09:09

Re: Свои службы и периодические задачи на launchd

Сообщение martinaitis »

Спасибо, наконец дошло почему мой cron-скрипт молча не читал Desktop. Добавил zsh в Full Disk Access и завелось. Жаль что Apple про это нигде явно не пишет.
👍 ❤️1 🔥1 😄 🤔
Аватара пользователя
emacs22
Сообщения: 1
Зарегистрирован: 13 май 2026, 11:27

Re: Свои службы и периодические задачи на launchd

Сообщение emacs22 »

Вопрос: а если ноут ночью был выключен совсем, не спал а именно off - launchd догонит пропущенный StartCalendarInterval после включения или нет? Со сном понятно что склеит, а с полным выключением как?
👍 ❤️ 🔥1 😄 🤔
Ответить
← Предыдущая глава
launchd и launchctl: система запуска macOS
Следующая глава →
Автоматизация: Shortcuts, AppleScript и osascript

Все главы курса «macOS для профи: администрирование, терминал и Homebrew»

Поделиться темой: ✈ Telegram VK
  • Похожие темы
Похожие запросы: launchd и автозагрузка на MacАвтоматизация на macOS

Вернуться в «macOS для профи: администрирование, терминал и Homebrew»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость