/Ubuntu 关机要等 90 秒?Python asyncio 服务不肯接 SIGTERM 的排查与修复/
作者:魔都水滴
公众号封面:关机要等 90 秒?Python asyncio 服务不肯接 SIGTERMTL;DR(先说结论):
这台刚升级到 24.04 的代理机,关机变成了一件折磨人的事:systemctl reboot 之后,SSH 不掉、灯不灭,要干等 90 秒 才进入真正的 shutdown 流程。journalctl 里很明确:smart-proxy.service: State 'stop-sigterm' timed out. Killing. —— systemd 给了 SIGTERM,等 90 秒没人响应,只能 SIGKILL 强杀。
根因不是 systemd 的错,也不是 Ubuntu 的错,是 Python 的 asyncio 服务在收到 SIGTERM 后,没有真正去结束它的子任务——它正卡在某个 socket.recv 上,Python 解释器根本不主动去看"有人叫我走"。
修复分两步,缺一不可:(1) 给两个 Python unit 写 systemd drop-in,把 TimeoutStopSec 从默认的 90s 缩到 20s;(2) 在 Python 服务里主动遍历 asyncio.all_tasks(),收到 SIGTERM 后把所有 in-flight 的 handler cancel() 掉,再用 asyncio.wait_for(self.stop(), timeout=10) 包一层做兜底。改完之后,同样一次 reboot,从 90 秒掉到 1 秒。
关机像打烊:系统是餐厅,服务是顾客,sshd 是保安
1
这篇文章解决什么问题
前一天下午,我刚把家里这台代理机从 Ubuntu 22.04(jammy)升级到了 24.04(noble),内核 6.8。升级本身 27 分钟搞定,服务全在,听上去一切顺利。
第二天,我想重启一次进入新内核做"冷启动验证"。于是:
ssh root@host 'systemctl reboot'
然后——
我盯着本地终端的 SSH,整整 90 秒没有掉。
90 秒后,SSH 终于断了。等了一会儿,再 ping,主机起来了。我登回去 journalctl -b -1,看到这条:
Oct 23 10:34:01 host systemd[1]: Stopping smart-proxy.service ...
Oct 23 10:35:31 host systemd[1]: smart-proxy.service: State 'stop-sigterm' timed out. Killing.
Oct 23 10:35:31 host systemd[1]: smart-proxy.service: Killing process 728 (python3) with signal SIGKILL.
Oct 23 10:35:31 host systemd[1]: smart-proxy.service: Main process exited, code=killed, status=9/KILL
Oct 23 10:35:31 host systemd[1]: smart-proxy.service: Failed with result 'timeout'.
Oct 23 10:35:31 host systemd[1]: Stopped smart-proxy.service.
注意时间:从 10:34:01 systemd 说"我要 stop 它"开始,到 10:35:31 systemd 忍无可忍 SIGKILL,中间正好 90 秒。这就是 systemd 的默认 TimeoutStopSec=90s。
下面那张图把这条时间线画清楚了:
SIGTERM vs SIGKILL:同一进程前后两次关机的不同结局为什么会这样?我们需要先弄懂两件事:systemd 怎么 stop 一个 service,以及Python asyncio 收到 SIGTERM 时到底在干什么。
2
这版为公众号做了什么调整
- 拿掉博客原文里所有手工标题编号(
一、、二、、1.1、2.1 之类),让 KNB 转换器自动加序号; - 把博客版的 4 张 SVG 示意图换成 4 张 1080×1440 的 PNG 图卡,字号加大到 24-32px,适合在微信里直接缩放;
- 独立制作了一张公众号题图(900×383,90 秒 → 1 秒的对比);
- 给"问题背景"、"问题分析"、"问题根因"、"解决方案"、"Q&A"几大段加
### 子标题,方便手机端目录跳转; - 删除了原文中段段都有的「图 N:xx」说明文字,改由图卡自带的标题承担。
3
问题分析:systemd 发的是"请帖",不是"执行令"
很多人(包括最初的我)以为,systemd stop 一个 service 就是"立刻杀死进程"。不是。
systemd 的"温柔"是有原因的:它要让进程有机会做清理工作。比如关数据库,正确的做法是"刷 dirty page、写 WAL、关 socket",而不是被 SIGKILL 一下,丢数据。
systemd 的 stop 流程是这样:
TimeoutStopSec (默认 90s)
<------------------------------>
t=0 t=1s t=90s
| | |
| SIGTERM | 进程仍未退出? | 仍未退出?
| -----> | -----> | ----->
| (客气地) | (继续客气等) | (不客气了)
| | |
| | (进程主动 exit,OK 收工) | SIGKILL
| | <----- | ----->
| | ExitCode=0 | ExitCode=137
v v v
systemd: stop systemd: Deactivated systemd: failed 'timeout'
SIGTERM 是一个协商信号——它说"我希望你离开,你方便的时候收拾一下再走"。如果进程乖乖 exit,systemd 就 happy。如果 90 秒过去还没走,systemd 升级到 SIGKILL——这个信号不能被 catch、不能被 block,进程立刻被内核杀掉。
这里有一个很多人没意识到的细节:systemd 在 t=0 发完 SIGTERM 之后,进程是否有"看到"这个信号,完全取决于进程在干什么。如果进程此刻正在执行一段纯 Python 代码,信号一来就被处理;但如果进程此刻正阻塞在系统调用上(比如 socket.recv、time.sleep、select.select),Python 解释器根本不会主动去看"有没有信号"——它要等那个系统调用自己返回,而那个调用可能要等几小时。
这一段解释成日常——
systemd 像餐厅的保安,要打烊了挨桌说"先生,我们要关门了"。smart-proxy 这位顾客正在打电话,点头示意听到了,但电话对面的人在讲一个事故报告,他走不开。保安等 1 分钟、5 分钟、89 分钟,顾客还是没挂电话。第 90 分钟保安说"对不起,我得把电话线拔了"。这就是 SIGKILL。
4
问题根因:Python asyncio 的子任务被"困"在 socket 上
再看一眼我的 Python 服务——smart_proxy_failover.py,449 行。它是个 asyncio 服务,主要做两件事:
- 跑两个 TCP server——SOCKS5 在 1080、HTTP proxy 在 1081,接收家庭设备的代理请求。
- 跑两个 health check loop——每 10 秒向上游 vps 探活,做 failover。
每次收到一个新连接,asyncio 会创建一条 handle_socks_client 或 handle_http_client 的协程。这条协程会做一件事:asyncio.open_connection() 连上游 vps,然后 socket.recv() 读上游响应。
服务跑了一晚上之后,几十条这种协程同时挂在 socket.recv 上——它们都在等上游 vps 回包,可能是 keepalive,可能是长轮询,可能根本就是死连接——反正没回。
收到 SIGTERM 的时候,Python 解释器的事件循环正在 select() 等 I/O。select() 不知道 SIGTERM,它只等文件描述符。SIGTERM 排队等着,但只要有一个 socket 在 select 上,Python 就不会去看信号队列。
结果就是:90 秒里,SIGTERM 信号被塞在队列里,Python 根本不知道有人在叫它。直到 systemd SIGKILL 把它整个撕掉,Python 才"知道"自己死了。
把这件事画成任务树就清楚了:
asyncio 任务树:父任务要负责拆掉子任务
5
解决方案:系统层和应用层各做一半
修这个问题,只动一边不够。
- 只改 systemd 的
TimeoutStopSec=20s:把 90 秒缩到 20 秒,看起来快了,但本质上还是 SIGKILL 强杀——任何在飞行中的连接都会被截断,SOCKS5 客户端会收到 Connection reset,HTTP 客户端会收到半个 HTTP 响应。体验不好。 - 只改 Python 加 signal handler:Python 不响应 SIGTERM 的话,systemd 永远要等 90 秒。
正确做法是两边都改,而且要让 Python 自己 cancel 它的子任务,而不是依赖 SIGKILL。
下面这张图是把整个修复策略画明白:
systemd drop-in:在不改原 unit 的前提下打个补丁5.1
系统层:写 systemd drop-in
/etc/systemd/system/<service>.service 是不能随便改的——下次包升级,有可能被冲掉。systemd 提供了一种安全补丁位:/etc/systemd/system/<service>.service.d/ 目录,里面放 *.conf 文件,会自动叠加到 unit 上。
为 smart-proxy.service 建一个 drop-in:
mkdir -p /etc/systemd/system/smart-proxy.service.d
cat > /etc/systemd/system/smart-proxy.service.d/override.conf <<'EOF'
[Service]
TimeoutStopSec=20s
KillMode=mixed
KillSignal=SIGTERM
FinalKillSignal=SIGKILL
EOF
systemctl daemon-reload
四个键值的含义:
TimeoutStopSec=20s:最坏情况 20 秒,无论 Python 怎么摆烂,systemd 到点就 SIGKILL。KillMode=mixed:SIGTERM 时只杀主进程,不杀整个 cgroup(留点余地给 Python 自己清理);SIGKILL 时杀整个 cgroup。KillSignal=SIGTERM:先发 SIGTERM(默认就是,但显式写出来更清楚)。FinalKillSignal=SIGKILL:超时之后升级到 SIGKILL。
对 rule-proxy.service 做一模一样的事。daemon-reload 后,这两个服务下次 stop 时就生效——不用重启服务、不用改原 unit 文件。
5.2
应用层:Python 自己 cancel 子任务
drop-in 把"最长等多久"从 90 秒压到 20 秒,但更彻底的做法是让 Python 在收到 SIGTERM 的 1 秒内主动结束自己。
asyncio 服务的 serve_forever 原本长这样(简化):
async def serve_forever(self) -> None:
await self.start()
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop_event.set)
await stop_event.wait()
await self.stop()
逻辑没问题——收到 SIGTERM 后 stop_event.set(),然后 await self.stop()。
真正的问题在 self.stop() 里:
async def stop(self) -> None:
for server in self.servers:
server.close()
await server.wait_closed() # ← 这步会卡住!
for task in self.background_tasks:
task.cancel()
if self.background_tasks:
await asyncio.gather(*self.background_tasks, return_exceptions=True)
server.close() 之后,新连接不再接受,但已经在跑的连接(handle_socks_client 协程)还在跑。这些协程挂在 socket.recv 上,而 socket.recv 不响应 SIGTERM,Python 不知道这些协程应该被取消。
所以 stop_event 被 set 之后,事件循环仍然在 select,仍然不响应任何东西。 只有等 systemd 90 秒到点强杀。
修复方法是:在 serve_forever 的 stop_event.wait() 之后,显式遍历所有正在跑的 task,匹配名字,把 in-flight 的连接 handler 强制 cancel:
async def serve_forever(self) -> None:
await self.start()
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop_event.set)
try:
await stop_event.wait()
finally:
# 收到信号后,主动把还在跑的"接客协程"砍掉
me = asyncio.current_task()
for task in asyncio.all_tasks():
if task is me:
continue
if task.get_coro().__qualname__ in (
"SmartProxyServer.handle_socks_client",
"SmartProxyServer.handle_http_client",
):
task.cancel()
# stop() 本身也套个超时,防止 server.wait_closed() 也卡
try:
await asyncio.wait_for(self.stop(), timeout=10)
except asyncio.TimeoutError:
LOGGER.warning("self.stop() timed out; forcing exit")
task.cancel() 会让对应的协程在下一个 await 处抛 CancelledError,通常就是在 socket.recv 那——抛完之后协程会进入 finally 分支关掉自己的 socket,然后 return,整条链路就被干净地拆掉了。
rule_proxy.py 做一模一样的修复,只是类名不同(RuleProxy.handle_socks、RuleProxy.handle_http)。
改完两件事,关机的时间就从 90 秒掉到了 1 秒。
6
验证:用一次真实的 reboot 来看时间
修复后,我触发了一次真实的 reboot,看 journalctl:
10:41:21 smart-proxy 启动监听 1080/1081
10:41:24 rule-proxy 启动监听 1090/1091
10:41:32 systemd 开始 stop rule-proxy
10:41:32 rule-proxy: Deactivated successfully ← <1s
10:41:32 systemd 开始 stop smart-proxy
10:41:32 smart-proxy: "received stop signal, shutting down"
10:41:32 smart-proxy: Deactivated successfully ← <1s
10:41:33 finalrd: Deactivated successfully
10:41:33 Reached target shutdown.target
10:41:45 下一次 boot 的第一条 log
rule-proxy 和 smart-proxy 各自在 1 秒内 Deactivated,而不再是卡 90 秒被 SIGKILL。
修复在 /opt/smart-proxy/ 里留了 .bak.20260619_103832 备份。如果新代码有问题想回滚,直接 cp 回去 systemctl restart 即可。
7
留给你的几条经验
这次排查让我重新认识了几件事:
TimeoutStopSec=90s 是个"最长等 90s"的兜底,不是"应该等 90s"的设计。 systemd 默认值是给"老派 daemon"准备的——那些会响应 SIGTERM 自己清理的服务。对 Python asyncio 这类"事件循环被 socket 卡住"的程序,默认 90s 就是"无论你写得有多烂,我也给你 90 秒体面地走"。真正体面的服务应该在毫秒级响应。
SIGTERM 不是 SIGKILL,systemd 在给你机会。 如果你的服务在 SIGTERM 时挂住了,不要把责任推给 systemd,先想想是不是你的 signal handler 漏了什么。asyncio 的世界里,loop.add_signal_handler 只是入口,真正的活是去 cancel 那些卡在 I/O 上的协程。
drop-in 是 systemd 给你留的安全补丁位。/etc/systemd/system/<service>.service.d/override.conf 比直接改原 unit 文件安全得多——包升级不会冲掉它,可以版本控制,review 起来一目了然。所有"对 system unit 的小调整"都应该走 drop-in。
finalrd 不是元凶。 我一开始看到 Stopping finalrd.service 也耗了"一分钟",就去翻它的 source,后来发现那只是 journal 的视觉错位——它实际 <1 秒就结束了。遇到慢,先抓 journal 的精确时间戳,别靠肉眼估计。
8
Q&A
8.1
为什么 systemd 默认 TimeoutStopSec=90s?
为了兼容老派守护进程(那些只用 signal.signal(signal.SIGTERM, handler) 设了个 handler,但 handler 里只打了一行 log 就 return 的程序)。90s 是给"进程可能正在做关键写入"的兜底,比如数据库刷盘。Python asyncio 这类"事件循环 + 长连接"程序不在这个假设里。
8.2
能不能直接把 TimeoutStopSec=5s,什么都不改 Python?
可以——会把 90 秒压到 5 秒,但本质还是 SIGKILL 强杀。正在飞的 SOCKS5 连接会被切断,客户端会拿到 Connection reset。体验差。drop-in 是兜底,不是治愈。
8.3
task.cancel() 是立刻结束吗?
不是。cancel() 只是给协程发了一个"该停了"的标记。协程要等到下一个 await 点才会真正抛 CancelledError。对挂在 socket.recv 上的协程,这个 await 就是 recv 那里——信号一来,socket 会被 asyncio 内部取消,recv 返回 CancelledError,协程进入 finally,关 socket,结束。所以 cancel() 是"毫秒级"而不是"立刻",但比 SIGKILL 干净。
8.4
server.wait_closed() 也会卡吗?
偶尔会。如果还有 connection 在 write buffer 里没收完,它会等收完。最安全的做法是 server.close() 之后不再 await wait_closed(),直接退出。我的修复用了 asyncio.wait_for(self.stop(), timeout=10) 给它 10 秒兜底,过了就放弃。
8.5
为什么不用 loop.stop() 或 loop.close()?
loop.stop() 只是让事件循环停止调度,不会取消已经创建的 task。你 stop 之后 Python 进程会立刻 exit,但子任务还在 socket.recv 上挂着——内核层面是干净的,但如果有 finally 块想做清理(比如写"已断开连接"日志),就跑不到了。主动 cancel 是更体面的做法。
8.6
asyncio.all_tasks() 里会不会漏掉?
如果某些连接是在 Task 之外被 asyncio.ensure_future() 启动的,默认它们也算"all tasks"。但有些用了 asyncio.TaskGroup 的复杂服务可能有自己的 task 树,这种时候 all_tasks() 仍然有效,因为它返回的是整个事件循环里的全部 task,不依赖任何分组。唯一会漏的,是用裸 coroutine await coroutine 而不是 asyncio.create_task(coroutine) 的情况——那种根本就没成为 task。
8.7
这套方法适用于非 asyncio 的普通 Python 服务吗?
适用,但代码不同。普通阻塞 Python 服务,你需要的是 signal.signal(signal.SIGTERM, handler),handler 里 sys.exit(0) 即可——阻塞服务收到 SIGTERM 时,Python 解释器本身会主动处理。只有事件循环"被卡住"的程序(asyncio、twisted、tornado)才需要这种主动 cancel 的处理。
8.8
不重启服务能 reload 修复吗?
不能。Python 进程的代码段在内存里,你必须 systemctl restart smart-proxy.service 让它重新加载新代码。但 drop-in 改了之后,只要 systemctl daemon-reload,unit 配置就生效——下次这个服务 stop 时就用新规则。所以这两件事是分开的:
- 立即:
systemctl daemon-reload + systemctl restart smart-proxy rule-proxy - 永久: drop-in 文件留在盘上,下次升级也不会被冲掉
8.9
有没有更"工程化"的方案?
有。如果你做长期维护,可以写一个 asyncio.ShutdownContext 上下文管理器:
@asynccontextmanager
async def graceful_shutdown(handler_names, timeout=10):
yield
# 收尾逻辑
me = asyncio.current_task()
for t in asyncio.all_tasks():
if t is me: continue
if t.get_coro().__qualname__ in handler_names:
t.cancel()
try:
await asyncio.wait_for(server.close_all(), timeout=timeout)
except asyncio.TimeoutError:
pass
每个 asyncio 服务套这个上下文,信号处理逻辑就一致了。这次我没做这层抽象,因为只有两个服务,抽象的代价大于重复的代价。
8.10
这次修复是不是过度设计?
不算。这次只有一个 unit 慢。但今晚的"一个 unit 慢",明晚就可能是"五个 unit 慢"。先把"系统层兜底 + 应用层主动 cancel"这两件套搞清楚,以后再遇到 asyncio 服务卡 SIGTERM,5 分钟就能修。
9
参考资料
- systemd
service 手册(TimeoutStopSec / KillMode / KillSignal):https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html ( https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html ) - systemd unit drop-in 机制(
service.d/*.conf):https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html ( https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html ) - Python 官方 asyncio 文档(
loop.add_signal_handler / Task.cancel):https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.add_signal_handler ( https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.add_signal_handler ) - PEP 492 — Coroutines with async and await:https://peps.python.org/pep-0492/ ( https://peps.python.org/pep-0492/ )
- systemd finalrd 手册:https://www.freedesktop.org/software/systemd/man/latest/finalrd.html ( https://www.freedesktop.org/software/systemd/man/latest/finalrd.html )
- journalctl 时间戳的正确读法:https://www.freedesktop.org/software/systemd/man/latest/journalctl.html ( https://www.freedesktop.org/software/systemd/man/latest/journalctl.html )
- systemd
KillMode=mixed 的语义:https://www.freedesktop.org/software/systemd/man/latest/systemd.kill.html ( https://www.freedesktop.org/software/systemd/man/latest/systemd.kill.html )
本文涉及的所有内网 IP、内网域名、用户名、密码、配置路径均已在公开前做脱敏处理;具体网络拓扑以你自家环境为准。