SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

systemd Type=simple、forking、notify怎么选

单元显示 active 但守护进程已退出?本文对比 simple、exec、forking、oneshot 和 notify,解释主 PID 判定及错误类型导致的监控偏差。

systemd 为什么会在进程已退出时仍将单元报告为 active

systemd 服务单元会在 systemd 认定为主进程的那个进程仍在运行时保持 active,而 Type= 位于 [Service] 部分,用于决定该进程。选择错误的值后,systemd 可能会监控 shell 包装进程或短暂存在的父进程,而您关注的守护进程却在同一单元中退出。该单元反映的是 systemd 被配置为监控的进程状态。

更改重启策略无法解决此问题。Restart= 会在主进程退出时生效,因此只要主 PID(进程标识符)仍属于某个正在运行的进程,Restart=always 就不会触发。请先修复 Type=。主进程真正退出后 systemd 应执行的操作是另一个独立决策,参见关于 Restart= 和 RestartSec= 的指南

Type=实际决定什么

每个 Type= 值同时回答两个问题:systemd 何时可以认为此单元已启动,以及哪个进程是主进程。

第一个答案决定启动顺序。在 After= 中声明依赖您的单元会等待 systemd 将您的单元标记为已启动。过早报告“已启动”的 Type= 会让依赖单元在您的服务能够响应请求之前运行。

第二个答案决定监督方式。systemd 会将单元启动的每个进程放入 cgroup(控制组)。cgroup 是内核提供的进程分组机制,可统一限制并终止这些进程。systemctl stop 使用 cgroup 执行清理:KillMode= 默认为 control-group,因此停止单元时会向其中的每个进程发送信号。主 PID 的范围更窄。它是唯一一个退出会结束单元、且其退出状态会成为单元结果的进程。将 cgroup 中的进程视为主 PID,正是混淆的起点。

Type=simple 会在二进制程序运行前报告已启动

当设置了 ExecStart= 且未提供 Type=BusName= 时,Type=simple 是默认值。systemd 创建进程后会立即认为单元已启动,并将该进程视为主 PID。后续单元会立即启动,此时服务二进制程序甚至还未执行。

最后一点解释了一个常见问题。即使 ExecStart= 路径存在拼写错误,启动作业仍会成功;稍后执行失败时才会报告错误。systemd 会将此情况记录为退出码 203,其自身的表格将其命名为 EXEC,表示无法执行服务二进制程序。因此,systemctl start 无错误返回并不能证明二进制文件存在。

对于在前台运行且不会自行转入后台的程序,应使用 simple。大多数现代守护进程以及几乎所有自行编写的程序都属于这种情况。

Type=exec 会等待程序实际启动

Type=exec 类似于 simple,但会多执行一个步骤。只有 fork 和二进制文件执行都成功后,systemd 才会将该单元视为已启动。二进制文件缺失,或无法解析的 User=,会直接导致启动作业失败,而不是先报告成功,再在片刻后静默失败。

Type=exec 于 systemd 240 中引入,因此当前的服务器发行版都支持它。截至 2026 年 8 月,Ubuntu 24.04 使用 systemd 255,Debian 13 使用 systemd 257。使用 systemctl --version 检查您的版本。

代价是启动时增加一个同步步骤。收益是 systemctl start 能返回真实的退出状态。对于前台程序,优先使用 exec,而不是 simple

Type=forking,以及主 PID 如何丢失

Type=forking 告诉 systemd,ExecStart= 中的进程会派生一个子进程,然后主动退出。systemd 会等待第一个进程退出,之后才认为该单元已启动。留下来的子进程就是 daemon。

难点在于确认进程身份。systemd 启动的进程已经退出,因此必须判断哪个幸存进程是主进程。将 PIDFile= 设置为 daemon 写入 PID 的文件,通常是 /run 下的路径,systemd 会从该文件读取 PID。systemd 还会检查文件中的 PID 是否指向已经属于此服务的进程,因此不会信任指向无关进程的过期文件。

如果未设置 PIDFile=,则使用 GuessMainPID=,其默认值为 yes。只有当服务最终稳定为单个进程时,这种判断才可靠。手册明确说明了这一限制:如果 daemon 由多个进程组成,判断结果可能错误,故障检测也会失效。单元的主 PID 还可能变为 0,这表示 systemd 没有任何进程可供监控。

大多数会派生进程的 daemon 也提供保持前台运行的开关。将该开关与 Type=exec 一起使用,并删除 PIDFile= 行。需要处理的环节越少,丢失 PID 的可能性就越低。

Type=oneshot 用于执行完成后退出的任务

Type=oneshot 要求进程运行并退出。systemd 只有在该单元启动的进程退出后,才会将其视为已启动,因此 oneshot 适合用于任何其他单元必须等待的任务。如果单元既未指定 Type=,也未指定 ExecStart=,则默认采用这种类型。

oneshot 有两种特有行为。只有这种类型允许使用多行 ExecStart=,并且这些命令会按顺序执行。它的启动超时默认也处于禁用状态,因此挂起的 oneshot 会无限期等待,除非您自行设置 TimeoutStartSec=

进程退出后,该单元会恢复为 inactive。RemainAfterExit=yes 会使它保持 active,即使完全没有进程运行。这正是本页开头所述现象的有意设计版本;当单元的任务是留下某种状态,而不是持续运行某个进程时,这种行为就是正确的,例如加载防火墙规则集或启动容器栈。这也是 重启后自动恢复的 Docker Compose 容器栈 所采用的模式:该单元运行 compose 命令后退出,但由于它启动的容器仍在运行,因此单元会保持 active。计划任务触发的也是 oneshot 单元,这是 使用 systemd timer 而不是 cron 运行任务 的另一部分。

Type=notify 让服务声明自身已就绪

Type=notify 将判断权交给服务。systemd 会保持启动作业处于等待状态,直到进程通过 Unix 套接字发送 READY=1。该套接字的路径由 NOTIFY_SOCKET 环境变量提供。C 接口为 sd_notify(3),许多服务器已经支持该接口。

这是回答“服务是否已启动”的准确方式。simpleexec 会在服务读取配置或打开监听套接字之前报告已启动,因此依赖单元可能过早启动,并在首次连接时失败。notify 会在服务自身声明已就绪的时刻报告已启动。

systemd 只接受主进程发送的该消息,这就是 NotifyAccess=main 的含义,Type=notify 也隐含了这一点。如果消息来自子进程或辅助进程,请设置 NotifyAccess=all。Shell 脚本可以调用 systemd-notify --ready,但该命令会作为独立的短生命周期进程运行,因此需要 NotifyAccess=all;如果发送方已经退出,systemd 可能无法确定消息来源。由服务自身实现该协议更加可靠。

还有两个相关设置值得了解。Type=notify-reload 从 systemd 253 开始可用,可将相同的握手机制扩展到重新加载操作,因此 systemctl reload 会在服务报告重新加载完成后返回,而不是在发送信号后立即返回。WatchdogSec= 要求支持通知的服务按指定间隔发送保活消息;如果错过截止时间,systemd 会将其视为失败。

Type=dbus 和 Type=idle

Type=dbus 会等待服务在 D-Bus 上注册名称。D-Bus 是系统服务和桌面服务相互通信所使用的消息总线。它要求使用 BusName=;设置 BusName= 后,它会立即成为默认类型。仅当服务确实会注册总线名称时,才使用此类型。

Type=idle 的行为类似于 simple,但它会延迟运行程序,直到已排队的作业完成调度,最长等待 5 秒。此类型用于避免启动时的控制台输出与状态消息交错显示。它不是用于控制顺序的工具,不应配置在普通服务上。

为什么包装脚本会让 systemd 监控错误的 PID

以下是产生原始问题的运行方式。

[Service]
Type=simple
ExecStart=/opt/app/run.sh
#!/bin/bash
export APP_ENV=production
/opt/app/bin/server --config /etc/app.yaml &
/opt/app/bin/exporter --port 9101

systemd 将 shell 记录为主 PID。当前台运行 exporter 时,shell 会保持运行。如果 server 退出,shell 不会察觉,因此主 PID 仍处于运行状态,单元仍为 active,而 Restart= 没有可处理的对象。两个进程始终位于该单元的 cgroup 中,因此 systemctl stop 仍会正确清理。出问题的是监控机制,而不是清理机制。

修复方式取决于该单元实际包含多少个长时间运行的进程。

如果只有一个进程,则让它替代 shell。

#!/bin/bash
export APP_ENV=production
exec /opt/app/bin/server --config /etc/app.yaml

exec 会让指定程序替代 shell,并保留相同的 PID,因此 systemd 记录的 PID 现在属于该守护进程。更好的做法是移除包装脚本。Environment=EnvironmentFile= 负责传递变量,ExecStartPre= 负责执行设置步骤,因此 systemd 可以直接启动守护进程,并从一开始就知道其 PID。

如果有两个进程,则没有单个 PID 能代表整个单元。将它们拆分为两个单元,并使用 After=Wants= 设置启动顺序。每个进程对应一个单元,是 systemd 最擅长监控的方式,也是让每个进程获得独立重启行为的唯一方式。

ExitType=cgroup 的变化

ExitType= 在 systemd 250 中加入。默认值是 main:主进程退出时,系统将该单元视为已停止。使用 ExitType=cgroup 时,只要其 cgroup 中仍有任意进程存活,系统就会将该单元视为仍在运行。

[Service]
Type=simple
ExitType=cgroup
ExecStart=/opt/app/launcher

这只解决一个特定问题。启动器启动实际工作进程后退出时,在 ExitType=main 下,systemd 会将该单元视为已停止,并终止仍在运行的进程。使用 ExitType=cgroup 后,该单元会跟踪整个进程组。

请明确它无法解决的问题。ExitType=cgroup 会在至少一个进程存活时保持单元处于活动状态。因此,包含两个守护进程的单元在其中一个退出后仍会保持活动。它解决了启动器场景,但不会将一个单元变成多个独立进程的监控器。ExitType= 也不能与 Type=oneshot 组合使用。

cgroup 也负责资源记账,因此 MemoryMax=CPUQuota= 等限制会应用于该单元启动的每个进程,不受 Type= 对主 PID 的说明影响。相关内容请参阅使用 systemd 限制服务的内存和 CPU

如何查找 systemd 实际监控的进程

按顺序在正在调试的单元上执行以下步骤。先查看 systemd 加载了什么,再查看它跟踪什么,最后将结果与进程表进行比较。

systemctl cat app.service
systemctl show -p Type,ExitType,GuessMainPID,PIDFile,MainPID,RemainAfterExit app.service

systemctl cat 会输出单元文件及适用于该单元的所有 drop-in,因此你看到的是 systemd 实际加载的内容,而不是记忆中编辑过的文件。systemctl show 会输出生效值,包括你没有明确写下的默认值。继续之前,记下 MainPID 的值。

systemd-cgls --unit=app.service
ps -o pid,ppid,stat,etime,args -p "$(systemctl show -p MainPID --value app.service)"

systemd-cgls 会列出该单元 cgroup 中的所有进程。ps 行描述 systemd 监管的单个进程。将两者结合起来查看。MainPID 为 0 表示 systemd 没有可监控的进程。若 MainPID 解析为 shell,而该 cgroup 同时包含你的 daemon,则属于前面所述的包装器情况。如果 cgroup 中的进程数超出预期,说明存在启动器或会派生进程的 daemon。

systemctl status app.service
journalctl -u app.service -b

systemctl status 会同时输出状态行和 cgroup 树,因此通常可以一次回答这两个问题。使用 -bjournalctl -u 限定为本次启动后记录的日志,可以看到 systemd 为该单元记录的启动和停止事件,以及它观察到的退出码。如果 daemon 将日志写入自己的日志文件而不是 journal,也要读取该文件,因为 systemd 只能记录传递给它的内容。

修改 Type= 后,重新加载并重启。

systemd-analyze verify /etc/systemd/system/app.service
sudo systemctl daemon-reload
sudo systemctl restart app.service

systemd-analyze verify 会解析文件,并报告无法接受的设置。daemon-reload 会让 systemd 重新从磁盘读取单元文件。修改后的 Type= 不会应用到已经运行的单元,因此必须重启,不能省略。

然后测试修改结果。从 systemd-cgls 获取实际关注进程的 PID,并终止该进程。紧接着运行 systemctl is-active app.service。如果 Type= 正确,该单元会退出 active 状态。如果它仍保持 active,说明 systemd 仍在监控其他进程。

应使用哪种 systemd 服务 Type=

  • 始终在前台运行的程序:Type=exec
  • 支持就绪通知的程序:Type=notify;如果还会确认重新加载,则使用 notify-reload
  • 必须转入后台运行的守护进程:使用带 PIDFile=Type=forking,或使用其前台运行选项和 Type=exec
  • 执行任务后退出的脚本:Type=oneshot;如果目的是留下状态,则还需使用 RemainAfterExit=yes
  • 退出后仍让子进程继续运行的启动器:使用带 ExitType=cgroupType=simple

如果不确定第三方守护进程需要哪一种,请先查看其随软件包提供的 unit 文件。对发行版提供的 unit 运行 systemctl cat,可以查看 Type= 上游选择的类型。该选择经过比您自己的配置更多的人员测试。

FAQ

为什么我的 systemd 单元在进程退出后仍保持 active?

因为 systemd 视为主进程的那个进程仍在运行。systemd 根据 Type= 为每个服务选择一个 PID,而不是监控单元 cgroup 中的每个进程。使用 Type=simple 启动的包装脚本是最常见的原因:shell 是主 PID,因此当 shell 在后台启动的 daemon 退出时,单元仍保持 active。运行 systemctl show -p MainPID app.service,然后使用 systemd-cgls --unit=app.service 列出该单元的 cgroup,并比较两者。

Type=simple 和 Type=exec 有什么区别?

Type=simple 在 systemd 创建进程后就认为单元已启动,此时二进制文件尚未执行。因此,ExecStart= 中的路径错误仍会先产生成功的启动任务,随后才失败。Type=exec 会等待执行成功,因此该失败会由启动任务本身报告。两者都将同一个进程视为主 PID。Type=exec 需要 systemd 240 或更高版本。

使用 Type=forking 时,是否仍需要 PIDFile=?

需要,只要 daemon 会写入 PID 文件就应配置。未配置时,systemd 会回退到 GuessMainPID=。这只是推测,只有当服务最终稳定为单个进程时才可靠。如果推测错误或无法完成,故障检测和自动重启将对该单元失效。将 PIDFile= 指向 daemon 写入的确切路径,通常位于 /run 下。

什么时候应使用 RemainAfterExit=yes?

当单元的用途是更改系统状态,而不是持续运行某个进程时。用于加载防火墙规则或启动容器堆栈的 Type=oneshot 单元会在工作完成后立即退出;如果没有 RemainAfterExit=yes,该单元会变为 inactive,导致 systemctl stop 没有可停止的对象,也无法运行 ExecStop= 清理操作。配置后,该单元会在没有任何进程的情况下保持 active,这正是此处的预期行为。

修改 Type= 后是否需要 daemon-reload?

需要,同时还需要重启该单元。systemctl daemon-reload 会让 systemd 重新读取磁盘上的单元文件,但正在运行的实例仍保留其启动时使用的 Type=。测试前运行 sudo systemctl daemon-reload,然后运行 sudo systemctl restart app.service;否则你仍在观察旧的监管行为。