Грубая аналогия для тех, кто из мира Linux: launchd - это systemd для macOS. Один процесс, который и init, и менеджер служб, и таймер, и сокет-активатор, и watchdog. Но устроен он иначе, со своими доменами и своим синтаксисом, и копировать привычки из systemd тут вредно.
launchd как PID 1: что это и почему вместо init/cron
После загрузки ядра XNU самый первый процесс в пространстве пользователя - это launchd, и у него PID 1. Проверь сам:
Код: Выделить всё
$ ps -p 1 -o pid,comm
PID COMM
1 /sbin/launchd
Декларативный - ключевое слово. Ты не пишешь скрипт запуска. Ты описываешь службу в property list (plist) - что запустить, от чьего имени, когда и при каких условиях разбудить. launchd сам решает, когда стартовать и перезапускать. Это и есть фундаментальное отличие от cron: cron тупо дёргает команду по времени, даже если машина спала и момент пропущен. launchd умеет ленивый запуск (по сокету, по обращению к Mach-порту, по появлению файла), умеет держать службу живой (KeepAlive), умеет догонять пропущенный по сну интервал (StartCalendarInterval сработает после пробуждения). Поэтому Apple не развивает cron - launchd просто мощнее.

LaunchDaemons против LaunchAgents: демоны и агенты
Главная развилка, в которой все путаются. Службы launchd бывают двух пород, и разница не косметическая - она про контекст исполнения и безопасность.
LaunchDaemons (демоны) - системные службы. Запускаются от root (или от пользователя, указанного ключом UserName) ещё до логина, при старте системы. У них нет доступа к графической сессии: показать окно, диалог или иконку в меню-баре демон не может в принципе. Это бэкенд - сетевые сервисы, БД, мониторинг, обновлятели. launchdaemons лежат в двух каталогах:
- - демоны от админа и стороннего софта;
Код: Выделить всё
/Library/LaunchDaemons - - демоны самой macOS (трогать нельзя, защищено SIP).
Код: Выделить всё
/System/Library/LaunchDaemons
- - агенты, которые поставил сам пользователь (только для него);
Код: Выделить всё
~/Library/LaunchAgents - - агенты от админа для всех пользователей;
Код: Выделить всё
/Library/LaunchAgents - - агенты macOS (под защитой SIP).
Код: Выделить всё
/System/Library/LaunchAgents
Минимальный plist агента выглядит так:
Код: Выделить всё
<?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>/usr/local/bin/backup.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>0</integer>
</dict>
<key>StandardErrorPath</key>
<string>/tmp/backup.err</string>
</dict>
</plist>
launchctl: современный синтаксис против устаревшего load/unload
Вот где живёт настоящая путаница, и где интернет завален устаревшими советами. Раньше всё делали через
Код: Выделить всё
launchctl load /path/to.plistКод: Выделить всё
launchctl unloadДомен - это пространство имён, в котором живёт служба. Их три, и они задают контекст:
- system - привилегированный домен root, сюда попадают LaunchDaemons. Управление требует sudo. Таргет: .
Код: Выделить всё
system/<Label> - gui/<uid> - агенты в графической сессии пользователя. Активен, только пока пользователь залогинен в GUI. Таргет: .
Код: Выделить всё
gui/501/<Label> - user/<uid> - пользовательский домен без привязки к GUI-логину.
Код: Выделить всё
id -u- - загрузить службу в домен (замена load). Для агента:
Код: Выделить всё
launchctl bootstrap <домен> <путь.plist>. Для демона:Код: Выделить всё
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ru.cyberlake.backup.plistКод: Выделить всё
sudo launchctl bootstrap system /Library/LaunchDaemons/... - - выгрузить (замена unload). Например
Код: Выделить всё
launchctl bootout <домен>/<Label>Код: Выделить всё
launchctl bootout gui/$(id -u)/ru.cyberlake.backup - /
Код: Выделить всё
launchctl enable <таргет>- включить/выключить службу. Это постоянное состояние, переживает перезагрузку; disable не даст службе стартовать даже после bootstrap.Код: Выделить всё
disable - - немедленно запустить службу; флаг -k сперва убивает текущий экземпляр и перезапускает (замена устаревших start/stop).
Код: Выделить всё
launchctl kickstart -k <таргет> - - вывалить всю метаинформацию о службе или домене.
Код: Выделить всё
launchctl print <таргет>
Разведка: list и print, ловим фоновые процессы и мусор
Самое полезное в повседневке - смотреть, что вообще запущено. Базовый обзор:
Код: Выделить всё
$ launchctl list | head -5
PID Status Label
1234 0 ru.cyberlake.backup
- 0 com.apple.Spotlight
567 0 homebrew.mxcl.postgresql@16
Для глубокого разбора - print по таргету:
Код: Выделить всё
$ launchctl print gui/$(id -u)/ru.cyberlake.backup
gui/501/ru.cyberlake.backup = {
active count = 1
state = running
program = /usr/local/bin/backup.sh
last exit code = 0
...
}
А теперь главное практическое применение для обычного пользователя. Удалил приложение перетаскиванием в корзину, а в фоне что-то осталось? Виноваты как раз агенты и демоны, которые установщик разбросал по каталогам и не убрал. Найди мусор так:
Код: Выделить всё
$ ls -1 ~/Library/LaunchAgents /Library/LaunchAgents /Library/LaunchDaemons
/Library/LaunchDaemons:
com.adobe.acc.installer.v2.plist
com.someapp.updater.plist
Код: Выделить всё
sudo launchctl bootout system/com.someapp.updaterКод: Выделить всё
sudo rm /Library/LaunchDaemons/com.someapp.updater.plistСвязь с brew services: что Homebrew делает под капотом
Если ставишь сервисы через Homebrew (на Apple Silicon - из
Код: Выделить всё
/opt/homebrewКод: Выделить всё
brew servicesКод: Выделить всё
homebrew.mxcl.<formula>Код: Выделить всё
$ brew services start postgresql@16
==> Successfully started `postgresql@16` (label: homebrew.mxcl.postgresql@16)
$ brew services list
Name Status User File
postgresql@16 started khovanskiy ~/Library/LaunchAgents/homebrew.mxcl.postgresql@16.plist
Код: Выделить всё
brew services start fooКод: Выделить всё
~/Library/LaunchAgentsКод: Выделить всё
sudo brew services start fooКод: Выделить всё
/Library/LaunchDaemonsКод: Выделить всё
launchctl enable gui/501/homebrew.mxcl.mysql exited with 112Типичные грабли
- Льёшь старые load/unload. Работает через раз и игнорирует домены. Перейди на bootstrap/bootout.
- Перепутал агент и демон. Демон с GUI не работает; агент без активного логина не стартует на сервере. Сначала реши, нужен ли GUI.
- bootout вместо disable. Выгрузил, а после ребута служба вернулась. Для постоянного отключения нужен disable.
- Права и владелец plist. Демон в /Library/LaunchDaemons должен принадлежать root:wheel с правами 644 и не быть group/world-writable, иначе launchd откажется его грузить из соображений безопасности. Чини:
Код: Выделить всё
sudo chown root:wheel ...; sudo chmod 644 ... - Забыл про SIP. Всё в /System/Library защищено System Integrity Protection. bootout и rm там не пройдут, и это правильно.
- Используешь cron и удивляешься. На ноутбуке, который спит, cron пропускает события. launchd с StartCalendarInterval догонит пропущенное после пробуждения.
- Узнай свой UID: . Запомни число (обычно 501).
Код: Выделить всё
id -u - Посмотри, что грузится у тебя как агент: . Пусто - нормально для чистой системы.
Код: Выделить всё
ls -la ~/Library/LaunchAgents - Сделай тестовый агент, который раз в минуту пишет в лог. Создай с Label ru.cyberlake.lab, ProgramArguments из
Код: Выделить всё
~/Library/LaunchAgents/ru.cyberlake.lab.plist, StartInterval 60 и RunAtLoad true.Код: Выделить всё
/bin/sh -c 'date >> /tmp/lab.log' - Загрузи:
Код: Выделить всё
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ru.cyberlake.lab.plist - Проверь, что он в списке: и разбери метаданные:
Код: Выделить всё
launchctl list | grep cyberlakeКод: Выделить всё
launchctl print gui/$(id -u)/ru.cyberlake.lab - Дёрни вручную, не дожидаясь минуты: , затем
Код: Выделить всё
launchctl kickstart -k gui/$(id -u)/ru.cyberlake.lab- увидишь строку с датой.Код: Выделить всё
cat /tmp/lab.log - Убери за собой: и удали plist с логом.
Код: Выделить всё
launchctl bootout gui/$(id -u)/ru.cyberlake.lab
- Тебе нужен сервис, который поднимается при включении сервера ещё до логина и работает от root. Это агент или демон, и в какой каталог положишь plist?
- Чем по смыслу отличается от
Код: Выделить всё
launchctl bootout? Какая из команд переживёт перезагрузку?Код: Выделить всё
launchctl disable - В выводе у службы прочерк в колонке PID и статус 0. Что это значит - служба сломана?
Код: Выделить всё
launchctl list - Что реально делает под капотом и чем отличается от того же без sudo?
Код: Выделить всё
sudo brew services start
launchd - это PID 1 и единый менеджер служб macOS, заменивший init, inetd и cron сразу. Службы бывают двух пород: демоны (LaunchDaemons, root, до логина, без GUI) и агенты (LaunchAgents, пользователь, после логина, с доступом к GUI), и лежат они в системных, общесистемных и пользовательских каталогах Library. Управляй ими современным launchctl - bootstrap/bootout/enable/disable/kickstart/print с явными доменами system, gui/uid и user/uid, а не древними load/unload. Умение читать launchctl list и print плюс ревизия каталогов LaunchAgents/LaunchDaemons - это твой главный навык против фоновых зомби, оставленных удалённым софтом. А brew services - всего лишь дружелюбная обёртка над всем этим, и теперь ты видишь, что под ней.