← Back to list

Part 3 — Understanding Celery: From Broker to Worker to Production

Part 3 of 3 — Celery in Production: Designing for Reliability, Not Just Throughput

Vishal lad · 2026-06-22 16:20 · 0 claps · 15.6 min read
#celery #kubernetes #python #backend-engineering #software-architecture
Open on Medium ↗
Wiki topics: 🌐 · Web Development ☁️ · DevOps & Cloud 🏛️ · Architecture

Part 3 — Understanding Celery: From Broker to Worker to Production

Part 3 of 3 — Celery in Production: Designing for Reliability, Not Just Throughput

AI Generated

AI Generated

This is Part 3 of a 3-part series. Part 1 covers the broker layer. Part 2 covers how Celery workers actually work. This part covers production pipeline design, scaling, failure modes, and knowing the limits.

What This Part Covers

Part 1 established the broker layer — Redis, BRPOP, Streams, persistence, and high availability. Part 2 went inside the worker — the process tree, acknowledgement, visibility timeout, concurrency models, and prefetching. This part is where it all comes together.

How do you design queues and pipelines for reliability? How does Kubernetes scaling actually work with Celery? What are the failure modes that only show up in production? And when does Celery stop being the right answer?

Designing for Production

Separate Queues Per Task Type

The first design mistake is running all tasks through a single default queue. When tasks of different types share one queue, a slow task type that accumulates at the front blocks everything behind it — including fast tasks whose workers are idle and ready. This is called head-of-line blocking: the queue can only be drained from the front, so whatever is first determines how long everything else waits.

The fix is separate queues per task type, each consumed by a dedicated worker pool sized for that work. A backlog in one queue does not affect throughput in another — the queues are independent and drain at their own pace.

In a document processing pipeline this means separate queues for extraction, vectorization, and summarization. Each has different resource profiles, different concurrency requirements, and different scaling thresholds. Putting them in one queue means the configuration is always wrong for at least two of the three.

task_routes = {
    'tasks.extract':   {'queue': 'extraction'},
    'tasks.vectorize': {'queue': 'vectorization'},
    'tasks.summarize': {'queue': 'summarization'},
}

Once queues are separated, the natural next question is how to coordinate multiple stages that depend on each other. That is what pipelines address.

Multi-Stage Pipelines

Separate queues solve the problem of concurrent tasks competing for resources. But many workloads have a different problem — tasks that must run in a specific order, where each step depends on the previous one completing successfully. Extraction must finish before vectorization can start. Vectorization must finish before summarization can start.

Without any coordination, you would have to manually trigger each stage — check if stage one is done, then enqueue stage two, then check again, then enqueue stage three. This is fragile and adds polling logic to your application.

Celery provides chain for this. A chain links tasks together so that when one completes, it automatically triggers the next. You define the full sequence upfront and Celery handles the sequencing.

But chain does two things, not one. It handles sequencing — triggering the next task when the previous one finishes. And by default, it also passes the return value of each task as the first argument to the next task. Stage one returns its result, Celery injects that into stage two, stage two returns its result, Celery injects that into stage three.

This data passing sounds convenient but creates two real problems. First, if stage one returns a large object — say, the full extracted text of a 50-page document — that data is serialized and pushed through Redis as a message. Large payloads add latency and memory pressure to the queue. Second, if stage two fails and retries, Celery needs stage one’s return value to call stage two again. If that value is not stored anywhere, stage one has to re-run just to regenerate it.

So the question becomes: how do you keep the sequencing that chain provides, but stop it from passing return values between tasks?

That is exactly what .si() does. Signature immutable — adding .si() tells Celery to use chain for sequencing only. Ignore whatever the previous task returned. Call this task with only the arguments explicitly provided, nothing injected.

from celery import chain

def trigger_pipeline(item_id):
    pipeline = chain(
        stage_one.si(item_id),    # completes → automatically triggers stage_two
        stage_two.si(item_id),    # receives only item_id, not stage_one's output
        stage_three.si(item_id),  # same — only item_id
    )
    pipeline.delay()

chain provides the sequencing. .si() removes the data injection. Each stage saves its output to the database when it completes and reads what it needs from the database when it starts. If stage two fails and retries, it queries the database for stage one's saved output. Stage one does not re-run. This is what stage isolation means in practice — a retry in one stage only retries that stage.

Retries and Timeouts

Tasks fail and external APIs rate-limit or go down. Models time out and network calls hang. A production task queue needs an explicit strategy for all of these — not because failures are exceptional, but because they are routine. Celery gives you two tools: retries for recoverable failures and time limits for tasks that hang.

Retries — When a task fails due to a transient error — a rate limit, a momentary network blip — you want to try again after a delay. The key question is how long to wait. Retrying immediately after a rate limit hits the same limit again. A fixed delay of 10 seconds retries at the same rate forever.

Exponential backoff — is the standard solution: each retry waits twice as long as the previous one. Retry 0 waits 60 seconds, retry 1 waits 120 seconds, retry 2 waits 240 seconds. This gives the external service progressively more time to recover.

Time limits — Some tasks do not fail — they hang. A network call that never times out, an API that stops responding mid-request. Without a time limit, the worker process waits indefinitely. It stops processing new tasks but reports itself as healthy. The queue grows silently behind it.

Celery provides two time limit layers. soft_time_limit raises a Python exception inside the running task after a set number of seconds — your code can catch this, save state, and exit cleanly. time_limit sends a SIGKILL — a forceful operating system signal that kills the process immediately with no opportunity for cleanup. Set both: the soft limit gives your code a chance to handle the situation gracefully, the hard limit guarantees termination regardless.

from celery.exceptions import SoftTimeLimitExceeded
@app.task(
    bind=True,
    queue='summarization',
    max_retries=5,
    soft_time_limit=240,    # raises SoftTimeLimitExceeded at 4 min
    time_limit=300,         # hard SIGKILL at 5 min - no cleanup possible
)
def summarize(self, item_id):
    try:
        result = call_external_api(item_id)
        save_result(item_id, result)
    except RateLimitError as e:
        # Retry 0: 60s, Retry 1: 120s, Retry 2: 240s
        raise self.retry(exc=e, countdown=60 * 2**self.request.retries)
    except SoftTimeLimitExceeded:
        # 4 minute warning - update status before hard kill at 5 min
        update_status(item_id, 'timed_out')
        raise

soft_time_limit and time_limit are two layers. Soft raises a Python exception inside your running task — you can catch it, update status, and re-raise. Hard sends SIGKILL with no cleanup possible. Set both. Always.

Scaling on Kubernetes with KEDA

When queue depth grows, you need more workers and when it drains, you need fewer. Doing this manually does not scale. Kubernetes has a built-in autoscaler called HPA (Horizontal Pod Autoscaler). HPA watches CPU usage or memory consumption and adds pods when either exceeds a threshold. For a web server, this makes sense — more traffic means more CPU. For a Celery worker, it does not. A worker pod sitting idle waiting on BRPOP uses almost no CPU. You could have 500 tasks queued and HPA would see 0% CPU and add zero pods.

KEDA (Kubernetes Event Driven Autoscaler) was built to solve exactly this. Instead of watching CPU or memory, KEDA watches an external event source — in this case, the length of a Redis list. When the queue depth crosses a threshold, KEDA tells Kubernetes to add more worker pods. When the queue drains, KEDA scales back down.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: worker-scaler
spec:
  scaleTargetRef:
    name: worker-deployment
  minReplicaCount: 2
  maxReplicaCount: 20
  triggers:
    - type: redis
      metadata:
        address: redis:6379
        listName: my_queue
        listLength: "10"    # 1 pod per 10 queued tasks

Scale-up smoothing — If the queue depth jumps from 10 to 200 tasks suddenly, KEDA might try to scale from 2 pods to 20 pods in one step. 18 pods starting simultaneously each open multiple Redis connections — this can cause a brief connection spike that disrupts existing workers. A stabilization policy limits how many pods are added per minute, smoothing the ramp-up:

behavior:
  scaleUp:
    stabilizationWindowSeconds: 60
    policies:
      - type: Pods
        value: 4
        periodSeconds: 60     # add at most 4 pods per minute

Graceful shutdown — When the queue drains, KEDA scales pods down. Kubernetes signals each pod with SIGTERM — a request to stop gracefully. Celery catches SIGTERM, stops accepting new tasks, waits for any in-flight tasks to finish, then exits cleanly. terminationGracePeriodSeconds controls how long Kubernetes waits before sending SIGKILL and force-killing the pod:

spec:
  terminationGracePeriodSeconds: 300

Set this lower than your longest task duration and Kubernetes will kill pods mid-task. Those tasks go back to the queue via the task_acks_late mechanism — but avoidable restarts add noise to your monitoring and increase reprocessing overhead.

With workers scaling correctly, you need to know the state of every task at every moment — which brings up a subtle but important gap in what Celery provides out of the box.

Tracking Status in Your Own Database

When a user uploads a document and waits for it to be processed, they need to know what is happening — is it queued, running, done, or failed? Your application needs to serve that information. The obvious answer is to ask Celery: it has a built-in result backend that stores task outcomes — success, failure, and return values.

It looks like the natural solution. But it has two problems that make it unreliable as a source of truth for user-facing status.

First, result backend entries have a TTL (time-to-live) — they expire and are deleted after a configurable period. A task that completed yesterday may no longer have a record in the backend today.

Second, Celery returns PENDING for any task ID it does not recognise — including task IDs that never existed. If the result backend loses an entry due to TTL expiry or a Redis restart, querying that task ID returns PENDING, which looks identical to a task that is genuinely waiting in the queue. There is no way to tell the difference.

Track task status in your own database instead, updated by the task itself at each stage:

def enqueue_task(item_id):
    TaskStatus.objects.create(item_id=item_id, status='queued')
    task = process_item.delay(item_id)
    TaskStatus.objects.filter(item_id=item_id).update(
        celery_task_id=task.id
    )

This gives you a queryable, persistent record of every task’s state that survives Redis restarts, TTL expiry, and backend outages. Knowing the state of your tasks is only useful if you are also watching them in aggregate — which requires monitoring.

Monitoring and Observability

Once your pipeline is running, you need visibility into what is actually happening. Tasks fail silently, queues build up without anyone noticing or a a worker hangs and looks healthy while the queue grows behind it. Without monitoring, the first signal that something is wrong is a user complaint.

Monitoring a Celery deployment has three distinct layers, each answering a different question.

Real-time visibility — what is running right now? Which workers are connected? Which tasks are active? Are any queues backing up? This is operational awareness at the moment.

Metrics and alerting — what patterns are emerging over time? Is the retry rate climbing? Is task duration growing? Is Redis memory approaching its limit? This is where you catch problems before they become incidents.

Exception tracking — when a task fails, what exactly happened? What were the arguments? How many times had it retried? This is where you debug individual failures after the fact.

Flower addresses the first layer. It is Celery’s built-in real-time monitoring dashboard — showing connected workers, active tasks, failure rates, and queue depths. It requires no additional infrastructure and should be running from day one.

pip install flower
celery -A app flower --port=5555 --basic-auth=admin:secret

Prometheus and Grafana address the second layer. Prometheus scrapes metrics from your workers and Redis on a schedule and stores them as time-series data. Grafana reads from Prometheus and renders dashboards and alerts. The metrics that matter most for a Celery deployment are:

Queue depth per queue         →  are tasks accumulating faster than workers drain them?
Active worker count           →  did workers lose connection or crash?
Task retry rate               →  is a downstream service degraded?
Task execution time (p50/p99) →  are tasks taking longer than expected?
Failed task count             →  is a poison pill message cycling through the queue?
Redis memory usage            →  are you approaching the eviction threshold?
Redis connected clients       →  are you approaching the connection limit?

p50 and p99 are percentiles — p50 is the median execution time, p99 is the time that 99% of tasks complete within. p99 catches slow outliers that the average hides.

Exception tracking addresses the third layer. When a task fails, you need to know what exactly happened — the arguments it was called with, how many times it had retried, and which line of code raised the exception. A raw log line with a stack trace is workable but slow to debug at scale. Centralised exception tracking tools like Sentry group related failures, make them searchable, and attach execution context automatically. It is not mandatory — teams with mature log aggregation (Datadog, ELK, CloudWatch) can get similar visibility from logs — but it significantly reduces the time to diagnose a failing task:

import sentry_sdk
sentry_sdk.init(dsn="your-dsn")

@app.task(bind=True)
def process_task(self, item_id):
    sentry_sdk.set_tag("celery_task_id", self.request.id)
    sentry_sdk.set_tag("item_id", item_id)
    # ... task code

With the right design in place, the next thing to understand is what fails anyway — not because of design mistakes, but because distributed systems fail in ways you do not always anticipate.

What Goes Wrong in Production

Queue Starvation

Symptom — A specific task type is taking an hour to start despite workers being available. In Flower, you can see other tasks completing — the queue is being drained, not the tasks you are waiting for. The fast tasks are stuck behind slow ones. No errors anywhere.

Cause — Multiple task types sharing one queue. A batch of slow, resource-heavy tasks fills the queue and accumulates at the front. Fast tasks that arrive later sit behind them. This is the same head-of-line blocking problem from the design section — except now it is a production incident rather than a design decision.

Fix — Separating queues per task type is the only fix. Throttling or prioritisation within a single queue does not eliminate the problem — it only adjusts which tasks suffer.

Zombie Workers

Symptom — Nothing is completing at all. Queue depth grows steadily. In Flower, you see tasks in STARTED state that have been running for hours with no resolution. Workers appear healthy — process running, heartbeat arriving, no error messages — but they are not finishing anything. The difference from Queue Starvation: here, no tasks complete, not even the slow ones.

Cause — A task is stuck and not moving with error. Without a time limit, the child process waits indefinitely. It is not dead — it is just waiting forever. The main process sees a live child and reports the worker as healthy. The queue grows because the worker is occupied but not finishing.

Fix — soft_time_limit and time_limit on every task without exception. The soft limit wakes the task up with an exception so it can clean up. The hard limit guarantees the process is terminated regardless of what it is doing.

Silent Task Loss

Symptom — Users submit work, receive confirmation, but some items never complete. The work simply disappears.

Cause — task_acks_late is not set, so Celery uses its default behaviour — acknowledging tasks immediately when a worker picks them up. KEDA scales down a pod while a worker is mid-task. The task was already acknowledged, so it is no longer in the queue. When the pod is killed, the work in progress is gone permanently.

Fix — Set task_acks_late=True and task_reject_on_worker_lost=True. The task is not acknowledged until the worker confirms completion. A pod killed mid-task means the task goes back to the queue and is redelivered to another worker.

Connection Spikes on Scale-Up

Symptom — Queue depth jumps suddenly. KEDA scales aggressively. For a brief window, some workers fail to connect to Redis. Error rate spikes and then recovers without any intervention.

Cause — Many pods starting at the same time. As covered in Part 2, each pod opens multiple connections to Redis on startup. Without a stabilization policy, KEDA can add 15 pods simultaneously. The resulting connection spike pushes Redis temporarily into a degraded state.

Fix — Stabilization policy on scale-up — add at most a fixed number of pods per minute rather than jumping to the target count immediately.

Poison Pill Messages

Symptom — One worker repeatedly fails on the same task. Other tasks in that queue are delayed while retries exhaust. If max_retries is not set, the task cycles indefinitely.

Cause — A poison pill — a message that can never be successfully processed. A malformed payload, a corrupt file, an input that triggers a bug in your processing logic. The task fails every time regardless of how many retries it gets. Without handling this explicitly, the task consumes worker capacity on every retry, delays other tasks, and either cycles forever or disappears silently when retries exhaust.

Fix — Set max_retries. When retries exhaust, route the message to a dead letter queue — a separate queue where permanently failed tasks are held for manual inspection rather than discarded. This preserves the message for debugging and prevents it from blocking other work.

@app.task(bind=True, max_retries=3)
def process_item(self, item_id):
    try:
        run_processing(item_id)
    except Exception as e:
        if self.request.retries >= self.max_retries:
            # Retries exhausted — hold for manual inspection
            route_to_dead_letter(item_id, error=str(e))
            update_status(item_id, 'permanently_failed')
            return
        raise self.retry(exc=e, countdown=10 * 2**self.request.retries)

Knowing the Limits

Understanding what Celery does well is only half the picture. Knowing where it stops being the right tool is equally important — and often skipped.

Where Celery Is Not the Right Tool

Exactly-once execution — With task_acks_late=True, Celery delivers at-least-once — meaning a task may execute more than once in crash scenarios. For idempotent work, a guard check at the start of each task handles this. For non-idempotent operations — triggering a payment, filing a compliance record, sending a unique notification — executing twice causes real harm that a guard check cannot always prevent. If exactly-once delivery is a hard requirement, Temporal — a durable workflow engine — provides this guarantee at the protocol level.

Human-in-the-loop workflows — Celery’s chain primitive runs tasks sequentially until all complete or one fails. It has no concept of pausing mid-execution to wait for a human to act. If a pipeline needs to pause for approval, wait for a reviewer, or resume after an external event that arrives hours or days later, Celery cannot model this. Temporal and Prefect are workflow engines built for long-running, stateful processes that span human decision points.

High-frequency streaming — Celery has overhead per task — serialization, broker round-trip, worker pickup. For tasks that take seconds or minutes, this overhead is negligible. For very high volumes of tiny, fast events — tens to hundreds of thousands per second — the overhead accumulates and the Redis broker becomes a bottleneck. Kafka with a stream processing framework is the right architecture at that throughput.

Simple scheduled jobs — Celery includes a scheduler called Celery Beat that triggers tasks on a schedule — similar to cron. If the only thing you need is a script that runs at 2am, Celery Beat plus a Redis broker plus a worker deployment is a lot of infrastructure for a simple problem. A Kubernetes CronJob does the same thing in 10 lines of YAML. Celery Beat is the right choice when scheduled tasks are already part of a larger Celery system — not as a standalone scheduler.

Redis Streams — When You Need Fan-Out

As covered in Part 1, BRPOP on a Redis List is destructive — one worker takes the message and it is gone. This is correct when exactly one worker should process each task. It breaks when multiple independent services need to react to the same event.

For a Celery-based system, Redis Streams sit alongside Celery rather than replacing it. Celery handles task execution through Redis Lists. Streams handle the event distribution layer — the same event delivered independently to every service that needs it.

A concrete example: when a document is uploaded, your application writes one event to a Redis Stream. A consumer group for the processing service reads it and enqueues a Celery task. A separate consumer group for the audit service reads the same event independently. A third consumer group for analytics reads it independently. The producer writes once. Each service is fully decoupled.

Mermaid (AI Generated)

Mermaid (AI Generated)

The mechanics of consumer groups, the Pending Entries List, and crash recovery are covered in the Redis Streams section of Part 1.

When to Move From Redis Streams to Kafka

Redis Streams handles fan-out well for most workloads. There are four specific conditions that indicate Kafka is the right next step.

Throughput ceiling — Redis processes commands in a single thread. At very high message volumes this thread becomes the bottleneck. Kafka distributes data across multiple brokers and partitions, handling millions of messages per second natively.

Long-term retention. — Redis Streams store messages in memory, with AOF providing durability on disk. Retaining months of event history requires a large amount of memory. Kafka stores messages directly on disk and is designed for long retention at low cost.

Cross-datacenter replication — Kafka has mature replication built into its protocol that works across datacenters and geographic regions. Redis Cluster replication is designed for a single datacenter.

Ecosystem integration — Kafka Connect provides hundreds of pre-built connectors to databases, data warehouses, and object stores. If events need to flow into many downstream systems, the Kafka ecosystem saves significant integration work.

The honest signal to move is a measurable constraint — Redis CPU consistently above 60%, stream backlog growing faster than consumers drain it, or consumers in multiple geographic regions. Not because Kafka is more sophisticated. Only when Redis Streams is actually the bottleneck.

Closing

Most Celery incidents are not mysterious. They are the predictable result of a few configuration choices made without understanding what happens underneath. task_acks_late left at its default loses tasks silently when workers crash. Missing time limits create zombie workers that hold queues indefinitely. A single queue causes head-of-line blocking that makes fast tasks look slow. Prefetch hoarding makes queue depth a misleading signal. Each failure mode has a clear cause and a clear fix — but only if you understand the system well enough to recognise what you are looking at.

Once you understand brokers, acknowledgements, worker lifecycle, and scaling, Celery stops feeling like a framework with too many configuration options and starts feeling like a distributed system you can reason about. The configuration stops being guesswork. The failures stop being surprises. That is the goal — not to memorise every setting, but to understand why each one exists.


메타데이터
post_id
da94f448e9c3
slug
part-3-celery-in-production-designing-for-reliability-not-just-throughput-da94f448e9c3
url
https://medium.com/@ladvishal1985/part-3-celery-in-production-designing-for-reliability-not-just-throughput-da94f448e9c3
canonical_url
https://medium.com/@ladvishal1985/part-3-celery-in-production-designing-for-reliability-not-just-throughput-da94f448e9c3
author_url
https://medium.com/@ladvishal1985
status
ok
fetched_at
2026-07-09 20:42:47