開発

FastAPIのlifespanの使い方|on_eventからの移行とDB接続・モデル読込をコードで解説

FastAPIのlifespanの使い方|on_eventからの移行とDB接続・モデル読込をコードで解説

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)

書き換えの手順は次のとおりです。

  1. startupの中身をyieldの前へ、shutdownの中身をfinallyの中へ移す
  2. app.state.xxx = ...をローカル変数に変え、yieldの辞書で返す
  3. エンドポイント側のrequest.app.state.xxxをrequest.state.xxxに置き換える
  4. 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を作ってルーターを載せ直します。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.13 テックブログ 宝くじ公式サイト不正アクセス:4,436名の対象確認とパスワード再設定、変更された仕組み
  2. 2026.11.13 テックブログ KARTEの不正アクセス:全機能停止で利用企業がタグ・配信・データ連携を確かめる手順
  3. 2026.10.21 テックブログ マネックス証券の不正アクセス:メールアドレス最大約113万件と本人確認画像1,592件の漏えいのおそれ、口座開設停止とPHP脆弱性との関係
  4. 2026.11.14 テックブログ ヤマダデンキの不正アクセス:リフォーム管理システム最大350万件と、住所・見積を持つ営業記録の守り方
  5. 2026.11.11 テックブログ Microsoft 2026年11月の月例更新:Outlookの悪用済みCVE-2026-124194と修正版の確認方法

RELATED POSTS 関連記事

目次