FastAPIのlifespanは、アプリが最初のリクエストを受ける前に1回だけ動く処理と、停止する直前に1回だけ動く処理を、1つの関数にまとめて書く仕組みです。DBの接続プール、外部APIのHTTPクライアント、機械学習モデルのように、起動時に用意して全リクエストで共有したい資源の置き場所になります。この記事では、FastAPI 0.162.7で接続プールとモデルをlifespanで読み込み、request.stateからエンドポイントへ渡す実装を示す構成です。非推奨の@app.on_event("startup")からの移行手順、ルーターやマウントしたアプリでの動作範囲、テストやDockerで終了処理が動かない原因の切り分けも扱います。
まとめ:FastAPIのlifespanで起動処理と終了処理を書くときの結論
新しく書くコードではon_eventを使わず、@asynccontextmanagerを付けた関数をFastAPI(lifespan=lifespan)に渡します。yieldより前が起動処理、後ろが終了処理です。Starletteは1.0でon_eventを削除しており、FastAPIが互換のために自前で残している状態です。
共有する資源はyieldで辞書として返し、エンドポイントではrequest.stateから受け取ります。グローバル変数に入れるよりテストで差し替えやすく、型の指定も可能です。lifespanを渡した時点でon_eventのハンドラは呼ばれなくなるため、移行は一度に済ませます。
置くのは「アプリ全体で1つ持つもの」だけです。リクエストごとのDBセッションはDependsのyield依存に、テーブル作成はマイグレーションに回します。テストではTestClientをwith文で使い、DockerではCMDをexec形式で書かないと、lifespanが動かないまま気付けません。
FastAPI 0.162系でlifespanを動かす検証環境と最小コードの書き方
まずは起動と終了のタイミングを、ログで目に見える形にして確かめます。
fastapi[standard]0.162.7とSQLAlchemy 2.1の導入手順
2026年11月14日時点のPyPIで、FastAPIの最新は0.162.7(2026年11月13日公開)です。動作要件はPython 3.11以上に上がっています。後半でDBの接続プールを使うため、SQLAlchemy 2.1系と非同期のSQLiteドライバaiosqliteも一緒に入れます。FastAPIの起動そのものが初めてなら、FastAPIの最短の起動手順とasync/defの使い分けを先に済ませてください。
# 仮想環境を作り、FastAPIと非同期DBドライバ、テスト用のpytestを入れる
python3 -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]==0.162.7" "sqlalchemy[asyncio]==2.1.8" aiosqlite pytest
# main.py を置いたディレクトリで開発サーバーを起動する
fastapi dev main.py
fastapi[standard]には開発サーバーのUvicornと、HTTPクライアントのhttpxが含まれます。後の例で外部API用のクライアントをlifespanで作るときに、このhttpxを使います。
asynccontextmanagerのyieldを境に起動と終了を分ける基本形
公式のLifespanイベントに沿った最小の形は次のとおりです。
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
print("起動処理:最初のリクエストより前に1回だけ動く")
yield
print("終了処理:停止の直前に1回だけ動く")
app = FastAPI(lifespan=lifespan)
@app.get("/")
def root():
return {"ok": True}
fastapi dev main.pyで起動すると、Uvicornの「Waiting for application startup.」の直後に起動処理の行が出て、そのあと「Application startup complete.」が続きます。Ctrl+Cで止めると、終了処理の行が出てからプロセスが終わります。
Starletteのlifespanの説明にあるとおり、起動処理が終わるまでリクエストは受け付けません。モデルの読み込みに30秒かかるなら、その30秒はヘルスチェックにも応答しない時間になります。yieldの前に置く処理の重さは、そのまま起動時間です。
lifespanでDB接続プールとMLモデルを読みrequest.stateで渡す実装
ここからは、DBの接続プール、外部API用のHTTPクライアント、推論モデルの3つをlifespanで用意し、エンドポイントで使うAPIを1ファイルで書きます。
# main.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, TypedDict
import httpx
from fastapi import Depends, FastAPI, Request
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
class State(TypedDict):
engine: AsyncEngine
http: httpx.AsyncClient
model: dict
def load_model() -> dict:
# 実際は joblib.load などで学習済みの重みを読む。ここでは係数だけを持つ
return {"coef": 42.0}
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[State]:
engine = create_async_engine("sqlite+aiosqlite:///./app.db")
try:
async with httpx.AsyncClient(timeout=10.0) as http:
yield {"engine": engine, "http": http, "model": load_model()}
finally:
# 接続プールを閉じる。呼ばないと終了時に警告が出る
await engine.dispose()
app = FastAPI(lifespan=lifespan)
def get_engine(request: Request) -> AsyncEngine:
return request.state.engine
EngineDep = Annotated[AsyncEngine, Depends(get_engine)]
@app.get("/health/db")
async def db_health(engine: EngineDep):
async with engine.connect() as conn:
value = await conn.scalar(text("SELECT 1"))
return {"db": value}
@app.get("/predict")
def predict(x: float, request: Request):
return {"result": x * request.state.model["coef"]}
起動したら、別の端末から次のように呼びます。
# 接続プールから1本借りて SELECT 1 を返す
curl "http://127.0.0.1:8000/health/db"
# lifespan で読み込んだ係数を使って推論する
curl "http://127.0.0.1:8000/predict?x=2"
lifespan stateの辞書をrequest.stateへ渡す仕組み
yieldに辞書を渡すと、その中身が各リクエストのrequest.stateに入ります。FastAPIは0.94.0(2023年3月)でこの「lifespan state」に対応しました。ASGIのLifespan Protocolは、サーバーがこの状態を各リクエストへ浅いコピーで渡すと定めています。
浅いコピーなので、request.state.model = 別の値と代入しても他のリクエストには影響しません。一方、辞書やクライアントの中身を書き換えると全リクエストに見えます。lifespanで渡すのは、接続プールやクライアントのように「共有してよいもの」に限ります。
app.state.engine = engineと書く古い方法も動きますが、アプリのオブジェクトをimportしないと取り出せないため、テストで差し替えにくくなります。上の例ではget_engineという依存関数を1枚挟みました。エンドポイントはrequest.stateの存在を知らずに済み、テストではDependsのdependency_overridesでの差し替えがそのまま使えます。
create_async_engineのプール終了時にdisposeを待つ理由
非同期エンジンは、作ったlifespanの終了処理でawait engine.dispose()を呼んで閉じます。SQLAlchemyのasyncioのドキュメントは、AsyncEngineがスコープを外れる前にdispose()をawaitで呼ぶことを推奨する内容です。ガベージコレクションの中ではawaitできないため、閉じ忘れると「Event loop is closed」に似たRuntimeErrorの警告が出ます。
上の例でtryとfinallyを使っているのは、yieldの後ろで例外が起きてもdispose()まで到達させるためです。PostgreSQLのように接続数の上限があるDBでは、create_async_engine(url, pool_size=10, max_overflow=5)のようにプールの大きさもここで決めます。セッションの作り方とcommitの置き場所は、SQLAlchemy 2.1でのセッション設計を実測した解説に譲ります。
MLモデルの読込がワーカー数の分だけメモリを使う前提と対処方法
lifespanは、アプリ全体で1回ではなく、ワーカーのプロセスごとに1回動きます。fastapi run --workers 4で起動すると、モデルの読み込みも4回走り、メモリも4倍使います。1GBのモデルなら4GBです。
対処は2つあります。1つは、ワーカー数をメモリから逆算して決めることです。もう1つは、推論を別のサーバー(専用の推論サービスや、GPUを持つ別コンテナ)に分け、FastAPIのlifespanではそこへのHTTPクライアントだけを作ることです。モデルが数百MBを超えるなら、後者を選びます。ワーカー数と再起動の運用は、Uvicornのワーカー数と本番設定の解説で扱っています。
非推奨on_eventからlifespanへの移行手順とStarlette 1.0の影響
古い記事や既存のコードには@app.on_event("startup")がまだ多く残っています。動くうちに書き換えておく理由と手順をまとめます。
Starlette 1.0がon_eventを削除しFastAPI側で残した経緯
FastAPIの土台であるStarletteは、1.0.0rc1でon_startupとon_shutdownの引数、on_event()、add_event_handler()を削除しました(Starletteのリリースノート)。FastAPIはその前に、on_eventを自前で実装し直すPRを0.128.3で取り込んでいます。経緯をFastAPIのリリースノートと合わせて表にします。
| 版(公開日) | lifespanとon_eventに関する変更 |
|---|---|
| FastAPI 0.93.0(2023-03) | lifespan引数を追加 |
| FastAPI 0.94.0(2023-03) | lifespan stateに対応 |
| FastAPI 0.112.2(2024-08) | ルーターのlifespanを合成 |
| FastAPI 0.128.3(2026-02) | on_eventを自前で再実装 |
| Starlette 1.0.0(2026-03) | on_eventと関連引数を削除 |
FastAPI 0.162.7のソースでも、on_eventには「deprecated, use lifespan event handlers instead」という非推奨の印が付いたままです。ソースのコメントには、lifespanの仕組みが改善されたら削除する旨のTODOも残っています。今は動いても、次の大きな版で消える前提で書き換えます。
startupとshutdownの組をlifespanの1関数へ書き換える手順
移行前のよくある形は、起動で作ったものをapp.stateに入れ、終了で閉じる2つの関数です。
# 移行前:非推奨の on_event
@app.on_event("startup")
async def startup():
app.state.engine = create_async_engine(DB_URL)
@app.on_event("shutdown")
async def shutdown():
await app.state.engine.dispose()
# 移行後:lifespan の1関数にまとめる
@asynccontextmanager
async def lifespan(app: FastAPI):
engine = create_async_engine(DB_URL)
try:
yield {"engine": engine}
finally:
await engine.dispose()
app = FastAPI(lifespan=lifespan)
書き換えの手順は次のとおりです。
startupの中身をyieldの前へ、shutdownの中身をfinallyの中へ移すapp.state.xxx = ...をローカル変数に変え、yieldの辞書で返す- エンドポイント側の
request.app.state.xxxをrequest.state.xxxに置き換える on_eventのデコレーターをすべて消し、FastAPI(lifespan=lifespan)を渡す
起動処理と終了処理を1つの関数にまとめ、同じローカル変数を使えるため、app.stateを経由した受け渡しが要らなくなります。
lifespanを渡すとon_eventのハンドラが呼ばれない混在の落とし穴
移行の途中でlifespanとon_eventを混ぜると、on_eventの方は黙って呼ばれなくなります。公式も「lifespanを渡すと、startupとshutdownのイベントハンドラはもう呼ばれない。すべてlifespanか、すべてイベントかのどちらかで、両方ではない」と警告しています。
起動時にはエラーが出ず、startupでapp.stateに入れるはずだった値を読んだ最初のリクエストでAttributeErrorになる、という分かりにくい壊れ方をします。ライブラリがon_eventで初期化を登録している場合も、lifespanを足した途端にその初期化が動かなくなる仕組みです。移行したらgrep -rn "on_event" .で残りが無いことを確かめ、ライブラリ側はlifespanを提供していないか、各ライブラリの最新版の説明を確認します。
APIRouterのlifespanとマウントしたサブアプリで起動処理が動く範囲
lifespanはアプリ本体だけでなく、ルーターにも渡せます。一方、app.mount()で載せた別アプリでは動きません。
include_routerで合成されるルーターのlifespanの順序と状態
APIRouter(lifespan=router_lifespan)のようにルーターへ渡したlifespanは、app.include_router(router)の時点でアプリ側と合成されます。FastAPIは0.112.2でこの合成に対応しました。0.162.7のソースでは、アプリ側のlifespanが外側、ルーター側が内側に入ります。
つまり起動はアプリ側、ルーター側の順で、終了はその逆です。ルーターの初期化でアプリ側の接続プールを使いたい場合でも、プールは先にできています。両方のyieldが辞書を返したときは1つに合成され、同じキーがある場合に残るのはアプリ側の値です。"engine"のような汎用の名前は衝突しやすいため、ルーター側は"orders_cache"のように機能名を付けます。ルーターの分け方そのものは、同じFastAPIの記事群のAPIRouter編で扱う予定です。
app.mountしたサブアプリのlifespanが呼ばれない制約と回避
公式のSub Applications – Mountsのとおり、lifespanはメインのアプリでしか実行されません。app.mount("/v2", sub_app)で載せたsub_appにlifespanを書いても、その中身は動かない仕組みです。
マウントするアプリが初期化を必要とするなら、そのlifespanをメインのアプリ側から呼びます。代表例がMCPサーバーで、FastMCPのhttp_app()をマウントするときはFastAPI(lifespan=mcp_app.lifespan)と渡さないとセッション管理が初期化されません。既存のlifespanと束ねる方法も含め、FastMCPを既存FastAPIに同居させる手順で扱っています。
起動失敗・テスト・Docker停止時にlifespanが動かない原因の切り分け
「lifespanに書いたのに動いていない」という相談の多くは、次の3つのどれかです。
Uvicornが起動例外でApplication startup failedを出す理由
yieldの前で例外が起きると、Starlette 1.10.0はlifespan.startup.failedというメッセージをサーバーへ送ってから例外を送り出します。Uvicorn 0.54.0はこれを受けて「Application startup failed. Exiting.」と出力し、プロセスを終了します。ASGIの仕様どおりの動きです。
この挙動は好都合です。DBに接続できないまま起動して全リクエストが500になるより、起動の段階で落ちた方が、コンテナの再起動やデプロイの失敗として早く気付けます。起動時にDBへSELECT 1を1回投げて、つながらなければ例外にしておくと、設定の誤りを本番に出す前に止めることが可能です。逆に、外部APIが一時的に落ちているだけで起動を止めたくない処理は、tryで受けてログに残し、起動は続けます。
TestClientをwith文で使わないとlifespanが実行されない理由
テストでclient = TestClient(app)と書くだけでは、lifespanは動きません。公式のTesting Eventsのとおり、with文で包んだときだけ、入るときに起動処理、抜けるときに終了処理が走ります。
# test_main.py
from fastapi.testclient import TestClient
from main import app
def test_predict():
# with に入ると lifespan の起動処理が、抜けると終了処理が走る
with TestClient(app) as client:
res = client.get("/predict", params={"x": 2})
assert res.json() == {"result": 84.0}
withを付け忘れると、request.stateにmodelが入らないままエンドポイントが呼ばれ、テストが失敗します。本番では動くのにテストだけ落ちる場合は、まずここを疑います。
DockerのCMDをshell形式で書くと終了処理が飛ばされる問題
公式のDockerの説明は、CMDを必ずexec形式で書くよう警告しています。shell形式ではシェルが先に立ち上がり、docker stopの停止シグナルがFastAPIまで届かないことがあります。その場合は既定の10秒後に強制終了され、lifespanの終了処理は動きません。
# 正:exec形式。停止シグナルが FastAPI に届き、終了処理が動く
CMD ["fastapi", "run", "main.py", "--port", "80"]
# 誤:shell形式。終了処理が飛ばされることがある
CMD fastapi run main.py --port 80
Dockerfile全体の書き方はFastAPIをDockerで動かす手順と本番構成で扱っています。
lifespanに書くべき処理とDependsのyield依存へ回す処理の判断基準
lifespanもDependsのyield依存も、「前で準備して後ろで片付ける」という同じ形をしています。違いは寿命です。
アプリ全体で1つ持つ資源とリクエストごとの資源を分ける線引き
判断は寿命で決めます。プロセスが動いている間ずっと1つを共有するもの(接続プール、HTTPクライアント、読み込んだモデル、設定値)はlifespanです。リクエストごとに作って捨てるもの(DBセッション、トランザクション、認証済みのユーザー)はDependsのyield依存に置きます。
| 資源 | 置き場所 |
|---|---|
| 接続プール(AsyncEngine) | lifespan |
| httpx.AsyncClient | lifespan |
| 学習済みモデル | lifespan |
| DBセッション(AsyncSession) | Dependsのyield依存 |
| 認証済みユーザー | Depends |
よくある失敗は、httpx.AsyncClientをエンドポイントの中で毎回作り、リクエストのたびに接続を張り直すことです。既存のAPIで起動処理や接続の管理を組み直したい、FlaskなどからFastAPIへの移行を相談したい場合は、API開発・システム連携の受託開発で受け付けています。
lifespanに入れると失敗するcreate_allと長時間のバックグラウンド処理
lifespanの中でBase.metadata.create_all()を呼ぶ例はよく見ますが、本番では避けます。create_all()は無いテーブルを作るだけで、列の追加や型の変更を反映しません。ワーカーが4つあれば4回同時に走ります。テーブルの作成と変更は、Alembicのマイグレーションをデプロイの工程で1回だけ実行します。
もう1つは、yieldの前でawaitする重い処理です。数分かかる集計やキャッシュの温めを入れると、その間はヘルスチェックにも応答せず、オーケストレーターに「起動しない」と判断されて再起動が繰り返されます。定期実行のような長い処理は、lifespanからasyncio.create_task()で起動して、終了処理でcancel()するか、CeleryやCronなど別のプロセスに出します。
よくある質問
FastAPIのlifespanについて、実装の途中で出てくることの多い質問に、2026年11月時点の版で答えます。
lifespanは同期関数(def)で書けますか?
FastAPI 0.162.7のソースは、yieldを含む普通のdefの関数も包んで受け付けます。ただし、公式ドキュメントの例はすべて@asynccontextmanagerとasync defの組で、非同期の接続プールやクライアントを閉じる際に必要なのはawaitです。中で同期の処理しかしない場合でも、async defで書いておけば後から非同期の資源を足すときに書き換えずに済みます。
–reloadで起動するとlifespanは何回実行されますか?
コードを保存するたびに、サーバーのプロセスが作り直されるため、lifespanの起動処理と終了処理もその都度走ります。モデルの読み込みが重いなら、開発時だけ小さなダミーのモデルを読む設定にします。--reloadと--workersはUvicornの設定上、同時に使えません。
uvicornの–lifespanオプションは何を指定すればよいですか?
既定のautoのままで構いません。FastAPIはlifespanのプロトコルに対応しているため、autoで起動処理と終了処理が実行されます。offを付けるとlifespanそのものが動かなくなるので、FastAPIでは使いません。offが要るのは、Djangoのようにlifespanを実装していないアプリで警告を消したい場合です。
lifespanの中でバックグラウンドタスクを動かしてよいですか?
短い定期処理なら動かせます。起動処理でtask = asyncio.create_task(loop())と作り、yieldの後ろでtask.cancel()して止めます。ワーカーの数だけタスクも増えるため、「全体で1回だけ実行したい」処理には向きません。WebSocketの配信でRedisを購読するタスクをlifespanで起動する例は、FastAPIのWebSocket実装の解説で扱っています。
lifespanで作った資源をテストで差し替えるにはどうしますか?
エンドポイントがrequest.stateを直接読まず、依存関数を経由する形にしておきます。この記事のget_engineのように1枚挟めば、テストではapp.dependency_overrides[get_engine]に、テスト用のSQLiteをつないだエンジンを返す関数を入れるだけで差し替えが可能です。lifespanごと差し替えたい場合は、テスト用のlifespanを渡した別のFastAPIを作ってルーターを載せ直します。
関連記事
- FastAPIとは?最短の起動手順とasync/defの使い分け、0.162の変更点:FastAPI単体の入門と起動手順です。
- Uvicornとは?読み方・Gunicornとの違いと起動・本番設定【0.54対応】:lifespanを呼び出すASGIサーバー側の設定です。
- FastAPIのDependsの使い方|依存性注入・yield・テストの差し替えをコードで解説:リクエスト単位の資源を扱うyield依存です。
- FastAPIのベストプラクティス|ディレクトリ構成・SQLAlchemy 2.1の設計・テストを実測で解説:lifespanで作るエンジンを使ったセッション設計です。
- FastMCPとは?PythonでMCPサーバーを作る手順とFastMCP 4.1の使い方【2026年10月】:マウントしたMCPアプリにlifespanを渡す例です。