systemd 服务起不来?按退出码和信号反查比翻日志快
别从日志第一行看起
服务起不来,第一反应是 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 这两个命令依然通用,可以先用它们把事实拿到手,再对照本文的分支去判断,不要直接套结论。
- 点赞
- 收藏
- 关注作者
评论(0)