Linux systemd 服务管理:从编写 unit 到故障排查

使用 systemd 管理自定义服务,覆盖 unit 文件、启动策略、日志查看和失败恢复。

技术文章约 26 分钟224 次阅读

#开篇:这个问题背后最基础的机制

这篇文章解决一个具体问题:把一个长驻服务(以 Node 服务为例)交给 systemd 管理,做到开机自启、崩溃自愈、日志可查、故障可排。而这一切之所以可能,根源在于一个比 systemctl 命令更底层的事实:systemd 是 PID 1,内核在它接管控制权后把整个系统的进程生命周期都交给它托管,每个 service unit 又对应一个独立的 cgroup(控制组)——于是"管理一个服务"不再是"启动一个进程",而是"管理一个可枚举、可整体回收的进程集合,并让它的启动、运行、退出、重启都沿着同一套状态机进行"。从这条事实出发,unit 文件里的每条指令、排障时的每条命令都可以推导出来,而不是死记硬背。

#原理层:systemd 凭什么接管进程的一生

下文先从 PID 1 与 cgroup 推导出"进程生命周期怎么被接管",再从 unit 依赖图推导出"启动顺序怎么决定",然后解释 Type/ExecStart/Environment 如何映射到 fork/exec,最后说明 journald 为什么能统一日志。这四层是后续所有操作的底层依据。

#PID 1:进程树的根与孤儿收养

Linux 上每个进程都有父进程,整个进程树以 PID 1 为根。内核完成自身初始化后把控制权交给 PID 1(传统上是 init 脚本体系,绝大多数现代发行版是 systemd),并且对 PID 1 有特殊保护:其他进程可以给 PID 1 发信号,但除非它自己安装了对应处理器,普通信号默认不会把它杀死——它一旦启动就注定是整个系统的"常驻根"。更关键的是另一条内核规则:任何进程的父进程先退出后,这个孤儿进程会被自动过继给 PID 1,也就是 systemd。

这一条规则直接决定了 systemd 的管理范围:只要一个进程由 systemd 启动,即使它后来 fork 出子进程、孙子进程,甚至父进程自己退出了,这些后代也永远挂在 systemd 名下。systemd 因此有能力回答"这个服务现在到底有哪些进程在跑"。传统 SysV init 做不到这一点:init 脚本通常只记得自己启动的那个进程号,子进程一旦独立就脱离了脚本的视野,/etc/init.d/xxx stop 杀不干净、残留进程占端口是家常便饭。systemd 换了个思路:不靠记忆进程号,而是靠 cgroup 这个内核机制把整个进程集合"圈"起来。

#cgroup:一张可枚举、可整体处置的进程清单

cgroup(控制组,Control Group)是内核提供的进程分组机制:把一组进程放进一个目录(cgroup),内核就会按这个组统一统计资源、施加限制。systemd 在启动每个 service unit 时会创建对应的 cgroup 目录,例如在 cgroup v2 统一层级(RHEL 8、Ubuntu 20.04 及之后的默认形态)下是:

/sys/fs/cgroup/system.slice/myapp.service/

所有由该服务启动的进程,包括它们 fork 出来的全部后代,都会被放进这个 cgroup。于是 systemd 不再需要追踪单个 PID 的父子关系,它只需要问内核:"这个 cgroup 里有哪些进程?"——答案就是服务的全部进程清单:

cat /sys/fs/cgroup/system.slice/myapp.service/cgroup.procs
systemd-cgls    # 以树状图展示整张 cgroup 进程清单

这个机制解释了 unit 文件里很多行为的底层原因:

  • 为什么 systemctl stop 能杀掉服务的所有子进程?因为默认 KillMode=control-group 的语义就是"把整个 cgroup 里的进程全部处理掉",而不是只杀主进程。
  • 为什么 systemctl status 能直接显示服务的资源占用?因为用量来自内核在 cgroup 页里的记账(v2 下对应 cpu.stat、memory.current 等文件)。
  • 为什么 MemoryMax=、CPUQuota= 这类资源限制指令能作用于进程组而非单个进程?因为它们本质是写进 cgroup 的配置。

一句话概括:进程生命周期 = cgroup 生命周期。服务"启动"是 cgroup 里出现了第一批进程;服务"停止"是 cgroup 被清空销毁;服务"崩溃重启"是清空旧 cgroup 后在新 cgroup 里重新拉起。这是理解后面一切操作的骨架。

#unit 依赖:从零散进程到启动顺序图

单个服务好办,但一个现代系统有成百上千个服务,谁先谁后、谁必须带谁,靠脚本手工排顺序无法维护。systemd 的解法是把每个可管理对象——服务、挂载点、设备、socket、target(目标)——统一抽象成 unit,再用两类边把 unit 连成一张有向图:

  • 需求边:Requires=(强依赖,被依赖的 unit 启动失败或停止会导致本 unit 失败/停止)、Wants=(弱依赖,被依赖的 unit 尽量启动,失败不牵连本 unit)。
  • 顺序边:After=(本 unit 在被依赖的 unit 之后启动)、Before=(在它之前启动)。

需求边决定"要不要启动",顺序边决定"什么时候启动",两者独立。所以 After= 里写的目标即使没有被 Requires=/Wants= 引用,也只会影响顺序,不会把目标拉进本次启动;反过来,Wants=redis.service 会把 redis 拉进来,但不保证 redis 先跑起来——需要同时写 After=redis.service 才能既拉取又排序。这是新手必踩的点,后面还会展开。

启动一个 unit 时,systemd 会沿依赖图解析出一笔事务(transaction):把需要启动的 unit 全部列出,按顺序边排序、去掉环,然后尽可能并行启动(并行是 systemd 启动快的关键),只在有顺序边约束的地方等待。所谓 multi-user.target 之类 target 其实也是 unit,只不过它的"行为"就是等待所有挂进它 WantedBy= 的 unit 完成启动。enable 的本质,是往 /etc/systemd/system/multi-user.target.wants/ 目录里放一个指向 unit 文件的符号链接;开机时 systemd 从默认 target(systemctl get-default 可查,服务器通常是 multi-user.target,桌面是 graphical.target)出发,顺着这张图把链接指向的服务全部拉起。

所以"WantedBy=multi-user.target + enable 实现开机自启"这句话的深层含义是:开机自启 = 把服务挂到 boot 启动图的某个同步点上。

#Type/ExecStart/Environment:指令到 fork/exec 的映射

unit 文件是声明式配置,但内核只认 fork()/exec() 这类原语。systemd 的角色就是把每条指令编译成一次受控的进程创建。ExecStart= 是真正触发 fork + exec 的那一条:systemd 先做环境准备——切换工作目录、读取 EnvironmentFile=、合并 Environment=、按 User=/Group= 切换身份、把 stdout/stderr 接到 journal——然后 exec 出目标程序。这就是为什么 ExecStart= 里必须是绝对路径、可执行文件本体,不能是 shell 内建命令,也不能依赖用户 shell 的 PATH:systemd 不会拿你的 shell 来解析这行命令。ExecStart=/usr/bin/node /opt/myapp/dist/server.js 会被 systemd 按自己的解析规则(支持引号与转义)切成 argv 直接 exec;而 ExecStart=node xxx.js 里的 node 只能靠 systemd 给服务准备的那份精简默认 PATH(/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin)去碰运气。

Type= 回答的是:systemd 怎么判定"启动完成"。它直接对应 fork/exec 的形态:

  • simple(默认):exec 一成功就算 started,适用于前台长驻进程——最直白,也最常用。
  • exec:和 simple 类似,但连"exec 是否成功"都会被检查,exec 失败会计为启动失败。
  • forking:适用于"父进程 fork 出守护进程后父进程退出"的传统 daemon;systemd 需要 PIDFile= 或自身逻辑找到真正的主进程,否则会把退出的父进程当成主进程、误判服务结束。
  • oneshot:启动后立即退出的短任务,适合初始化类脚本;配合 RemainAfterExit=yes 可以让 unit 在进程退出后仍保持 active。
  • notify:进程启动后主动通过 sd_notify(3)(或 systemd-notify 命令)告诉 systemd "我准备好了",systemd 才把状态置为 active——这是"等待就绪"的正规解法。
  • dbus:进程在 D-Bus 总线上注册成功后视为就绪;idle:等启动事务基本完成后、系统空闲时再启动。

对长驻服务选 simple 是符合直觉的:进程不退出、不 fork,exec 成功即 running,没有任何歧义。

Environment= 与 EnvironmentFile= 则是向 exec 环境注入变量的声明式写法:前者直接写在 unit 里(Environment=NODE_ENV=production),后者从文件读取 KEY=VALUE 行。注意 EnvironmentFile= 指向的文件默认必须存在,缺失会直接启动失败并报 Failed to load environment files;如果希望文件缺失也能启动,给路径加 - 前缀(EnvironmentFile=-/opt/myapp/.env)。另外,unit 文件里 %n(unit 名)、%h(用户家目录)这类 specifier 会在解析时展开,模板 unit(如 myapp@.service)用 %i 区分实例,启动多个同构服务时很有用。

#状态机:Restart 指令落在哪一环

systemd 给每个 unit 一个明确状态机:inactive(未运行)→ activating(启动中)→ active(运行中),停止时走 deactivating → inactive,异常时落到 failed。Restart= 不是"外部命令",而是状态机的转移规则:当 active 状态下的主进程异常退出(非零退出码、被信号杀死、超时、watchdog 触发),state 进入 auto-restart 子状态,等 RestartSec= 后再回到 activating;只有 StartLimit 计数被打满,才从 auto-restart 转入 failed。理解这层之后,"崩溃后为什么等了 5 秒才拉起""为什么反复崩最后停在 failed"这类现象都变成了状态机转义的必然结果,而不是玄学。

#资源限制与记账:cgroup 的另外一半

cgroup 不只用于"圈住进程",它还是资源配额与记账的载体。unit 里写 MemoryMax=512M、MemoryHigh=384M、CPUQuota=50% 会被 systemd 转写成 cgroup v2 的限制文件(memory.max、memory.high、cpu.max),内核按组强制执行;这些数字不存在于 unit 之外的任何"单独配置库",就落在 cgroup 目录里。排障时同样从这里取证:

# 实时查看各 cgroup 的 CPU/内存/IO 占用
systemd-cgtop

# 这个服务有没有被内核 OOM killer 杀过
cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
# 关注 oom 与 oom_kill 两行的计数

服务进程被 OOM 杀掉时,journal 里也有记录;如果应用日志里只见进程消失、看不到报错,先查 memory.events 与 dmesg,再怀疑代码。这不是"高级功能",而是进程集合管理的自然延伸:既然 systemd 必须枚举这个组的进程,内核就顺带把这个组的资源账单也交给它。

#journald:为什么日志天然是"统一"的

传统方式里,每个 daemon 自己开日志文件,格式各异、轮转自行管理,排查时要在十几个目录里翻。systemd 的解法不是要求应用改打日志方式,而是在 exec 之前把服务的文件描述符 1/2 接进 journald:StandardOutput=journal、StandardError=journal(现代 systemd 不写这两行的默认行为也是 journal,显式写出更清楚)意味着服务的 stdout/stderr 全部流入 journal socket,由 journald 统一接收。

journald 不只是当"接收器":它会给每条日志附加结构化元数据——所属 unit(_SYSTEMD_UNIT=)、进程号(_PID=)、优先级(PRIORITY=)、时间戳、启动批次(_BOOT_ID=)等。所以 journalctl -u myapp 能精准过滤出这个服务的全部输出,-p err 能只看错误级,--since 能按时间窗过滤,-b 能只看本次启动——这些能力全部来自"日志在写入时就被打上了 service 维度标签",而不是靠事后 grep 文本去猜。

#操作层:把一个 Node 服务做成开机自启

以下是完整可复现的操作链路。执行环境:systemd 249+ 的现代发行版(RHEL 9、Ubuntu 22.04+、Debian 12、Fedora 38+ 等行为一致,个别发行版的默认 PATH 或 journald 持久化开关略有差异,文中会标注);需要 root 权限(sudo);假定存在系统用户 deploy,应用位于 /opt/myapp,入口为 dist/server.js。

#前置条件与预检

# 1. 确认 PID 1 确实是 systemd(否则下面的概念全部不成立)
ps -p 1 -o comm=
# 期望输出: systemd

# 2. 确认 node 可执行文件的绝对路径(Debian/Ubuntu 发行版包可能叫 nodejs)
command -v node        # 或 which node
# 期望输出: /usr/bin/node,请把它抄进 ExecStart

# 3. 确认部署用户存在
id deploy

如果 ps -p 1 -o comm= 的输出不是 systemd(少数容器镜像里是 bash 或 docker-init),说明当前环境不走 PID 1 托管,本文直接使用 systemctl 的部分需要另行处理;绝大多数裸机、虚拟机、云主机都是 systemd。

#准备应用目录(示例用最小 Node 服务)

为让整条链路可复现,这里给一个能真正跑起来的最小 HTTP 服务作为示例对象;实际生产服务替换成你自己的入口即可。

sudo mkdir -p /opt/myapp/dist
sudo chown -R deploy:deploy /opt/myapp
// /opt/myapp/dist/server.js —— 最小长驻 Node 服务,仅供复现链路使用
const http = require("http");
const server = http.createServer((req, res) => {
  res.end("myapp ok\n");
});
server.listen(3000, () => {
  console.log("myapp listening on 3000");
});
# 先用前台方式验证它能跑(看到 listening 后 Ctrl-C 退出)
sudo -u deploy /usr/bin/node /opt/myapp/dist/server.js

这里的 /usr/bin/node 是上一步 command -v node 得到的绝对路径,请按你的实际输出替换。

#编写完整的 service unit

unit 文件放在 /etc/systemd/system/myapp.service(系统级服务)。下面是一个覆盖常用指令的完整示例,逐段对应原理层讲过的三类信息:[Unit] 定义依赖与顺序,[Service] 定义 fork/exec 细节与失败策略,[Install] 定义挂在启动图上的位置。

[Unit]
Description=My App Service
After=network-online.target redis.service
Wants=network-online.target

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/opt/myapp
Environment=NODE_ENV=production
EnvironmentFile=/opt/myapp/.env
ExecStart=/usr/bin/node /opt/myapp/dist/server.js
Restart=on-failure
RestartSec=5
StartLimitBurst=5
StartLimitIntervalSec=60
TimeoutStartSec=30
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

逐条说明(与原理层一一对应):

  • [Unit] 段:After= 只声明顺序——让 network-online.target 和 redis.service 先启动;Wants= 把 network-online.target 拉入本次启动。注意它不保证网络已就绪、redis 已能接受连接,就绪语义要靠 Type=notify 或 socket 激活,见"边界与坑"。
  • [Service] 段:
    • Type=simple:前台长驻进程,exec 成功即 running。
    • User=/Group=:exec 前切换身份;WorkingDirectory=:exec 前 chdir。
    • Environment=/EnvironmentFile=:注入环境变量,映射为 exec 时的环境;EnvironmentFile 缺失会导致启动失败(可用 - 前缀容忍)。
    • ExecStart=:真正执行 fork/exec 的命令,必须是绝对路径。
    • Restart=on-failure + RestartSec=5:异常退出后等 5 秒再拉起,防止"秒崩-秒启"风暴。
    • StartLimitBurst=5 + StartLimitIntervalSec=60:60 秒内最多重启 5 次,超过进入 failed——没有这个保护,一个配置错误的服务会把 CPU 耗在无意义的循环重启上。
    • TimeoutStartSec=30:启动阶段 30 秒内没完成(exec 一直失败、notify 一直不通知)就判失败。
    • KillSignal=SIGTERM:停止时先发 SIGTERM 做优雅停机,超时才 SIGKILL。
    • StandardOutput=journal/StandardError=journal:把 stdout/stderr 送进 journald。
  • [Install] 段:WantedBy=multi-user.target 决定 enable 时把符号链接放进哪个 target 的 .wants/ 目录。

#加载、启动与开机自启

# 1. 重新加载 unit 定义(改过 unit 之后这一步必须做)
sudo systemctl daemon-reload

# 2. 注册开机自启 + 立即启动(最常用组合)
sudo systemctl enable --now myapp

# 3. 确认开机自启状态
sudo systemctl is-enabled myapp
# 期望输出: enabled

# 4. 确认运行状态
sudo systemctl is-active myapp
# 期望输出: active

# 5. 在运行中的服务列表里能找到它
sudo systemctl list-units --type=service --state=running | grep myapp

daemon-reload 不是走形式:systemd 只在收到 reload 后才会重新读盘上的 unit 文件。写完 unit 不 reload 就 start,最常见的报错是 Unit myapp.service not found——文件明明在,systemd 眼里却没有。这是新手最常犯的错,也是"我的 unit 改了没生效"的第一排查项。

#用 drop-in 覆盖微调参数

生产里"改 unit"不必也不应该总是动原文件:发行版升级可能覆盖 /usr/lib/systemd/system/ 里的 vendor unit,直接改会让你的变更丢失。正确姿势是 drop-in 覆盖。systemctl edit myapp 会在 /etc/systemd/system/myapp.service.d/override.conf 打开覆盖文件;非交互环境直接手工创建同路径文件即可:

[Service]
RestartSec=10
Environment=EXTRA_OPTS=--verbose

保存后 systemctl daemon-reload 再 systemctl restart myapp 生效。合并规则:标量型指令(如 RestartSec、Type)后加载者覆盖先加载者;列表型指令(如 Environment、After、Wants)在 drop-in 中是追加而不是覆盖。所以 drop-in 里通常只写要调整的键,原文件保持不动;systemctl cat myapp 会把原 unit 与所有 drop-in 合并后的结果打印出来,用来确认最终生效值;systemctl revert myapp 清掉全部 drop-in 恢复默认。需要整体重构 unit 时用 systemctl edit --full myapp,systemd 会复制一份到 /etc/systemd/system/ 供你修改。

#不用文件也能建服务:systemd-run 与 transient unit

unit 不一定要来自磁盘上的文件。systemd-run 能在内存里即时生成 transient unit,把一条命令纳入同一套 cgroup 与日志体系,适合临时任务、调试、以及"给一个没人管的进程补上进程树归属"的场景:

# 把一条命令放进独立 cgroup(scope),进程纳入 systemd 管理
sudo systemd-run --scope /bin/sleep 300

# 指定 unit 名,便于用 status/journalctl -u 定位
sudo systemd-run --unit=myjob --scope /bin/sleep 300

生成后它就是一个普通 unit:systemctl status myjob.scope 能看状态,journalctl -u myjob.scope 能看日志,systemctl stop myjob.scope 能停掉。systemd-run --user 则用在 user 级。理解这一点有助于分清"服务管理"与"unit 文件"不是一回事:systemd 管理的对象是 unit,文件只是 unit 最常用的持久化来源。

#验证层:怎么确认我做对了

"能 start"只是第一步。下面四组检查分别验证四件不同的事:状态机、日志、进程/资源、重启行为。

#1. 状态与属性

# 完整状态 + 最近日志(排障第一命令)
systemctl status myapp

# 机器可读的关键属性:主进程号、状态、重启次数
systemctl show myapp -p MainPID -p ActiveState -p SubState -p NRestarts

status 输出里重点看两行:Loaded: loaded (/etc/systemd/system/myapp.service; enabled; ...) 说明加载路径与开机自启状态;Active: active (running) 说明当前状态。show 的字段可以进脚本:systemctl is-active 的退出码(0 为 active)比人眼读输出可靠;NRestarts 非零说明服务真的经历过重启,是判断"是否在反复崩溃"的硬证据。

#2. 日志验证

# 服务自己的全部输出(stdout/stderr 都会在这里,且自动带上 unit 标签)
journalctl -u myapp --since "10 minutes ago"

# 跟随输出(调试时另开一个终端)
journalctl -u myapp -f

# 只看错误级(含 err 及以上:crit/alert/emerg)
journalctl -u myapp -p err

# 结构化输出:能看到日志附带的 unit/pid 等元数据
journalctl -u myapp -n 5 -o json-pretty

判定标准:启动后 myapp listening on 3000 之类的 stdout 应该出现在 journal 里;如果一行都没有,说明输出没有接到 journal(可能被程序自己重定向,见"边界与坑")。

#3. 进程与 cgroup 验证

# systemd 认为的主进程
systemctl show myapp -p MainPID --value

# 看它落在哪个 cgroup,以及 cgroup 里有哪些进程
systemctl show myapp -p ControlGroup
cat /sys/fs/cgroup/system.slice/myapp.service/cgroup.procs

对照原理层:这两条命令验证的是"systemd 通过 cgroup 管理整个进程集合"这一事实。如果服务还 fork 了子进程,子进程的 PID 也必须在 cgroup.procs 里;不在说明它逃出了 cgroup(正常的 cgroup v2 下不会发生,除非进程自己移出)。

#4. 重启行为验证

# 杀掉主进程(SIGKILL 属于异常退出,on-failure 应触发重启)
sudo kill -9 $(systemctl show myapp -p MainPID --value)
sleep 8
systemctl status myapp
# 期望: 8 秒后状态回到 active,且 NRestarts 增加了 1

再验证"防重启风暴":临时把 ExecStart 改成立即失败的命令(比如 /bin/false),reload 后观察系统在 60 秒内最多拉起 5 次就停住:

# 编辑 /etc/systemd/system/myapp.service,把 ExecStart 改为 ExecStart=/bin/false
# 保存后:
sudo systemctl daemon-reload
sudo systemctl start myapp
sleep 70
journalctl -u myapp --since "10 minutes ago"
# 期望: 日志出现 "Start request repeated too quickly",状态停在 failed

这个实验做完记得把 ExecStart 改回来并 daemon-reload。### 5. 启动顺序与性能验证

"启动成功"不等同于"顺序正确"。要确认依赖图真的按你预期的顺序执行、没有哪个环节拖慢整体,用 systemd 自带的三个分析命令:

# 本机完整 boot 链路上各 unit 的启动耗时排序,看有没有拖慢开机的环节
systemd-analyze blame | head -n 10

# 从启动根到 myapp 的关键路径,含每步耗时
systemd-analyze critical-chain myapp

# 依赖图:myapp 的启动会拉进哪些 unit
systemctl list-dependencies myapp

critical-chain 输出的链条形态大致是(示意,不含时间列):

graphical.target
└─multi-user.target
  └─myapp.service
    └─redis.service

判定标准:如果 critical-chain 里 myapp 上游某个环节耗时异常(例如 network-online.target 等了近百秒),说明顺序依赖配置有问题,瓶颈不在 myapp 本身。另一个低成本检查是静态验证:systemd-analyze verify /etc/systemd/system/myapp.service 不启动服务也能发现拼写错误、非法指令等连 status 都不会暴露的问题——把它当作 unit 的 lint 工具,写复杂 unit 后先跑一遍。

#边界与坑

#改完不 reload:最常见的 "Unit not found"

原理层已解释:unit 是声明,systemd 只在 daemon-reload 时重新读盘。凡是"改了 unit 没生效"、"报 not found"、"状态还是旧的",第一反应都应是 reload 而不是继续改文件。另外注意:新增 unit、删除 unit、修改 unit 都要 reload;enable/disable 只是动符号链接,不强制 reload。

#After= 只保证顺序,不保证就绪

After=redis.service 的语义仅仅是"等 redis 的启动 job 完成"。对 Type=simple 服务来说,"启动 job 完成"就是 exec 成功,不代表端口已经监听、业务已经就绪。要真等到"能用",三条正路:

  • Type=notify:服务启动后通过 sd_notify(3) 通知就绪(Node 生态可用原生模块,或在 wrapper 脚本里调 systemd-notify);
  • socket 激活:.socket unit 持有监听 socket,连接到来时才拉起服务,天然解决"端口要永远在、进程可以后起"的问题;
  • 应用层自愈:ExecStartPre= 里做等待/健康检查(例如 curl --retry 探测上游),把"就绪"显式推迟。

另外 network-online.target 只有在 NetworkManager-wait-online 或 systemd-networkd-wait-online 这类 wait-online 服务启用时才真正等到"网络可用",否则它同样只是顺序标记,不会等你拨号完成。

#Type 选错的连锁反应

把会 fork 的传统 daemon 声明成 Type=simple:父进程 fork 完就退出,systemd 看到"主进程退出"会认为服务结束了,随后按 Restart 策略反复拉起,形成诡异的重启循环。反过来,把前台进程声明成 Type=forking:systemd 一直等不到"守护进程 fork 完成"的信号,会一直等到 TimeoutStartSec 超时才判失败。原则:你的程序是什么进程形态,就选对应的 Type;不清楚时优先选 simple,并保证程序前台运行(现代应用框架普遍支持前台长驻)。

#重启风暴与 StartLimit 保护

Restart=on-failure 只对异常退出(非零退出码、被信号杀死、超时等)重启;on-abnormal 只在被信号杀死等非正常路径重启;always 连正常退出也重启(适合进程周期性自发退出的场景,但要小心退不出来的死循环)。无论哪种,都必须配合 StartLimitBurst/StartLimitIntervalSec 做限流:没有限流,一个启动即崩的服务能让 systemd 无限循环拉进程,CPU 全耗在无意义的重启上。限流打满后状态停在 failed,日志里能看到 "Start request repeated too quickly";此时 reload 不会清除计数,需要 systemctl reset-failed myapp 手工复位后才能再次启动。

#优雅停机:SIGTERM、TimeoutStopSec 与 KillMode

默认停止流程是:发 KillSignal(默认 SIGTERM)给主进程 → 等 TimeoutStopSec(默认 90 秒)→ 超时后按 KillMode 补刀。所以应用必须正确实现 SIGTERM 处理器:关连接、刷盘、退出。忽略 SIGTERM 的应用会让 systemctl stop 挂 90 秒才被强杀,在发布、滚动重启流程里是灾难。

KillMode=control-group(默认)停止时处理整个 cgroup 内全部进程;有些程序在 SIGTERM 下确实不退(比如只给主进程装了处理器、子进程没装),此时可以:

  • 显式声明 ExecStop=/bin/kill -TERM $MAINPID,把停止动作写明确;
  • 或改用 KillMode=mixed:先向主进程发 SIGTERM,超时后再 SIGKILL 掉整个 cgroup 里剩余的进程;
  • 需要某个服务重载配置时,用 systemctl kill -s HUP myapp 单独发信号,不必 stop/start。

#端口占用与旧进程

ss -tlnp 是查端口占用的事实来源。服务启动报 "Address already in use" 时,先看占用者:如果是上一代进程,说明停止流程没走干净(见上一条);如果端口明明被监听却查不到占用进程,可能是内核 TIME_WAIT 状态或另一个 network namespace 里的进程,不要凭印象乱 kill。

#路径、环境与权限

三段高频坑:

  • ExecStart 路径写错或 User= 无权访问:报错特征 Failed at step EXEC spawning /usr/bin/node: No such file or directory。先 command -v node(或 which node)确认绝对路径,再确认 User= 对可执行文件和 WorkingDirectory 有读/执行权限。
  • systemd 给服务的 PATH 是精简集(/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin)。任何依赖用户 shell 环境的启动方式(比如装在 nvm 里、只对 deploy 的 shell 可见的 node)都必须写成绝对路径;需要更多环境时用 Environment=/EnvironmentFile= 显式补齐。
  • EnvironmentFile 指向的文件默认必须存在,缺失会直接启动失败;业务上可缺省时用 EnvironmentFile=-/opt/myapp/.env 的 - 前缀容忍。

#journald 持久化与输出逃逸

journal 默认的策略是 Storage=auto:日志落在 /var/log/journal(持久)或 /run/log/journal(内存,重启即失)。如果系统里没有 /var/log/journal 目录,日志重启就丢;需要持久化时在 /etc/systemd/journald.conf 里设 Storage=persistent,然后 systemctl restart systemd-journald,并用 journalctl --disk-usage 观察占用。另外有两种"journal 里看不到日志"的常见原因:程序自己把 stdout/stderr 重定向到了应用日志文件(fd 在 exec 后被换掉),或程序 fork 后关闭了标准 fd——此时日志在应用自己的文件里,统一的前提是程序别抢走 stdout/stderr。日志增长也需要配额管理,否则 /var/log/journal 可能把磁盘写满:在 /etc/systemd/journald.conf 里设 SystemMaxUse=(如 SystemMaxUse=1G)与 Compress=yes,改完 systemctl restart systemd-journald,再用 journalctl --disk-usage 确认当前占用与上限是否匹配。

#改错文件:以 systemctl cat 为准

systemd 的 unit 查找优先级从高到低是 /etc/systemd/system、/run/systemd/system、/usr/local/lib/systemd/system、/usr/lib/systemd/system(发行版自带的 unit 在 /usr/lib,管理员覆盖放 /etc);enable 还会在 target 的 .wants/ 目录里放符号链接。生产环境改服务配置前,先 systemctl cat myapp 看 systemd 眼里实际生效的配置(含 drop-in 合并结果),防止改的文件根本不在生效路径上——比如有人改了 /etc/init.d/ 下的旧脚本,systemd 根本不读。日常小改动建议用 systemctl edit myapp(生成 drop-in 覆盖,不污染原文件),需要撤销覆盖时 systemctl revert myapp;想永久禁用且防手动启动用 systemctl mask myapp,普通停用只需 disable。

#容器里的 systemd:PID 1 不总是 systemd

在容器里跑 systemctl start xxx 经常会得到一个典型报错:System has not been booted with systemd as init system (PID 1). Can't operate. 原因正是全文开头那条底层事实:PID 1 接管进程生命周期,而大多数容器的 PID 1 是应用进程或 entrypoint,不是 systemd,systemctl 无从谈起。解决方案按场景选:容器只跑单进程时,不要用 systemd,直接让应用当 PID 1 并处理信号;需要完整 init 语义时,用 docker run --init 注入 tini,或改用带 systemd 的基础镜像并配合正确挂载/特权(cgroup v2 下通常还要挂 /sys/fs/cgroup)。判断环境最简单的方法是前文的 ps -p 1 -o comm=:输出不是 systemd,就说明这套机制在当前 PID namespace 里不存在。

#超时参数家族:TimeoutStartSec / TimeoutStopSec / TimeoutSec

  • TimeoutStartSec=:单轮启动的判定上限,默认 90 秒。simple 类型 exec 失败会更快暴露;notify 类型如果一直不通知就绪,到点判失败。
  • TimeoutStopSec=:停止阶段容忍 SIGTERM 后无响应的上限,默认 90 秒;超时后按 KillMode 升级为 SIGKILL。ExecStop 的执行时间也计入这个窗口。
  • TimeoutSec=:同时设置以上两者,适合"启动停止都不该超过 N 秒"的明确诉求。

这些超时只约束"这一轮启动/停止"的判定,与 Restart 的次数统计相互独立:重启循环由 StartLimit 管,单轮卡死由 Timeout 管,两者是不同层面的保护。

#模板 unit 与实例化

当你有 N 个同构服务(多个 worker 进程、多环境部署)时,不要复制 N 份 unit 文件,用模板。把文件命名为 myapp@.service,内部用 %i 占位:

[Service]
ExecStart=/usr/bin/node /opt/myapp/%i/server.js
Restart=on-failure

然后按实例启动:systemctl start myapp@worker1.service,systemd 会把 %i 替换成 worker1;enable 同样按实例操作。注意实例名中的特殊字符需要用 systemd-escape 处理,否则 %i 展开可能不符合预期。模板和普通 unit 的规则一致,只是把"每个服务一份文件"变成"一份文件管一族服务"。

#一个完整的排障决策路径:从症状到根因

把上面这些坑连起来,就是一条可直接照做的排障路径。服务起不来时按症状定位,每一条都能从原理层推导出"为什么要这么做":

  1. systemctl start myapp 报 Unit myapp.service not found,或改了配置不生效:先 sudo systemctl daemon-reload,再看 systemctl cat myapp 确认生效文件与 drop-in 合并结果。根因:unit 是声明,systemd 只在 reload 时读盘。
  2. 报 Failed at step EXEC spawning /usr/bin/node: No such file or directory:ExecStart 路径写错,或 User= 对可执行文件/工作目录无权。command -v node(或 which node)、ls -l 核对。根因:systemd 跳过 shell 直接 exec,必须给绝对路径。
  3. 启动后立刻退,Main process exited, code=exited, status=1/:业务代码崩溃。journalctl -u myapp --since "10 minutes ago" 看应用日志;Restart=on-failure 会自动拉起,但若 StartLimitBurst 被打满会停在 failed,需要 systemctl reset-failed myapp 复位。根因:状态机限流生效。
  4. 报 Address already in use:ss -tlnp 找占用者;大概率是上一代进程没退干净,检查 KillSignal/TimeoutStopSec/KillMode/ExecStop 的停止链路。根因:停止 = 清空 cgroup,清不干净服务就起不来。
  5. 服务 active 但连不通:After= 只保证顺序不保证就绪,改用 Type=notify、socket 激活或应用层健康检查。根因:顺序边与就绪语义是两回事。
  6. journal 里没有日志:程序抢走了 stdout/stderr 或 fork 后关闭了标准 fd;另查 journald 持久化与 SystemMaxUse 配额。根因:统一日志的前提是 fd 1/2 还连在 journal 上。

按这条路径走,多数故障在 10 分钟内能从症状落到根因,而不是反复改配置碰运气。

#收尾

从 PID 1 与 cgroup 这两条底层事实出发,unit 的三段结构、依赖图、启动状态机、journal 日志都是可推导的产物;把这层机制想清楚,systemctl 的命令就不再是黑盒,排障时也自然知道"先看哪里"。下一步值得花时间的方向:systemd.timer 替代 cron、socket 激活(ListenStream= + Accept=no)、以及安全加固(systemd-analyze security、ProtectSystem=、NoNewPrivileges=、PrivateTmp=)。反过来也要知道什么时候不该用 systemd:它管理的是"系统的生命周期",如果只是要跑一个一次性脚本,用 oneshot 配合 timer 比常驻服务更合适;桌面端的周期性任务优先考虑 user 级 unit(systemctl --user),不要为了一个小脚本去碰 root 级服务。

相关阅读

Related

留言讨论

0 条

还没有留言,来说点什么吧。

Linux systemd 服务管理:从编写 unit 到故障排查 · LXH·BLOG