中文 English

Ubuntu 关机要等 90 秒?Python asyncio 服务不肯接 SIGTERM 的排查与修复

发布时间: 2026-06-19
Ubuntu systemd systemd unit TimeoutStopSec SIGTERM SIGKILL Python asyncio drop-in journalctl 故障排查 运维 家庭实验室

先说结论

这台升级到 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 是保安

封面:把"关机"这件事画成"餐厅打烊前最后十分钟"。服务员 finalrd 1 秒起身,Python 顾客 smart-proxy / rule-proxy 卡在电话里不肯挂,保安 systemd 等 90 秒只能掐电话。这张图是整篇文章的"地图"。


一、问题背景:刚升级完的代理机,关机变慢了

时间线是这样的——

前一天下午,我刚把家里这台代理机从 Ubuntu 22.04 (jammy) 升级到了 24.04 (noble),内核 6.8。升级本身 27 分钟搞定,服务全在,听上去一切顺利。

第二天,我想重启一次进入新内核做"冷启动验证"。于是:

ssh root@host 'systemctl reboot'

然后——

我盯着本地终端的 SSH,整整 90 秒没有掉

Ubuntu 24.04 桌面

图 0 · 这台机器的主人是 Ubuntu 24.04 Noble Numbat(官方壁纸),系统服务里跑着自研 Python 代理 + v2ray。

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:同一进程前后两次关机的不同结局

图 2:同一次关机,t=0 systemd 发 SIGTERM;t=1s 老代码完全没响应;t=90s systemd 忍无可忍,发 SIGKILL,强杀。之后 reboot 才真正开始。整段"等"的时间全部浪费在 Python 进程上。

为什么会这样?我们需要先弄懂两件事:systemd 怎么 stop 一个 service,以及Python asyncio 收到 SIGTERM 时到底在干什么


二、问题分析: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.recvtime.sleepselect.select),Python 解释器根本不会主动去看"有没有信号"——它要等那个系统调用自己返回,而那个调用可能要等几小时。

这一段解释成日常——

systemd 像餐厅的保安,要打烊了挨桌说"先生,我们要关门了"。smart-proxy 这位顾客正在打电话,点头示意听到了,但电话对面的人在讲一个事故报告,他走不开。保安等 1 分钟、5 分钟、89 分钟,顾客还是没挂电话。第 90 分钟保安说"对不起,我得把电话线拔了"。这就是 SIGKILL。


三、问题根因:Python asyncio 的子任务被"困"在 socket 上

再看一眼我的 Python 服务——smart_proxy_failover.py,449 行。它是个 asyncio 服务,主要做两件事:

  1. 跑两个 TCP server——SOCKS5 在 1080、HTTP proxy 在 1081,接收家庭设备的代理请求。
  2. 跑两个 health check loop——每 10 秒向上游 vps 探活,做 failover。

每次收到一个新连接,asyncio 会创建一条 handle_socks_clienthandle_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 任务树:父任务要负责拆掉子任务

图 3:Python 进程的 asyncio 任务树。父任务 serve_forever 等 SIGTERM。它下面挂了 N 个 handle_socks_client / handle_http_client 子任务,每个子任务下面又挂了 asyncio.open_connection 协程。健康检查 loop 是另一支。SIGTERM 只到了父任务那一层,子任务的 socket 还在等,父任务 await self.stop() 也卡着。


四、解决方案:系统层和应用层各做一半

修这个问题,只动一边不够。

正确做法是两边都改,而且要让 Python 自己 cancel 它的子任务,而不是依赖 SIGKILL。

下面这张图是把整个修复策略画明白:

systemd drop-in:不打补丁也能给 unit 套新规则

图 4:左侧是原 unit 文件,不动一行;右侧是 systemd drop-in,只覆盖 4 个关键键值,实现"在不动原文件的前提下打补丁"。drop-in 是 systemd 给你留的安全位。

4.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

四个键值的含义:

rule-proxy.service 做一模一样的事。daemon-reload 后,这两个服务下次 stop 时就生效——不用重启服务、不用改原 unit 文件。

4.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_foreverstop_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_socksRuleProxy.handle_http)。

改完两件事,关机的时间就从 90 秒掉到了 1 秒。


五、验证:用一次真实的 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。

下面这张对比图是这个修复的核心:

SIGTERM vs SIGKILL 时间轴对比:90 秒变 1 秒

图 2 (回看):同样的进程、同样的服务、同样的 reboot 命令,只是 Python 加了 15 行主动 cancel 的代码,systemd 加了一个 4 行 drop-in。从 90 秒到 1 秒。

修复在 /opt/smart-proxy/ 里留了 .bak.20260619_103832 备份。如果新代码有问题想回滚,直接 cp 回去 systemctl restart 即可。


六、留给你的几条经验

这次排查让我重新认识了几件事:

1. TimeoutStopSec=90s 是个"最长等 90s"的兜底,不是"应该等 90s"的设计。 systemd 默认值是给"老派 daemon"准备的——那些会响应 SIGTERM 自己清理的服务。对 Python asyncio 这类"事件循环被 socket 卡住"的程序,默认 90s 就是"无论你写得有多烂,我也给你 90 秒体面地走"。真正体面的服务应该在毫秒级响应

2. SIGTERM 不是 SIGKILL,systemd 在给你机会。 如果你的服务在 SIGTERM 时挂住了,不要把责任推给 systemd,先想想是不是你的 signal handler 漏了什么。asyncio 的世界里,loop.add_signal_handler 只是入口,真正的活是去 cancel 那些卡在 I/O 上的协程

3. drop-in 是 systemd 给你留的安全补丁位。 /etc/systemd/system/<service>.service.d/override.conf 比直接改原 unit 文件安全得多——包升级不会冲掉它,可以版本控制,review 起来一目了然。所有"对 system unit 的小调整"都应该走 drop-in。

4. finalrd 不是元凶。 我一开始看到 Stopping finalrd.service 也耗了"一分钟",就去翻它的 source,后来发现那只是 journal 的视觉错位——它实际 <1 秒就结束了。遇到慢,先抓 journal 的精确时间戳,别靠肉眼估计


Q&A

Q1:为什么 systemd 默认 TimeoutStopSec=90s?

为了兼容老派守护进程(那些只用 signal.signal(signal.SIGTERM, handler) 设了个 handler,但 handler 里只打了一行 log 就 return 的程序)。90s 是给"进程可能正在做关键写入"的兜底,比如数据库刷盘。Python asyncio 这类"事件循环 + 长连接"程序不在这个假设里。

Q2:能不能直接把 TimeoutStopSec=5s,什么都不改 Python?

可以——会把 90 秒压到 5 秒,但本质还是 SIGKILL 强杀。正在飞的 SOCKS5 连接会被切断,客户端会拿到 Connection reset。体验差。drop-in 是兜底,不是治愈。

Q3:task.cancel() 是立刻结束吗?

不是。cancel() 只是给协程发了一个"该停了"的标记。协程要等到下一个 await才会真正抛 CancelledError。对挂在 socket.recv 上的协程,这个 await 就是 recv 那里——信号一来,socket 会被 asyncio 内部取消,recv 返回 CancelledError,协程进入 finally,关 socket,结束。所以 cancel() 是"毫秒级"而不是"立刻",但比 SIGKILL 干净

Q4:server.wait_closed() 也会卡吗?

偶尔会。如果还有 connection 在 write buffer 里没收完,它会等收完。最安全的做法是 server.close() 之后不再 await wait_closed(),直接退出。我的修复用了 asyncio.wait_for(self.stop(), timeout=10) 给它 10 秒兜底,过了就放弃。

Q5:为什么不用 loop.stop()loop.close()?

loop.stop() 只是让事件循环停止调度,不会取消已经创建的 task。你 stop 之后 Python 进程会立刻 exit,但子任务还在 socket.recv 上挂着——内核层面是干净的,但如果有 finally 块想做清理(比如写"已断开连接"日志),就跑不到了。主动 cancel 是更体面的做法

Q6:asyncio.all_tasks() 里会不会漏掉?

如果某些连接是在 Task 之外被 asyncio.ensure_future() 启动的,默认它们也算"all tasks"。但有些用了 asyncio.TaskGroup 的复杂服务可能有自己的 task 树,这种时候 all_tasks() 仍然有效,因为它返回的是整个事件循环里的全部 task,不依赖任何分组。唯一会漏的,是用裸 coroutine await coroutine 而不是 asyncio.create_task(coroutine) 的情况——那种根本就没成为 task

Q7:这套方法适用于非 asyncio 的普通 Python 服务吗?

适用,但代码不同。普通阻塞 Python 服务,你需要的是 signal.signal(signal.SIGTERM, handler),handler 里 sys.exit(0) 即可——阻塞服务收到 SIGTERM 时,Python 解释器本身会主动处理。只有事件循环"被卡住"的程序(asyncio、twisted、tornado)才需要这种主动 cancel 的处理

Q8:不重启服务能 reload 修复吗?

不能。Python 进程的代码段在内存里,你必须 systemctl restart smart-proxy.service 让它重新加载新代码。但 drop-in 改了之后,只要 systemctl daemon-reload,unit 配置就生效——下次这个服务 stop 时就用新规则。所以这两件事是分开的:

Q9:有没有更"工程化"的方案?

有。如果你做长期维护,可以写一个 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 服务套这个上下文,信号处理逻辑就一致了。这次我没做这层抽象,因为只有两个服务,抽象的代价大于重复的代价

Q10:这次修复是不是过度设计?

不算。这次只有一个 unit 慢。但今晚的"一个 unit 慢",明晚就可能是"五个 unit 慢"。先把"系统层兜底 + 应用层主动 cancel"这两件套搞清楚,以后再遇到 asyncio 服务卡 SIGTERM,5 分钟就能修。


参考资料

  1. systemd service 手册(TimeoutStopSec / KillMode / KillSignal):https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
  2. systemd unit drop-in 机制(service.d/*.conf):https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html
  3. Python 官方 asyncio 文档(loop.add_signal_handler / Task.cancel):https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.add_signal_handler
  4. PEP 492 — Coroutines with async and await:https://peps.python.org/pep-0492/
  5. systemd finalrd 手册:https://www.freedesktop.org/software/systemd/man/latest/finalrd.html
  6. journalctl 时间戳的正确读法:https://www.freedesktop.org/software/systemd/man/latest/journalctl.html
  7. systemd KillMode=mixed 的语义:https://www.freedesktop.org/software/systemd/man/latest/systemd.kill.html

本文涉及的所有内网 IP、内网域名、用户名、密码、配置路径均已在公开前做脱敏处理;具体网络拓扑以你自家环境为准。