引言

想在 MacOS 后台跑一个 frp 服务,想到 MacOS 是类 Unix 系统所以应该跟 Linux 差不多,自然而然的想到使用 systemd 将 frp 服务作为守护进程管理。然鹅输入vim /etc/systemd/sys之后按了半天 tab 都不自动补全为vim /etc/systemd/system,一看/etc目录下压根就没有systemd这个目录。上网一搜才知道 MacOS 压根就没有 systemd,取而代之的是 launchd。既然都提到了,就简要介绍一下吧。

起源

传统的 Linux 系统使用 System V init 或 BSD init 作为系统的第一个进程,负责启动其他服务和进程。随着系统复杂性的增加,这些 init 系统暴露出一些缺点,比如串行启动服务、启动速度慢等。

随后,upstart 项目引入了并行启动服务等概念用以解决 System V init 存在的问题,但仍然存在一些限制。

接着,systemd 诞生了,它提供更高效、更灵活的系统启动和管理方式。但是由于 systemd 使用了 cgroups 等组件以实现其特性,所以只适用于Linux。

苹果系统是闭源的,但是 launchd 却是开源的,还挺有意思。其最早由 Dave Zarzycki 等人创建,并在Mac OS X Tiger(10.4版本)中首次引入,用于替代传统的 init 脚本、SystemStarter 以及其他一些服务管理工具,如 inetd、atd 和 crond 等。

systemd 实战入门

在本节中,我们首先通过一个小🌰来入门 systemd,然后接下来给出 service 文件(核心)的详细说明。

小例子:后台记录时间

一、首先我们需要编写一个需要后台运行的脚本,这里以每10秒输出一次时间的脚本/root/bin/systemd_test.sh为例,内容如下

1
2
3
4
5
6
7
#!/bin/bash

while true; do
CURRENT_TIME=$(date +"%Y-%m-%d %H:%M:%S")
echo "$CURRENT_TIME"
sleep 10
done

二、赋予脚本可执行权限

1
chmod +x /root/bin/systemd_test.sh

三、先执行一下看看有没有问题

image-20250614192113838

四、创建 systemd 服务文件

/etc/systemd/system/目录下创建一个服务文件,名字随意,但必须以.service结尾,本示例为/etc/systemd/system/record_time.service,内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Unit]
Description=Record Time Service
After=network.target

[Service]
Type=simple
ExecStart=/root/bin/systemd_test.sh
Restart=on-failure
RestartSec=10s
StandardOutput=append:/var/log/record_time/output.log
StandardError=append:/var/log/record_time/error.log

[Install]
WantedBy=multi-user.target

五、执行如下命令即可启动服务

1
2
3
systemctl daemon-reload
systemctl start record_time # 启动服务
systemctl enable record_time # 设置服务开机自动启动

成功运行示例如下

image-20250614195034781

如果启动失败,可以使用命令journalctl -u record_time查看失败日志。比如我最开始就因为忘记创建/var/log/record_time目录导致启动失败了😂

image-20250614195313254

service 文件详解

看到网上有一篇介绍 service 文件如何编写的文章 OpenSUSE: How to write a systemd service,已经写的很通俗很详细了,这里就不重复造轮子了。

用户级 systemd

上面我们给出的例子是需要管理员权限的,普通用户是无法在/etc/systemd/system目录下创建.service文件的

image-20250614202237287

但是我们可以在自己的用户目录下创建,具体的目录为~/.config/systemd/user/xxx.service,service 文件的内容和普通 service 文件是相同的,相应的管理命令也稍有不同(需要加上--user参数),如下

1
2
3
systemctl --user daemon-reload
systemctl --user start record_time # 启动服务
systemctl --user enable record_time # 设置用户登录后自动启动

有一点比较坑的是执行systemctl --user enable xxx后的后台守护进程只在用户登录后运行,用户退出登录后服务就停止了。有一个解决办法是设置用户登录会话为逗留状态,这样即使用户注销,其会话仍然会保持,也就允许后台服务或定时任务继续运行,命令如下

1
2
loginctl enable-linger <username>    # 启动逗留状态
loginctl disable-linger <username> # 关闭逗留状态

launchd 实战入门

同上,我们仍然是先给出一个例子,然后再给出具体解释。

例子:后台记录时间

(没错,还是这个例子,我们主要关注操作方式与 systemd 的不同)

一、编写脚本~/Scripts/launchd_test.sh,内容如下

1
2
3
4
5
6
7
#!/bin/bash

while true; do
CURRENT_TIME=$(date +"%Y-%m-%d %H:%M:%S")
echo "$CURRENT_TIME"
sleep 10
done

二、赋予脚本可执行权限

1
chmod +x launchd_test.sh

三、先执行一下,看看有没有问题

image-20250614201839000

四、创建 plist 配置文件

~/Library/LaunchAgents目录下创建一个服务文件,名字随意,但必须以.plist结尾,本示例为~/Library/LaunchAgents/com.pushihao.record_time.plist,内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?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.pushihao.record_time</string>

<key>ProgramArguments</key>
<array>
<string>/Users/itgrape/Scripts/launchd_test.sh</string>
</array>

<key>RunAtLoad</key>
<true/>

<key>KeepAlive</key>
<true/>

<key>StandardOutPath</key>
<string>/Users/itgrape/logs/record_time/output.log</string>

<key>StandardErrorPath</key>
<string>/Users/itgrape/logs/record_time/error.log</string>

<key>WorkingDirectory</key>
<string>/Users/itgrape/Scripts/</string>

</dict>
</plist>

五、加载/卸载服务

旧:

1
2
launchctl load ~/Library/LaunchAgents/com.pushihao.record_time.plist    # 加载服务
launchctl unload ~/Library/LaunchAgents/com.pushihao.record_time.plist # 卸载服务

新(更推荐):

1
2
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.pushihao.record_time.plist    # 在用户 GUI 域中注册服务
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.pushihao.record_time.plist # 从用户 GUI 域中卸载服务

六、执行命令启动服务

1
2
3
4
5
launchctl list | grep com.pushihao.record_time            # 检查服务是否被加载
launchctl start com.pushihao.record_time # 启动已加载服务
launchctl stop com.pushihao.record_time # 停止服务
launchctl enable gui/$(id -u)/com.pushihao.record_time # 设置用户登录自动启动(bootstrap 后默认开启)
launchctl disable gui/$(id -u)/com.pushihao.record_time # 关闭用户登录自动启动

注意:因为 KeepAlive 设置为 true,所以手动 stop 后,launchd 可能很快会再次尝试启动。要想永久停止,需要使用上述卸载服务的 unload/bootout 命令。

成功运行示例如下

image-20250614205709768

还会弹出个这玩意

image-20250614210112192

plist 文件解释

这里去看 Apple 的官方文档即可 Daemons and Services Programming Guide,它介绍的已经非常详细了。这里就简要介绍一下上面例子中各配置项的作用。

  • Label: 服务的唯一标识符,launchctl命令会用到它。
  • ProgramArguments: 要执行的命令和参数。数组的第一个字符串是要执行脚本的绝对路径
  • RunAtLoad: <true/>表示当服务被加载时立即启动。
  • KeepAlive: <true/>这是实现长期运行的关键!如果设为truelaunchd会监视该进程,一旦它退出(无论是因为错误崩溃还是正常结束),launchd会立刻重新启动它。
  • StandardOutPath: (可选) 重定向标准输出(脚本中echo的内容)到指定文件。
  • StandardErrorPath: (可选) 重定向标准错误到指定文件。这对于排查脚本错误至关重要。
  • WorkingDirectory: (可选) 设置脚本运行前的工作目录。

系统级 launchd

注意看,这里我的介绍顺序是和 systemd 是反着的。systemd 我给出的例子是系统级,随后我又给出了用户级 systemd 的使用方法。而该 launchd 的例子我给出的是用户级,接下来我介绍一下系统级 launchd 的使用。

注意:上面我们已经提到了旧版和新版的加载/卸载命令,他们对应的用户级/系统级 launchd 的使用方式也是不同的。

旧版 load/unload 命令

其区别主要在于 plist 文件的存放位置,系统级 launchd 的 .plist 文件放在/Library/LaunchDaemons/目录下,而用户级 launchd 的 .plist 文件放在~/Library/LaunchAgents//Library/LaunchAgents/目录下。他们之间的区别如下:

~/Library/LaunchAgents

  • 启动时机:仅当特定用户登录时启动。
  • 运行权限:以当前用户的权限运行。
  • 访问GUI:可以。因为它们在用户的图形会话中运行,所以这些任务可以创建窗口、显示菜单栏图标或执行其他与用户界面交互的操作。
  • 适用场景:适用于仅当前用户需要的服务,例如个人的定时任务或用户特定的后台程序。

/Library/LaunchAgents

  • 启动时机:当任何用户登录时启动。
  • 运行权限:以当前登录用户的权限运行。
  • 访问GUI:可以。同上。
  • 适用场景:适用于所有用户共用的用户级服务,例如某些需要在用户登录时启动的系统服务。

/Library/LaunchDaemons

  • 启动时机:在系统启动时运行,无需用户登录
  • 运行权限:通常以root用户的权限运行,但也可以指定其他用户。
  • 访问GUI:不可以。因为它们独立于任何用户,所以它们无法(也不应该)与用户界面进行任何交互。
  • 适用场景:适用于需要在系统启动时运行的与用户无关的全局服务,例如网络服务、数据库服务等。

除此之外,/System/Library/LaunchDaemons//System/Library/LaunchAgents/目录下也会保存有一些.plist文件,这些文件是由苹果系统提供的系统守护进程或用户代理。一般不要动它们。

新版 bootstrap/bootout 命令

bootstrap 与旧的 load 命令最大的不同在于明确性。

  • launchctl load(旧方式)通过.plist文件的存放路径来推断我们想要将服务注册到哪个域。这种方式比较模糊,依赖于文件路径的约定,现在苹果官方已不推荐在新的脚本中使用。

  • launchctl bootstrap(新方式)强制要求我们必须明确指定要注册到哪个域,命令本身就包含了所有信息,不再依赖于文件路径来做判断。这种方式更清晰、更精确,减少了歧义,是目前官方推荐的方式。

通过域信息,我们就可以指定该任务是在哪个级别下运行了。域信息如下

  • system 域:类似于老方式的/Library/LaunchDaemons
  • gui<uid> 域:在指定用户登录后创建的图形会话中运行,类似于老方式下的~/Library/LaunchAgents
  • user<uid> 域,用于注册指定用户后台代理,但不提供GUI访问权限。

虽说使用了新版方式后理论上.plist文件可以放在任何位置,但是由于系统启动或用户登录时 launchd 进程只会去扫描一组预先定义好的、受信任的标准目录(也就是我们上面所说的5个目录)而并不会去扫描整个硬盘来寻找所有.plist文件。因此如果我们的.plist文件放在一个自定义目录(比如/Users/itgrape/Launchd/),那么在扫描阶段,launchd 根本就不会去查看那个目录,因此它永远发现不了我们的服务,这也就导致了 enable 可能会失效。兼容起见,还是放在上面我提到的三个目录其中之一里面吧。

在读完了上述章节之后,相信读者也已经对 systemd 和 launchd 的基本使用有了一定的了解。接下来我们在设计层面比较一下他们之间的差异。

两大体系对比

它们虽然干着类似的活儿——管理那些开机启动的、默默在后台运行的服务(守护进程),以及响应各种事件触发任务,但它们两个的设计理念以及工作方式却大不相同。

启动方式

Launchd:按需启动

  • Mac 上有很多服务(比如打印服务、文件共享服务等)。默认按需启动,也就是说 Launchd 让这些服务平时都在“睡觉”,节省资源。只有当真正有人或系统事件需要它时(比如我们点了“打印”,或者有个网络连接请求进来),它才会迅速唤醒对应的服务。服务干完活,如果没特别要求,它就又回去“睡觉”了。这样电脑就能又快(响应我们的操作时快)又省电。除非指定了 KeepAlive 为 true,服务才会一直运行。

Systemd:开机并行驱动

  • 它首要目标就是让 Linux 开机速度大大加快。怎么做到?不是按需启动,而是并行启动。分析好各个服务之间的依赖关系(谁先启动谁后启动),然后尽可能多同时启动没有依赖冲突的服务。结果就是:系统整体启动嘎嘎快。

文件组织

Launchd:所有配置全放在.plist文件(一种 XML 或二进制格式的文件)里。这个文件描述了服务叫什么名字(Label)、启动命令是什么(ProgramArguments)、监听什么事件(Sockets, WatchPaths, StartCalendarInterval)、要不要一直跑(KeepAlive)等等。简单来说,就这一种文件。

Systemd:配置是按单元(Unit)分的(.ini风格),有不同的类型,每种单元单独写一个文件:

  • .service:定义要运行的后台服务本身(启动命令、重启策略等)。
  • .socket:定义监听一个网络端口或 Unix Socket(用来按需启动服务)。
  • .timer:定义定时任务(替代 cron)。
  • .path:定义监控文件或目录变化(变化了启动服务)。
  • .target:定义一组单元(类似传统 Linux 的运行级别)。
  • … 其他(.mount, .slice 等)。

日志查看

Launchd:服务自己决定日志写哪。

Systemd:它自己带了个日志系统叫 journald。所有由 systemd 管理的服务的日志(包括它们的输出信息),默认都统一收拢到这里。然后用journalctl命令就能查、过滤、跟踪所有服务的日志。

快速参考备忘录

接下来我将用一个清晰的表格概括 systemd 和 launchd 的使用。

表格中...表示带后缀的配置文件的绝对路径。

功能 systemd (Linux) launchd (macOS)
配置文件 /etc/systemd/system/my-app.service ~/Library/LaunchAgents/com.me.myapp.plist
启用并立即启动 sudo systemctl enable –now my-app launchctl load … | launchctl bootstrap …
禁用并停止 sudo systemctl disable –now my-app launchctl unload … | launchctl bootout …
启动服务 sudo systemctl start my-app launchctl start com.me.myapp
停止服务 sudo systemctl stop my-app launchctl stop com.me.myapp
查看服务状态 sudo systemctl status my-app launchctl list
查看日志 sudo journalctl -u my-app -f tail -f /path/to/your.log
重载配置 sudo systemctl daemon-reload 需要先 unload 再 load

总结

systemd 使用于 Linux,而 launchd 适用于 MacOS,并非替代关系。