本节目标:用
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 与类型化模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。