SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-23

systemd Type=simple、exec、forking、notify 怎么选

systemd 单元显示 active 但守护进程已退出?本文解析 Type=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。这种做法源自 SysV 时代:init 脚本返回后,没有任何组件继续监督 daemon,PID 文件是记录运行中进程的唯一方式。这一限制正是systemd 取代 init 脚本的原因的核心之一。

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

如果未设置 PIDFile=,则使用 GuessMainPID=,其默认值为 yes。只有当服务最终稳定为单个进程时,这种推断才可靠。手册明确说明了这一限制:如果 daemon 由多个进程组成,推断结果可能错误,故障检测也会停止工作。单元还可能出现主 PID 为 0 的情况,这表示 systemd 完全没有可监督的进程。

大多数会 fork 的 daemon 也提供保持前台运行的开关。将该开关与 Type=exec 一起使用,并删除 PIDFile= 行。组件越少,丢失 PID 的可能性就越低。

用于执行完成即退出的任务

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),许多服务器已经支持该接口。

这是回答“服务是否已启动”的准确方式。simple 和 exec 会在服务读取配置或打开监听套接字之前报告服务已启动,因此依赖单元可能过早启动,并在第一次连接时失败。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 中的进程数超出预期,则说明存在启动器或会 fork 的 daemon。

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

systemctl status 会同时打印状态行和 cgroup 树,因此通常可以一次回答这两个问题。将 journalctl -u 限制为本次启动,并配合 -b 使用,可以显示 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。
  • 必须转入后台运行的守护进程:使用 Type=forking 和 PIDFile=,或使用其前台运行选项与 Type=exec。
  • 执行任务后退出的脚本:Type=oneshot;如果目的是留下状态,则再加上 RemainAfterExit=yes。
  • 退出后由子进程继续运行的启动器:使用 Type=simple 和 ExitType=cgroup。

如果不确定第三方守护进程需要哪一种,请先阅读其随软件包提供的单元文件。对发行版提供的单元运行 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;否则你仍在观察旧的监管行为。