服务"起不来"和"起来了又没了",是两类完全不同的问题
个人站长在服务器上装服务,最常见的两个求助场景长这样:
场景一: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 加进你的操作习惯里,可以省掉大量这种低级的自伤。