<?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>Reliability on AI2CORE - AI技術ブログ</title>
    <link>https://www.ai2core.com/tags/reliability/</link>
    <description>Recent content in Reliability on AI2CORE - AI技術ブログ</description>
    <generator>Hugo -- 0.146.4</generator>
    <language>ja</language>
    <lastBuildDate>Fri, 06 Mar 2026 09:03:00 +0900</lastBuildDate>
    <atom:link href="https://www.ai2core.com/tags/reliability/index.xml" rel="self" type="application/rss+xml" />
    <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>
  </channel>
</rss>
