FastAPI Task Dispatch with KEDA Autoscaling
Build a FastAPI service that dispatches Celery tasks to KubeMQ-backed workers on Kubernetes, autoscaled by queue depth with KEDA.
Scenario
A FastAPI web tier accepts HTTP requests and offloads slow work to background tasks. Each request dispatches a Celery task with task.delay(...) and immediately returns a task id, so the client never blocks on the work itself. A separate endpoint lets the client poll task status and fetch the result once it is ready.
The twist: there is no Redis and no RabbitMQ anywhere in the stack. Both the Celery broker and the result backend are KubeMQ, reached through the kubemq:// transport. The web pods and the worker pods talk to a single KubeMQ broker, and a KEDA ScaledObject watches the queue depth on that broker to scale the worker Deployment up during request bursts and back down when the queue drains.
This scenario walks the full path: a shared Celery app, the FastAPI endpoints, an optional WebSocket progress stream, the worker tuning profile for API workloads, containerization with docker-compose for local runs, the Kubernetes Deployment, the KEDA trigger, and the reliability settings that keep fast API tasks honest.
Architecture
The web tier and the workers are separate Deployments that share one KubeMQ broker. FastAPI pods only ever produce tasks and read results; the worker Deployment consumes tasks, and KEDA scales it from the queue depth that KubeMQ reports.
Shared Celery app
The web tier and the workers import the same Celery app module. Importing kubemq_celery registers the kubemq:// transport with Kombu, and both the broker and the result backend are read from environment variables so the identical image runs locally and in-cluster. This is the app from examples/integrations/fastapi_integration.py.
from __future__ import annotations
import os
import time
from typing import Any
from celery import Celery
import kubemq_celery # noqa: F401 registers the kubemq:// transport
celery_app = Celery(
"fastapi_integration",
broker=os.environ.get("CELERY_BROKER_URL", "kubemq://localhost:50000"),
result_backend=os.environ.get("CELERY_RESULT_BACKEND", "kubemq://localhost:50000"),
)
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
result_expires=3600,
task_track_started=True,
worker_prefetch_multiplier=1,
)
@celery_app.task(bind=True)
def process_data(self, data: str, options: dict[str, Any] | None = None) -> dict:
"""Process data with configurable transformations."""
options = options or {}
self.update_state(state="PROGRESS", meta={"step": "processing", "percent": 50})
time.sleep(1)
result = data
if options.get("uppercase"):
result = result.upper()
if options.get("reverse"):
result = result[::-1]
return {
"input": data,
"output": result,
"options_applied": list(options.keys()),
}task_track_started=True makes Celery emit a STARTED state the moment a worker picks up the task, which gives the status endpoint a useful intermediate state between PENDING and SUCCESS. import kubemq_celery must run before the app connects, or Kombu will not recognize the kubemq:// scheme.
FastAPI endpoints
FastAPI is the producer. The POST endpoint dispatches the task and returns its id without waiting; the GET endpoint builds an AsyncResult and maps the Celery state onto a response model. This is examples/integrations/fastapi_integration.py.
from typing import Any
from celery.result import AsyncResult
from fastapi import FastAPI
from pydantic import BaseModel
from tasks import celery_app, process_data
class TaskRequest(BaseModel):
data: str
options: dict[str, Any] | None = None
class TaskResponse(BaseModel):
task_id: str
status: str
detail: str
class TaskStatus(BaseModel):
task_id: str
status: str
result: Any | None = None
error: str | None = None
progress: dict[str, Any] | None = None
api = FastAPI(
title="KubeMQ Celery API",
description="FastAPI + Celery with KubeMQ transport",
version="1.0.0",
)
@api.post("/tasks/process", response_model=TaskResponse)
async def submit_task(request: TaskRequest) -> TaskResponse:
"""Submit a data processing task to the KubeMQ broker."""
result = process_data.delay(request.data, request.options)
return TaskResponse(
task_id=result.id,
status="PENDING",
detail="Task submitted to KubeMQ broker",
)
@api.get("/tasks/{task_id}", response_model=TaskStatus)
async def get_task_status(task_id: str) -> TaskStatus:
"""Check the status of a submitted task."""
result = AsyncResult(task_id, app=celery_app)
if result.state == "PROGRESS":
return TaskStatus(task_id=task_id, status="PROGRESS", progress=result.info)
elif result.state == "SUCCESS":
return TaskStatus(task_id=task_id, status="SUCCESS", result=result.result)
elif result.state == "FAILURE":
return TaskStatus(task_id=task_id, status="FAILURE", error=str(result.result))
else:
return TaskStatus(task_id=task_id, status=result.state)The dispatch-and-poll flow looks like this from the client side:
# Submit a task -> returns a task id
curl -X POST http://localhost:8000/tasks/process \
-H "Content-Type: application/json" \
-d '{"data": "hello world", "options": {"uppercase": true}}'
# {"task_id": "a1b2c3...", "status": "PENDING", "detail": "Task submitted to KubeMQ broker"}
# Poll status with the returned id
curl http://localhost:8000/tasks/a1b2c3...
# {"task_id": "a1b2c3...", "status": "SUCCESS", "result": {"input": "hello world", "output": "HELLO WORLD", ...}}Real-time progress
Polling is fine for short tasks, but a longer task can stream its progress to the browser over a WebSocket instead. The task reports fine-grained progress with self.update_state(...), and the WebSocket endpoint reads those PROGRESS states from the result backend and forwards them to the client until the task reaches a terminal state. This combines examples/integrations/fastapi_websocket_progress.py with the progress task from examples/monitoring/progress_tracking.py.
import asyncio
from celery.result import AsyncResult
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from tasks import celery_app
@celery_app.task(bind=True)
def long_running_task(self, steps: int = 10) -> dict:
"""Task that reports progress at each step."""
import time
for i in range(1, steps + 1):
self.update_state(
state="PROGRESS",
meta={
"current": i,
"total": steps,
"percent": int(i / steps * 100),
"message": f"Processing step {i}/{steps}",
},
)
time.sleep(1)
return {"steps_completed": steps, "status": "done"}
api = FastAPI()
@api.post("/tasks/process")
async def submit_task(steps: int = 5) -> dict:
result = long_running_task.delay(steps)
return {"task_id": result.id, "status": "PENDING", "ws_url": f"/ws/progress/{result.id}"}
@api.websocket("/ws/progress/{task_id}")
async def websocket_progress(websocket: WebSocket, task_id: str):
"""Stream task progress updates over WebSocket."""
await websocket.accept()
max_iterations = 300
try:
for _ in range(max_iterations):
result = AsyncResult(task_id, app=celery_app)
if result.state == "PROGRESS":
await websocket.send_json({"status": "PROGRESS", "progress": result.info})
elif result.state == "SUCCESS":
await websocket.send_json({"status": "SUCCESS", "result": result.result})
break
elif result.state == "FAILURE":
await websocket.send_json({"status": "FAILURE", "error": str(result.result)})
break
else:
await websocket.send_json({"status": result.state})
await asyncio.sleep(0.5)
except WebSocketDisconnect:
passEach update_state() overwrites the task's entry on the KubeMQ result channel, and the client reads the latest value by peeking that channel — the backend does not keep a history of progress snapshots. The endpoint caps its read loop at max_iterations so a never-finishing task cannot hold the socket open forever.
Worker config for API workload
Tasks dispatched from web requests need to start fast, not batch efficiently. The low-latency profile from docs/performance.md raises concurrency so more requests run in parallel, keeps prefetch_multiplier=1 so no worker hoards queued tasks while it is busy, and sets max_batch_size=1 to pull one message at a time for the lowest possible dispatch latency.
celery_app.conf.update(
worker_concurrency=8,
worker_prefetch_multiplier=1,
broker_transport_options={
"wait_timeout": 1,
"max_batch_size": 1, # minimize latency
},
)Keep wait_timeout below Celery's drain_events timeout (default 2s) to avoid transport deadlocks. wait_timeout: 1 satisfies this.
Containerize and run locally
Package the shared app into one image and run the same image as both the web tier and the worker, differing only by command. The Dockerfile from examples/kubernetes/Dockerfile installs dependencies with uv.
FROM python:3.12-slim
WORKDIR /app
# Install uv and dependencies
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml .
RUN uv pip install --system --no-cache .
# Copy application code
COPY src/ src/
COPY examples/ examples/
ENV PYTHONPATH=/app/src:/app/examples
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD python -c "import kubemq_celery; print('ok')" || exit 1
CMD ["celery", "-A", "basic_task", "worker", "--loglevel=info"]Before touching Kubernetes, validate the whole stack with docker-compose. This compose file (adapted from examples/kubernetes/docker-compose.yaml) brings up a KubeMQ broker with a health check and a Celery worker that waits for the broker to be ready.
services:
kubemq:
image: europe-docker.pkg.dev/kubemq/images/kubemq:next
ports:
- "50000:50000" # gRPC
- "9090:9090" # Shared HTTP server (REST/health)
- "8080:8080" # Management API dashboard
environment:
- KUBEMQ_LOG_LEVEL=info
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
worker:
build:
context: .
dockerfile: Dockerfile
command: celery -A tasks worker --loglevel=info -Q celery --concurrency=8
environment:
- CELERY_BROKER_URL=kubemq://kubemq:50000
- CELERY_RESULT_BACKEND=kubemq://kubemq:50000
depends_on:
kubemq:
condition: service_healthy
restart: unless-stopped
api:
build:
context: .
dockerfile: Dockerfile
command: uvicorn api:api --host 0.0.0.0 --port 8000
ports:
- "8000:8000"
environment:
- CELERY_BROKER_URL=kubemq://kubemq:50000
- CELERY_RESULT_BACKEND=kubemq://kubemq:50000
depends_on:
kubemq:
condition: service_healthy
restart: unless-stoppeddocker compose up -d
# KubeMQ dashboard: http://localhost:8080 (Management API — watch queue depth here)
# FastAPI: http://localhost:8000/docsDeploy to Kubernetes
In-cluster, the broker runs as a StatefulSet and the workers as a separate Deployment. Workers reach the broker through its Kubernetes service DNS name, and the readiness and liveness probes use celery inspect ping, which exercises the transport's verify_connection() against the broker. This worker Deployment is from docs/kubernetes.md.
apiVersion: apps/v1
kind: Deployment
metadata:
name: celery-worker
namespace: default
labels:
app: celery-worker
spec:
replicas: 3
selector:
matchLabels:
app: celery-worker
template:
metadata:
labels:
app: celery-worker
spec:
containers:
- name: worker
image: myapp:latest
command:
- celery
- -A
- tasks
- worker
- --loglevel=info
- --concurrency=8
env:
- name: CELERY_BROKER_URL
value: "kubemq://kubemq.default.svc:50000"
- name: CELERY_RESULT_BACKEND
value: "kubemq://kubemq.default.svc:50000"
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
readinessProbe:
exec:
command:
- celery
- -A
- tasks
- inspect
- ping
- --timeout=5
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
livenessProbe:
exec:
command:
- celery
- -A
- tasks
- inspect
- ping
- --timeout=5
initialDelaySeconds: 60
periodSeconds: 60
timeoutSeconds: 10The broker itself runs as a StatefulSet exposing gRPC on 50000, the shared HTTP server (REST/health) on 9090, and the Management API dashboard on 8080. Inside the cluster, workers and web pods address it as kubemq.default.svc:50000.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: kubemq
namespace: default
spec:
serviceName: kubemq
replicas: 3 # HA cluster
selector:
matchLabels:
app: kubemq
template:
metadata:
labels:
app: kubemq
spec:
containers:
- name: kubemq
image: europe-docker.pkg.dev/kubemq/images/kubemq:next
ports:
- containerPort: 50000
name: grpc
- containerPort: 9090
name: http
- containerPort: 8080
name: dashboard
readinessProbe:
tcpSocket:
port: 50000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 50000
initialDelaySeconds: 15
periodSeconds: 20The worker probes use celery inspect ping, which connects through the broker. Give the broker StatefulSet a generous initialDelaySeconds (the worker readiness probe waits 30s) so the broker is reachable before workers start failing health checks during a cold start.
Autoscale with KEDA
HPA scales on CPU and memory, which lag behind a queue that is filling from a request burst. KEDA scales on the metric that actually matters here — the KubeMQ queue depth. The kubemq trigger watches the celery channel and adds workers when more than 10 messages are pending, scaling between 1 and 20 replicas. This is examples/kubernetes/keda-scaler.yaml.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: celery-worker-scaler
namespace: default
labels:
app: celery-worker
component: autoscaling
spec:
scaleTargetRef:
name: celery-worker # must match Deployment name
minReplicaCount: 1 # always keep at least 1 worker
maxReplicaCount: 20 # maximum workers
pollingInterval: 10 # check queue depth every 10 seconds
cooldownPeriod: 60 # wait 60s before scaling down
triggers:
- type: kubemq
metadata:
# KubeMQ broker address (cluster-internal)
address: "kubemq.default.svc:50000"
# Queue channel to monitor (Celery default queue)
channel: "celery"
# Scale threshold: add workers when queue depth > 10
queueLength: "10"Install KEDA and apply the scaler:
helm repo add kedacore https://kedacore.github.io/charts
helm install keda kedacore/keda --namespace keda --create-namespace
kubectl apply -f keda-scaler.yamlWhen a request burst hits the FastAPI tier, the celery channel depth climbs, KEDA notices within pollingInterval (10s) and scales the worker Deployment up; once the queue drains it scales back down after cooldownPeriod (60s). Watch the depth live in the KubeMQ Management API dashboard at port 8080 (or query the REST /queue/info endpoint on the shared HTTP server at 9090).
Reliability notes
A few defaults matter for fast, request-driven tasks:
-
acks_earlyis the right default. The message is acknowledged before the task runs, so a quick API task is not re-delivered if a worker dies mid-execution. For at-least-once delivery on a critical task, opt in per task withacks_late=Trueandreject_on_worker_lost=True— but be aware that KubeMQ's transaction timeout can expire for long-running tasks (> 60s), so useacks_earlyor make the task idempotent for those.reliability.py celery_app.conf.task_acks_late = False # default — good for fast API tasks @celery_app.task(acks_late=True, reject_on_worker_lost=True) def critical_payment(payment_id: str) -> dict: """Process payment — must not be lost. Idempotent via payment_id dedup.""" ... -
Configure a dead letter queue for poison messages. The DLQ operates at the broker level (receive count) and is independent of Celery's task-level
max_retries; the two work together so a message that repeatedly fails to process lands in a DLQ channel instead of looping forever. -
Keep
result_expiresat or below 86400. For a polling API, results only need to live long enough for the client to fetch them. The shared app setsresult_expires=3600(one hour); the documented maximum is 86400 (24 hours). Bounding this keeps the result backend from accumulating stale entries.
Next steps
Was this page helpful?