jongkwan.dev
Development · Essay №078

Deploying and Operating Celery

Deployment patterns, worker pools, prefetch fairness, scaling, monitoring, and security settings for running Celery in production

Jongkwan Lee2026년 4월 21일8 min read
Contents

Running Celery in production means tuning broker reliability, worker availability, monitoring, and performance together.

Why deployment is the hard part

Celery is a distributed task queue: tasks are pushed onto a message queue, an intermediate store for passing work asynchronously, and workers pull them off and run them. A single-host demo starts with one command; production does not. If the broker stops, queued tasks disappear, and if workers are overloaded, responses back up.

Deployment therefore has to address four axes at once: message delivery reliability, worker availability, monitoring, and performance tuning. The default values quoted below come from the official Celery documentation (docs.celeryq.dev).

Three deployment patterns

Pick one of the following based on whether you are deploying to a single host or many. Scale and load variability decide the answer.

PatternFitsScalingNotes
Docker ComposeSingle host, small scaleManual (adjust replicas)Fastest to set up
KubernetesMultiple hosts, variable loadAutomatic via HPAProbes and rolling updates
systemdDirectly on VMsManualOS integration, no external dependencies

Docker Compose bundles the broker, result backend, workers, and scheduler into a single file. A production configuration adds health checks, restart policies, and volume persistence.

yaml
services:
  rabbitmq:
    image: rabbitmq:3.12-management-alpine
    environment:
      RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS}
      RABBITMQ_DEFAULT_VHOST: /celery
    healthcheck:
      test: rabbitmq-diagnostics -q ping
      interval: 10s
      timeout: 5s
      retries: 5
 
  worker:
    build: .
    command: celery -A myapp worker -l INFO -c 4 --max-tasks-per-child 1000 --max-memory-per-child 200000
    depends_on:
      rabbitmq:
        condition: service_healthy
    environment:
      CELERY_BROKER_URL: amqp://${RABBITMQ_USER}:${RABBITMQ_PASS}@rabbitmq//celery
      CELERY_RESULT_BACKEND: redis://:${REDIS_PASS}@redis:6379/1
    restart: unless-stopped

On Kubernetes, workers run as a Deployment with health probes and a termination grace period. The liveness probe uses inspect ping to check that the worker is alive. The readiness probe uses inspect active to check whether it is ready to accept work.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: celery-worker
spec:
  replicas: 3
  template:
    spec:
      terminationGracePeriodSeconds: 120
      containers:
      - name: worker
        image: myregistry/myapp:latest
        command: ["celery", "-A", "myapp", "worker"]
        args: ["-l", "INFO", "-c", "4", "--max-tasks-per-child", "1000"]
        env:
        - name: CELERY_BROKER_URL
          valueFrom:
            secretKeyRef:
              name: celery-secrets
              key: broker-url
        resources:
          requests: { memory: "256Mi", cpu: "500m" }
          limits: { memory: "512Mi", cpu: "1000m" }
        livenessProbe:
          exec:
            command: ["celery", "-A", "myapp", "inspect", "ping"]
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          exec:
            command: ["celery", "-A", "myapp", "inspect", "active"]
          initialDelaySeconds: 10
          periodSeconds: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

On SIGTERM a worker performs a warm shutdown: it finishes the tasks already in flight before stopping. That is why terminationGracePeriodSeconds has to exceed the runtime of your longest task. Set it too low and Kubernetes sends SIGKILL, cutting tasks off midway.

When running directly on a VM, register the worker as a systemd unit. Type=notify lets the worker signal readiness to systemd, and a restart policy brings it back after failure.

ini
# /etc/systemd/system/celery-worker.service
[Service]
Type=notify
User=celery
Group=celery
WorkingDirectory=/opt/myapp
Environment="CELERY_BROKER_URL=redis://localhost:6379/0"
ExecStart=/usr/local/bin/celery -A myapp worker \
    -l INFO -c 4 \
    --max-tasks-per-child=1000 \
    --max-memory-per-child=200000
Restart=on-failure
RestartSec=10

Worker pools and concurrency

The pool type determines how a worker runs tasks in parallel. The right choice depends on whether tasks burn CPU or wait on I/O.

PoolCPU-boundI/O-boundConcurrencyMemory
preforkBestGoodLow to mediumHigh
eventletPoor fitBestVery highLow
geventPoor fitBestVery highLow
soloSequentialSequential1Low

The default, prefork, spawns a process per task and uses every CPU core. eventlet and gevent use green threads, lightweight threads that switch cooperatively in user space. A single process can hold thousands of concurrent I/O waits. Use the following as a selection rule.

text
I/O-bound + many concurrent connections → eventlet / gevent
CPU-bound or memory leak risk           → prefork (default)
Development / testing                   → solo

Concurrency is set with -c. When load varies, --autoscale gives an upper and lower bound so the worker grows and shrinks its process count on its own.

bash
# CPU work: one prefork process per core
celery -A myapp worker -l INFO -c 4
 
# I/O work: concurrency 1000 with gevent
celery -A myapp worker -P gevent -c 1000
 
# Variable load: between 4 and 16 processes
celery -A myapp worker -l INFO --autoscale=16,4

eventlet and gevent do not support max_tasks_per_child. If you suspect a memory leak under those pools, you need separate monitoring and periodic restarts.

Prefetch and fairness

Workers pull tasks ahead of time for throughput. The number pulled at once is concurrency x worker_prefetch_multiplier, and the default multiplier is 4 (per the official Celery documentation).

Prefetching is faster when tasks are short and numerous. Long tasks are the problem. A long task at the head of the batch leaves everything pulled with it waiting behind it.

Task typeRecommended multiplierReason
Short and numerous4 (default)Prefetching raises throughput
Long and heavy1One at a time keeps things fair

Do not mix long and short tasks on the same worker. Splitting queues and dedicating workers per queue keeps congestion on one side from blocking the other.

bash
# Short task queue: high concurrency
celery -A myapp worker -Q short -l INFO -c 8
 
# Long task queue: low concurrency, prefetch 1, with timeouts
celery -A myapp worker -Q long -l INFO -c 2 \
    --time-limit=3600 --soft-time-limit=3500

The long-task worker should also set app.conf.worker_prefetch_multiplier = 1. Queue separation and prefetch 1 together are what stop a long task from trapping others.

Scaling

Scaling goes in three directions: horizontally by adding workers, vertically by raising concurrency, and by partitioning queues. Adding workers that consume the same queue increases throughput.

On Kubernetes, the Horizontal Pod Autoscaler (HPA) adjusts worker pods to match load. It moves between the minimum and maximum replica counts based on CPU and memory utilization.

yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: celery-worker-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: celery-worker
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

CPU utilization alone sometimes misses a queue that is backing up. If pending message count is what matters for your workload, exposing queue depth as a separate metric and scaling on it is more accurate.

Monitoring

Flower is the standard web monitoring tool for Celery. It shows worker status, task execution history, and live statistics, and it also supports remote control such as revoking tasks or adjusting rate limits.

bash
# Start Flower
celery -A myapp flower --port=5555

Monitoring works by consuming the events workers emit. Event sending has to be enabled before Flower can trace task flow.

python
app.conf.worker_send_task_events = True
app.conf.task_send_sent_event = True
app.conf.task_track_started = True

For long-term metrics, Prometheus scrapes Flower's /metrics endpoint. Control commands go through the inspect and control application programming interface (API). The diagram below traces the path from task publication to monitoring.

Two things stand out in the diagram. The task path (producer to broker to worker to result) and the observation path (worker to events to monitoring) are separate. Task processing therefore continues even when monitoring is down.

Reliability and security settings

If a worker acknowledges a task the moment it receives it, a crash mid-execution loses the task. task_acks_late=True defers the acknowledgement until the task finishes, preventing that loss. The trade-off is that the same task may run twice, so tasks must be idempotent, meaning repeated processing of the same input yields the same result.

python
app.conf.task_acks_late = True
app.conf.task_reject_on_worker_lost = True
app.conf.worker_prefetch_multiplier = 1

Long-running prefork workers can creep upward in memory usage. Recycling the child process after a task count or memory threshold cuts the leak off.

python
app.conf.worker_max_tasks_per_child = 1000      # recycle after 1000 tasks
app.conf.worker_max_memory_per_child = 200_000  # recycle above 200MB (value in KB)

Security starts with serialization. Pickle serialization allows arbitrary code execution, so use JSON. Workers run as a dedicated user, never as root.

python
app.conf.task_serializer = 'json'
app.conf.accept_content = ['json']
bash
# Run as a dedicated user
sudo -u celery celery -A myapp worker

If you also need to prevent message tampering, enable cryptographic signing. Messages are signed with a key and certificate, and the serialization format changes to auth. The broker connection itself is encrypted with Transport Layer Security (TLS).

python
from celery.security import setup_security
 
setup_security(
    app,
    key='/path/to/key.pem',
    cert='/path/to/cert.pem',
    store='/path/to/ca.pem',
    digest='sha256',
)
app.conf.task_serializer = 'auth'
app.conf.accept_content = ['auth']
app.conf.broker_use_ssl = True

Summary

Production Celery does not end with picking a deployment pattern. Reliability and observability have to be tuned alongside it before the system runs stably. Choose Docker Compose, Kubernetes, or systemd according to the environment. Give workers a pool and concurrency that match the shape of the work, and split long tasks into their own queue with prefetch set to 1 so they do not trap short ones.

Monitoring collects events through Flower and Prometheus. Reliability comes from acks_late plus idempotency; security comes from JSON serialization, a dedicated user, and message signing. Before deploying, it pays to walk a checklist once: broker high availability, result backend, monitoring, alerting, security audit, and a performance baseline.