<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>FastAPI on AI2CORE - AI技術ブログ</title>
    <link>https://www.ai2core.com/tags/fastapi/</link>
    <description>Recent content in FastAPI on AI2CORE - AI技術ブログ</description>
    <generator>Hugo -- 0.146.4</generator>
    <language>ja</language>
    <lastBuildDate>Sat, 07 Mar 2026 11:10:00 +0900</lastBuildDate>
    <atom:link href="https://www.ai2core.com/tags/fastapi/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>FastAPI &#43; SQLAlchemy性能改善プレイブック: 遅いAPIを計測ベースで高速化する</title>
      <link>https://www.ai2core.com/posts/2026-03-07-fastapi-sqlalchemy-performance-tuning-playbook/</link>
      <pubDate>Sat, 07 Mar 2026 11:10:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-07-fastapi-sqlalchemy-performance-tuning-playbook/</guid>
      <description>FastAPIとSQLAlchemyのAPI性能を、N&#43;1解消・クエリ最適化・接続プール設定・負荷検証まで含めて具体的に改善する実践手順を解説。</description>
      <content:encoded><![CDATA[<h1 id="fastapi--sqlalchemy性能改善プレイブック-遅いapiを計測ベースで高速化する">FastAPI + SQLAlchemy性能改善プレイブック: 遅いAPIを計測ベースで高速化する</h1>
<p>FastAPIの初期実装は非常に快適です。しかし運用フェーズに入ると、次のような症状が出てきます。</p>
<ul>
<li>一覧APIのレスポンスが急に遅くなる</li>
<li>同時接続が増えるとp95が跳ねる</li>
<li>CPUは余っているのにタイムアウトが増える</li>
<li>DB接続数が上限に張り付く</li>
</ul>
<p>こうした問題の多くは「Pythonが遅い」のではなく、<strong>SQLAlchemyの使い方とDBアクセス設計</strong> に起因します。</p>
<p>本記事では、FastAPI + SQLAlchemy + PostgreSQL構成を前提に、実際の改善手順を計測ベースで整理します。</p>
<h2 id="1-最初に測るべき指標">1. 最初に測るべき指標</h2>
<p>最適化は、体感ではなく数値で進めます。最低限、以下を可視化します。</p>
<ul>
<li>APIのp50/p95/p99レイテンシ</li>
<li>エンドポイント別SQL発行回数</li>
<li>1リクエストあたりのDB滞在時間</li>
<li>connection pool待ち時間</li>
<li>slow query件数（200ms以上など）</li>
</ul>
<p>OpenTelemetryやNew Relicを使っているなら、アプリspanとDB spanを必ず紐付けてください。これだけでボトルネック特定速度が上がります。</p>
<h2 id="2-n1問題を最優先で潰す">2. N+1問題を最優先で潰す</h2>
<p>最も頻出するのがN+1です。例えばユーザー一覧でプロフィールを参照すると、ユーザー数分の追加クエリが発行されます。</p>
<h3 id="21-悪い例">2.1 悪い例</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>users <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>query(User)<span style="color:#f92672">.</span>limit(<span style="color:#ae81ff">100</span>)<span style="color:#f92672">.</span>all()
</span></span><span style="display:flex;"><span>result <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> u <span style="color:#f92672">in</span> users:
</span></span><span style="display:flex;"><span>    result<span style="color:#f92672">.</span>append({
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;id&#34;</span>: u<span style="color:#f92672">.</span>id,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;name&#34;</span>: u<span style="color:#f92672">.</span>name,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;profile&#34;</span>: u<span style="color:#f92672">.</span>profile<span style="color:#f92672">.</span>bio,
</span></span><span style="display:flex;"><span>    })
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="22-改善例joinedloadselectinload">2.2 改善例（joinedload/selectinload）</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sqlalchemy.orm <span style="color:#f92672">import</span> selectinload
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>users <span style="color:#f92672">=</span> (
</span></span><span style="display:flex;"><span>    session<span style="color:#f92672">.</span>query(User)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>options(selectinload(User<span style="color:#f92672">.</span>profile))
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>limit(<span style="color:#ae81ff">100</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>all()
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>joinedload</code> と <code>selectinload</code> はデータ量で使い分けます。</p>
<ul>
<li>1対1/少量: <code>joinedload</code></li>
<li>1対多/件数多め: <code>selectinload</code></li>
</ul>
<p>闇雲に <code>joinedload</code> を増やすと行爆発が起きるため、EXPLAINで確認しながら適用します。</p>
<h2 id="3-sqlalchemy-2xスタイルへ揃える">3. SQLAlchemy 2.xスタイルへ揃える</h2>
<p>旧query APIと新APIが混在すると可読性と最適化精度が落ちます。2.xスタイルへ統一しましょう。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sqlalchemy <span style="color:#f92672">import</span> select
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>stmt <span style="color:#f92672">=</span> (
</span></span><span style="display:flex;"><span>    select(Order)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>where(Order<span style="color:#f92672">.</span>status <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;paid&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>order_by(Order<span style="color:#f92672">.</span>created_at<span style="color:#f92672">.</span>desc())
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>limit(<span style="color:#ae81ff">50</span>)
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>orders <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>execute(stmt)<span style="color:#f92672">.</span>scalars()<span style="color:#f92672">.</span>all()
</span></span></code></pre></td></tr></table>
</div>
</div><p>この形式は、<code>EXPLAIN</code> の追跡や再利用がしやすく、レビュー品質も上がります。</p>
<h2 id="4-必要な列だけ取る過剰フェッチの削減">4. 必要な列だけ取る（過剰フェッチの削減）</h2>
<p>ORMは便利ですが、何も考えずモデル全体を取ると不要データまで転送されます。特にJSONカラムやTEXTが重い場合、ここが効きます。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>stmt <span style="color:#f92672">=</span> select(User<span style="color:#f92672">.</span>id, User<span style="color:#f92672">.</span>name, User<span style="color:#f92672">.</span>email)<span style="color:#f92672">.</span>where(User<span style="color:#f92672">.</span>active<span style="color:#f92672">.</span>is_(<span style="color:#66d9ef">True</span>))
</span></span><span style="display:flex;"><span>rows <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>execute(stmt)<span style="color:#f92672">.</span>all()
</span></span></code></pre></td></tr></table>
</div>
</div><p>一覧APIはDTO用の軽量SELECTを使い、詳細APIでのみ重いカラムを取得する設計が安定します。</p>
<h2 id="5-接続プール設定を環境に合わせる">5. 接続プール設定を環境に合わせる</h2>
<p><code>pool_size</code> を適当に増やすだけでは逆効果です。PostgreSQL側上限とアプリ台数を合わせて設計します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>engine <span style="color:#f92672">=</span> create_engine(
</span></span><span style="display:flex;"><span>    DB_URL,
</span></span><span style="display:flex;"><span>    pool_size<span style="color:#f92672">=</span><span style="color:#ae81ff">20</span>,
</span></span><span style="display:flex;"><span>    max_overflow<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>,
</span></span><span style="display:flex;"><span>    pool_timeout<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>,
</span></span><span style="display:flex;"><span>    pool_recycle<span style="color:#f92672">=</span><span style="color:#ae81ff">1800</span>,
</span></span><span style="display:flex;"><span>    pool_pre_ping<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></td></tr></table>
</div>
</div><p>設計の目安:</p>
<ul>
<li>DB max_connections = 300</li>
<li>API Pod = 6</li>
<li>1 Podあたりpool_size 20</li>
</ul>
<p>この時点で理論最大120接続。バッチや管理接続も見込み、余白を残すのが安全です。</p>
<h2 id="6-トランザクション境界を短くする">6. トランザクション境界を短くする</h2>
<p>長いトランザクションはロック競合とスループット低下を招きます。</p>
<p>悪い例:</p>
<ol>
<li>DB更新</li>
<li>外部API呼び出し</li>
<li>メール送信</li>
<li>commit</li>
</ol>
<p>この順は危険です。外部I/Oをトランザクション外へ逃がします。</p>
<p>改善例:</p>
<ol>
<li>DB更新 + commit</li>
<li>外部通知は非同期ジョブで実行</li>
</ol>
<p>これだけで同時処理性能が目に見えて改善します。</p>
<h2 id="7-インデックス設計をapi単位で見直す">7. インデックス設計をAPI単位で見直す</h2>
<p>「インデックスはある」だけでは不足です。実際のWHERE + ORDER BYに合っているかが重要です。</p>
<p>例: 注文履歴API</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> id, total_amount, created_at
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> orders
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> user_id <span style="color:#f92672">=</span> <span style="color:#960050;background-color:#1e0010">$</span><span style="color:#ae81ff">1</span> <span style="color:#66d9ef">AND</span> status <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;paid&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> created_at <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">50</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p>この場合、次の複合indexが有効です。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> CONCURRENTLY idx_orders_user_status_created_at_desc
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> orders (user_id, status, created_at <span style="color:#66d9ef">DESC</span>);
</span></span></code></pre></td></tr></table>
</div>
</div><p>単独indexを乱立させるより、アクセスパターンに合わせた複合indexを厳選した方が効きます。</p>
<h2 id="8-キャッシュ導入は遅い理由の解決後に行う">8. キャッシュ導入は「遅い理由の解決後」に行う</h2>
<p>キャッシュは万能ではありません。N+1やスロークエリを放置したまま載せると、整合性事故の温床になります。</p>
<p>導入順序:</p>
<ol>
<li>SQL最適化</li>
<li>接続プール調整</li>
<li>必要なエンドポイントに限定してRedis cache</li>
</ol>
<p>キャッシュキーは <code>resource:id:version</code> 形式にし、更新時の無効化戦略を先に定義してください。</p>
<h2 id="9-負荷試験シナリオk6例">9. 負荷試験シナリオ（k6例）</h2>
<p>最適化の成果は負荷試験で確認します。最低3シナリオが必要です。</p>
<ul>
<li>steady: 通常トラフィック</li>
<li>burst: 短時間ピーク</li>
<li>soak: 長時間連続実行（リーク検知）</li>
</ul>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">http</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;k6/http&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">check</span>, <span style="color:#a6e22e">sleep</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;k6&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">options</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">stages</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">duration</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;2m&#39;</span>, <span style="color:#a6e22e">target</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">50</span> },
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">duration</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;5m&#39;</span>, <span style="color:#a6e22e">target</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">50</span> },
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">duration</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;1m&#39;</span>, <span style="color:#a6e22e">target</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">200</span> },
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">duration</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;2m&#39;</span>, <span style="color:#a6e22e">target</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0</span> },
</span></span><span style="display:flex;"><span>  ],
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">default</span> <span style="color:#66d9ef">function</span> () {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">res</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">get</span>(<span style="color:#e6db74">&#39;https://api.example.com/orders?limit=50&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">check</span>(<span style="color:#a6e22e">res</span>, { <span style="color:#e6db74">&#39;status 200&#39;</span><span style="color:#f92672">:</span> (<span style="color:#a6e22e">r</span>) =&gt; <span style="color:#a6e22e">r</span>.<span style="color:#a6e22e">status</span> <span style="color:#f92672">===</span> <span style="color:#ae81ff">200</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">sleep</span>(<span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>改善前後で <code>p95</code>, SQL回数, DB CPU を比較し、定量で判断します。</p>
<h2 id="10-本番での改善手順テンプレート">10. 本番での改善手順テンプレート</h2>
<ol>
<li>ボトルネックendpointの特定</li>
<li>SQLログとEXPLAIN ANALYZE取得</li>
<li>N+1解消</li>
<li>必要列取得へ変更</li>
<li>インデックス追加（CONCURRENTLY）</li>
<li>pool設定調整</li>
<li>負荷試験再実施</li>
<li>段階リリース（10%→50%→100%）</li>
</ol>
<p>この手順を運用チーム全体でテンプレ化すると、パフォーマンス問題への対応速度が上がります。</p>
<h2 id="まとめ">まとめ</h2>
<p>FastAPI + SQLAlchemyの性能改善は、派手なテクニックより <strong>計測→原因分離→小さく改善</strong> の積み重ねが効きます。</p>
<ul>
<li>N+1解消</li>
<li>過剰フェッチ削減</li>
<li>接続プール最適化</li>
<li>インデックスの再設計</li>
<li>負荷試験で再検証</li>
</ul>
<p>この5点を回せば、遅いAPIは高確率で改善できます。まずは「1リクエストあたりのSQL発行数」を可視化するところから始めるのが最短です。</p>
<h2 id="付録-改善施策の優先順位最短で効く順">付録: 改善施策の優先順位（最短で効く順）</h2>
<p>時間が限られる現場では、まず「SQL発行回数削減 → インデックス最適化 → connection pool調整」の順で着手すると効果が出やすいです。特に、N+1解消だけでp95が半減するケースは珍しくありません。改善後は必ず同一負荷条件で再計測し、数字で効果を残しておくと、次の改善投資判断が通りやすくなります。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>FastAPI</category>
      <category>SQLAlchemy</category>
      <category>PostgreSQL</category>
      <category>Python</category>
      <category>Performance</category>
    </item>
    <item>
      <title>FastAPI &#43; Celery信頼性設計: 非同期ジョブを本番で壊さないための実装パターン</title>
      <link>https://www.ai2core.com/posts/2026-03-06-fastapi-celery-reliability-patterns/</link>
      <pubDate>Fri, 06 Mar 2026 09:03:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-06-fastapi-celery-reliability-patterns/</guid>
      <description>FastAPIとCeleryを使った非同期処理を本番運用するために、再実行安全性、監視、失敗復旧、デプロイ戦略を具体例付きで解説。</description>
      <content:encoded><![CDATA[<h1 id="fastapi--celery信頼性設計-非同期ジョブを本番で壊さないための実装パターン">FastAPI + Celery信頼性設計: 非同期ジョブを本番で壊さないための実装パターン</h1>
<p>FastAPIでAPIを作ると、重い処理はすぐに非同期ジョブへ逃がしたくなります。画像変換、レポート生成、外部API連携、メール配信など、Celeryは非常に便利です。ですが、本番で問題になるのは「動くかどうか」ではなく、<strong>失敗したときに壊れないか</strong> です。</p>
<ul>
<li>同じジョブが二重実行される</li>
<li>一時障害で永遠にリトライしてキューが詰まる</li>
<li>ワーカー再起動で中途半端な状態が残る</li>
<li>完了通知が先に飛んで実データがない</li>
</ul>
<p>本記事では FastAPI + Celery + Redis 構成を前提に、再実行安全性（idempotency）と運用信頼性を上げる実装手順をまとめます。</p>
<h2 id="1-まず守るべき設計原則">1. まず守るべき設計原則</h2>
<p>非同期基盤の事故は、ほぼ次の4原則で防げます。</p>
<ol>
<li><strong>At-least-once前提</strong>（同一タスク再実行は必ず起こる）</li>
<li><strong>副作用は冪等化</strong>（何回実行されても結果が壊れない）</li>
<li><strong>状態遷移を明示</strong>（PENDING/RUNNING/SUCCEEDED/FAILED）</li>
<li><strong>失敗を可観測化</strong>（リトライ回数・死活・滞留時間を計測）</li>
</ol>
<p>この原則を外すと、障害時に「何が完了して何が未完了か」が追えなくなります。</p>
<h2 id="2-参照アーキテクチャ">2. 参照アーキテクチャ</h2>
<ul>
<li>API: FastAPI</li>
<li>Queue Broker: Redis</li>
<li>Worker: Celery</li>
<li>Result Store: PostgreSQL（業務状態）</li>
<li>Monitoring: Flower + Prometheus + Sentry</li>
</ul>
<p>ポイントは、<strong>業務上重要な状態はRedis結果バックエンドに依存しない</strong> ことです。Redisは一時的に使い、真実の状態はRDBに持たせます。</p>
<h2 id="3-実装の土台-タスク受付api">3. 実装の土台: タスク受付API</h2>
<h3 id="31-受け付け時に-idempotency_key-を必須化">3.1 受け付け時に idempotency_key を必須化</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> fastapi <span style="color:#f92672">import</span> FastAPI, HTTPException
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pydantic <span style="color:#f92672">import</span> BaseModel
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sqlalchemy <span style="color:#f92672">import</span> select
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> FastAPI()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">JobRequest</span>(BaseModel):
</span></span><span style="display:flex;"><span>    idempotency_key: str
</span></span><span style="display:flex;"><span>    report_type: str
</span></span><span style="display:flex;"><span>    user_id: str
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.post</span>(<span style="color:#e6db74">&#34;/reports&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">create_report</span>(req: JobRequest):
</span></span><span style="display:flex;"><span>    existing <span style="color:#f92672">=</span> find_job_by_key(req<span style="color:#f92672">.</span>idempotency_key)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> existing:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;job_id&#34;</span>: existing<span style="color:#f92672">.</span>id, <span style="color:#e6db74">&#34;status&#34;</span>: existing<span style="color:#f92672">.</span>status}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    job <span style="color:#f92672">=</span> create_job_record(
</span></span><span style="display:flex;"><span>        idempotency_key<span style="color:#f92672">=</span>req<span style="color:#f92672">.</span>idempotency_key,
</span></span><span style="display:flex;"><span>        status<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;PENDING&#34;</span>,
</span></span><span style="display:flex;"><span>        report_type<span style="color:#f92672">=</span>req<span style="color:#f92672">.</span>report_type,
</span></span><span style="display:flex;"><span>        user_id<span style="color:#f92672">=</span>req<span style="color:#f92672">.</span>user_id,
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    generate_report<span style="color:#f92672">.</span>delay(job<span style="color:#f92672">.</span>id)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;job_id&#34;</span>: job<span style="color:#f92672">.</span>id, <span style="color:#e6db74">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;PENDING&#34;</span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>これでクライアント再送が来てもジョブ多重作成を防げます。</p>
<h3 id="32-db制約で最終防衛線を張る">3.2 DB制約で最終防衛線を張る</h3>
<p><code>idempotency_key</code> に UNIQUE 制約を入れ、アプリバグ時も二重作成を防ぎます。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> async_jobs <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">CONSTRAINT</span> uq_async_jobs_idempotency <span style="color:#66d9ef">UNIQUE</span> (idempotency_key);
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="4-celeryタスクの実践設定">4. Celeryタスクの実践設定</h2>
<h3 id="41-推奨設定">4.1 推奨設定</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> celery <span style="color:#f92672">import</span> Celery
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>celery_app <span style="color:#f92672">=</span> Celery(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;worker&#34;</span>,
</span></span><span style="display:flex;"><span>    broker<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;redis://redis:6379/0&#34;</span>,
</span></span><span style="display:flex;"><span>    backend<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;redis://redis:6379/1&#34;</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>celery_app<span style="color:#f92672">.</span>conf<span style="color:#f92672">.</span>update(
</span></span><span style="display:flex;"><span>    task_acks_late<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    task_reject_on_worker_lost<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    worker_prefetch_multiplier<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>    task_time_limit<span style="color:#f92672">=</span><span style="color:#ae81ff">900</span>,
</span></span><span style="display:flex;"><span>    task_soft_time_limit<span style="color:#f92672">=</span><span style="color:#ae81ff">840</span>,
</span></span><span style="display:flex;"><span>    task_default_retry_delay<span style="color:#f92672">=</span><span style="color:#ae81ff">30</span>,
</span></span><span style="display:flex;"><span>    task_routes<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;tasks.generate_report&#34;</span>: {<span style="color:#e6db74">&#34;queue&#34;</span>: <span style="color:#e6db74">&#34;reports&#34;</span>}},
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></td></tr></table>
</div>
</div><ul>
<li><code>acks_late=True</code>: 実行完了後にACK。途中クラッシュ時は再配信</li>
<li><code>prefetch_multiplier=1</code>: 取り込み過多を防ぎ、偏りを減らす</li>
<li>time limit: ハング抑止</li>
</ul>
<h3 id="42-リトライは指数バックオフ--上限">4.2 リトライは指数バックオフ + 上限</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#a6e22e">@celery_app.task</span>(
</span></span><span style="display:flex;"><span>    bind<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    autoretry_for<span style="color:#f92672">=</span>(TemporaryExternalError,),
</span></span><span style="display:flex;"><span>    retry_backoff<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    retry_backoff_max<span style="color:#f92672">=</span><span style="color:#ae81ff">300</span>,
</span></span><span style="display:flex;"><span>    retry_jitter<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    max_retries<span style="color:#f92672">=</span><span style="color:#ae81ff">7</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">generate_report</span>(self, job_id: str):
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">...</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>無制限リトライは障害増幅装置です。必ず上限を設定します。</p>
<h2 id="5-冪等タスクの実装パターン">5. 冪等タスクの実装パターン</h2>
<h3 id="51-状態遷移をトランザクションで管理">5.1 状態遷移をトランザクションで管理</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">start_job</span>(job_id: str):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> session<span style="color:#f92672">.</span>begin():
</span></span><span style="display:flex;"><span>        job <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>get(Job, job_id, with_for_update<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> job<span style="color:#f92672">.</span>status <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;RUNNING&#34;</span>, <span style="color:#e6db74">&#34;SUCCEEDED&#34;</span>):
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>        job<span style="color:#f92672">.</span>status <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;RUNNING&#34;</span>
</span></span><span style="display:flex;"><span>        job<span style="color:#f92672">.</span>started_at <span style="color:#f92672">=</span> utcnow()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">True</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>FOR UPDATE</code> を使い、同時実行で状態が競合しないようにします。</p>
<h3 id="52-副作用前に実行済みチェック">5.2 副作用前に“実行済みチェック”</h3>
<p>外部API呼び出しやファイル生成前に、既に成果物が存在するか確認します。</p>
<ul>
<li>既に同名レポートが生成済みならスキップ</li>
<li>外部通知は送信履歴テーブルで重複防止</li>
<li>決済や課金は必ず業務ID単位で一意化</li>
</ul>
<h3 id="53-完了処理はcompare-and-setで確定">5.3 完了処理はCompare-and-Setで確定</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">complete_job</span>(job_id: str, result_url: str):
</span></span><span style="display:flex;"><span>    updated <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>execute(
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        UPDATE async_jobs
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        SET status=&#39;SUCCEEDED&#39;, result_url=:url, finished_at=now()
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        WHERE id=:id AND status=&#39;RUNNING&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &#34;&#34;&#34;</span>,
</span></span><span style="display:flex;"><span>        {<span style="color:#e6db74">&#34;id&#34;</span>: job_id, <span style="color:#e6db74">&#34;url&#34;</span>: result_url},
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> updated<span style="color:#f92672">.</span>rowcount <span style="color:#f92672">==</span> <span style="color:#ae81ff">1</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>これで二重完了更新を防げます。</p>
<h2 id="6-失敗時の設計dlq相当の運用">6. 失敗時の設計（DLQ相当の運用）</h2>
<p>Celeryに“標準DLQ”はありませんが、実運用では次の形で代替できます。</p>
<ul>
<li>リトライ上限超過時に <code>FAILED_PERMANENT</code> へ遷移</li>
<li>失敗理由とスタックトレースをDB保存</li>
<li>再実行API（手動リカバリ）を提供</li>
<li>重大失敗はSentry + Pagerで通知</li>
</ul>
<p>この構成で「黙って死ぬジョブ」をなくせます。</p>
<h2 id="7-監視設計最低限">7. 監視設計（最低限）</h2>
<h3 id="メトリクス">メトリクス</h3>
<ul>
<li>キュー滞留数（queue length）</li>
<li>oldest message age</li>
<li>タスク成功率 / 失敗率</li>
<li>p95 実行時間</li>
<li>リトライ回数分布</li>
</ul>
<h3 id="アラート例">アラート例</h3>
<ul>
<li>滞留数が通常の3倍を10分継続</li>
<li>失敗率 &gt; 5% が15分継続</li>
<li>oldest message age &gt; 20分</li>
<li>worker heartbeat消失</li>
</ul>
<p>「CPU高い」より「キューが古い」がユーザー影響に直結します。</p>
<h2 id="8-デプロイ時の落とし穴">8. デプロイ時の落とし穴</h2>
<h3 id="81-ローリング更新での重複実行">8.1 ローリング更新での重複実行</h3>
<ul>
<li><code>acks_late</code> + graceful shutdown を設定</li>
<li><code>TERM</code> 後にタスク完了待ち時間を確保</li>
<li>長時間ジョブは分割し、中断耐性を持たせる</li>
</ul>
<h3 id="82-スキーマ変更の順序">8.2 スキーマ変更の順序</h3>
<p>非同期基盤では、ワーカーとAPIが異なるバージョンで同居します。</p>
<p>安全な順序:</p>
<ol>
<li>先に後方互換なDB変更を適用</li>
<li>ワーカーを先に更新</li>
<li>APIを更新</li>
<li>非互換削除は次リリースで</li>
</ol>
<p>これを守らないと、古いタスクが新スキーマで失敗します。</p>
<h2 id="9-ローカルステージングでの検証手順">9. ローカル・ステージングでの検証手順</h2>
<ol>
<li>正常系: ジョブ作成→完了→結果取得</li>
<li>再送系: 同一 <code>idempotency_key</code> で2回POST</li>
<li>障害系: 外部APIタイムアウトを強制しリトライ確認</li>
<li>クラッシュ系: 実行中にworker再起動し再配信確認</li>
<li>負荷系: 1000ジョブ投入で滞留時間と失敗率確認</li>
</ol>
<p>この5ケースを自動テストに入れるだけで、運用品質は大幅に上がります。</p>
<h2 id="10-本番チェックリスト">10. 本番チェックリスト</h2>
<ul>
<li><input disabled="" type="checkbox"> idempotency_key のUNIQUE制約あり</li>
<li><input disabled="" type="checkbox"> 冪等な状態遷移実装（RUNNING/SUCCEEDED）</li>
<li><input disabled="" type="checkbox"> リトライ上限 + バックオフ設定済み</li>
<li><input disabled="" type="checkbox"> 手動再実行導線あり</li>
<li><input disabled="" type="checkbox"> 失敗通知（Sentry/Pager）有効</li>
<li><input disabled="" type="checkbox"> 滞留監視とアラート運用あり</li>
<li><input disabled="" type="checkbox"> デプロイ手順に互換性ルール明記</li>
</ul>
<h2 id="まとめ">まとめ</h2>
<p>FastAPI + Celeryの本質は、非同期化そのものではなく <strong>失敗しても壊れない設計</strong> にあります。</p>
<ul>
<li>At-least-once を前提に設計する</li>
<li>冪等性をDB制約と状態遷移で担保する</li>
<li>リトライと監視を“運用可能”な形で実装する</li>
<li>デプロイ時のバージョン混在を想定する</li>
</ul>
<p>ここまで作り込むと、ジョブ基盤は「たまに落ちるブラックボックス」から「予測可能に運用できるインフラ」へ変わります。まずは <code>idempotency_key</code> と状態遷移の明確化から始めるのがおすすめです。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>FastAPI</category>
      <category>Celery</category>
      <category>Redis</category>
      <category>Python</category>
      <category>Reliability</category>
    </item>
    <item>
      <title>FastAPI認証・認可の本番設計：JWT運用、権限制御、監査ログまで含めた実装パターン</title>
      <link>https://www.ai2core.com/posts/2026-03-04-fastapi-authn-authz-production-patterns/</link>
      <pubDate>Wed, 04 Mar 2026 09:35:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-04-fastapi-authn-authz-production-patterns/</guid>
      <description>FastAPIで安全に認証・認可を実装するために、トークン設計、ローテーション、RBAC、監査、障害時運用まで具体手順で解説。</description>
      <content:encoded><![CDATA[<h1 id="fastapi認証認可の本番設計jwt運用権限制御監査ログまで含めた実装パターン">FastAPI認証・認可の本番設計：JWT運用、権限制御、監査ログまで含めた実装パターン</h1>
<p>FastAPI は実装が速い反面、認証・認可を最小構成のまま本番に出してしまい、後からセキュリティ事故に発展するケースが少なくありません。特に「JWT を入れたから安全」という誤解は危険です。</p>
<p>本記事では、<strong>開発速度を落とさずに本番で耐える認証基盤</strong>を作るための設計を、コード例と運用手順込みで解説します。</p>
<h2 id="1-認証と認可を分離して設計する">1. 認証と認可を分離して設計する</h2>
<p>最初に押さえるべきは責務分離です。</p>
<ul>
<li>認証（Authentication）: 誰かを確認する</li>
<li>認可（Authorization）: 何をしてよいか判定する</li>
</ul>
<p>この2つを混ぜると、実装も監査も破綻します。FastAPI では dependency を分け、<code>get_current_user</code> と <code>require_permission</code> を独立させるのが基本です。</p>
<h2 id="2-jwt-は短命--リフレッシュ--失効管理で使う">2. JWT は「短命 + リフレッシュ + 失効管理」で使う</h2>
<p>アクセストークンを長寿命にすると、漏えい時の被害が大きくなります。実運用では以下が標準です。</p>
<ul>
<li>Access Token: 5〜15分</li>
<li>Refresh Token: 7〜30日</li>
<li>Refresh Token は DB 保存し、ローテーション時に旧トークンを失効</li>
</ul>
<p><code>sub</code> だけでなく、<code>jti</code>（トークンID）や <code>scope</code> を持たせると管理しやすくなります。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> datetime <span style="color:#f92672">import</span> datetime, timedelta, timezone
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> jwt
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ALGORITHM <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;HS256&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">create_access_token</span>(user_id: str, scopes: list[str], secret: str) <span style="color:#f92672">-&gt;</span> str:
</span></span><span style="display:flex;"><span>    now <span style="color:#f92672">=</span> datetime<span style="color:#f92672">.</span>now(timezone<span style="color:#f92672">.</span>utc)
</span></span><span style="display:flex;"><span>    payload <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;sub&#34;</span>: user_id,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;scope&#34;</span>: <span style="color:#e6db74">&#34; &#34;</span><span style="color:#f92672">.</span>join(scopes),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;iat&#34;</span>: int(now<span style="color:#f92672">.</span>timestamp()),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;exp&#34;</span>: int((now <span style="color:#f92672">+</span> timedelta(minutes<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>))<span style="color:#f92672">.</span>timestamp()),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;jti&#34;</span>: <span style="color:#e6db74">&#34;generated-uuid&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> jwt<span style="color:#f92672">.</span>encode(payload, secret, algorithm<span style="color:#f92672">=</span>ALGORITHM)
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="3-鍵管理とローテーション">3. 鍵管理とローテーション</h2>
<p>秘密鍵を <code>.env</code> に固定して数年運用するのは典型的な事故パターンです。最低限、次を実施します。</p>
<ul>
<li>KMS/Vault など外部シークレット管理を利用</li>
<li><code>kid</code> をヘッダに持たせ、複数鍵を並行運用</li>
<li>鍵ローテーション手順を runbook 化</li>
</ul>
<p>ローテーションの要点:</p>
<ol>
<li>新鍵を追加（検証側は新旧どちらも受理）</li>
<li>発行側を新鍵へ切替</li>
<li>旧鍵の有効期限を過ぎたら削除</li>
</ol>
<p>この手順にすると、無停止で切替できます。</p>
<h2 id="4-fastapi-dependencyで認可を明示化">4. FastAPI Dependencyで認可を明示化</h2>
<p>ロジック中で <code>if user.role == &quot;admin&quot;</code> を乱立させると、抜け漏れが起こります。権限チェックは dependency 化し、ルート定義に明示します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> fastapi <span style="color:#f92672">import</span> Depends, HTTPException, status
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">require_permission</span>(required: str):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">checker</span>(user<span style="color:#f92672">=</span>Depends(get_current_user)):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> required <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> user<span style="color:#f92672">.</span>permissions:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">raise</span> HTTPException(
</span></span><span style="display:flex;"><span>                status_code<span style="color:#f92672">=</span>status<span style="color:#f92672">.</span>HTTP_403_FORBIDDEN,
</span></span><span style="display:flex;"><span>                detail<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;insufficient permissions&#34;</span>
</span></span><span style="display:flex;"><span>            )
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> user
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> checker
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@router.delete</span>(<span style="color:#e6db74">&#34;/projects/</span><span style="color:#e6db74">{project_id}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">delete_project</span>(
</span></span><span style="display:flex;"><span>    project_id: str,
</span></span><span style="display:flex;"><span>    user<span style="color:#f92672">=</span>Depends(require_permission(<span style="color:#e6db74">&#34;project:delete&#34;</span>))
</span></span><span style="display:flex;"><span>):
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">...</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>ルート単位で要件が見えるため、レビュー効率と監査性が上がります。</p>
<h2 id="5-rbacabac-の使い分け">5. RBAC/ABAC の使い分け</h2>
<p>小規模なら RBAC（role-based）で十分ですが、顧客単位データや組織階層があると ABAC（属性ベース）を併用した方が安全です。</p>
<ul>
<li>RBAC: <code>admin</code>, <code>editor</code>, <code>viewer</code></li>
<li>ABAC: <code>tenant_id</code>, <code>resource_owner_id</code>, <code>department</code></li>
</ul>
<p>実務では「ロールで粗く許可し、属性で絞る」が扱いやすいです。</p>
<h2 id="6-マルチテナントで必須の防御">6. マルチテナントで必須の防御</h2>
<p>マルチテナント API では、ID 推測よりも<strong>テナント境界漏れ</strong>が主要リスクです。対策は次の通りです。</p>
<ul>
<li>すべての DB クエリに <code>tenant_id</code> 条件を必須化</li>
<li>管理者 API でも境界を明示的に超える操作だけ許可</li>
<li>監査ログに <code>tenant_id</code>, <code>actor_id</code>, <code>resource_id</code> を残す</li>
</ul>
<p>SQLAlchemy でも repository 層で共通フィルタを強制すると漏れを減らせます。</p>
<h2 id="7-監査ログを設計段階で入れる">7. 監査ログを設計段階で入れる</h2>
<p>認証系は障害後に「誰が何をしたか」が必要になります。後付けだと間に合いません。最低限、次を記録します。</p>
<ul>
<li>ログイン成功/失敗（IP, user-agent, reason）</li>
<li>権限エラー（403）</li>
<li>重要操作（削除、権限変更、請求情報更新）</li>
<li>トークン失効・再発行</li>
</ul>
<p>フォーマットは JSON 構造化に統一し、SIEM や OpenSearch に流せる形にしておくと分析が速いです。</p>
<h2 id="8-レート制限とブルートフォース対策">8. レート制限とブルートフォース対策</h2>
<p>パスワード認証がある場合、レート制限なしは危険です。<code>slowapi</code> などを使い、ログイン系エンドポイントに制限を入れます。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> slowapi <span style="color:#f92672">import</span> Limiter
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> slowapi.util <span style="color:#f92672">import</span> get_remote_address
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>limiter <span style="color:#f92672">=</span> Limiter(key_func<span style="color:#f92672">=</span>get_remote_address)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@router.post</span>(<span style="color:#e6db74">&#34;/auth/login&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@limiter.limit</span>(<span style="color:#e6db74">&#34;5/minute&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">login</span>(<span style="color:#f92672">...</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">...</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>さらに次を組み合わせると強化できます。</p>
<ul>
<li>失敗回数に応じた遅延（progressive delay）</li>
<li>CAPTCHA（必要時のみ）</li>
<li>異常IP/ASN の遮断</li>
</ul>
<h2 id="9-よくある実装ミス">9. よくある実装ミス</h2>
<h3 id="ミスa-署名検証はしているが-audiss-未検証">ミスA: 署名検証はしているが <code>aud/iss</code> 未検証</h3>
<p>結果:</p>
<ul>
<li>他システム向けトークンを誤受理</li>
</ul>
<p>対処:</p>
<ul>
<li>issuer/audience を厳格検証</li>
<li>想定外クレームは拒否</li>
</ul>
<h3 id="ミスb-refresh-token-の使い回しを検知しない">ミスB: refresh token の使い回しを検知しない</h3>
<p>結果:</p>
<ul>
<li>漏えい時に長期間乗っ取られる</li>
</ul>
<p>対処:</p>
<ul>
<li>ローテーション時に旧トークン失効</li>
<li>再利用検知時はセッション全失効</li>
</ul>
<h3 id="ミスc-認可チェックが一部エンドポイントで抜ける">ミスC: 認可チェックが一部エンドポイントで抜ける</h3>
<p>結果:</p>
<ul>
<li>水平権限昇格</li>
</ul>
<p>対処:</p>
<ul>
<li>dependency ベースで強制</li>
<li>重要ルートにセキュリティテスト追加</li>
</ul>
<h2 id="10-テスト戦略必須">10. テスト戦略（必須）</h2>
<p>認証はユニットテストだけでなく、統合テストで権限境界を確認します。</p>
<ul>
<li>有効トークン/期限切れ/改ざんトークン</li>
<li>role ごとのアクセス可否</li>
<li>tenant 越境アクセス拒否</li>
<li>refresh token 再利用検知</li>
</ul>
<p>pytest では fixture で role 別トークンを用意し、回帰を防ぎます。</p>
<h2 id="11-障害時-runbook最低限">11. 障害時 runbook（最低限）</h2>
<p>インシデント時に迷わないよう、次を文書化しておきます。</p>
<ol>
<li>鍵漏えい疑い時の全トークン失効手順</li>
<li>認証基盤障害時のフェイル動作（許可しすぎを防ぐ）</li>
<li>監査ログの検索手順</li>
<li>関係者通知テンプレート</li>
</ol>
<p>特に「認証サーバーが落ちたとき、API をどうするか」は事前に決めておく必要があります。</p>
<h2 id="12-導入チェックリスト">12. 導入チェックリスト</h2>
<ul>
<li><input disabled="" type="checkbox"> access token は短寿命（&lt;=15分）</li>
<li><input disabled="" type="checkbox"> refresh token は DB 管理 + ローテーション</li>
<li><input disabled="" type="checkbox"> 鍵ローテーション手順がある</li>
<li><input disabled="" type="checkbox"> ルート単位で認可 dependency が明示されている</li>
<li><input disabled="" type="checkbox"> tenant 境界を DB レイヤーで強制している</li>
<li><input disabled="" type="checkbox"> 監査ログ（認証/認可/重要操作）を構造化保存</li>
<li><input disabled="" type="checkbox"> レート制限と異常検知がある</li>
<li><input disabled="" type="checkbox"> 権限境界の統合テストがある</li>
</ul>
<p>FastAPI の認証・認可は、フレームワーク機能だけでは守り切れません。<strong>トークン寿命設計、鍵運用、境界強制、監査、テスト、runbook</strong>まで含めて初めて、本番で信頼できるセキュリティ基盤になります。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>FastAPI</category>
      <category>Security</category>
      <category>JWT</category>
      <category>OAuth2</category>
      <category>Python</category>
    </item>
    <item>
      <title>FastAPI本番運用ハードニング完全ガイド：セキュリティ・性能・障害対応を実装で固める</title>
      <link>https://www.ai2core.com/posts/2026-02-28-fastapi-production-hardening-guide/</link>
      <pubDate>Sat, 28 Feb 2026 09:15:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-02-28-fastapi-production-hardening-guide/</guid>
      <description>FastAPIを本番運用する際に必要なセキュリティ、性能最適化、観測性、デプロイ手順を具体的に解説。</description>
      <content:encoded><![CDATA[<h1 id="fastapi本番運用ハードニング完全ガイドセキュリティ性能障害対応を実装で固める">FastAPI本番運用ハードニング完全ガイド：セキュリティ・性能・障害対応を実装で固める</h1>
<p>FastAPI は開発速度が高く、PoC から本番まで一気に進めやすいフレームワークです。しかし、早く作れることと安全に運用できることは別問題です。実際の障害は、コードの正しさよりも運用の隙から発生します。</p>
<p>本記事では、FastAPI を本番で安心して運用するためのハードニング手順を、実装可能な形でまとめます。対象は「すでにAPIが動いているが、運用強度を上げたい」チームです。</p>
<h2 id="1-入口防御tlsヘッダレート制限">1. 入口防御：TLS、ヘッダ、レート制限</h2>
<h3 id="tls終端とforwardedヘッダ">TLS終端とForwardedヘッダ</h3>
<p>ロードバランサ配下で動かす場合、<code>X-Forwarded-For</code> と <code>X-Forwarded-Proto</code> の扱いを明確にします。誤るとクライアントIPが取れず、監査も制限も機能しません。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> fastapi <span style="color:#f92672">import</span> FastAPI
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> starlette.middleware.trustedhost <span style="color:#f92672">import</span> TrustedHostMiddleware
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> FastAPI()
</span></span><span style="display:flex;"><span>app<span style="color:#f92672">.</span>add_middleware(TrustedHostMiddleware, allowed_hosts<span style="color:#f92672">=</span>[<span style="color:#e6db74">&#34;api.example.com&#34;</span>, <span style="color:#e6db74">&#34;*.example.com&#34;</span>])
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>allowed_hosts</code> をワイルドにしすぎると Host Header Injection の温床になります。</p>
<h3 id="セキュリティヘッダ">セキュリティヘッダ</h3>
<p>最低限次を返します。</p>
<ul>
<li><code>Strict-Transport-Security</code></li>
<li><code>X-Content-Type-Options: nosniff</code></li>
<li><code>X-Frame-Options: DENY</code></li>
<li><code>Referrer-Policy</code></li>
</ul>
<p>APIでも無関係ではありません。管理画面やドキュメントUIを守る意味があります。</p>
<h3 id="レート制限">レート制限</h3>
<p>ブルートフォースと突発負荷に備え、IPまたはAPIキー単位でレート制限を設定します。</p>
<ul>
<li>認証系: 5 req/min</li>
<li>通常API: 60 req/min</li>
<li>高負荷検索: 20 req/min</li>
</ul>
<p>Redis バックエンド方式にして、アプリ再起動でカウンタが失われないようにします。</p>
<h2 id="2-認証認可の落とし穴を塞ぐ">2. 認証・認可の落とし穴を塞ぐ</h2>
<h3 id="jwt検証の必須項目">JWT検証の必須項目</h3>
<p><code>exp</code> だけ見て通す実装は危険です。少なくとも次を検証します。</p>
<ul>
<li><code>iss</code>（発行者）</li>
<li><code>aud</code>（想定利用先）</li>
<li><code>nbf</code>（有効開始）</li>
<li><code>kid</code> に基づく鍵ローテーション</li>
</ul>
<h3 id="認可はエンドポイント単位ではなくリソース単位">認可は「エンドポイント単位」ではなく「リソース単位」</h3>
<p><code>/users/{id}</code> のアクセス時に、path パラメータの <code>id</code> とトークンの主体を照合しない事故は頻発します。FastAPI の dependency で統一的に実施します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">authorize_user_resource</span>(current_user, target_user_id: str):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> (current_user<span style="color:#f92672">.</span>is_admin <span style="color:#f92672">or</span> current_user<span style="color:#f92672">.</span>user_id <span style="color:#f92672">==</span> target_user_id):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> HTTPException(status_code<span style="color:#f92672">=</span><span style="color:#ae81ff">403</span>, detail<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;forbidden&#34;</span>)
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="3-入出力の安全化pydanticだけでは不十分">3. 入出力の安全化：Pydanticだけでは不十分</h2>
<p>Pydantic は型安全に強いですが、ビジネス制約は別で実装が必要です。</p>
<ul>
<li>文字列長上限</li>
<li>許可文字セット</li>
<li>SQL/NoSQLインジェクションの危険文字</li>
<li>HTML/Markdown サニタイズ</li>
</ul>
<p>特に検索APIやエクスポートAPIは、クエリ文字列が巨大化しやすく DoS の入口になります。<code>max_length</code> を必ず定義してください。</p>
<h2 id="4-性能ハードニングワーカdbタイムアウト">4. 性能ハードニング：ワーカ・DB・タイムアウト</h2>
<h3 id="uvicorngunicorn-構成">Uvicorn/Gunicorn 構成</h3>
<p>CPUコア数に応じて worker を決めます。目安は <code>workers = 2 * core + 1</code> から開始し、実測で調整。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>gunicorn app.main:app -k uvicorn.workers.UvicornWorker --workers <span style="color:#ae81ff">5</span> --bind 0.0.0.0:8000 --timeout <span style="color:#ae81ff">30</span>
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="db接続プール">DB接続プール</h3>
<p><code>asyncpg</code> や SQLAlchemy async engine のプール上限を設定しないと、ピーク時に接続飽和します。</p>
<ul>
<li>min: 5</li>
<li>max: 30（DB性能と相談）</li>
<li>pool timeout: 5s</li>
</ul>
<h3 id="タイムアウト戦略">タイムアウト戦略</h3>
<p>上流・下流の timeout を揃えないと、雪崩障害が発生します。</p>
<ul>
<li>外部API呼び出し: connect 1s / read 3s</li>
<li>DBクエリ: statement timeout 2s（重処理は別キュー）</li>
<li>API全体: 10s で fail fast</li>
</ul>
<h2 id="5-例外設計と障害時の挙動">5. 例外設計と障害時の挙動</h2>
<p>本番障害では「500が出ること」より「500の意味が不明」なことが問題です。エラーレスポンス形式を固定し、trace_id を必ず返します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;error&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;code&#34;</span>: <span style="color:#e6db74">&#34;INTERNAL_ERROR&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;message&#34;</span>: <span style="color:#e6db74">&#34;unexpected error&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;trace_id&#34;</span>: <span style="color:#e6db74">&#34;8f3d...&#34;</span>
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>内部例外はそのまま返さず、ログ側に stack trace を記録。ユーザーには安全な文言のみ返却します。</p>
<h2 id="6-可観測性ログメトリクストレース">6. 可観測性：ログ・メトリクス・トレース</h2>
<h3 id="構造化ログ">構造化ログ</h3>
<p>JSON ログを標準化し、次を必須項目にします。</p>
<ul>
<li>timestamp</li>
<li>level</li>
<li>service</li>
<li>trace_id</li>
<li>user_id（可能なら）</li>
<li>endpoint</li>
<li>latency_ms</li>
</ul>
<h3 id="メトリクス">メトリクス</h3>
<p>最低限:</p>
<ul>
<li>RPS</li>
<li>エラー率（4xx/5xx）</li>
<li>P50/P95/P99 latency</li>
<li>DB遅延</li>
<li>外部API失敗率</li>
</ul>
<h3 id="トレース">トレース</h3>
<p>OpenTelemetry で endpoint → service → DB をつなぐと、障害切り分けが劇的に速くなります。</p>
<h2 id="7-デプロイ戦略壊さずに出す">7. デプロイ戦略：壊さずに出す</h2>
<p>推奨は Blue/Green か Canary。FastAPI 単体の問題より、周辺設定差異が事故の原因になります。</p>
<p>リリース前チェックリスト:</p>
<ol>
<li>DB migration の後方互換性</li>
<li>依存ライブラリ脆弱性スキャン</li>
<li>load test（代表3API）</li>
<li>rollback 手順の実行確認</li>
<li>feature flag で段階有効化</li>
</ol>
<h2 id="8-運用で効くインシデント訓練">8. 運用で効くインシデント訓練</h2>
<p>月1回、次の擬似障害を実施すると運用強度が上がります。</p>
<ul>
<li>DB遅延 3秒化</li>
<li>外部API 30% 失敗</li>
<li>メモリリーク発生</li>
<li>JWT鍵ローテーション失敗</li>
</ul>
<p>重要なのは、復旧時間だけでなく「誰が何を見て判断したか」を記録することです。Runbook の更新まで含めて初めて訓練が完結します。</p>
<h2 id="9-すぐ使える最小ハードニングチェック">9. すぐ使える最小ハードニングチェック</h2>
<ul>
<li><input disabled="" type="checkbox"> Host ヘッダ制限</li>
<li><input disabled="" type="checkbox"> JWT <code>iss/aud/exp/nbf</code> 検証</li>
<li><input disabled="" type="checkbox"> 全エンドポイントに認可 dependency</li>
<li><input disabled="" type="checkbox"> 外部API timeout/retry/circuit breaker</li>
<li><input disabled="" type="checkbox"> JSON 構造化ログ + trace_id</li>
<li><input disabled="" type="checkbox"> P95 latency 監視とアラート</li>
<li><input disabled="" type="checkbox"> rollback 手順が5分で実行可能</li>
</ul>
<p>この 7 項目が揃うだけで、障害時の被害規模は大きく下がります。</p>
<h2 id="まとめ">まとめ</h2>
<p>FastAPI は高速開発の武器ですが、本番運用では「早く作る」より「安全に壊れる」設計が重要です。入口防御、認証認可、性能制御、観測性、リリース運用をセットで整備すれば、チームは安心して機能開発に集中できます。</p>
<p>もし何から始めるか迷うなら、まずは trace_id 付きの構造化ログと timeout 統一から着手してください。最小の投資で、運用の見通しが一気に良くなります。</p>
<h2 id="10-セキュアな開発フローを維持するためのci設定">10. セキュアな開発フローを維持するためのCI設定</h2>
<p>本番ハードニングはコードだけでなく、CI フローで担保する必要があります。推奨するジョブは次の通りです。</p>
<ol>
<li>依存脆弱性スキャン（pip-audit / osv-scanner）</li>
<li>SAST（bandit など）</li>
<li>型チェック（mypy）</li>
<li>負荷テストのスモーク（k6）</li>
<li>OpenAPI 差分チェック（破壊的変更検出）</li>
</ol>
<p>特に OpenAPI 差分チェックは有効です。意図しないレスポンス変更を早期に検知でき、フロントエンド障害を防げます。</p>
<h2 id="11-バックアップと復旧を設計に含める">11. バックアップと復旧を設計に含める</h2>
<p>API 運用は「壊れない」ではなく「壊れても戻せる」が現実的です。最低限次を決めておきます。</p>
<ul>
<li>DB バックアップ頻度（例: 15分ごと増分、日次フル）</li>
<li>復旧目標（RTO/RPO）</li>
<li>復旧手順の担当と実行順</li>
</ul>
<p>復旧訓練をしていないバックアップは、存在しないのと同じです。四半期に一度は検証環境でリストア演習を行ってください。</p>
<h2 id="12-監査対応を見据えたログ保全">12. 監査対応を見据えたログ保全</h2>
<p>B2B API では監査要件が後から増えることが多いです。最初から次を満たす設計にしておくと後で困りません。</p>
<ul>
<li>監査ログとアプリログを分離</li>
<li>重要操作（権限変更、削除、課金操作）の証跡保存</li>
<li>ログ保持期間の明確化（例: 180日）</li>
<li>改ざん検知（WORM ストレージや署名）</li>
</ul>
<p>「誰が、いつ、何をしたか」を追えることは、障害解析だけでなく法務リスク低減にも直結します。</p>
<h2 id="最終まとめ">最終まとめ</h2>
<p>FastAPI の本番運用は、フレームワーク知識だけでは足りません。セキュリティ、性能、可観測性、復旧性を一体で設計することが重要です。チェックリスト化し、CI と運用手順へ落とし込むことで、安定した開発速度を維持できます。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>FastAPI</category>
      <category>Python</category>
      <category>Security</category>
      <category>SRE</category>
    </item>
  </channel>
</rss>
