bigywater.tech
Python · 110

优雅关闭:服务收到 SIGTERM 后如何完成收尾

容器被停止、服务被重启,收到的第一个信号通常是 SIGTERM。第 90 篇讲了「哪些退出方式不会清理」;这篇更进一步:一个真的在处理任务的服务,收到 SIGTERM 之后到底该怎么做,才能既不丢在途任务、又不会「怎么也停不下来」?六种做法各跑一遍,把结局摆在一起。

0.18 s
只设停止标志:从 SIGTERM 到干净退出(退出码 0,在途任务做完)
3 s 退不出去
处理函数里 sys.exit(0) + 普通工作线程:被父进程 SIGKILL 才结束
1.06 s
任务卡死时,1 秒看门狗把「永远等下去」变成有界的强制退出

做的事:一个真的在干活的服务

svc.py(核心) def worker(): while not stop.is_set(): # 每做完一个任务检查一次停止标志 try: i = jobs.get(timeout=0.05) except queue.Empty: return work(i) # 每个 0.3 秒 atexit.register(cleanup) # 关闭连接 / 刷盘 # 六种模式对 SIGTERM 的处理: # default 不注册处理函数 # flag handler 里 stop.set() # exit_nondaemon handler 里 sys.exit(0),工作线程是普通线程 # exit_daemon handler 里 sys.exit(0),工作线程是守护线程 # flag_stuck handler 里 stop.set(),但 job 3 永远卡住 # flag_watchdog handler 里 stop.set() + Timer(1.0, os._exit(1))

父进程在服务处理 job 3 的时候(0.75 秒)发出 SIGTERM,记录进程什么时候、以什么状态结束,以及日志里留下了什么。日志由服务自己用 os.write 直接写文件(不经缓冲),时间是相对服务启动的秒数。

验证一:默认处置——立刻被杀

实测 · 本机执行输出
  SIGTERM 发出后 0.00 秒进程结束 | 被信号 15 终止 | 自己退出
    0.000 job 1 开始
     0.305 job 1 完成
     0.306 job 2 开始
     0.610 job 2 完成
     0.610 job 3 开始

没有注册任何处理函数时,SIGTERM 直接终止进程(被信号 15 终止):job 3 只有「开始」没有「完成」,cleanup 没有执行(见第 90 篇)。服务什么都来不及做。

验证二:处理函数只设置停止标志——干净退出

实测 · 本机执行输出
  SIGTERM 发出后 0.18 秒进程结束 | 退出码 0 | 自己退出
    0.000 job 1 开始
     0.305 job 1 完成
     0.306 job 2 开始
     0.608 job 2 完成
     0.609 job 3 开始
     0.764 收到 SIGTERM: 只设置停止标志
     0.913 job 3 完成
     0.914 worker 线程结束, main 返回
     0.914 cleanup: 关闭连接 / 刷盘 (已完成 3 个, 队列里还剩 17 个未处理)

信号处理函数里只做一件最小的事:设置一个 threading.Event。工作线程做完 job 3(0.914 秒完成),下一轮循环检查到标志就退出;main 返回,atexit 里的 cleanup 执行,进程以 退出码 0 退出,从收到信号到退出 0.18 秒。已完成的 3 个任务是完整的,队列里剩下的 17 个可以在下次启动时继续。注意:信号处理函数只在主线程执行,所以工作线程要靠共享的 Event 得知要停。

验证三:处理函数里 sys.exit(0)——退不出去

实测 · 本机执行输出
  SIGTERM 发出后 3.01 秒进程结束 | 被信号 9 终止 | 由父进程 SIGKILL(等了 3.0 秒仍未退出)
    0.000 job 1 开始
     0.305 job 1 完成
     0.306 job 2 开始
     0.611 job 2 完成
     0.611 job 3 开始
     0.753 收到 SIGTERM: 处理函数里直接 sys.exit(0)
     0.913 job 3 完成
     0.914 job 4 开始
     1.219 job 4 完成
     1.219 job 5 开始
     1.521 job 5 完成
     1.521 job 6 开始
     1.826 job 6 完成
     1.827 job 7 开始
     2.132 job 7 完成
     2.132 job 8 开始
     2.437 job 8 完成
     2.437 job 9 开始
     2.741 job 9 完成
     2.742 job 10 开始
     3.043 job 10 完成
     3.043 job 11 开始
     3.347 job 11 完成
     3.347 job 12 开始
     3.652 job 12 完成
     3.652 job 13 开始

一种看起来很自然的写法:在 handler 里直接 sys.exit(0)。但 SystemExit 只在主线程里抛出;解释器退出时要等待所有非守护线程结束,而工作线程完全不知道要停,继续处理 job 4、5、6……。日志显示 SIGTERM 之后它一直在干活;最后是父进程等了 3.0 秒后发 SIGKILL 才终结它(被信号 9 终止),cleanup 没有执行,job 13 被中途杀掉。这就是很多服务「stop 之后还得等超时被强杀」的原因之一。

验证四:守护线程——退得快,但丢在途任务

实测 · 本机执行输出
  SIGTERM 发出后 0.02 秒进程结束 | 退出码 0 | 自己退出
    0.000 job 1 开始
     0.303 job 1 完成
     0.304 job 2 开始
     0.609 job 2 完成
     0.609 job 3 开始
     0.760 收到 SIGTERM: 处理函数里直接 sys.exit(0)
     0.760 cleanup: 关闭连接 / 刷盘 (已完成 2 个, 队列里还剩 17 个未处理)

把工作线程设成守护线程后,同样的 sys.exit(0) 让进程在 0.02 秒内退出、退出码 0,cleanup 也执行了——但 job 3 被丢弃(只有「开始」),cleanup 里的「已完成 2 个」也能印证。守护线程在解释器退出时被直接丢掉,不会有机会做完手头的事。

验证五:任务卡死时,只设标志会永远等下去

实测 · 本机执行输出
  SIGTERM 发出后 3.01 秒进程结束 | 被信号 9 终止 | 由父进程 SIGKILL(等了 3.0 秒仍未退出)
    0.000 job 1 开始
     0.305 job 1 完成
     0.305 job 2 开始
     0.611 job 2 完成
     0.611 job 3 开始
     0.765 收到 SIGTERM: 只设置停止标志

标志设置了,但 job 3 卡在下游调用里(这里用 sleep(60) 模拟),工作线程根本走不到「检查标志」那一步。进程一直不退出,直到父进程等了 3.0 秒后发 SIGKILL(被信号 9 终止),cleanup 没有执行。优雅关闭不能只靠对方的配合。

验证六:加一个有时限的看门狗

实测 · 本机执行输出
  SIGTERM 发出后 1.06 秒进程结束 | 退出码 1 | 自己退出
    0.000 job 1 开始
     0.300 job 1 完成
     0.301 job 2 开始
     0.606 job 2 完成
     0.606 job 3 开始
     0.754 收到 SIGTERM: 设置停止标志, 并启动 1.0 秒的看门狗
     1.760 看门狗超时: os._exit(1)

在 handler 里除了设置标志,再启动一个 threading.Timer(1.0, os._exit(1)):在这 1 秒的宽限期里,能做完的任务会做完;卡死的话,宽限期一到就强制退出——SIGTERM 发出 1.06 秒后进程结束,退出码 1(表明「不是正常收尾」),而不是等到编排系统的 SIGKILL。代价是 os._exit 不会执行 cleanup;要在强退前做最关键的收尾,得在 Timer 的回调里显式写。宽限期应当小于编排系统的强杀等待时间——这一点我没有验证具体的编排系统默认值。

总结:一个可靠的关闭流程

  1. 信号处理函数里只设置标志(Event),不要在里面做重活,也不要直接 sys.exit。
  2. 工作循环每做完一个任务检查标志;长任务内部也要有可以中断的检查点或超时。
  3. 给关闭加一个有时限的看门狗,别指望每个任务都会配合。
  4. 清理逻辑放在 atexit/finally,并且要知道 os._exit 和 SIGKILL 会跳过它们。

分步演示:同一个 SIGTERM,六种结局

日志与耗时取自上面的真实运行
优雅关闭
当前结局 / 最后一条日志

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 signal、threading、atexit、subprocess;所有时间取自服务自己的 time.perf_counter,数字每次略有不同
    • 没有验证:asyncio 下的 loop.add_signal_handler、多进程 worker 的信号传递、Docker / Kubernetes 的宽限期默认值与发送信号的具体行为、SIGINT 与 SIGTERM 同时处理
    京ICP备2026067502号-1京公网安备11010502063487号