为什么你的服务重启就掉?Systemd 开机自启与保活的 3 个坑
systemctl start myapp 跑得好好的,一 reboot 就起不来;systemctl enable 也执行过了,开机还是报依赖失败或者端口没就绪。更隐蔽的一类是:进程被 OOM 杀掉或者异常退出后,就再也没被拉起来,你甚至不知道它是哪一刻挂的,直到有同事来问「你们服务是不是挂了」。这几种情况碰多了你会发现,治标的重启解决不了任何一次——病根几乎总是同一个:systemd 单元的自启和保活配置,并没有你以为的那么完整。
systemd 是声明式的,对细节挑剔得很:enable 只决定单元会不会被拉起,After/Requires/Wants 管的是启动顺序和依赖强度,Type 决定它怎么判断「已经就绪」,Restart 和 StartLimit 决定挂了之后要不要重试。少写一行或者用错一个字段,服务就会在重启、依赖延迟或者崩溃的节骨眼上静默失败;而 systemctl status 往往只甩给你一个干巴巴的 failed,真正的病根得靠 journalctl 一层层挖出来。
这篇文章按 3 个坑逐一拆解,每个坑都给出典型现象、根本原因、可复制的修复配置和验证命令。示例基于 Ubuntu 22.04 / Debian 12 的 systemd 249+,路径与命令在其他发行版上基本通用;若用 CentOS/RHEL、Arch 或 openSUSE,只需注意单元文件路径优先级和 systemctl --version 的版本差异,诊断思路完全一致。
通用诊断起手式:先看日志,再改配置
在动手修改任何单元文件前,先用三条命令建立基线,避免盲目 restart 掩盖问题:
systemctl status myapp.service -l --no-pager
journalctl -u myapp.service --since "1 hour ago" --no-pager | tail -n 100
systemd-analyze verify /etc/systemd/system/myapp.servicesystemctl status 给出当前状态与最近几行日志,journalctl -u 给出完整时间线,systemd-analyze verify 在不启动服务的情况下检查单元语法与依赖引用是否有效。若 verify 报错如 Service has no ExecStart= 或 Unknown key name,说明单元文件本身未通过校验,daemon-reload 后也不会生效。另一个常被忽略的命令是 systemd-analyze critical-chain myapp.service,可直观看到该服务的启动链路与耗时,判断是否被前置依赖拖慢。养成“改前必 verify、改后必 daemon-reload、重启后必 status + journalctl”的习惯,能避免大量“改了但没生效”的错觉。
坑一:enable 了,不代表 ready——WantedBy、依赖与 Type
现象
systemctl enable myapp 执行成功,systemctl is-enabled myapp 显示 enabled,但重启后 systemctl status myapp 显示 inactive (dead) 或 failed,日志中可能出现 dependency failed 或启动超时。
原因 1:WantedBy 与 enable 目标不一致
enable 的本质是在 WantedBy 指定的 target 目录下创建符号链接。查看实际生效的单元与链接:
systemctl cat myapp.service
systemctl is-enabled myapp.service
systemctl status myapp.service
ls -l /etc/systemd/system/multi-user.target.wants/myapp.service
systemctl get-default若 Install 段写成 WantedBy=graphical.target,而服务器默认启动到 multi-user.target(可通过 systemctl get-default 确认),该服务就不会被拉起。容器、云主机最小化镜像、以及从桌面发行版拷贝配置的场景常见此问题。另一个变种是自定义 target,服务被 enable 到了一个从未被 isolate 或 wants 的 target。
修复:服务端常驻服务应使用 WantedBy=multi-user.target。修改后必须执行 daemon-reload 并重新 enable,否则旧链接仍指向错误 target:
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl reenable myapp.service
systemctl is-enabled myapp.service
ls -l /etc/systemd/system/multi-user.target.wants/myapp.servicereenable 会先移除旧链接再创建新链接,比单独 enable 更可靠。验证时建议直接重启机器,而非仅 restart,因为 restart 不经过 target 拉起路径。
原因 2:After / Wants / Requires 语义混淆
这是最容易踩的语义坑,三者职责不同:
After=只定义启动顺序,不定义依赖。只写After=network.target并不能保证网络已可用,network.target仅表示网络管理服务已启动,不代表地址已分配。Wants=是弱依赖,依赖单元失败不会导致本单元失败,适合网络、日志等非强依赖。Requires=是强依赖,依赖单元失败会连带本单元失败,适合数据库、消息队列等强依赖。
需要等待网络可用的服务应使用 After=network-online.target 并配合 Wants=network-online.target,并确保 systemd-networkd-wait-online.service 或 NetworkManager-wait-online.service 已启用,否则 network-online.target 会立即就绪,等待无意义:
systemctl is-enabled systemd-networkd-wait-online.service
systemctl status systemd-networkd-wait-online.service需要数据库的服务则应对数据库单元使用 Requires= + After=,例如 Requires=postgresql.service + After=postgresql.service,或 Requires=docker.service + After=docker.service。仅写 After 而不写 Requires/Wants,当数据库未安装或启动失败时,本服务仍会尝试启动,随后因连接失败而退出,日志中只剩下应用层的 connection refused,容易被误判为自身 bug 而忽略了 systemd 层的依赖缺失。
原因 3:Type 与进程模型不匹配
Type 决定 systemd 如何判定服务已启动完成:
Type=simple:ExecStart的主进程即服务进程,启动即视为就绪。适用于大多数 Go、Node、Python 服务。Type=forking:预期ExecStart会 fork 并让父进程退出,systemd 跟踪子进程,需配合PIDFile=。仅适用于会自行 daemonize 的传统服务。Type=notify:需进程主动调用sd_notify(READY=1),适用于支持该协议的服务。Type=oneshot:一次性任务,配合RemainAfterExit=yes使用。
将一个不 fork 的程序配成 forking,systemd 会因收不到父进程退出信号而判定启动超时,最终 failed;将需要 notify 的服务配成 simple,则会错过就绪判定,健康检查可能过早执行。优先使用 Type=simple,除非明确知道程序的 fork 行为。修改 Type 后务必 daemon-reload:
sudo systemd-analyze verify /etc/systemd/system/myapp.service
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl show myapp.service -p Type -p ActiveState -p SubState -p Result若 verify 无输出且 ActiveState=active、SubState=running,说明类型与依赖基本正确。重启验证需用 sudo reboot 后检查 systemctl status,而非仅 restart,因为后者不经过完整的依赖排序。
坑二:环境与权限——WorkingDirectory、Environment 与 User
现象
手动在 shell 中执行 node app.js 或 ./myapp --config ./config.yaml 能正常运行,用 systemd 启动却报 No such file or directory、Permission denied、找不到配置文件或环境变量为空。
原因
systemd 的执行环境是干净的,不继承登录 shell 的 PATH、PWD、env、ulimit 与 ~ 展开。常见遗漏可归为四类:
- 工作目录缺失:未设置
WorkingDirectory,相对路径的配置文件、静态资源、模板文件读取失败。程序在 shell 中因cd /opt/myapp后启动而正常,systemd 默认工作目录为/。 - 环境变量缺失:未设置
Environment或EnvironmentFile,DATABASE_URL、NODE_ENV、PORT等变量为空,导致连接失败或监听错误端口。 - 用户与权限不一致:服务以
root调试通过,切换到app用户后无文件读取、端口绑定或目录写入权限。低于 1024 的端口需要额外能力或反向代理。 - 路径与 shell 展开:
ExecStart写成相对路径或依赖~、$VAR、管道、重定向,systemd 不做 shell 展开,直接按字面解析。
修复示例
一个可直接套用的最小可用单元:
[Unit]
Description=MyApp Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=app
Group=app
WorkingDirectory=/opt/myapp
Environment=NODE_ENV=production
EnvironmentFile=-/etc/myapp/env
ExecStart=/usr/bin/node /opt/myapp/app.js
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target要点:
ExecStart必须用绝对路径;需要 shell 特性(管道、重定向、变量展开)时显式写/bin/bash -c '...',并注意转义。EnvironmentFile前的-表示文件不存在时不报错,适合可选配置;不带-时文件缺失会导致单元加载失败。WorkingDirectory不存在会导致启动直接失败,需预先mkdir -p /opt/myapp并chown app:app。- 私有文件如
/etc/myapp/env应设为640并属主root:app,避免敏感变量被全局可读。
验证:
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
journalctl -u myapp.service --since "5 min ago" --no-pager
systemctl show myapp.service -p WorkingDirectory -p User -p Group -p Environment -p ExecStart
# 对比手工环境
env | sort
pwd; id
ls -ld /opt/myapp /etc/myapp若 journalctl 中仍出现路径或权限错误,逐项对比 systemctl cat 与手工执行的 env、pwd、id、ls -l 输出,补齐缺失变量与目录权限。对于 Node/Python 服务,建议在代码中启动时打印 process.cwd() / os.getcwd() 与关键环境变量,便于在 journalctl 中快速核对。
坑三:保活与重启策略——Restart、RestartSec 与 StartLimit
现象
服务因 OOM 被 kill、端口冲突退出或异常崩溃后,systemd 没有自动拉起;或频繁崩溃导致进入 failed 状态后不再尝试,systemctl status 显示 Start request repeated too quickly。
原因
systemd 默认 Restart=no,进程退出后不会重启。即使配置了 Restart=on-failure,还受 StartLimit* 节流限制:短时间内失败次数超过阈值,systemd 会放弃重试,进入 failed。此外,TimeoutStartSec 过短会在慢启动服务上误判超时。
相关字段对照:
| 字段 | 作用 | 常见取值 | 说明 |
|---|---|---|---|
Restart | 何种退出触发重启 | no / on-failure / always / on-abnormal | 默认 no,常驻服务建议 on-failure |
RestartSec | 重启前等待时间 | 5s / 10s | 避免紧循环打爆日志与 CPU |
StartLimitIntervalSec | 统计窗口 | 60s / 120s | 窗口内计数 |
StartLimitBurst | 窗口内最大尝试次数 | 5 / 10 | 超限后不再重试 |
TimeoutStartSec | 启动超时 | 30s / 60s | 慢启动服务需调大 |
在较新的 systemd 版本中,StartLimitIntervalSec 与 StartLimitBurst 位于 [Unit] 段;旧版位于 [Service] 段且字段名为 StartLimitInterval / StartLimitBurst。若按新版写法在旧系统上配置,节流不会生效,频繁崩溃时仍会紧循环。可用 systemctl --version 确认版本,必要时同时兼容两种写法或统一升级。
Restart=always 会连正常 systemctl stop 后的退出也尝试重启,适合常驻服务但需配合 ExecStop 使用;on-failure 仅在非零退出、异常信号、超时时重启,更符合多数业务预期。RestartSec 过小会导致日志刷屏与资源争抢,建议不低于 5s。StartLimit 在 systemd 230+ 后 moved 到 [Unit] 段,旧版在 [Service] 段,两者需注意版本差异。
修复与诊断
推荐的保活组合(systemd 240+):
[Unit]
StartLimitIntervalSec=60s
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=5s
TimeoutStartSec=30s诊断流程:
journalctl -u myapp.service -n 100 --no-pager
journalctl -u myapp.service -o cat --since "10 min ago" | tail -n 50
systemctl status myapp.service -l --no-pager
systemctl show myapp.service -p Restart -p RestartUSec -p StartLimitIntervalSec -p StartLimitBurst -p NRestarts -p Result关注三类日志:Main process exited, code=exited, status=1/FAILURE 表示程序异常退出;Killed 或 Out of memory / Memory cgroup out of memory 表示 OOM;Start request repeated too quickly 表示触发了 StartLimit 节流。OOM 场景除调整 Restart 外,还需检查 MemoryMax/MemoryHigh 与宿主机内存水位,必要时增加 OOMScoreAdjust 或调大实例规格;端口冲突则需修正 After 依赖或服务启动顺序,避免多个服务抢占同一端口。
验证保活是否生效:
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
# 模拟崩溃
sudo kill -9 $(systemctl show myapp.service -p MainPID --value)
sleep 6
systemctl status myapp.service
journalctl -u myapp.service --since "1 min ago" --no-pager若 ActiveState 仍为 active 且 NRestarts 递增,说明重启策略已生效。频繁崩溃被节流时,应先修复崩溃根因,再用 systemctl reset-failed myapp.service 清除失败计数,否则即使修复后也不会自动重试。
延伸:定时任务与一次性任务是否也用 systemd
除常驻服务外,systemd.timer 同样适合替代 cron 管理定时任务,优势在于可声明依赖、日历表达式更可读、失败可重试且日志统一进 journalctl。对于一次性初始化任务(如首次部署的数据库迁移),使用 Type=oneshot + RemainAfterExit=yes 的单元,配合 WantedBy=multi-user.target,可确保只执行一次且可通过 systemctl status 查看结果。若你的部署流程中既有常驻服务又有初始化脚本,建议将后者拆为独立单元并让主服务 After= 它,避免在 ExecStartPre 中堆砌复杂逻辑。
把这套检查固化进日常
三个坑对应三次验证:重启后是否自启、环境是否与手工一致、崩溃后是否自愈。建议按顺序检查,每步修复后都执行 daemon-reload 再验证:
systemctl cat确认WantedBy、After、Type;daemon-reload+reenable后重启验证。systemctl show与journalctl -u对比手工env/pwd/id,补齐WorkingDirectory、Environment、User,修正ExecStart绝对路径。- 配置
Restart/RestartSec/StartLimit,用kill -9 $MainPID模拟故障,观察systemctl status与journalctl是否自动恢复。
systemctl enable --now 会在创建自启链接的同时立即启动服务,适合首次部署时一次性完成——但修改单元文件后仍要先 daemon-reload 再执行。需要按顺序联动重启多个服务时用 PartOf=,数据库迁移这类一次性任务则拆成 Type=oneshot 的独立单元并让主服务 After= 它,避免在 ExecStartPre 里堆复杂逻辑。
还要留意日志存储。systemd 默认只把日志写进内存:若 journald.conf 里 Storage 为 auto 且 /var/log/journal 不存在,重启后这次故障的现场就丢了。为关键服务开启 Storage=persistent,再结合 journalctl --list-boots 与 journalctl -u myapp.service -b -1,就能复盘上一次启动的完整日志。养成查看 journalctl -u <service> --since today 的习惯,比反复 restart 更能定位根因。
日常管理结合 Termark 终端关键词高亮 与 本地加密与数据恢复说明,可以更高效地在远程会话中扫日志;若服务仍需在 SSH 断开后持续运行,参考 SSH 断开后程序还在跑吗 对 systemd 与 tmux 的选型讨论。但在重启自启与保活这类系统层问题上,可靠性最终取决于 systemd 单元本身的正确性,而非终端工具——把单元文件写对、依赖和重启策略配全,比任何外部保活脚本都更可靠。
参考
- systemd.service(5) —
man systemd.service - systemd.unit(5) —
man systemd.unit - systemd.exec(5) —
WorkingDirectory/Environment/User - systemd.resource-control(5) —
MemoryMax/OOMScoreAdjust journalctl/systemctl手册页