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

systemd 单元无法启动:如何查看退出代码

先运行 systemctl status,准确判断故障来源。本文解释 203/EXEC 与 226/NAMESPACE 的含义,并说明单元为何可能启动成功后仅一秒就退出。

systemd 单元无法启动的原因

无法启动的 systemd 单元会在一个字段中说明原因。运行 systemctl status <unit>,在报告失败的行中查找 code=status=。200 多的状态码表示 systemd 从未运行您的程序:它在构建单元文件所请求的环境时就失败了。低于 200 的状态码表示您的程序确实运行过,并自行退出,因此单元文件通常没有问题,问题在于应用程序。

这就是判断路径。下面的内容都遵循这一判断,并按照这些数字出现的顺序展开。

按顺序回答该问题的 3 个命令

systemctl status myapp.service
journalctl -u myapp.service -b --no-pager
systemd-analyze verify /etc/systemd/system/myapp.service

systemctl status 给出结论。先读取 Loaded: 行,因为它会显示 systemd 实际解析的文件,并说明该单元是已启用、已屏蔽,还是根本不存在。然后读取 Active: 行,以及下面的 code=status=

journalctl -u myapp.service -b --no-pager 提供详细信息。-u 将输出限制为该单元,-b 将范围限制为本次启动,这样不会读到上周的故障信息;--no-pager 直接将内容输出到终端,因此可以将其通过管道传给 grepstatus 只显示最后几行日志,并截断过长的行。journal 会显示程序退出前输出的所有内容,这通常才是真正的错误信息。添加 -n 100 可查看更多历史记录;也可以在第二个终端中运行带有 -f 的命令,然后重启该单元。

systemd-analyze verify 加载单元文件,但不会运行该单元。它会警告未知的节和指令,并标记 ExecStart= 中无法执行的命令。这样可以发现两类不易察觉的错误:拼写错误的键名,以及不存在的路径。对于拼写错误的键名,systemd 在加载时会忽略它并发出警告,但大多数人不会查看该警告。

编辑任何单元文件后,都要运行 sudo systemctl daemon-reload。在此之前,systemd 会继续使用之前加载的副本,而 systemctl status 会提示磁盘上的文件已发生变化。看似“没有效果”的修复,通常只是 systemd 尚未重新读取该文件。

还有两个命令也很有用。systemctl cat myapp.service 会输出生效的单元配置,即主文件以及 /etc/systemd/system/myapp.service.d/ 下所有 drop-in 配置的合并结果。systemctl show myapp.service -p ExecStart -p User -p WorkingDirectory 会按照 systemd 的解析结果输出这些值,也就是实际运行时使用的配置。

status=203/EXEC 表示什么?

203/EXEC 表示 systemd 已完成设置、调用了 execve(),但内核拒绝执行。您的程序一行自己的代码都没有运行。几乎所有情况都可归结为以下4种原因。

  1. ExecStart= 中的路径错误,或不是绝对路径。使用 ls -l 检查它,并与单元文件中的完整字符串进行对比。
  2. 文件没有执行位。使用 sudo chmod +x /opt/myapp/run.sh 修复。文件从归档中解压,或从其他计算机复制过来后,通常会丢失该权限位。
  3. shebang 行损坏。内核会读取脚本的第一行,并运行其中指定的解释器。因此,当服务的 PATH 中没有 python3 时,#!/usr/bin/env python3 会失败;如果文件使用 Windows 换行符保存,则会请求名为 /bin/bash\r 的解释器,但该解释器不存在。
  4. 该文件不是此计算机可以运行的文件:架构不匹配,或是完全没有 shebang 的文本文件。

在修改任何内容之前,先以服务用户身份手动复现问题。

sudo -u appuser /opt/myapp/run.sh
file /opt/myapp/run.sh
head -1 /opt/myapp/run.sh | cat -A

当换行符导致问题时,file 会显示架构,并报告“使用 CRLF 行终止符”。cat -A 会以末尾的 ^M 形式显示同一问题。使用 sed -i 's/\r$//' /opt/myapp/run.sh 删除这些字符。

关于该范围,有一点需要说明:200及以上只是约定,并非保证。您自己的程序也可以退出并返回203,systemd 无法区分这两种情况。systemd-analyze exit-status 203 会显示任意代码的名称和类别,帮助您阅读该表;但如果应用程序使用大于199的退出码,请修改这些退出码。

为什么会出现 217/USER 或 216/GROUP?

217/USER 表示服务启动时,User= 中指定的账户不存在。216/GROUP 表示 Group=SupplementaryGroups= 中指定的组不存在。分别运行一条命令即可确认。

getent passwd appuser
getent group appgroup

每条命令要么输出一行,要么不输出内容并返回非零状态。不输出内容表示系统中不存在该名称,因此 systemd 无法切换到该账户或组,并会在执行程序前停止。解决方法是创建该账户或组,而不是设置 User=root。让服务以具有最小权限的专用系统账户运行,正是该指令的用途。

sudo useradd --system --no-create-home --shell /usr/sbin/nologin appuser

DynamicUser=yes 让 systemd 在每次启动时分配临时账户,从而绕过此问题。这适用于不保存状态的服务。任何会写入文件的服务都需要同时设置 StateDirectory=,因为每次启动时用户 ID 都会变化,普通路径下的文件最终会归属于一个已不存在的账户。

什么是 226/NAMESPACE?

226/NAMESPACE 来自沙箱相关指令。当单元设置 ProtectSystem=ProtectHome=PrivateTmp=ReadWritePaths= 或类似指令时,systemd 会在执行程序前为该服务创建私有挂载命名空间。这里的命名空间是某个进程所看到的文件系统私有视图。如果挂载计划中的任何一项失败,服务就会以 226 退出,程序也不会运行。

最常见的原因是 ReadWritePaths= 中的某个路径不存在。ProtectSystem=strict 会以只读方式挂载整个文件系统,ReadWritePaths= 会重新以可写方式打开指定路径。systemd 无法重新打开不存在的目录。有两种合适的修复方法。使用 StateDirectory= 让 systemd 创建目录;该指令会在每次启动时创建 /var/lib/<name>,并将其交给服务用户。或者在路径前加上 -,告诉 systemd 在源路径缺失时忽略该项。不要删除加固配置;这只是用永久性问题换取暂时的 5 分钟问题。

[Service]
ProtectSystem=strict
ProtectHome=yes
StateDirectory=myapp
ReadWritePaths=-/srv/uploads

如果无法确定是哪一行导致问题,可以删除整个加固配置块,重新加载配置,然后启动服务。如果服务成功启动,再逐行恢复配置,每恢复一行就重启一次。此类错误中另外两个相近的错误是 233/RUNTIME_DIRECTORY238/STATE_DIRECTORY。它们表示 systemd 无法创建或取得 RuntimeDirectory=StateDirectory= 中指定目录的所有权,通常是因为该路径已经存在,且属于其他用户。

为什么 WorkingDirectory 看起来正确,却出现 200/CHDIR?

200/CHDIR 表示进入 WorkingDirectory= 时,chdir() 失败。目录可能不存在,或者服务用户无法进入该目录。进入目录需要对该目录及其所有上级目录拥有执行权限。因此,即使 /home/deploy/app 完全可读,只要 /home/deploy 的权限模式为 700,且服务以 appuser 身份运行,该目录仍然无法访问。

sudo -u appuser test -x /srv/myapp && echo ok
namei -l /srv/myapp

namei -l 会输出路径中每个组件的所有者和权限模式。这是快速找出阻塞后续路径的目录的最有效方法。设置 WorkingDirectory=-/srv/myapp 后,目录缺失不会导致服务失败。对于不关心从哪个目录启动的程序,这样设置是正确的;对于按相对路径打开文件的程序,这样设置则不正确。

为什么服务启动后会在 1 秒后停止?

这里没有 200 系列代码,通常也完全没有错误文本。单元在启动后直接显示 inactive (dead),或者不断循环显示 activating (auto-restart)。systemd 已正确设置运行环境。问题在于程序的行为与 Type= 承诺的运行方式不匹配。

Type=simple 是默认值,表示程序会保持在前台运行。如果让它运行一个 fork 到后台后退出的守护进程,systemd 会看到主进程结束,并认为服务已完成。大多数守护进程都有保持前台运行的选项,例如 nginx -g 'daemon off;'

Type=forking 表示第一个进程会在其子进程就绪后退出。如果让它运行前台程序,启动任务会一直等待,直到 TimeoutStartSec= 超时。默认超时时间为 90 秒,随后 systemd 会终止该程序,并记录超时。

Type=notify 表示程序会调用 sd_notify() 来报告就绪状态。不支持该机制的程序不会发送任何通知,因此启动会超时,journal 会将结果记录为协议失败。

根据程序的实际行为选择类型。了解 simple、forking、oneshot 和 notify 的区别是解决这一类故障的关键。

服务反复退出时,systemd 会停止尝试,并报告启动请求重复过快。此时单元会保持 failed 状态,直到速率限制时间窗口结束,或运行 sudo systemctl reset-failed myapp.service。提高限制只会掩盖症状。应从第一次失败开始查看 journal,而不是只看最后一次失败;修改前请先了解 Restart=on-failure 实际会重试什么

为什么单元完全没有报错却处于非活动状态?

单元可能会被跳过,而不是启动。Condition* 指令本身不会产生日志:检查失败时,systemd 会将该任务标记为成功,然后不执行任何操作。包含 ConditionPathExists=/etc/myapp/config.yml 的单元在该文件缺失时不会启动,也不会报告错误。

systemctl show myapp.service -p ConditionResult -p ConditionTimestamp
journalctl -u myapp.service -b --no-pager | grep -i condition

ConditionResult=no 可确认该单元被跳过,journal 中会记录未满足的检查条件。如果缺少前置条件时应明确失败,请改用 Assert* 指令。条件、断言和单元排序介绍了每种检查应放置的位置。

其他几种静默情况也与此相关。“could not be found”错误通常表示文件位于错误的目录,或者您尚未重新加载配置:您编写的单元文件应放在 /etc/systemd/system/ 中。被屏蔽的单元在 sudo systemctl unmask myapp.service 清除屏蔽状态前会拒绝所有启动请求。如果单元没有 [Install] 部分,systemctl enable 会失败,因此请为其添加 WantedBy=multi-user.target

如果进程被终止,而不是启动失败,该怎么办?

code=killedcode=exited 的含义不同。进程是被外部因素终止的。status=9/KILL 表示触发了 out of memory (OOM) killer,journal 会记录它选择终止的进程。您自行设置的限制也会在 cgroup(control group)内执行相同操作,因此请使用 free -m 检查主机上的可用内存,并检查该单元是否设置了 MemoryMax=MemoryMax、CPUQuota 和其他 cgroup 限制 说明哪些限制会终止进程,哪些限制只会降低进程速度。

启动尝试后立即出现 status=15/TERM,通常表示 systemd 等待启动超时并终止了进程,因此请返回检查 Type=

避免大多数此类故障的两个习惯

始终使用绝对路径。 systemd 不会运行您的登录 shell,因此没有 .bashrc、没有 .profile,也没有已激活的虚拟环境。系统服务的 $PATH 只是一个简短的内置列表,其中不包含 /opt 或语言版本管理器的 shim。请完整写出 /usr/bin/python3/opt/myapp/venv/bin/python。在 shell 中运行 command -v myapp 可输出可直接粘贴的路径。同样的规则适用于 WorkingDirectory=EnvironmentFile= 以及 ReadWritePaths= 中的所有路径。

ExecStart= 不是 shell。 systemd 会将该行拆分为多个单词,然后直接调用 execve()。管道、重定向、通配符、&&、反引号和 ~ 都不会被解释,而是作为字面量参数传递给程序。ExecStart=/usr/bin/myapp --flag > /tmp/out.log 会将 >/tmp/out.log 传递给 myapp,后者随后以用法错误退出,而错误表现通常与 systemd 问题完全不同。需要使用 shell 功能时,请显式调用 shell。

ExecStart=/bin/sh -c '/usr/bin/myapp --flag | /usr/bin/tee -a /var/log/myapp.log'

如果只需要输出,则不必这样做。服务输出默认会写入 journal,StandardOutput=append:/var/log/myapp.log 无需 shell 即可将输出写入文件。

变量展开也受到同样限制。$MYVAR${MYVAR} 会从 Environment=EnvironmentFile= 中替换,除此之外不会展开任何内容。系统服务不会设置 $HOME,除非您显式设置它。EnvironmentFile= 也不是 shell 脚本:其中不能使用 export,其引用规则与 bash 不同;如果文件不存在,则会直接失败,除非在路径前加上 -

在运行中的服务器上排查

阅读代码,确认原因,修改一项内容,然后重启。这个顺序比记住所有编号更重要,因为它可以避免连续进行3项未经验证的修改,导致无法判断哪项修改生效。对于不是您编写的单元,也可以使用相同的方法。永不触发的计时器,通常对应一个从未启动的服务,因此应先排查服务:systemd 计时器及其触发的服务会以完全相同的方式失败;在您向 journal 请求日志之前,计时器会隐藏服务输出。

FAQ

systemctl status 中的 status=203/EXEC 表示什么?

systemd 已完成该单元请求的设置,但 execve() 调用失败,因此程序从未启动。请按以下顺序检查 4 项:ExecStart= 中的路径是否存在且为绝对路径,文件是否带有执行位,shebang 指定的解释器是否存在于服务的 PATH 中,以及文件是否使用 Unix 换行符。对于最后一项,file 会报告“with CRLF line terminators”,这会将解释器名称转换为 /bin/bash\r,导致内核拒绝执行。

为什么我的服务启动后立即停止?

单元文件声明的行为与程序实际支持的行为不一致。使用 Type=simple 时,systemd 期望程序保持在前台运行,因此会将派生到后台的守护进程视为已完成。使用 Type=forking 时,systemd 会等待第一个进程退出,因此前台程序会让启动任务一直等待,直到 TimeoutStartSec= 超时。请根据程序行为匹配 Type=;如果程序提供前台运行选项,请将该选项与默认值 Type=simple 一起使用。

如何查看实际错误,而不是简短的状态输出?

systemctl status 只显示 journal 的最后几行,并截断过长的行。运行 journalctl -u myapp.service -b --no-pager 可获取该单元在本次启动期间记录的全部内容;添加 -n 200 可扩大时间窗口,或将输出通过管道传给 grep。如果应用程序写入自己的日志文件,也应读取该文件,因为 systemd 只会捕获程序发送到标准输出和标准错误的内容。

为什么我的单元处于 inactive 状态且没有错误消息?

最常见的原因是某个 Condition* 指令跳过了该单元。这些检查不会显示错误:条件失败时,启动任务仍会被标记为成功。运行 systemctl show myapp.service -p ConditionResult 并查找 ConditionResult=no,然后阅读指出该检查的 journal 行。另一个常见原因是单元已被屏蔽;在 sudo systemctl unmask 清除屏蔽前,它会拒绝所有启动请求。

每次修改单元文件后都需要 daemon-reload 吗?

需要。修改单元文件或 drop-in 后,都必须执行此操作。sudo systemctl daemon-reload 会让 systemd 从磁盘重新读取文件,然后 sudo systemctl restart myapp.service 将这些更改应用到正在运行的服务。执行 systemctl edit 后不需要再执行它,因为该命令会自动重新读取配置。修改属于应用程序而非 systemd 的配置文件后,也不需要执行它。

#systemd#troubleshooting#journalctl#exit-codes#linux-fundamentals