systemd 服务起不来与莫名消失排查实战:journalctl 读法、Type 与 WorkingDirectory 陷阱、OOM 判断与重启熔断

服务"起不来"和"起来了又没了",是两类完全不同的问题

个人站长在服务器上装服务,最常见的两个求助场景长这样:

场景一:systemctl start myapp 执行完,systemctl status myapp 显示 Active: failed,日志里只有一句模糊的 "Main process exited, code=exited, status=1/FAILURE"。你手动去敲那个启动命令,发现能跑起来。

场景二:服务启动后运行了几分钟,程序突然消失,systemctl status 显示 inactive (dead),而且没有明显的报错。你以为程序崩了,去看应用日志,发现应用日志最后一行是正常的运行记录——它根本没来得及报错就死了。

这两个场景经常被混在一起讨论,但它们的根因完全不同:场景一是"启动环境不对",场景二是"进程被外部杀掉了"。这篇文章把 systemd 的服务排查方法完整讲一遍,重点放在怎么快速判断自己遇到的是哪一类。

第一件事:学会读 systemctl status 的结构

大多数人对 systemctl status 的输出只是扫一眼颜色,其实它的每一段都有明确含义。完整输出:

● myapp.service - My App Service
     Loaded: loaded (/etc/systemd/system/myapp.service; enabled; preset: enabled)
     Active: active (running) since Fri 2026-09-25 21:30:11 CST; 2h 4min ago
   Main PID: 18234 (node)
      Tasks: 11 (limit: 4657)
     Memory: 84.2M
        CPU: 1min 22.431s
     CGroup: /system.slice/myapp.service
             ├─18234 /usr/bin/node /opt/myapp/server.js
             └─18251 /usr/bin/node /opt/myapp/worker.js

逐段解读:

  • Loaded:配置文件从哪加载的、是不是开机自启(enabled)。如果这里是 disabled,那么服务重启后不会自动起来——很多"重启服务器后网站挂了"就是这个原因。
  • Active:当前状态和持续时间。这里的 active (running) 和 inactive (dead)、failed 的区别很关键。
  • Main PID:主进程号,后面括号里是进程名。这个 PID 是排查的锚点,稍后所有深入分析都要用它。
  • CGroup:这是 systemd 最强大的特性之一。它列出了这个服务管辖下的所有进程,形成一个树形结构。上例中主进程拉起了一个 worker 子进程,两者都在同一个 cgroup 里。这意味着你能一眼看出"服务是不是真的只有一个进程"、"有没有意外拉起的孤儿进程"。

场景一排查:服务起不来,但是命令行能跑

这是最经典的问题,根因几乎总是同一个:systemd 的启动环境和你的 shell 环境不一样。

第一步:看完整日志,不要只看 status 的最后几行

status 只显示最后 10 行,往往不够。用 journalctl 拉出这个单元的全部近期日志:

journalctl -u myapp.service -n 100 --no-pager

# 只看本次启动之后的
journalctl -u myapp.service -b --no-pager

# 实时追踪
journalctl -u myapp.service -f

关键技巧:看输出里有没有 -- No entries --。如果日志是空的,说明你的应用根本没产生 stdout/stderr,而这通常意味着两点之一:要么应用把日志写到了自己的文件里(要去看那个文件),要么进程在输出任何东西之前就被杀了(属于场景二)。

第二步:环境差异的四个经典项

当 journal 里出现 "command not found"、"module not found"、或者"启动脚本报错但手动执行正常"时,逐项核对:

(1)PATH 不一样。systemd 服务的默认 PATH 极短,通常是 /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin。如果你用的是 nvm 装的 node、pyenv 装的 python、或者 /opt/ 下的自定义程序,就会找不到。两种修法:

# 修法 A:在 service 里显式设 PATH
[Service]
Environment=PATH=/root/.nvm/versions/node/v20.11.0/bin:/usr/local/bin:/usr/bin:/bin

# 修法 B(更推荐):ExecStart 写绝对路径
ExecStart=/root/.nvm/versions/node/v20.11.0/bin/node /opt/myapp/server.js

永远优先用绝对路径,这是最不容易出错的做法。

(2)工作目录不一样。systemd 的默认工作目录是 /,而你手动执行时是在项目目录下。如果你的代码里用了相对路径读配置(比如 ./config.json),必然失败。修法:

[Service]
WorkingDirectory=/opt/myapp

(3)环境变量没被加载。~/.bashrc、/etc/profile 这些文件对 systemd 服务完全无效。数据库密码、API key 这类变量要显式声明:

# 方式 A:逐条写
Environment=NODE_ENV=production
Environment=DATABASE_URL=postgres://localhost:5432/app

# 方式 B(推荐,适合变量多的场景):读环境文件
EnvironmentFile=/opt/myapp/.env

注意 EnvironmentFile 的格式要求:只能 KEY=VALUE 一行一个,不能有 export,值不要加引号。很多人的 .env 是从 shell 脚本抄来的,带 export 就会解析失败。

(4)用户和权限不一样。如果你在 [Service] 里写了 User=www-data,那这个进程就是以 www-data 身份运行的。手动执行时你是 root,能读写的东西它以 www-data 身份可能读不了。检查你设置的 User 是否有权限访问日志目录、socket 文件、密钥文件。

第三步:不要用 Type=simple 却写了后台启动

这是一个高频陷阱。看这段配置:

# ❌ 错误示例
[Service]
Type=simple
ExecStart=/opt/myapp/start.sh
# start.sh 里写的是: nohup ./server > /dev/null 2>&1 &

start.sh 自己 fork 到后台就退出了,systemd 看到主进程(脚本本身)退出,就认为服务结束了,直接进入 inactive 状态。表现就是"服务显示启动了,但马上又没了",和场景二很像,但根因不一样。

正确做法是要么让程序前台运行,要么用 Type=forking:

# ✅ 方案一:让程序前台跑(最推荐)
Type=simple
ExecStart=/opt/myapp/server --foreground

# ✅ 方案二:确实是守护进程模式
Type=forking
PIDFile=/run/myapp.pid
ExecStart=/opt/myapp/start.sh

判断标准:如果程序自己能 daemonize,就必须用 Type=forking 并且告诉 systemd PID 文件在哪。如果程序是前台阻塞的,就用 Type=simple 且不要在 ExecStart 里加 &。

场景二排查:服务跑了一会儿自己消失

这类问题的排查入口和场景一不同,因为答案往往不在应用日志里。进程是被"外部"杀掉的,所以要看的是 systemd 和内核的记录。

第一步:看退出原因和时间点

journalctl -u myapp.service --no-pager | grep -E "Stopped|Killed|exited|signal"

# 典型输出
# Sep 25 23:41:02 host systemd[1]: myapp.service: Main process exited, code=killed, status=9/KILL
# Sep 25 23:41:02 host systemd[1]: myapp.service: Failed with result 'signal'.
# Sep 25 23:41:02 host systemd[1]: myapp.service: Scheduled restart job, restart counter is at 3.

看 code=killed, status=9/KILL 这一行,这是决定性证据。status=9 表示进程收到了 SIGKILL(信号 9)。SIGKILL 是不能被程序捕获的,所以应用日志里不会有任何记录——这就解释了为什么"应用日志最后一行是正常的"。能发 SIGKILL 的只有两个来源:内核的 OOM Killer,或者有人(或 systemd 自己)手动 kill -9。

第二步:区分是 OOM 还是别的

如果是内核 OOM Killer 干的,内核日志里一定有记录:

dmesg -T | grep -i -E "killed process|out of memory"
# 或者
journalctl -k --no-pager | grep -i "killed process"

# 典型输出
# [Fri Sep 25 23:41:01 2026] Out of memory: Killed process 18234 (node) total-vm:2048576kB, anon-rss:1789234kB
# [Fri Sep 25 23:41:01 2026] oom_reaper: reaped process 18234 (node), now anon-rss:0kB

如果有这两行,问题就定了:内存不够,内核杀掉了最占内存的进程。注意 anon-rss 的值——它告诉你进程实际用了多少物理内存。如果这个值接近或超过了机器总内存,那就必须从应用层面做内存管理(限制缓存大小、修复泄漏),或者加 swap 降温(swap 不能解决问题,但能把"直接杀死"变成"变慢",给你争取排查时间)。

如果 dmesg 里没有 OOM 记录,但进程确实是 SIGKILL 死的,那就要怀疑:

  • 有人手动 kill -9(检查是否有运维脚本、定时任务在清理进程)
  • systemd 的 RuntimeMaxSec 到时间了——这是 Restart=always 的阶段性限制,超过运行时间会主动杀掉重启
  • 容器的内存 limit 触发,由容器的 cgroup OOM 处理(日志在容器层面,不在宿主机 dmesg)

第三步:看重启策略是不是在掩盖问题

很多人配了 Restart=always,结果服务一直在"崩溃-重启-崩溃"的循环里,表面上看起来服务"偶尔不可用",实际上是持续在崩。看这个计数器:

systemctl show myapp -p NRestarts
# NRestarts=427

journalctl -u myapp.service --no-pager | grep "restart counter"
# myapp.service: Scheduled restart job, restart counter is at 42.

如果 NRestarts 是个大数字,这就是在崩溃循环。继续往下看时间戳,如果每次重启间隔非常短(比如 100 毫秒),说明启动就崩——那是场景一的问题,不是场景二。

把重启策略配对:别让 systemd 帮倒忙

理解了重启机制之后,配置就清楚了。常用的组合:

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

逐项解释:

  • Restart=on-failure:只在异常退出(非 0 退出码、被信号杀死)时重启。比 always 好,因为 always 会在你用 systemctl stop 正常停止后也试图拉起来,造成困扰。always 只适合那种"必须永远活着"的守护进程。
  • RestartSec=5:重启前等待 5 秒。这个值非常重要——不要设成 0 或极小的值,否则一旦服务启动就崩,systemd 会在极短时间内疯狂重启(每秒几十次),把 CPU 打满,还可能把日志文件塞爆。
  • StartLimitIntervalSec + StartLimitBurst:60 秒内最多重启 5 次,超过就停止尝试并进入 failed 状态。

这两个参数的真正价值是"熔断":它把"无限崩溃循环"变成"明确失败并停下",让你能清楚地看到服务挂了,而不是被隐藏在一遍遍重启里。

注意一个容易踩的坑:在较新的 systemd(v230+)里,StartLimitIntervalSec 和 StartLimitBurst 应该放在 [Unit] 段,而不是 [Service] 段。放错位置会报 "Unknown key" 警告并被忽略:

[Unit]
Description=My App
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Restart=on-failure
RestartSec=5

验证清单:改完必须跑一遍

每次改 service 文件后,按这个顺序验证,不要跳步:

# 1. 语法检查(会明确报出哪个 key 不认识)
systemd-analyze verify /etc/systemd/system/myapp.service

# 2. 重载配置(改文件后必须做,否则 systemd 用的是旧配置)
systemctl daemon-reload

# 3. 检查是否开机自启
systemctl is-enabled myapp
# enabled / disabled

# 4. 重启并立即看状态
systemctl restart myapp && sleep 2 && systemctl status myapp --no-pager

# 5. 确认进程真的在(有时 status 是 running 但进程已经僵死)
systemctl show myapp -p MainPID -p ActiveState -p SubState
# MainPID=18234
# ActiveState=active
# SubState=running

第 5 步的 SubState 是判断服务健康的可靠字段:running 才是正常运行;exited 表示一次性任务已结束;auto-restart 表示正在等待重启(说明刚崩过);failed 就是彻底失败了。

把这几条写成一行脚本,做成 crontab 每五分钟巡检一次,就能在用户投诉之前发现问题:

#!/bin/bash
for svc in nginx php-fpm mysql redis myapp; do
    st=$(systemctl is-active $svc)
    [ "$st" != "active" ] && echo "[$(date)] ALERT: $svc is $st" 
done

小结

systemd 服务的排查,先分清楚是"起不来"还是"起来又没了"。前者去查 PATH、WorkingDirectory、EnvironmentFile、User 和 Type 五项环境差异,日志看 journalctl;后者去找 code=killed, status=9 这个决定性证据,然后分 OOM 还是外部 kill,内核日志看 dmesg。最后把 Restart、RestartSec 和 StartLimit* 配好,让崩溃循环变成明确失败。

最容易踩的坑是"改了 service 文件忘了 daemon-reload",以及"RestartSec 设得太小导致 CPU 被打满"。把 systemd-analyze verify 加进你的操作习惯里,可以省掉大量这种低级的自伤。

Last modification:September 25th, 2026 at 07:24 pm

Leave a Comment