systemd 为什么没有重启我的服务?
Restart=只监控主进程:同一 cgroup 中的子进程崩溃不会触发重启。本文说明 Type=、重启限制,以及如何从 journal 判断真实原因。
systemd 重启策略只监控每个单元中的一个进程
systemd 重启策略只监控每个单元中的一个进程:主进程。Restart= 读取的也只有该进程的退出状态。一个单元的控制组可以包含 20 个进程,其中一个进程可能退出,但只要主进程仍在运行,该单元就会保持 active (running) 状态。对 systemd 而言没有任何失败,因此不会重启任何进程。
systemd 确实知道其他进程的存在。单元停止时,它会终止这些进程;计算单元内存限制时,会将这些进程占用的内存计入其中;它会对这些进程应用单元的 CPU 配额;并在 systemctl status 中列出这些进程。但它从不读取这些进程的退出状态。重启逻辑和 cgroup 是两套不同的机制,本指南大部分内容都围绕它们之间的差距展开。
cgroup 保存什么,以及重启逻辑读取什么
cgroup(控制组)是内核对象,用于管理一组进程。每个服务单元都有一个 cgroup,名称与单元相同。进程无法离开所属的 cgroup。子进程会继承父进程的 cgroup,非特权进程也无法将自己移动到其他 cgroup。因此,即使守护进程进行两次 fork,systemd 也能清理它;旧式 init 脚本无法可靠地做到这一点。
将以下两个事实放在一起查看:
systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Restart -p RestartUSec myapp.servicesystemd-cgls 列出该单元中的每个进程。MainPID 是重启策略读取的唯一数字。当这两者与您的理解不一致时,不一致之处就是问题所在。MainPID=0 比 PID 错误更严重:这表示 systemd 根本没有跟踪任何进程,因此不会有任何 Restart= 值触发。
主进程规则有一个真正的例外。如果内核的 out-of-memory killer 终止了该单元 cgroup 中的任意进程,systemd 仍能检测到,因为它会监控 cgroup 的 memory.events 文件。OOMPolicy= 决定接下来发生什么,其默认值为 stop:整个单元会停止,结果会记录为 oom-kill,并被视为失败,因此会触发 Restart=on-failure。journal 会明确记录这一点。
myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Failed with result 'oom-kill'.因此,被内存限制终止的子进程确实会导致单元停止,而因 segmentation fault 退出的同一子进程则不会。如果您为单元设置了内存限制,请先阅读MemoryMax 和 CPUQuota 如何应用于单元的 cgroup,再调整重启策略,因为这两项功能只会在这里相互影响。
Type=如何选择主进程
Type= 位于 [Service] 部分,不仅用于控制启动顺序。它还规定哪个 PID(进程 ID)会成为 MainPID,也就是决定 Restart= 能够看到什么。
Type=simple是默认值。systemd 从ExecStart=派生的进程就是主进程。systemd 会立即将单元标记为已启动,而不会先确认exec是否真正成功。二进制文件路径中的拼写错误会导致启动任务成功,随后Main process exited, code=exited, status=203/EXEC在片刻后失败。Type=exec的行为类似simple,但启动任务会等待exec成功。这会将上面的拼写错误正确报告为启动失败。它需要 systemd 240 或更高版本,所有受支持的发行版都满足此要求。优先使用它,而不是simple。Type=forking要求ExecStart=中的进程派生一个后台守护进程,然后退出。systemd 会等待父进程退出,再查找真正的守护进程。请为它设置PIDFile=。如果未设置,默认启用的GuessMainPID=只有在 cgroup 中恰好剩下一个进程时才能正常工作。如果留下两个进程,MainPID会保持为0。Type=notify表示服务会调用sd_notify(3),并在可以处理流量时发送READY=1。它也可以发送MAINPID=,将另一个进程交给 systemd 跟踪。NotifyAccess=默认为main,因此子进程发送的通知会被忽略,日志会记录发送通知的 PID。Type=oneshot没有持续运行的主进程。除非设置RemainAfterExit=yes,否则单元会在ExecStart=完成后立即变为非活动状态。此处会拒绝Restart=always和Restart=on-success,并显示消息Service has Restart= set to either always or on-success, which isn't allowed for Type=oneshot services. Refusing.。包括on-failure在内的其他值都会被接受。
有两个 Type=forking 错误值得记住,因为它们都会导致单元看起来无故损坏:
myapp.service: Can't open PID file /run/myapp.pid (yet?) after start: No such file or directory
myapp.service: New main PID 4711 does not belong to service, and PID file is not owned by root. Refusing.第一个错误表示守护进程将 PID 文件写入了其他位置,或者写入时间晚于 systemd 的检查时间。第二个错误表示 PID 文件指向单元 cgroup 之外的进程,systemd 会拒绝接管该进程。否则,可写的 PID 文件可能被用来让 systemd 向服务器上的任意进程发送信号。
包装脚本为何会掩盖子进程退出
下面是导致标题所述问题的脚本结构。
#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait该单元的主进程是 shell,即 Type=simple。不带参数运行 wait 时,只有所有子进程都退出后才会返回。终止 worker 后,shell 仍在等待 web 进程,因此 shell 不会退出,MainPID 也不会退出,Restart= 因而永远不会被查询。此时 cgroup 中少了一个进程,systemctl status 会输出缩短后的进程树,而该单元仍处于 active (running) 状态。systemd 不会监控这棵进程树的变化。
同一错误的另一种形式更加隐蔽:
ExecStart=/bin/sh -c 'export APP_ENV=production; /usr/local/bin/myapp'主进程是 shell,而不是 myapp。执行 systemctl stop 时,systemd 会向主进程发送 SIGTERM;等待前台子进程的 shell 不会转发该信号。停止操作随后会等待完整的 TimeoutStopSec,默认值为 90 秒,最后结果如下:
myapp.service: State 'stop-sigterm' timed out. Killing.
myapp.service: Killing process 4711 (myapp) with signal SIGKILL.修复方法是使用 exec。写成 exec /usr/local/bin/myapp 后,shell 会被该程序替换,因此 MainPID 就是该程序,信号也能到达它。更好的做法是删除 shell,并在单元中使用 Environment= 或 EnvironmentFile=。注意,当 -c 字符串只包含一条命令时,这个问题会被掩盖,因为 bash 和 dash 都会将这种情况优化为直接执行 exec。向字符串中添加第二条命令后,shell 就会继续作为程序前的父进程运行。
在测试 VPS 上用两分钟复现
将上面的包装脚本保存为 /usr/local/bin/two-children.sh,使用 chmod +x 赋予执行权限,并将两个程序路径替换为 sleep 3600。使用 Type=simple 和 Restart=on-failure 将单元指向该脚本,然后执行 systemctl daemon-reload 并启动它。运行 systemd-cgls --unit two-children.service,记录 3 个 PID:shell 及其两个子进程。使用 sudo kill <pid> 终止其中一个子进程。再次检查该单元。进程树少了一个进程,但状态仍为 active (running),日志也没有新增内容。现在改为运行 sudo kill -9 <shell pid>。该单元失败,剩余的子进程会被清理,因为 KillMode=control-group 是默认值;日志会显示 Scheduled restart job, restart counter is at 1.
完整的 Restart= 取值说明,以及何时应优先使用 on-failure
Restart= 有七个取值,区分这些取值的关键在于什么算作正常退出。systemd 将退出代码 0、SuccessExitStatus= 中列出的代码,以及信号 SIGHUP、SIGINT、SIGTERM 和 SIGPIPE 视为正常退出。其他情况都属于异常退出,包括 SIGKILL 和 SIGSEGV。
no是默认值。单元不会自动重启,因此没有Restart=配置行的单元在第一次崩溃后就会停止,并保持停止状态。on-success只在正常退出后重启。on-failure在退出代码非 0、信号导致异常退出、启动或停止超时,或 watchdog 超时后重启。on-abnormal在信号导致异常退出、超时或 watchdog 超时后重启,但不会因普通的非 0 退出代码重启。on-abort只在信号导致异常退出时重启,也就是发生崩溃时重启。on-watchdog只在WatchdogSec=到期后重启。always在上述所有情况下都重启,包括以状态码 0 正常退出。
对于长期运行的守护进程,on-failure 是合适的默认值。它会在进程崩溃后恢复服务,但不会干预主动执行的 exit 0。如果程序因自身无法控制的原因正常退出,则应使用 always,例如隧道客户端在远端断开连接时返回 0。always 的代价是会掩盖错误:服务启动后读取损坏的配置文件,记录错误并以 0 退出,就会无限循环;唯一的迹象是重启计数持续增加。
SuccessExitStatus= 可以调整正常退出与异常退出之间的界限。Borg 在出现警告时退出 1,在出现错误时退出 2。因此,未配置 SuccessExitStatus=1 的备份单元每次跳过一个无法读取的文件时都会被标记为失败。RestartPreventExitStatus= 列出即使使用 always 时也会阻止重启的退出代码,这是程序明确表示不应重新启动的正确方式。RestartForceExitStatus= 的作用相反。备份任务应放在由计时器驱动的 Type=oneshot 单元中,而不是放入重启循环;可参考按计划运行任务的服务与计时器单元组合。
测试时需要注意一点。直接使用 kill <pid> 终止服务时,发送的是 SIGTERM,而它位于正常退出列表中,因此 Restart=on-failure 会正确地不执行任何操作,你可能会误以为配置有问题。请改用 kill -9 <pid> 或 systemctl kill -s SIGKILL myapp.service。还要记住,Restart= 不会在 systemctl stop 之后触发;如果单元依赖的 BindsTo= 或 PartOf= 消失,导致该单元被停止时,也不会触发。停止任务不属于失败。
RestartSec 和 100 毫秒默认值
RestartSec= 是单元停止后,systemd 再次启动该单元前的暂停时间,默认值为 100 毫秒。检查单元实际加载的值:
systemctl show -p RestartUSec -p StartLimitIntervalUSec -p StartLimitBurst myapp.service未设置该值的单元会输出 RestartUSec=100ms。对于只崩溃一次、随后能够恢复的服务,这个默认值没有问题。对于完全无法启动的服务,这个值不合适,因为服务会在不到半秒内连续重启 5 次,正好触发下一节所述的速率限制。对于依赖数据库、挂载或网络路由的服务,应将其设置为 RestartSec=5s 或更长。
截至 2026 年 8 月,systemd 254 及更高版本还提供 RestartSteps= 和 RestartMaxDelaySec=,可让延迟从 RestartSec= 开始逐次增长,并在多次尝试后达到上限。Ubuntu 24.04 随附 systemd 255,支持这些选项。Debian 12 随附 systemd 252,不支持这些选项。当依赖项可能长时间不可用时,逐步增加延迟才是正确做法。
“start request repeated too quickly”真正表示什么
这是读者认为 systemd 任意放弃启动时所处的状态。它实际上是一个计数器。规则是:如果某个单元在 StartLimitIntervalSec= 内启动次数超过 StartLimitBurst=,systemd 就会拒绝再次启动它,并将其置于 failed 状态。默认值是 10 秒内启动 5 次。
journal 会显示以下序列:
myapp.service: Scheduled restart job, restart counter is at 5.
myapp.service: Start request repeated too quickly.
myapp.service: Failed with result 'start-limit-hit'.
Failed to start myapp.service - My application.而 systemctl start 会直接给出已写好的修复命令:
Job for myapp.service failed because start of the service was attempted too often. See "systemctl status myapp.service" and "journalctl -xeu myapp.service" for details. To force a start use "systemctl reset-failed myapp.service" followed by "systemctl start myapp.service" again.systemctl reset-failed myapp.service 会清除计数器和 failed 状态。其他操作都不会清除它,因此在运行该命令之前,即使执行普通的 systemctl start,请求也会持续被拒绝。手动启动同样计入限制,所以你在编辑配置文件时不耐烦地执行几次 systemctl restart,即使服务根本没有崩溃,也可能触发此限制。
最容易误导人的地方是:start-limit-hit 从来不会说明服务为什么失败。它只表示服务快速重复失败。真正的原因位于它上方的 journal 日志行中。
这两个设置都应写在 [Unit] 段中。你会看到一些示例将它们写在 [Service] 中;较旧版本的 systemd 接受这种写法,混淆也由此产生。请将它们写入 [Unit],然后使用 systemctl show 查询 systemd 实际加载的值,因为只有已加载的值才会生效。
[Unit]
Description=My application
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=exec
ExecStart=/usr/local/bin/myapp
Restart=on-failure
RestartSec=10s这样,单元会在 5 分钟窗口内尝试 5 次,之后放弃。StartLimitIntervalSec=0 会完全关闭该限制。你应明确了解这样做的后果:如果服务始终无法启动,它现在会无限重试,并在每次重试时写入 journal。整台机器的默认值位于 /etc/systemd/system.conf 中,分别是 DefaultStartLimitIntervalSec= 和 DefaultStartLimitBurst=。
还有一个相邻设置需要注意。StartLimitAction= 决定达到限制后要执行的操作,其值包括 reboot、reboot-force 和 poweroff。默认值是 none,它会使单元失败,但不会影响整台机器。对于远程 VPS,poweroff 表示服务器会一直关机,直到你打开服务商的控制台。
修正方案一:每个单元只运行一个进程
这在几乎所有情况下都是正确的做法。如果必须运行两个程序,就编写两个单元。这样每个单元都有明确的主进程、明确的退出状态和独立的重启策略。您还可以分别记录日志、设置资源限制和统计重启次数,这正是凌晨 3 点排查问题时所需要的。
在单元文件中表达单元之间的关系,不要通过 shell 脚本表达。
After=只控制启动顺序,不表示任何故障关系。Requires=会让另一个单元与当前单元同时启动;如果另一个单元被显式停止,当前单元也会停止。BindsTo=包含Requires=的行为,并增加了您真正需要的情况:无论另一个单元因何停止,包括崩溃,当前单元都会停止。请将它与After=配合使用,否则顺序未定义。PartOf=会向下传递停止和重启操作,因此systemctl restart myapp.target会作用于所有属于它的PartOf=单元。Upholds=(systemd 249 及更高版本,因此适用于 Ubuntu 22.04 及更高版本)会保持指定单元运行:如果该单元停止,systemd 会再次启动它。它受到与其他单元相同的启动速率限制。
某个 worker 必须始终依赖其 API 服务器运行,并且只要 API 服务器处于运行状态,systemd 就必须保持该 worker 运行:
# /etc/systemd/system/myapp-api.service
[Unit]
Description=myapp API server
Wants=network-online.target
After=network-online.target
Upholds=myapp-worker.service
[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp serve
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target# /etc/systemd/system/myapp-worker.service
[Unit]
Description=myapp background worker
BindsTo=myapp-api.service
After=myapp-api.service
StartLimitIntervalSec=120
StartLimitBurst=5
[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp worker
Restart=on-failure
RestartSec=5sworker 没有 [Install] 部分,也不会手动启用。API 单元通过 Upholds= 将其拉起,因此您只需运行 systemctl enable --now myapp-api.service。重新加载配置,并检查 systemd 如何处理这两个单元:
sudo systemctl daemon-reload
systemd-analyze verify /etc/systemd/system/myapp-worker.service
systemctl list-dependencies myapp-api.service配置文件没有问题时,systemd-analyze verify 完全不输出任何内容。任何输出都表示存在问题。通常是因为 systemd 无法识别您写在该部分中的某个键,或者依赖的单元不存在。
修复二:使用 Type=notify,让 systemd 获取的不只是 PID
如果程序支持 systemd 通知协议,请使用该协议。使用 Type=notify 后,服务会在准备就绪时通知 systemd,使启动顺序真正生效,而不是停留在预期层面;服务还可以发送 MAINPID=,让 systemd 关注实际运行的进程,而不是启动器。
WatchdogSec= 是值得配置的部分。设置该选项后,服务必须至少每隔相应时间通过 sd_notify(3) 发送一次 WATCHDOG=1。如果停止发送,systemd 会使用 SIGABRT 终止服务,并将其标记为失败,因此 Restart=on-failure 或 Restart=on-watchdog 可以将其重新启动。这是内置的唯一方法,可用于重新启动仍在运行但已卡住的进程;任何退出状态策略都无法捕获这种情况。
[Service]
Type=notify
NotifyAccess=main
ExecStart=/usr/local/bin/myapp serve
WatchdogSec=30s
Restart=on-failure
RestartSec=5s看门狗触发时,journal 中会先出现 myapp.service: Watchdog timeout (limit 30s)!,随后记录终止操作。如果单元反而一直处于 activating (start),直到 TimeoutStartSec 耗尽,说明 READY=1 从未到达:程序可能不支持该协议,也可能是 NotifyAccess=main 拒绝了来自子进程的通知;journal 会同时记录两个 PID。
对于提供 HTTP 健康检查端点但不支持 sd_notify 的软件,合理的选择只有两种:创建一个小型 timer 单元,探测该端点并调用 systemctl restart;或者让容器运行时执行探测,这正是 Compose 健康检查及其重启行为 的用途。
修复三:仅在别无选择时,在单元内运行监督程序
有些软件确实以进程集合的形式发布,并由无法拆分的启动器负责启动。这时,您可以在单元内运行监督程序,但必须接受相应后果:systemd 监控监督程序,监督程序监控其他所有进程,而重启策略会分别配置在两个文件中。
这种情况常见于容器运行时。docker compose 或 podman 单元正是这种模式:每个容器的重启策略写在 Compose 文件中,systemd 单元只负责保持运行时进程处于运行状态。如果您的架构也是如此,请参阅在启动时启动 Compose 堆栈的单元,其中包含可用的配置,并说明为什么这里通常应使用 Type=oneshot 和 RemainAfterExit=yes。
cgroup 仍然可以发挥作用。监督程序启动的所有进程都会保留在该单元的 cgroup 中,因此 MemoryMax=、CPUQuota= 以及停止时的清理操作仍会覆盖整个进程树。只有重启决策被委托给监督程序。
无论选择哪种监督程序,都不要在未仔细评估的情况下,同时为外层单元设置 Restart=always,并在单元内部配置激进的重启策略。两层重启逻辑分别使用自己的退避机制,会导致服务持续抖动数分钟,而 journal 也无法说明原因。
ExitType=cgroup 不表示“任意进程退出时重启”
ExitType=(systemd 250 及更高版本,因此 Ubuntu 24.04 和 Debian 12 都支持)是人们搜索此问题时找到的设置,但它的实际作用与名称给人的暗示相反。默认值 ExitType=main 表示主进程退出后,服务即被视为已停止。ExitType=cgroup 表示只有 cgroup 中的最后一个进程退出后,服务才被视为已停止。
因此,ExitType=cgroup 会降低单个进程退出对单元的影响,而不是提高敏感度。对于会派生实际工作进程、随后退出父进程且不写入 PID 文件的程序,这是正确的设置,因为 Type=forking 无法找到该守护进程。但对于这里描述的故障,这是错误的设置。
不存在一个 Restart= 值,可以表示“cgroup 中任意进程退出时重启单元”。如果需要这种行为,就必须让每个单元只运行一个进程。如果无法拆分程序,且您可以控制包装脚本,那么最接近的做法是 wait -n,它会在第一个子进程退出后立即返回:
#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait -n
exit 1现在,任意子进程退出都会使包装脚本以非零状态退出,因此 Restart=on-failure 会生效。这是一种折中方案,不是修复方案。两个程序仍共用一个重启计数器和一条日志流,并且无法单独重启发生故障的部分。
如何检查实际发生了什么
按以下顺序执行 4 个命令。
systemctl status myapp.service
systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Result -p ExecMainStatus myapp.service
journalctl -u myapp.service -b -o short-precisesystemctl status会在一个屏幕中显示状态、主 PID 和 cgroup 树。健康的单元应显示 Active: active (running),并有一行 Main PID: 指向预期进程。如果底部的树列出了无法识别的进程,或者缺少某个应存在的进程,原因就已经明确了。
systemd-cgls --unit会输出未截断的同一棵树。当单元包含的进程超过少数几个时,完整输出就很重要。
systemctl show会输出机器可读的信息。NRestarts=是重启计数器。通过它可以快速区分已重启 40 次的服务和自系统启动以来一直运行的服务。Result=保存最后一次失败原因:exit-code、signal、timeout、oom-kill、watchdog 或 start-limit-hit。ExecMainStatus=是最后一个主进程的原始退出状态。
journal 会记录事件顺序。以下是需要搜索的 3 行:
myapp.service: Main process exited, code=exited, status=1/FAILURE
myapp.service: Failed with result 'exit-code'.
myapp.service: Scheduled restart job, restart counter is at 1.code=exited, status=N表示程序主动返回了 N,因此故障位于程序本身或其配置中。code=killed, signal=SEGV表示程序崩溃。code=killed, signal=TERM通常表示其他进程请求它停止。这不属于失败,也不会触发 Restart=on-failure。code=dumped表示程序生成了 core 文件;安装 coredumpctl list 后,systemd-coredump会显示该文件。
如果涉及多台机器,NRestarts是值得定期收集的指标。某个单元的计数器每天递增,就表示它每天都在失败,无论是否有人注意到。管理两三台以上的服务器后,在每台服务器上以一致方式执行同一个命令,就能将猜测变成报告。
FAQ
为什么 systemctl 显示服务处于 active 状态,但进程已经退出?
systemd 会为每个服务单元跟踪一个进程,即主进程,而 Restart= 只读取该进程的退出状态。该单元启动的其他进程都位于同一个 cgroup 中。服务单元停止时,systemd 会终止这些进程,但不会监视它们是否退出。运行 systemctl show -p MainPID myapp.service,并将数量与 systemd-cgls --unit myapp.service 进行比较。如果已退出的进程出现在进程树中,但不是 MainPID,说明 systemd 的行为完全符合设计。解决方法是每个单元只运行一个进程,并在单元之间通过 BindsTo= 和 Upholds= 声明依赖关系。
“start request repeated too quickly”是什么意思?
这表示该单元在 StartLimitIntervalSec= 内启动次数超过 StartLimitBurst=。默认值是 10 秒内启动 5 次,因此 systemd 停止继续尝试。这是速率限制,不会说明服务失败的原因,因此应读取它上方的 journal 日志。使用 systemctl reset-failed myapp.service 清除状态,然后修复根本故障。如果服务需要等待某个启动较慢的依赖项,应增大 RestartSec=,因为默认的 100 毫秒间隔会让 5 次尝试在不到 1 秒内全部耗尽。
应该使用 Restart=always 还是 Restart=on-failure?
大多数情况下使用 on-failure。它会在进程崩溃、以非零状态退出、超时或 watchdog 触发时重启服务,但不会处理主动执行的 exit 0。只有当程序因自身无法控制的原因正常退出时,才使用 always,例如对端断开连接时客户端返回 0。使用 always 的问题是:如果服务读取损坏的配置、记录一条错误后以 0 退出,就会无限循环;唯一明显的症状是 NRestarts 在 systemctl show 中不断增加。
为什么手动终止进程不会触发重启?
因为 systemd 将 SIGHUP、SIGINT、SIGTERM 和 SIGPIPE 视为正常退出,而普通的 kill <pid> 会发送 SIGTERM。在 Restart=on-failure 下,正常退出不属于失败,因此不会触发重启。这样会让人误以为配置有问题,但实际上配置没有错误。使用 kill -9 <pid> 或 systemctl kill -s SIGKILL myapp.service 进行测试;这会执行非正常终止,并触发重启策略。同一规则也解释了为什么 systemctl stop 从不会与重启策略冲突。
StartLimitIntervalSec 和 StartLimitBurst 应该放在哪里?
放在 [Unit] 部分。旧资料和旧版 systemd 会将它们放在 [Service] 中,因此复制的示例可能互相矛盾。不要猜测当前版本采用哪种配置。执行 systemctl daemon-reload 后,使用 systemctl show -p StartLimitBurst -p StartLimitIntervalUSec myapp.service 查询 systemd 实际加载的配置,并以这些数值为准。systemd-analyze verify /etc/systemd/system/myapp.service 会检测 systemd 完全无法识别的键;配置文件正确时不会输出任何内容。