优雅关闭改变的是服务接收与完成工作的方式,信号只是触发条件。它能够提供的保证比“绝不丢失任何东西”更有限,也更具体:停止接收新的业务工作,记录此前已经接受的任务,在有界时间内等待完成,并在期限耗尽时如实报告剩余工作。仅仅捕获一个信号,并不等于已经实现安全关闭。
信号开始的是协调过程
应用可以处理 SIGTERM,然后启动自己的关闭流程;SIGKILL 则不能捕获、阻塞或忽略。首先应确认谁真正收到了停止信号:如果外层包装进程收到信号却不转发,内部工作进程即使安装了正确的处理器,也可能根本没有机会执行它。
信号处理器应保持简短。Python 在主解释器的主线程执行信号处理函数,并明确警告不要在其中使用同步锁。实验只设置一个标记,把修改就绪、关闭准入和等待任务的工作留给普通控制流程,不在信号处理器里执行网络操作或等待工作线程。
应该尽早撤下就绪,让路由变更开始传播,但本地业务准入仍需要独立闸口,因为已有连接和传播延迟都可能带来后续请求。关闭闸口与登记已经接受的任务,必须使用一致的边界;否则看似已经停止接收,实际上却可能漏记同时进入的新任务。
截止期限约束完整生命周期
任务应一直记账到真正的完成点。调用方等待超时,不证明工作线程已经停止;响应已经发送,也不证明业务事务已经持久化。应根据操作契约选择完成边界,而不是把任意一次连接关闭都解释成成功,更不能仅凭客户端离开就提前回收在途任务。
进程内的经过时间应由单调时钟计算,并在监督器实际允许的时间内为清理留出余量。期限到达后,对剩余工作必须有明确的取消、放弃或可靠移交政策。从等待循环里跳出来,本身不会终止正在运行的线程,也不会撤销已经执行的业务效果。
正常排空和未完整关闭,应在记录中保持可区分。某个非零退出码是服务自身的约定,不是能够保证正确恢复的通用规则。客户端没有拿到响应时,仍可能需要根据幂等契约或业务记录确认结果,不能仅凭服务正在关闭就断言重试一定安全。
与监督器核对同一份契约
systemd 的 TimeoutStopSec 管理配置中的停止命令等待和服务停止等待;KillMode 选择被终止的进程范围,不决定排空能持续多久。mixed 模式首先向主进程发送终止信号,最后的强制终止覆盖控制组内剩余进程;主进程提前退出,也可能触发这一升级。真实 unit 中的停止钩子和通知延长机制必须一起检查。
Kubernetes 在执行已配置的 preStop 钩子之前,就开始计算终止宽限时间,钩子不是额外领取另一份独立预算。EndpointSlice 的终止状态更新与本地关闭同时推进,并不是完成一次全局同步屏障后,从此再也不会有请求到达。因此,端点变更不能替代应用自己的准入判断。
实际停止信号也可能受到镜像或运行时配置影响。应测试部署中的进程树和停止约定,而不是把本地直接执行程序的结果扩大为容器、sidecar 与代理协调全部正确的证据。这些组件各自的关闭顺序和宽限时间,需要沿真实调用链核对。
坏掉的样子
一个只修改就绪状态的关闭标记,可能让已有的持久连接继续不断提交新工作;反过来,立即切断全部活动连接,又会丢掉原本有时间完成的请求。停止新的业务准入,与完成已经获准的工作,是两个需要分别控制和观察的决定。
无界等待线程可能超过监督器允许的时长。守护线程允许进程退出时不等待它们,但这意味着放弃,不等于安全取消或事务回滚。库自身也有关闭契约:Python socketserver 的 shutdown 必须从不同于 serve_forever 的线程调用,否则会造成死锁,关闭路径反而永远无法结束。
应同时观察接受、完成、拒绝和未完成的工作,以及关闭耗时和退出结果。只记录一个“退出成功”,却不区分这些数量,可能掩盖真正丢掉的任务。验收对象应该是完整生命周期,而不是那一行信号注册代码或某个看起来正常的退出状态。
快速验证
保存为 shutdown_check.py,在类 Unix 系统上用 Python 3.10 以上版本执行 python3 shutdown_check.py。实验已在 macOS、Python 3.14.4 上运行。每种情况都创建自己的子进程和回环 HTTP 服务,SIGTERM 只发给该子进程;POST release 路由仅用于测试协调,不是建议上线的管理接口。
from concurrent.futures import ThreadPoolExecutor
from http.client import HTTPConnection, RemoteDisconnected
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import selectors
import signal
import subprocess
import sys
from threading import Condition, Event, Thread
import time
def child(grace):
state = {"draining": False, "active": 0}
condition, release = Condition(), Event()
stop_requested = False
def stop(signum, frame):
nonlocal stop_requested
stop_requested = True # No lock, I/O, or shutdown call in the signal handler.
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def reply(self, status, value):
body = json.dumps(value).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/state":
with condition:
snapshot = dict(state)
self.reply(200, snapshot)
elif self.path == "/readyz":
with condition:
ready = not state["draining"]
self.reply(200 if ready else 503, {"ready": ready})
elif self.path == "/work":
with condition:
admitted = not state["draining"]
if admitted:
state["active"] += 1
if not admitted:
self.reply(503, {"error": "draining"})
return
try:
if release.wait(timeout=5):
self.reply(200, {"done": True})
else:
self.reply(503, {"error": "fixture-timeout"})
finally:
with condition:
state["active"] -= 1
condition.notify_all()
else:
self.reply(404, {"error": "not-found"})
def do_POST(self):
if self.path == "/release": # Loopback-only test coordination, not workload admission.
self.reply(200, {"released": True})
release.set() # Finish the control reply before work can complete and the child exits.
else:
self.reply(404, {"error": "not-found"})
signal.signal(signal.SIGTERM, stop)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
server.daemon_threads = True # Explicitly permits abandonment in the deadline case.
serving = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01})
serving.start()
print(json.dumps({"port": server.server_port}), flush=True)
while not stop_requested:
time.sleep(0.005)
deadline = time.monotonic() + grace
with condition:
state["draining"] = True
while state["active"] and time.monotonic() < deadline:
condition.wait(timeout=max(0, deadline - time.monotonic()))
unfinished = state["active"]
server.shutdown() # Called outside the thread running serve_forever.
serving.join(timeout=2)
server.server_close() # Does not wait for daemon request threads.
assert not serving.is_alive()
print(json.dumps({"unfinished": unfinished, "deadline_expired": unfinished > 0}), flush=True)
return 3 if unfinished else 0 # Fixture policy, not a universal exit-code convention.
def request(port, route, method="GET"):
connection = HTTPConnection("127.0.0.1", port, timeout=3)
try:
connection.request(method, route)
response = connection.getresponse()
return response.status, json.loads(response.read())
finally:
connection.close()
def wait_state(port, predicate):
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
status, state = request(port, "/state")
assert status == 200
if predicate(state):
return state
time.sleep(0.01)
raise AssertionError("child did not reach the expected state")
def exercise(finish):
process = subprocess.Popen(
[sys.executable, __file__, "child", "3" if finish else "1"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
try:
with selectors.DefaultSelector() as selector:
selector.register(process.stdout, selectors.EVENT_READ)
assert selector.select(timeout=5), "child did not announce its local port"
port = json.loads(process.stdout.readline())["port"]
assert request(port, "/readyz") == (200, {"ready": True})
with ThreadPoolExecutor(max_workers=1) as pool:
active = pool.submit(request, port, "/work")
wait_state(port, lambda state: state["active"] == 1)
process.send_signal(signal.SIGTERM)
wait_state(port, lambda state: state["draining"])
assert request(port, "/readyz") == (503, {"ready": False})
assert request(port, "/work") == (503, {"error": "draining"})
assert request(port, "/state")[1]["active"] == 1
if finish:
assert request(port, "/release", "POST")[0] == 200
assert active.result(timeout=4) == (200, {"done": True})
output, errors = process.communicate(timeout=5)
assert errors == ""
record = json.loads(output)
assert process.returncode == (0 if finish else 3)
assert record == {"unfinished": 0 if finish else 1, "deadline_expired": not finish}
if not finish:
try:
active.result(timeout=4)
except (OSError, RemoteDisconnected):
pass
else:
raise AssertionError("abandoned work must not report a successful reply")
print("completed drain" if finish else "expired drain", record, "exit", process.returncode)
finally:
if process.poll() is None:
process.kill() # Safety cleanup targets only this fixture's child.
process.wait(timeout=5)
process.stdout.close()
process.stderr.close()
if __name__ == "__main__":
if sys.argv[1:2] == ["child"]:
raise SystemExit(child(float(sys.argv[2])))
exercise(True)
exercise(False)
两个场景都在 SIGTERM 之前接受一个请求。排空标记可见后,就绪返回 503,新的业务请求也得到 503,而先前获准的请求仍然计入在途工作,没有因为开始关闭就被假装清零。断言比较的是这些真实 HTTP 响应与子进程状态。
释放原请求后,它先收到 200,子进程再以零退出,并报告没有未完成工作。如果不释放,当前实验的一秒排空预算会耗尽;子进程报告一个未完成请求,以三退出,等待中的客户端拿不到成功响应。退出三只是这份实验明确选择的结果约定。
后一个场景刻意演示退出时放弃守护线程,不证明协作式取消、持久化、SIGKILL 清理或安全重放。实验没有运行 systemd unit、部署 Kubernetes 工作负载、执行真实事务或施加生产流量;这些更强保证不能从本机关闭结果中直接推导。
延伸阅读
来源
- Linux signal(7),终止信号与 SIGKILL 不可捕获的边界,已核验 2026-09-09。
- Python 信号,主线程信号处理及处理器内禁止使用锁的警告,已核验 2026-09-09。
- Python socketserver,关闭调用的线程要求及守护请求线程行为,已核验 2026-09-09。
- systemd.service,TimeoutStopSec 的停止等待与通知延长机制,已核验 2026-09-09。
- systemd.kill,KillMode 进程范围与终止升级,已核验 2026-09-09。
- Kubernetes:Pod 生命周期,preStop、终止宽限、停止信号与 EndpointSlice 变化,已核验 2026-09-09。