systemd 服务起不来?按退出码和信号反查比翻日志快

举报
逍遥渝枫 发表于 2026/11/01 08:33:12 2026/11/01
【摘要】 服务起不来时直接翻 journalctl 第一行,常常被应用自己的报错带偏,而 systemd 早已在 Result 和 ExecMainStatus 里给出了分支判定。本文基于 Ubuntu 22.04(systemd 249)的真实排障过程,先教你把这两个字段捞出来,再用一张对照表按退出码反查 12 类常见原因,最后给出单元文件的正确写法与三个高频坑的处理步骤。

别从日志第一行看起

服务起不来,第一反应是 journalctl -u xxx 往上翻。多数时候有用,但在 systemd 托管的场景里,日志第一行常常是应用自己的抱怨,而 systemd 在这之前就已经把失败原因判定完了。比如 ExitCode=203/EXEC,应用根本没被拉起来,日志里那句 “config file not found” 是上一次运行残留的噪声,照着它去改配置文件只会越改越远。

真正决定分支的是四个字段:ActiveState、SubState、Result、ExecMainStatus。一条命令全捞出来,比翻两百行日志快:

svc=fail2ban

systemctl show -p ActiveState,SubState,Result,ExecMainCode,ExecMainStatus,NRestarts "$svc"
journalctl -u "$svc" -b --no-pager -o short-precise -n 60
systemctl cat "$svc"                     # 含 drop-in,比直接读 unit 文件靠谱
systemd-analyze verify "/etc/systemd/system/$svc.service"

Result 比退出码更有信息量,常见取值就七种:exit-code、signal、timeout、start-limit-hit、resources、core-dump、protocol。看到 start-limit-hit 就不用查应用了——那是 systemd 的重启风暴保护已经触发,应用自己没错,是它在反复秒退;看到 protocol 基本可以锁定 Type=notify 用错了。

一张按退出码反查的对照表

ExecMainStatus 后面跟的数字,绝大多数情况已经指明方向。下表是我在自己这台机器上遇到并记录过的分支,按命中概率排序:

现象(Result / 退出码) 实际含义 高概率原因 一步验证
exit-code 203 / EXEC 可执行文件无法被执行 路径写错、文件无 x 位、shebang 里的解释器不存在、ExecStart 写了 shell 语法 ls -l 看权限;file 看架构;head -1 看 shebang
exit-code 200 / CHDIR 切换工作目录失败 WorkingDirectory= 指向不存在的路径(常是部署脚本删了旧目录) systemctl show -p WorkingDirectory 后手动 cd
exit-code 217 / USER 用户或组不存在 User= 写了未创建的用户;DynamicUser= 与 StateDirectory= 混用的副作用 id <用户名>
exit-code 226 / NAMESPACE 沙箱内路径不可用 开了 ProtectSystem=strict 却没给 ReadWritePaths=;PrivateTmp= 后应用仍写 /tmp 的绝对路径 临时在 ReadWritePaths 里加上报错路径再试
exit-code 1 / FAILURE 应用自己退出 1 配置错误、端口被占、依赖服务未就绪,答案只在日志里 journalctl -u <svc> -b -n 100
signal TERM / 15 被 SIGTERM 结束 TimeoutStartSec 超时被 systemd 杀掉,启动太慢 看启动耗时是否逼近 TimeoutStartSec 默认 90s
signal KILL / 9 被 SIGKILL 或 OOM 内存打满被内核 OOM killer 干掉 dmesg -T | grep -i "killed process"
timeout 启动等待超时 Type=forking 但进程不 fork;Type=notify 未调 sd_notify;ExecStartPre 卡死 先单独手工跑 ExecStartPre 的命令
start-limit-hit 重启次数超限被拦 Restart=always 配了秒退的应用 systemctl reset-failed <svc> 后单次启动看真实退出码
Unit xxx not found 单元没被加载 放在了 /lib/systemd/system 而想用本地覆盖;改完没 daemon-reload systemctl list-unit-files | grep <名>
exit-code 209 / STDOUT 标准输出目标不可写 StandardOutput=append:/var/log/x.log 的目录不存在或无权限 手工 touch 该文件验证

203/EXEC 是最容易自己挖的坑

ExecStart 不经过 shell,这是设计而不是缺陷。所以下面这行必挂:

# 错误写法:&&、管道、变量展开都不存在,systemd 会把它当成一个带奇怪参数的可执行文件
ExecStart=/usr/bin/node /opt/app/index.js >> /var/log/app.log 2>&1

正确做法要么让 systemd 自己管输出,要么显式调 shell:

# 方案 A:输出交给 journald,需要落文件时用 append: 前缀
ExecStart=/usr/bin/node /opt/app/index.js
StandardOutput=append:/var/log/app/app.log
StandardError=append:/var/log/app/app.err.log

# 方案 B:确实需要 shell 语义(管道、环境变量拼接)
ExecStart=/bin/bash -lc 'exec /usr/bin/node /opt/app/index.js 2>&1 | tee -a /var/log/app/app.log'

方案 A 更可控,但要求 /var/log/app/ 事先存在——用 LogsDirectory=app 让 systemd 建目录并自动授权,比手写 mkdir 稳:

[Service]
LogsDirectory=app
LogsDirectoryMode=0750

另一个隐蔽的 203:脚本第一行写了 #!/usr/bin/env python3,而 python3 在 systemd 的精简 PATH 里找不到。env 会去查 PATH,systemd 的默认 PATH 不含 /usr/local/bin。要么把 shebang 改成绝对路径,要么在单元里补 Environment=PATH=/usr/local/bin:/usr/bin:/bin。

沙箱开了之后的 226

安全加固时给服务加上 ProtectSystem=strict 是常见做法,它把整个文件系统挂成只读,只留 ReadWritePaths= 里列的目录可写。加完之后报 226/NAMESPACE,八成是漏了某个写入点——不只是业务数据目录,还包括 pid 文件、socket 文件、缓存目录:

[Service]
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
NoNewPrivileges=yes
ReadWritePaths=/var/lib/myapp /var/log/myapp /run/myapp
StateDirectory=myapp
RuntimeDirectory=myapp
RuntimeDirectoryMode=0750

RuntimeDirectory= 会自动创建 /run/myapp 并在服务停止后清理,比自己在 ExecStartPre 里 mkdir -p 干净得多,也躲得开 PrivateTmp 带来的时序问题。判断是不是沙箱问题有个很土但有效的办法:临时把 ProtectSystem 注释掉、daemon-reload 再起一次,能起来就说明是权限路径没给全,然后再逐条加回去定位。

依赖没就绪,报错看起来像应用 bug

还有一类失败,退出码是普普通通的 1 或 resources,但根源在启动顺序。典型表现是:手工 systemctl start 能起,开机自启就失败;或者重启后前几十秒失败、之后手动再起就好。原因通常是 After=network.target。这个依赖只保证网络栈被拉起,不保证网卡拿到地址——服务在 bind() 时因为本机 IP 还没配上而失败。

要等地址就绪得用 network-online.target,而且它本身是个"靶子",必须有人去等:装了 NetworkManager 的系统要 systemctl enable NetworkManager-wait-online,用 netplan/systemd-networkd 的要 systemd-networkd-wait-online。没启用等待服务时,network-online.target 秒过,等于白写。

[Unit]
Description=myapp
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=60
StartLimitBurst=5

这里 Restart=on-failure 配 StartLimitBurst=5 是个折中:应用偶发失败会自己回来,真挂了也不会无限重启把 journal 刷爆。注意 StartLimitIntervalSec 和 StartLimitBurst 在 systemd 229 之后属于 [Unit] 段,写在 [Service] 里会被忽略并给一条警告——这条警告就在 systemd-analyze verify 的输出里,值得每次改完都跑一遍。

另外 Requires= 和 Wants= 的区别要分清:Requires= 的依赖挂了,本服务会被连带停掉;只是想"先起它"的话用 Wants= + After=,别用 Requires=,否则备份任务失败会把业务服务一起带走。

两个真实案例

案例一:fail2ban 起不来,报 “Failed to initialize any backend”。 在 Ubuntu 22.04 上,jail.local 里写 backend = systemd 是官方推荐做法,能准确按日志级别取到 sshd 的失败记录。但 22.04 的最小安装镜像不带 python3-systemd,fail2ban 找不到 systemd 的 Python 绑定,直接以 255 退出。Result=exit-code、ExecMainStatus=255,日志里那行报错出现在启动的第三行,很容易被前面的 “Jail ‘sshd’ uses systemd” 盖过去。补一个包即可:

apt-get install -y python3-systemd
systemctl restart fail2ban
fail2ban-client status sshd    # 能看到 Currently failed / Total failed 才算真的在拦

案例二:改完 unit 死活不生效。 改了一个 drop-in 文件后 systemctl restart,行为跟改之前一模一样。原因是只 restart 没 daemon-reload,systemd 仍在用内存里的旧单元定义。顺带一个容易踩的:同名文件同时存在于 /lib/systemd/system/ 和 /etc/systemd/system/ 时,后者优先;但如果放在 /etc/systemd/system/<svc>.service.d/*.conf,那是 drop-in,只能覆盖同名配置项,不能删掉上游的配置项——想删某个 ExecStartPre,得先 ExecStartPre= 清空再重写。

排查顺序固定成三步,能省掉大部分来回:

systemctl daemon-reload
systemctl reset-failed myapp && systemctl start myapp
journalctl -u myapp -b --since "2 min ago" --no-pager

边界说明

以上内容在 Ubuntu 22.04(systemd 249)上验证。不同发行版的 systemd 版本差异会影响两点:Result 的取值集合在新版本里有扩充(比如 exec-condition 相关),226/NAMESPACE 的具体触发条件也随沙箱选项增减而变;ProtectSystem=strict 对 /etc 的处理在更老的版本上略有不同。换发行版时,systemd-analyze verify 和 systemctl show 这两个命令依然通用,可以先用它们把事实拿到手,再对照本文的分支去判断,不要直接套结论。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。