Article

Celeryで実装する分散タスクキューシステム

高田晃太郎
Celeryで実装する分散タスクキューシステム

GitHubの公開情報ではCeleryのスター数は2万超に達し、PyPIのダウンロードも右肩上がりが続いています[1,2]。バックグラウンド処理や非同期実行は、API応答時間の短縮やスループット向上に直結する一方、設計を誤ると再実行の二重処理やボトルネックの固定化を招きます。分散タスクキューは単なる非同期化の道具ではなく、疎結合なマイクロサービス間の調停役でもあります。ここでは、Celeryを用いてRabbitMQをブローカー、Redisを結果バックエンドとする王道構成を軸に、設計思想、実装、運用パターン、観測とスケーリングまで具体的なコードと実務で役立つ知見を交えて解説します。CTOやリーダーの関心事であるROIについても、待ち行列吸収とピーク平準化の観点から触れます。

分散タスクキューの設計思想とCeleryの位置づけ

分散タスクキューの本質は、同期I/OやCPU負荷の高い処理をリクエストの臨界経路から外し、可用性とスループットを同時に高める点にあります。CeleryはAMQP(メッセージング規格)を話すブローカーと連携する成熟したフレームワークで、プロデューサとワーカーを疎結合に保ちながら**少なくとも一度(at-least-once)の配信保証を提供します[3,4,22]。この保証は、障害時に同一タスクが複数回実行され得ることを意味するため、ビジネスロジック側では冪等性(同じ入力なら結果が変わらない性質)**を担保する設計が必須です[5]。決済やメール送信のような副作用を伴う処理では、業務キーとバージョンを用いた重複排除や、結果整合性を許容した冪等トークンの発行が実用的です。

ブローカーにはRabbitMQ、バックエンドにはRedisを採用するのが典型です[3,9]。RabbitMQはフェアディスパッチ(prefetch/QoS: 消費者が先読みするメッセージ数の制御)やデッドレター(DLX: 期限切れや拒否メッセージの避難先)によって配送制御がしやすく[6,7]、(注:可視性タイムアウトはSQSやRedisなど一部ブローカーの概念で、RabbitMQではACK/再配信とTTL/DLXで近似制御します)[8,7]。Redisは結果のTTL(result_expires: 結果の有効期限)を設定でき、軽量なキー操作で冪等性の補助にも向きます[9]。シリアライザはJSONを推奨します。言語間連携や監査に有利で、セキュリティの観点でもpickleを避けるだけでリスクが減ります[10]。

最小構成の実装と設定の要点

まずは最小構成を用意します。RabbitMQとRedisをDockerで起動し、CeleryアプリをPythonで定義します。以降のコードはすべて完全なインポート付きです。

# docker-compose.yml
version: "3.9"
services:
  rabbitmq:
    image: rabbitmq:3.13-management
    ports: ["5672:5672", "15672:15672"]
    environment:
      RABBITMQ_DEFAULT_USER: guest
      RABBITMQ_DEFAULT_PASS: guest
  redis:
    image: redis:7
    ports: ["6379:6379"]
# app.py
from celery import Celery

app = Celery(
    "myapp",
    broker="amqp://guest:guest@localhost:5672//",
    backend="redis://localhost:6379/0",
)

app.conf.update(
    task_serializer="json",
    result_serializer="json",
    accept_content=["json"],
    timezone="Asia/Tokyo",
    enable_utc=False,
    worker_prefetch_multiplier=1,  # バースト時の遅延を抑える(prefetch制御)
    task_acks_late=True,           # 処理後ACKでat-least-onceを担保(ACK=受領通知)
    task_reject_on_worker_lost=True,
    broker_pool_limit=10,
    task_time_limit=30,            # ハードタイムリミット
    task_soft_time_limit=25,       # ソフトタイムリミット(優雅なキャンセル)
    worker_concurrency=4,
)
# tasks.py
import hashlib
import time
import logging
from typing import Dict
import requests
from redis import Redis
from celery import shared_task
from celery.exceptions import SoftTimeLimitExceeded
from app import app

redis_client = Redis(host="localhost", port=6379, db=1)
logger = logging.getLogger(__name__)

@shared_task(bind=True, autoretry_for=(requests.exceptions.RequestException,),
             retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 5})
def fetch_and_hash(self, url: str) -> Dict[str, str]:
    key = f"idem:fetch_and_hash:{hashlib.sha256(url.encode()).hexdigest()}"
    if not redis_client.setnx(key, "1"):
        ttl = redis_client.ttl(key)
        logger.info("duplicate suppressed for %s (ttl=%s)", url, ttl)
        return {"url": url, "status": "duplicate"}
    redis_client.expire(key, 3600)
    try:
        resp = requests.get(url, timeout=10)
        resp.raise_for_status()
        digest = hashlib.sha256(resp.content).hexdigest()
        return {"url": url, "sha256": digest}
    except SoftTimeLimitExceeded:
        logger.warning("soft time limit exceeded for %s", url)
        raise
    except Exception as e:
        logger.exception("unexpected error: %s", e)
        raise

@shared_task(bind=True)
def cpu_heavy(self, n: int) -> int:
    # デモ用に軽いCPU処理
    s = 0
    for i in range(n):
        s ^= (i * i) % 97
    time.sleep(0.01)
    return s
# producer.py
from celery import group, chord
from app import app
from tasks import fetch_and_hash, cpu_heavy

if __name__ == "__main__":
    urls = [
        "https://www.example.com/",
        "https://www.python.org/",
        "https://www.palletsprojects.com/",
    ]
    job = group(fetch_and_hash.s(u) for u in urls)()
    print("hash job id:", job.id)

    # chordのコールバックには集計タスクを指定するのが実用的
    from celery import shared_task

    @shared_task(name="tasks.reducer")
    def reducer(results):
        return sum(results)

    hdr = (cpu_heavy.s(10000) for _ in range(100))
    res = chord(hdr)(reducer.s())
    print("chord id:", res.id)
# celeryconfig.py
from celery.schedules import crontab
beat_schedule = {
    "cleanup-every-5min": {
        "task": "tasks.cpu_heavy",
        "schedule": crontab(minute="*/5"),
        "args": (1000,),
    },
}

実行はワーカーとビートの2プロセスに分けるのが一般的です。開発環境であれば、ワーカーは celery -A app.app worker --loglevel=INFO、ビートは celery -A app.app beat --loglevel=INFO のように起動します。取り回しやすいのはJSONシリアル化、遅延ACK、ソフトタイムリミットの組み合わせです。前者は安全性、後二者は再実行時の整合を助けます[10,22,12]。

設定の深掘りと落とし穴の回避

worker_prefetch_multiplier(先読み数)を大きくするとスループットは伸びますが、バースト時の待ち時間(キュー滞留)が肥大化します。API側のP95(95パーセンタイルの応答時間)を守りたいなら1〜2に抑え、バックグラウンドのバッチ処理ならCPUバウンド度合いに応じて4以上を検討します[4,11]。task_acks_lateはプロセス落ち時の再配信を可能にしますが、ワーカー側の冪等性が甘いと二重実行を招きます[22,5]。可視性タイムアウトやデッドレターキューはブローカー実装に依存するため、RabbitMQではTTL(有効期限)やDLXポリシーで制御し、タイムアウト(または再配信不能)に達したメッセージを別隊列へ送り手動審査できるようにしておくと運用が安定します[7,8]。

本番運用で効くCeleryパターン

再試行は指数バックオフとジッタ(ゆらぎ)を標準で提供します。外部APIのスロットリングに追随しつつ、スパイクの共鳴を避けられます[13]。時間制約が厳しい処理はソフト・ハードの二重タイムリミットを用い、ソフトで優雅にキャンセルし、ハードでワーカーを強制中断します[12]。冪等性はアプリケーション側での最重要責務で、上記のsetnxにより一意キーの獲得と期限付き抑止を組み合わせるのが現実解です[5]。

# chains_and_callbacks.py
from celery import chain
from tasks import fetch_and_hash, cpu_heavy

if __name__ == "__main__":
    c = chain(
        fetch_and_hash.s("https://www.python.org/")
        | cpu_heavy.s()
    )
    result = c.apply_async()
    print(result.get(timeout=60))

大量の小仕事を集計するにはgroupとchordが有効です。fan-outで分散し、集計コールバックで結果を統合します[14]。コールバックの重複実行が致命的な場合は、集計側にも冪等キーを導入し、成功時に明示的に成果物へコミットする二相的な流儀を採用するとよいでしょう[5]。

# chord_example.py
from celery import chord, shared_task
from tasks import cpu_heavy

@shared_task(name="tasks.reducer")
def reducer(results):
    return sum(results)

if __name__ == "__main__":
    hdr = (cpu_heavy.s(20000) for _ in range(200))
    job = chord(hdr)(reducer.s())
    print(job.get(timeout=120))

定期実行はCelery Beatで事足りますが、クラスタで単一実行を保証したいならリーダー選出を組み合わせるか、外部のスケジューラ(AirflowやKubernetes CronJob)へ委譲する選択もあります。Web開発の現場では既存のインフラとの親和性で決めるのが最終的にコストを下げます[15]。

エラーハンドリングと観測可能性の組み込み

例外は失敗キューへのルーティングと通知で検知可能にします。requests由来のエラーはautoretry_forで包み、未知の例外は明示的にraiseして結果バックエンドに記録します[13]。メトリクス(可観測性のための数値指標)はCeleryのイベントから収集できますが、手早い方法としてPrometheusのカスタムカウンタを増分しておくとダッシュボード化が容易です。Celeryのシグナル(task_success/task_failure)をフックすれば、成功・失敗件数を外部エクスポータに送れます[16]。

# metrics.py
from prometheus_client import Counter, start_http_server

TASK_SUCCESS = Counter("celery_task_success_total", "Tasks succeeded", ["name"]) 
TASK_FAILURE = Counter("celery_task_failure_total", "Tasks failed", ["name"]) 

def boot_metrics(port: int = 8000) -> None:
    start_http_server(port)
# wiring_metrics.py
import functools
from celery import signals
from metrics import TASK_SUCCESS, TASK_FAILURE, boot_metrics

boot_metrics(8000)

@signals.task_success.connect
def on_success(sender=None, **kwargs):
    TASK_SUCCESS.labels(name=sender.name).inc()

@signals.task_failure.connect
def on_failure(sender=None, **kwargs):
    TASK_FAILURE.labels(name=sender.name).inc()

シグナルで成功と失敗を拾えば、レイテンシは結果バックエンドのタイムスタンプ差分で近似できます。Flowerは celery -A app.app flower --port=5555 で起動でき、キュー深さ、実行中、失敗の可視化が即座に得られます[17]。

スケーリング、チューニング、計測結果

スケールは二方向があります。ワーカー数を水平展開する方法と、1プロセス内の並列度(—concurrency)を上げる方法です。CPUバウンド処理では論理CPU数程度が目安で、I/Oバウンドならイベントループやスレッドプールを併用して並行度を上げるのが効果的です[4]。RabbitMQ側ではキューのパーティショニングや優先度キューでホットスポットを緩和できます[18]。prefetchを小さくして待ち時間を削り、バッチ処理の時間帯にだけprefetchを上げる運用も現場では定番です[11]。--autoscale=8,2 のような自動スケールは手軽ですが、短周期の伸縮はスラッシングを招くため、HPA/外部メトリクス(例:KEDAのRabbitMQスケーラ)でキュー深さに対する緩やかな目標値制御をかけるのが堅実です[19]。

一般的な傾向として、CPUバウンドなタスクでは並列度を闇雲に上げると文脈切替が増え、効率が頭打ちになります。一方、I/Oバウンドなタスクではprefetchの設定や非同期I/O化により、待ち時間を抑えつつスループットを高めやすくなります。acks_lateを無効にするとワーカー異常終了時の取りこぼしが発生しやすいため、再配信に頼れない設計でない限りは有効化が安全です[22]。これらはあくまで代表的なパターンですが、APIのP95とバッチの総所要時間を**別々のSLO(サービス目標)**として最適化することが鍵であると分かります[4]。

コスト面では、APIの同期処理に占める重い後処理をCeleryへオフロードするだけで、応答のP95が目に見えて短縮され、同じSLOを達成するためのアプリケーションPodやインスタンス規模を抑えられる可能性があります。ピークをキューに吸収できるため、オートスケールの反応時間に依存せず、より小さな構成で運用できる点がROIに直結しやすい設計です[4,11]。

セキュリティと信頼性の実務ポイント

ブローカー接続にはTLSを有効化し、amqpsスキームで証明書を指定します[20]。結果バックエンドのRedisもTLSとAUTHを使い、結果のTTLを短めにしてPIIの滞留を避けます[21,9]。RabbitMQでは死隊列(DLX)へ失敗を送るポリシーを設定し、一定回数の再試行後に人手対応へ落とし込むのが健全です[7]。メッセージサイズは小さく保ち、バイナリはオブジェクトストレージへ置いて参照URLのみを渡します。これによりネットワーク帯域とシリアライズ時間を同時に削減できます[4,10]。

まとめ:小さく始めて、観測で育てる

Celeryはシンプルに始められ、運用の知見に応じて段階的に高度化できる道具です。最小構成を確立し、冪等性と再試行、ソフト・ハードタイムリミット、観測の4点を押さえるだけで、Web開発の大半の非同期要件は満たせます[5,13,12,16]。次に、prefetchと並列度を分けて最適化し、APIのP95とバッチの総所要時間という二つの物差しで継続的にチューニングしていきましょう[11,4]。チームの規模やトラフィックの性質はプロダクトごとに異なります。あなたの現場ではどのキューが最も混み合い、どのSLOが真に顧客体験を規定しているでしょうか。まずは本稿のコードをそのまま動かし、FlowerとPrometheusのダッシュボードを眺めながら、最初のボトルネックを一つ解消するところから始めてください[17]。

参考文献

  1. Celery GitHub Repository. https://github.com/celery/celery
  2. PyPI Stats: celery. https://pypistats.org/packages/celery
  3. Celery Docs — Backends and Brokers (stable). https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/
  4. Celery Docs — User Guide: Optimizing. https://docs.celeryq.dev/en/stable/userguide/optimizing.html
  5. Celery Docs — Glossary: Idempotent. https://docs.celeryq.dev/en/v5.5.1/glossary.html#term-idempotent
  6. RabbitMQ Docs — Consumer Prefetch (QoS). https://www.rabbitmq.com/consumer-prefetch.html
  7. RabbitMQ Docs — Dead Letter Exchanges. https://www.rabbitmq.com/dlx.html
  8. Celery Docs — Glossary: visibility_timeout. https://docs.celeryq.dev/en/v5.5.1/glossary.html#term-visibility-timeout
  9. Celery Docs — Configuration: result_expires / Redis Result Backend. https://docs.celeryq.dev/en/stable/userguide/configuration.html#result-expires
  10. Celery Docs — Security Guide: Serializers. https://docs.celeryq.dev/en/stable/userguide/security.html#serializers
  11. Celery Docs — Optimizing: Prefetch and Latency. https://docs.celeryq.dev/en/stable/userguide/optimizing.html#prefetch-limits
  12. Celery Docs — Tasks: Time Limits (soft/hard). https://docs.celeryq.dev/en/stable/userguide/tasks.html#time-limits
  13. Celery Docs — Tasks: Retrying, Backoff and Jitter. https://docs.celeryq.dev/en/stable/userguide/tasks.html#retrying
  14. Celery Docs — Canvas (group, chord, chain). https://docs.celeryq.dev/en/stable/userguide/canvas.html
  15. Celery Docs — Periodic Tasks (Beat). https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html
  16. Celery Docs — Signals. https://docs.celeryq.dev/en/stable/userguide/signals.html
  17. Flower Documentation. https://flower.readthedocs.io/
  18. RabbitMQ Docs — Priority Queues. https://www.rabbitmq.com/priority.html
  19. KEDA — RabbitMQ Queue Scaler. https://keda.sh/docs/latest/scalers/rabbitmq-queue/
  20. RabbitMQ Docs — TLS/SSL Support. https://www.rabbitmq.com/ssl.html
  21. Redis Docs — TLS Encryption. https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/
  22. Celery Docs — Configuration: task_acks_late. https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-acks-late