《Python编程实战》5.3 生命周期、优雅关闭与健康探针

用 lifespan 管理应用级资源(连接池、后台任务),在 SIGTERM 下实现优雅关闭让在途请求跑完,并按 Kubernetes 语义设计 liveness 与 readiness 两类健康探针;全部用 uvicorn 0.54.0 真实进程实测。

本节目标:用 lifespan 统一管理启动/关闭资源,理解优雅关闭如何在 SIGTERM 下让在途请求体面收尾,并会按 liveness / readiness 语义设计健康探针。
适用版本:Python 3.12+(实测 3.14.6);FastAPI 0.143.0、Starlette 1.7.0、uvicorn 0.54.0

5.3 生命周期、优雅关闭与健康探针

前两节把应用骨架和请求链路搭好了。但进程本身还有一段生命周期没管:启动时要建哪些资源、关闭时怎么收场、编排系统怎么判断这个实例能不能接流量。这三件事做不好,滚动更新就会丢请求,扩缩容就会把流量打到还没准备好的实例上。这一节把它们讲透,并且用真实 uvicorn 进程实测。

5.3.1 lifespan:一个上下文管理器管住首尾

早期 FastAPI 用 @app.on_event("startup") / @app.on_event("shutdown") 两个装饰器,现在已不推荐——它们把「同一份资源的创建与销毁」拆到两个函数里,容易漏配对。取而代之的是 lifespan:一个异步上下文管理器,yield 之前是启动,之后是关闭。

from contextlib import asynccontextmanager

from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    # ---- 启动:建连接池、起后台任务、加载模型 ----
    res = Resources()
    await res.start()
    try:
        yield
    finally:
        # ---- 关闭:无论正常退出还是异常,都会走到这里 ----
        await res.stop()


app = FastAPI(lifespan=lifespan)

try/finally 是重点:即使启动之后抛了异常,关闭逻辑照样执行,资源不会泄漏。这比两个独立的事件回调可靠得多。

5.3.2 用 dataclass 装应用级资源

资源对象挂在 app.state 上,端点里通过 app.state.res 取用。用 dataclass 定义一个:

import asyncio
import contextlib
from dataclasses import dataclass, field


@dataclass
class Resources:
    ready: bool = False
    shutting_down: bool = False
    ticks: int = 0
    _stop: asyncio.Event = field(default_factory=asyncio.Event)
    _worker: asyncio.Task | None = None

    async def _heartbeat(self) -> None:
        while not self._stop.is_set():
            with contextlib.suppress(asyncio.TimeoutError):
                await asyncio.wait_for(self._stop.wait(), timeout=0.05)
            self.ticks += 1

    async def start(self) -> None:
        self._worker = asyncio.create_task(self._heartbeat())
        self.ready = True

    async def stop(self) -> None:
        self.shutting_down = True
        self.ready = False
        self._stop.set()
        if self._worker is not None:
            await self._worker          # 等后台任务收尾,避免任务泄漏

三个字段各司其职:ready 是就绪标志(给探针用),shutting_down 标记正在排空,ticks 是后台心跳计数(证明后台任务真的在跑)。注意 stop() 里 await self._worker——不等它结束就退出,asyncio 会打印「Task was destroyed but it is pending」告警。

5.3.3 实测:lifespan 的启动与关闭

用 TestClient 的上下文管理器触发 lifespan(with 进入 = startup,退出 = shutdown)。这一点和 5.1 节末尾提醒呼应:httpx.ASGITransport 不会跑 lifespan。

import time

from fastapi.testclient import TestClient

from app.lifespan_app import app

with TestClient(app) as client:
    print("live:", client.get("/healthz/live").status_code)
    r = client.get("/healthz/ready")
    print("ready:", r.status_code, r.json())
    time.sleep(0.12)                      # 让后台心跳跑几拍
    print("work:", client.get("/work").json())

print("after shutdown ready:", app.state.res.ready)
print("after shutdown worker done:", app.state.res._worker.done())

真实输出:

live: 200
ready: 200 {'status': 'ready', 'ticks': 0}
work: {'ticks': 2}
after shutdown ready: False
after shutdown worker done: True

读法:进入 with 后 ready 为真、后台任务在累加 ticks;退出 with 后 ready 被置假、后台任务已结束。生命周期闭环了。

5.3.4 优雅关闭:SIGTERM 下让在途请求跑完

容器滚动更新时,编排系统先给进程发 SIGTERM,等一段时间(terminationGracePeriodSeconds)再 SIGKILL。如果进程一收到 SIGTERM 就立刻退出,正在处理的请求会被拦腰截断——用户看到 502。优雅关闭就是:收到信号后停止接收新连接,把在途请求处理完,再退出。

uvicorn 0.54.0 默认就支持。写一个带 2 秒慢端点的服务,真实跑一遍:

@app.get("/slow")
async def slow() -> dict[str, str]:
    print("REQUEST: /slow 开始", flush=True)
    await asyncio.sleep(2.0)
    print("REQUEST: /slow 结束(未被中断)", flush=True)
    return {"status": "done"}

启动服务、发起慢请求、0.5 秒后发 SIGTERM,真实服务端日志:

INFO:     Waiting for application startup.
STARTUP: 资源就绪
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8731 (Press CTRL+C to quit)
REQUEST: /slow 开始
INFO:     Waiting for connections to close. (CTRL+C to force quit)
REQUEST: /slow 结束(未被中断)
INFO:     Waiting for application shutdown.
SHUTDOWN: 开始排空在途请求
SHUTDOWN: 资源已释放
INFO:     Application shutdown complete.

客户端拿到的是完整响应:

HTTP 200 耗时 2.016366s
{"status":"done"}

时间线清清楚楚:SIGTERM 到达 → uvicorn 进入「Waiting for connections to close」→ 在途的 /slow 跑满 2 秒返回 200 → 才执行 application shutdown。客户端耗时 2.016 秒,一分没少。

5.3.5 关闭期间:新连接被拒,在途请求完成

再验证关闭时的边界行为——排空期间发新请求会怎样:

--- 关闭中再发新请求 ---
new request: HTTP 000
new request: 连接失败(服务已在排空,不再接新连接)
--- 在途请求结果 ---
inflight: HTTP 200 2.009091s

结论清晰:排空期间不再接受新连接(000 表示连接失败),但在途请求正常完成。这正是优雅关闭想要的行为——给编排系统留出时间把流量切走。

两个调优旋钮:uvicorn 的 --timeout-graceful-shutdown <秒> 控制最长等待时间(默认无限制,建议设为略大于最慢接口的耗时),Kubernetes 的 terminationGracePeriodSeconds 必须大于它,否则进程还没排空就被 SIGKILL。

本地开发按 Ctrl+C 发的是 SIGINT,uvicorn 同样会走一遍优雅关闭——所以本地看到「Waiting for connections to close」是正常现象,不是卡死。想强制立刻退出,再按一次 Ctrl+C 即可(日志里的「CTRL+C to force quit」说的就是这个)。

5.3.6 健康探针:liveness 与 readiness

编排系统靠 HTTP 探针判断实例状态,两类探针语义完全不同:

探针回答的问题失败后果检查内容
liveness进程还活着吗?重启容器只查进程本身,不碰外部依赖
readiness现在能接流量吗?摘掉流量(不重启)查数据库/缓存等依赖是否就绪

最忌讳把两者写成一个接口。 如果 liveness 去查数据库,数据库抖动一下,编排系统会误判进程死了、把所有实例一起重启——雪崩。正确做法是分开:

from fastapi.responses import JSONResponse


@app.get("/healthz/live")
async def live() -> dict[str, str]:
    # 进程活着就返回 200;不依赖任何外部资源
    return {"status": "ok"}


@app.get("/healthz/ready")
async def ready() -> JSONResponse:
    res = app.state.res
    if not res.ready or res.shutting_down:
        return JSONResponse({"status": "not-ready"}, status_code=503)
    return JSONResponse({"status": "ready", "ticks": res.ticks})

实测依赖不可用时两者的表现——把就绪标志手动置假:

ready: 200 {'status': 'ready', 'ticks': 0}
dep down: 503 {'status': 'not-ready'}
live still: 200

依赖挂了 readiness 回 503,liveness 照样 200——编排系统只摘流量、不重启进程。等依赖恢复,readiness 自动转回 200,流量重新进来。这个区分是后端上生产的必修课。

5.3.7 探针设计清单

几条落地的经验:

  • 路径分开:/healthz/live 与 /healthz/ready,别复用 /health。
  • liveness 要极简:只返回 200,不做任何 I/O;哪怕多查一次数据库都可能触发误重启。
  • readiness 检查关键依赖:连接池能否拿到连接、缓存能否 ping 通;但要设超时,别让探针自己被拖死。
  • 启动慢的服务用 startupProbe 给足冷启动时间,避免还没加载完模型就被 liveness 判死。
  • 优雅关闭期间主动让 readiness 回 503(本例的 shutting_down 标志),让编排系统提前把流量切走。

小结

  • lifespan 用 try/finally 管住启动与关闭,取代了容易漏配对的 on_event;TestClient 的 with 会触发它,httpx.ASGITransport 不会。
  • 应用级资源挂 app.state,用 dataclass 封装;关闭时记得 await 后台任务,否则任务泄漏。
  • 优雅关闭 = 收到 SIGTERM 后停收新连接 + 等在途请求完成;uvicorn 默认支持,实测在途请求跑满 2 秒后完整返回 200。
  • 排空期间新连接被拒(000),在途请求不受影响。
  • liveness 只查进程(失败重启),readiness 查依赖(失败摘流量),绝不能合成一个接口。
  • 关闭期间主动让 readiness 回 503,能让编排系统更快把流量切走。

到这里,一个 Web 服务的「骨架 + 请求链路 + 生命周期」就完整了。但 service 层背后的数据还是内存字典——下一章我们把 ItemRepository 换成 SQLAlchemy 2.1.4 的类型化模型,让数据真正落地。

延伸阅读:Python Web 框架全景 、Python 微服务架构 。

阅读导航:上一节:路由、中间件与请求上下文 · 下一节:SQLAlchemy 2.x ORM 与类型化模型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时