サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

FastAPIのlifespan入門:共有リソースをプロセス単位で安全に管理する

起動から終了まで共有リソースを管理するAPIサーバーの流れを抽象化した図

FastAPIで外部APIやデータベースを使うとき、接続用オブジェクトをリクエストのたびに作ると、接続プールを再利用できず、終了処理も分散します。

反対に、モジュールの読み込み時に作るだけでは、テストやコマンド実行でも初期化が走り、誰が解放するのかが曖昧になります。

lifespanは、アプリケーションがリクエストを受け付ける前の初期化と、受付を終えた後の解放を一つの関数にまとめる仕組みです。

本稿では、共有HTTPクライアントを例に、リソースの寿命がコードから読み取れる実装へ整えます。

lifespanが管理する範囲

FastAPI公式のLifespan Eventsでは、yieldより前が起動時、後が終了時に実行されると説明されています。

アプリケーション全体で共有し、終了時に解放が必要なものが対象です。

リソース 適した寿命 管理方法
外部API用HTTPクライアント アプリケーションプロセス lifespan
データベース接続プール アプリケーションプロセス lifespan
機械学習モデル アプリケーションプロセス lifespan
データベースセッションやトランザクション リクエスト yieldを使う依存関係
認証済みユーザーの情報 リクエスト 依存性注入

FastAPIは従来のstartupshutdownイベントより、lifespanの利用を推奨しています。

lifespanを指定した場合、従来のイベントハンドラーは呼ばれないため、二つの方式を混在させず、移行単位を明確にします。

Python側では、contextlib.asynccontextmanagerの公式仕様が、tryyieldfinallyによる取得と解放の対応を定めています。

共有HTTPクライアントを初期化する

次の例では、HTTPXのAsyncClientをプロセスの起動時に一つ作り、lifespanの状態として各リクエストへ渡します。

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, TypedDict

import httpx
from fastapi import Depends, FastAPI, Request


class LifespanState(TypedDict):
    http_client: httpx.AsyncClient


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[LifespanState]:
    async with httpx.AsyncClient(
        base_url="https://api.example.com",
        timeout=httpx.Timeout(5.0, connect=2.0),
    ) as http_client:
        yield {"http_client": http_client}


app = FastAPI(lifespan=lifespan)


def get_http_client(request: Request) -> httpx.AsyncClient:
    return request.state.http_client


HttpClient = Annotated[httpx.AsyncClient, Depends(get_http_client)]


@app.get("/catalog/{item_id}")
async def read_catalog(item_id: str, client: HttpClient):
    response = await client.get(f"/items/{item_id}")
    response.raise_for_status()
    return response.json()

Starletteのlifespan state仕様では、lifespanが返した辞書はリクエストのstateへ浅いコピーとして渡されます。

ここでコピーされるのは辞書の入れ物です。

AsyncClient自体は各リクエストで同じインスタンスを参照するため、接続プールを共有できます。

HTTPXの非同期クライアント公式ガイドも、接続プールを活用するには処理の頻繁な箇所でクライアントを繰り返し生成せず、一つのスコープで共有するよう案内しています。

async withを使うと、通常の終了だけでなく、yield以後に例外が伝わった場合もクライアントの終了処理へ進みます。

ただし、プロセスの強制終了やホスト障害まで後処理が実行されるとは限りません。

未送信データをメモリだけに置くなど、正しさを終了処理だけに依存させる設計は避けます。

lifespanと依存性注入を分担させる

lifespanは共有オブジェクトの生成場所であり、各エンドポイントが状態を直接探し回る場所ではありません。

例ではget_http_clientを介してクライアントを注入し、エンドポイントから状態の保存方法を隠しています。

この境界を置くと、テストでは依存関係を差し替えられ、外部APIへ接続しない検証を組み立てやすくなります。

リクエストごとに開始と終了が必要なデータベースセッションは、アプリケーション全体の接続プールとは寿命が異なります。

FastAPI公式のyield付き依存関係に沿い、セッションは依存関係で取得し、レスポンス処理の後に閉じます。

接続プールをlifespan、セッションを依存関係で管理すれば、長寿命の資源と短寿命の作業単位が混ざりません。

アプリの分割方針も同時に見直す場合は、FastAPIのプロジェクト構成を段階的に分ける方法も参照してください。

複数ワーカーではプロセスごとに初期化される

「起動時に一度」は、サーバー全体で一度ではなく、アプリケーションプロセスごとに一度です。

FastAPI公式の複数ワーカー例では、--workers 4を指定すると四つのサーバープロセスが起動し、それぞれでアプリケーション起動が完了しています。

したがって、四つのワーカーでHTTPクライアントや接続プールを作れば、共有リソースも原則として四組できます。

データベースの最大接続数は、プール一つの上限ではなく、ワーカー数を掛けた総数で見積もります。

スキーマ変更や初期データ投入のように環境全体で一度だけ実行したい処理は、lifespanへ安易に置けません。

複数プロセスが同時に実行しても安全な処理にするか、デプロイ前の独立した工程として実行します。

起動失敗を隠さない

必須リソースを作れないままyieldすると、アプリケーションは起動しているのに主要機能が使えない状態になります。

データベース接続や設定の検証が必須なら、初期化例外を広く握りつぶさず、起動を失敗させるほうが監視と復旧の判断は明確です。

一方、補助的な外部サービスが停止していても主要機能を提供できる場合は、利用可能性を状態として保持し、該当する機能だけ明示的なエラーへ切り替えます。

「何でも起動失敗にする」か「何でも縮退させる」かではなく、サービスの契約に沿って資源ごとに決めます。

テストで起動と終了を通す

TestClient(app)を作るだけではなく、コンテキストマネージャーとして使うと、テスト内でlifespanの開始と終了を通せます。

from fastapi.testclient import TestClient


def test_resource_is_available_during_request():
    with TestClient(app) as client:
        response = client.get("/catalog/sample")
        assert response.status_code == 200

FastAPI公式のlifespanテスト手順も、with TestClient(app)のブロックへ入ると起動処理が走り、抜けると終了処理が走ることを示しています。

実際のテストでは外部API用依存関係を偽物へ差し替え、次の三点を別々に確認します。

終了メソッドの確認には、モックや記録用のテスト実装を使います。

実在する外部サービスへの接続結果だけで判定すると、ネットワーク状態とライフサイクル設計の検証が混ざります。

導入時の確認項目

  1. 共有したい対象がプロセス単位か、リクエスト単位かを決める。
  2. 生成と解放を同じlifespan関数で対応させる。
  3. async withまたはtryfinallyで終了処理を保証しやすくする。
  4. エンドポイントへは依存性注入で渡し、保存場所への直接依存を広げない。
  5. ワーカー数を含めて接続数とメモリ使用量を見積もる。
  6. with TestClient(app)で起動と終了の両方を検証する。

lifespanの価値は、初期化コードを一か所へ移すことだけではありません。

誰が資源を所有し、どの範囲で共有し、いつ解放するのかをコード上の境界として示せることにあります。

よくある質問

startupイベントはすぐに書き換えるべきですか

動作中の処理を無計画に一括変更する必要はありません。

ただし、FastAPIはlifespanを推奨しており、両方式は混在できないため、テストを用意したうえでアプリケーション単位に移行します。

データベースセッションもlifespanで共有できますか

接続プールは共有できますが、トランザクションを持つセッションを全リクエストで共有する設計は避けます。

セッションはリクエスト単位の依存関係で作成し、処理後に閉じます。

共有HTTPクライアントに利用者ごとの認証情報を設定してよいですか

利用者ごとに変わる認証ヘッダーやCookieを共有クライアントの既定値へ書き換えると、別のリクエストへ状態が漏れる危険があります。

接続プールは共有し、利用者固有の値は個々のリクエスト引数として渡します。

参考資料

モバイルバージョンを終了