1. 问题场景:一条 systemctl stop 命令卡住了
如果你也遇到过这种情况——在终端敲下systemctl stop xxx,然后光标就开始闪烁,一等就是 90 秒,甚至更久,最后才出现提示或者干脆报错——那这篇文章就是写给你的。我接手过的环境里,因为服务停不掉导致的故障可真不少:发布流程卡在“停止旧服务”这一步,整个流水线超时失败;服务器维护的时候发现服务进程怎么都杀不死,只能干瞪眼;最倒霉的一次是深夜线上告警,某核心服务响应已经全部超时,结果连 stop 都花了近两分钟才把僵尸流程清干净。
这个标题里的“XXXX”可以是任何服务,数据库、消息队列、业务进程、定时任务……问题表现一模一样:Stop 操作长时间无响应。表面上这只是 systemd 的一个小毛病,但往深了挖,它牵扯到 systemd 的停止机制、进程信号处理、依赖关系图、超时控制、资源清理策略,还有你的服务本身健壮不健壮。这篇文章我从原理讲到实操,把排查思路和解决方案都整理清楚,照着操作基本能解决 90% 的“停不下来”问题。
2. systemd 停止服务背后的运行机制
2.1 停止一个服务,systemd 到底在做什么
等你执行systemctl stop xxx的时候,systemd 会严格按照 unit 文件里的配置顺序执行一套动作。很多人以为 stop 就是“杀掉进程”这么简单,实际上远不止——它要协调多个环节:
首先是停止依赖关系。systemd 会先检查有没有其他正在运行的服务依赖这个 unit。如果有,它不会直接停,而是先处理依赖关系,这个逻辑很关键,经常是超时的潜在根源。
然后进入ExecStop 命令执行阶段。如果你的 unit 文件里定义了ExecStop=,systemd 会先执行这些命令,等它们全部跑完,再走下一步。有些人在 ExecStop 里写了脚本,脚本里有数据库备份或外呼 API 之类的耗时操作,这个阶段就会变得很漫长。
接着是主进程停止。systemd 向主进程发送 SIGTERM 信号,等待进程自行退出。如果在TimeoutStopSec指定的等待时间内进程还没退出,systemd 升级手段,再发 SIGKILL 强杀。
最后是残留资源清理,比如 cgroup 里的线程、子进程等。如果 cgroup 里还有进程未退出,systemd 还要逐一干掉它们。
整个链条里任何一环卡住,systemctl stop就会迟迟不返回。理解这个顺序,对排查问题特别有帮助——很多人一上来就怀疑是系统负载太高,其实大多时候是某个中间环节在磨蹭。
2.2 决定停止时长的关键参数
systemd 的 unit 文件里有一些和停止时间直接相关的参数,务必要搞清楚:
TimeoutStopSec=:这个参数定义了 systemd 从发送 SIGTERM 到发 SIGKILL 的等待时间。默认值是 90 秒。超过这个时间就直接上强杀。TimeoutSec=:这个值是统一的超时上限,同时会覆盖TimeoutStartSec和TimeoutStopSec。如果只写了这个,没单独写 TimeoutStopSec,那 stop 等待上限就用这个值。ExecStop=:停止时要额外执行的命令。它执行的时间会叠加到整个停止流程中,也就是说,ExecStop 跑 120 秒,TimeStop 又等 90 秒,总时长轻松破两百秒。KillSignal=:默认是 SIGTERM。有些服务对 SIGTERM 响应特别慢(比如某些 Java 应用或脚本型服务),可以考虑自定义信号类型。SendSIGKILL=:默认 yes。如果设成 no,即使超时也不会强杀进程,服务就会一直卡住。这个一般不建议动,除非有特殊需求。
非线性叠加的时间模型可以用一张简表说明:
| 阶段 | 动作 | 是否计入停止超时 |
|---|---|---|
| 处理依赖停止 | 停止依赖 unit | 不计入,但会实质性耗时间 |
| ExecStop 执行 | 运行自定义命令 | 不计入 TimeoutStopSec,但会叠加总时间 |
| SIGTERM 等待 | 等待主进程退出 | 计入 TimeoutStopSec |
| SIGKILL 强杀 | 强制终止残留进程 | 计入 TimeoutStopSec 之后的恢复阶段 |
很多人遇到 stop 超时,第一反应是把 TimeoutStopSec 改小,比如改成 10 秒。这个思路不能算错,但要注意:改成 10 秒后,systemd 只给 10 秒优雅退出窗口,时间一到就发 SIGKILL。对于一些需要持久化状态的服务(如数据库),数据损坏的风险会显著上升。所以调整超时时间前,先搞清楚你的服务是哪一类,再决定怎么调。
3. 为什么 stop 会如此缓慢:从根源上拆解
3.1 卡点一:ExecStop 脚本里的耗时操作
很多业务的 systemd unit 文件都配了 ExecStop,用于执行一些清理逻辑,比如把内存里的数据落盘、通知注册中心下线、打日志。悲剧在于,这些操作本身就可能阻塞很久。
我遇到过一个典型例子:某服务停止时需要调一个外部 API 通知服务下线,但那个 API 响应特别慢,平均耗时 40 秒,偶尔还在网络超时后重试 3 次,一次 ExecStop 就可以折腾好几分钟。由于 ExecStop 的耗时不在 TimeoutStopSec 管理范围内,systemd 就干等着,给人感觉就是“卡死”。
遇到这种情况,要审视一下 ExecStop 里的命令是不是“非做不可、非同步做不可”。比如通知类的操作完全可以异步执行,先在 ExecStop 里触发一个后台进程,让它自己慢慢通知,主流程立即返回,这样停止速度就快多了。
另外 ExecStop 的执行超时需要明确给一个上限,可以通过在脚本内部设置timeout命令来约束。具体做法后面写方案的时候会细说。
3.2 卡点二:进程对 SIGTERM 信号响应迟钝
这是最常见的卡点。systemd 默认先发 SIGTERM,进程收到信号后如果立即退出,整个 stop 流程非常快,几十毫秒就结束了。但现实是,很多进程对 SIGTERM 的处理并不好:
Python 脚本没有注册 SIGTERM 信号处理器,默认行为是直接终止,这算快的;但如果代码里注册了 handler 且 handler 里做了大量清理,就会很慢。
Java 应用默认对 SIGTERM 的处理会触发 JVM 的 shutdown hook,里面可能包含复杂的资源释放操作。GC 不健康时,这个释放过程会特别慢。
自定义脚本在 trap 里写了循环等待或重试逻辑,没注意到信号被信号处理器“吃了”,进程迟迟不退。
一个我在排查时常用的命令:systemctl status xxx看主进程 PID,然后用ps -o pid,stat,wchan:30,cmd -p PID查看进程当前在内核里的等待状态。如果wchan显示的是do_exit,说明进程已经进入退出流程了,系统很快就能收走;如果显示的是wait_woken、pipe_read这类阻塞态,大概率是卡在某次 IO 或网络操作上。
排查信号处理问题的实操方法:直接手工向进程发一次 SIGTERM,观察它的退出耗时。步骤很简单:
# 获取主进程 PID,Service 名替换为你的服务 systemctl show -p MainPID --value xxx.service # 手工发送 SIGTERM kill -TERM 上面拿到的PID # 观察进程是否退出,间隔1秒重复查看 ps -p PID -o pid,stat,etime,cmd手工 kill 后进程秒退,说明是 systemd 与服务的交互配置有问题;手工 kill 也要等很久,问题就在服务本身的信号响应上。
3.3 卡点三:系统负载过高,cgroup 资源回收困难
另一个容易忽略的卡点来自系统层面。如果你的服务器负载很高——比如 CPU 使用率打满、IO 等待时间长——那么进程接收到 SIGTERM 后,很多清理动作里的磁盘同步、内存回收也就跟着变慢了。
我遇到过一次很典型的故障:某台机器跑着大数据计算任务,CPU 负载长期超过 20,此时重启运维平台的服务。正常 3 秒能停掉的服务,硬是被拖到 100 多秒。用uptime一看负载,用iostat -x 1一查磁盘,确认是系统整体处于过载状态。这类问题不是 systemd 能直接解决的,需要先降低系统负载,再执行停止操作。
另外要注意 cgroup 里的“顽固子进程”。有些服务会 fork 一堆子进程,子进程和主进程不在同一个进程组或者无视终止信号。systemd 默认在停止主进程后,还要把 cgroup 里残留的进程都清理掉。假如子进程处于不可中断的睡眠状态(D 状态,通常是内核 IO 等待),systemd 就可能阻塞在回收环节。
3.4 卡点四:单元间的停止依赖链
如果服务 A 依赖服务 B,停止 A 时 systemd 会先检查 B 的状态并试图停止 B(或者按照After=语义等待相关服务停止)。这种依赖链一旦很长,任何一环卡住都会拖累整条链。
举个例子:服务 ARequires服务 B,B 又Requires服务 C,C 的 stop 超时设置是 90 秒且信号响应很慢,那你停 A 的时候,最坏情况就要等 C 超时完,再加上 B 和 A 自己的 HTTP 时间……这就是“连锁超时”。
对于这种依赖关系导致的问题,需要查看完整的依赖图:
systemctl list-dependencies xxx.service --reverse这个命令能列出哪些服务依赖目标服务,以及目标服务依赖哪些服务。看到依赖链后,重点检查链条里每一个环节的 timeout 配置。
4. 系统诊断实操:定位超时瓶颈的标准流程
4.1 锁定卡住的阶段
拿到一个 stop 超时问题,第一步是判断卡在哪个阶段。这个过程我一般分三步走:
第一步,打开日志端。执行 stop 之前,先在另一个终端准备看 journal:
journalctl -u xxx.service -f第二步,正常执行 stop,观察日志输出的时间间隔。如果日志停留在“Stopping xxx...”之后就长时间没动静,说明卡在 ExecStop 阶段了;如果日志显示“Stopping”之后很久才出现“Sending SIGKILL”,那就要重点看 SIGTERM 等待阶段。
第三步,用 systemctl show 查看当时 systemd 记录的单元状态:
systemctl show xxx.service -p ActiveState -p SubState -p ExecMainPID -p TimeoutStopUSecTimeoutStopUSec会直接打印当前生效的 TimeoutStopSec 值,以防配置被 drop-in 或 override 修改了。
4.2 用 perf trace 或 strace 深入分析
如果上面步骤只能定位到“信号响应慢”,还需要进一步看进程内部发生了什么。生产环境不方便直接 gdb,但 strace 是相对低风险的工具。
我的操作方式是:先停掉服务(或者配合 systemd 的暂停机制),然后用 strace 附加到主进程:
# 附加到主进程,跟踪信号相关系统调用 strace -p PID -e trace=signal,read,write,close -tt -o /tmp/strace_xxx.log然后再另开终端执行 stop。观察 strace 输出里进程最后在等什么——是卡在 read 等待网络数据返回,还是卡在 write 等待磁盘 IO,还是不断在循环处理信号。
有一次排查某服务停不掉,strace 显示进程收到 SIGTERM 后没有立即退出,而是进入了一个循环,反复调用epoll_wait,每次超时 30 秒,循环了 3 次才最终退出。原因是代码里注册的退出 hook 在等待一个网络连接优雅关闭,而那个连接对端已经彻底失联了。知道真相后就好办了,给代码里的退出等待加上总超时即可。
4.3 检查 systemd 内部超时事件
systemd 自身也会在超时发生时留下线索。日志里如果出现类似 “Stopping timed out. Killing.” 的带时间戳记录,看下 journal 里的精确时间间隔,能算出实际的等待时长。
另外,执行下面命令看当前服务的定时器信息:
systemctl show xxx.service -p TimerMonotonic -p InactiveExitTimestamp -p ActiveEnterTimestamp这几个时间点能帮助判断服务处于什么状态,以及状态切换的时间间隔。
4.4 复现技巧:带时间戳观察整个生命周期
我个人的习惯是:在执行 stop 前先记录一个起始时间,然后每 5 秒刷新一次服务状态。用 shell 循环就能快速复现问题:
date +%s; systemctl stop xxx.service; date +%s两端时间差就是 stop 总耗时。然后用:
systemctl status xxx.service --no-pager看最后的日志,同时参考/var/log/messages里 systemd 发出的告警信息,能快速压缩排查范围。
5. 解决方案:让 systemctl stop 真正“停止”下来
5.1 临时处理:手动完成停止并跳过卡点
遇到线上服务卡在 stop,有时不能等它慢慢超时,需要立即处置。我的建议是分优先级操作:
如果服务还能响应,尝试向主进程直接发 SIGTERM:
systemctl kill -s TERM --kill-who=main xxx.service这个命令不经过 ExecStop,直接给主进程发信号。注意,它跳过了 ExecStop 里的清理逻辑,所以确认这些清理可以接受损失再用。
如果需要强制终止整个 cgroup 里所有进程,用:
systemctl kill -s KILL --kill-who=all xxx.service这个命令直接 SIGKILL cgroup 里的全部进程,是最后的辣手手段。可以短暂解决卡死问题,但业务进程来不及清理内存或持久化状态,存在数据风险。我以前在用这个命令前,都会提醒自己:先确认这个服务是无状态应用再做。
还有一种投机取巧的临时方法:如果卡死的 ExecStop 脚本本身是个死循环或外部 API 超时,可以直接把受影响的脚本进程 kill 掉,让 ExecStop 返回。这时候 systemd 会自动继续后续步骤。实操时先找到 ExecStop 脚本进程:
ps -ef | grep -E "ExecStop|xxx.sh"找到 PID 后 kill 掉。这个方法不用动整棵进程树,对服务本身影响最小。
5.2 根治方案:调整 unit 配置和超时策略
临时处置之后,一定要做永久性修复,否则下次同样问题还会出现。推荐的配置策略分几种场景:
场景一:服务优雅退出本来就慢,但需要保证数据完整性。
适合把 TimeoutStopSec 适当调大,比如 120 秒或者 180 秒。同时要把 ExecStop 里的脚本加上超时控制,比如用 timeout 命令包裹:
[Service] TimeoutStopSec=180 ExecStop=/usr/bin/timeout 150 /opt/xxx/bin/pre-stop.sh这样设置的好处是:脚本最多跑 150 秒,如果超时 timeout 会直接杀掉脚本进程,ExecStop 返回后 systemd 继续流程;剩余 30 秒留给主进程优雅退出和清理。整体上限理论上不超过 180 秒。
场景二:服务响应慢,但允许强杀。
可以把 TimeoutStopSec 调小,比如 20 秒,让 systemd 尽快升级到 SIGKILL。适用于无状态服务,或者可以接受秒级中断的业务:
[Service] TimeoutStopSec=20这里有个隐含规则要记住:TimeoutStopSec不是“总时长”,而是“从发送 SIGTERM 到 SIGKILL 的等待时长”。如果 ExecStop 里脚本耗时很长,这 20 秒管不到 ExecStop,总时长仍然可能很长。
场景三:彻底跳过优雅停止,直接强杀。
有些服务(比如某些弹性任务、批处理进程)本身就不需要优雅退出。可以将配置设为:
[Service] TimeoutStopSec=1 KillSignal=SIGKILL这样一来,stop 时 systemd 几乎立刻发 SIGKILL,整个过程通常在 2 秒内完成。适用场景是明确无状态且没有持久化诉求的服务。数据库这种千万别这么搞。
5.3 针对 ExecStop 的命令改造
ExecStop 是很多时候的“真凶”。我整理了一套改造思路:
第一,所有 ExecStop 命令都包上 timeout 上限,任何单条命令不允许超过 30 秒。
第二,把通知类操作抽成异步脚本,例如:
ExecStop=/opt/xxx/bin/notify-offline.sh脚本内容改成 nohup 的方式先行返回:
#!/bin/bash nohup curl -m 5 -X POST http://registry/api/offline & exit 0同步的通知变成异步的,停止流程就快了。代价是通知可能失败,所以这种改造只适用于可以容忍短暂延迟的协调逻辑。
第三,尽量把清理工作放到服务自身退出时执行,而不是依赖外部脚本。比如在应用代码里注册信号处理,收到 SIGTERM 后自行清理并退出。systemd 只需要等待进程退出即可,省掉了外部命令的执行时间。
5.4 用 drop-in 覆盖配置而不动原文件
修改 unit 文件时,推荐用 drop-in 机制而不是直接改 /usr/lib/systemd/system 下的原始文件。理由很简单:直接改原始文件,软件包升级时配置就丢了;drop-in 则可以在不碰原始文件的前提下覆盖参数。
创建一个 drop-in 目录和配置文件:
mkdir -p /etc/systemd/system/xxx.service.d cat > /etc/systemd/system/xxx.service.d/override.conf <<EOF [Service] TimeoutStopSec=30 ExecStop= ExecStop=/usr/bin/timeout 20 /opt/xxx/bin/pre-stop.sh EOF注意:ExecStop=这一行非常重要,它先把原来的 ExecStop 列表清空,再重新设置,否则新的 ExecStop 会和原来的叠加,而不是替换。这是 drop-in 覆盖列表型参数的关键细节。
改完配置要重新加载:
systemctl daemon-reload systemctl restart xxx.service验证配置是否生效,用:
systemctl show xxx.service -p TimeoutStopUSec -p ExecStop6. 常见问题速查与避坑指南
6.1 故障模式与处理速查表
| 现象 | 可能阶段 | 优先排查项 | 临时处理 | 长期方案 |
|---|---|---|---|---|
| stop 时日志卡在 “Stopping” 很久 | ExecStop 阶段 | ExecStop 脚本内容 | kill 掉脚本进程 | 给 ExecStop 加 timeout、异步化 |
| 日志显示 “Sending SIGKILL” 前等了很久 | SIGTERM 等待阶段 | 服务信号处理器 | systemctl kill -s KILL | 调 TimeoutStopSec、修信号处理代码 |
| 进程已退出但命令仍然阻塞 | cgroup 回收阶段 | 残留子进程状态 | 检查 ps 中的 D 状态进程 | 规范进程管理、避免孤儿进程 |
| 多个服务连锁卡死 | 依赖链阶段 | systemctl list-dependencies | 按依赖顺序手动停止 | 梳理依赖关系、设置合理依赖顺序 |
| 停止后相关文件锁未释放 | 应用清理阶段 | 检查进程是否还在 | 确认无进程后删除锁文件 | 应用自行处理锁清理 |
这张表是多年排查经验的浓缩,遇到 stop 超时,先对着表定位阶段,再精准下手。
6.2 避坑清单:我踩过的那些坑
坑一:把 TimeoutStopSec 当作“整个 stop 的总时间”。很多文档含混地说“超时时间”,其实它只管 SIGTERM 到 SIGKILL 的窗口。ExecStop 和依赖处理都不归它管。所以调整前先确认当前卡在哪一环,不然调了等于白调。
坑二:ExecStop 里的timeout命令杀了外部程序,但 systemd 仍认为命令失败。因为 timeout 命令返回码是 124,systemd 默认 ExecStop 命令返回非零并不直接中断流程,但会产生一条告警日志。如果 Configured 的 IgnoreSIGPIPE 或 Restart 相关设置敏感,返回值可能引发其他问题。稳妥的做法是脚本最后兜底exit 0。
坑三:改了 drop-in 但忘了 daemon-reload。这是低级但高发的问题。改了 /etc/systemd/system 下的配置后,不执行 daemon-reload,新配置不生效,看起来“改了半天没用”。验证是否生效用 systemctl show 看结果,比靠感觉靠谱得多。
坑四:不知不觉把 Unit 文件的依赖写得过深。比如某服务 After 了一堆网络服务,又没有设置合理的超时,stop 时网络服务正好在重启中,就会被拖住。建议精简依赖,或给关键依赖设置单独的 stop 超时。
坑五:把 KillMode 改了之后引发子进程失控。KillMode 的常见值包括 control-group、mixed、process、none。默认是 control-group,stop 时 cgroup 里所有进程都会被处理。如果改成 process,systemd 只杀主进程,子进程会变成孤儿进程继续运行。很多服务“明明停了但端口还通”,就是这个原因。KillMode 调整要格外谨慎,默认值就是最稳妥的。
6.3 实测经验:一次典型修复的全过程还原
用一个我实际处理过的模拟案例串起整个方法论。某服务的 systemd unit 配置是:
[Unit] Description=Demo Application After=network.target [Service] Type=simple ExecStart=/opt/demo/app ExecStop=/opt/demo/notify.sh Restart=always现象:systemctl stop demo.service耗时 130 秒左右。
排查过程:
第一步,带时间戳复现,确认总耗时约 130 秒。
第二步,看 journal -f 输出,观察到 "Stopping Demo Application..." 后大约 100 秒才出现 "Stopping timed out. Killing."。初步定位:ExecStop 脚本耗时过长,加上后续 wait 窗口又超过了 TimeoutStopSec 默认 90 秒。
第三步,查看 notify.sh 内容,发现里面有 curl 调外部接口,curl 没有设置超时,DNS 解析和连接超时叠加,脚本最坏能跑 100 秒。
第四步,做临时处置:kill 掉 notify.sh 对应进程,stop 很快完成。
第五步,修改 drop-in 配置:
[Service] TimeoutStopSec=15 ExecStop= ExecStop=/usr/bin/timeout 5 /opt/demo/notify.sh这里把 ExecStop 用 timeout 包住,硬性上限 5 秒。同时把 TimeoutStopSec 从默认的 90 秒缩减到 15 秒,确保 SIGTERM 窗口也受控。
最后执行 daemon-reload 和 stop 验证,整个停止过程稳定在 6 秒以内。
这套流程走下来,既解决了眼前故障,又避免了未来复发。没有神奇的银弹,就是把每一段耗时控制住、给每一条路径一个明确的超时上限。
7. 写在最后:给系统设计者的几点提醒
说实话,systemctl stop会卡,问题的根源往往不在于 systemd 本身,而在于我们的服务设计和运维配置。systemd 提供了一套复杂而灵活的机制,但机制给你自由,也要求你对每个选择负责。
在设计新服务时,我会把“停止路径”当成和“启动路径”同等重要的一等公民来设计:信号处理要快,清理动作要短,外部依赖要异步,超时要明码标价。在运维老服务时,我会定期检查 unit 文件里的 TimeoutStopSec、ExecStop、KillMode,并用 stopwatch 脚本做一次“停止演练”,把耗时记录在案。这样真正出问题时,手上早有数据。
最后分享一个小技巧:如果你想对服务器上所有服务的停止时长做一次摸底,可以写一个简单的循环,逐个执行 stop 并计时,找出那些“停止耗时超标”的 unit。这个过程我在每次大版本变更前都会做一次,花不了多少时间,却能在关键时刻帮你避开大坑。