systemd 服务启动失败:区分单元配置、执行环境、权限与重启限流

151 次浏览5 条回复

适用于 Linux 上由 systemd 托管的服务出现 failed、启动后立即退出、status=203/EXEC 或 start request repeated too quickly。命令基线为 systemd 245+、util-linux 2.36+;读取系统级单元、完整日志和其他用户文件通常需要 root。示例单元 app.service、程序路径和健康检查地址必须替换为实际值。先保留失败现场,不要一开始就循环重启、把服务改成 root,或放宽整个目录的权限。

1. 固定时间线与实际加载的单元

date -u
systemctl --version | head -n 1
systemctl status app.service --no-pager -l
systemctl show app.service \
  -p LoadState -p ActiveState -p SubState -p Result \
  -p ExecMainCode -p ExecMainStatus -p MainPID \
  -p FragmentPath -p DropInPaths
systemctl cat app.service
journalctl -u app.service -b --no-pager -o short-iso

systemctl cat 用于确认主单元和 drop-in 的合并来源,不能只查看某个猜测路径下的文件。日志应保留首次失败、后续自动重启和触发限流的完整顺序;只看最后一行容易把结果当成原因。若故障跨越了本次启动,按实际 UTC 窗口使用 --since/--until,并核对上一轮启动日志。

2. 先按退出方式分流

Result 描述单元结果,ExecMainCode 区分正常退出与信号终止,ExecMainStatus 才是退出码或信号编号。常见的 203/EXEC 指向执行阶段失败,200/CHDIR 指向工作目录切换失败;应再结合日志与本机的 systemd.exec(5),不要把所有非零状态都解释成应用自身返回。

若主进程确实开始运行后返回应用退出码,应转查应用日志、参数和依赖;若被信号终止,还要对齐内核日志、资源限制与 cgroup 状态:

journalctl -k --since '-15 min' --no-pager -o short-iso
systemctl show app.service \
  -p MemoryCurrent -p MemoryMax -p TasksCurrent -p TasksMax \
  -p LimitNOFILE -p OOMPolicy

这些属性的可用性会随 systemd 和 cgroup 版本变化,空值不能单独证明没有限制。

3. 检查语法、覆盖关系与命令解释方式

UNIT_PATH=$(systemctl show app.service -p FragmentPath --value)
test -n "$UNIT_PATH" && test -r "$UNIT_PATH" || exit 1
systemd-analyze verify "$UNIT_PATH"
systemctl cat app.service

systemd-analyze verify 可发现部分未知指令、依赖和命令问题,但仍需以当前管理器实际加载的主文件与 drop-in 为准。ExecStart= 不是交互式 shell 命令行,&&、管道、重定向和 shell 初始化文件不会自动生效。需要复杂逻辑时,应使用可审计的脚本并给出绝对路径;不要为了让操作符生效而无条件套一层 shell。

修改单元或 drop-in 后才执行 systemctl daemon-reload,然后重新查看 systemctl cat 与 systemctl show,确认管理器确实加载了预期版本。

4. 复核服务用户、工作目录与执行链路

systemctl show app.service \
  -p User -p Group -p DynamicUser -p WorkingDirectory \
  -p RootDirectory -p RootImage -p PrivateTmp \
  -p ProtectSystem -p ProtectHome -p NoNewPrivileges

readlink -f /opt/app/bin/server
namei -l /opt/app/bin/server
stat /opt/app/bin/server
findmnt -T /opt/app/bin/server -o TARGET,SOURCE,FSTYPE,OPTIONS
head -n 1 /opt/app/bin/server

对脚本要同时核对首行解释器、解释器自身权限和每一级父目录的搜索权限;文件具有执行位仍可能被挂载参数 noexec、缺失解释器或访问控制阻止。WorkingDirectory= 也必须存在并允许服务用户进入。启用 RootDirectory=、RootImage=、ProtectHome= 或其他沙箱后,宿主机上存在的路径在服务命名空间内未必可见。

不要直接发布完整环境变量或环境文件内容,其中可能含敏感配置。服务不会继承交互式终端的 PATH 和 shell 初始化结果,程序与关键依赖优先使用明确的绝对路径。

5. 区分根因失败与重启限流

systemctl show app.service \
  -p Restart -p RestartUSec -p NRestarts \
  -p StartLimitIntervalUSec -p StartLimitBurst
journalctl -u app.service -b --no-pager -o short-iso

start request repeated too quickly 通常表示此前的连续失败已经触发启动速率限制,本身不是最早根因。应先修正第一处执行、权限、配置或应用错误。确认修正后,才使用:

systemctl reset-failed app.service
systemctl start app.service

reset-failed 会清除失败状态和相关计数;在取证前执行会损失一部分状态信息。若 Restart=always 使日志快速增长,可在获准的维护窗口内控制重试,但需要保留原配置并记录变更。

6. 从 systemd 状态验证到真实入口

systemctl status app.service --no-pager -l
systemctl is-active app.service
systemctl show app.service -p MainPID -p NRestarts -p ActiveEnterTimestamp
journalctl -u app.service --since '-5 min' --no-pager -o short-iso

active 只表示 systemd 认为单元处于活动状态,不等于应用已经可用。还应通过实际入口执行受控健康检查,例如已有 HTTP 健康端点时使用带超时的 curl,并核对监听、依赖连接和应用错误率。闭环标准是原失败入口恢复、观察窗口内 NRestarts 不再增长、没有新增退出记录,且服务以预期用户、参数和沙箱配置运行。

再补一个 203/EXEC 的窄分支:目标文件存在、权限链和挂载参数都正常时,还要检查二进制格式、ELF 解释器以及 LSM 拒绝。execve(2) 在动态加载器缺失时也可能返回 ENOENT,所以日志里的“文件不存在”未必指 ExecStart 本身。

命令前提:file 5.38+;readelf 来自 binutils 2.34+;读取完整内核或审计日志通常需要 root。

file /opt/app/bin/server
readelf -hW /opt/app/bin/server | sed -n '/Class:/p;/Machine:/p'
readelf -lW /opt/app/bin/server | sed -n '/interpreter/p'
uname -m
journalctl -k --since '-15 min' --no-pager -o short-iso \
  | grep -Ei 'apparmor|avc:|selinux|denied'

将 readelf 显示的解释器路径放到服务实际的 RootDirectory=/RootImage= 和挂载命名空间中核对;只在宿主机检查存在性不够。Machine 与主机架构不匹配、部署了错误制品、解释器未打进根目录,或 SELinux/AppArmor 拒绝执行,都可能表现为这一状态。若系统启用了 auditd,可再按同一 UTC 故障窗口查询 AVC 记录。

不建议用 ldd 检查来源不可信的可执行文件;这里用 readelf 只解析 ELF 元数据。修正后仍按原文流程 daemon-reload(仅单元有改动时)、reset-failed、启动并验证真实入口。

再补一个“进程看似已经起来,但单元仍在 activating,随后启动超时”的分支。这通常不是 203/EXEC,而要核对 Type= 对应的就绪协议。适用于 systemd 245+;读取系统级单元和完整日志通常需要 root。

systemctl show app.service \
  -p Type -p Result -p TimeoutStartUSec \
  -p NotifyAccess -p PIDFile -p MainPID
systemctl status app.service --no-pager -l
journalctl -u app.service -b --no-pager -o short-iso

Type=notify 要求服务通过 sd_notify() 发送 READY=1;程序未编译通知支持、通知套接字未传入实际主进程,或 NotifyAccess= 与发送者不匹配时,进程可以持续运行,但 systemd 仍会等到 TimeoutStartSec= 后判定失败。Type=forking 则依赖父进程按约定退出,并常需正确的 PIDFile=;PID 文件写得过晚、路径不一致或残留旧 PID,都可能让主进程识别和启动完成判断出错。前台运行的服务若误配为 forking,也会卡在错误的生命周期假设上。

先从日志确认是否出现 start operation timed out、通知权限或 PID 文件相关信息,再对照程序真实启动模型修正 Type=、通知实现或 PID 文件。不要先单独放大 TimeoutStartSec=:这只会延后失败,不能修复未履行的就绪协议。修改单元后执行 daemon-reload,再验证 ActiveState、SubState、MainPID、真实健康入口以及观察窗口内 NRestarts 是否稳定。

还可以补一个“根本没有进入 ExecStart=”的分支:单元被 Condition...= 跳过、被 Assert...= 拒绝,或 ExecCondition= 决定不继续时,沿着执行文件和权限排查通常不会命中。以下适用于 systemd 245+;读取系统级单元及完整日志通常需要 root。

systemctl show app.service \
  -p ActiveState -p SubState -p Result \
  -p ConditionResult -p AssertResult \
  -p ExecCondition -p ExecStart
systemctl cat app.service
journalctl -u app.service -b --no-pager -o short-iso

ConditionPathExists=、ConditionEnvironment= 等条件为假时,启动通常会被跳过而不是把单元标成 failed;Assert...= 为假则会让启动作业失败。两者都发生在真正执行服务命令之前。日志中的 Condition check resulted in ... being skipped、assertion failed 一类信息,比单看 inactive (dead) 更有区分度。还要从 systemctl cat 核对 drop-in,因为条件可能并不在主单元文件中。

ExecCondition= 的语义也不同于普通 ExecStartPre=:命令正常退出且状态为 1 到 254 时,后续启动命令会被跳过,但单元通常不会因此进入失败状态;退出 255 或被信号、超时等异常终止时才按失败处理。条件程序若依赖相对路径、交互式 shell 环境或未显式提供的环境变量,也可能造成“手工执行通过、systemd 下跳过”。

验证时应在同一管理器实例中重新发起一次受控启动并立即对齐日志;用户单元使用 systemctl --user 和 journalctl --user-unit=app.service,不要混读系统实例。闭环不只是看到 active,还要确认日志中条件已通过、ExecStart 确实产生了预期主进程,并通过原健康入口验证服务可用。

再补一个容易误判的来源:日志里的 status=203/EXEC 不一定来自主 ExecStart=,也可能是 ExecStartPre= 或 ExecStartPost= 中某条命令无法执行。适用于 systemd 245+;读取系统级单元及完整日志通常需要 root。

systemctl show app.service \
  -p Result -p ExecMainCode -p ExecMainStatus \
  -p ExecStartPre -p ExecStart -p ExecStartPost -p ExecStopPost
systemctl status app.service --no-pager -l
journalctl -u app.service -b --no-pager -o short-iso

未带忽略失败前缀的 ExecStartPre= 命令执行失败时,后续 ExecStart= 不会运行;ExecStartPost= 属于启动流程的一部分,它失败也会让启动作业失败,即使主进程曾短暂启动。此时只盯着 ExecMainStatus 可能看不到真正的失败命令,应把日志中记录的可执行路径、PID 和时间顺序,与 systemctl show 展开的各阶段命令逐项对齐。

还要注意,ExecStartPost= 的执行时点受 Type= 影响:例如 Type=simple 下,启动命令被拉起后就可能进入 post 阶段,并不表示应用已完成初始化。需要等待真实就绪条件时,应采用与程序启动模型匹配的 Type= 和就绪机制,而不是在 post 命令里用固定时长的 sleep 猜测。

修正时只改实际失败的阶段;若单元文件有变更再执行 daemon-reload。随后重新启动,并同时验证启动作业返回值、各阶段命令状态、主进程和原健康入口,避免把“主进程曾出现过”误当成启动成功。

再补一个启动命令本身没有报错、但启动作业因依赖关系失败的分支。适用于 systemd 245+;读取系统级单元与完整日志通常需要 root。

systemctl show app.service \
  -p Requires -p Requisite -p Wants -p BindsTo -p PartOf \
  -p After -p Before
systemctl list-dependencies app.service --all
systemctl status app.service --no-pager -l
journalctl -b --no-pager -o short-monotonic -u app.service

先区分“拉入事务”和“规定顺序”:Requires=、Wants= 等会把其他单元拉入启动事务,After=/Before= 只规定已经进入事务的作业顺序,单独写 After=network-online.target 并不会自动启动该 target。Requires= 与 After= 组合使用时,被依赖单元启动失败通常会使本单元的启动作业失败;Wants= 则是弱依赖,所需单元失败不必然阻止本单元继续启动。Requisite= 要求目标单元已经处于活动状态,语义也不同于启动它。

日志若出现 Dependency failed for、Job ... failed with result 'dependency',应先从同一启动事务和单调时间线定位最早失败的依赖单元,再读取该单元的状态与日志。例如确认实际失败单元为 db.service 后:

systemctl status db.service --no-pager -l
journalctl -b --no-pager -o short-monotonic \
  -u app.service -u db.service
systemctl cat app.service db.service

不要看到依赖错误就给应用增加固定 sleep,也不要把所有 Requires= 改成 Wants= 来掩盖失败;前者不能表达就绪条件,后者会改变故障传播语义。若问题只在开机时出现,还要核对依赖提供的是“已启动”还是应用真正需要的“已就绪”,并让提供方使用匹配的 Type= 或明确的健康门槛。修改关系后执行 systemctl daemon-reload,重新发起一次受控启动,并验证依赖单元、应用单元和真实服务入口都达到预期状态。