跳转到内容

为什么你的服务重启就掉?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 掩盖问题:

bash
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.service

systemctl 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 目录下创建符号链接。查看实际生效的单元与链接:

bash
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:

ini
[Install]
WantedBy=multi-user.target
bash
sudo systemctl daemon-reload
sudo systemctl reenable myapp.service
systemctl is-enabled myapp.service
ls -l /etc/systemd/system/multi-user.target.wants/myapp.service

reenable 会先移除旧链接再创建新链接,比单独 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 会立即就绪,等待无意义:

bash
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:

bash
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 与 ~ 展开。常见遗漏可归为四类:

  1. 工作目录缺失:未设置 WorkingDirectory,相对路径的配置文件、静态资源、模板文件读取失败。程序在 shell 中因 cd /opt/myapp 后启动而正常,systemd 默认工作目录为 /。
  2. 环境变量缺失:未设置 Environment 或 EnvironmentFile,DATABASE_URL、NODE_ENV、PORT 等变量为空,导致连接失败或监听错误端口。
  3. 用户与权限不一致:服务以 root 调试通过,切换到 app 用户后无文件读取、端口绑定或目录写入权限。低于 1024 的端口需要额外能力或反向代理。
  4. 路径与 shell 展开:ExecStart 写成相对路径或依赖 ~、$VAR、管道、重定向,systemd 不做 shell 展开,直接按字面解析。

修复示例 ​

一个可直接套用的最小可用单元:

ini
[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,避免敏感变量被全局可读。

验证:

bash
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+):

ini
[Unit]
StartLimitIntervalSec=60s
StartLimitBurst=5

[Service]
Restart=on-failure
RestartSec=5s
TimeoutStartSec=30s

诊断流程:

bash
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 依赖或服务启动顺序,避免多个服务抢占同一端口。

验证保活是否生效:

bash
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 再验证:

  1. systemctl cat 确认 WantedBy、After、Type;daemon-reload + reenable 后重启验证。
  2. systemctl show 与 journalctl -u 对比手工 env/pwd/id,补齐 WorkingDirectory、Environment、User,修正 ExecStart 绝对路径。
  3. 配置 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 手册页

Termark · SSH 客户端与终端工作台