熟悉macOS系统管理的人都知道,真正让这台系统在重启后依然按部就班干活的东西,不是用户登录后的那些App,而是launchd这套“幕后管家”。我早年用Linux习惯了systemd和crontab,刚切到macOS时对着LaunchDaemon、LaunchAgent和plist文件一脸懵,花了很多时间才把几个关键概念和坑理清楚。这篇内容就是想把我自己的理解、配置思路和踩坑过程完整记录下来,给同样被launchd折磨过的朋友一个可以少走弯路的参考。
这套机制能做的事情非常多:开机自动启动一个内网穿透客户端、定时执行数据库备份、监控某个目录并自动处理文件、让脚本在意外退出后自动拉起,全都能通过launchd实现。而且它比crontab更强大,比登录项更可控,是macOS下做服务管理和定时任务的首选方案。无论你是前端开发、后端运维、还是在mac上折腾自动化脚本的普通用户,只要想把手动操作变成系统自动执行,这篇内容都能帮上忙。
1. 先搞清楚你到底需要哪个:LaunchDaemon还是LaunchAgent?
我见过不少人在这一步就直接搞混。其实这两个概念从名字上就能看出来一些端倪,但真正使用时,区别不仅仅在“Daemon”和“Agent”这两个单词上,而是在运行上下文、权限、启动时机上都有本质差异。搞清楚这个,比急着写plist重要得多。
1.1 两者的核心区别与适用场景
LaunchDaemon是系统级守护进程。它由launchd在系统启动阶段加载,属于root用户运行,即使没有用户登录,它也会照常工作。这意味着它通常不依赖图形界面,也不能直接访问用户钥匙串里那些加密的敏感信息,因为在它启动时用户会话可能根本不存在。
LaunchAgent则属于用户级代理。它是在用户登录之后由launchd加载并启动,运行在当前登录用户的上下文里,可以访问用户拥有的文件、环境变量、GUI应用会话等。比如你希望脚本启动后能弹个通知、能调用某个需要授权信息的命令,用LaunchAgent会更合适。
一句话总结,系统级、无界面、需要开机就能跑的任务,优先考虑LaunchDaemon;用户登录后才能工作、需要访问用户数据的任务,选择LaunchAgent。
1.2 从几个实际需求出发判断该选谁
我把自己经常遇到的几个场景列出来,你对照一下心里就有数了。
- 内网穿透客户端(比如frpc、cloudflared):我建议用LaunchDaemon,因为它需要开机即启动,并且要在没有用户登录的情况下也要保持网络通道可用。
- 自动备份MySQL数据库的定时脚本:这个任务不依赖用户会话,我也倾向于用LaunchDaemon,或者直接用LaunchAgent配合StartCalendarInterval。如果脚本需要读取数据库密码,而密码存在用户的~/.my.cnf里,那LaunchAgent更安全。
- 后台监听快捷键并执行某个程序:这种和用户交互密切相关的,明显更适合LaunchAgent,因为它要和当前登录用户的应用环境交互。
- 开发环境里的代码热重载、文件监听服务:这类任务平时是手动启动的,用LaunchDaemon或LaunchAgent做成开机自启反而干扰开发,我会建议不放到这里,而是配合tmux或后台进程处理。
选错了级别,最常见的结果就是:LaunchDaemon配置好了,但脚本里访问的用户目录变量为空,或者反过来LaunchAgent总是启动失败,查日志发现是权限不足。所以下单前,先把“谁需要运行它”想清楚。
2. plist文件:格式、路径与权限的坑
选好了类型,接下来就是核心内容——plist文件编写。plist实质上是一个XML格式的属性列表文件,macOS系统用它描述配置信息。launchd的plist文件就是一种定义任务参数的特殊plist。
2.1 plist的核心键值:Program、RunAtLoad、KeepAlive等
一个launchd的plist文件,最低限度需要描述“启动什么程序”和“在什么条件下启动”。我日常使用频率最高的键如下:
| 键名 | 作用 | 必填 |
|---|---|---|
| Label | 任务标识,必须全局唯一,建议用域名反写 | 是 |
| ProgramArguments | 要运行的命令行参数数组 | 是 |
| Program | 单条执行路径,与ProgramArguments二选一 | 非必须 |
| RunAtLoad | 加载后是否立刻运行 | 否 |
| KeepAlive | 进程退出后是否重新拉起 | 否 |
| StartInterval | 每隔多少秒运行一次 | 否 |
| StartCalendarInterval | 指定日期时间运行,类似crontab | 否 |
| WorkingDirectory | 工作目录 | 否 |
| EnvironmentVariables | 环境变量字典 | 否 |
| StandardOutPath | 标准输出重定向文件路径 | 否 |
| StandardErrorPath | 标准错误重定向文件路径 | 否 |
| UserName | 指定运行用户(LaunchDaemon专用) | 否 |
这里有个最容易犯的错:很多人习惯用Program键,但如果你需要传参数,Program和ProgramArguments同时使用会导致参数混乱。我的建议是,一律用ProgramArguments数组,第一个元素就是程序路径,后面的元素依次是参数。这样既清晰又能正确处理带空格的路径。
举个例子,如果我用ProgramArguments运行/usr/bin/python3 /Users/me/scripts/backup.py --full,plist中的写法是:
<key>ProgramArguments</key> <array> <string>/usr/bin/python3</string> <string>/Users/me/scripts/backup.py</string> <string>--full</string> </array>2.2 标准放置路径与文件命名规则
plist文件不是随便找个地方放就行,launchd只会扫描特定目录。这也是很多人配置后却看不到效果的原因。
LaunchDaemon的plist放在以下任意一个目录:
/Library/LaunchDaemons/:系统级守护进程,用户登录前就会加载。/System/Library/LaunchDaemons/:Apple系统自带的,普通用户不要去动。
LaunchAgent的plist放在:
/Library/LaunchAgents/:全局用户代理,所有登录用户都会加载。~/Library/LaunchAgents/:当前用户的代理,我自己写的大多数放这里。
有一个特殊目录/System/Library/LaunchAgents/是系统自带的,同样不建议改。
文件命名上有一个约定俗成的习惯:最好和Label保持一致。如果Label是com.example.backup,文件名就是com.example.backup.plist。这能避免很多管理上的混乱,尤其在多任务并行时,一眼就能看出每个文件对应哪个服务。
2.3 权限与所有权的正确设置
在这个地方,网上很多教程都没有强调,但它恰恰是让LaunchDaemon无法加载的头号原因。
LaunchDaemon的plist文件要求所有者为root,属组为wheel,权限通常是644。如果文件所有者是普通用户,launchctl在加载时可能直接报错或者直接无视。下面的命令可以统一修复:
sudo chown root:wheel /Library/LaunchDaemons/com.example.daemon.plist sudo chmod 644 /Library/LaunchDaemons/com.example.daemon.plistLaunchAgent的文件就没有那么严格,通常是当前用户所有,权限644或600都可以。但有一点需要注意,文件不能有写权限给“其他”用户,否则launchctl会警告权限不安全,加载也可能失败。我自己就遇到过chmod 666后加载报错的情况,改回644就好了。
注意:在真正排查launchd问题之前,第一件事一定是检查plist文件的权限和所有者。看似不起眼,却总是浪费我最多时间。
3. 一个完整的LaunchAgent配置从零到跑通的实操
前面都是理论,这一部分我结合一个真实场景来演示:写一个LaunchAgent,让它每隔30分钟执行一次Python脚本,清理系统下载目录中超过7天未使用的临时文件。这个任务不涉及root权限,使用LaunchAgent完全够用。
3.1 手写一个最简单的plist:定时备份脚本
先准备一个Python脚本,路径假设为/Users/me/scripts/clean_downloads.py,内容大致是一个删除逻辑。脚本本身不是重点,关键是让launchd可以正确执行它。
然后创建plist文件~/Library/LaunchAgents/com.example.cleanup.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>com.example.cleanup</string> <key>ProgramArguments</key> <array> <string>/usr/bin/python3</string> <string>/Users/me/scripts/clean_downloads.py</string> </array> <key>StartInterval</key> <integer>1800</integer> <key>RunAtLoad</key> <true/> <key>StandardOutPath</key> <string>/tmp/cleanup.log</string> <key>StandardErrorPath</key> <string>/tmp/cleanup_error.log</string> <key>WorkingDirectory</key> <string>/Users/me/scripts</string> </dict> </plist>StartInterval的值是秒,1800就是半小时。RunAtLoad设为true,表示加载这个plist的时候立即执行一次。这样不需要等待第一个30分钟周期就能立刻看到效果,对验证配置是否正确很有帮助。
3.2 用launchctl加载、启动与停止
写完后,需要用launchctl让launchd认识这个任务。旧版本的macOS用load -w和unload,但新版本系统(macOS 11之后)推荐使用以下方式:
# 加载并启动 launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.cleanup.plist # 查看加载状态 launchctl print gui/$(id -u)/com.example.cleanup # 停止但不卸载 launchctl kill SIGTERM gui/$(id -u)/com.example.cleanup # 卸载 launchctl bootout gui/$(id -u)/com.example.cleanup如果你用的是LaunchDaemon,那就是系统域:
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.daemon.plist sudo launchctl print system/com.example.daemon sudo launchctl bootout system/com.example.daemon有一点要说明,launchctl的域路径中gui/$(id -u)里的$(id -u)是当前用户的UID,通常为501。不要写成固定的数字,否则换了账户就会失效。
3.3 常见配置项的意义与参数选择
StartInterval和StartCalendarInterval是最常用的两种定时方式。StartInterval是“距上次运行结束后的间隔秒数”,不是“自然时间每隔半小时”,如果你设置的是长时间运行的任务,要注意这个时间可能被拉长。StartCalendarInterval则更接近crontab的写法,比如每天凌晨3点15分运行:
<key>StartCalendarInterval</key> <dict> <key>Hour</key> <integer>3</integer> <key>Minute</key> <integer>15</integer> </dict>KeepAlive这个键我单独说一下。很多人以为KeepAlive设置为true就是“保持进程不断运行”,但它真正的含义是“如果进程退出了,launchd把它重新拉起来”。如果你希望一个守护型服务崩溃后自动重启,就把KeepAlive设为true。如果你是一个定时脚本,最好不要设置KeepAlive,否则它会陷入“醒来、运行、退出、再拉起”的死循环。
KeepAlive还可以接受更复杂的字典形式,比如:
<key>KeepAlive</key> <dict> <key>SuccessfulExit</key> <false/> </dict>这个含义是“只有当进程异常退出时才重新拉起,正常退出不重启”。对于某些定期执行、但仍希望进程崩溃时能恢复的服务,这种配置比裸true安全得多。
4. 面向系统的LaunchDaemon编写要点
说完了LaunchAgent,还是要专门聊聊LaunchDaemon。虽然plist的结构大同小异,但系统级任务有几个躲不开的差异点,我在第一次写的时候就踩进了环境变量的坑,花了不少时间才排查出来。
4.1 系统级任务的配置差异
LaunchDaemon加载时,用户还没登录,所以许多用户环境下理所当然的东西,它都没有。比如$HOME可能是空的,$PATH也不是你终端里那个完整PATH。如果你的脚本里依赖了/opt/homebrew/bin下的工具,比如用Homebrew安装的jq、ffmpeg,而plist里没有显式声明PATH,脚本就可能找不到命令。
解决方式有两种。第一种是在plist里设置EnvironmentVariables:
<key>EnvironmentVariables</key> <dict> <key>PATH</key> <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin</string> </dict>第二种是在脚本开头自己export PATH。我更推荐第二种,因为脚本脱离launchd后,在终端手动执行时的行为也能保持一致,排查问题更方便。
另一个区别在于权限。LaunchDaemon默认以root身份运行,用户交互受限。如果你的脚本需要写某个用户目录,必须注意文件权限问题,否则运行时报“Permission denied”。为了避免这种问题,可以在plist里加UserName键,指定一个特定的用户来运行该任务:
<key>UserName</key> <string>me</string>不过这里又有一个新的坑:UserName指定的用户,其Home目录的路径在脚本中未必是你想象中的/Users/me。因为LaunchDaemon本身不是从用户的图形会话继承环境的,除非脚本里显式使用/Users/me这种绝对路径,否则它可能还是找不到用户目录。
4.2 权限、root用户与环境变量的处理
我实际给一台Mac mini配置过开机自动启动frpc,客户端实现家里内网机器的远程访问。当时先在终端手动执行/usr/local/bin/frpc -c /Users/me/frp/frpc.ini完全没有问题,但放到LaunchDaemon后总是连不上服务端。查了半天才发现,frpc读取配置文件时用了相对路径,而LaunchDaemon启动时的当前目录是/,不是/Users/me/frp。最后在plist里加了WorkingDirectory:
<key>WorkingDirectory</key> <string>/Users/me/frp</string>问题立刻解决。这个教训说明:凡是涉及文件读写的脚本,在launchd环境下必须使用绝对路径,或者显式指定工作目录,千万不能依赖终端里的当前目录环境。
再补充一个与root权限相关的点,macOS从10.15开始有一些额外的隐私保护策略,比如TCC(Transparency, Consent, and Control)。LaunchDaemon以root运行可以绕过许多用户级别权限限制,但如果你的脚本要访问“桌面”“文档”“下载”这些受TCC保护的目录,即使是root也需要额外的授权。如果遇到明明权限正确却读不到文件的情况,可以去“系统设置-隐私与安全性-完全磁盘访问权限”里给你的主程序或bash添加允许项。
5. 我的排错链路:从launchctl能加载但任务不运行的真相
配置launchd最让人头疼的不是不会写,而是写了也加载了,但就是不执行,或者执行了但没效果。下面我把自己的排查思路完整梳理一遍,你可以按这个顺序来定位问题。
5.1 查看日志与错误输出
首先,不管问题现象是什么,都要先确认任务有没有被launchd识别。使用这个命令查看任务状态:
launchctl print gui/$(id -u)/com.example.cleanup输出信息里,state = running或state = waiting表示任务已经加载,后面还有“last exit code”字段,可以看到上一次的运行结果。如果exit code不是0,说明脚本本身出错了。
如果任务根本没有出现在print的结果中,说明加载阶段就失败了,这时要看系统日志。使用以下命令:
log show --last 5m --predicate 'subsystem == "com.apple.xpc.launchd"'这个日志信息很冗长,要耐心过滤。通常能看到Service exited with abnormal code这样的关键字,再配合plist里设置的StandardErrorPath日志文件,基本能定位到问题。
5.2 几个我踩过的经典坑
我把自己遇到的高频问题按频次从高到低列出来,供你做排查参考。
第一个坑是plist语法错误。XML标签不闭合、多了一个<string>、或者键值写错,launchctl加载时不会像编译器那样给你一个友好的提示,而是一句“Bootstrap failed: 5: Input/output error”。这个时候建议用macOS自带的plutil命令检查格式:
plutil -lint ~/Library/LaunchAgents/com.example.cleanup.plist这个命令会直接告诉你第几行有问题,非常方便。我也习惯用plutil -p查看编译后的plist内容,确认键值对是否符合预期。
第二个坑是和脚本执行权限有关。plist配置里写了Python脚本的路径,但脚本没有执行权限(没有chmod +x)。如果是用/usr/bin/python3显式调用脚本,这个问题不会出现;但如果是直接把脚本路径放在ProgramArguments第一项,就必须有执行权限。
第三个坑是脚本内部语法或依赖问题。由于launchd不会像终端一样继承你的shell环境,脚本里用到的绝路径可能有差异。比如有的工具安装在/opt/homebrew/bin下,而LaunchDaemon的PATH默认不包含这个目录。这个问题在前文已经说过,最好的验证方法是先在plist里加上StandardOutPath和StandardErrorPath,把脚本输出重定向到文件,然后手动触发一次,查看文件内容。肉眼看到具体错误比猜快太多。
第四个坑是任务频繁被拉起导致的“CPU飙升”。如果你设置了KeepAlive为true,但脚本每次启动后因为某个错误立刻退出,launchd会认为“异常退出”,于是疯狂重试。我遇到过脚本在缺少锁文件时崩溃,结果launchd每秒钟就拉起一次,直接把CPU打满。排查这个的方法还是看日志,一旦看到同一任务大量反复启动,第一反应就要想到KeepAlive配置是否合理。
6. 学习launchd必须掌握的进阶技巧
到这里,基础配置和排错基本已经能解决90%的问题了。最后再补充几个我实测下来非常高效的技巧,让launchd用起来更顺手。
6.1 使用launchctl打印属性列表检查配置
除了print命令,我还会在配置修改后,用下面这条命令直接查看launchd视角下的配置内容:
launchctl print gui/$(id -u)/com.example.cleanup输出里的state、program =、arguments =、environment =等字段,能帮你确认launchd是否真的按plist解析了内容。有一次我的plist里漏写了一个<key>WorkingDirectory</key>导致工作目录没生效,但这个字段在print输出里有没有就很明显。如果print结果与预期不符,多半是plist修改后没有重新加载,或者加载了旧的缓存版本。
加载新配置的正确顺序是:先bootout卸载旧的,再bootstrap加载新的。很多人直接修改plist后只执行了bootstrap,结果launchd仍然保留着旧配置。这也是一个容易忽略的坑。
6.2 任务调试与频率控制经验
调试时,我强烈建议把StartCalendarInterval或StartInterval调成很短的时间,比如5秒钟运行一次,用任务自动调度来验证正确性。确认没问题后再改回真正的调度周期。这样做比反复手动launchctl kill效率高很多。
如果你需要在调试时立刻手动触发任务,可以使用下面命令:
launchctl kickstart gui/$(id -u)/com.example.cleanupkickstart会强制启动一个已经加载的任务,不管任务当前有没有在运行。这个命令比先bootout再bootstrap轻量,也更适合日常调试。
关于频率控制,还有一个经常被大家忽视的点:StartInterval的计时是从“上一次进程退出”开始的,如果任务的执行时间超过间隔时间,那么两次运行之间的真实间隔会被拉长。举例来说,设了StartInterval为300秒,但脚本本身运行需要200秒,那么实际间隔可能是300秒加200秒,甚至更长。如果对时间精度有要求,建议在脚本内部自己记录时间戳,或者改用StartCalendarInterval来固定执行点。
如果想要临时暂停某个任务而不删除它的plist,也不是非得unload再load,可以这样操作:
launchctl disable gui/$(id -u)/com.example.cleanup重新启用就是:
launchctl enable gui/$(id -u)/com.example.cleanup这个方式在需要临时停掉某个服务时非常方便,而且重启后依然保持禁止状态,属于“活禁用”,不会真正删除配置。
我自己的习惯是,所有launchd相关的plist都统一放到一个Git仓库里管理,每次修改都提交一次,方便回溯。特别是涉及生产环境的LaunchDaemon,一个标点符号的错误可能会让服务挂掉,有版本记录心里踏实很多。最后再分享一个小技巧:plist文件写好后,先用plutil -lint验证格式,再用plutil -p快速浏览内容,最后才用launchctl bootstrap加载。这个三步走流程看起来多花十几秒,却帮我省了无数个排查问题的深夜。如果哪天你也被launchd折磨,记得先从权限和路径这两个老冤家查起。